《机器学习实战:基于Scikit-Learn、Keras和TensorFlow第2版》-学习笔记(3)

第三章 分类

· Hands-On Machine Learning with Scikit-Learn, Keras, and TensorFlow, 2nd Edition, by Aurélien Géron (O’Reilly). Copyright 2019 Aurélien Géron, 978-1-492-03264-9.
· 环境:Anaconda(Python 3.8) + Pycharm
· 学习时间:2022.04.01~2022.04.02

最常见的有监督学习任务包括回归任务(预测值)和分类任务(预测类)。
第2章尝试了一个回归任务——预测住房价格,用到了线性回归、决策树和随机森林等算法。
本章将尝试做一个分类任务。内容大致如下:

3.1 MNIST

本章将使用MNIST数据集,这是一组由美国高中生和人口调查局员工手写的70000个数字的图片。每张图片都用其代表的数字标记。
这个数据集被广为使用,因此也被称作是机器学习领域的“Hello World”:但凡有人想到了一个新的分类算法,都会想看看在MNIST上的执行结果。
因此只要是学习机器学习的人,早晚都要面对MNIST。

Scikit-Learn提供了许多助手功能来帮助你下载流行的数据集。MNIST也是其中之一。下面是获取MNIST数据集的代码:

# 获取MNIST数据集
from sklearn.datasets import fetch_openml
mnist = fetch_openml('mnist_784', version=1, as_frame=False)
# 这里加入as_frame=False是因为按书上写法后面步骤出现了问题:“查看数据集中的一个数字”显示不出来。
# 原因可能是:https://www.cnpython.com/qa/1394204
print(mnist.keys())  # 查看所有的键
# 读取速度有点慢,查了一下网上直接下载文件的方法: https://www.jianshu.com/p/d282bce1a999
# 注释写道:“默认情况下,Scikit-Learn将下载的数据集缓存在$HOME/scikit_learn_data目录下。”所以,之后的调用应该都会快一些。

Scikit-Learn加载的数据集通常具有类似的字典结构,包括:

  • DESCR键: 描述数据集;
  • data键: 包含一个数组,每个实例为一行,每个特征为一列;
  • target键: 包含一个带有标记的数组.
# 查看MNIST数据集中的数组
X, y = mnist["data"], mnist["target"]
print(X.shape)
print(y.shape)

共有7万张图片(70000),每张图片有784个特征(因为图片是28×28像素(28*28=784),每个特征代表了一个像素点的强度,从0(白色)到255(黑色))。
先来看看数据集中的一个数字,只需要随手抓取一个实例的特征向量,将其重新形成一个28×28数组,然后使用Matplotlib的imshow()函数将其显示出来:

# 查看数据集中的一个数字
import matplotlib.pyplot as plt
some_digit = X[1]
some_digit_image = some_digit.reshape(28, 28)
plt.imshow(some_digit_image, cmap="binary")
plt.axis("off")
plt.show()  # 查看数据集中的一个数字
print(y[1])  # 查看该数字的标签
# 要注意:标签是字符,大部分机器学习算法希望是都是数字,所以要把y转换成整数:
import numpy as np
y = y.astype(np.uint8)

在开始深入研究这些数据之前,你还是应该先创建一个测试集,并将其放在一边。
事实上,MNIST数据集已经分成训练集(前6万张图片)和测试集(最后1万张图片)

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

最后,我们将训练集数据混洗。

  • 这样能保证交叉验证时所有的折叠都差不多(你肯定不希望某个折叠丢失一些数字)

  • 此外,有些机器学习算法对训练实例的顺序敏感,如果连续输入许多相似的实例,可能导致执行性能不佳。给数据集混洗正是为了确保这种情况不会发生

3.2 训练二元分类器

作为初学者,先简化问题,只尝试识别一个数字,比如数字5。
那么这个 “数字5检测器”就是一个二元分类器的示例,它只能区分两个类别:5和非5。先为此分类任务创建目标向量:

y_train_5 = (y_train == 5)  # y_train_5/y_test_5中保存的是一系列True或False值
y_test_5 = (y_test == 5)  # y_train_5/y_test_5中的True表示“是数字5”,False表示“不是数字5”

接着挑选一个分类器并开始训练。一个好的初始选择是随机梯度下降(SGD)分类器,使用Scikit-Learn的SGDClassifier类即可。
这个分类器的优势是能够有效处理非常大型的数据集。这部分是因为SGD独立处理训练实例,一次一个(这也使得SGD非常适合在线学习),稍后我们将会看到。

# 此时先创建一个SGDClassifier并在整个训练集上进行训练:
from sklearn.linear_model import SGDClassifier
sgd_clf = SGDClassifier(random_state=42)  # 定义1个SGDClassifier分类器,并设置参数random_state=42
# 因为SGDClassifier在训练时是完全随机的(因此得名“随机”),如果你希望得到可复现的结果,需要设置参数random_state。
sgd_clf.fit(X_train, y_train_5)  # 用数据集去训练这个分类器

使用它来检测图片是否是数字5:(some_digit是上面查看的X[0]的数字图片)

print(sgd_clf.predict([some_digit]))  # 输出将是“True”

分类器猜这个图像代表5(True)。看起来这次它猜对了!那么,下面评估一下这个模型的性能。

3.3 性能测量

评估分类器比评估回归器要困难得多,因此本章将用很多篇幅来讨论这个主题,同时会涉及许多性能考核的方法。

3.3.1 使用交叉验证测量准确率

正如第2章所述,交叉验证是一个评估模型的好办法。

(1)for循环实现交叉验证

相比于Scikit-Learn提供cross_val_score()这一类交叉验证的函数,有时你可能希望自己能控制得多一些。
在这种情况下,你可以自行实现交叉验证,操作也简单明了。

from sklearn.model_selection import StratifiedKFold
from sklearn.base import clone
# 使用交叉验证测量准确率
skfolds = StratifiedKFold(n_splits=3, shuffle=True, random_state=42)  # 定义折叠的参数:3次,随机种子是42,确保可复现
# 原书程序运行时出错,提示说要加上shuffle=True
for train_index, test_index in skfolds.split(X_train, y_train_5):
    # 每个折叠由StratifiedKFold执行分层抽样产生,其所包含的各个类的比例符合整体比例。
    clone_clf = clone(sgd_clf)  # 复制SGDClassifier分类器。每次迭代都创建一个副本,然后用测试集进行预测,每次互相不影响
    X_train_folds = X_train[train_index]  # 用迭代的train_index/test_index划分本次的数据集和测试集
    y_train_folds = y_train_5[train_index]
    X_test_fold = X_train[test_index]
    y_test_fold = y_train_5[test_index]
    clone_clf.fit(X_train_folds, y_train_folds)  # 用数据训练副本分类器
    y_pred = clone_clf.predict(X_test_fold)  # 用分类器对本次的测试集进行预测
    n_correct = sum(y_pred == y_test_fold)
    print(n_correct / len(y_pred))  # 分别输出:0.9502, 0.96565, 0.96495
(2)cross_val_score函数实现交叉验证

现在,用cross_val_score()函数来评估SGDClassifier模型,采用K-折交叉验证法(3个折叠)。
记住,K-折交叉验证的意思是将训练集分解成K个折叠(在本例中,为3折),然后每次留其中1个折叠进行预测,剩余的折叠用来训练(参见第2章)

from sklearn.model_selection import cross_val_score
cross_val_score_result = cross_val_score(sgd_clf, X_train, y_train_5, cv=3, scoring="accuracy")
print(cross_val_score_result)

所有折叠交叉验证的准确率(正确预测的比率)超过93%?看起来挺神奇的,是吗?

(3)准确率对比

在你开始激动之前,我们来定义一个蠢笨的分类器Never5Classifier,它将每张图都分类成“非5”:

from sklearn.base import BaseEstimator


class Never5Classifier(BaseEstimator):
    def fit(self, X, y=None):
        return self
    
    def predict(self, X):
        return np.zeros((len(X), 1), dtype=bool)

能猜到这个模型的准确率吗?我们看看:

never_5_clf = Never5Classifier()
cross_val_score_result = cross_val_score(never_5_clf, X_train, y_train_5, cv=3, scoring="accuracy")
print(cross_val_score_result)  # 输出 [0.91125, 0.90855, 0.90915]

没错,准确率同样超过90%!
这是因为只有大约10%的图片是数字5,所以如果你猜一张图不是5,90%的概率你都是正确的,简直超越了大预言家!
这说明准确率通常无法成为分类器的首要性能指标,特别是当你处理有偏数据集时(即某些类比其他类更为频繁)。

3.3.2 混淆矩阵

评估分类器性能的更好方法是混淆矩阵,其总体思路就是统计A类别实例被分成为B类别的次数。
混淆矩阵中的行表示实际类别,列表示预测类别。

例如,要想知道分类器将数字3和数字5混淆多少次,只需要通过混淆矩阵的第5行第3列来查看。

(1)输出混淆矩阵

要计算混淆矩阵,需要先有一组预测才能将其与实际目标进行比较。当然,可以通过测试集来进行预测,但是现在先不要动它
(测试集最好留到项目的最后,准备启动分类器时再使用)。作为替代,可以使用cross_val_predict()函数:

与cross_val_score()函数一样,cross_val_predict()函数同样执行K-折交叉验证,但返回的不是评估分数,而是每个折叠的预测。这意味着对于每个实例都可以得到一个干净的预测(“干净”的意思是模型预测时使用的数据在其训练期间从未见过)。

from sklearn.model_selection import cross_val_predict
y_train_pred = cross_val_predict(sgd_clf, X_train, y_train_5, cv=3)  # 得到交叉验证的预测结果

现在可以使用confusion_matrix()函数来获取混淆矩阵了。只需要给出目标(实际)类别(y_train_5)和预测类别(y_train_pred)即可:

from sklearn.metrics import confusion_matrix
SGD_confusion_matrix = confusion_matrix(y_train_5, y_train_pred)  # 获取混淆矩阵
print(SGD_confusion_matrix)
# 输出[53892, 687],
#    [ 1891, 3530]

本例中第一行表示所有“非5”(负类)的图片中:53 057张被正确地分为“非5”类别(真负类),1522张被错误地分类成了“5”(假正类);第二行表示所有“5”(正类)的图片中:1325张被错误地分为“非5”类别(假负类),4096张被正确地分在了“5”这一类别(真正类)。
一个完美的分类器只有真正类和真负类,所以它的混淆矩阵只会在其对角线(左上到右下)上有非零值:

y_train_perfect_predictions = y_train_5  # pretend we reached perfection(假设我们达到了完美<把真实数据当做预测数据>)
print(confusion_matrix(y_train_5, y_train_perfect_predictions))
# 输出[54579, 0],
#    [ 0, 5421]

混淆矩阵能提供大量信息,但有时你可能希望指标更简洁一些。

(2)评价指标

正类预测的准确率是一个有意思的指标,它也称为分类器的精度。TP是真正类的数量,FP是假正类的数量。

精 度 = T P T P + F P 精度 = \frac{TP}{TP + FP} =TP+FPTP

做一个单独的正类预测,并确保它是正确的,就可以得到完美精度(精度=1/1=100%)。但这没什么意义,因为分类器会忽略这个正类实例之外的所有内容。
因此,精度通常与另一个指标一起使用,这个指标就是召回率,也称为灵敏度或者真正类率:
它是分类器正确检测到的正类实例的比率。FN是假负类的数量。

召 回 率 = T P T P + F N 召回率 = \frac{TP}{TP + FN} =TP+FNTP

3.3.3 精度、召回率和F1-score

Scikit-Learn提供了计算多种分类器指标的函数,包括精度和召回率:

from sklearn.metrics import precision_score, recall_score
print(precision_score(y_train_5, y_train_pred))  # 输出:0.8370879772350012
print(recall_score(y_train_5, y_train_pred))  # 输出:0.6511713705958311

现在再看,这个5-检测器看起来并不像它的准确率那么光鲜亮眼了。当它说一张图片是5时,只有72.9%的概率是准确的,并且也只有75.6%的数字5被它检测出来了。
因此我们可以很方便地将精度和召回率组合成一个单一的指标,称为F1分数。当你需要一个简单的方法来比较两种分类器时,这是个非常不错的指标。
F1分数是精度和召回率的谐波平均值

正常的平均值平等对待所有的值,而谐波平均值会给予低值更高的权重。因此,只有当召回率和精度都很高时,分类器才能得到较高的F1分数。

F 1 − s c o r e = 2 1 精 度 + 1 召 回 率 = 2 ∗ 精 度 ∗ 召 回 率 精 度 + 召 回 率 = T P T P + F N + F P 2 F1-score = \frac{2}{\frac{1}{精度} + \frac{1}{召回率}} = 2 * \frac{精度*召回率}{精度+召回率} = \frac{TP}{TP + \frac{FN+FP}{2}} F1score=1+12=2+=TP+2FN+FPTP

要计算F1分数,只需要调用f1_score()即可:

from sklearn.metrics import f1_score
print(f1_score(y_train_5, y_train_pred))

F1分数对那些具有相近的精度和召回率的分类器更为有利。这不一定能一直符合你的期望:
在某些情况下,你更关心的是精度,而另一些情况下,你可能真正关心的是召回率。

例如,假设你训练一个分类器来检测儿童可以放心观看的视频,那么你可能更青睐那种拦截了很多好视频(低召回率),
但是保留下来的视频都是安全(高精度)的分类器,而不是召回率虽高,但是在产品中可能会出现一些非常糟糕的视频的分类器(这种情况下,
你甚至可能会添加一个人工流水线来检查分类器选出来的视频)。反过来说,如果你训练一个分类器通过图像监控来检测小偷:
你大概可以接受精度只有30%,但召回率能达到99%(当然,安保人员会收到一些错误的警报,但是几乎所有的窃贼都在劫难逃)。

3.3.4 精度/召回率权衡(如何设置阈值)

遗憾的是,鱼和熊掌不可兼得,你不能同时增加精度又减少召回率,反之亦然。这称为精度/召回率权衡。
要理解这个权衡过程,我们来看看SGDClassifier如何进行分类决策。

对于每个实例,它会基于决策函数计算出一个分值,如果该值大于阈值,则将该实例判为正类,否则便将其判为负类。
现在,如果提高阈值,假正类变成了真负类,因此精度得到提升,但是一个真正类变成一个假负类,召回率降低至50%。
反之,降低阈值则会在增加召回率的同时降低精度。
Scikit-Learn不允许直接设置阈值,但是可以访问它用于预测的决策分数。不是调用分类器的predic()方法,而是调用decision_function()方法,
这种方法返回每个实例的分数,然后就可以根据这些分数,使用任意阈值进行预测了

# 调用decision_function设置阈值进行判断
y_scores = sgd_clf.decision_function([some_digit])  # 返回some_digit的分数到y_scores里面
print(y_scores)  # 输出:2164.22030239
threshold = 0  # 阈值设为0
y_some_digit_pred1 = (y_scores > threshold)
threshold = 8000  # 阈值设为8000
y_some_digit_pred2 = (y_scores > threshold)
print(y_some_digit_pred1, '\n', y_some_digit_pred2)  # 分别输出True和False

这证明了提高阈值确实可以降低召回率。
那么要如何决定使用什么阈值呢?首先,使用cross_val_predict()函数获取训练集中所有实例的分数,但是这次需要它返回的是决策分数而不是预测结果:
有了这些分数,可以使用precision_recall_curve()函数来计算所有可能的阈值的精度和召回率:

# 判断使用什么阈值更合适
y_scores = cross_val_predict(sgd_clf, X_train, y_train_5, cv=3, method="decision_function")  # 获取训练集中所有实例的分数
from sklearn.metrics import precision_recall_curve
precisions, recalls, thresholds = precision_recall_curve(y_train_5, y_scores)  # 计算所有可能的阈值下的精度和召回率
# precision_recall_curve计算不同概率阈值的精确召回对(注意:此实现仅限于二进制分类任务),分别返回precisions, recalls, thresholds

最后,使用Matplotlib绘制精度和召回率相对于阈值的函数图:

# 绘制制精度和召回率相对于阈值的函数图
import matplotlib as mpl


def plot_precision_recall_vs_threshold(precisions, recalls, thresholds):
    plt.style.use('seaborn')  # 指定绘画风格
    mpl.rcParams["font.sans-serif"] = ["SimHei"]  # 指定字体为SimHei,用于显示中文,如果Ariel,中文会乱码
    mpl.rcParams["axes.unicode_minus"] = False  # 用来正常显示负号
    plt.plot(thresholds, precisions[:-1], "b--", label="Precision")  # 一条阈值-精度曲线
    plt.plot(thresholds, recalls[:-1], "g-", label="Recall")  # 一条阈值-召回率曲线
    plt.xlabel('阈值', fontsize=20)  # 添加x坐标轴标签
    plt.legend(fontsize=18)  # 加图例


plot_precision_recall_vs_threshold(precisions, recalls, thresholds)
plt.show()

你可能会感到好奇,为什么在图3-4中精度曲线比召回率曲线要崎岖一些?原因在于,当你提高阈值时,精度有时也有可能会下降(尽管总体趋势是上升的)。
另一种找到好的精度/召回率权衡的方法是直接绘制精度和召回率的函数图。

# 绘制Precision-Recall曲线
from sklearn.metrics import PrecisionRecallDisplay
pr_display = PrecisionRecallDisplay(precision=precisions, recall=recalls).plot()
plt.show()

假设你决定将精度设为90%。查找图并发现需要设置8000的阈值。更精确地说,你可以搜索到能提供至少90%精度的最低阈值
(np.argmax()会给你最大值的第一个索引,在这种情况下,它表示第一个True值):

threshold_90_precision = thresholds[np.argmax(precisions >= 0.90)]
print(threshold_90_precision)  # 输出:3370.0194991439557

要进行预测(现在是在训练集上),除了调用分类器的predict()方法,也可以运行这段代码:

y_train_pred_90 = (y_scores >= threshold_90_precision)
print(y_train_pred_90)

检查一下这些预测结果的精度和召回率:

y_train_pred_90_p_score = precision_score(y_train_5, y_train_pred_90)
y_train_pred_90_r_score = recall_score(y_train_5, y_train_pred_90)
print('精度:', y_train_pred_90_p_score, '\n', '召回率:', y_train_pred_90_r_score)

现在你有一个90%精度的分类器了(或者足够接近)!如你所见,创建任意一个你想要的精度的分类器是相当容易的事情:
只要阈值足够高即可!然而,如果召回率太低,精度再高,其实也不怎么有用!
如果有人说:“我们需要99%的精度。”你就应该问:“召回率是多少?”

3.3.5 ROC曲线

还有一种经常与二元分类器一起使用的工具,叫作受试者工作特征曲线(简称ROC)。

ROC与精度/召回率曲线非常相似,但绘制的不是精度和召回率,而是真正类率(召回率的另一名称)和假正类率(FPR)。
FPR是被错误分为正类的负类实例比率。它等于1减去真负类率(TNR),后者是被正确分类为负类的负类实例比率,也称为特异度。
因此,ROC曲线绘制的是灵敏度(召回率)和(1-特异度)的关系

要绘制ROC曲线,首先需要使用roc_curve()函数计算多种阈值的TPR和FPR, 然后再使用Matplotlib绘制FPR对TPR的曲线。

# 使用roc_curve()函数计算多种阈值的TPR和FPR
from sklearn.metrics import roc_curve
fpr, tpr, thresholds = roc_curve(y_train_5, y_scores)


# 使用Matplotlib绘制FPR对TPR的曲线。
def plot_roc_curve(fpr, tpr, label=None):
    plt.style.use('seaborn')  # 指定绘画风格
    mpl.rcParams["font.sans-serif"] = ["SimHei"]  # 指定字体为SimHei,用于显示中文,如果Ariel,中文会乱码
    mpl.rcParams["axes.unicode_minus"] = False  # 用来正常显示负号
    plt.plot(fpr, tpr, linewidth=2, label=label)
    plt.plot([0, 1], [0, 1], 'k--')  # Dashed diagonal
    plt.xlabel('假正率', fontsize=18)  # 添加x坐标轴标签
    plt.ylabel('真正率(召回率)', fontsize=18)  # 添加x坐标轴标签


plot_roc_curve(fpr, tpr)
plt.show()

同样这里再次面临一个折中权衡:召回率(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_forSGD = roc_auc_score(y_train_5, y_scores)
print(roc_auc_score_forSGD)

由于ROC曲线与精度/召回率(PR)曲线非常相似,因此你可能会问如何决定使用哪种曲线。
有一个经验法则是,当正类非常少见或者你更关注假正类而不是假负类时,应该选择PR曲线,反之则是ROC曲线

例如,看前面的ROC曲线图(以及ROC AUC分数),你可能会觉得分类器真不错。但这主要是因为跟负类(非5)相比,正类(数字5)的数量真的很少。
相比之下,PR曲线清楚地说明分类器还有改进的空间(曲线还可以更接近左上角)。

3.3.6 实验(RandomForestClassifier分类器的ROC AUC分数)

现在我们来训练一个RandomForestClassifier分类器,并比较它和SGDClassifier分类器的ROC曲线和ROC AUC分数。

首先,获取训练集中每个实例的分数。
但是由于它的工作方式不同,RandomForestClassifier类没有decision_function()方法,相反,它有dict_proba()方法。

Scikit-Learn的分类器通常都会有这两种方法中的一种(或两种都有)。

dict_proba()方法会返回一个数组,其中每行代表一个实例,每列代表一个类别,意思是某个给定实例属于某个给定类别的概率
(例如,这张图片有70%的可能是数字5)

# 测试随机森林模型的ROC和AUC
from sklearn.ensemble import RandomForestClassifier
forest_clf = RandomForestClassifier(random_state=42)  # 定义1个随机森林分类器
y_probas_forest = cross_val_predict(forest_clf, X_train, y_train_5, cv=3, method="predict_proba")  # 交叉验证
# roc_curve()函数需要标签和分数,但是我们不提供分数,而是提供类概率。我们直接使用正类的概率作为分数值:
y_scores_forest = y_probas_forest[:, 1]  # score = proba of positive class
fpr_forest, tpr_forest, thresholds_forest = roc_curve(y_train_5, y_scores_forest)  # 把分数返回到各个参数
# 绘制ROC曲线来看看对比结果:
plt.plot(fpr, tpr, "b:", label="SGD")  # 传入SGD模型的的分数及标签
plot_roc_curve(fpr_forest, tpr_forest, "Random Forest")  # 传入Random Forest模型的分数及标签
plt.legend(loc="lower right")  # 显示标签并定义位置
print('\n\ntest')
plt.show()
# 计算随机森林的ROC AUC分数
RandomForest_roc_auc_score = roc_auc_score(y_train_5, y_scores_forest)
print(RandomForest_roc_auc_score)

RandomForestClassifier的ROC曲线看起来比SGDClassifier好很多,它离左上角更接近,因此它的ROC AUC分数也高得多。

希望现在你已经掌握了如何训练二元分类器,如何选择合适的指标利用交叉验证来对分类器进行评估,
如何选择满足需求的精度/召回率权衡,以及如何使用ROC曲线和ROC AUC分数来比较多个模型。

3.4 多类分类器

二元分类器在两个类中区分,而多类分类器(也称为多项分类器)可以区分两个以上的类。
有一些算法(如随机森林分类器或朴素贝叶斯分类器)可以直接处理多个类。也有一些严格的二元分类器(如支持向量机分类器或线性分类器)。
但是,有多种策略可以让你用几个二元分类器实现多类分类的目的:

  • 要创建一个系统将数字图片分为10类(从0到9),一种方法是训练10个二元分类器,每个数字一个(0-检测器、1-检测器、2-检测器,以此类推)。
    然后,当你需要对一张图片进行检测分类时,获取每个分类器的决策分数,哪个分类器给分最高,就将其分为哪个类。
    这称为一对剩余(OvR)策略,也称为一对多(one-versus-all)
  • 另一种方法是为每一对数字训练一个二元分类器:一个用于区分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类来试试SVM分类器:

one_digit = X[0]  # 提取图片数据集中的1个数字图片,用来测试
from sklearn.svm import SVC
svm_clf = SVC()
svm_clf.fit(X_train, y_train)  # y_train, not y_train_5
one_digit_pre = svm_clf.predict([one_digit])
print(one_digit_pre)

非常容易!这段代码使用原始目标类0到9(y_train)在训练集上对SVC进行训练,而不是以“5”和“剩余”作为目标类(y_train_5),
然后做出预测(在本例中预测正确)。而在内部,Scikit-Learn实际上训练了45个二元分类器,获得它们对图片的决策分数,然后选择了分数最高的类。
要想知道是不是这样,可以调用decision_function()方法。它会返回10个分数,每个类1个,而不再是每个实例返回1个分数:

one_digit_scores = svm_clf.decision_function([one_digit])
print(one_digit_scores)
one_digit_scores_max = np.argmax(one_digit_scores)  # 输出最大值(9.3132482对应的值)
print(one_digit_scores_max)  # 确认最大值确实是5

当训练分类器时,目标类的列表会存储在classes_属性中,按值的大小排序。
在本例里,classes_数组中每个类的索引正好对应其类本身(例如,索引上第5个类正好是数字5这个类),但是一般来说,不会这么恰巧。

print(svm_clf.classes_)
print(svm_clf.classes_[5])

如果想要强制Scikit-Learn使用一对一(OvO)或者一对剩余(OvR)策略,可以使用OneVsOneClassifier或OneVsRestClassifier类。
只需要创建一个实例,然后将分类器传给其构造函数(它甚至不必是二元分类器)。
例如,下面这段代码使用OvR策略,基于SVC创建了一个多类分类器:

from sklearn.multiclass import OneVsRestClassifier
ovr_clf = OneVsRestClassifier(SVC())  # 使用0VR策略的SVC
ovr_clf.fit(X_train, y_train)
ovr_one_digit_pre = ovr_clf.predict([one_digit])
print(ovr_one_digit_pre)  # 输出0VR策略的SVC所预测的值
print(len(ovr_clf.estimators_))  # 输出类别数量?

此外,也同样可以用SGDClassifier或者RandomForestClassifier进行多分类:

# 用SGD做多分类
from sklearn.linear_model import SGDClassifier
sgd_clf = SGDClassifier(random_state=42)  # 定义1个SGDClassifier分类器,并设置参数random_state=42
sgd_clf.fit(X_train, y_train)
sgd_clf.predict([one_digit])
sgd_clf.decision_function([one_digit])  # 这次Scikit-Learn不必运行OvR或者OvO了,因为SGD分类器直接就可以将实例分为多个类。
# 调用decision_function()可以获得分类器将每个实例分类为每个类的概率列表:
sgd_clf.decision_function([one_digit])
# 现在,你当然要评估这个分类器。与往常一样,可以使用交叉验证。
# 使用cross_val_score()函数来评估SGDClassifier的准确性:
from sklearn.model_selection import cross_val_score
cross_val_score(sgd_clf, X_train, y_train, cv=3, scoring="accuracy")

在所有的测试折叠上都超过了84%。如果是一个纯随机分类器,准确率大概是10%,所以这个结果不是太糟,但是依然有提升的空间。
例如,将输入进行简单缩放(如第2章所述)可以将准确率提到89%以上:

from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train.astype(np.float64))
cross_val_score(sgd_clf, X_train_scaled, y_train, cv=3, scoring="accuracy")

3.5 误差分析

在这里,假设你已经找到了一个有潜力的模型,现在你希望找到一些方法对其进一步改进。方法之一就是分析其错误类型。

首先看看混淆矩阵。就像之前做的,使用cross_val_predict()函数进行预测,然后调用confusion_matrix()函数:

from sklearn.model_selection import cross_val_predict
from sklearn.metrics import confusion_matrix
y_train_pred = cross_val_predict(sgd_clf, X_train_scaled, y_train, cv=3)
conf_mx = confusion_matrix(y_train, y_train_pred)
print(conf_mx)
plt.matshow(conf_mx, cmap=plt.cm.gray)  # 调用matpllib查看混淆矩阵图像
plt.show()

混淆矩阵看起来很不错,因为大多数图片都在主对角线上,这说明它们被正确分类。
数字5看起来比其他数字稍稍暗一些,这可能意味着数据集中数字5的图片较少,也可能是分类器在数字5上的执行效果不如在其他数字上好。实际上,你可能会验证这两者都属实。
让我们把焦点放在错误上。
首先,你需要将混淆矩阵中的每个值除以相应类中的图片数量,这样你比较的就是错误率而不是错误的绝对值(后者对图片数量较多的类不公平):
用0填充对角线,只保留错误,重新绘制结果(见下图):

现在可以清晰地看到分类器产生的错误种类了。记住,每行代表实际类,而每列表示预测类。
第8列看起来非常亮,说明有许多图片被错误地分类为数字8了。然而,第8行不那么差,告诉你实际上数字8被正确分类为数字8。
注意,错误不是完全对称的,比如,数字3和数字5经常被混淆(在两个方向上)。
**分析混淆矩阵通常可以帮助你深入了解如何改进分类器。**通过上图来看,你的精力可以花在改进数字8的分类错误上。

例如,可以试着收集更多看起来像数字8的训练数据,以便分类器能够学会将它们与真实的数字区分开来。
或者,也可以开发一些新特征来改进分类器——例如,写一个算法来计算闭环的数量(例如,数字8有两个,数字6有一个,数字5没有)。
再或者,还可以对图片进行预处理(例如,使用Scikit-Image、Pillow或OpenCV)让某些模式更为突出,比如闭环之类的。

分析单个的错误也可以为分类器提供洞察:它在做什么?它为什么失败?但这通常更加困难和耗时。
例如,我们来看看数字3和数字5的示例(plot_digits()函数只是使用Matplotlib的imshow()函数):

# 查看数字3和数字5的示例
def plot_digits(instances, images_per_row=10, **options):
    size = 28
    images_per_row = min(len(instances), images_per_row)
    images = [instance.reshape(size,size) for instance in instances]
    n_rows = (len(instances) - 1) // images_per_row + 1
    row_images = []
    n_empty = n_rows * images_per_row - len(instances)
    images.append(np.zeros((size, size * n_empty)))
    for row in range(n_rows):
        rimages = images[row * images_per_row : (row + 1) * images_per_row]
        row_images.append(np.concatenate(rimages, axis=1))
    image = np.concatenate(row_images, axis=0)
    plt.imshow(image, cmap=mpl.cm.binary, **options)
    plt.axis('off')


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)]
plt.figure(figsize=(8, 8))
plt.subplot(221)
plot_digits(X_aa[:25], images_per_row=5)
plt.subplot(222)
plot_digits(X_ab[:25], images_per_row=5)
plt.subplot(223)
plot_digits(X_ba[:25], images_per_row=5)
plt.subplot(224)
plot_digits(X_bb[:25], images_per_row=5)
plt.show()

分类器弄错的数字(即左下方和右上方的矩阵)里,确实有一些写得非常糟糕,即便是人类也很难做出区分(例如,第1行的数字5看起来真的很像数字3)。
然而,对我们来说,大多数错误分类的图片看起来还是非常明显的错误,我们很难理解分类器为什么会弄错。
原因在于,我们使用的简单的SGDClassifier模型是一个线性模型。
它所做的就是为每个像素分配一个各个类别的权重,当它看到新的图像时,将加权后的像素强度汇总,从而得到一个分数进行分类。
而数字3和数字5只在一部分像素位上有区别,所以分类器很容易将其弄混。

数字3和数字5之间的主要区别是在于连接顶线和下方弧线的中间那段小线条的位置。
如果你写的数字3将连接点略往左移,分类器就可能将其分类为数字5,反之亦然。换言之,这个分类器对图像移位和旋转非常敏感。
因此,减少数字3和数字5混淆的方法之一,就是对图片进行预处理,确保它们位于中心位置并且没有旋转。这也同样有助于减少其他错误。

3.6 多标签分类

到目前为止,每个实例都只会被分在一个类里。而在某些情况下,你希望分类器为每个实例输出多个类。

例如,人脸识别的分类器:如果在一张照片里识别出多个人怎么办?当然,应该为识别出来的每个人都附上一个标签。

假设分类器经过训练,已经可以识别出三张脸——爱丽丝、鲍勃和查理,那么当看到一张爱丽丝和查理的照片时,它应该输出[1,0,1]
(意思是“是爱丽丝,不是鲍勃,是查理”)这种输出多个二元标签的分类系统称为多标签分类系统。
让我们来看一个更为简单的示例:

# 多标签分类器(两个标签)
from sklearn.neighbors import KNeighborsClassifier
y_train_large = (y_train >= 7)  # 数字书否是大数(是否>7)
y_train_odd = (y_train % 2 == 1)  # 数字是否是奇数
y_multilabel = np.c_[y_train_large, y_train_odd]  # 把两个标签合并成一个数组
knn_clf = KNeighborsClassifier()  # 定义1个KNeighborsClassifier分类器(不是所有的分类器都支持多标签分类)
knn_clf.fit(X_train, y_multilabel)  # 使用多个目标数组数据去训练KNeighborsClassifier分类器
print(knn_clf.predict([one_digit]))  # 对X[0]这个数据进行预测,并输出。(输出的是两个标签:[False, True])

结果是正确的!数字5确实不大(False),为奇数(True)。
评估多标签分类器的方法很多,如何选择正确的度量指标取决于你的项目。
比如方法之一是测量每个标签的F1分数(或者之前讨论过的任何其他二元分类器指标),然后简单地计算平均分数。

# 计算所有标签的平均F1分数:
from sklearn.metrics import f1_score
y_train_knn_pred = cross_val_predict(knn_clf, X_train, y_multilabel, cv=3)
knn_f1_score = f1_score(y_multilabel, y_train_knn_pred, average="macro")  # average="weighted"
print(knn_f1_score)

这里假设所有的标签都同等重要,但实际可能不是这样。特别地,如果训练的照片里爱丽丝比鲍勃和查理要多很多,你可能想给区分爱丽丝的分类器更高的权重。
一个简单的办法是给每个标签设置一个等于其自身支持的权重(也就是具有该目标标签的实例的数量)。
为此,只需要在上面的代码中设置average="weighted"即可。

3.7 多输出分类

我们即将讨论的最后一种分类任务称为多输出-多类分类(或简单地称为多输出分类)。
简单来说,它是多标签分类的泛化,其标签也可以是多类的(比如它可以有两个以上可能的值)。
为了说明这一点,构建一个系统去除图片中的噪声。给它输入一张有噪声的图片,它将(希望)输出一张干净的数字图片,与其他MNIST图片一样,
以像素强度的一个数组作为呈现方式。请注意,这个分类器的输出是多个标签(一个像素点一个标签),每个标签可以有多个值(像素强度范围为0到225)。
所以这是个多输出分类器系统的示例。

分类和回归之间的界限有时很模糊,比如这个示例。可以说,预测像素强度更像是回归任务而不是分类。
而多输出系统也不仅仅限于分类任务,可以让一个系统给每个实例输出多个标签,同时包括类标签和值标签。

还先从创建训练集和测试集开始,使用NumPy的randint()函数为MNIST图片的像素强度增加噪声。目标是将图片还原为原始图片:

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  # 原始图片作为测试集的目标值
# 左边是有噪声的输入图片,右边是干净的目标图片。现在通过训练分类器,清洗这张图片:
knn_clf.fit(X_train_mod, y_train_mod)
some_index = 0
clean_digit = knn_clf.predict([X_test_mod[some_index]])


def plot_digit(data):
    image = data.reshape(28, 28)
    plt.imshow(image, cmap=mpl.cm.binary, interpolation="nearest")
    plt.axis("off")


plot_digit(clean_digit)
plt.show()

看起来离目标够接近了。分类器之旅到此结束。希望现在你掌握了如何为分类任务选择好的指标,如何选择适当的精度/召回率权衡,如何比较多个分类器,以及更为概括地说,如何为各种任务构建卓越的分类系统。

3.8 练习题

1.为MNIST数据集构建一个分类器,并在测试集上达成超过97%的准确率。
提示:KNeighborsClassifier对这个任务非常有效,你只需要找到合适的超参数值即可(试试对weights和n_neighbors这两个超参数进行网格搜索)。

2.写一个可以将MNIST图片向任意方向(上、下、左、右)移动一个像素的功能。然后对训练集中的每张图片,创建四个位移后的副本(每个方向一个),
添加到训练集。最后,在这个扩展过的训练集上训练模型,测量其在测试集上的准确率。你应该能注意到,模型的表现甚至变得更好了!
这种人工扩展训练集的技术称为数据增广或训练集扩展。

3.Kaggle上非常棒的起点:处理泰坦尼克(Titanic)数据集。

4.创建一个垃圾邮件分类器(更具挑战性的练习):

  • 从Apache SpamAssassin的公共数据集中下载垃圾邮件和非垃圾
    邮件。
  • 解压数据集并熟悉数据格式。
  • 将数据集分为训练集和测试集。
  • 写一个数据准备的流水线将每封邮件转换为特征向量。你的流水线应将电子邮件转换为一个“指示出所有可能的词存在与否”的(稀疏)向量。
    比如,如果所有的邮件都只包含四个词“Hello”“how”“are”“you”,那么邮件“Hello you Hello Hello you”会被转换成为向量[1,0,0,1]
    (意思是“Hello”存在,“how”不存在,“are”不存在,“you”存在),如果你希望算上每个词出现的次数,那么这个向量就是[3,0,0,2]。
  • 在流水线上添加超参数来控制是否剥离电子邮件标题,是否将每封邮件转换为小写,是否删除标点符号,是否将“URLs”替换成“URL”,
    是否将所有小写number替换为“NUMBER”,甚至是否执行词干提取(即去掉单词后缀,有可用的Python库可以实现该操作)。
  • 最后,多试几个分类器,看看是否能创建出一个高召回率且高精度的垃圾邮件分类器。

以上练习题的解答可以在Jupyter notebook上获得,链接地址为:https://github.com/ageron/handson-ml2。

PS:学第一和第二章的时候还没有想着写出来,所以也就没有整理,后面把论文写完后再来整理吧(累.jpg),and这篇文章还是先发的LeetCode

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

新四石路打卤面

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值