给团队里做机器学习的新同学讲评估方法时,我通常会先问一个问题:你如何判断训练出来的模型在真实场景里能打?最常见的回答是——“用留出法啊,把数据切开,留一部分当测试集,跑一下指标不就知道了。”可大多数人只能说到这一步,追问下去,切分比例怎么定、要不要分层、随机种子怎么留、同一组数据能不能同时出现在两边,往往就开始支支吾吾。
机器学习里的模型评估方案不少,留出法(holdout)是最基础也最容易被想当然使用的一种。它看上去就是把数据集切成两块,但背后的边界条件其实相当多。我打算从原理、实操和翻车案例三个角度,把它彻底讲透:既说清楚它到底在估什么,也把实际项目里容易踩进去的坑一个个揪出来。
1. 训练成绩不等于实战能力:留出法想解决的核心问题
1.1 过拟合:模型很可能只是在“背答案”
早年做模型时,大家特别爱看训练集上的损失曲线,降到几乎为0的时候团队群里一片欢呼,结果模型换一批新数据就一塌糊涂。这不是玄学,而是模型的容量和训练机制决定的。模型在训练时,能做的最偷懒的一件事就是把训练样本本身“记下来”,尤其是当模型容量足够大、样本量不够多时,这种记忆很容易发生。打个比方,决策树不限制深度,训练集上可以做到100%正确,测试集上却未必能到80%,这就是过拟合。
我习惯拿学生考试做类比。平时练习册翻来覆去地刷,模拟考次次高分,但高考真题里把条件稍微改一改,就发现这个学生其实是在背题,而不是真的理解了解题思路。训练集就是那本练习册,测试集就是一份没做过的模拟卷。模型评估要做的,就是设计一份真正没做过的模拟卷,检验模型是理解了规律,还是只记住了答案。
1.2 泛化误差才是我们真正关心的指标
机器学习建模的终极目标是让模型在未知样本上表现好,而不是在旧样本上“回忆”。用统计的话说,我们要的是泛化误差:模型在一个服从真实数据分布的任意新样本上,损失函数的期望。
但泛化误差没法直接算,因为我们不可能拿到所有数据。留出法的思路很朴实:把手里已有的样本随机分成两部分,一部分用来训练,另一部分完全不让它参与训练,把这个“留出来”的部分当作未知数据的代理。测试集上的表现,就是对泛化误差的一次抽样估计。
值得强调的是,一次切分得到的精度或AUC,只是一个点估计,不是一个确定的值。因为切分带有随机性,得到的指标天然有波动。很多人把留出法跑出的0.92当作模型真实水平,这是理解偏差。更严谨的说法是:在当前的切分方式下,测试集给出了一个泛化水平的估计,这个估计可能有正负几个百分点的误差。后面我会专门讲这个波动到底能有多大。
1.3 为什么留出法能存在这么久
留出法被广泛使用,不是因为它完美,而是因为它极其务实。实现简单、几乎零计算成本、一次训练就能给出评估结果,而且在数据量足够大时,单次切分的随机波动可以被压到很小。可以说,它是机器学习里“性价比”最高的评估起点。
很多经典教材,比如周志华的《机器学习》把“模型评估与选择”单独列了一章,留出法就放在最前面,再用它引出交叉验证和自助法。它几乎是一切评估思想的骨架。理解了留出法,再理解K折交叉验证就很容易——交叉验证不过是把“留出”这一步重复了K次而已。
2. 标准留出法流程:切分、比例、分层、种子一个都不能少
2.1 先搞清楚训练集、验证集、测试集三者的分工
很多资料把训练集、验证集、测试集混着说,实际工作中会因此翻车。它们的定位是完全不同的:
- 训练集:用来拟合模型参数。
- 验证集:用来做超参数选择、提前停止、特征选择等调优决策。
- 测试集:最终评估泛化能力,不参与任何训练和调参过程。
如果数据量有限,只有训练集和测试集,很容易出现的情况是:反复用测试集调参,调着调着测试集就“变脏”了,因为调参者会根据测试结果不断改变模型,测试集的信息间接参与了建模。这就是“三层数据”在正式流程中存在的意义。标准的留出式评估会先留出一块测试集锁死,然后把剩下的数据继续按比例拆成训练集和验证集。
2.2 切分比例怎么定
提到切分比例,网上答案最多的是“7:3”“8:2”,但比这个参数更重要的是绝对样本量。切分比例本身没有绝对最优,取决于数据规模、模型复杂度和评估指标的稳定性。
我自己的经验是这样的:
- 数据量在万级别以下,测试集可以取20%到30%,但更推荐优先考虑交叉验证;
- 数据量在十万到百万级,测试集取10%到20%通常足够;
- 数据量达到百万甚至更大,测试集哪怕只取1%,绝对样本也很多,评估指标依然稳定。
为什么?因为我们真正关心的是测试集里有多少个有效样本支撑评估,而不是占比多少。假设二分类真实AUC是0.8,测试集只有50个样本,出来的结果可能围绕0.8大幅震荡;而测试集有5000个样本时,单次切分估算的波动就小得多。所以决定测试集大小的第一位标准,是“测试集里能提供多少个有效样本”,而不是那个比例数字。
2.3 分层采样:保持类别比例,尤其对不平衡数据
随机切分在理论上没问题,前提是样本独立同分布且类别分布均匀。现实中的数据往往不是这样,正样本可能只占5%。如果完全不控制,切分后训练集里可能只剩下个别正样本,模型根本学不到正类特征;或者测试集里正样本太少,指标算出来没有意义。
解决办法是分层采样。sklearn的train_test_split提供了stratify参数,可以按类别标签比例切分:
from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y )传入stratify=y之后,切分出来的训练集和测试集里,正负样本的比例会尽量接近原始数据集。这算是最基础的一步,但很多人就是会漏掉。
2.4 随机种子:让“随机切分”变成“可复现切分”
train_test_split的本质是先洗牌再切分。如果不固定random_state,每次运行代码都会得到不同的训练/测试划分,就会出现一种很尴尬的情况:你跑两次实验,模型参数一样、数据一样,评估指标却不同。这在对比调优时影响很大,因为你无法判断指标差异来自模型改动还是切分运气。
固定random_state=42不只是为了复现论文结果。在项目迭代中,固定种子能让所有方案在同一份测试集上比较,公平很多。但要注意:种子固定不等于结果就是真实水平。你可以在一个调优周期内固定种子,但模型定稿前,最好用多个不同的种子各切分几次,看指标平均值和波动范围,而不是只相信一个幸运的种子。
2.5 什么时候不能直接随机切分
随机切分有一个前提:样本之间是独立同分布的。如果你的数据里存在分组结构,例如同一个用户、同一台设备、同一个门店的多条记录,情况就变了。它们之间并不独立,模型很容易通过同一分组的历史记录“猜到”测试集中该分组的标签。这时候随机切分看似没毛病,实际已经造成泄漏。
常见处理办法是:按照实体ID先分组,再把整个组切到训练侧或测试侧。sklearn里有GroupShuffleSplit、LeaveOneGroupOut这些工具。时序数据也一样,直接用随机切分不合适,应该按时间先后切分:前一段做训练,后一段做测试,模拟真实上线时“用历史预测未来”的流程。
3. 留出法最容易被忽视的四个坑
3.1 坑一:切分后的预处理顺序错误,把测试集信息泄漏进训练集
先切分再预处理,这个原则说起来简单,实际操作中却经常被忽略。最常见的场景是:读取数据后先对全量特征做标准化或归一化,再做train_test_split。这种写法的危害在于,标准化计算用的均值和标准差来自“训练集+测试集”的全体数据,测试集的信息通过这两个统计数据进入了训练流程。模型训练时,已经间接“看”到了测试集的分布特征。
正确的做法是把数据变换器先在训练集上fit,得到均值和标准差,再transform训练集和测试集;或者直接用sklearn的Pipeline,让预处理在交叉验证流程里自动按折进行。下面这段是错误示范:
# 错误示范:全量标准化后再切分 scaler = StandardScaler() X_scaled = scaler.fit_transform(X) X_train, X_test, y_train, y_test = train_test_split( X_scaled, y, test_size=0.2, random_state=42 )正确写法:
from sklearn.preprocessing import StandardScaler from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test)特征选择、缺失值填充、降维也一样。任何需要从数据里统计数据分布的步骤,都必须严格限定在训练集内完成,然后原样套用到测试集。这条规则一旦被打破,后面所有评估结果都不可信。
3.2 坑二:类别不平衡时没有做分层,小众类别被“随机”弄丢
假设有一个二分类任务,正样本只占2%。数据集总共1万条,正样本200条。随机切分在概率上当然不会完全均匀,测试集可能只分到10条正样本,甚至极端情况为零。测试集里正样本为0,AUC、F1这类指标就完全废了。分层采样能保证比例基本一致,但比例一致也不等于万无一失。如果数据再少一些,分层后测试集里依然只有个位数正样本,即使比例正确,抽样误差依然很大。
碰到这种极端不平衡,正确的思考方向是:要么增大测试集的总样本量,要么干脆采用K折交叉验证把评估结果平均起来,而不是指望一次留出法给出可信答案。有时候,我甚至会专门从正样本中单独划分一部分进去测试集,人为保证测试集至少有一定数量的正样本,再配合分层采样。这在“正样本本来就稀缺”的场景下是合理的工程做法,但需要把规则写清楚,避免样本选择偏差。
3.3 坑三:测试集样本量太小,指标波动到没有参考价值
很多同学分配数据集时只关心比例,觉得“20%的测试集已经很多了”,但没有意识到真正决定评估可信度的是测试集里的绝对样本量。可以用一个最简单的统计模型说明这一点。
假设模型真实精度是0.85,测试集大小是n,那么测试集上观测到的正确样本数近似服从以0.85×n为均值、以sqrt(n×0.85×0.15)为标准差的二项分布。换算成精度指标,标准差是sqrt(p(1-p)/n)。当n=30、100、1000时,这个标准差分别约为0.065、0.036、0.011,也就是说95%参考区间宽度大约分别是正负13%、正负7%、正负2%。
所以,当你在一个只有几百条样本的数据集上按8:2切分,得到大约100条测试样本,0.86和0.79两个版本的模型可能根本没有显著差异。必须先估算一下指标波动区间,再判断“模型提升了”这个结论成不成立。这也是我一直建议小数据集优先考虑交叉验证,而不是一次留出法的原因。
3.4 坑四:反复更换随机种子,等于对测试集做过拟合
我见过一种非常隐蔽的坏习惯:模型调参调不出来时,有人会换random_state重跑一遍,看哪个种子的指标好看,就留下哪个。这在本质上是一种“种子上位”,比固定一个种子更危险。因为你实际上是在多个切分中选了一个对当前模型最友好的测试集,测试集已经不再是未知样本的干净代理,评估结果被严重虚高。
可能有人辩解说“只是换了个随机种子,没什么大不了”。实际上,这种行为等于在评估环节加入了人工挑选。正确做法有两种:一是固定种子,但明确指出评估结果依赖这一次切分;二是在模型确定后,用多个不同种子跑多次,报告指标均值和标准差,用波动范围来展示真实水平。后一种方式信息量大得多,也更接近留出法评估应有的严谨性。
4. 留出法不是万能钥匙:和交叉验证、自助法的适配边界
4.1 为什么交叉验证通常更稳
K折交叉验证做的事情很简单:把数据分成K份,每次拿K-1份训练、1份验证,轮流K次,最后把K次验证结果平均。相比留出法的一次切分,交叉验证相当于做了多次留出法,提供的是平均值和方差,受切分运气的影响小得多。
尤其在小数据集上,交叉验证几乎是标准操作。假设总共只有500条样本,留出法留出100条做测试,单次结果波动会很大;而五折交叉验证可以轮换验证,每次验证集都不同,最后指标稳定很多。代价也很明确:训练K次模型,计算成本翻倍。
4.2 留出法反而更适合的场景
交叉验证很好,但不是所有时候都合适。留出法有一些交叉验证替代不了的场景:
第一,数据规模极大时。百万级数据上,单次切分出10%测试集,就有十万条样本,评估置信度已经足够,而五折交叉验证要把训练量放大五倍,完全没有必要。
第二,训练成本很高时。深度学习大模型,尤其是预训练模型微调或大Batch训练,单次训练都要以小时计,K折会直接拉长整个实验周期。先用留出法快速看趋势,最后有问题再上交叉验证,是更务实的选择。
第三,时序和分组场景中。时序预测天然要求按时间切分,不能用随机切分,也不适合普通K折随机抽样,因为未来的数据不能被“偷”到训练集里。分组场景则需要按组切分,而留出法里的GroupShuffleSplit就是很好用的方案。
第四,贴近真实上线环境。很多推荐系统和风控模型,线上只会用历史数据去预测未来新样本。用“历史段训练、最近段测试”这种留出式切分,反而比K折更贴近实际部署。
4.3 一个更推荐的组合姿势:外层留出,内层交叉验证
把留出法和交叉验证对立起来是完全没必要的。成熟的项目流程往往是两者配合使用:先在最外层留出一份终极测试集,锁死不动,专门用来做最终验收;然后在剩下的训练数据上,用交叉验证来完成超参数搜索和模型选择;最后把选定的模型在终极测试集上跑一次,得到泛化能力的最终估计。
这种做法能同时拿到两个好处。交叉验证保证了调参阶段的稳定性,每一份数据都能被用作验证,免去留出法一次切分的运气成分;终极测试集则保证了最外层评估的“清白”,因为整个调参过程永远不会碰它。很多工业界的模型训练流水线,本质都是这个结构,只是在工程上被封装成了更复杂的形式。
| 评估方式 | 评估稳定性 | 训练开销 | 典型适用场景 |
|---|---|---|---|
| 留出法 | 单次估计,波动较大 | 1次训练 | 大样本、快速迭代、贴近线上时序流程 |
| K折交叉验证 | 多次平均,稳定性好 | K次训练 | 中小样本、超参调优、模型选型 |
| 自助法 | 样本重复抽样,对不均衡数据友好 | 采样数×训练 | 小样本、稀有事件、需要稳定置信区间 |
自助法值得一提:它通过有放回抽样构造训练集,未被抽到的样本做测试,特别适合小样本场景,缺点是实现比较复杂,实际项目中用得不算多。理解它的价值就行。
5. 一次翻车复盘:同一组用户被随机切分后,指标直接失真
5.1 问题现场
前几年做电商用户复购预测,数据是按“用户-日期-行为”组织的明细。一个用户可能有好几行,分别对应不同月的浏览、加购、购买记录。目标变量是“未来30天内是否复购”。当时项目的流程很常规:把这批明细样本丢进train_test_split,留下20%当测试集,训练完一看AUC 0.83,大家都很满意,模型直接上线。结果真实的线上AUC只有0.71。
5.2 排查链路
排查过程大致是这样:先怀疑特征,把线上特征分布拉出来和训练时对比,差异不大;然后又怀疑模型过拟合,可训练集和测试集的表现差距也没大到异常;最后把目光收回到数据划分,手滑查了一下,发现同一个用户在训练集里和测试集里同时大量出现。
问题很清楚:随机切分把同一个用户的不同行为记录打散到了两边。模型在训练时已经见过同一个ID的历史行为,测试时看到另一部分行为就能很容易“顺藤摸瓜”猜到标签。这是一个典型的分组泄漏,而表面上的AUC只是一个虚假繁荣。
用代码模拟一下当时的问题:
from sklearn.model_selection import train_test_split # 错误做法:直接对明细数据随机切分 X_train, X_test, y_train, y_test = train_test_split( df[features], df["label"], test_size=0.2, random_state=42 )5.3 修复方式
正确做法是让同一组用户完整地落在同一侧,可以用GroupShuffleSplit,或者干脆按用户ID先分组再对用户做随机抽样:
from sklearn.model_selection import GroupShuffleSplit gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) for train_idx, test_idx in gss.split(df[features], df["label"], groups=df["user_id"]): X_train, X_test = df[features].iloc[train_idx], df[features].iloc[test_idx] y_train, y_test = df["label"].iloc[train_idx], df["label"].iloc[test_idx]修好后重新评估,AUC从0.83掉到了0.72,和线上真实表现对上了。虽然数字难看,但这才是模型的真实水平。那个0.83从头到尾都是一个幻觉。
5.4 我后来固定下来的检查习惯
这件事之后,我在每次用留出法评估前都会强制回答三个问题:
- 数据的真实粒度是什么,同一实体是否会被切到两边?
- 数据切分之前,有没有任何步骤使用了全量数据的统计信息?
- 如果只用一次切分,评估指标的置信区间大概多宽,能不能支撑“模型有效”这个结论?
这三个问题能拦住绝大多数离谱的评估结果。留出法本身非常简单,复杂的地方全在这些容易被忽略的边界条件上。也是因为这样,我始终建议团队里做评估实验时,先把“数据是怎么切开的”写清楚,这比模型用了什么结构重要得多。