2.5 模型选择 (Model Selection) 本节摘要:模型选择要回答的不是"哪个算法最厉害",而是"在我的数据上,哪套配置放到新数据上最稳"。它把三件事串成一条线:先切分数据、再用交叉验证给出可信分数、最后用网格搜索或随机搜索找出好超参数。核心矛盾是过拟合与欠拟合——一个把训练集背得太熟,一个连训练集都没学进去。本节给出诊断这两者、以及一步步逼近平衡点的可操作方法,说白了就是给模型挑一条既不过火、也不偷懒的配置。
本节摘要:模型选择要回答的不是"哪个算法最厉害",而是"在我的数据上,哪套配置放到新数据上最稳"。它把三件事串成一条线:先切分数据、再用交叉验证给出可信分数、最后用网格搜索或随机搜索找出好超参数。核心矛盾是过拟合与欠拟合——一个把训练集背得太熟,一个连训练集都没学进去。本节给出诊断这两者、以及一步步逼近平衡点的可操作方法,说白了就是给模型挑一条既不过火、也不偷懒的配置。
阅读完本节,你应当能够:
初学者容易把模型选择理解成"翻算法菜单":线性回归、决策树、支持向量机、随机森林,挨个试一遍,谁分高用谁。这么想没错,但只对了一半。真正的模型选择,是选一个在没见过的新数据上表现最好的组合——算法只是其中一维,还有超参数、特征、预处理方式,都是变量。
这里的核心矛盾,是训练误差和泛化误差的拉扯。把模型想成一个学生:他可以把整本习题集的答案背下来,平时测验满分,可一到高考遇到没见过的题就崩——这叫过拟合,模型把噪声和偶然性也学进去了。反过来,如果他连习题集都没吃透,平时就及不了格,上了考场自然更不行——这叫欠拟合,模型太简单,连规律都没抓住。
模型选择的目标,就是在"背得太死"和"学得太浅"之间找一个平衡点。怎么找?分三步走:先切数据,再量分数,最后调参数。下面按这个顺序展开。
第一步最简单也最容易做错。把数据分成训练集和测试集,训练集用来喂模型,测试集假装成"未来上线后的新数据",全程不让模型碰,只在最后评估一次。
from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42, stratify=y )
几个参数值得记住:test_size 决定测试集比例,小数据集常取 0.2 到 0.3;random_state 固定随机种子,保证每次切分一致、结果能复现;stratify=y 让训练集和测试集保持各类别比例一致,分类任务强烈建议加上,否则小类别可能全被切进某一侧。
留出法的问题也明显:分数受这一刀切在哪影响太大。 数据小了,换个种子分数就漂。更隐蔽的是,如果你反复在同一个测试集上调参、再回来看分数,测试集其实已经"泄题"了——它不再是干净的新数据,你的选择被它带偏了。这就是为什么后面要用交叉验证,把"挑配置"这件事从测试集里剥离出来。
顺带说个切分比例的经验:数据量大时,测试集取 20% 甚至 10% 就够,剩下的都留给训练;数据小时,宁可测试集稍大些,也别让它只剩几十个样本,否则最后那个分数根本不可信。train_test_split 还支持 train_size,两者取一个即可,不用都写。还有 stratify,分类任务强烈建议带上——它保证正负例比例在两边一致,否则小类别可能被整个切进某一侧,训练集里干脆没有它,模型学了个寂寞。
交叉验证的思路上一节讲过:把训练数据再切 K 折,轮换当验证集,取平均分。它在这里的用途是比较候选模型——不用等到最后碰测试集,就能给出一个相对可信的排名。
from sklearn.model_selection import cross_val_score from sklearn.tree import DecisionTreeClassifier from sklearn.linear_model import LogisticRegression for name, model in [("决策树", DecisionTreeClassifier()), ("逻辑回归", LogisticRegression(max_iter=1000))]: scores = cross_val_score(model, X_train, y_train, cv=5) print(name, scores.mean())
看平均分的同时,别忘了看分数的波动。两个模型平均分都是 0.90,一个五折是 0.88 到 0.92,另一个是 0.80 到 1.00,前者显然更稳,更值得信任。只看均值、不看方差,是选型时常见的偷懒。
选型这件事上,我更倾向先把复杂模型和简单模型都拉进来跑一轮 CV 兜底——有时候一个调得好的线性模型,分数不输没调过的树模型,而它解释性更好、跑得更快。别默认"更复杂的算法一定更好",先让交叉验证说话。
想进一步诊断过拟合,可以让 cross_validate 同时返回训练集和验证集分数:
from sklearn.model_selection import cross_validate res = cross_validate(model, X_train, y_train, cv=5, return_train_score=True) print(res["train_score"].mean(), res["test_score"].mean())
训练分和验证分之间的差,就是过拟合的体温计:训练分逼近满分、验证分低一大截,说明模型在背题;两个都低,说明还没学会。选型时不光挑验证分高的,还该挑这个差小的。
选定了算法,剩下的超参数怎么定?一个个手试太低效,用搜索工具让交叉验证自动帮你跑。scikit-learn 提供了两个主力:
from sklearn.model_selection import GridSearchCV from sklearn.ensemble import RandomForestClassifier grid = { "n_estimators": [50, 100, 200], "max_depth": [None, 5, 10], } search = GridSearchCV(RandomForestClassifier(random_state=42), grid, cv=5, scoring="f1", n_jobs=-1) search.fit(X_train, y_train) print(search.best_params_) print(search.best_score_)
跑完后有三个属性最常用:best_params_ 是最佳超参数组合,best_score_ 是它在交叉验证上的平均分,best_estimator_ 是已经用最佳参数在整个训练集上重新训练好的模型,可以直接拿去预测。
两种搜索怎么选,看这张表:
| 维度 | 网格搜索 | 随机搜索 |
|---|---|---|
| 搜索方式 | 穷举所有组合 | 随机抽样固定数量 |
| 是否保证最优 | 网格内保证 | 不保证,通常接近 |
| 计算量 | 随组合数爆炸 | 可控,只取决于抽样数 |
| 适合场景 | 参数少、范围小 | 参数多、范围大 |
| 连续参数 | 只能取离散点 | 可用分布采样 |
一个很实用的组合拳:先用随机搜索在宽范围里粗扫一轮,摸清哪个方向有戏,再用网格搜索在小范围里精扫。 这比一上来就上密集网格划算得多——密集网格的大部分组合其实是浪费的。
两个参数再补一句。n_jobs=-1 让搜索用满所有 CPU 核,能成倍提速;refit=True(默认)会在找到最佳参数后用整个训练集重新训练一个最终模型,存进 best_estimator_ 供你直接拿去预测。还要分清两个分数:best_score_ 是交叉验证上的平均分,把 best_estimator_ 放到测试集上得到的分数才是对未来表现的估计,别把前者当成上线依据。
调参调不动时,先别急着加算力,回头判断一下模型处在哪一端。
| 现象 | 训练集表现 | 验证集表现 | 成因 | 对策 |
|---|---|---|---|---|
| 过拟合 | 很高 | 明显偏低 | 模型太复杂、数据太少、噪声多 | 加正则、减复杂度、加数据、早停 |
| 欠拟合 | 偏低 | 也偏低 | 模型太简单、特征不够、没训够 | 换更强模型、加特征、多迭代 |
判断的关键,是同时看训练集和验证集两个分数。如果训练集接近满分、验证集一塌糊涂,是过拟合,模型"背题"了;如果两个都低,是欠拟合,模型"没学会"。只盯一个分数是看不出端倪的。
拿决策树的 max_depth 做个实验最直观。深度从 1 一点点加到 30,训练分数会一路涨到接近满分,可验证分数先升后降,在某个中等深度到达顶点之后开始往下掉。那个顶点,就是复杂度最合适的点,调参本质上就是找到它。所以过拟合不是"模型变坏了",而是"复杂度越过了拐点"。
from sklearn.tree import DecisionTreeClassifier for d in [1, 3, 5, 10, None]: m = DecisionTreeClassifier(max_depth=d, random_state=42) m.fit(X_train, y_train) print(d, m.score(X_train, y_train), m.score(X_test, y_test))
跑完这几行,你会亲眼看到训练分和测试分随着深度拉开的剪刀差,比任何文字都更有说服力。
⚠️ 常见坑:把测试集当验证集反复用。每次你在测试集上看分数再回去改模型,测试集就泄漏了一点信息,几轮之后它就不再干净,最终上线分数会比测试分数难看。正确做法是:验证交给交叉验证,测试集只在最后碰一次。
⚠️ 常见坑:调参范围拍脑袋定得太宽或太窄。太宽,算力白白烧掉;太窄,最优解可能在边界外。先跑一次默认参数拿到基线,再围绕它放缩范围。
💡 关键直觉:模型选择不是一次性的"选一个",而是"先圈范围、再逼近"的迭代。粗搜定方向,精搜定落点,测试集最后只验收一次,这条纪律能省掉大量返工。
前面所有交叉验证都默认一个前提——样本之间相互独立、可以任意打乱。有两种数据打破了这个前提,硬套常规 K 折会得到虚高甚至完全错误的结论。
一种是时间序列。今天的数据和昨天强相关,随机切折会让模型"用未来预测过去",等于作弊。正确做法是按时间顺序切:只用过去的数据训练,去预测未来的数据,也就是时序交叉验证。另一种是分组数据,比如同一用户的多条记录。如果同一用户的样本散落在训练折和验证折里,模型只要记住用户是谁就能"猜对",分数虚高。这时要用分组交叉验证,保证同一组的样本不跨折。
判断标准一句话:如果样本不是独立同分布,先想清楚"泄漏的通道"在哪,再选对应的划分策略。 这两类数据的评估细节,留到后续实践章再展开。记住一条总原则就好:数据的结构决定了你能用哪种划分方式,先看清数据长什么样,再决定怎么切、怎么评,别拿标准 K 折硬套一切。
下一节我们把"预处理 + 模型"打包成管道,让交叉验证和调参不再担心数据泄漏。