上一章是降维,这一章来点没有那么重理论的东西:我们来看看当你训练完模型,如何评价模型的好坏,以及如何调参来让模型更好。
我们不妨来通过一个真实数据集来讲解这一章的内容。
我们要是用的数据集叫做BCWD,是一个乳腺癌数据集,包含了569条数据,每条数据有30个特征,最后还有一个标签,表示这个数据是良性还是恶性。
我们先来看看这个数据集的基本情况:
1 | import pandas as pd |
随后,我们将这30个特征赋值到X中,标签赋值到y中:
1 |
|
其中我们通过使用 LabelEncoder 把原本的标签M和B转换成了0和1,方便后续的模型训练。
接下来,我们可以把原本的数据集进行标准化和降维。与其说我们直接把每一步都分开来写,我们实际上也可以使用sklearn提供的管线(Pipeline)来把这些步骤串联起来:
1 |
|
可以看到我们使用 make_pipeline 定义一个管线,托管给变量 pipe_lr,这个管线包含了三个步骤,依次执行数据标准化,PCA降维,最后训练一个逻辑回归模型。
所以方便之处就是,我们可以直接调用 pipe_lr.fit() 来训练整个管线,调用 pipe_lr.predict() 来进行预测,调用 pipe_lr.score() 来评估模型的准确率。
验证
经过上面的例子,我们就应该能够得到一个针对乳腺癌数据集的模型了,那么我们如何来评估这个模型的好坏呢?
在训练机器学习模型的时候,我们需要考虑模型对于未曾见过的数据表现究竟如何,而方差和偏差就是我们评估模型性能的两个重要指标。方差指的是模型对于训练数据的拟合程度,偏差指的是模型对于未见过的数据的预测能力。
所以,验证 (Validation) 指的是在训练过程中评估模型性能的过程。在这里我们介绍两个主要验证方法,分别是 留出法 (Holdout validation) 和 K折交叉验证 (K-fold cross-validation)。
留出法
留出法可能是最经典,最受欢迎的验证方法了。
留出法的基本思想是将数据集划分成训练集和测试集两部分。训练集用来训练模型,让模型来根据这些数据自己调整,找到最佳参数;而测试集则用来评估模型的性能,看看模型在未见过的数据上的表现如何。测试集自始至终都不参与模型的训练过程,只有在最后评估模型性能的时候才会用到。
就像高中生准备高考。他们会在平时的学习中刷题来训练自己,这些练习题就相当于训练集;而高考就是测试集,只有在高考的时候才会用到,来评价各个“模型”的性能,平常这个题库是不会被泄露出去的。
在训练模型过程中,调整参数几乎可以说是最核心的环节,但是仅仅让模型去调整参数也不够,我们有时候还需要考虑 超参数 (Hyperparameters) 的调整。
超参数是指那些在模型训练之前需要设置的参数,比如说学习率,正则化强度,树的深度等等。虽然超参数不会在模型学习过程中被自动调整,但是超参数的调整会间接影响参数的调整,进而直接影响模型的性能。因此,我们不仅要让模型找到最佳的参数,我们还需要找到能够让模型发挥最佳性能的超参数。
由于模型不会再训练集上进行超参数调整,因此我们目前广泛使用的留出法会在分离训练集和测试集的基础上,再从训练集中划分出一个验证集来进行超参数调整。
如果使用带验证集的留出法,我们使用数据集的流程大概是:
将整个数据集划分成训练集和测试集,然后我们在训练集里面再划分出一个验证集,用来进行超参数调整。
首先我们使用机器学习算法在训练集上进行训练,调整权重,得到一个模型。
接下来为了了调整超参数,我们会在验证集上评估模型的性能,根据验证集的表现来调整超参数,直到找到一个最佳的超参数组合。
最后,我们使用测试集来评估最终模型的性能,看看它在未见过的数据上的表现如何。
K折交叉验证
留出法会带来一个关键缺点。模型的性能可能会因为我们划分训练集和验证集的方式不同而有很大的差异。比如说,如果我们在划分数据集的时候,恰好把一些重要的样本划分到了验证集中,那么模型在验证集上的表现可能会非常差;反之,如果我们把这些重要的样本划分到了训练集中,那么模型在验证集上的表现可能会非常好。
K折交叉验证 (K-fold cross-validation) 就是为了解决这个问题而提出的。
K折交叉验证的基本思想是将数据集划分成K个大小相等的子集,然后进行K次训练和验证。在每次训练和验证过程中,我们使用K-1个子集作为训练集,剩下的一个子集作为验证集。这样,我们就可以在不同的划分方式下评估模型的性能,最终得到一个更稳定,更可靠的性能评估结果。
下面就是K折交叉验证的示意图:

上图能看出来我们把整个训练集划分成了10个子集,所以我们的 $K = 10$。 在第一次迭代中,我们选择前九个子集作为训练集,最后一个作为验证集。一次迭代我们得出来的性能评估结果,叫做 $E_1$。
在第二次迭代中,我们选择前八个子集和最后一个子集作为训练集,剩下的那个子集作为验证集,得到性能评估结果 $E_2$。以此类推,我们总共进行10次迭代,得到10个性能评估结果 $E_1, E_2, ..., E_{10}$。
最后,我们可以通过计算这10个性能评估结果的平均值来得到模型的最终性能评估结果:
$$E = \frac{1}{K} \sum_{i=1}^{K} E_i$$
当我们使用K折交叉验证找到了一个最佳的超参数组合之后,我们可以使用这个超参数组合在整个训练集上重新训练模型,然后在测试集上评估模型的性能。
在绝大多数情况下,K的值通常被设置为10。对于规模较小的数据集,K的值可以设置得更大一些,比如说20或者30;对于规模较大的数据集,K的值可以设置得更小一些,比如说5或者10。提升K值可以导致每次训练和验证的样本数量增加,从而得到更稳定的性能评估结果,但同时也会增加计算成本。
反之,对于大规模数据集来说,我们可能会选择较小的K值来减少计算成本,但这可能会导致性能评估结果的方差增加。
留一交叉验证
K折交叉验证的一个特殊情况是当K等于数据集的样本数量时,这种方法被称为 留一交叉验证 (Leave-One-Out Cross-Validation, LOOCV)。
留一交叉验证会将数据集中的每一个样本都单独作为验证集,其余样本作为训练集,进行K次训练和验证。也就是 $k = n$,其中 $n$ 是数据集的样本数量。因此,我们最好是在样本数量较小的数据集上使用留一交叉验证,因为它的计算成本非常高。不过好处就是它可以最大程度地利用数据集中的每一个样本来评估模型的性能,从而得到一个非常稳定的性能评估结果。
Stratified K折交叉验证
也叫做分层K折交叉验证。在这里,我们会在划分数据集的时候,保持每个子集中各个类别的样本比例与整个数据集中的比例相同。这样可以确保每个子集中都包含了足够的样本来评估模型的性能,尤其是在处理不平衡数据集时非常有用。
还记得之前在scikit-learn中我们划分训练集和测试集的时候,使用了 stratify=y 参数吗?差不多也是这个意思,就是在划分数据集的时候保持各个类别的样本比例不变。
1 | import numpy as np |
在上面的例子中,我们首先使用 StratifiedKFold 来创建一个分层K折交叉验证的对象,然后我们在每次迭代中使用这个对象来划分训练集和验证集,最后评估模型的性能并计算平均准确率和标准差。
学习曲线和验证曲线
这两个曲线是我们在评估模型性能和调参过程中非常有用的工具。我们可以将模型的性能表现可视化出来,从而更直观地理解模型的学习过程和超参数调整的效果。
学习曲线
学习曲线 (Learning curve) 更偏重揭示模型的学习情况,它展示了模型在训练集和验证集上的性能随着训练样本数量的增加而变化的情况。有助于我们判断模型是否存在过拟合或者欠拟合的问题。
例如下图:

左上展示的是High Bias,也就是高偏差。换算到人话讲就是,模型的表现不是很好。我们能看见虽然Training Acc和Validation Acc的曲线非常接近,但是它们都远远低于Desired Accuracy。这说明模型在训练集和测试集上的表现都不太好,可能是因为模型过于简单,无法捕捉到数据中的复杂关系。
右上是High Variance,高方差。与高偏差不一样,高方差的模型在训练集上的表现不赖,但是在测试集上的表现却很差,并且两者之间的差距很大。你可以从图上看出,尽管两条曲线都比较接近Desired Acc,但是两条曲线间隔太大了。这通常是因为模型过于复杂,过度拟合了训练数据中的噪声,导致在未见过的数据上表现不佳。
右下是Good Fit的情况,你能看出Training Acc和Validation Acc的曲线不仅非常接近,而且也随着训练样本的增加越来越靠近Desired Accuracy。这就是我们希望看到的情况,说明模型在训练集和测试集上的表现都不错,并且随着训练样本的增加,模型的性能也在不断提升。
比如,我们可以借助matplotlib来绘制学习曲线:
1 | import matplotlib.pyplot as plt |
验证曲线
与学习曲线不同,验证曲线 (Validation curve) 更偏向于揭示模型的超参数调整情况。它展示某个特定的超参数的不同取值下,模型在训练集和验证集上的性能表现。通过观察验证曲线,我们可以判断模型对于不同超参数取值的敏感程度,从而找到一个最佳的超参数组合。
比如下面这个图就展示了Acc与正则化强度超参数C的关系:

1 | from sklearn.model_selection import validation_curve |
网格搜索
在前面我们提到了超参数调整的重要性,那么我们如何来找到一个最佳的超参数组合呢?
网格搜索 (Grid Search) 就是一个非常常用的超参数调整方法。它的基本思想是定义一个超参数的搜索空间,然后在这个搜索空间中进行穷举式的搜索,找到一个最佳的超参数组合。有点像暴力枚举,虽然效率不高,但是它可以保证找到一个全局最优的超参数组合。
但正因为它像暴力枚举,所以当搜索空间比较大,或者模型比较复杂的时候,网格搜索的计算成本就会非常高。因此,在实际应用中,我们通常会结合一些启发式的方法来缩小搜索空间,比如说随机搜索。
如果选用随机搜索,那么在scikit-learn中,我们可以使用提供的 RandomizedSearchCV 来进行随机搜索。我们需要对随机搜索指定一个上限,来控制搜索的迭代次数,这样就可以在一定程度上控制计算成本,同时也能找到一个比较好的超参数组合:
1 | from sklearn.model_selection import RandomizedSearchCV |
在上面的例子中,我们定义了一个超参数搜索空间,其中 C 的取值是从0.001到100之间的均匀分布。我们使用 RandomizedSearchCV 来进行随机搜索,指定了搜索的迭代次数和交叉验证的折数。最后,我们可以通过 best_score_ 和 best_params_ 来查看最佳的性能评估结果和对应的超参数组合。
嵌套交叉验证
嵌套交叉验证 (Nested Cross-Validation) 是一种更为复杂的验证方法,它结合了K折交叉验证和网格搜索的思想,来同时评估模型的性能和调整超参数。
不变的是,我们仍然会将数据集划分成K个子集,然后进行K次训练和验证。但区别在于与其说是仅对训练集进行循环切分,我们这次对整个数据集进行循环切分。外层循环中,我们首先将整个数据集分成训练集和测试集,然后在内层循环中,我们再对刚才分出来的训练集进行K折交叉验证,来调整超参数。最后,我们使用外层循环中的测试集来评估最终模型的性能。
因此,相比于K折交叉验证,我们将测试集的划分放在了外层循环中,这样就可以在每次迭代中都使用一个独立的测试集来评估模型的性能,从而得到一个更为可靠的性能评估结果。

1 | gs = GridSearchCV(estimator=pipe_svc, param_grid=param_grid, scoring='accuracy', cv=2) |
返回的平均交叉验证准确率和标准差可以帮助我们评估模型的性能,并且可以比较不同模型或者不同超参数组合的性能表现。
同样我们也可以使用嵌套交叉验证来与SVM模型进行比较:
1 | from sklearn.tree import DecisionTreeClassifier |
1 | CV accuracy: 0.934 +/- 0.016 |
模型的评估
直到我们该如何验证模型的性能之后,我们需要考虑如何科学地评估模型的性能了。
在评估模型性能的时候,我们通常会使用一些常见的性能评估指标,比如说准确率,精确率,召回率,F1分数等等。不同的指标适用于不同的场景,我们需要根据具体的问题来选择合适的指标。
混淆矩阵
混淆矩阵 (Confusion Matrix) 可能是机器学习中最常用的性能评估工具了。它是一个二维矩阵,用来描述分类模型的性能。混淆矩阵中的每个元素都表示模型在不同类别上的预测结果和实际结果的关系,如下图所示:

按照默认来讲,混淆矩阵左上角的元素表示真正例 (True Positives, TP),右上角的元素表示假正例 (False Negatives, FN),左下角的元素表示假负例 (False Positives, FP),右下角的元素表示真负例 (True Negatives, TN)。
掰开来看,真正例 (TP) 是指模型正确地将正类样本预测为正类的数量;假正例 (FP) 是指模型错误地将负类样本预测为正类的数量;假负例 (FN) 是指模型错误地将正类样本预测为负类的数量;真负例 (TN) 是指模型正确地将负类样本预测为负类的数量。
我们可以直接调用 sklearn.metrics 模块中的 confusion_matrix 函数来计算混淆矩阵:
1 | from sklearn.metrics import confusion_matrix |
1 | [[71 1] |
1 | fig, ax = plt.subplots(figsize=(2.5, 2.5)) |

你会发现默认来说混淆矩阵的布局没有按照我们刚才所说的那样,左上角是TP,右下角是TN。为了解决这个问题,我们可以在调用 confusion_matrix 函数的时候,指定 labels 参数来调整混淆矩阵的布局:
1 | confmat = confusion_matrix(y_true=y_test, y_pred=y_pred, labels=[1, 0]) |
1 | [[40 2] |
至于为什么 labels 参数的值是 [1, 0],而不是 [0, 1],这是因为在我们的数据集中,正类样本被编码为1,负类样本被编码为0。通过指定 labels=[1, 0],我们告诉函数将正类样本放在混淆矩阵的第一行和第一列,而将负类样本放在第二行和第二列。这样就可以得到我们期望的混淆矩阵布局了。
1 | le.transform(['M', 'B']) |
1 | array([1, 0], dtype=int64) |
你会发现,正类样本M被编码为1,负类样本B被编码为0,这就是我们在指定 labels 参数时使用 [1, 0] 的原因了。
误差与准确率
误差 (Error) 是指模型在测试集上的错误预测的比例,计算公式为:
$$\text{Error(ERR)} = \frac{FP + FN}{TP + FP + FN + TN}$$
用来衡量模型的错误率,误差越小,模型的性能越好。直觉上来讲,误差描述的是模型把正类样本预测成负类样本,或者把负类样本预测成正类样本的比例。也就是说,在给定所有的预测结果中,模型错误预测的比例。
准确率 (Accuracy) 是指模型在测试集上的正确预测的比例,计算公式为:
$$\text{Accuracy(ACC)} = \frac{TP + TN}{TP + FP + FN + TN}$$
用来衡量模型在测试集上的整体性能,准确率越高,模型的性能越好。
准确率衡量的是模型在给定的所有预测结果中,正确的预测结果所占的比例。
准确率实际上与误差是互补的关系,因为它们的分子加起来等于总的样本数量,所以我们可以通过以下公式来计算准确率:
$$\text{Accuracy(ACC)} = 1 - \text{Error(ERR)}$$
真正率与假正率
真正率 (True Positive Rate, TPR) 也叫做召回率 (Recall),是指模型正确地将正类样本预测为正类的比例,计算公式为:
$$\text{TPR} = \frac{TP}{TP + FN}$$
如果一个模型最后能够得到一个较高的真正率,那么就意味着你的模型能够在所有应当是正类的样本中,正确地识别出大部分的正类样本。换句话说,真正率高的模型能够更好地捕捉到正类样本,从而减少漏报的情况。
假正率 (False Positive Rate, FPR) 是指模型错误地将负类样本预测为正类的比例,计算公式为:
$$\text{FPR} = \frac{FP}{FP + TN}$$
逻辑与真正率相反,如果一个模型最后能够得到一个较低的假正率,那么就意味着你的模型能够在所有应当是负类的样本中,正确地识别出大部分的负类样本。换句话说,假正率低的模型能够更好地避免误报的情况。
就好比说我们在一个罕见症数据集上训练了一个预测模型,如果这个模型的真正率很高,那么它能够正确地识别出大部分的罕见症患者,从而帮助医生更好地进行诊断和治疗,相反,如果这个模型的假正率很高,那么它可能会误将一些健康人预测为罕见症患者,从而导致不必要的担忧和医疗资源的浪费。
精确度与召回率
召回率我们刚才说过了,就是真正率。精确度 (Precision) 是指模型正确地将正类样本预测为正类的比例,计算公式为:
$$\text{Precision(Prec)} = \frac{TP}{TP + FP}$$
精确度衡量的是在所有被模型预测为正类的样本中,真正的正类样本所占的比例。如果模型的精确度越高,那么就代表着在所有模型预测为正类的样本中,真正的正类样本所占的比例越高。换句话说,精确度高的模型能够更好地避免误报的情况。
F1分数
了解了所有的拼图之后,我们就可以使用这些数据来构建一个综合的性能评估指标了,这个指标就是 F1分数 (F1 Score)。
F1分数是精确度和召回率的调和平均数,计算公式为:
$$\text{F1} = 2 \cdot \frac{\text{Precision} \cdot \text{Recall}}{\text{Precision} + \text{Recall}}$$
F1分数的取值范围在0和1之间,F1分数越高,模型的性能越好。F1分数能够综合考虑模型的精确度和召回率,从而提供一个更全面的性能评估指标。
在实战中,当你观测到你训练的模型的F1分数很低的时候,你就可以通过观察模型的精确度和召回率来判断模型是过于注重精确度,还是过于注重召回率了。比如说,如果模型的精确度很高,但是召回率很低,那么就说明模型过于注重精确度,导致了漏报;反之,如果模型的召回率很高,但是精确度很低,那么就说明模型过于注重召回率,导致了误报。
ROC曲线与AUC
ROC曲线 (Receiver Operating Characteristic Curve) 是一个用来评估二分类模型性能的工具。它展示了模型在不同的阈值下的真正率和假正率之间的关系。
ROC曲线图的横轴表示假正率(FPR),纵轴表示真正率(TPR)。通过观察ROC曲线,我们可以判断模型在不同的阈值下的性能表现,从而选择一个合适的阈值来平衡真正率和假正率。
下面是一段绘制ROC曲线的代码,
1 | from sklearn.metrics import roc_curve, auc |
最后你得到的结果看起来大概是:

你会发现ROC曲线看起来像是一个弓形,那么我们该怎么读懂ROC图呢?我们可以通过观察ROC曲线与对角线之间的面积来评估模型的性能。ROC曲线越接近左上角,模型的性能就越好;ROC曲线越接近对角线,模型的性能就越差。
因此如果你训练的模型的ROC图在很早阶段就接近左上角,那么就说明你的模型的性能非常好;如果你的模型的ROC图在很晚阶段才接近左上角,那么就说明你的模型的性能一般;如果你的模型的ROC图一直接近对角线,那么就说明你的模型的性能很差。
知道了ROC曲线之后,我们就可以通过计算ROC曲线下的面积来得到一个更为综合的性能评估指标了,这个指标就是AUC。AUC (Area Under the Curve) 是指ROC曲线下的面积,AUC的取值范围在0和1之间,AUC越高,模型的性能越好。AUC能够综合考虑模型在不同阈值下的性能表现,从而提供一个更全面的性能评估指标。
多分类的性能评估
我们刚才讲述的性能评估指标都是针对二分类模型的,那么如果我们训练的是一个多分类模型,我们该如何来评估它的性能呢?
对于多分类模型,我们可以使用一些扩展的性能评估指标,比如说宏平均 (Macro Average) 和微平均 (Micro Average)。
宏平均 (Macro Average) 是指对每个类别的性能评估指标进行平均,计算公式为:
$$\text{Macro Average} = \frac{1}{N} \sum_{i=1}^{N} \text{Metric}_i$$
例如,宏平均精确度的计算公式就是:
$$\text{PRE}_{macro} = \frac{PRE_1 + PRE_2 + \cdots + PRE_k}{k}$$
微平均 (Micro Average) 是指将所有类别的性能评估指标进行加权平均,计算公式为:
$$\text{Micro Average} = \frac{\sum_{i=1}^{N} TP_i}{\sum_{i=1}^{N} TP_i + \sum_{i=1}^{N} FP_i}$$
例如,微平均精确度的计算公式就是:
$$\text{PRE}_{micro} = \frac{TP_1 + TP_2 + \cdots + TP_k}{TP_1 + TP_2 + \cdots + TP_k + FP_1 + FP_2 + \cdots + FP_k}$$
那么两个衡量方式有什么区别呢?
宏平均是对每个类别的性能评估指标进行平均,因此它对每个类别的权重是相等的。换句话说,宏平均会把每个类别看作同等重要的,即使某些类别的样本数量非常少,宏平均也会给予它们同样的权重。
而微平均是对所有类别的性能评估指标进行加权平均,因此它会根据每个类别的样本数量来调整权重。换句话说,微平均会把样本数量较多的类别看作更重要的,即使某些类别的样本数量非常少,微平均也会给予它们较小的权重。
要将这些理论映射到真正的情况中,我们可以想象一个数据集,其中不同类别的样本数量非常不平衡。比如说,类别A有1000个样本,类别B有100个样本,类别C只有10个样本。因为A的样本量比较多,所以模型对于类别A的性能表现可能会非常好,而对于样本数量较少的类别,尤其是C,可能会表现得非常差。
在这种情况下,如果你使用宏平均来评估模型的性能,那么你会发现模型的宏平均性能可能会非常差,因为它会把每个类别看作同等重要的,即使类别C的样本数量非常少,宏平均也会给予它同样的权重,从而导致整体性能评估结果较低;相反,如果你使用微平均来评估模型的性能,那么你得到的平均性能大概没有宏平均表现出来的那么差,因为微平均会根据每个类别的样本数量来调整权重,即使类别C的样本数量非常少,微平均也会给予它较小的权重,从而导致整体性能评估结果较高。
不平衡数据集
在实际生活中,我们经常会遇到不平衡数据集,也就是说,在数据集中某些类别的样本数量远远多于其他类别的样本数量。比如说,在一个医疗数据集中,健康人可能占绝大多数,而患病的人可能只有少数;在一个欺诈检测数据集中,正常交易可能占绝大多数,而欺诈交易可能只有少数。
我们不妨来真正去试一下不平衡数据集的情况。在原来的乳腺癌数据集中,良性样本占了357个,而恶性样本只有212个。我们可以通过以下代码来创建一个不平衡的数据集:
1 | X_imb = np.vstack((X[y == 0], X[y == 1][:40])) |
如果我们训练的模型啥也不干,只会一个劲嗯报class0,那么在这个不平衡的数据集上,模型仍然能达到一个很高的准确率;
1 | y_pred = np.zeros(y_imb.shape[0]) |
1 | 89.92443324937027 |
你会发现,模型的准确率达到了89.92%,看起来好像模型的性能非常好,但实际上模型什么都没学到,它只是一个只会预测负类样本的模型而已。因为在这个不平衡的数据集中,负类样本占了绝大多数,所以即使模型什么都不干,只要一直预测负类样本,就能得到一个很高的准确率。换你你也肯定这么干啊,反正准确率高了就行了。
所以在实际实践中,我们处理这些不平衡数据集的时候,我们通常会使用一些特殊的技术来处理这些不平衡的数据,比如说过采样 (Oversampling),欠采样 (Undersampling),或者是使用一些专门针对不平衡数据集设计的算法,比如说SMOTE (Synthetic Minority Over-sampling Technique)。
过采样 (Oversampling) 是指通过复制少数类样本来增加少数类样本的数量,从而平衡数据集。比如说,在我们的不平衡数据集中,恶性样本只有40个,我们可以通过复制这些恶性样本来增加它们的数量,从而使得恶性样本和良性样本的数量更加接近。
欠采样 (Undersampling) 是指通过删除多数类样本来减少多数类样本的数量,从而平衡数据集。比如说,在我们的不平衡数据集中,良性样本有357个,我们可以通过删除一些良性样本来减少它们的数量,从而使得恶性样本和良性样本的数量更加接近。
而我们的重头戏,SMOTE (Synthetic Minority Over-sampling Technique) 是一种基于过采样的技术,它通过在特征空间中生成新的少数类样本来增加少数类样本的数量,从而平衡数据集。SMOTE通过在特征空间中找到少数类样本的近邻,然后在这些近邻之间插值来生成新的少数类样本。这样就可以增加少数类样本的数量,同时也能保持数据集的多样性,从而提高模型的性能。