模型评估与泛化
假设两个模型参加了一场测验。一个在练习题上几乎满分,另一个偶尔出错。你会选谁?
还不能决定。前一个也许记住了练习题,遇到新题就无从下手。机器学习关心的泛化,正是模型对没有参与拟合的新数据表现如何。训练分数很有用,但它回答的是“练得怎样”,不是完整的“学会了吗”。
1. 先确定这场测验代表什么
评估前,先用一句话写清未来会遇到的数据。本章假设:未来样本与已有数据独立,来自相同的生成过程。这样,随机留出一些记录才有机会模拟未来任务。
真实项目可能不是这样。同一个用户的几十条记录彼此相似,同一台设备一天内的日志也可能强相关。如果把它们随机拆到两边,验证集里可能充满训练集的“近亲”。
| 未来想解决的问题 | 更合适的划分思路 |
|---|---|
| 同类来源的新独立记录 | 随机划分 |
| 从未见过的用户或设备 | 按用户或设备分组 |
| 明天、下个月的数据 | 按时间先后划分 |
| 新地区、新站点 | 保留对应地区或站点进行检验 |
划分方式是问题的一部分。没有一种比例能自动让所有评估都公平。
2. 训练、验证、测试怎样配合
最简单的流程是保留三部分:训练拟合参数,验证选择模型或超参数,最后才打开测试集。
如果验证集很小,选出的方案可能碰巧适合这几条记录。交叉验证让更多数据轮流承担验证角色。以五折为例,把开发数据分成五份,每次拿四份训练、一份验证,重复五次。
图 1:这里的“开发集”包含轮流训练与验证的数据。外部测试集不参与这五次循环。
候选模型得到五个分数 ,我们用平均值比较:
选好方案后,在整个开发集重新训练一次,再评估保留测试集。交叉验证阶段的五个模型通常不是最终交付的那个模型。
五折之间共享大量训练样本,因此五个分数不是五次完全独立的实验。折间标准差可以描述分数波动,却不能随手除以 ,就当作一个严格的泛化误差置信区间。
如果尝试上百种方案,挑出的最佳交叉验证分数也会有选择带来的乐观偏差。独立测试仍然有价值;需要更严格比较整套选择流程时,可以使用外层评估、内层选参数的嵌套交叉验证。
3. 一条直线不够,十次多项式就一定够好吗
我们生成 60 条带噪声的数据:
均匀落在 。留出 18 条测试,其余 42 条作为开发集。比较三种输入表示:
| 次数 | 交给线性模型的特征 |
|---|---|
| 1 | |
| 2 | |
| 10 |
它们都对参数保持线性,却能对原始输入画出不同形状。为了缓和数值问题,代码对展开后的每列标准化,并使用很小的岭正则项。
图 2:图中只展示开发数据,各图纵轴刻度不同。十次曲线在靠近输入范围边缘、观测稀疏的位置剧烈摆动;最右端甚至跌到 −300 以下。比较时要读刻度,不能只比较曲线看起来有多弯。
一次模型表达不了二次弯曲,训练和验证误差都偏高,这是欠拟合。十次模型把训练误差压得更低,验证误差却非常大,这是过拟合的表现。
过拟合不等于“模型参数多”这个静态标签。它描述的是模型对训练样本的适应,没有同样延续到所关心的新数据上。数据量、噪声、正则化、特征与训练过程都会影响它。
也要留意另一种情况:如果优化器还没收敛,训练误差同样会很高。先检查能否把训练目标优化好,再判断是否需要更有表达能力的模型。
4. 第一次用 scikit-learn 完成实验
前面的标准库实现帮助我们看清训练细节。接下来用 scikit-learn 组织交叉验证和预处理,避免每个实验都重新写一遍划分与循环。
本书这一组例子在 Python 3.12、scikit-learn 1.9.1、NumPy 2.5.4 下运行。仓库中已固定依赖版本:
python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
python src/examples/evaluation.py
Windows 的激活命令为 .venv\Scripts\activate。如果你只下载单个脚本,也请同时下载依赖清单,在它所在目录安装,再运行脚本。安装过程需要网络;这些示例的数据都在本地生成,运行时不下载数据集。
完整脚本如下:
"""第 8 章:交叉验证选择复杂度,最后才使用测试集。"""
import numpy as np
from sklearn.linear_model import Ridge
from sklearn.metrics import mean_absolute_error
from sklearn.model_selection import KFold, cross_val_score, train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import PolynomialFeatures, StandardScaler
def make_data():
rng = np.random.default_rng(2026)
x = rng.uniform(-3, 3, size=(60, 1))
y = 2 + 0.8*x[:, 0] + 1.2*x[:, 0]**2 + rng.normal(0, 1, 60)
return train_test_split(x, y, test_size=0.3, random_state=2026)
def make_model(degree):
return make_pipeline(
PolynomialFeatures(degree, include_bias=False),
StandardScaler(), Ridge(alpha=1e-6),
)
def compare(x_train, y_train):
folds = KFold(n_splits=5, shuffle=True, random_state=2026)
results = []
for degree in [1, 2, 10]:
model = make_model(degree)
scores = -cross_val_score(model, x_train, y_train, cv=folds,
scoring="neg_mean_absolute_error")
model.fit(x_train, y_train)
train_mae = mean_absolute_error(y_train, model.predict(x_train))
results.append((degree, train_mae, scores, model))
return results
def main():
x_train, x_test, y_train, y_test = make_data()
results = compare(x_train, y_train)
print(f"开发集 {len(x_train)} 条;保留测试集 {len(x_test)} 条")
for degree, train_mae, scores, _ in results:
print(f"次数={degree}:训练 MAE={train_mae:.3f},"
f"五折 MAE 均值={scores.mean():.3f},折间标准差={scores.std():.3f}")
degree, _, _, selected = min(results, key=lambda row: row[2].mean())
print(f"选择次数 {degree},在完整开发集重新拟合")
print(f"测试 MAE={mean_absolute_error(y_test, selected.predict(x_test)):.3f}")
# 模拟部署输入范围改变;没有根据这组诊断数据重新选择模型。
rng = np.random.default_rng(2029)
shifted_x = rng.uniform(4, 6, (100, 1))
shifted_y = (2 + 0.8*shifted_x[:, 0] + 1.2*shifted_x[:, 0]**2
+ rng.normal(0, 1, 100))
print(f"范围变为 [4,6] 后的诊断 MAE="
f"{mean_absolute_error(shifted_y, selected.predict(shifted_x)):.3f}")
if __name__ == "__main__":
main()
输出为:
开发集 42 条;保留测试集 18 条
次数=1:训练 MAE=1.931,五折 MAE 均值=2.378,折间标准差=0.911
次数=2:训练 MAE=0.881,五折 MAE 均值=0.934,折间标准差=0.240
次数=10:训练 MAE=0.566,五折 MAE 均值=36.652,折间标准差=69.350
选择次数 2,在完整开发集重新拟合
测试 MAE=0.735
范围变为 [4,6] 后的诊断 MAE=3.475
十次模型的折间标准差很大,说明它不是每折都同样差,而是某些划分会非常不稳定。此时只看一个平均数还不够,可以打印 scores,找出哪一折出现大误差,检查那一折验证点附近有没有足够的训练数据。
scikit-learn 的评分接口通常规定“越大越好”,因此误差指标常带 neg_ 前缀,返回负误差。代码在前面加负号,将它恢复为我们熟悉的正 MAE。漏掉这一步,再对分数取最小值,就会把优劣选反。
5. 为什么预处理必须跟着每一折走
代码把多项式展开、标准化和模型放进同一个 Pipeline。每一折都重新拟合训练部分的均值、尺度和模型参数,然后只对验证部分做转换与预测。
错误的做法是先对全部 60 条数据拟合标准化器,再划分。虽然标准化器没有看标签,它仍然提前看到了保留数据的分布。特征选择、缺失值填补和降维,也有同样的问题。
更严重的泄漏可能隐藏在列的含义中:用“最终退款金额”预测下单时是否退货;用某次故障之后才形成的维修记录预测故障;同一份文档的副本同时出现在两边。一个漂亮得不寻常的分数,值得先检查信息是否真的能在预测时获得。
Pipeline 能帮助避免变换步骤的泄漏,却不会替我们识别时间含义或重复记录。工具能约束计算顺序,任务定义仍需要人想清楚。
6. 测试集通过了,为什么上线后仍会变差
脚本最后把输入范围从 改成 ,保持同一条真函数和噪声规则。这模拟了输入分布变化。它不参与模型选择,只是一次额外诊断。
本例 MAE 从 0.735 变成 3.475。二次模型的形式虽然正确,有限数据估出的系数仍然有误差;到了更远的范围, 会放大这些误差。
这次只是输入范围改变。真实场景还可能出现类别比例变化,或相同输入对应的结果改变,例如打印机更换后,同样页数的耗时整体降低。一个旧测试分数只能支持与当时条件相近的结论。
部署后的反馈,应记录时间、来源、样本量和重要子群体的表现。发现新分布后可以重新建模,但若据此反复修改,原来的诊断数据就已经加入了开发过程,需要新的保留数据来评估更新后的方案。
7. 自测与小实验
- 五折交叉验证中,每条开发样本被用于验证几次?最终模型通常在多少份数据上训练?
- 用训练误差选择多项式次数,为什么容易偏向复杂模型?
- 交叉验证的标准差能否直接当成总体误差的置信区间?
- 对全部数据拟合标准化器时没有使用标签,为什么仍可能泄漏?
- 同一个用户有一百条记录,目标是预测新用户,应该怎样划分?
- 负 MAE 分数 −2 和 −5,哪个更好?
- 本例范围变化后误差增大,能否推出所有二次模型都无法外推?
展开参考答案
- 验证一次,参与其余四次训练;选定方案后通常在全部五份开发数据上重新拟合。
- 复杂模型有更多自由适应训练样本,训练误差不能独立衡量泛化。
- 不能。各折不独立,且区间还需要相应统计假设与构造方法。
- 因为保留部分的输入分布参与了变换参数的学习。
- 按用户分组,同一用户的记录放在同一侧。
- −2 更好,它对应较小的正 MAE。
- 不能。这是当前数据、拟合误差与范围共同作用的结果。
选做:在不查看测试集的前提下,给候选模型增加次数 3 和 5,记录五折全部分数。再把 60 条数据增加到 600 条,观察复杂模型的稳定性如何变化。两个实验分别改变什么,要在运行前写清楚。
下一章进一步处理输入中常见的缺失、异常和类别:数据处理与特征工程。交叉验证接口与划分方法可参阅官方文档,泄漏示例见常见陷阱。