《Hands-on Machine Learning with Scikit-Learn, Keras and TensorFlow, Third Edition》 全书第三章:分类

Geron教授所著的该书第一章中已经简要介绍了监督学习任务是回归(预测数值)和分类(预测类别)。在第二章中探索了一个预测加州地区房价的回归任务,并测试了如线性回归、决策树和随机森林等算法。现在我们将注意力转向分类系统。

MNIST 数据集

MNIST 数据集是一组美国高中生及人口普查局员工手写的70,000个数字图像。每个图像都标有它代表的数字。这个集合已经在机器学习领域被大量研究以至于通常称之为机器学习的“hello world”。以下代码为使用 Scikit-Learn 获取 MNIST 数据集的方法:

1
2
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数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
>>> 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,)

共70000张图片,每张图片有784=28*28个像素。使用 Matplotlib 的 imshow() 函数,令 cmap=“binary” 来获取灰度颜色图像:

1
2
3
4
5
6
7
8
9
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()

这看起来像一个5,实际上标签的结果也印证了这一点:

1
2
>>> y[0]
'5'

分离训练集与测试集:

1
X_train, X_test, y_train, y_test = X[:60000], X[60000:], y[:60000], y[60000:]

训练二元分类器

现在让我们简化这个问题,只尝试识别一个数字——例如,数字5。这个“5检测器”将是二元分类器的一个例子,能够区分两个类别,5和非5。首先,我们将为这个分类任务创建目标向量:

1
2
y_train_5 = (y_train == '5')
y_test_5 = (y_test == '5')

现在让我们选择一个分类器并训练它。一个好的起点是使用ScikitLearn的随机梯度下降(Stochastic Gradient Descent, SGD)分类器,SGDClassifier类。这个分类器能够有效地处理非常大的数据集。这部分是因为SGD独立地处理训练实例,每次处理一个,这也使得SGD非常适合在线学习。让我们创建一个SGDClassifier并在整个训练集上训练它:

1
2
3
from sklearn.linear_model import SGDClassifier
sgd_clf = SGDClassifier(random_state=42)
sgd_clf.fit(X_train, y_train_5)

现在我们可以用它来检测数字5的图像:

1
2
>>> sgd_clf.predict([some_digit])
array([ True])

分类器猜测该图像代表5(True)。看起来它在这个特殊情况下猜对了!现在,我们来评估一下这个模型的性能。

模型性能评估

评估分类器通常比评估回归器要棘手得多,所以我们将在本章的大部分时间里讨论这个话题。有许多可用的绩效衡量标准:

使用交叉验证测量精度

评估模型的一个好方法是使用交叉验证函数 cross_val_score() 来评估我们的 SGDClassifier 模型,使用三次的 k-fold 交叉验证。k-fold 交叉验证意味着将训练集分成k个折叠(在本例中为3个),然后训练模型 k 次,每次进行不同的折叠进行评估:

1
2
3
>>> 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):

1
2
3
4
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_

你能猜出这个模型的精度吗?让我们来看看:

1
2
>>> 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%的时间是正确的。这说明了为什么准确率通常不是分类器的首选性能度量,特别是当您处理倾斜的数据集。评估分类器性能的更好方法是查看混淆矩阵。

混淆矩阵(Confusion Matrix, CM)

混淆矩阵的一般思想是对于所有A/B对计算A类实例被分类为B类的次数。例如,要知道分类器混淆8和0图像的次数,您可以查看混淆矩阵的第8行第0列。要计算混淆矩阵,首先需要有一组预测,以便将它们与实际目标进行比较。您可以对测试集进行预测,但是现在最好不要碰它(请记住,您只希望在项目的最后使用测试集,一旦您有了准备启动的分类器)。相反,你可以使用 cross_val_predict() 函数:

1
2
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交叉验证,但它不是返回评估分数,而是返回对每个测试 fold 所做的预测。这意味着您可以对训练集中的每个实例进行干净的预测(这里的“干净”是指“样本外”:模型对训练期间从未见过的数据进行预测)。现在可以使用 confusion_matrix() 函数获得混淆矩阵了。只需将目标类 y_train_5 和预测类 y_train_pred 传递给它:

1
2
3
4
5
>>> 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(真阳性)。一个完美的分类器只会有真正和真负,所以它的混淆矩阵只会在它的主对角线上(从左上到右下)有非零值:

1
2
3
4
>>> 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]])

准确率和召回率

混淆矩阵为您提供了大量信息,但有时您可能更喜欢更简洁的度量。一个有趣的问题是阳性预测的准确性;这被称为分类器的准确率(Precision)。

$$ {\rm Precision} = \frac{\rm TP}{\rm TP+FP} \tag{3-1}\label{3-1}$$

其中,TP 表示真阳性数,FP 表示假阳性数。

获得完美准确率的一个简单方法是创建一个分类器,它总是做出负面预测,除了对它最自信的实例进行一个单一的正面预测。如果这一个预测是正确的,那么分类器有100%的精度(精度= 1/1 = 100%)。显然,这样的分类器不是很有用,因为它会忽略除一个阳性实例外的所有实例。因此,精度通常与另一个名为召回率的指标一起使用,也称为灵敏度或真阳性率(True Position Rate, TPR):这是分类器正确检测到的阳性实例的比率。

$$ {\rm Recall} = \frac{\rm TP}{\rm TP+FN} \tag{3-2}\label{3-2}$$

其中,FN 是假阴性的数量。

图 3-3: 图示的混淆矩阵显示了真阴性(左上)、假阳性(右上)、假阴性(左下)和真阳性(右下)的示例。

简单的说,准确率代表用户得到模型判断为真的样本中确实是真的比率,召回率代表确实为真的样本被模型判断为真的比率。准确率与召回率均较高代表模型达到了优秀的性能,只有单一指标优秀不代表模型性能好。

Scikit-Learn 提供了几个函数来计算分类器指标,包括准确率和召回率:

1
2
3
4
5
>>> 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。将准确率和召回率组合成一个称为 ${\rm F}_1$ 分数的指标通常很方便,特别是当您需要一个指标来比较两个分类器时。F1分数是准确率和召回率的调和平均值。常规均值对所有值一视同仁,而调和均值对低值给予更多的权重。因此,只有在召回率和准确率都很高的情况下,分类器才会得到很高的${\rm F}_1$分数。

$$ {\rm F}_1 = \frac{2}{\frac{1}{\rm Precision}+\frac{1}{\rm Recall}} = \frac{\rm TP}{\rm TP+\frac{FN+FP}{2}} \tag{3-3}\label{3-3} $$

要计算 ${\rm F}_1$分数只需调用 f1_score() 函数:

1
2
3
>>> from sklearn.metrics import f1_score 
>>> f1_score(y_train_5, y_train_pred)
0.7325171197343846

F1分数倾向于具有相似精度和召回率的分类器。这并不总是你想要的:在某些情况下,你最关心的是准确性,而在其他情况下,你真正关心的是召回率。例如,如果你训练一个分类器来检测对孩子安全的视频,你可能更喜欢一个分类器,它会拒绝许多好的视频(低召回率),但只保留安全的视频(高精度),而不是一个具有更高召回率的分类器,但会让一些非常糟糕的视频出现在你的产品中(在这种情况下,你甚至可能想要添加一个人工管道来检查分类器的视频选择)。另一方面,假设你训练一个分类器来检测监视图像中的商店扒手:如果你的分类器只有30%的准确率,只要它有99%的召回率,这可能是好的(当然,保安会得到一些错误的警报,但几乎所有的商店扒手都会被抓住)。不幸的是,你不能两全其美:提高精确度会降低召回率,反之亦然。这被称为精确度/召回率权衡(precision/recall trade-off)。

精确度/召回率权衡(precision/recall trade-off)

为了理解这种权衡,让我们看看 SGDClassifier 是如何做出分类决策的。对于每个实例,它根据决策函数计算一个分数。如果该分数大于阈值,则将实例分配给正类;否则它会把它赋值给负类。图 3-4 给出了分数从左边最低到右边最高的几个数字。假设决策阈值位于中间的箭头(在两个5之间):你会发现在该值的右边有4个真阳性,和1个假阳性。但在6个真实的5中,分类器只检测到4个。如果你提高阈值,假阳性(6)成为一个真正的阴性,从而增加的精度(在本例中高达100%),但一个真正的5的成为假阴性,召回率降低到50%。相反,降低阈值会增加召回率,降低准确率。

图 3-4: 精度/召回权衡:图像根据分类器得分进行排序,高于所选决策阈值的图像被认为是正面的;阈值越高,召回率越低,但(通常)精度越高

Scikit-Learn 不能让你直接设置阈值,但它可以让你访问它用来做出预测的决策分数。你可以调用分类器的predict() 方法,而不是调用它的 decision_function() 方法,它会为每个实例返回一个分数,然后使用你想要基于这些分数进行预测的任何阈值:

1
2
3
4
5
6
>>>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() 方法相同的结果。让我们提高阈值:

1
2
3
4
>>>threshold = 3000 
>>>y_some_digit_pred = (y_scores > threshold)
>>>y_some_digit_pred
array([False])

这证实了提高阈值会降低召回。图像实际上代表一个5,当阈值为0时,分类器会检测到它,但当阈值增加到3000时,它会错过它。如何决定使用哪个阈值?首先,使用cross_val_predict() 函数获取训练集中所有实例的分数,但这次指定要返回决策分数而不是预测分类:

1
y_scores = cross_val_predict(sgd_clf, X_train, y_train_5, cv=3, method="decision_function")

有了这些分数,使用 precision_recall_curve() 函数来计算所有可能阈值的精度和召回率(该函数添加的最后精度为0,最后召回率为1,对应于无限阈值),最后,使用Matplotlib绘制精度和召回率作为阈值的函数(图3-5)。让我们显示我们选择的阈值3000:

1
2
3
4
5
6
7
8
9
from sklearn.metrics import precision_recall_curve

precisions, recalls, thresholds = precision_recall_curve(y_train_5, y_scores)

plt.plot(thresholds, precisions[:-1], "b--", label="Precision", linewidth=2)
plt.plot(thresholds, recalls[:-1], "g-", label="Recall", linewidth=2)
plt.vlines(thresholds, 0, 1.0, "k", "dotted", label="threshold")
[...] # beautify the figure: add grid, legend, axis, labels, and circles
plt.show()

图 3-5:精确率和召回率对决策阈值的影响

此外该图还呈现了一个有趣的现象,当阈值提高时,准确率偶尔会下降,而召回率一定随阈值提高而单调下降。

在3000这个阈值下,准确率接近90%,召回率约为50%。另一种选择良好的精度/召回率权衡的方法是直接绘制精度与召回率的关系,如图3-6所示:

图 3-6:准确率 vs 召回率

你可以看到,准确度在 80% 的召回率左右开始急剧下降。您可能希望在下降之前选择一个准确率/召回率权衡-例如,在60% 左右召回。当然,选择取决于您的项目。假设你的目标是90% 的准确率,你可以用第一张图找到你需要使用的阈值,但这不是很精确。或者,您可以搜索至少提供90%精度的最低阈值。为此,您可以使用 NumPy 数组的 argmax() 方法。这将返回最大值的第一个索引,在本例中意味着第一个 True 值。

受试者操作特征(ROC)曲线

受试者操作特征(Receiver Operating Characteristic, ROC)曲线是二值分类器的另一个常用工具。它与精确率/召回率曲线非常相似,但ROC曲线不是绘制精确率与召回率的关系,而是绘制真阳性率(召回率的另一个名称)与假阳性率(False Positive Rate, FPR)的关系。FPR(也称为fall-out)是指False事件被错误地归类为True事件的比率。它等于1-真阴性率(True Negative Rate, TNR),即正确归类为阴性的阴性实例的比率。TNR也被称为特异性。因此,ROC曲线绘制敏感性(召回率)与1-特异性。为了绘制ROC曲线,首先使用roc_curve() 函数计算各种阈值的 TPR 和 FPR:

1
2
from sklearn.metrics import roc_curve
fpr, tpr, thresholds = roc_curve(y_train_5, y_scores)

然后,您可以使用 Matplotlib 绘制FPR与TPR的关系。下面的代码生成图3-7中的图。为了找到对应于90%精度的点,我们需要查找所需阈值的索引:

1
2
3
4
5
6
7
8
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曲线;一个好的分类器会尽可能远离那条线(朝向左上角)。比较分类器的一种方法是测量曲线下面积(Area Under the Curve, AUC)。一个完美的分类器的ROC AUC等于1,而一个纯粹随机的分类器的ROC AUC等于0.5。Scikit-Learn 提供了一个函数来估计ROC AUC:

1
2
3
>>> from sklearn.metrics import roc_auc_score 
>>> roc_auc_score(y_train_5, y_scores)
0.9604938554008616