原文:Hands-On Machine Learning with Scikit-Learn, Keras, and TensorFlow
译者:飞龙
第三章:分类
在第一章中,我提到最常见的监督学习任务是回归(预测值)和分类(预测类)。在第二章中,我们探讨了一个回归任务,使用各种算法(如线性回归、决策树和随机森林)来预测房屋价值(这将在后面的章节中进一步详细解释)。现在我们将把注意力转向分类系统。
MNIST
在本章中,我们将使用 MNIST 数据集,这是由美国人口普查局的高中学生和员工手写的 70,000 张小数字图像集。每个图像都带有它代表的数字标签。这个数据集已经被研究了很多次,通常被称为机器学习的“hello world”:每当人们提出一个新的分类算法时,他们都很好奇它在 MNIST 上的表现如何,任何学习机器学习的人迟早都会处理这个数据集。
Scikit-Learn 提供许多辅助函数来下载流行的数据集。MNIST 就是其中之一。以下代码从 OpenML.org 获取 MNIST 数据集:¹
from sklearn.datasets import fetch_openml
mnist = fetch_openml('mnist_784', as_frame=False)
sklearn.datasets
包主要包含三种类型的函数:fetch_*
函数,如fetch_openml()
用于下载真实数据集,load_*
函数用于加载与 Scikit-Learn 捆绑的小型玩具数据集(因此不需要通过互联网下载),以及make_*
函数用于生成虚假数据集,对测试很有用。生成的数据集通常作为包含输入数据和目标的(X, y)
元组返回,都作为 NumPy 数组。其他数据集作为sklearn.utils.Bunch
对象返回,这些对象是字典,其条目也可以作为属性访问。它们通常包含以下条目:
"DESCR"
数据集描述
“数据”
输入数据,通常作为 2D NumPy 数组
“目标”
标签,通常作为 1D NumPy 数组
fetch_openml()
函数有点不同,因为默认情况下它将输入返回为 Pandas DataFrame,将标签返回为 Pandas Series(除非数据集是稀疏的)。但是 MNIST 数据集包含图像,而 DataFrame 并不理想,因此最好设置as_frame=False
以将数据作为 NumPy 数组获取。让我们看看这些数组:
>>> X, y = mnist.data, mnist.target
>>> X
array([[0., 0., 0., ..., 0., 0., 0.],
[0., 0., 0., ..., 0., 0., 0.],
[0., 0., 0., ..., 0., 0., 0.],
...,
[0., 0., 0., ..., 0., 0., 0.],
[0., 0., 0., ..., 0., 0., 0.],
[0., 0., 0., ..., 0., 0., 0.]])
>>> X.shape
(70000, 784)
>>> y
array(['5', '0', '4', ..., '4', '5', '6'], dtype=object)
>>> y.shape
(70000,)
共有 70,000 张图像,每张图像有 784 个特征。这是因为每个图像是 28×28 像素,每个特征只是表示一个像素的强度,从 0(白色)到 255(黑色)。让我们看一下数据集中的一个数字(图 3-1)。我们只需要获取一个实例的特征向量,将其重塑为 28×28 数组,并使用 Matplotlib 的imshow()
函数显示它。我们使用cmap="binary"
来获取一个灰度色图,其中 0 是白色,255 是黑色:
import matplotlib.pyplot as plt
def plot_digit(image_data):
image = image_data.reshape(28, 28)
plt.imshow(image, cmap="binary")
plt.axis("off")
some_digit = X[0]
plot_digit(some_digit)
plt.show()
图 3-1。MNIST 图像示例
这看起来像一个 5,事实上标签告诉我们是这样的:
>>> y[0]
'5'
为了让您感受分类任务的复杂性,图 3-2 显示了 MNIST 数据集中的更多图像。
但是!在仔细检查数据之前,您应该始终创建一个测试集并将其放在一边。fetch_openml()
返回的 MNIST 数据集实际上已经分为训练集(前 60,000 张图像)和测试集(最后 10,000 张图像):²
X_train, X_test, y_train, y_test = X[:60000], X[60000:], y[:60000], y[60000:]
训练集已经为我们洗牌,这很好,因为这保证了所有交叉验证折叠将是相似的(我们不希望一个折叠缺少一些数字)。此外,一些学习算法对训练实例的顺序敏感,如果它们连续获得许多相似实例,则表现会很差。洗牌数据集确保这种情况不会发生。³
图 3-2。来自 MNIST 数据集的数字
训练二元分类器
现在,让我们简化问题,只尝试识别一个数字,例如数字 5。这个“5 检测器”将是一个二元分类器的示例,能够区分只有两个类别的 5 和非 5。首先,我们将为这个分类任务创建目标向量:
y_train_5 = (y_train == '5') # True for all 5s, False for all other digits
y_test_5 = (y_test == '5')
现在让我们选择一个分类器并对其进行训练。一个很好的开始地方是使用随机梯度下降(SGD,或随机 GD)分类器,使用 Scikit-Learn 的SGDClassifier
类。这个分类器能够高效处理非常大的数据集。部分原因是因为 SGD 独立处理训练实例,一次一个,这也使得 SGD 非常适合在线学习,稍后您将看到。让我们创建一个SGDClassifier
并在整个训练集上对其进行训练:
from sklearn.linear_model import SGDClassifier
sgd_clf = SGDClassifier(random_state=42)
sgd_clf.fit(X_train, y_train_5)
现在我们可以使用它来检测数字 5 的图像:
>>> sgd_clf.predict([some_digit])
array([ True])
分类器猜测这幅图像代表数字 5(True
)。在这种特殊情况下,看起来它猜对了!现在,让我们评估这个模型的性能。
性能指标
评估分类器通常比评估回归器要困难得多,因此我们将在本章的大部分时间中讨论这个主题。有许多性能指标可用,所以抓杯咖啡,准备学习一堆新概念和首字母缩略词!
使用交叉验证测量准确率
评估模型的一个好方法是使用交叉验证,就像您在第二章中所做的那样。让我们使用cross_val_score()
函数来评估我们的SGDClassifier
模型,使用三折交叉验证。请记住,k-fold 交叉验证意味着将训练集分成k折(在本例中为三折),然后训练模型k次,每次保留一个不同的折叠用于评估(参见第二章):
>>> from sklearn.model_selection import cross_val_score
>>> cross_val_score(sgd_clf, X_train, y_train_5, cv=3, scoring="accuracy")
array([0.95035, 0.96035, 0.9604 ])
哇!在所有交叉验证折叠中超过 95%的准确率(正确预测的比例)?看起来很惊人,不是吗?好吧,在你太兴奋之前,让我们看一个只将每个图像分类为最频繁类别的虚拟分类器,这种情况下是负类别(即非 5):
from sklearn.dummy import DummyClassifier
dummy_clf = DummyClassifier()
dummy_clf.fit(X_train, y_train_5)
print(any(dummy_clf.predict(X_train))) # prints False: no 5s detected
您能猜到这个模型的准确率吗?让我们找出来:
>>> cross_val_score(dummy_clf, X_train, y_train_5, cv=3, scoring="accuracy")
array([0.90965, 0.90965, 0.90965])
没错,它的准确率超过 90%!这仅仅是因为大约 10%的图像是 5,所以如果您总是猜测一幅图像不是 5,您将有 90%的准确率。胜过诺斯特拉达姆。
这说明为什么准确率通常不是分类器的首选性能指标,特别是当您处理倾斜数据集(即某些类别比其他类别更频繁时)。评估分类器性能的一个更好方法是查看混淆矩阵(CM)。
混淆矩阵
混淆矩阵的一般思想是计算类 A 的实例被分类为类 B 的次数,对于所有 A/B 对。例如,要知道分类器将 8 的图像误判为 0 的次数,您将查看混淆矩阵的第 8 行,第 0 列。
计算混淆矩阵,首先需要一组预测结果,以便与实际目标进行比较。您可以对测试集进行预测,但最好现在保持不变(记住,您只想在项目的最后阶段使用测试集,一旦您准备启动分类器)。相反,您可以使用cross_val_predict()
函数:
from sklearn.model_selection import cross_val_predict
y_train_pred = cross_val_predict(sgd_clf, X_train, y_train_5, cv=3)
就像cross_val_score()
函数一样,cross_val_predict()
执行k-fold 交叉验证,但不返回评估分数,而是返回在每个测试折叠上做出的预测。这意味着您可以获得训练集中每个实例的干净预测(“干净”是指“样本外”:模型对训练期间从未见过的数据进行预测)。
现在您已经准备好使用confusion_matrix()
函数获取混淆矩阵。只需将目标类(y_train_5
)和预测类(y_train_pred
)传递给它:
>>> from sklearn.metrics import confusion_matrix
>>> cm = confusion_matrix(y_train_5, y_train_pred)
>>> cm
array([[53892, 687],
[ 1891, 3530]])
混淆矩阵中的每一行代表一个实际类别,而每一列代表一个预测类别。该矩阵的第一行考虑了非 5 的图像(负类):其中 53,892 个被正确分类为非 5(称为真负例),而剩下的 687 个被错误分类为 5(假正例,也称为类型 I 错误)。第二行考虑了 5 的图像(正类):其中 1,891 个被错误分类为非 5(假阴性,也称为类型 II 错误),而剩下的 3,530 个被正确分类为 5(真正例)。一个完美的分类器只会有真正例和真负例,因此其混淆矩阵只会在其主对角线上有非零值(从左上到右下):
>>> y_train_perfect_predictions = y_train_5 # pretend we reached perfection
>>> confusion_matrix(y_train_5, y_train_perfect_predictions)
array([[54579, 0],
[ 0, 5421]])
混淆矩阵提供了很多信息,但有时您可能更喜欢一个更简洁的指标。一个有趣的指标是正预测的准确率;这被称为分类器的精度(方程 3-1)。
方程 3-1。精度
TP是真正例的数量,FP是假正例的数量。
实现完美精度的一个微不足道的方法是创建一个分类器,总是做出负预测,除了在它最有信心的实例上做出一个单一的正预测。如果这一个预测是正确的,那么分类器的精度就是 100%(精度=1/1=100%)。显然,这样的分类器不会很有用,因为它会忽略除了一个正实例之外的所有实例。因此,精度通常与另一个指标一起使用,该指标称为召回率,也称为敏感度或真正例率(TPR):这是分类器正确检测到的正实例的比率(方程 3-2)。
方程 3-2。回想一下
FN,当然,是假阴性的数量。
如果您对混淆矩阵感到困惑,图 3-3 可能会有所帮助。
图 3-3。一个说明混淆矩阵的示例,显示真负例(左上)、假正例(右上)、假阴性(左下)和真正例(右下)
精度和召回率
Scikit-Learn 提供了几个函数来计算分类器的指标,包括精度和召回率:
>>> from sklearn.metrics import precision_score, recall_score
>>> precision_score(y_train_5, y_train_pred) # == 3530 / (687 + 3530)
0.8370879772350012
>>> recall_score(y_train_5, y_train_pred) # == 3530 / (1891 + 3530)
0.6511713705958311
现在我们的 5 检测器看起来并不像我们在查看其准确性时那么出色。当它声称一幅图像代表 5 时,它只有 83.7%的时间是正确的。此外,它只能检测到 65.1%的 5。
通常方便将精度和召回率结合成一个称为F[1]分数的单一指标,特别是当您需要一个单一指标来比较两个分类器时。 F[1]分数是精度和召回率的调和平均(方程 3-3)。而普通平均值对所有值都一视同仁,调和平均值更加重视低值。因此,只有当召回率和精度都很高时,分类器才会获得高的 F[1]分数。
方程 3-3。F[1]分数
要计算 F[1]分数,只需调用f1_score()
函数:
>>> from sklearn.metrics import f1_score
>>> f1_score(y_train_5, y_train_pred)
0.7325171197343846
F[1]分数偏向于具有类似精度和召回率的分类器。这并不总是你想要的:在某些情境下,你更关心精度,而在其他情境下,你真的很在意召回率。例如,如果你训练一个用于检测适合儿童观看的视频的分类器,你可能更喜欢一个拒绝许多好视频(低召回率)但仅保留安全视频(高精度)的分类器,而不是一个召回率更高但让一些非常糟糕的视频出现在你的产品中的分类器(在这种情况下,你甚至可能想添加一个人工流水线来检查分类器的视频选择)。另一方面,假设你训练一个用于在监控图像中检测扒手的分类器:如果你的分类器只有 30%的精度,只要召回率达到 99%就可以了(当然,保安人员会收到一些错误警报,但几乎所有扒手都会被抓住)。
不幸的是,你不能两全其美:提高精度会降低召回率,反之亦然。这被称为精度/召回率权衡。
精度/召回率权衡
为了理解这种权衡,让我们看看SGDClassifier
是如何做出分类决策的。对于每个实例,它根据决策函数计算得分。如果该得分大于阈值,则将实例分配给正类;否则将其分配给负类。图 3-4 显示了一些数字,从最低得分的左侧到最高得分的右侧。假设决策阈值位于中间箭头处(两个 5 之间):你会发现在该阈值右侧有 4 个真正例(实际为 5),以及 1 个假正例(实际上是 6)。因此,使用该阈值,精度为 80%(5 个中的 4 个)。但在 6 个实际为 5 的情况下,分类器只检测到 4 个,因此召回率为 67%(6 个中的 4 个)。如果提高阈值(将其移动到右侧的箭头处),假正例(6)变为真负例,从而增加精度(在这种情况下最高可达 100%),但一个真正例变为假负例,将召回率降低到 50%。相反,降低阈值会增加召回率并降低精度。
图 3-4。精度/召回率权衡:图像按其分类器得分排名,高于所选决策阈值的图像被视为正例;阈值越高,召回率越低,但(一般而言)精度越高
Scikit-Learn 不允许直接设置阈值,但它确实让您访问它用于做出预测的决策得分。您可以调用分类器的decision_function()
方法,而不是调用predict()
方法,该方法返回每个实例的得分,然后根据这些得分使用任何阈值进行预测:
>>> y_scores = sgd_clf.decision_function([some_digit])
>>> y_scores
array([2164.22030239])
>>> threshold = 0
>>> y_some_digit_pred = (y_scores > threshold)
array([ True])
SGDClassifier
使用阈值等于 0,因此前面的代码返回与predict()
方法相同的结果(即True
)。让我们提高阈值:
>>> threshold = 3000
>>> y_some_digit_pred = (y_scores > threshold)
>>> y_some_digit_pred
array([False])
这证实了提高阈值会降低召回率。图像实际上代表一个 5,当阈值为 0 时分类器检测到它,但当阈值增加到 3,000 时却错过了它。
如何决定使用哪个阈值?首先,使用cross_val_predict()
函数获取训练集中所有实例的分数,但是这次指定要返回决策分数而不是预测:
y_scores = cross_val_predict(sgd_clf, X_train, y_train_5, cv=3,
method="decision_function")
使用这些分数,使用precision_recall_curve()
函数计算所有可能阈值的精度和召回率(该函数添加最后一个精度为 0 和最后一个召回率为 1,对应于无限阈值):
from sklearn.metrics import precision_recall_curve
precisions, recalls, thresholds = precision_recall_curve(y_train_5, y_scores)
最后,使用 Matplotlib 绘制精度和召回率作为阈值值的函数(图 3-5)。让我们展示我们选择的 3,000 的阈值:
plt.plot(thresholds, precisions[:-1], "b--", label="Precision", linewidth=2)
plt.plot(thresholds, recalls[:-1], "g-", label="Recall", linewidth=2)
plt.vlines(threshold, 0, 1.0, "k", "dotted", label="threshold")
[...] # beautify the figure: add grid, legend, axis, labels, and circles
plt.show()
图 3-5. 精度和召回率与决策阈值
注意
你可能会想知道为什么图 3-5 中的精度曲线比召回率曲线更加崎岖。原因是当你提高阈值时,精度有时会下降(尽管通常会上升)。要理解原因,请回顾图 3-4,注意当你从中心阈值开始,将其向右移动一个数字时会发生什么:精度从 4/5(80%)下降到 3/4(75%)。另一方面,当增加阈值时,召回率只能下降,这解释了为什么其曲线看起来平滑。
在这个阈值下,精度接近 90%,召回率约为 50%。选择一个良好的精度/召回率折衷的另一种方法是直接绘制精度与召回率的图表,如图 3-6 所示(显示相同的阈值):
plt.plot(recalls, precisions, linewidth=2, label="Precision/Recall curve")
[...] # beautify the figure: add labels, grid, legend, arrow, and text
plt.show()
图 3-6. 精度与召回率
你可以看到精度在约 80%的召回率处开始急剧下降。你可能希望在该下降之前选择一个精度/召回率折衷,例如在约 60%的召回率处。但是,选择取决于你的项目。
假设你决定追求 90%的精度。你可以使用第一个图表找到需要使用的阈值,但这不太精确。或者,你可以搜索给出至少 90%精度的最低阈值。为此,你可以使用 NumPy 数组的argmax()
方法。这将返回最大值的第一个索引,这在这种情况下意味着第一个True
值:
>>> idx_for_90_precision = (precisions >= 0.90).argmax()
>>> threshold_for_90_precision = thresholds[idx_for_90_precision]
>>> threshold_for_90_precision
3370.0194991439557
要进行预测(目前只是在训练集上),而不是调用分类器的predict()
方法,你可以运行这段代码:
y_train_pred_90 = (y_scores >= threshold_for_90_precision)
让我们检查这些预测的精度和召回率:
>>> precision_score(y_train_5, y_train_pred_90)
0.9000345901072293
>>> recall_at_90_precision = recall_score(y_train_5, y_train_pred_90)
>>> recall_at_90_precision
0.4799852425751706
太棒了,你有一个 90%的精度分类器!正如你所看到的,几乎可以轻松地创建一个任意精度的分类器:只需设置足够高的阈值,就可以了。但是等等,不要那么快——如果召回率太低,高精度的分类器就不太有用!对于许多应用程序来说,48%的召回率根本不好。
提示
如果有人说:“让我们达到 99%的精度”,你应该问:“召回率是多少?”
ROC 曲线
接收者操作特征(ROC)曲线是与二元分类器一起使用的另一个常见工具。它与精度/召回率曲线非常相似,但是 ROC 曲线不是绘制精度与召回率,而是绘制真正例率(召回率的另一个名称)与假正例率(FPR)。FPR(也称为误报率)是被错误分类为正例的负实例的比率。它等于 1 减去真负例率(TNR),即被正确分类为负例的负实例的比率。TNR 也称为特异性。因此,ROC 曲线绘制灵敏度(召回率)与 1-特异性。
要绘制 ROC 曲线,首先使用roc_curve()
函数计算各种阈值的 TPR 和 FPR:
from sklearn.metrics import roc_curve
fpr, tpr, thresholds = roc_curve(y_train_5, y_scores)
然后,您可以使用 Matplotlib 绘制 FPR 与 TPR。以下代码生成图 3-7 中的图。要找到对应于 90%精确度的点,我们需要查找所需阈值的索引。由于在这种情况下,阈值按降序列出,因此我们在第一行上使用<=
而不是>=
:
idx_for_threshold_at_90 = (thresholds <= threshold_for_90_precision).argmax()
tpr_90, fpr_90 = tpr[idx_for_threshold_at_90], fpr[idx_for_threshold_at_90]
plt.plot(fpr, tpr, linewidth=2, label="ROC curve")
plt.plot([0, 1], [0, 1], 'k:', label="Random classifier's ROC curve")
plt.plot([fpr_90], [tpr_90], "ko", label="Threshold for 90% precision")
[...] # beautify the figure: add labels, grid, legend, arrow, and text
plt.show()
图 3-7。ROC 曲线绘制了所有可能阈值的假阳性率与真阳性率之间的关系;黑色圆圈突出显示了选择的比率(在 90%精确度和 48%召回率处)
再次存在权衡:召回率(TPR)越高,分类器产生的假阳性(FPR)就越多。虚线代表纯随机分类器的 ROC 曲线;一个好的分类器尽可能远离该线(朝向左上角)。
比较分类器的一种方法是测量曲线下面积(AUC)。完美的分类器的 ROC AUC 等于 1,而纯随机分类器的 ROC AUC 等于 0.5。Scikit-Learn 提供了一个估计 ROC AUC 的函数:
>>> from sklearn.metrics import roc_auc_score
>>> roc_auc_score(y_train_5, y_scores)
0.9604938554008616
提示
由于 ROC 曲线与精确率/召回率(PR)曲线非常相似,您可能想知道如何决定使用哪个。作为经验法则,当正类别很少或您更关心假阳性而不是假阴性时,应优先选择 PR 曲线。否则,请使用 ROC 曲线。例如,查看先前的 ROC 曲线(以及 ROC AUC 分数),您可能会认为分类器非常好。但这主要是因为与负例(非 5)相比,正例(5)很少。相比之下,PR 曲线清楚地表明分类器有改进的空间:曲线实际上可以更接近右上角(再次参见图 3-6)。
现在让我们创建一个RandomForestClassifier
,我们可以将其 PR 曲线和 F[1]分数与SGDClassifier
的进行比较:
from sklearn.ensemble import RandomForestClassifier
forest_clf = RandomForestClassifier(random_state=42)
precision_recall_curve()
函数期望每个实例的标签和分数,因此我们需要训练随机森林分类器并使其为每个实例分配一个分数。但是,由于RandomForestClassifier
类的工作方式(我们将在第七章中介绍),它没有decision_function()
方法。幸运的是,它有一个predict_proba()
方法,为每个实例返回类概率,并且我们可以将正类别的概率作为分数,因此它将正常工作。⁴我们可以调用cross_val_predict()
函数,使用交叉验证训练RandomForestClassifier
,并使其为每个图像预测类概率,如下所示:
y_probas_forest = cross_val_predict(forest_clf, X_train, y_train_5, cv=3,
method="predict_proba")
让我们看看训练集中前两个图像的类概率:
>>> y_probas_forest[:2]
array([[0.11, 0.89],
[0.99, 0.01]])
模型预测第一幅图像为正面的概率为 89%,并且预测第二幅图像为负面的概率为 99%。由于每幅图像要么是正面要么是负面,因此每行中的概率总和为 100%。
警告
这些是估计概率,而不是实际概率。例如,如果您查看模型将其分类为正面的所有图像,其估计概率在 50%到 60%之间,大约 94%实际上是正面的。因此,在这种情况下,模型的估计概率要低得多,但模型也可能过于自信。sklearn.calibration
包含工具,可以校准估计的概率,使其更接近实际概率。有关更多详细信息,请参阅本章笔记本中的额外材料部分。
第二列包含正类别的估计概率,因此让我们将它们传递给precision_recall_curve()
函数:
y_scores_forest = y_probas_forest[:, 1]
precisions_forest, recalls_forest, thresholds_forest = precision_recall_curve(
y_train_5, y_scores_forest)
现在我们准备绘制 PR 曲线。为了查看它们的比较,也有必要绘制第一个 PR 曲线(图 3-8):
plt.plot(recalls_forest, precisions_forest, "b-", linewidth=2,
label="Random Forest")
plt.plot(recalls, precisions, "--", linewidth=2, label="SGD")
[...] # beautify the figure: add labels, grid, and legend
plt.show()
图 3-8。比较 PR 曲线:随机森林分类器优于 SGD 分类器,因为其 PR 曲线更接近右上角,并且具有更大的 AUC
正如您在图 3-8 中所看到的,RandomForestClassifier
的 PR 曲线看起来比SGDClassifier
的要好得多:它更接近右上角。它的 F[1]分数和 ROC AUC 分数也显著更好:
>>> y_train_pred_forest = y_probas_forest[:, 1] >= 0.5 # positive proba ≥ 50%
>>> f1_score(y_train_5, y_pred_forest)
0.9242275142688446
>>> roc_auc_score(y_train_5, y_scores_forest)
0.9983436731328145
尝试测量精确度和召回率得分:您应该会发现大约 99.1%的精确度和 86.6%的召回率。还不错!
您现在知道如何训练二元分类器,选择适合您任务的适当度量标准,使用交叉验证评估您的分类器,选择适合您需求的精确度/召回率折衷,并使用多种指标和曲线比较各种模型。您已经准备好尝试检测不仅仅是数字 5 了。
多类分类
而二元分类器区分两个类别,多类分类器(也称为多项分类器)可以区分两个以上的类别。
一些 Scikit-Learn 分类器(例如LogisticRegression
、RandomForestClassifier
和GaussianNB
)能够本地处理多个类别。其他严格的二元分类器(例如SGDClassifier
和SVC
)。然而,有各种策略可用于使用多个二元分类器执行多类分类。
创建一个能够将数字图像分类为 10 个类别(从 0 到 9)的系统的一种方法是训练 10 个二元分类器,每个数字一个(一个 0 检测器,一个 1 检测器,一个 2 检测器,依此类推)。然后,当您想要对一幅图像进行分类时,您会从每个分类器中获取该图像的决策分数,并选择输出最高分数的类别。这被称为一对剩余(OvR)策略,有时也称为一对所有(OvA)。
另一种策略是为每对数字训练一个二元分类器:一个用于区分 0 和 1,另一个用于区分 0 和 2,另一个用于 1 和 2,依此类推。这被称为一对一(OvO)策略。如果有N个类别,您需要训练N×(N - 1)/ 2 个分类器。对于 MNIST 问题,这意味着训练 45 个二元分类器!当您想要对一幅图像进行分类时,您必须通过所有 45 个分类器并查看哪个类别赢得了最多的决斗。OvO 的主要优势在于每个分类器只需要在包含它必须区分的两个类别的训练集部分上进行训练。
一些算法(如支持向量机分类器)随着训练集的大小而扩展得很差。对于这些算法,OvO 更受青睐,因为在小训练集上训练许多分类器比在大训练集上训练少数分类器要快。然而,对于大多数二元分类算法,OvR 更受青睐。
Scikit-Learn 会检测到您尝试将二元分类算法用于多类分类任务时,并根据算法自动运行 OvR 或 OvO。让我们尝试使用sklearn.svm.SVC
类中的支持向量机分类器(参见第五章)。我们只会在前 2,000 幅图像上进行训练,否则会花费很长时间:
from sklearn.svm import SVC
svm_clf = SVC(random_state=42)
svm_clf.fit(X_train[:2000], y_train[:2000]) # y_train, not y_train_5
这很容易!我们使用原始目标类别从 0 到 9(y_train
)来训练SVC
,而不是使用 5 对剩余目标类别(y_train_5
)。由于有 10 个类别(即超过 2 个),Scikit-Learn 使用了 OvO 策略并训练了 45 个二元分类器。现在让我们对一幅图像进行预测:
>>> svm_clf.predict([some_digit])
array(['5'], dtype=object)
这是正确的!这段代码实际上进行了 45 次预测——每对类别一次——并选择了赢得最多决斗的类别。如果调用decision_function()
方法,您会看到它为每个实例返回 10 个分数:每个类别一个。每个类别得分等于赢得的决斗数加上或减去一个小调整(最大±0.33)以打破平局,基于分类器的分数:
>>> some_digit_scores = svm_clf.decision_function([some_digit])
>>> some_digit_scores.round(2)
array([[ 3.79, 0.73, 6.06, 8.3 , -0.29, 9.3 , 1.75, 2.77, 7.21,
4.82]])
最高分是 9.3,确实对应于类别 5:
>>> class_id = some_digit_scores.argmax()
>>> class_id
5
当分类器训练完成时,它会将目标类别列表存储在其classes_
属性中,按值排序。在 MNIST 的情况下,classes_
数组中每个类别的索引恰好与类别本身匹配(例如,索引为 5 的类在数组中是类'5'
),但通常您不会那么幸运;您需要像这样查找类标签:
>>> svm_clf.classes_
array(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'], dtype=object)
>>> svm_clf.classes_[class_id]
'5'
如果您想强制 Scikit-Learn 使用一对一或一对多,您可以使用OneVsOneClassifier
或OneVsRestClassifier
类。只需创建一个实例并将分类器传递给其构造函数(甚至不必是二元分类器)。例如,此代码使用 OvR 策略基于SVC
创建一个多类分类器:
from sklearn.multiclass import OneVsRestClassifier
ovr_clf = OneVsRestClassifier(SVC(random_state=42))
ovr_clf.fit(X_train[:2000], y_train[:2000])
让我们进行预测,并检查训练过的分类器数量:
>>> ovr_clf.predict([some_digit])
array(['5'], dtype='<U1')
>>> len(ovr_clf.estimators_)
10
在多类数据集上训练SGDClassifier
并使用它进行预测同样简单:
>>> sgd_clf = SGDClassifier(random_state=42)
>>> sgd_clf.fit(X_train, y_train)
>>> sgd_clf.predict([some_digit])
array(['3'], dtype='<U1')
哎呀,那是错误的。预测错误确实会发生!这次 Scikit-Learn 在幕后使用了 OvR 策略:由于有 10 个类别,它训练了 10 个二元分类器。decision_function()
方法现在返回每个类别的一个值。让我们看看 SGD 分类器为每个类别分配的分数:
>>> sgd_clf.decision_function([some_digit]).round()
array([[-31893., -34420., -9531., 1824., -22320., -1386., -26189.,
-16148., -4604., -12051.]])
您可以看到分类器对其预测并不是很自信:几乎所有分数都非常负面,而类别 3 的分数为+1,824,类别 5 也不远处为-1,386。当然,您会希望对这个分类器进行多个图像的评估。由于每个类别中的图像数量大致相同,准确度指标是可以接受的。通常情况下,您可以使用cross_val_score()
函数来评估模型:
>>> cross_val_score(sgd_clf, X_train, y_train, cv=3, scoring="accuracy")
array([0.87365, 0.85835, 0.8689 ])
它在所有测试折叠上都超过了 85.8%。如果使用随机分类器,您将获得 10%的准确率,因此这并不是一个很差的分数,但您仍然可以做得更好。简单地缩放输入(如第二章中讨论的)可以将准确率提高到 89.1%以上:
>>> from sklearn.preprocessing import StandardScaler
>>> scaler = StandardScaler()
>>> X_train_scaled = scaler.fit_transform(X_train.astype("float64"))
>>> cross_val_score(sgd_clf, X_train_scaled, y_train, cv=3, scoring="accuracy")
array([0.8983, 0.891 , 0.9018])
错误分析
如果这是一个真实的项目,您现在将按照机器学习项目清单中的步骤进行操作(请参阅附录 A)。您将探索数据准备选项,尝试多个模型,列出最佳模型,使用GridSearchCV
微调其超参数,并尽可能自动化。在这里,我们假设您已经找到了一个有希望的模型,并且想要找到改进它的方法。其中一种方法是分析它所犯的错误类型。
首先,看一下混淆矩阵。为此,您首先需要使用cross_val_predict()
函数进行预测;然后您可以将标签和预测传递给confusion_matrix()
函数,就像您之前所做的那样。然而,由于现在有 10 个类别而不是 2 个,混淆矩阵将包含相当多的数字,可能很难阅读。
彩色混淆矩阵图表更容易分析。要绘制这样的图表,请使用ConfusionMatrixDisplay.from_predictions()
函数,如下所示:
from sklearn.metrics import ConfusionMatrixDisplay
y_train_pred = cross_val_predict(sgd_clf, X_train_scaled, y_train, cv=3)
ConfusionMatrixDisplay.from_predictions(y_train, y_train_pred)
plt.show()
这将生成图 3-9 中的左侧图。这个混淆矩阵看起来相当不错:大多数图像都在主对角线上,这意味着它们被正确分类了。请注意,对角线上的第 5 行和第 5 列的单元格看起来比其他数字稍暗。这可能是因为模型在 5 上犯了更多错误,或者因为数据集中的 5 比其他数字少。这就是通过将每个值除以相应(真实)类别中图像的总数(即除以行的总和)来对混淆矩阵进行归一化的重要性。这可以通过简单地设置normalize="true"
来完成。我们还可以指定values_format=".0%"
参数以显示没有小数的百分比。以下代码生成图 3-9 中右侧的图表:
ConfusionMatrixDisplay.from_predictions(y_train, y_train_pred,
normalize="true", values_format=".0%")
plt.show()
现在我们可以很容易地看到,只有 82%的 5 的图像被正确分类。模型在 5 的图像中最常见的错误是将它们错误分类为 8:这发生在所有 5 的 10%中。但只有 2%的 8 被错误分类为 5;混淆矩阵通常不是对称的!如果你仔细观察,你会注意到许多数字被错误分类为 8,但从这个图表中并不立即明显。如果你想让错误更加突出,你可以尝试在正确预测上设置零权重。以下代码就是这样做的,并生成了图 3-10 中左边的图表:
sample_weight = (y_train_pred != y_train)
ConfusionMatrixDisplay.from_predictions(y_train, y_train_pred,
sample_weight=sample_weight,
normalize="true", values_format=".0%")
plt.show()
图 3-9。混淆矩阵(左)和相同的通过行归一化的 CM(右)
图 3-10。仅显示错误的混淆矩阵,通过行归一化(左)和通过列归一化(右)
现在你可以更清楚地看到分类器所犯的错误类型。类别 8 的列现在非常亮,这证实了许多图像被错误分类为 8。事实上,这是几乎所有类别中最常见的错误分类。但要注意如何解释这个图表中的百分比:记住我们已经排除了正确的预测。例如,第 7 行第 9 列的 36% 不意味着 36%的所有 7 的图像被错误分类为 9。它意味着 36%的模型在 7 的图像上犯的错误被错误分类为 9。实际上,只有 3%的 7 的图像被错误分类为 9,你可以在右边的图表中看到图 3-9。
也可以通过列而不是通过行对混淆矩阵进行归一化:如果设置normalize="pred"
,你会得到图 3-10 中右边的图表。例如,你可以看到 56%的错误分类的 7 实际上是 9。
分析混淆矩阵通常可以让你了解如何改进你的分类器。从这些图表中看,你的努力应该花在减少错误的 8 上。例如,你可以尝试收集更多看起来像 8 的(但实际上不是)数字的训练数据,这样分类器就可以学会区分它们和真正的 8。或者你可以设计新的特征来帮助分类器,例如,编写一个算法来计算闭环的数量(例如,8 有两个,6 有一个,5 没有)。或者你可以预处理图像(例如,使用 Scikit-Image、Pillow 或 OpenCV)使一些模式,如闭环,更加突出。
分析单个错误也是了解你的分类器在做什么以及为什么失败的好方法。例如,让我们以混淆矩阵样式绘制 3 和 5 的示例(图 3-11):
cl_a, cl_b = '3', '5'
X_aa = X_train[(y_train == cl_a) & (y_train_pred == cl_a)]
X_ab = X_train[(y_train == cl_a) & (y_train_pred == cl_b)]
X_ba = X_train[(y_train == cl_b) & (y_train_pred == cl_a)]
X_bb = X_train[(y_train == cl_b) & (y_train_pred == cl_b)]
[...] # plot all images in X_aa, X_ab, X_ba, X_bb in a confusion matrix style
图 3-11。一些 3 和 5 的图像以混淆矩阵的方式组织
正如你所看到的,分类器错误分类的一些数字(即,左下角和右上角的块)写得非常糟糕,即使是人类也会难以分类。然而,大多数错误分类的图像对我们来说似乎是明显的错误。理解分类器为什么犯错可能很困难,但请记住,人类大脑是一个出色的模式识别系统,我们的视觉系统在任何信息到达我们的意识之前都进行了大量复杂的预处理。因此,这个任务看起来简单并不意味着它是简单的。回想一下,我们使用了一个简单的SGDClassifier
,它只是一个线性模型:它只是为每个像素分配一个类别权重,当它看到一个新的图像时,它只是将加权像素强度相加以获得每个类别的得分。由于 3 和 5 之间只相差几个像素,这个模型很容易混淆它们。
3s 和 5s 之间的主要区别是连接顶线和底部弧线的小线的位置。如果你画一个 3,连接处稍微向左移动,分类器可能会将其分类为 5,反之亦然。换句话说,这个分类器对图像的移动和旋转非常敏感。减少 3/5 混淆的一种方法是预处理图像,确保它们居中且旋转不太多。然而,这可能并不容易,因为它需要预测每个图像的正确旋转。一个更简单的方法是通过增加训练集中略微移动和旋转的变体来增强训练集。这将迫使模型学会更容忍这些变化。这被称为数据增强(我们将在第十四章中介绍;也请参见本章末尾的练习 2)。
多标签分类
到目前为止,每个实例总是被分配到一个类。但在某些情况下,您可能希望您的分类器为每个实例输出多个类。考虑一个人脸识别分类器:如果它在同一张图片中识别出几个人,它应该做什么?它应该为它识别出的每个人附上一个标签。假设分类器已经训练好了识别三张脸:Alice、Bob 和 Charlie。那么当分类器看到 Alice 和 Charlie 的图片时,它应该输出[True, False, True]
(意思是“Alice 是,Bob 不是,Charlie 是”)。这样一个输出多个二进制标签的分类系统被称为多标签分类系统。
我们暂时不会讨论人脸识别,但让我们看一个更简单的例子,仅供说明目的:
import numpy as np
from sklearn.neighbors import KNeighborsClassifier
y_train_large = (y_train >= '7')
y_train_odd = (y_train.astype('int8') % 2 == 1)
y_multilabel = np.c_[y_train_large, y_train_odd]
knn_clf = KNeighborsClassifier()
knn_clf.fit(X_train, y_multilabel)
这段代码创建一个包含每个数字图像两个目标标签的y_multilabel
数组:第一个指示数字是否大(7、8 或 9),第二个指示数字是否奇数。然后代码创建一个KNeighborsClassifier
实例,支持多标签分类(并非所有分类器都支持),并使用多目标数组训练这个模型。现在您可以进行预测,并注意到它输出了两个标签:
>>> knn_clf.predict([some_digit])
array([[False, True]])
而且它预测正确了!数字 5 确实不是大的(False
)且是奇数(True
)。
有许多方法可以评估多标签分类器,选择正确的度量标准取决于您的项目。一种方法是测量每个单独标签的 F[1]分数(或之前讨论过的任何其他二元分类器度量标准),然后简单地计算平均分数。以下代码计算所有标签的平均 F[1]分数:
>>> y_train_knn_pred = cross_val_predict(knn_clf, X_train, y_multilabel, cv=3)
>>> f1_score(y_multilabel, y_train_knn_pred, average="macro")
0.976410265560605
这种方法假设所有标签都同等重要,但这可能并非总是如此。特别是,如果您有比 Bob 或 Charlie 更多的 Alice 图片,您可能希望在 Alice 图片上给分类器的分数更多的权重。一个简单的选择是为每个标签赋予一个权重,等于其支持(即具有该目标标签的实例数)。要做到这一点,只需在调用f1_score()
函数时设置average="weighted"
。⁵
如果您希望使用不原生支持多标签分类的分类器,比如SVC
,一种可能的策略是为每个标签训练一个模型。然而,这种策略可能难以捕捉标签之间的依赖关系。例如,一个大数字(7、8 或 9)是奇数的可能性是偶数的两倍,但“奇数”标签的分类器不知道“大”标签的分类器预测了什么。为了解决这个问题,模型可以被组织成一个链:当一个模型做出预测时,它使用输入特征加上链中之前所有模型的预测。
好消息是,Scikit-Learn 有一个名为ChainClassifier
的类,它就是做这个的!默认情况下,它将使用真实标签进行训练,根据它们在链中的位置为每个模型提供适当的标签。但是,如果设置cv
超参数,它将使用交叉验证为训练集中的每个实例获取“干净”(样本外)预测,并且这些预测将用于以后在链中训练所有模型。以下是一个示例,展示如何使用交叉验证策略创建和训练ChainClassifier
。与之前一样,我们将只使用训练集中的前 2,000 幅图像以加快速度:
from sklearn.multioutput import ClassifierChain
chain_clf = ClassifierChain(SVC(), cv=3, random_state=42)
chain_clf.fit(X_train[:2000], y_multilabel[:2000])
现在我们可以使用这个ChainClassifier
进行预测:
>>> chain_clf.predict([some_digit])
array([[0., 1.]])
多输出分类
我们将在这里讨论的最后一种分类任务类型称为多输出-多类别分类(或多输出分类)。这是多标签分类的一种泛化,其中每个标签可以是多类别的(即,它可以有两个以上的可能值)。
为了说明这一点,让我们构建一个从图像中去除噪声的系统。它将以嘈杂的数字图像作为输入,然后(希望)输出一个干净的数字图像,表示为像 MNIST 图像一样的像素强度数组。请注意,分类器的输出是多标签的(每个像素一个标签),每个标签可以有多个值(像素强度范围从 0 到 255)。因此,这是一个多输出分类系统的示例。
注意
分类和回归之间的界限有时是模糊的,比如在这个例子中。可以说,预测像素强度更类似于回归而不是分类。此外,多输出系统不仅限于分类任务;您甚至可以拥有一个系统,它为每个实例输出多个标签,包括类标签和值标签。
让我们从使用 NumPy 的randint()
函数向 MNIST 图像添加噪声来创建训练集和测试集。目标图像将是原始图像:
np.random.seed(42) # to make this code example reproducible
noise = np.random.randint(0, 100, (len(X_train), 784))
X_train_mod = X_train + noise
noise = np.random.randint(0, 100, (len(X_test), 784))
X_test_mod = X_test + noise
y_train_mod = X_train
y_test_mod = X_test
让我们看一下测试集中的第一幅图像(图 3-12)。是的,我们正在窥探测试数据,所以您现在应该皱起眉头。
图 3-12. 一幅嘈杂的图像(左)和目标干净图像(右)
左边是嘈杂的输入图像,右边是干净的目标图像。现在让我们训练分类器,让它清理这幅图像(图 3-13):
knn_clf = KNeighborsClassifier()
knn_clf.fit(X_train_mod, y_train_mod)
clean_digit = knn_clf.predict([X_test_mod[0]])
plot_digit(clean_digit)
plt.show()
图 3-13. 清理后的图像
看起来接近目标了!这结束了我们的分类之旅。您现在知道如何为分类任务选择良好的度量标准,选择适当的精确度/召回率折衷,比较分类器,以及更一般地构建各种任务的良好分类系统。在接下来的章节中,您将了解您一直在使用的所有这些机器学习模型实际上是如何工作的。
练习
-
尝试为 MNIST 数据集构建一个分类器,在测试集上实现超过 97%的准确率。提示:
KNeighborsClassifier
对这个任务效果很好;您只需要找到好的超参数值(尝试在weights
和n_neighbors
超参数上进行网格搜索)。 -
编写一个函数,可以将 MNIST 图像向任何方向(左、右、上或下)移动一个像素。然后,对于训练集中的每个图像,创建四个移位副本(每个方向一个)并将它们添加到训练集中。最后,在这个扩展的训练集上训练您最好的模型,并在测试集上测量其准确率。您应该观察到您的模型现在表现得更好了!这种人为扩展训练集的技术称为数据增强或训练集扩展。
-
解决泰坦尼克号数据集。一个很好的开始地方是Kaggle。或者,您可以从https://homl.info/titanic.tgz下载数据并解压缩这个 tarball,就像您在第二章中为房屋数据所做的那样。这将给您两个 CSV 文件,train.csv和test.csv,您可以使用
pandas.read_csv()
加载。目标是训练一个分类器,可以根据其他列预测Survived
列。 -
构建一个垃圾邮件分类器(一个更具挑战性的练习):
-
从Apache SpamAssassin 的公共数据集下载垃圾邮件和正常邮件的示例。
-
解压数据集并熟悉数据格式。
-
将数据分割为训练集和测试集。
-
编写一个数据准备流水线,将每封电子邮件转换为特征向量。您的准备流水线应该将一封电子邮件转换为一个(稀疏)向量,指示每个可能单词的存在或不存在。例如,如果所有电子邮件只包含四个单词,“Hello”、“how”、“are”、“you”,那么电子邮件“Hello you Hello Hello you”将被转换为向量[1, 0, 0, 1](表示[“Hello”存在,“how”不存在,“are”不存在,“you”存在]),或者如果您更喜欢计算每个单词出现的次数,则为[3, 0, 0, 2]。
您可能希望在准备流水线中添加超参数,以控制是否剥离电子邮件头部,将每封电子邮件转换为小写,删除标点符号,用“URL”替换所有 URL,用“NUMBER”替换所有数字,甚至执行词干提取(即修剪单词结尾;有 Python 库可用于执行此操作)。
-
最后,尝试几种分类器,看看是否可以构建一个既具有高召回率又具有高精度的垃圾邮件分类器。
-
这些练习的解决方案可以在本章笔记本的末尾找到,网址为https://homl.info/colab3。
¹ 默认情况下,Scikit-Learn 会将下载的数据集缓存到名为scikit_learn_data的目录中,该目录位于您的主目录中。
² fetch_openml()
返回的数据集并不总是被洗牌或分割。
³ 在某些情况下,洗牌可能不是一个好主意——例如,如果您正在处理时间序列数据(如股票市场价格或天气状况)。我们将在第十五章中探讨这个问题。
⁴ Scikit-Learn 分类器总是具有decision_function()
方法或predict_proba()
方法,有时两者都有。
⁵ Scikit-Learn 提供了一些其他平均选项和多标签分类器指标;更多细节请参阅文档。
⁶ 您可以使用scipy.ndimage.interpolation
模块中的shift()
函数。例如,shift(image, [2, 1], cval=0)
将图像向下移动两个像素,向右移动一个像素。
第四章:训练模型
到目前为止,我们大多将机器学习模型及其训练算法视为黑匣子。如果您在之前章节的一些练习中有所了解,您可能会对不知道底层原理的情况下能做多少事情感到惊讶:您优化了一个回归系统,改进了一个数字图像分类器,甚至从头开始构建了一个垃圾邮件分类器,所有这些都是在不知道它们实际如何工作的情况下完成的。实际上,在许多情况下,您并不真正需要知道实现细节。
然而,对事物如何运作有一个良好的理解可以帮助您快速找到适当的模型、正确的训练算法以及适合您任务的一组良好的超参数。了解底层原理还将帮助您更有效地调试问题并执行错误分析。最后,本章讨论的大多数主题将对理解、构建和训练神经网络(本书的第二部分中讨论)至关重要。
在本章中,我们将首先看一下线性回归模型,这是最简单的模型之一。我们将讨论两种非常不同的训练方法:
-
使用一个“封闭形式”方程¹直接计算最适合训练集的模型参数(即最小化训练集上成本函数的模型参数)。
-
使用一种称为梯度下降(GD)的迭代优化方法,逐渐调整模型参数以最小化训练集上的成本函数,最终收敛到与第一种方法相同的参数集。我们将看一下几种梯度下降的变体,当我们研究神经网络时会一再使用:批量 GD、小批量 GD 和随机 GD。
接下来我们将看一下多项式回归,这是一个可以拟合非线性数据集的更复杂模型。由于这个模型比线性回归有更多的参数,所以更容易过拟合训练数据。我们将探讨如何通过学习曲线检测是否存在这种情况,然后我们将看一下几种正则化技术,可以减少过拟合训练集的风险。
最后,我们将研究另外两种常用于分类任务的模型:逻辑回归和 softmax 回归。
警告
本章将包含相当多的数学方程,使用线性代数和微积分的基本概念。要理解这些方程,您需要知道向量和矩阵是什么;如何转置、相乘和求逆;以及什么是偏导数。如果您对这些概念不熟悉,请查看在线补充材料中作为 Jupyter 笔记本提供的线性代数和微积分入门教程。对于那些真正对数学过敏的人,您仍然应该阅读本章,并简单跳过方程;希望文本足以帮助您理解大部分概念。
线性回归
在第一章中,我们看了一个关于生活满意度的简单回归模型:
life_satisfaction = θ[0] + θ[1] × GDP_per_capita
该模型只是输入特征GDP_per_capita
的线性函数。θ[0]和θ[1]是模型的参数。
更一般地,线性模型通过简单地计算输入特征的加权和加上一个称为偏置项(也称为截距项)的常数来进行预测,如方程 4-1 所示。
方程 4-1。线性回归模型预测
在这个方程中:
-
ŷ是预测值。
-
n是特征数量。
-
x[i]是第i个特征值。
-
θ[j]是第j个模型参数,包括偏置项θ[0]和特征权重θ[1]、θ[2]、⋯、θ[n]。
这可以用矢量化形式更简洁地表示,如方程 4-2 所示。
方程 4-2. 线性回归模型预测(矢量化形式)
在这个方程中:
-
h[θ]是假设函数,使用模型参数θ。
-
θ是模型的参数向量,包括偏置项θ[0]和特征权重θ[1]到θ[n]。
-
x是实例的特征向量,包含x[0]到x[n],其中x[0]始终等于 1。
-
θ · x是向量θ和x的点积,等于θ[0]x[0] + θ[1]x[1] + θ[2]x[2] + ... + θ[n]x[n]。
注意
在机器学习中,向量通常表示为列向量,这是具有单列的二维数组。如果θ和x是列向量,那么预测值为,其中是θ的转置(行向量而不是列向量),是和x的矩阵乘法。当然,这是相同的预测,只是现在表示为单元格矩阵而不是标量值。在本书中,我将使用这种表示法,以避免在点积和矩阵乘法之间切换。
好的,这就是线性回归模型,但我们如何训练它呢?嗯,回想一下,训练模型意味着设置其参数,使模型最好地适应训练集。为此,我们首先需要一个衡量模型与训练数据拟合程度的指标。在第二章中,我们看到回归模型最常见的性能指标是均方根误差(方程 2-1)。因此,要训练线性回归模型,我们需要找到最小化 RMSE 的θ的值。在实践中,最小化均方误差(MSE)比最小化 RMSE 更简单,并且会导致相同的结果(因为最小化正函数的值也会最小化其平方根)。
警告
在训练期间,学习算法通常会优化不同的损失函数,而不是用于评估最终模型的性能指标。这通常是因为该函数更容易优化和/或因为在训练期间仅需要额外的项(例如,用于正则化)。一个好的性能指标应尽可能接近最终的业务目标。一个好的训练损失易于优化,并且与指标强相关。例如,分类器通常使用成本函数进行训练,如对数损失(稍后在本章中将看到),但使用精度/召回率进行评估。对数损失易于最小化,这样做通常会提高精度/召回率。
线性回归假设h[θ]在训练集X上的 MSE 是使用方程 4-3 计算的。
方程 4-3. 线性回归模型的 MSE 成本函数
大多数这些符号在第二章中已经介绍过(参见“符号”)。唯一的区别是我们写h[θ]而不是只写h,以明确模型是由向量θ参数化的。为了简化符号,我们将只写 MSE(θ)而不是 MSE(X, h[θ])。
正规方程
为了找到最小化 MSE 的θ的值,存在一个闭式解——换句话说,一个直接给出结果的数学方程。这被称为正规方程(方程 4-4)。
方程 4-4. 正规方程
在这个方程中:
-
是最小化成本函数的θ的值。
-
y是包含y((1))到*y*((m))的目标值向量。
让我们生成一些看起来线性的数据来测试这个方程(图 4-1):
import numpy as np
np.random.seed(42) # to make this code example reproducible
m = 100 # number of instances
X = 2 * np.random.rand(m, 1) # column vector
y = 4 + 3 * X + np.random.randn(m, 1) # column vector
图 4-1. 随机生成的线性数据集
现在让我们使用正规方程计算。我们将使用 NumPy 的线性代数模块(np.linalg
)中的inv()
函数计算矩阵的逆,以及矩阵乘法的dot()
方法:
from sklearn.preprocessing import add_dummy_feature
X_b = add_dummy_feature(X) # add x0 = 1 to each instance
theta_best = np.linalg.inv(X_b.T @ X_b) @ X_b.T @ y
注意
@
运算符执行矩阵乘法。如果A
和B
是 NumPy 数组,则A @ B
等同于np.matmul(A, B)
。许多其他库,如 TensorFlow、PyTorch 和 JAX,也支持@
运算符。但是,不能在纯 Python 数组(即列表的列表)上使用@
。
我们用来生成数据的函数是y = 4 + 3x[1] + 高斯噪声。让我们看看方程找到了什么:
>>> theta_best
array([[4.21509616],
[2.77011339]])
我们希望θ[0] = 4 和θ[1] = 3,而不是θ[0] = 4.215 和θ[1] = 2.770。足够接近,但噪声使得无法恢复原始函数的确切参数。数据集越小且噪声越大,问题就越困难。
现在我们可以使用进行预测:
>>> X_new = np.array([[0], [2]])
>>> X_new_b = add_dummy_feature(X_new) # add x0 = 1 to each instance
>>> y_predict = X_new_b @ theta_best
>>> y_predict
array([[4.21509616],
[9.75532293]])
让我们绘制这个模型的预测(图 4-2):
import matplotlib.pyplot as plt
plt.plot(X_new, y_predict, "r-", label="Predictions")
plt.plot(X, y, "b.")
[...] # beautify the figure: add labels, axis, grid, and legend
plt.show()
图 4-2. 线性回归模型预测
使用 Scikit-Learn 执行线性回归相对简单:
>>> from sklearn.linear_model import LinearRegression
>>> lin_reg = LinearRegression()
>>> lin_reg.fit(X, y)
>>> lin_reg.intercept_, lin_reg.coef_
(array([4.21509616]), array([[2.77011339]]))
>>> lin_reg.predict(X_new)
array([[4.21509616],
[9.75532293]])
请注意,Scikit-Learn 将偏置项(intercept_
)与特征权重(coef_
)分开。LinearRegression
类基于scipy.linalg.lstsq()
函数(名称代表“最小二乘法”),您可以直接调用该函数:
>>> theta_best_svd, residuals, rank, s = np.linalg.lstsq(X_b, y, rcond=1e-6)
>>> theta_best_svd
array([[4.21509616],
[2.77011339]])
这个函数计算,其中是X的伪逆(具体来说,是 Moore-Penrose 逆)。您可以使用np.linalg.pinv()
直接计算伪逆:
>>> np.linalg.pinv(X_b) @ y
array([[4.21509616],
[2.77011339]])
伪逆本身是使用称为奇异值分解(SVD)的标准矩阵分解技术计算的,可以将训练集矩阵X分解为三个矩阵U Σ V^⊺的矩阵乘法(参见numpy.linalg.svd()
)。伪逆计算为。为了计算矩阵,算法取Σ并将小于一个微小阈值的所有值设为零,然后用它们的倒数替换所有非零值,最后转置结果矩阵。这种方法比计算正规方程更有效,而且可以很好地处理边缘情况:实际上,如果矩阵X^⊺X不可逆(即奇异),例如如果m<n或者某些特征是冗余的,那么正规方程可能无法工作,但伪逆总是被定义的。
计算复杂度
正规方程计算X⊺**X**的逆,这是一个(*n*+1)×(*n*+1)矩阵(其中*n*是特征数)。求解这样一个矩阵的*计算复杂度*通常约为*O*(*n*(2.4))到O(n³),取决于实现。换句话说,如果特征数翻倍,计算时间大约会乘以 2^(2.4)=5.3 到 2³=8。
Scikit-Learn 的LinearRegression
类使用的 SVD 方法大约是O(n²)。如果特征数量翻倍,计算时间大约会乘以 4。
警告
当特征数量增多时(例如 100,000),正规方程和 SVD 方法都变得非常慢。积极的一面是,它们都与训练集中实例数量线性相关(它们是O(m)),因此它们可以有效地处理大型训练集,只要它们可以放入内存。
此外,一旦训练好线性回归模型(使用正规方程或任何其他算法),预测速度非常快:计算复杂度与您要进行预测的实例数量和特征数量成正比。换句话说,对两倍实例(或两倍特征)进行预测将花费大约两倍的时间。
现在我们将看一种非常不同的训练线性回归模型的方法,这种方法更适用于特征数量较多或训练实例太多无法放入内存的情况。
梯度下降
梯度下降是一种通用的优化算法,能够找到各种问题的最优解。梯度下降的一般思想是迭代地调整参数,以最小化成本函数。
假设你在浓雾中的山中迷失了方向,只能感受到脚下的坡度。快速到达山谷底部的一个好策略是沿着最陡的坡度方向下坡。这正是梯度下降所做的:它测量了关于参数向量θ的误差函数的局部梯度,并沿着下降梯度的方向前进。一旦梯度为零,你就到达了一个最小值!
在实践中,您首先用随机值填充θ(这称为随机初始化)。然后逐渐改进它,每次尝试减少成本函数(例如 MSE)一点点,直到算法收敛到最小值(参见图 4-3)。
图 4-3。在这个梯度下降的描述中,模型参数被随机初始化,并不断调整以最小化成本函数;学习步长大小与成本函数的斜率成比例,因此随着成本接近最小值,步长逐渐变小
梯度下降中的一个重要参数是步长的大小,由学习率超参数确定。如果学习率太小,那么算法将需要经过许多迭代才能收敛,这将花费很长时间(参见图 4-4)。
图 4-4。学习率太小
另一方面,如果学习率太高,您可能会跳过山谷,最终停在另一侧,甚至可能比之前更高。这可能导致算法发散,产生越来越大的值,无法找到一个好的解决方案(参见图 4-5)。
图 4-5。学习率太高
此外,并非所有成本函数都像漂亮的、规则的碗一样。可能会有洞、脊、高原和各种不规则的地形,使得收敛到最小值变得困难。图 4-6 展示了梯度下降的两个主要挑战。如果随机初始化将算法开始于左侧,则它将收敛到局部最小值,这不如全局最小值好。如果它从右侧开始,则穿过高原将需要很长时间。如果您停得太早,您将永远无法达到全局最小值。
图 4-6。梯度下降的陷阱
幸运的是,线性回归模型的 MSE 成本函数恰好是一个凸函数,这意味着如果您选择曲线上的任意两点,连接它们的线段永远不会低于曲线。这意味着没有局部最小值,只有一个全局最小值。它还是一个连续函数,斜率永远不会突然改变。这两个事实有一个重要的结果:梯度下降保证可以无限接近全局最小值(如果等待足够长的时间且学习率不太高)。
虽然成本函数的形状像一个碗,但如果特征具有非常不同的比例,它可能是一个延长的碗。图 4-7 展示了在特征 1 和 2 具有相同比例的训练集上的梯度下降(左侧),以及在特征 1 的值远小于特征 2 的训练集上的梯度下降(右侧)。
图 4-7。特征缩放的梯度下降(左)和不缩放的梯度下降(右)
正如您所看到的,左侧的梯度下降算法直接朝向最小值,因此快速到达,而右侧首先朝向几乎与全局最小值方向正交的方向,最终沿着几乎平坦的山谷长途跋涉。它最终会到达最小值,但需要很长时间。
警告
在使用梯度下降时,您应确保所有特征具有相似的比例(例如,使用 Scikit-Learn 的StandardScaler
类),否则收敛所需的时间将更长。
这个图表还说明了训练模型意味着寻找一组模型参数的组合,使得成本函数(在训练集上)最小化。这是在模型的参数空间中进行的搜索。模型的参数越多,空间的维度就越多,搜索就越困难:在一个 300 维的草堆中搜索一根针比在 3 维空间中要困难得多。幸运的是,由于线性回归的情况下成本函数是凸的,所以这根针就在碗底。
批量梯度下降
要实现梯度下降,您需要计算成本函数相对于每个模型参数θ[j]的梯度。换句话说,您需要计算如果您稍微改变θ[j],成本函数将如何变化。这被称为偏导数。这就像问,“如果我面向东,脚下的山坡有多陡?”然后面向北问同样的问题(如果您可以想象一个超过三维的宇宙,那么其他维度也是如此)。方程 4-5 计算了关于参数θ[j]的 MSE 的偏导数,表示为∂ MSE(θ) / ∂θ[j]。
方程 4-5. 成本函数的偏导数
与单独计算这些偏导数不同,您可以使用方程 4-6 一次性计算它们。梯度向量,表示为∇[θ]MSE(θ),包含成本函数的所有偏导数(每个模型参数一个)。
方程 4-6. 成本函数的梯度向量
警告
请注意,这个公式涉及对整个训练集X进行计算,每次梯度下降步骤都要进行!这就是为什么该算法被称为批量梯度下降:它在每一步使用整个批量的训练数据(实际上,全梯度下降可能是一个更好的名称)。因此,在非常大的训练集上,它非常慢(我们很快将看到一些更快的梯度下降算法)。然而,梯度下降随着特征数量的增加而扩展得很好;当特征数量达到数十万时,使用梯度下降训练线性回归模型比使用正规方程或 SVD 分解要快得多。
一旦有了指向上坡的梯度向量,只需朝相反方向前进以下坡。这意味着从θ中减去∇[θ]MSE(θ)。这就是学习率η发挥作用的地方:⁴将梯度向量乘以η来确定下坡步长的大小(方程 4-7)。
方程 4-7. 梯度下降步骤
让我们快速实现这个算法:
eta = 0.1 # learning rate
n_epochs = 1000
m = len(X_b) # number of instances
np.random.seed(42)
theta = np.random.randn(2, 1) # randomly initialized model parameters
for epoch in range(n_epochs):
gradients = 2 / m * X_b.T @ (X_b @ theta - y)
theta = theta - eta * gradients
这并不难!每次对训练集的迭代称为epoch。让我们看看得到的theta
:
>>> theta
array([[4.21509616],
[2.77011339]])
嘿,这正是正规方程找到的!梯度下降完美地工作了。但是如果您使用了不同的学习率(eta
)会怎样呢?图 4-8 显示了使用三种不同学习率的梯度下降的前 20 步。每个图中底部的线代表随机起始点,然后每个迭代由越来越深的线表示。
图 4-8. 不同学习率的梯度下降
在左侧,学习率太低:算法最终会达到解,但需要很长时间。在中间,学习率看起来相当不错:在几个迭代中,它已经收敛到解。在右侧,学习率太高:算法发散,跳来跳去,实际上每一步都离解越来越远。
要找到一个好的学习率,可以使用网格搜索(参见第二章)。然而,您可能希望限制迭代次数,以便网格搜索可以消除收敛时间过长的模型。
您可能想知道如何设置迭代次数。如果太低,当算法停止时,您仍然离最优解很远;但如果太高,您将浪费时间,因为模型参数不再改变。一个简单的解决方案是设置一个非常大的迭代次数,但在梯度向量变得微小时中断算法——也就是说,当其范数小于一个微小数ϵ(称为容差)时——因为这表示梯度下降已经(几乎)达到了最小值。
随机梯度下降
批量梯度下降的主要问题在于,它在每一步使用整个训练集来计算梯度,这使得在训练集很大时非常缓慢。相反,随机梯度下降 在每一步选择训练集中的一个随机实例,并仅基于该单个实例计算梯度。显然,一次只处理一个实例使得算法更快,因为每次迭代时需要操作的数据量很少。这也使得在庞大的训练集上进行训练成为可能,因为每次迭代只需要一个实例在内存中(随机梯度下降可以作为一种离线算法实现;参见第一章)。
另一方面,由于其随机(即随机)性质,这种算法比批量梯度下降不规则得多:成本函数不会温和地减少直到达到最小值,而是会上下波动,仅平均减少。随着时间的推移,它最终会非常接近最小值,但一旦到达那里,它将继续上下波动,永远不会稳定下来(参见图 4-9)。一旦算法停止,最终的参数值将是不错的,但不是最优的。
图 4-9。使用随机梯度下降,每个训练步骤比使用批量梯度下降快得多,但也更不规则。
当成本函数非常不规则时(如图 4-6 中所示),这实际上可以帮助算法跳出局部最小值,因此随机梯度下降比批量梯度下降更有可能找到全局最小值。
因此,随机性有助于摆脱局部最优解,但也不好,因为这意味着算法永远无法稳定在最小值处。解决这一困境的一个方法是逐渐降低学习率。步骤开始很大(有助于快速取得进展并摆脱局部最小值),然后变得越来越小,允许算法在全局最小值处稳定下来。这个过程类似于模拟退火,这是一种受金属冶炼过程启发的算法,其中熔化的金属被慢慢冷却。确定每次迭代学习率的函数称为学习计划。如果学习率降低得太快,您可能会陷入局部最小值,甚至最终冻结在最小值的一半。如果学习率降低得太慢,您可能会在最小值周围跳来跳去很长时间,并且如果您在训练过早停止,最终会得到一个次优解。
此代码使用简单的学习计划实现随机梯度下降:
n_epochs = 50
t0, t1 = 5, 50 # learning schedule hyperparameters
def learning_schedule(t):
return t0 / (t + t1)
np.random.seed(42)
theta = np.random.randn(2, 1) # random initialization
for epoch in range(n_epochs):
for iteration in range(m):
random_index = np.random.randint(m)
xi = X_b[random_index : random_index + 1]
yi = y[random_index : random_index + 1]
gradients = 2 * xi.T @ (xi @ theta - yi) # for SGD, do not divide by m
eta = learning_schedule(epoch * m + iteration)
theta = theta - eta * gradients
按照惯例,我们按照m次迭代的轮次进行迭代;每一轮称为epoch,如前所述。虽然批量梯度下降代码通过整个训练集迭代了 1,000 次,但这段代码只通过训练集迭代了 50 次,并达到了一个相当不错的解决方案:
>>> theta
array([[4.21076011],
[2.74856079]])
图 4-10 显示了训练的前 20 步(请注意步骤的不规则性)。
请注意,由于实例是随机选择的,一些实例可能在每个 epoch 中被多次选择,而其他实例可能根本不被选择。如果您想确保算法在每个 epoch 中通过每个实例,另一种方法是对训练集进行洗牌(确保同时洗牌输入特征和标签),然后逐个实例地进行,然后再次洗牌,依此类推。然而,这种方法更复杂,通常不会改善结果。
图 4-10。随机梯度下降的前 20 步
警告
在使用随机梯度下降时,训练实例必须是独立同分布的(IID),以确保参数平均被拉向全局最优解。确保这一点的一个简单方法是在训练期间对实例进行洗牌(例如,随机选择每个实例,或在每个 epoch 开始时对训练集进行洗牌)。如果不对实例进行洗牌,例如,如果实例按标签排序,则 SGD 将从优化一个标签开始,然后是下一个标签,依此类推,并且不会接近全局最小值。
要使用 Scikit-Learn 进行随机梯度下降线性回归,您可以使用SGDRegressor
类,默认情况下优化 MSE 成本函数。以下代码最多运行 1,000 个时代(max_iter
)或在 100 个时代内损失下降不到 10^(–5)(tol
)时停止(n_iter_no_change
)。它以学习率 0.01(eta0
)开始,使用默认学习计划(与我们使用的不同)。最后,它不使用任何正则化(penalty=None
;稍后会详细介绍):
from sklearn.linear_model import SGDRegressor
sgd_reg = SGDRegressor(max_iter=1000, tol=1e-5, penalty=None, eta0=0.01,
n_iter_no_change=100, random_state=42)
sgd_reg.fit(X, y.ravel()) # y.ravel() because fit() expects 1D targets
再次,您会发现解决方案与正规方程返回的解非常接近:
>>> sgd_reg.intercept_, sgd_reg.coef_
(array([4.21278812]), array([2.77270267]))
提示
所有 Scikit-Learn 估计器都可以使用fit()
方法进行训练,但有些估计器还有一个partial_fit()
方法,您可以调用它来对一个或多个实例运行一轮训练(它会忽略max_iter
或tol
等超参数)。反复调用partial_fit()
会逐渐训练模型。当您需要更多控制训练过程时,这是很有用的。其他模型则有一个warm_start
超参数(有些模型两者都有):如果您设置warm_start=True
,在已训练的模型上调用fit()
方法不会重置模型;它将继续训练在哪里停止,遵守max_iter
和tol
等超参数。请注意,fit()
会重置学习计划使用的迭代计数器,而partial_fit()
不会。
小批量梯度下降
我们将要看的最后一个梯度下降算法称为小批量梯度下降。一旦您了解了批量梯度下降和随机梯度下降,这就很简单了:在每一步中,小批量梯度下降不是基于完整训练集(批量梯度下降)或仅基于一个实例(随机梯度下降)计算梯度,而是在称为小批量的小随机实例集上计算梯度。小批量梯度下降相对于随机梯度下降的主要优势在于,您可以通过硬件优化矩阵运算获得性能提升,尤其是在使用 GPU 时。
该算法在参数空间中的进展比随机梯度下降更加稳定,尤其是在使用相当大的小批量时。因此,小批量梯度下降最终会比随机梯度下降更接近最小值,但它可能更难逃离局部最小值(在存在局部最小值的问题中,不同于具有 MSE 成本函数的线性回归)。图 4-11 显示了训练过程中三种梯度下降算法在参数空间中的路径。它们最终都接近最小值,但批量梯度下降的路径实际上停在最小值处,而随机梯度下降和小批量梯度下降则继续移动。但是,请不要忘记,批量梯度下降需要很长时间才能完成每一步,如果您使用良好的学习计划,随机梯度下降和小批量梯度下降也会达到最小值。
图 4-11. 参数空间中的梯度下降路径
表 4-1 比较了迄今为止我们讨论过的线性回归算法(请回忆m是训练实例的数量,n是特征的数量)。
表 4-1. 线性回归算法比较
算法 | 大 m | 支持离线 | 大 n | 超参数 | 需要缩放 | Scikit-Learn |
---|---|---|---|---|---|---|
正规方程 | 快 | 否 | 慢 | 0 | 否 | N/A |
SVD | 快 | 否 | 慢 | 0 | 否 | LinearRegression |
批量梯度下降 | 慢 | 否 | 快 | 2 | 是 | N/A |
随机梯度下降 | 快 | 是 | 快 | ≥2 | 是 | SGDRegressor |
小批量梯度下降 | 快 | 是 | 快 | ≥2 | 是 | N/A |
训练后几乎没有区别:所有这些算法最终得到非常相似的模型,并以完全相同的方式进行预测。
多项式回归
如果你的数据比一条直线更复杂怎么办?令人惊讶的是,你可以使用线性模型来拟合非线性数据。一个简单的方法是将每个特征的幂作为新特征添加,然后在这个扩展的特征集上训练线性模型。这种技术称为多项式回归。
让我们看一个例子。首先,我们将生成一些非线性数据(参见图 4-12),基于一个简单的二次方程——即形式为y = ax² + bx + c的方程——再加上一些噪声:
np.random.seed(42)
m = 100
X = 6 * np.random.rand(m, 1) - 3
y = 0.5 * X ** 2 + X + 2 + np.random.randn(m, 1)
图 4-12。生成的非线性和嘈杂数据集
显然,一条直线永远无法正确拟合这些数据。因此,让我们使用 Scikit-Learn 的PolynomialFeatures
类来转换我们的训练数据,将训练集中每个特征的平方(二次多项式)作为新特征添加到训练数据中(在这种情况下只有一个特征):
>>> from sklearn.preprocessing import PolynomialFeatures
>>> poly_features = PolynomialFeatures(degree=2, include_bias=False)
>>> X_poly = poly_features.fit_transform(X)
>>> X[0]
array([-0.75275929])
>>> X_poly[0]
array([-0.75275929, 0.56664654])
X_poly
现在包含了X
的原始特征以及该特征的平方。现在我们可以将LinearRegression
模型拟合到这个扩展的训练数据上(图 4-13):
>>> lin_reg = LinearRegression()
>>> lin_reg.fit(X_poly, y)
>>> lin_reg.intercept_, lin_reg.coef_
(array([1.78134581]), array([[0.93366893, 0.56456263]]))
图 4-13。多项式回归模型预测
不错:模型估计,而实际上原始函数是。
请注意,当存在多个特征时,多项式回归能够找到特征之间的关系,这是普通线性回归模型无法做到的。这是因为PolynomialFeatures
还会添加给定次数的所有特征组合。例如,如果有两个特征a和b,PolynomialFeatures
的degree=3
不仅会添加特征a²、a³、b²和b³,还会添加组合ab、a²b和ab²。
警告
PolynomialFeatures(degree=*d*)
将包含n个特征的数组转换为包含(n + d)! / d!n!个特征的数组,其中n!是n的阶乘,等于 1 × 2 × 3 × ⋯ × n。注意特征数量的组合爆炸!
学习曲线
如果进行高次多项式回归,你很可能会比普通线性回归更好地拟合训练数据。例如,图 4-14 将一个 300 次多项式模型应用于前面的训练数据,并将结果与纯线性模型和二次模型(二次多项式)进行比较。请注意,300 次多项式模型在训练实例周围摆动以尽可能接近训练实例。
图 4-14。高次多项式回归
这个高次多项式回归模型严重过拟合了训练数据,而线性模型则欠拟合了。在这种情况下,最能泛化的模型是二次模型,这是有道理的,因为数据是使用二次模型生成的。但通常你不会知道是什么函数生成了数据,那么你如何决定模型应该有多复杂呢?你如何判断你的模型是过拟合还是欠拟合了数据?
在第二章中,您使用交叉验证来估计模型的泛化性能。如果模型在训练数据上表现良好,但根据交叉验证指标泛化能力差,那么您的模型是过拟合的。如果两者表现都不好,那么它是拟合不足的。这是判断模型过于简单或过于复杂的一种方法。
另一种方法是查看学习曲线,这是模型的训练误差和验证误差作为训练迭代的函数的图表:只需在训练集和验证集上定期评估模型,并绘制结果。如果模型无法进行增量训练(即,如果它不支持partial_fit()
或warm_start
),那么您必须在逐渐扩大的训练集子集上多次训练它。
Scikit-Learn 有一个有用的learning_curve()
函数来帮助解决这个问题:它使用交叉验证来训练和评估模型。默认情况下,它会在不断增长的训练集子集上重新训练模型,但如果模型支持增量学习,您可以在调用learning_curve()
时设置exploit_incremental_learning=True
,它将逐步训练模型。该函数返回评估模型的训练集大小,以及每个大小和每个交叉验证折叠的训练和验证分数。让我们使用这个函数来查看普通线性回归模型的学习曲线(参见图 4-15):
from sklearn.model_selection import learning_curve
train_sizes, train_scores, valid_scores = learning_curve(
LinearRegression(), X, y, train_sizes=np.linspace(0.01, 1.0, 40), cv=5,
scoring="neg_root_mean_squared_error")
train_errors = -train_scores.mean(axis=1)
valid_errors = -valid_scores.mean(axis=1)
plt.plot(train_sizes, train_errors, "r-+", linewidth=2, label="train")
plt.plot(train_sizes, valid_errors, "b-", linewidth=3, label="valid")
[...] # beautify the figure: add labels, axis, grid, and legend
plt.show()
图 4-15. 学习曲线
这个模型拟合不足。为了了解原因,首先让我们看看训练误差。当训练集中只有一个或两个实例时,模型可以完美拟合它们,这就是曲线从零开始的原因。但随着新实例被添加到训练集中,模型无法完美拟合训练数据,因为数据存在噪声,而且根本不是线性的。因此,训练数据的误差会上升,直到达到一个平台,在这一点上,向训练集添加新实例不会使平均误差变得更好或更糟。现在让我们看看验证误差。当模型在非常少的训练实例上训练时,它无法正确泛化,这就是为什么验证误差最初相当大的原因。然后,随着模型展示更多的训练示例,它学习,因此验证误差慢慢下降。然而,再次,一条直线无法很好地对数据建模,因此误差最终会达到一个接近另一条曲线的平台。
这些学习曲线是典型的拟合不足模型。两条曲线都达到了一个平台;它们接近且相当高。
提示
如果您的模型对训练数据拟合不足,增加更多的训练样本将无济于事。您需要使用更好的模型或提出更好的特征。
现在让我们看看相同数据上 10 次多项式模型的学习曲线(参见图 4-16):
from sklearn.pipeline import make_pipeline
polynomial_regression = make_pipeline(
PolynomialFeatures(degree=10, include_bias=False),
LinearRegression())
train_sizes, train_scores, valid_scores = learning_curve(
polynomial_regression, X, y, train_sizes=np.linspace(0.01, 1.0, 40), cv=5,
scoring="neg_root_mean_squared_error")
[...] # same as earlier
图 4-16. 10 次多项式模型的学习曲线
这些学习曲线看起来有点像之前的曲线,但有两个非常重要的区别:
-
训练数据上的误差比以前低得多。
-
曲线之间存在差距。这意味着模型在训练数据上的表现明显优于验证数据,这是过拟合模型的标志。然而,如果您使用更大的训练集,这两条曲线将继续接近。
提示
改进过拟合模型的一种方法是提供更多的训练数据,直到验证误差达到训练误差。
正则化线性模型
正如您在第一章和第二章中看到的,减少过拟合的一个好方法是对模型进行正则化(即,约束它):它的自由度越少,过拟合数据的难度就越大。对多项式模型进行正则化的一种简单方法是减少多项式次数。
对于线性模型,通常通过约束模型的权重来实现正则化。我们现在将看一下岭回归、套索回归和弹性网络回归,它们实现了三种不同的约束权重的方式。
岭回归
岭回归(也称为Tikhonov 正则化)是线性回归的正则化版本:一个等于的正则化项被添加到 MSE 中。这迫使学习算法不仅拟合数据,还要尽量保持模型权重尽可能小。请注意,正则化项应该只在训练期间添加到成本函数中。一旦模型训练完成,您希望使用未经正则化的 MSE(或 RMSE)来评估模型的性能。
超参数α控制着您希望对模型进行多少正则化。如果α=0,则岭回归就是线性回归。如果α非常大,则所有权重最终都非常接近零,结果是一条通过数据均值的平坦线。方程 4-8 呈现了岭回归成本函数。⁷
方程 4-8。岭回归成本函数
请注意,偏置项θ[0]不被正则化(总和从i=1 开始,而不是 0)。如果我们将w定义为特征权重的向量(θ[1]到θ[n]),则正则化项等于α(∥ w ∥[2])² / m,其中∥ w ∥[2]表示权重向量的ℓ[2]范数。⁸ 对于批量梯度下降,只需将 2αw / m添加到对应于特征权重的 MSE 梯度向量的部分,而不要将任何内容添加到偏置项的梯度(参见方程 4-6)。
警告
在执行岭回归之前,重要的是对数据进行缩放(例如,使用StandardScaler
),因为它对输入特征的规模敏感。这对大多数正则化模型都是正确的。
图 4-17 显示了在一些非常嘈杂的线性数据上使用不同α值训练的几个岭模型。在左侧,使用普通的岭模型,导致线性预测。在右侧,首先使用PolynomialFeatures(degree=10)
扩展数据,然后使用StandardScaler
进行缩放,最后将岭模型应用于生成的特征:这是带有岭正则化的多项式回归。请注意,增加α会导致更平缓(即,更不极端,更合理)的预测,从而减少模型的方差但增加其偏差。
图 4-17。线性(左)和多项式(右)模型,都具有不同级别的岭正则化
与线性回归一样,我们可以通过计算闭式方程或执行梯度下降来执行岭回归。优缺点是相同的。方程 4-9 显示了闭式解,其中A是(n + 1) × (n + 1) 单位矩阵,⁹除了左上角的单元格为 0,对应于偏置项。
方程 4-9. 岭回归闭式解
以下是如何使用 Scikit-Learn 执行岭回归的闭式解(一种方程 4-9 的变体,使用 André-Louis Cholesky 的矩阵分解技术):
>>> from sklearn.linear_model import Ridge
>>> ridge_reg = Ridge(alpha=0.1, solver="cholesky")
>>> ridge_reg.fit(X, y)
>>> ridge_reg.predict([[1.5]])
array([[1.55325833]])
使用随机梯度下降:¹⁰
>>> sgd_reg = SGDRegressor(penalty="l2", alpha=0.1 / m, tol=None,
... max_iter=1000, eta0=0.01, random_state=42)
...
>>> sgd_reg.fit(X, y.ravel()) # y.ravel() because fit() expects 1D targets
>>> sgd_reg.predict([[1.5]])
array([1.55302613])
penalty
超参数设置要使用的正则化项的类型。指定"l2"
表示您希望 SGD 将正则化项添加到 MSE 成本函数中,等于alpha
乘以权重向量的ℓ[2]范数的平方。这就像岭回归一样,只是在这种情况下没有除以m;这就是为什么我们传递alpha=0.1 / m
,以获得与Ridge(alpha=0.1)
相同的结果。
提示
RidgeCV
类也执行岭回归,但它会自动使用交叉验证调整超参数。它大致相当于使用GridSearchCV
,但它针对岭回归进行了优化,并且运行快得多。其他几个估计器(主要是线性的)也有高效的 CV 变体,如LassoCV
和ElasticNetCV
。
Lasso 回归
最小绝对值收缩和选择算子回归(通常简称为Lasso 回归)是线性回归的另一个正则化版本:就像岭回归一样,它向成本函数添加一个正则化项,但是它使用权重向量的ℓ[1]范数,而不是ℓ[2]范数的平方(参见方程 4-10)。请注意,ℓ[1]范数乘以 2α,而ℓ[2]范数在岭回归中乘以α / m。选择这些因子是为了确保最佳α值与训练集大小无关:不同的范数导致不同的因子(有关更多细节,请参阅Scikit-Learn 问题#15657)。
方程 4-10. Lasso 回归成本函数
图 4-18 显示了与图 4-17 相同的内容,但用 Lasso 模型替换了岭模型,并使用不同的α值。
图 4-18. 线性(左)和多项式(右)模型,都使用不同级别的 Lasso 正则化
Lasso 回归的一个重要特征是它倾向于消除最不重要特征的权重(即将它们设置为零)。例如,图 4-18 中右侧图中的虚线看起来大致是立方形:高次多项式特征的所有权重都等于零。换句话说,Lasso 回归自动执行特征选择,并输出具有少量非零特征权重的稀疏模型。
你可以通过查看图 4-19 来了解这种情况:坐标轴代表两个模型参数,背景轮廓代表不同的损失函数。在左上角的图中,轮廓代表ℓ[1]损失(|θ[1]| + |θ[2]|),随着你靠近任何轴,损失会线性下降。例如,如果你将模型参数初始化为θ[1] = 2 和θ[2] = 0.5,运行梯度下降将等量减少两个参数(如虚线黄线所示);因此θ[2]会先达到 0(因为它最初更接近 0)。之后,梯度下降将沿着槽滚动,直到达到θ[1] = 0(稍微反弹一下,因为ℓ[1]的梯度从不接近 0:对于每个参数,它们要么是-1 要么是 1)。在右上角的图中,轮廓代表套索回归的成本函数(即,MSE 成本函数加上ℓ[1]损失)。小白色圆圈显示了梯度下降优化某些模型参数的路径,这些参数最初设定为θ[1] = 0.25 和θ[2] = -1:再次注意路径如何迅速到达θ[2] = 0,然后沿着槽滚动并最终在全局最优解周围反弹(由红色方块表示)。如果增加α,全局最优解将沿着虚线黄线向左移动,而如果减小α,全局最优解将向右移动(在这个例子中,未正则化 MSE 的最佳参数为θ[1] = 2 和θ[2] = 0.5)。
图 4-19。套索与岭正则化
两个底部图表展示了相同的情况,但使用了ℓ[2]惩罚。在左下角的图中,你可以看到随着我们靠近原点,ℓ[2]损失减少,因此梯度下降直接朝着那个点前进。在右下角的图中,轮廓代表岭回归的成本函数(即,MSE 成本函数加上ℓ[2]损失)。正如你所看到的,随着参数接近全局最优解,梯度变小,因此梯度下降自然减慢。这限制了反弹,有助于岭回归比套索收敛更快。还要注意,当增加α时,最佳参数(由红色方块表示)越来越接近原点,但它们永远不会完全消失。
提示
为了防止在使用套索回归时梯度下降在最后反弹到最优解周围,你需要在训练过程中逐渐减小学习率。它仍然会在最优解周围反弹,但步长会变得越来越小,因此会收敛。
套索成本函数在θ[i] = 0(对于 i = 1, 2, ⋯, n)处不可微,但如果在任何θ[i] = 0 时使用子梯度向量 g¹¹,梯度下降仍然有效。方程 4-11 展示了一个你可以用于套索成本函数的梯度下降的子梯度向量方程。
方程 4-11。套索回归子梯度向量
这里有一个使用Lasso
类的小型 Scikit-Learn 示例:
>>> from sklearn.linear_model import Lasso
>>> lasso_reg = Lasso(alpha=0.1)
>>> lasso_reg.fit(X, y)
>>> lasso_reg.predict([[1.5]])
array([1.53788174])
请注意,您也可以使用SGDRegressor(penalty="l1", alpha=0.1)
。
弹性网回归
弹性网回归是岭回归和套索回归之间的中间地带。正则化项是岭回归和套索回归正则化项的加权和,您可以控制混合比例r。当r=0 时,弹性网等同于岭回归,当r=1 时,它等同于套索回归(方程 4-12)。
方程 4-12。弹性网成本函数
那么何时使用弹性网回归,或者岭回归、套索回归,或者普通线性回归(即没有任何正则化)?通常最好至少有一点点正则化,因此通常应避免普通线性回归。岭回归是一个很好的默认选择,但如果您怀疑只有少数特征是有用的,您应该更喜欢套索或弹性网,因为它们倾向于将无用特征的权重降至零,正如前面讨论的那样。总的来说,相对于套索,弹性网更受青睐,因为当特征数量大于训练实例数量或者多个特征强相关时,套索可能表现不稳定。
这里有一个使用 Scikit-Learn 的ElasticNet
的简短示例(l1_ratio
对应混合比例r):
>>> from sklearn.linear_model import ElasticNet
>>> elastic_net = ElasticNet(alpha=0.1, l1_ratio=0.5)
>>> elastic_net.fit(X, y)
>>> elastic_net.predict([[1.5]])
array([1.54333232])
早停
一种非常不同的正则化迭代学习算法(如梯度下降)的方法是在验证错误达到最小值时停止训练。这被称为早停止。图 4-20 显示了一个复杂模型(在本例中,是一个高次多项式回归模型)在我们之前使用的二次数据集上使用批量梯度下降进行训练。随着时代的变迁,算法学习,其在训练集上的预测误差(RMSE)下降,以及在验证集上的预测误差也下降。然而,一段时间后,验证错误停止下降并开始上升。这表明模型已经开始过拟合训练数据。通过早停止,您只需在验证错误达到最小值时停止训练。这是一种简单而高效的正则化技术,Geoffrey Hinton 称之为“美丽的免费午餐”。
图 4-20。早停止正则化
提示
对于随机梯度下降和小批量梯度下降,曲线不那么平滑,可能很难知道是否已经达到最小值。一个解决方案是只有在验证错误超过最小值一段时间后(当您确信模型不会再有更好的表现时),然后将模型参数回滚到验证错误最小值的点。
这是早停止的基本实现:
from copy import deepcopy
from sklearn.metrics import mean_squared_error
from sklearn.preprocessing import StandardScaler
X_train, y_train, X_valid, y_valid = [...] # split the quadratic dataset
preprocessing = make_pipeline(PolynomialFeatures(degree=90, include_bias=False),
StandardScaler())
X_train_prep = preprocessing.fit_transform(X_train)
X_valid_prep = preprocessing.transform(X_valid)
sgd_reg = SGDRegressor(penalty=None, eta0=0.002, random_state=42)
n_epochs = 500
best_valid_rmse = float('inf')
for epoch in range(n_epochs):
sgd_reg.partial_fit(X_train_prep, y_train)
y_valid_predict = sgd_reg.predict(X_valid_prep)
val_error = mean_squared_error(y_valid, y_valid_predict, squared=False)
if val_error < best_valid_rmse:
best_valid_rmse = val_error
best_model = deepcopy(sgd_reg)
这段代码首先添加多项式特征并缩放所有输入特征,对于训练集和验证集都是如此(代码假定您已将原始训练集分成较小的训练集和验证集)。然后它创建一个没有正则化和较小学习率的SGDRegressor
模型。在训练循环中,它调用partial_fit()
而不是fit()
,以执行增量学习。在每个时代,它测量验证集上的 RMSE。如果低于迄今为止看到的最低 RMSE,则将模型的副本保存在best_model
变量中。这个实现实际上并没有停止训练,但它允许您在训练后返回到最佳模型。请注意,使用copy.deepcopy()
复制模型,因为它同时复制了模型的超参数和学习参数。相比之下,sklearn.base.clone()
只复制模型的超参数。
逻辑回归
正如在第一章中讨论的那样,一些回归算法可以用于分类(反之亦然)。逻辑回归(也称为logit 回归)通常用于估计一个实例属于特定类别的概率(例如,这封电子邮件是垃圾邮件的概率是多少?)。如果估计的概率大于给定阈值(通常为 50%),则模型预测该实例属于该类别(称为正类,标记为“1”),否则预测它不属于该类别(即属于负类,标记为“0”)。这使其成为一个二元分类器。
估计概率
那么逻辑回归是如何工作的呢?就像线性回归模型一样,逻辑回归模型计算输入特征的加权和(加上偏置项),但是不像线性回归模型直接输出结果,它输出这个结果的逻辑(参见方程 4-13)。
方程 4-13。逻辑回归模型估计概率(向量化形式)
逻辑函数 σ(·) 是一个 S 形函数,输出介于 0 和 1 之间的数字。它的定义如 方程式 4-14 和 图 4-21 所示。
方程式 4-14. 逻辑函数
图 4-21. 逻辑函数
逻辑回归模型一旦估计出概率 = hθ,即实例 x 属于正类的概率,它可以轻松地进行预测 ŷ(见 方程式 4-15)。
方程式 4-15. 使用 50% 阈值概率的逻辑回归模型预测
注意到当 t < 0 时,σ(t) < 0.5,当 t ≥ 0 时,σ(t) ≥ 0.5,因此使用默认的 50% 概率阈值的逻辑回归模型会在 θ^⊺ x 为正时预测为 1,为负时预测为 0。
注意
得分 t 通常被称为 对数几率。这个名字来自于对数几率函数的定义,即 logit(p) = log(p / (1 – p)),它是逻辑函数的反函数。实际上,如果计算估计概率 p 的对数几率,你会发现结果是 t。对数几率也被称为 对数几率比,因为它是正类估计概率与负类估计概率之间的比值的对数。
训练和成本函数
现在你知道逻辑回归模型如何估计概率并进行预测了。但是它是如何训练的呢?训练的目标是设置参数向量 θ,使模型为正实例(y = 1)估计出高概率,为负实例(y = 0)估计出低概率。这个想法被 方程式 4-16 中的成本函数所捕捉,针对单个训练实例 x。
方程式 4-16. 单个训练实例的成本函数
这个成本函数是有意义的,因为当 t 接近 0 时,–log(t) 会变得非常大,所以如果模型为正实例估计出接近 0 的概率,成本会很大,如果模型为负实例估计出接近 1 的概率,成本也会很大。另一方面,当 t 接近 1 时,–log(t) 接近 0,所以如果负实例的估计概率接近 0,或者正实例的估计概率接近 1,成本会接近 0,这正是我们想要的。
整个训练集上的成本函数是所有训练实例的平均成本。它可以用一个称为对数损失的单个表达式来表示,如方程 4-17 所示。