使用学习曲线和验证曲线调试算法
本节中,我们来看两个非常简单但强大的诊断工具,可帮助我们提升学习算法的性能:学习曲线和验证曲线,在接下的小节中,我们会讨论如何使用学习曲线诊断学习算法是否有过拟合(高方差)或欠拟合(高偏置)的问题。另外,我们还会学习验证曲线,它辅助我们处理学习算法中的常见问题。
通过学习曲线诊断偏置和方差问题
如果模型对给定训练数据集过于复杂,比如非常深的决策树,模型会倾向于过度拟合训练数据,但对未见数据的泛化不佳。通常,收集更多的训练样本可降低过拟合的程度。
但在实践中,采集更多数据通常很昂贵或根本就不可行。通过将模型训练和验证精度按训练数据集大小的函数进行绘制,我们可以轻易地监测出模型是否存在高方差或高偏置,以及收集更多的数据是否有助于解决这一问题。
但在讨论如何在scikit-learn中绘制学习曲线之前,我们通过下图先讨论这两种常见的模型问题:
图6.4:常见模型问题
左上角的图显示模型有高偏置。这个模型的训练精度和交叉验证精度都低,说明对训练数据是欠拟合的。处理这一问题的常见方式是增加模型参数,比如通过采集或构建更多的特征或在支持向量机(SVM) 或逻辑回归分类器中降低正则化程度。
右上角的图显示模型存在高方差问题,可通过训练精度和交叉验证精度之间的大间隔看出。要解决过拟合问题,我们可以收集更多的训练数据,减少模型的复杂度或增加正则化参数。
对于非正则化模型,可以通过特征选择(第4章)或特征提取(第5章)来降低过拟合程序来减少特征数。虽然采集更多的训练数据通常能降低过拟合的机率,但并不总是有效,比如,如果训练数据噪音极高或是模型已经非常接近最优。
在下面的小节中,我们会学习如何使用验证曲线来处理这些模型,但我们先来学习如何使用scikit-learn中的学习曲线函数来评估模型:
>>> import matplotlib.pyplot as plt
>>> from sklearn.model_selection import learning_curve
>>> pipe_lr = make_pipeline(StandardScaler(),
... LogisticRegression(penalty='l2',
... max_iter=10000))
>>> train_sizes, train_scores, test_scores =\
... learning_curve(estimator=pipe_lr,
... X=X_train,
... y=y_train,
... train_sizes=np.linspace(
... 0.1, 1.0, 10),
... cv=10,
... n_jobs=1)
>>> train_mean = np.mean(train_scores, axis=1)
>>> train_std = np.std(train_scores, axis=1)
>>> test_mean = np.mean(test_scores, axis=1)
>>> test_std = np.std(test_scores, axis=1)
>>> plt.plot(train_sizes, train_mean,
... color='blue', marker='o',
... markersize=5, label='Training accuracy')
>>> plt.fill_between(train_sizes,
... train_mean + train_std,
... train_mean - train_std,
... alpha=0.15, color='blue')
>>> plt.plot(train_sizes, test_mean,
... color='green', linestyle='--',
... marker='s', markersize=5,
... label='Validation accuracy')
>>> plt.fill_between(train_sizes,
... test_mean + test_std,
... test_mean - test_std,
... alpha=0.15, color='green')
>>> plt.grid()
>>> plt.xlabel('Number of training examples')
>>> plt.ylabel('Accuracy')
>>> plt.legend(loc='lower right')
>>> plt.ylim([0.8, 1.03])
>>> plt.show()
注意我们在实例化LogisticRegression
对象(默认迭代1000次)时传递了一个额外参数max_iter=10000
,来避免小数据集或极端正则化参数值的收敛问题(在下一节中会讲解)。成功执行以上代码后,会获得如下的学习曲线图:
图6.5:展示训练和验证数据集精度对训练样本数的学习曲线
通过learning_curve
函数中的train_sizes
参数,我们可以控制用于生成学习曲线的训练样本绝对或相对数。这里,我们设置train_sizes=np.linspace(0.1, 1.0, 10)
来使用对训练数据集大小做10个空间上均分的相对间隔。默认,learning_curve
函数使用分层k-折交叉验证计算分类器的交叉验证精度,我们通过cv
参数设置k = 10用于10-折分层交叉验证。
然后,我们通过返回的训练数据集不同大小的交叉验证训练和测试分数来计算平均精度,使用Matplotlib的plot
函数进行了绘制。此外,我们添加了平均精度的标准差绘图,使用fill_between
函数来表明评估的方差。
在上面的学习曲线图中可以看出,在训练时如果250个以上的样本时模型对训练和验证数据集的表现都很好。还可以看到样本小于250个时训练数据集的训练精度会上升,验证精度和训练精度的间隔会拉大,这表示过拟合的程度在上升。
处理验证曲线的过拟合和欠拟合
验证曲线是通过解决过拟合或欠拟合问题提升模型表现很有用的工具。验证曲线与学习曲线相关,但它绘制的是样本大小对训练和测试精度的函数,我们变化模型参数的值,比如逻辑回归中的逆向正则化参数C
。
我们下面学习如何通过scikit-learn创建验证曲线:
>>> from sklearn.model_selection import validation_curve
>>> param_range = [0.001, 0.01, 0.1, 1.0, 10.0, 100.0]
>>> train_scores, test_scores = validation_curve(
... estimator=pipe_lr,
... X=X_train,
... y=y_train,
... param_name='logisticregression__C',
... param_range=param_range,
... cv=10)
>>> train_mean = np.mean(train_scores, axis=1)
>>> train_std = np.std(train_scores, axis=1)
>>> test_mean = np.mean(test_scores, axis=1)
>>> test_std = np.std(test_scores, axis=1)
>>> plt.plot(param_range, train_mean,
... color='blue', marker='o',
... markersize=5, label='Training accuracy')
>>> plt.fill_between(param_range, train_mean + train_std,
... train_mean - train_std, alpha=0.15,
... color='blue')
>>> plt.plot(param_range, test_mean,
... color='green', linestyle='--',
... marker='s', markersize=5,
... label='Validation accuracy')
>>> plt.fill_between(param_range,
... test_mean + test_std,
... test_mean - test_std,
... alpha=0.15, color='green')
>>> plt.grid()
>>> plt.xscale('log')
>>> plt.legend(loc='lower right')
>>> plt.xlabel('Parameter C')
>>> plt.ylabel('Accuracy')
>>> plt.ylim([0.8, 1.0])
>>> plt.show()
使用以上的代码,我们获取参数C
的验证曲线图:
图6.6: SVM超参数C的验证曲线图
类似于learning_curve
函数,validation_curve
函数默认使用分层k-折交叉验证来评估分类器的表现。在validation_curve
函数内,我们指定待评估的参数。本例中为C
,LogisticRegression
分类器的逆向正则化参数,写作logisticregression__C
,用于访问s通过param_range
参数设置的指定范围值 scikit-learn管道中的LogisticRegression
对象。类似前一节中的学习曲线示例,我们绘制了训练和交叉验证精度与相关的标准差。
虽然不同C
值的精度判别分别小,我们还是可以看出在增加正则化强度(小C
值)时模型会有轻微的欠拟合。但在C
为大值时,就表示降低正则化强度,因此模型会趋向对数据轻微的过拟合。本例中,最佳点位于C
值在0.1和1.0之间。
通过网格搜索调优机器学习模型
在机器学习领域,有两种类型的参数:通过训练数据学习的参数,如逻辑回归中的权重,和单独优化的学习算法参数。后者是模型的调优参数(或超参数),如逻辑回归中的正则化参数或决策树中的最大深度参数。
在上一节中,我们使用验证曲线通过调优一种超参数来提升模型的性能。本节中,我们会研究一种流行的超参数优化技术,称为网格搜索,可通过查找超参数值的最优组合来提升模型表现。
通过网格搜索调优超参数
网格搜索方法相当简单:是一种暴力穷举搜索范式,可指定不同超参数的值列表,然后计算机评估每种组合的模型表现来获取集合中值的最优组合:
>>> from sklearn.model_selection import GridSearchCV
>>> from sklearn.svm import SVC
>>> pipe_svc = make_pipeline(StandardScaler(),
... SVC(random_state=1))
>>> param_range = [0.0001, 0.001, 0.01, 0.1,
... 1.0, 10.0, 100.0, 1000.0]
>>> param_grid = [{'svc__C': param_range,
... 'svc__kernel': ['linear']},
... {'svc__C': param_range,
... 'svc__gamma': param_range,
... 'svc__kernel': ['rbf']}]
>>> gs = GridSearchCV(estimator=pipe_svc,
... param_grid=param_grid,
... scoring='accuracy',
... cv=10,
... refit=True,
... n_jobs=-1)
>>> gs = gs.fit(X_train, y_train)
>>> print(gs.best_score_)
0.9846153846153847
>>> print(gs.best_params_)
{'svc__C': 100.0, 'svc__gamma': 0.001, 'svc__kernel': 'rbf'}
使用以上代码,我们初始化了sklearn.model_selection
模块中的GridSearchCV
对象来训练和调优SVM管道。我们将GridSearchCV
的param_grid
参数设置为一个字典列表,以指定希望调优的参数。对于线性SVM,我们只评估逆正则化参数C
,对于径向基函数(RBF)核SVM,我们调优svc__C
及svc__gamma
参数。注意svc__gamma
是核SVM特有的。GridSearchCV
使用k-折交叉验证对比不同超参数设置所训练的模型。通过设置cv=10
,会执行10-折交叉验证及(通过scoring='accuracy'
) 计算10个折叠的平均精度来评估模型表现。我们设置n_jobs=-1
,这样GridSearchCV
可以使用所有的处理器核心来通过并行拟合各折叠加速网格搜索,你也可以设置n_jobs
来仅使用单处理器。
在使用训练数据执行网格搜索后,我们通过best_score_
属性获取到了最佳表现模型的分数,通过best_params_
属性可获取到相应的参数。在本例中,RBF核SVM模型在svc__C = 100.0
时产生了最佳的k-折交叉验证精度:98.5%。
最后,我们使用独立测试数据集来评估最优选模型的表现,可通过GridSearchCV
对象的best_estimator_
属性获取:
>>> clf = gs.best_estimator_
>>> clf.fit(X_train, y_train)
>>> print(f'Test accuracy: {clf.score(X_test, y_test):.3f}')
Test accuracy: 0.974
请注意在训练集上使用最佳设置(gs.best_estimator_
) 拟合模型,完成网格搜索后手动设置clf.fit(X_train, y_train)
并非必需的。GridSearchCV
类有一个refit
参数,如果设置refit=True
(默认值)会自动对整个训练集重新拟合gs.best_estimator_
。
使用随机搜索更广泛地探索超参数配置
因网格搜索是一种穷举搜索,只要用户指定参数网格中就一定能持续到最优超参数配置。但指定大型超参数网格在实践中是开销很大。一种采样不同参数组合的替代方法是随机搜索。在随机搜索中,我们通过分布(或离散集合)随机提取超参数配置。这样让我们能以成分和时间更高效的方式探索超参数值设置更广泛的范围。这一概念见图6.7,它显示了网格搜索和随机搜索的9个超参数设置固定网格:
图6.7: 分别采样9个不同参数配置的网格搜索与随机搜索对比
重点是虽然网格搜索只探讨离散的、用户指定的选项,但如果搜索空间太稀疏,可能会错过良好的超参数配置。感兴趣的读者可以在以下文章中找到有关随机搜索的详细信息和实证研究: 《用随机搜索优化超参数》,J. Bergstra, Y. Bengio, 《机器学习研究杂志》,2012年,第281-305页,https://www.jmlr.org/papers/volume13/bergstra12a/bergstra12a。
我们来看如何使用随机搜索调优SVM。Scikit-learn实现了一个RandomizedSearchCV
类,类似于我们在前面的小节中使用的GridSearchCV
。主要的区别在于,我们可以将分布指定到参数网格中,并指定要评估的超参数配置的总数。例如,我们考虑前面小节网格搜索示例中用于调优SVM的几个超参数的范围:
>>> import scipy.stats
>>> param_range = [0.0001, 0.001, 0.01, 0.1,
... 1.0, 10.0, 100.0, 1000.0]
注意,虽然RandomizedSearchCV
可以接收类似的离散值列表作为参数网格的输入,这在考虑分类超参数时非常有用,但其主要优势在于我们可以用分布来替换这些列表进行采样。因此,例如我们可以使用SciPy中的以下分布来替换前面的列表:
>>> param_range = scipy.stats.loguniform(0.0001, 1000.0)
例如,使用对数均匀分布而非常规均匀分布将确保在足够数量的试验中,从[0.0001, 0.001]中抽取的样本数量与从[10.0, 100.0]中抽取的样本数量相同。为检查其行为,我们可以通过rvs(10)
方法从该分布中抽取10个随机样本,如下所示:
>>> np.random.seed(1)
>>> param_range.rvs(10)
array([8.30145146e-02, 1.10222804e+01, 1.00184520e-04, 1.30715777e-02,
1.06485687e-03, 4.42965766e-04, 2.01289666e-03, 2.62376594e-02,
5.98924832e-02, 5.91176467e-01])
指定分布
RandomizedSearchCV
支持任意分布,只要我们可以通过调用rvs()
方法从中进行采样即可。scipy.stats
包含的所有分布列表请见:https://docs.scipy.org/doc/scipy/reference/stats.html#probability-distributions。
下面我们实际查看RandomizedSearchCV
并像前一节中的GridSearchCV
那样调优SVM:
>>> from sklearn.model_selection import RandomizedSearchCV
>>> pipe_svc = make_pipeline(StandardScaler(),
... SVC(random_state=1))
>>> param_grid = [{'svc__C': param_range,
... 'svc__kernel': ['linear']},
... {'svc__C': param_range,
... 'svc__gamma': param_range,
... 'svc__kernel': ['rbf']}]
>>> rs = RandomizedSearchCV(estimator=pipe_svc,
... param_distributions=param_grid,
... scoring='accuracy',
... refit=True,
... n_iter=20,
... cv=10,
... random_state=1,
... n_jobs=-1)
>>> rs = rs.fit(X_train, y_train)
>>> print(rs.best_score_)
0.9670531400966184
>>> print(rs.best_params_)
{'svc__C': 0.05971247755848464, 'svc__kernel': 'linear'}
根据这段示例代码,可以看出其使用与GridSearchCV
非常相似,区别是我们可以使用分布来指定参数范围以及通过设置n_iter=20
来指定迭代次数为20次。
通过successive halving实现资源更高效的超参数搜索
进一步拓展随机搜索的思想,scikit-learn实现了一个连续减半的变种HalvingRandomSearchCV
,在查找适合的超参数配置时更高效。连续减半是指在给定一组候选配置时,连续地淘汰较差的超参数配置,直到只剩下一个配置为止。我们可以将其流程总结为如下步骤:
- 通过随机抽样获取一组大量的候选配置。
- 使用有限资源来训练模型,例如,使用训练数据的一个小子集(而不是使用整个训练集)。
- 根据预测表现,丢弃最差的50%。
- 返回步骤2,并增加可用资源的数量。
重复以上步骤,直到只剩下一个超参数配置。注意,网格搜索变种还有一个连续减半的实现,称为HalvingGridSearchCV
,在步骤1中使用了所有指定的超参数配置,而不是随机样本。
在scikit-learn1.0中,HalvingRandomSearchCV
仍然是实验性的,因此我们需要先启用它:
>>> from sklearn.experimental import enable_halving_search_cv
(以上代码可能无法运行或者在未来的版本不再支持)
在启用了实验性支持后,我们可以使用持续减半的随机搜索,如下:
>>> from sklearn.model_selection import HalvingRandomSearchCV
>>> hs = HalvingRandomSearchCV(pipe_svc,
... param_distributions=param_grid,
... n_candidates='exhaust',
... resource='n_samples',
... factor=1.5,
... random_state=1,
... n_jobs=-1)
resource='n_samples'
(默认设置)指定训练集大小,作为在每轮之间变化的资源。通过factor
参数,我们可以决定每轮淘汰多少候选样本。例如,设置factor=2
会淘汰一半的样本,而设置factor=1.5
表示只有100% / 1.5 ≈ 66% 的样本进入下一轮。与RandomizedSearchCV
选择固定迭代次数不同,我们设置n_candidates='exhaust'
(默认值),它会采样超参数配置的数量,以便在最后一轮使用最大数量的资源(此处为训练样本)。
然后,我们可以执行类似RandomizedSearchCV
的搜索:
>>> hs = hs.fit(X_train, y_train)
>>> print(hs.best_score_)
0.9617647058823529
>>> print(hs.best_params_)
{'svc__C': 4.934834261073341, 'svc__kernel': 'linear'}
>>> clf = hs.best_estimator_
>>> print(f'Test accuracy: {hs.score(X_test, y_test):.3f}')
Test accuracy: 0.982
如果将前两个小节中GridSearchCV
和RandomizedSearchCV
的结果与HalvingRandomSearchCV
模型进行比较,可以看到后者在测试集上的表现略好一些(准确率为98.2%,大于97.4%)。
使用hyperopt进行超参数调优
另一个用于超参数优化的流行库是hyperopt(https://github.com/hyperopt/hyperopt),它实现了几种不同的超参数优化方法,包括随机搜索和树状结构Parzen 估计器(TPE)方法。TPE是一种贝叶斯优化方法,它基于过往超参数评估和相关性能评分不断更新概率模型,而不是将这些评估视为独立事件。读者可以在《超参数优化算法》 Bergstra J, Bardenet R, Bengio Y, Kegl B. NeurIPS 2011. 第2546–2554页, https://dl.acm.org/doi/10.5555/2986459.2986743中了解有关TPE的更多信息。
虽然hyperopt提供了一个通用的超参数优化接口,但还有一个针对scikit-learn的特定包,称为hyperopt-sklearn,更为便利:https://github.com/hyperopt/hyperopt-sklearn。
使用嵌套交叉验证进行算法选择
用k-折交叉验证结合网格搜索或随机搜索是一种很有用的方法,可以通过改变超参数的值来调优机器学习模型的性能,在前面的小节中我们也看到了。但如果我们希望在不同的机器学习算法之间进行选择,另一种推荐的方法是嵌套交叉验证。在一项对误差估计偏差的深入研究中,Sudhir Varma和Richard Simon得出结论,使用嵌套交叉验证时,预估的真实误差相较测试数据集几乎是无偏的(《使用交叉验证做模型选择时误差估计的偏差》,S. Varma和R. Simon,BMC Bioinformatics,7(1): 91,2006,https://bmcbioinformatics.biomedcentral.com/articles/10.1186/1471-2105-7-91)。
在嵌套交叉验证中,有一个外部的k-折交叉验证循环,将数据分割为训练折叠和测试折叠,并使用内部循环对训练折叠上使用k-折交叉验证选择模型。在选取模型之后,测试折叠用于评估模型的表现。图6.8说明了仅包含五个外部折叠和两个内部折叠的嵌套交叉验证的概念,这对于计算性能为重的大型数据集会很有用;这种特定类型的嵌套交叉验证也被称为5×2交叉验证:
图6.8:嵌套交叉验证的概念
在scikit-learn中,我们可以通过网格搜索执行嵌套交叉验证,如下:
>>> param_range = [0.0001, 0.001, 0.01, 0.1,
... 1.0, 10.0, 100.0, 1000.0]
>>> param_grid = [{'svc__C': param_range,
... 'svc__kernel': ['linear']},
... {'svc__C': param_range,
... 'svc__gamma': param_range,
... 'svc__kernel': ['rbf']}]
>>> gs = GridSearchCV(estimator=pipe_svc,
... param_grid=param_grid,
... scoring='accuracy',
... cv=2)
>>> scores = cross_val_score(gs, X_train, y_train,
... scoring='accuracy', cv=5)
>>> print(f'CV accuracy: {np.mean(scores):.3f} '
... f'+/- {np.std(scores):.3f}')
CV accuracy: 0.974 +/- 0.015
返回的平均交叉验证准确率为我们提供了一个很好的评估,可以预测到调优模型超参数并用于未知数据时所得到的结果。
例如,我们可以使用嵌套交叉验证方法来比较SVM模型和简单的决策树分类器;为了简化起见,我们只调整其深度参数:
>>> from sklearn.tree import DecisionTreeClassifier
>>> gs = GridSearchCV(
... estimator=DecisionTreeClassifier(random_state=0),
... param_grid=[{'max_depth': [1, 2, 3, 4, 5, 6, 7, None]}],
... scoring='accuracy',
... cv=2
... )
>>> scores = cross_val_score(gs, X_train, y_train,
... scoring='accuracy', cv=5)
>>> print(f'CV accuracy: {np.mean(scores):.3f} '
... f'+/- {np.std(scores):.3f}')
CV accuracy: 0.934 +/- 0.016
可以看到,SVM模型嵌套交叉验证的表现(97.4%)明显优于决策树的表现(93.4%),因此,我们预测对来自与该特定数据集相同的总体的新数据进行分类时,SVM模型可能是更优选。