Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

模型评估与泛化

假设两个模型参加了一场测验。一个在练习题上几乎满分,另一个偶尔出错。你会选谁?

还不能决定。前一个也许记住了练习题,遇到新题就无从下手。机器学习关心的泛化,正是模型对没有参与拟合的新数据表现如何。训练分数很有用,但它回答的是“练得怎样”,不是完整的“学会了吗”。

1. 先确定这场测验代表什么

评估前,先用一句话写清未来会遇到的数据。本章假设:未来样本与已有数据独立,来自相同的生成过程。这样,随机留出一些记录才有机会模拟未来任务。

真实项目可能不是这样。同一个用户的几十条记录彼此相似,同一台设备一天内的日志也可能强相关。如果把它们随机拆到两边,验证集里可能充满训练集的“近亲”。

未来想解决的问题更合适的划分思路
同类来源的新独立记录随机划分
从未见过的用户或设备按用户或设备分组
明天、下个月的数据按时间先后划分
新地区、新站点保留对应地区或站点进行检验

划分方式是问题的一部分。没有一种比例能自动让所有评估都公平。

2. 训练、验证、测试怎样配合

最简单的流程是保留三部分:训练拟合参数,验证选择模型或超参数,最后才打开测试集。

如果验证集很小,选出的方案可能碰巧适合这几条记录。交叉验证让更多数据轮流承担验证角色。以五折为例,把开发数据分成五份,每次拿四份训练、一份验证,重复五次。

图 1:这里的“开发集”包含轮流训练与验证的数据。外部测试集不参与这五次循环。

候选模型得到五个分数 E1,…,E5E_1,\ldots,E_5,我们用平均值比较:

Eˉ=15∑k=15Ek \bar E=\frac15\sum_{k=1}^5E_k

选好方案后,在整个开发集重新训练一次,再评估保留测试集。交叉验证阶段的五个模型通常不是最终交付的那个模型。

五折之间共享大量训练样本,因此五个分数不是五次完全独立的实验。折间标准差可以描述分数波动,却不能随手除以 5\sqrt5,就当作一个严格的泛化误差置信区间。

如果尝试上百种方案,挑出的最佳交叉验证分数也会有选择带来的乐观偏差。独立测试仍然有价值;需要更严格比较整套选择流程时,可以使用外层评估、内层选参数的嵌套交叉验证。

3. 一条直线不够,十次多项式就一定够好吗

我们生成 60 条带噪声的数据:

y=2+0.8x+1.2x2+ε,ε∼N(0,1) y=2+0.8x+1.2x^2+\varepsilon, \qquad \varepsilon\sim\mathcal N(0,1)

xx 均匀落在 [−3,3][-3,3]。留出 18 条测试,其余 42 条作为开发集。比较三种输入表示:

次数交给线性模型的特征
1xx
2x,x2x,x^2
10x,x2,…,x10x,x^2,\ldots,x^{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. 测试集通过了,为什么上线后仍会变差

脚本最后把输入范围从 [−3,3][-3,3] 改成 [4,6][4,6],保持同一条真函数和噪声规则。这模拟了输入分布变化。它不参与模型选择,只是一次额外诊断。

本例 MAE 从 0.735 变成 3.475。二次模型的形式虽然正确,有限数据估出的系数仍然有误差;到了更远的范围,x2x^2 会放大这些误差。

这次只是输入范围改变。真实场景还可能出现类别比例变化,或相同输入对应的结果改变,例如打印机更换后,同样页数的耗时整体降低。一个旧测试分数只能支持与当时条件相近的结论。

部署后的反馈,应记录时间、来源、样本量和重要子群体的表现。发现新分布后可以重新建模,但若据此反复修改,原来的诊断数据就已经加入了开发过程,需要新的保留数据来评估更新后的方案。

7. 自测与小实验

  1. 五折交叉验证中,每条开发样本被用于验证几次?最终模型通常在多少份数据上训练?
  2. 用训练误差选择多项式次数,为什么容易偏向复杂模型?
  3. 交叉验证的标准差能否直接当成总体误差的置信区间?
  4. 对全部数据拟合标准化器时没有使用标签,为什么仍可能泄漏?
  5. 同一个用户有一百条记录,目标是预测新用户,应该怎样划分?
  6. 负 MAE 分数 −2 和 −5,哪个更好?
  7. 本例范围变化后误差增大,能否推出所有二次模型都无法外推?
展开参考答案
  1. 验证一次,参与其余四次训练;选定方案后通常在全部五份开发数据上重新拟合。
  2. 复杂模型有更多自由适应训练样本,训练误差不能独立衡量泛化。
  3. 不能。各折不独立,且区间还需要相应统计假设与构造方法。
  4. 因为保留部分的输入分布参与了变换参数的学习。
  5. 按用户分组,同一用户的记录放在同一侧。
  6. −2 更好,它对应较小的正 MAE。
  7. 不能。这是当前数据、拟合误差与范围共同作用的结果。

选做:在不查看测试集的前提下,给候选模型增加次数 3 和 5,记录五折全部分数。再把 60 条数据增加到 600 条,观察复杂模型的稳定性如何变化。两个实验分别改变什么,要在运行前写清楚。

下一章进一步处理输入中常见的缺失、异常和类别:数据处理与特征工程。交叉验证接口与划分方法可参阅官方文档,泄漏示例见常见陷阱。