机器学习笔记九之交叉验证、模型正则化

验证数据集与交叉验证

在上一篇笔记中我们又提到了训练数据集和测试数据集,拆分样本数据的这种做法目的就是通过测试数据集判断模型的好坏,如果我们发现训练出的模型产生了过拟合的现象,既在训练数据集上预测评分很好,但是在测试数据集上预测评分不好的情况,那可能就需要重新调整超参数训练模型,以此类推,最终找到一个或一组参数使得模型在测试数据集上的预测评分也很好,也就是训练出的模型泛化能力比较好。那么这种方式会产生一个问题,就是有可能会针对测试数据过拟合,因为每次都是找到参数训练模型,然后看看在测试数据集上的表现如何,这就让我们的模型又被测试数据集左右了,既可以理解为训练出的模型对特定的训练数据集和特定的测试数据集表现都不错,但是再来一种类似的样本数据,表现可能又不尽如人意了。

那么要彻底解决这个问题,就要引入验证数据集的概念,既将样本数据分为三份,训练数据集、验证数据集、测试数据集。

  • 训练数据集和之前的用途一样,是用来训练模型的。
  • 验证数据集的作用和之前的测试数据集一样,是用来验证由训练数据集训练出的模型的好坏程度的,或者说是调整超参数使用的数据集。
  • 此时的测试数据集和之前的作用就不一样了,这里的测试数据集是当训练出的模型在训练数据集和验证数据集上都表现不错的前提下,最终衡量该模型性能的数据集。测试数据集在整个训练模型的过程中是不参与的。

交叉验证(Cross Validation)

我们在验证数据集概念的基础上,再来看看交叉验证。交叉验证其实解决的是随机选取验证数据集的问题,因为如果验证数据集是固定的,那么万一验证数据集过拟合了,那就没有可用的验证数据集了,所以交叉验证提供了随机的、可持续的、客观的模型验证方式。

交叉验证的思路是将训练数据分成若干份,假设分为A、B、C三份,分别将这三份各作为一次验证数据集,其他两份作为训练数据集训练模型,然后将训练出的三个模型评分取均值,将这个均值作为衡量算法训练模型的结果来调整参数,如果平均值不够好,那么再调整参数,再训练出三个模型,以此类推。

实现交叉验证

我们使用KNN算法,用训练数据集和测试数据集方式进行超参数kp的调整查找(KNN的kp两个超参数查阅第二篇学习笔记):

# KNN算法中使用训练数据集和测试数据集进行超参数k和p的调整
from sklearn.neighbors import KNeighborsClassifier

# 初始化最佳评分,最佳k值和最佳p值
best_score, best_k, best_p = 0, 0, 0
# k值从2到10之间搜寻
for k in range(2, 11):
# p值从1到5之间搜寻
for p in range(1, 6):
# 对每个k值,p值的组合实例化KNN分类器,通过训练数据训练出模型,然后通过测试数据计算评分,每次将最好的评分和对应的k,p值记录下来,最终找到评分最好的k和p值
knn_clf = KNeighborsClassifier(weights='distance', n_neighbors=k, p=p)
knn_clf.fit(X_train, y_train)
score = knn_clf.score(X_test, y_test)
if score > best_score:
best_score, best_k, best_p = score, k, p

print("Best Score =", best_score)
print("Best k = ", best_k)
print("Best p =", best_p)

# 结果
Best Score = 0.986091794159
Best k = 3
Best p = 4

从结果看,通过上面的算法,我们找到了最好评分98.6%和对应的k值3和p值4。但是需要注意的是,这个结果有可能是对测试数据集过拟合的结果。

下面我们再来看看如何使用交叉验证方法进行超参数的调参:

# 导入Scikit Learn中交叉验证的函数
from sklearn.model_selection import cross_val_score

knn_clf = KNeighborsClassifier()
cross_val_score(knn_clf, X_train, y_train)
# 结果
array([ 0.98895028, 0.97777778, 0.96629213])

我们直接使用Scikit Learn中提供的交叉验证函数,默认会将训练数据分成三份,所以会有三个模型的评分。然后再修改一下上面调参的算法:

# KNN算法中使用训练数据集和验证数据集进行超参数k和p的调整
from sklearn.neighbors import KNeighborsClassifier

best_score, best_k, best_p = 0, 0, 0
for k in range(2, 11):
for p in range(1, 6):
knn_clf = KNeighborsClassifier(weights='distance', n_neighbors=k, p=p)
# 对每个k值,p值的组合实例化KNN分类器,通过交叉验证发训练出若干个模型,然后对这若干个模型的评分求平均值,该平均值即为每次的模型评分,每次将最好的评分和对应的k,p值记录下来,最终找到评分最好的k和p值
scores = cross_val_score(knn_clf, X_train, y_train)
# 对若干个模型的评分求平均值
score = np.mean(scores)
if score > best_score:
best_score, best_k, best_p = score, k, p

print("Best Score =", best_score)
print("Best k = ", best_k)
print("Best p =", best_p)

# 结果
Best Score = 0.982359987401
Best k = 2
Best p = 2

可以看到,使用交叉验证法最后搜寻到的最佳k值是2,最佳p值是2,然后我们再用搜寻出的这两个超参数来训练模型,然后使用测试数据集来计算评分:

knn_clf = KNeighborsClassifier(weights='distance', n_neighbors=2, p=2)
knn_clf.fit(X_train, y_train)
knn_clf.score(X_test, y_test)
# 结果
0.98052851182197498

最终我们搜寻到的最佳超参数训练出的模型,通过测试数据验证后评分为98.05%,这个评分虽然比之前用训练数据和测试数据搜寻到的最佳评分低一些,但是这个分数不会对验证数据集过拟合,是泛化能力更好的模型。

第三篇笔记中,讲过KNN通过网格搜索搜寻最佳超参数的方法,其实当时GridSearchCV中的CV就是Cross Validation的意思,也就是网格搜索本身就使用的交叉验证的方式搜寻超参数,我们再来回顾一下:

from sklearn.model_selection import GridSearchCV
# 定义出超参数的组合,就相当于之前我们算法中的两层循环
param_grid = [
{
"weights": ['distance'],
"n_neighbors": [i for i in range(2, 11)],
"p": [i for i in range(1, 6)]
}
]

grid_search = GridSearchCV(knn_clf, param_grid, verbose=1)
grid_search.fit(X_train, y_train)

# 结果
Fitting 3 folds for each of 45 candidates, totalling 135 fits

可以看到执行fit函数后,会打印出一句话来,意思就是超参数组合一共有45个,每个组合会将训练数据分为三份,一共会训练出135个模型,最后求出一个泛化能力最好的模型。

grid_search.best_score_
# 结果
0.98237476808905377

grid_search.best_params_
# 结果
{'n_neighbors': 2, 'p': 2, 'weights': 'distance'}

best_knn_clf = grid_search.best_estimator_
best_knn_clf.score(X_test, y_test)
# 结果
0.98052851182197498

通过上面的结果可以看到,和我们之前的结果是一致的。

偏差(Bias)与方差(Variance)

在机器学习算法中,模型的好坏有一个统称就是预测结果的误差大小。那么这个误差具体可分为偏差和方差。


上面这幅图有四个靶子,可以很好的诠释方差和偏差的概念。红色靶心就相当于我们目标,灰色弹孔就相当于模型预测的值。我们来解读一下这四幅图:

  • 左上:模型预测的值基本都在目标值上,并且每次预测的都很集中,说明偏差和方差都很小。
  • 左下:模型预测的值虽然每次都很集中,但是整体和目标值差的很远,说明偏差很大,方差比较小。
  • 右上:模型预测的值基本都围绕着目标值,但是每次预测的值之间差距较大,说明偏差较小,方差比较大。
  • 右下:模型预测的值离目标值都很远,并且每次预测的值之间差距也比较打,说明偏差和方差都很大。

通常情况下,我们训练出的模型误差指的是偏差和方差的总和,再加上一些不可避免的误差,比如训练数据本身噪音比较大等。

通常导致偏差的主要原因是对问题本身的假设不正确,比如本身训练数据并没有线性关系,但我们还是使用线性回归去训练模型,那么模型的偏差肯定会很大,也就是欠拟合的情况。

通常导致方差的主要原因是因为我们的模型太过复杂,学习到太多的噪音,比如多项式回归,当degree参数非常大的时候,也就是过拟合的情况。

非参数学习通常都是高方差算法,比如分类的算法,因为不会对数据进行任何假设。参数学习通常都是高偏差算法,因为会对数据有极强的假设,一旦训练数据有问题,那么就会导致模型整体偏离真实情况。

在使用机器学习解决问题的实践中,通常我们的挑战都是降低模型的方差,一般有以下几种手段:

  • 降低模型复杂度。比如降低多项式回归的degree参数。
  • 减少数据维度,降噪。比如使用PCA。
  • 增加样本数量。让训练数据足以支撑复杂的模型,从而能计算出合适的参数。
  • 使用交叉验证。避免过拟合情况。

模型正则化(Regularization)

在说模型正则化之前,我们先来看一个例子:

import numpy as np
import matplotlib.pyplot as plt

# 构建和之前一样的样本数据
x = np.random.uniform(-3, 3, size=100)
X = x.reshape(-1, 1)
y = 0.5 * x ** 2 + x + 2 + np.random.normal(0, 1, size=100)

# 将这些的折线图绘制出来
plt.plot(np.sort(x), y[np.argsort(x)], color='r')
plt.show()

可以看到当计算y的方程,多项式前的系数比较低(0.5和1)的时候折线图在横轴从-3到3,纵轴从-1到10的坐标系里还能全部展现,如果将这两个系数扩大10倍,会出现什么情况呢:

y1 = 5 * x ** 2 + 10 * x + 2 + np.random.normal(0, 1, size=100)
plt.plot(np.sort(x), y1[np.argsort(x)], color='r')
plt.axis([-3, 3, -1, 10])
plt.show()

可以看到这个折线图波动已经非常大了,在同样的坐标系中只能展现出一部分了。我们再来回顾一下前面的那张过拟合的图:

上图中,两侧的曲线图波动非常大,其实就说明了这条曲线的线性多项式方程的系数非常大,那么模型正则化做的事情就是限制这些系数的大小。

下面来看看模型正则化的基本思路,在第四篇笔记中将多元线性回归问题的时候,我们知道最终求的是下面这个函数的最优解,既让下面这个损失函数的值尽可能的小:

$$ \sum_{i=1}^m(y^{(i)}- (\theta_0+\theta_1X_1^{(i)}+\theta_2X_2^{(i)}+…+\theta_nX_n^{(i)} ))^2$$

下面我们来转变一下这个损失函数:

$$ L(\theta) = \sum_{i=1}^m(y^{(i)}- (\theta_0+\theta_1X_1^{(i)}+\theta_2X_2^{(i)}+…+\theta_nX_n^{(i)} ))^2 + \alpha \frac 1 2 \sum_{i=1}^n \theta_i^2$$

我们在损失函数里加了一部分$\alpha \frac 1 2 \sum_{i=1}^n \theta_i^2$,此时要想让损失函数尽可能的小,就不能只考虑前面那一部分了,还要考虑后面新加的这一部分,又因为后面这部分包含多项式系数的平方,所以就整体约束了多项式系数的大小。这就是模型正则化的基本思路。这里的$\alpha$就是一个新的超参数,它的含义代表在模型正则化下,新的损失函数中每一个$\theta$都尽可能的小,这个小的程度占整个优化损失函数的多少。比如如果$\alpha$是0,那么相当于损失函数没有加入模型正则化,如果$\alpha$非常非常大,那么真正的损失函数就可以忽略了,主要考虑使模型正则化中的$\theta$尽可能小。所以$\alpha$的作用就是让真正的损失函数尽可能小和新加入的模型正则化中的$\theta$尽可能小之间找到一个平衡,在实际的运用中,不同的数据,$\alpha$的取值也不同,是需要有不断调整$\alpha$这个超参数的过程。

岭回归(Ridge Regression)

在模型正则化中,加入$\alpha \frac 1 2 \sum_{i=1}^n \theta_i^2$的方式,称之为岭回归。下面通过不使用模型正则化和使用岭回归对比来看看:

import numpy as np
import matplotlib.pyplot as plt

# 构建样本数据
np.random.seed(666)
x = np.random.uniform(-3, 3, size=100)
X = x.reshape(-1, 1)
y = 0.5 * x + 3 + np.random.normal(0, 1, size=100)

plt.scatter(x, y)
plt.show()

# 导入Pipeline和其他需要打包进Pipeline的类
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.preprocessing import PolynomialFeatures
from sklearn.linear_model import LinearRegression

# 将样本数据集拆分为训练数据集和测试数据集
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.4, random_state=666)

# 构建多项式线性回归Pipeline
def PloynomialRegression(degree):
return Pipeline([
("poly", PolynomialFeatures(degree=degree)),
("std_scalar", StandardScaler()),
("lr", LinearRegression())
])

# 导入均方误差MSE函数
from sklearn.metrics import mean_squared_error

ploy_reg = PloynomialRegression(degree=20)
ploy_reg.fit(X_train, y_train)
y_poly_predict = ploy_reg.predict(X_test)
mean_squared_error(y_test, y_poly_predict)
# 结果
1.9558727426614517

X_ploy = np.linspace(-3, 3, 100).reshape(100, 1)
y_ploy = ploy_reg.predict(X_ploy)

plt.scatter(x, y)
plt.plot(X_ploy[:, 0], y_ploy, color='r')
plt.axis([-3, 3, 0, 6])
plt.show()

可以看到不试用模型正则化时,MSE为1.95,虽然数值不大,但是拟合曲线的波动非常大,是一个过拟合的特征。下面来看看使用模型正则化岭回归后结果会怎样:

# 导入Scikit Learn中岭回归的类Ridge
from sklearn.linear_model import Ridge

# 构建通过岭回归进行模型正则化的Pipeline,有两个超参数degree和alpha
def RidgeRegression(degree, alpha):
return Pipeline([
("poly", PolynomialFeatures(degree=degree)),
("std_scalar", StandardScaler()),
("rr", Ridge(alpha=alpha))
])

ridge_reg = RidgeRegression(20, 0.0001)
ridge_reg.fit(X_train, y_train)

y_ridge_predict = ridge_reg.predict(X_test)
mean_squared_error(y_test, y_ridge_predict)
# 结果
1.0033258623784587

X_ridge = np.linspace(-3, 3, 100).reshape(100, 1)
y_ridge = ridge_reg.predict(X_ridge)

plt.scatter(x, y)
plt.plot(X_ridge[:, 0], y_ridge, color='r')
plt.axis([-3, 3, 0, 6])
plt.show()

使用岭回归后MSE降低到1,并且拟合曲线波动平缓了许多,尤其是曲线两头。这就是模型正则化的作用。

LASSO 回归(Least Absolute Shrinkage and Selection Operator Regression)

其实岭回归和LASSO都是模型正则化的一种具体实现,区别就在于增加的模型正则公式不同。岭回归增加的是$\alpha \frac 1 2 \sum_{i=1}^n \theta_i^2$,而LASSO回归中增加的是$\alpha \sum_{i=1}^n |\theta_i|$。

下面来看看相同的数据使用LASSO回归后的结果:

import numpy as np
import matplotlib.pyplot as plt
# 构建样本数据
np.random.seed(666)
x = np.random.uniform(-3, 3, size=100)
X = x.reshape(-1, 1)
y = 0.5 * x + 3 + np.random.normal(0, 1, size=100)

from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.4, random_state=666)

# 导入Pipeline和其他需要打包进Pipeline的类
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.preprocessing import PolynomialFeatures
from sklearn.linear_model import Lasso
from sklearn.metrics import mean_squared_error

def LassoRegression(degree, alpha):
return Pipeline([
("poly", PolynomialFeatures(degree=degree)),
("std_scalar", StandardScaler()),
("lasso", Lasso(alpha=alpha))
])

lasso_reg = LassoRegression(20, 0.01)
lasso_reg.fit(X_train, y_train)

y_lasso_predict = lasso_reg.predict(X_test)
mean_squared_error(y_test, y_lasso_predict)
# 结果
0.90488682905372464

X_lasso = np.linspace(-3, 3, 100).reshape(100, 1)
y_lasso = lasso_reg.predict(X_lasso)

plt.scatter(x, y)
plt.plot(X_lasso[:, 0], y_lasso, color='r')
plt.axis([-3, 3, 0, 6])
plt.show()

可以看到相同的数据,通过LASSO处理后的模型MSE更小一些,拟合曲线也更平滑。

岭回归和LASSO回归的数学含义上的区别

下面来解释一下岭回归和LASSO回归之间深层意义上的区别。先来看看岭回归,在前面讲过,当$\alpha$趋近无穷大时,真正的损失函数就可以忽略了,模型正则化后的损失函数就变成了求解模型正则公式的最小值,既求$\alpha \frac 1 2 \sum_{i=1}^n \theta_i^2$的最小值,那么该公式的梯度就是对$\theta$求导可得:

$$ \nabla = \begin{bmatrix}
\theta_1 \\
\theta_2 \\
\theta_3 \\
… \\
\theta_n
\end{bmatrix} $$

数据点按照梯度一步一步寻求最优解,如下图:


所以经过岭回归处理后训练出的模型,拟合线基本都是曲线。

再来看看LASSO回归,当$\alpha$趋近无穷大时,我们只考虑$\alpha \sum_{i=1}^n |\theta_i|$,但是$|\theta_i|$是不可导的,我们使用数学符号函数来表示其梯度:

符号函数($sign(x)$)是很有用的一类函数,能够帮助我们实现一些直接实现有困难的情况。在数学和计算机运算中,其功能是取某个数的符号(正或负),既当$x > 0$时,$sign(x) = 1$,当$x = 0$时,$sign(x) = 0$,当$x < 0$时,$sign(x) = -1$。

$$ \nabla = \alpha \begin{bmatrix}
sign(\theta_1) \\
sign(\theta_2) \\
sign(\theta_3) \\
… \\
sign(\theta_n)
\end{bmatrix} $$

对于$\alpha \sum_{i=1}^n |\theta_i|$而言,当$x > 0$时,就相当于$y = x$这条直线,当$x < 0$时,就相当于$y = -x$这条直线。当数据点按照LASSO的梯度寻找最优解的路线是下图情况:

可以看到真正的寻址路线是橘黄色的虚线,应该中间有很多点直接打到了$y$轴,所以绘制出的拟合蓝色线很多时候就是一条直线,而不是曲线。那些$sign(x)$为0的梯度,既损失函数中的一部分$\theta$为0,这也就说明了LASSO中最后的SO(Selection Operator)选择操作符的含义,既将一些噪音比较大的特征过滤掉,选择出主要的、有用的特征。但是这是有风险的,因为很有可能LASSO将实际有用的特征给过滤掉了,所以就模型正则化的准确率来说,岭回归还是更好一些。但是在处理有巨大量特征的样本数据时,使用LASSO可以作为降低特征数量的一种方法。

LP范数

在数学定义上,范数包括向量范数和矩阵范数,向量范数表示向量空间中向量的大小,矩阵范数表征矩阵引起向量变化的大小。比如对于向量范数,向量空间中的向量都是有大小的,这个大小如何度量,就是用范数来度量的,不同的范数都可以来度量这个大小,就好比米和尺都可以来度量远近一样。对于矩阵范数,我们知道,通过运算$Ax=B$,可以将向量$x$变化为矩阵$B$,矩阵范数就是来度量这个变化大小的。

下面我们再来看一张图:

上图展示了模型正则化岭回归、LASSO回归,线性回归评测标准均方误差(MSE)、平均绝对误差(MAE),距离公式欧拉距离、曼哈顿距离之间的对比。我们可以发现一个很有意思的现象,第一行的三个公式都是平方求和的模式,第二行的三个公式都是绝对值求和的模式。其实虽然机器学习中的公式、名词很多,但是究其背后的数学原理都是有规律可循的。

我们在讲KNN时知道它除了$k$这个超参数外还有一个超参数$p$,继而介绍了明可夫斯基距离:

$$ (\sum_{i=1}^n |X_i^{(a)}-X_i^{(b)}|^p)^\frac 1 p $$

我们对明可夫斯基距离公式再进行一下泛化,将其提炼成这种形式:

$$||X||_p=(\sum_{i=1}^n|X_i|^p)^{\frac 1 p}$$

在数学上,我们将上面这个公式称为$L_p$范数,当$p$为1时,$L_1$范数就是曼哈顿距离,$p$为2时,$L_2$范数就是欧拉距离。那么在模型正则项中,是$L_2$范数就是岭回归,或者叫$L_2$正则项,$L_1$范数就是LASSO回归,或者叫$L_1$正则化。

这里要注意的时,如果是$L_2$范数,岭回归的模型正则化公式应该还需要开根号,但是为了计算方便,一般使用时不加这个开根号,但是加不加根号对于模型正则化的效果来说是一样的。

弹性网(Elastic Net)

弹性网很简单,就是将$L_1$正则项和$L_2$正则项都加入模型正则化中,既结合了岭回归和LASSO回归:

$$ L(\theta) = \sum_{i=1}^m(y^{(i)}- (\theta_0+\theta_1X_1^{(i)}+\theta_2X_2^{(i)}+…+\theta_nX_n^{(i)} ))^2 + (1-r)\alpha \frac 1 2 \sum_{i=1}^n \theta_i^2 + r\alpha \sum_{i=1}^n |\theta_i|$$

从公式中看到我们又引入了一个超参数$r$,这个超参数表示岭回归和LASSO回归在整个模型正则化中各占的比例。

申明:本文为慕课网liuyubobobo老师《Python3入门机器学习 经典算法与应用》课程的学习笔记,未经允许不得转载。

分享到: