scikit-learn 入门:Pipeline、交叉验证与网格搜索
前面的四节用 statsmodels 做统计推断:估计系数、算标准误、给 p 值和置信区间。推断的目标是解释「自变量每变化一个单位,因变量平均变化多少」。scikit-learn 面向另一个问题:给定一批新样本,预测它的结果,并判断这个预测器在没见过的数据上能有多准。两个库不冲突,日常研究里通常都要用。
估计器:一套统一的接口
Section titled “估计器:一套统一的接口”scikit-learn 里所有模型都叫估计器(estimator),都遵守同一套方法约定:
fit(X, y)拟合模型,返回估计器自身predict(X)返回预测结果score(X, y)返回默认评价指标,分类器给准确率,回归器给 R²transform(X)只做数据变换,不预测,用于预处理和无监督方法
约定里 X 永远是二维结构(样本 × 特征),y 是一维。fit 返回自身这一点让链式调用成为可能,也解释了为什么 LogisticRegression().fit(X, y).predict(X) 可以直接连写。
from sklearn.datasets import load_breast_cancer
data = load_breast_cancer(as_frame=True)print(data.frame.shape)print(data.target_names.tolist())print(data.frame["target"].value_counts().to_dict())(569, 31)['malignant', 'benign']{1: 357, 0: 212}该数据集有 569 例乳腺肿物穿刺样本、30 个形态学特征,二分类标签 0 是恶性(malignant)、1 是良性(benign)。30 个特征里没有分类变量,后面的例子可以直接送进模型;真实数据里有分类变量时,得先用 OneHotEncoder 转成数值。
划分训练集与测试集
Section titled “划分训练集与测试集”用同一批数据同时做拟合和评估,得到的是模型记住数据的程度,不是它预测新样本的能力。train_test_split 把数据切开:
from sklearn.model_selection import train_test_split
X, y = data.data, data.targetX_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42, stratify=y)print(X_train.shape, X_test.shape)print(y_train.value_counts(normalize=True).round(4).to_dict())print(y_test.value_counts(normalize=True).round(4).to_dict())(398, 30) (171, 30){1: 0.6281, 0: 0.3719}{1: 0.6257, 0: 0.3743}stratify=y 是分类任务里必须加的参数。它让两边的类别比例与原始数据一致:不加的话,随机切分可能让某一类在测试集里明显偏少,准确率会随划分方式跳动几个百分点,比较模型时无法区分是模型差异还是划分运气。random_state 固定随机种子,保证每次运行得到同一份划分——分析报告里的数字要能被别人复现。
只看训练集表现会高估多少
Section titled “只看训练集表现会高估多少”先看一个近邻分类器:
from sklearn.neighbors import KNeighborsClassifier
knn = KNeighborsClassifier(n_neighbors=5)print(knn)knn.fit(X_train, y_train)print(round(knn.score(X_train, y_train), 4))print(round(knn.score(X_test, y_test), 4))KNeighborsClassifier()0.94970.924KNeighborsClassifier() 是估计器的字符串表示,冒号里没有内容表示全部使用默认参数。训练集准确率 0.9497,测试集 0.924,差 2.6 个百分点。
换成不限制深度的决策树,差距会拉开:
from sklearn.tree import DecisionTreeClassifier
tree = DecisionTreeClassifier(random_state=0)tree.fit(X_train, y_train)print(round(tree.score(X_train, y_train), 4))print(round(tree.score(X_test, y_test), 4))print(tree.get_depth(), tree.get_n_leaves())1.00.91816 16训练集准确率 1.0,测试集 0.9181。决策树不停分裂直到每个叶子节点只剩同一类样本,训练集自然被完全记住。get_depth() 给出树深 6,get_n_leaves() 给出 16 个叶节点——398 个训练样本被切成 16 组,平均每组 25 个,模型描述的已经是这批样本的个别特征,不是总体的规律。这就是过拟合(overfitting):训练集表现与实际泛化能力脱钩,训练集越完美,往往说明模型越贴噪声。
预处理必须放进 Pipeline
Section titled “预处理必须放进 Pipeline”KNeighborsClassifier 按距离计算,各特征量纲不同会让数值大的特征主导距离。mean area 的量级在 1000 上下,mean smoothness 在 0.1 上下,不做标准化,后者对距离几乎没有贡献。
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()scaler.fit(X_train)print(round(scaler.mean_[0], 3), round(scaler.scale_[0], 3))
knn.fit(scaler.transform(X_train), y_train)print(round(knn.score(scaler.transform(X_test), y_test), 4))14.094 3.4850.9591标准化把每个特征减去训练集均值、除以训练集标准差。scaler.mean_ 是第一个特征 mean radius 在训练集上的均值 14.094,scaler.scale_ 是标准差 3.485。标准化后同一个近邻模型从 0.924 升到 0.9591。
这里有个容易写错的地方:scaler.fit() 必须只用训练集,测试集走 transform()。如果对全体数据先做一次 fit_transform 再切分,测试集的均值和标准差就参与了训练,测试集不再是「没见过的数据」,评估结果会偏乐观。这类问题叫数据泄漏(data leakage)。
标准化泄漏的信息有限,换成特征选择会放大到离谱的程度。把预处理和模型绑成一个对象的工具叫 Pipeline,它的完整用法在下一节,先看它解决的问题:
import numpy as npfrom sklearn.feature_selection import SelectKBest, f_classiffrom sklearn.linear_model import LogisticRegressionfrom sklearn.model_selection import cross_val_score, StratifiedKFoldfrom sklearn.pipeline import Pipeline
rng = np.random.default_rng(0)Xn = rng.normal(size=(60, 5000)) # 5000 个纯随机特征yn = rng.integers(0, 2, size=60) # 标签与特征完全无关cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=0)
honest = Pipeline([ ("select", SelectKBest(f_classif, k=10)), ("clf", LogisticRegression(max_iter=1000)),])print(round(cross_val_score(honest, Xn, yn, cv=cv, scoring="accuracy").mean(), 4))
X_sel = SelectKBest(f_classif, k=10).fit_transform(Xn, yn)leaked = LogisticRegression(max_iter=1000)print(round(cross_val_score(leaked, X_sel, yn, cv=cv, scoring="accuracy").mean(), 4))0.66670.9333标签是 rng.integers 随机生成的,与特征没有任何关系,任何诚实的评估都该给出接近 0.5 的准确率。写进 Pipeline 的那份给出 0.6667,先对全体数据挑特征的那份给出 0.9333。后者挑出的 10 个特征是「在包含验证折的全部 60 个样本上」与标签相关性最高的,验证折的标签信息已经渗进了特征里。真实研究里这种错误不会这么极端,只会让 AUC 悄悄高零点零几,而审稿人无法从报告里看出来。
Pipeline:把步骤串成一个估计器
Section titled “Pipeline:把步骤串成一个估计器”Pipeline 把预处理和模型组成一个对象,对外表现得像一个估计器:
from sklearn.pipeline import Pipelinefrom sklearn.preprocessing import StandardScalerfrom sklearn.linear_model import LogisticRegression
pipe = Pipeline([ ("scaler", StandardScaler()), ("clf", LogisticRegression(max_iter=1000)),])pipe.fit(X_train, y_train)print(round(pipe.score(X_test, y_test), 4))print(list(pipe.named_steps))print(pipe.named_steps["clf"].coef_.shape)0.9883['scaler', 'clf'](1, 30)调用 pipe.fit(X_train, y_train) 时,Pipeline 依次执行 scaler.fit_transform(X_train) 和 clf.fit(...);调用 pipe.predict(X_test) 时,执行 scaler.transform(X_test) 再 clf.predict(...)。测试集只会被 transform,不会被 fit,上一节的泄漏问题从写法上被排除掉了。
named_steps 是步骤名到对象的字典,取中间结果用它:pipe.named_steps["clf"].coef_ 拿到系数矩阵,形状 (1, 30)——一个二分类器对应一行系数、30 个特征。步骤名不能省,交叉验证和网格搜索都靠名字定位参数。
交叉验证:别把分数寄托在一次划分上
Section titled “交叉验证:别把分数寄托在一次划分上”只切一次,测试集只有 171 个样本,准确率的波动可能有好几个百分点。交叉验证(cross-validation)把训练集分成 k 折,轮流留一折做验证:
scores = cross_val_score(pipe, X_train, y_train, cv=cv, scoring="accuracy")print(np.round(scores, 4))print(round(scores.mean(), 4), round(scores.std(), 4))[0.975 0.9375 0.975 0.9747 0.962 ]0.9648 0.0146五折的分数从 0.9375 到 0.975,标准差 0.0146。报告均值 0.9648 时报上标准差,读者才知道这个数字有多稳。只给一个平均值,等于隐藏了模型对数据划分的敏感程度。
cv 用 StratifiedKFold(n_splits=5, shuffle=True, random_state=0) 构造,分类任务指定分层折,回归任务用普通 KFold。shuffle=True 打乱顺序后才分折,避免原始数据的排列(比如按时间、按批次录入)让某一折全是同类样本。
注意 cross_val_score 内部对每一折都重新 fit,所以在 Pipeline 上调用它,标准化统计量逐折独立计算,这正是想要的。
网格搜索:把调参写成循环
Section titled “网格搜索:把调参写成循环”C 是逻辑回归的正则化强度倒数,越小惩罚越强、模型越保守。取值靠猜不如穷举:
from sklearn.model_selection import GridSearchCV
param_grid = {"clf__C": [0.01, 0.1, 1, 10, 100]}gs = GridSearchCV(pipe, param_grid, cv=cv, scoring="accuracy")gs.fit(X_train, y_train)print(gs.best_params_)print(round(gs.best_score_, 4))print(round(gs.score(X_test, y_test), 4)){'clf__C': 0.1}0.97240.9825参数名的写法是 步骤名__参数名,双下划线:clf__C 表示 Pipeline 中名为 clf 那一步的 C 参数。这是上一步强调步骤名不能省的原因——名字是网格搜索定位参数的唯一途径。
best_score_ 是五个 C 值里交叉验证平均分最高的那个,0.9724。best_params_ 给出最优取值 0.1。此时 gs 已经用最优参数在整个训练集上重新拟合,可以直接当模型用,所以 gs.score(X_test, y_test) 返回测试集准确率 0.9825。
网格搜索本身会引入一层选择偏差:C 是在同一批验证折上挑出来的,best_score_ 比真实泛化能力略高。所以测试集只在最后用一次,不要拿它反复比较模型。
完整结果:混淆矩阵与分类报告
Section titled “完整结果:混淆矩阵与分类报告”准确率把两类错误混在一起。混淆矩阵分开展示:
from sklearn.metrics import confusion_matrix, classification_report
y_pred = gs.predict(X_test)print(confusion_matrix(y_test, y_pred))print(classification_report(y_test, y_pred, target_names=data.target_names, digits=3))[[ 62 2] [ 1 106]] precision recall f1-score support
malignant 0.984 0.969 0.976 64 benign 0.981 0.991 0.986 107
accuracy 0.982 171 macro avg 0.983 0.980 0.981 171weighted avg 0.982 0.982 0.982 171混淆矩阵的行是真实类别、列是预测类别,target_names 顺序与标签 0、1 对应。第一行说明 64 例恶性里有 62 例判对、2 例判成良性;第二行说明 107 例良性里 106 例判对、1 例判成恶性。
医学场景里这两类错误的代价完全不同:把恶性判成良性的假阴性(2 例)会延误治疗,把良性判成恶性的假阳性(1 例)只带来一次额外复查。报告结果时要说明你更在意哪一个,并据此调整判定阈值。
分类报告给出三个指标:精确率(precision)是「判为该类的样本里有多少真的属于该类」,召回率(recall)是该类样本被找出来的比例,F1 是两者的调和平均。恶性类的召回率 0.969 意味着 3.1% 的恶性样本被漏掉,这个数字比总准确率 0.982 更值得写进结论。macro avg 与 weighted avg 的区别在于是否按类别样本量加权:两类样本量接近时差异很小,类别不平衡时必须以 macro avg 或少数类的指标为准。
无监督:没有 y 的估计器
Section titled “无监督:没有 y 的估计器”聚类、降维、异常检测没有标签,接口只差在 fit 不接受 y,并用 transform 取结果:
from sklearn.decomposition import PCA
pca = PCA(n_components=2, random_state=0)X_p = pca.fit_transform(StandardScaler().fit_transform(X_train))print(X_p.shape)print(np.round(pca.explained_variance_ratio_, 4))(398, 2)[0.4516 0.1963]30 个特征压缩成 2 个主成分,第一个解释 45.16% 的方差,第二个解释 19.63%,合计 64.79%。fit_transform 是 fit 与 transform 的合并写法,只在训练集上用;测试集要单独调 pca.transform(X_test),用训练集得到的主成分方向投影。
和 statsmodels 的分工
Section titled “和 statsmodels 的分工”两个库处理同一批数据,回答的问题不同:
statsmodels的results.pvalues、results.conf_int()给出系数的不确定性和显著性,适合回答「这个变量的效应有多大、是否可信」。写论文的方法与结果部分用它。scikit-learn的coef_只有点估计,没有标准误,也没有 p 值。它的强项是预测、交叉验证、统一接口和调参,适合回答「这个预测器有多准」。
同一个线性问题两边都能做,结论应该一致。用 statsmodels 判断「哪个变量重要」,用 scikit-learn 判断「这个模型能不能用」,是常见组合。逻辑回归的推断写法见 /python/modeling/logistic-regression,方差分析用 statsmodels 的写法见 /python/modeling/anova。
这篇覆盖的是有监督学习的标准流程:划分、Pipeline、交叉验证、网格搜索。统计推断的部分到此结束,如果要把分析写成可交付的报告,接着看 /python/reporting/notebook-export;R 语言用混合效应模型处理重复测量与嵌套数据的做法,见 /r/modeling/mixed-models,那类数据的独立性假设不成立,train_test_split 的随机划分同样不适用,需要按个体分组切分。