🧪 第17章 机器学习 API 的开发程序与实践
📄 一句话概括

训练误差最低的模型,往往不是测试误差最低的那个。 一次切分不算数,五折交叉验证看的是平均分和标准差两样。 先跑一个最简单的基准模型,是所有机器学习项目的标准起手式。

📝 本章和前面几章的关系

这一篇对应教材 17.1 与 17.2:选定最佳的演算法/模型、实作机器学习模型、实作程序。 第 16 章把概念讲完了,这一篇开始选:五个候选摆在桌上,拿数据说话。

一、这次用一份真数据

不再自造了,用 scikit-learn 自带的一份医学数据:442 条样本、10 个特征, 标签是「一年后病情进展指标」,最小值 25.0、最大值 346.0、平均 152.1。 十个特征的名字是 age、sex、bmi、bp、s1 到 s6。 这十个特征在数据集里已经标准化过(均值 0、方差 1),所以量级一致——这一点在第五节会用到。 标签是连续的数,所以这是回归任务。回归的评估最简单:算误差,越小越好。

换真数据有个好处:自造数据往往太干净。第 16 章那份房价数据,一条直线就能拟合到 3% 误差; 真实数据不会这么客气。

二、五个候选,同一份切分

模型 训练误差 测试误差 训练耗时 ms
线性回归 52.9542 55.6518 1.4
岭回归 alpha=1 58.4505 56.9076 1.3
决策树 depth=4 46.3829 66.2915 1.5
随机森林 100 棵 21.8555 59.3601 272.1
梯度提升 100 棵 27.8986 60.5819 120.9

切的是 7 比 3:训练 309 条、测试 133 条。五个模型吃的是同一份切分,不然比出来的东西没有意义。 耗时那一列值得看一眼:随机森林 272 毫秒,线性回归 1.4 毫秒,差两百倍。 这份数据只有 442 条,差距还不算夸张;换成几十万条,这个差距会变成「能跑」和「跑不动」的区别。

三、两个「最低」不在同一行

训练误差最低的是随机森林(21.8555),测试误差最低的是线性回归(55.6518)。两个答案不一样。 随机森林把训练误差压到 21.86,测试误差却还有 59.36——这就是第 16 章那个过拟合,只是换了身衣服: 一百棵树把训练集记得很牢,换一批数据就露馅。 所以「哪个模型好」这个问题,不能拿训练误差回答。 这句话在第 16 章说过一遍,这里用真数据又验证了一遍。

还有一个细节:岭回归的训练误差(58.45)比线性回归(52.95)高,测试误差也高(56.91 对 55.65)。 加了正则反而更差,说明这份数据上正则的收益抵不过它带来的偏差。 这就是「调参」的真相:没有哪个参数是普遍更好的。alpha=1 在这里不划算, 换个数据集可能就很划算。

四、一次切分不算数

模型 五折平均 标准差
线性回归 54.6918 1.3745
岭回归 alpha=1 58.4492 2.0029
决策树 depth=4 64.3202 3.5177
随机森林 100 棵 57.7409 2.0355
梯度提升 100 棵 58.4502 2.1634

交叉验证做的是:把数据切成五份,每次拿四份训练、一份测试,转五圈取平均。 每一份数据都当过一次测试集,所以这个数比单次切分稳得多。 看标准差那一列:决策树的 3.52 最大,线性回归的 1.37 最小。 标准差大说明「换一批测试数据,结果就变」。 一个模型好不好,既要看平均分,也要看它稳不稳。两个模型平均分一样、一个标准差 0.5 一个 3.0, 选前者——因为你不知道上线那天会碰到哪一批数据。

五次具体数字也值得看:决策树从 58.33 到 67.68,跨了近十分。 这种模型放到线上,用户会觉得「今天怎么这么不准」。

五、标准化:同一个 alpha 不是一回事

做法 测试误差
直接喂 Ridge(alpha=1) 56.9076
先 StandardScaler 再 fit 55.7106

这份数据整体已经标准化过一次,但在这 309 条训练子集上再标一遍,结果还是变了——低了 2.10%。 原因是:alpha=1 在两种尺度下不是同一件事。正则惩罚的是系数的绝对值,尺度一变,系数也跟着变, 同样的 alpha 就成了不同的强度。 所以工程上的做法是:不管数据有没有标准化过,都套一个 StandardScaler。代价几乎为零, 收益是「换个数据集也不会翻车」。

顺带一条纪律:scaler 必须只在训练集上 fit。拿全量数据 fit 再切分, 测试集的均值方差就漏进了训练过程——这叫数据泄漏,评估结果会虚高,而且上线才发现。 用 make_pipeline 把两步串起来,能自动避开这个坑:fit 时它只在训练集上算, predict 时用存下来的参数。

六、结果有点意外:简单模型全赢了

维度 线性回归 梯度提升 100 棵
单次测试误差 55.6518 60.5819
五折平均 54.6918 58.4502
五折标准差 1.3745 2.1634
训练耗时 ms 1.4 120.9

四项里线性回归赢了四项:误差更低、更稳、快 86 倍。 最可能的解释是:这 10 个特征和标签的关系本来就以线性成分为主。 线性模型能到 55.65,说明大部分规律已经被它抓住了; 复杂的模型没有额外的东西可学,只多拟合了一点噪声。 这正是「先跑一个基准模型」的价值。如果一上来就上梯度提升, 你会看到它比决策树好(60.58 对 66.29),以为调得不错——实际上还不如一点四毫秒就能算完的线性回归。

教材 17.1 讲「选定最佳的演算法/模型」,选的标准就是这个: 在同一份切分、同一套指标下比,谁的数字好看选谁,不许换着指标比。 换着比有多容易出错:用训练误差比,会选随机森林;用测试误差比,会选线性回归; 用交叉验证比,还是线性回归。指标一换,结论就翻。

七、训好的模型怎么存

模型 pickle 体积 加载耗时
梯度提升 100 棵 128.2 KB 4.02 ms
线性回归 0.6 KB 0.33 ms

体积差 208 倍。这个差会直接反映到镜像大小和冷启动时间上——第 15 章那些部署参数,根子在这儿。 读回来之后预测误差 60.5819,跟存之前一模一样。pickle 存的确实是完整的模型状态。 但 pickle 有个坑:它把类的路径也存进去了。用 scikit-learn 1.9 存的模型, 拿到装了 1.5 的机器上可能读不出来,或者读出来行为不对。 所以线上环境的 scikit-learn 版本要跟训练时钉成同一个——下一篇那行 == 就是为这个。

存整个模型还是只存权重?第 13 章算过:RandomForest 存整个 1058.5 KB、只存权重 210.9 KB。 这一课用 pickle 存整个,省事;要跨版本就存权重。

八、实作程序:五步,不许跳

步 做什么 产出
1 定任务 回归还是分类,标签是什么 一句话
2 备数据 切分、标准化 训练集与测试集
3 选模型 基准模型先跑,再上复杂的 一张对比表
4 评估 交叉验证看平均与标准差 一个数加减误差
5 存盘 pickle,记下版本号 一个 .pkl 文件

教材 17.2.1 讲「实作程序」,顺序就是这五步。 跳步最常见的是从 1 直接跳到 3:没切分就开始调模型,调出来的数全是训练误差,没有参考价值。 第二步和第四步是最容易被省略的两步,也是最值钱的两步。模型本身有现成的库,切分和评估没有。 下一篇开始做第二件事:把这一整套流程包成一个别人能调的服务。先定契约,再动代码。

九、动手 Demo:选模型:五个候选同场比,再用交叉验证复核

这个 Demo 用 scikit-learn 自带的糖尿病数据,把五个模型放在同一份切分上比, 再用五折交叉验证复核一遍。 第三节那个「两个最低不在同一行」,是整篇的转折点。

cands = [
    ("线性回归", LinearRegression()),
    ("岭回归 alpha=1", Ridge(alpha=1.0)),
    ("决策树 depth=4", DecisionTreeRegressor(max_depth=4)),
    ("随机森林 100 棵", RandomForestRegressor(n_estimators=100)),
    ("梯度提升 100 棵", GradientBoostingRegressor()),
]
for name, mdl in cands:
    mdl.fit(Xtr, ytr)
    print(name, rmse(ytr, mdl.predict(Xtr)), rmse(yte, mdl.predict(Xte)))

实跑输出:

--------------------------------------------------------------------
选模型:五个候选同场比,再用交叉验证复核
--------------------------------------------------------------------

  ① 这次用一份真数据
  --------------------------------------------------------------------
  样本 442 条,特征 10 个
  特征名 -> age、sex、bmi、bp、s1、s2、s3、s4、s5、s6
  标签(一年后病情进展指标)-> 最小 25.0,最大 346.0,平均 152.1
  ! 这是 scikit-learn 自带的数据,不用联网。
  442 条、10 个特征,十个特征都已经做过标准化(均值 0、方差 1),所以量级一致。
  ! 标签是连续的数,所以这是回归任务。三类任务里,回归的评估最简单:算误差,越小越好。

  ② 五个候选,同一份切分
  --------------------------------------------------------------------
  切 7 比 3 -> 训练 309 条,测试 133 条

| 模型 | 训练误差 | 测试误差 | 训练耗时 ms | 预测耗时 ms |
|---|---:|---:|---:|---:|
| 线性回归 | 52.9542 | 55.6518 | 1.4 | 0.45 |
| 岭回归 alpha=1 | 58.4505 | 56.9076 | 1.3 | 0.37 |
| 决策树 depth=4 | 46.3829 | 66.2915 | 1.5 | 0.46 |
| 随机森林 100 棵 | 21.8555 | 59.3601 | 272.1 | 29.88 |
| 梯度提升 100 棵 | 27.8986 | 60.5819 | 120.9 | 1.40 |

  ! 训练误差最低的是 随机森林 100 棵(21.8555),但测试误差最低的是 线性回归(55.6518)。
  两个答案不一样。
  ! 随机森林把训练误差压到 21.8555,测试误差却还有 59.3601——这就是上一篇那个过拟合,
  只是换了身衣服。一百棵树把训练集记得很牢,换一批数据就露馅。
  ! 但代价在耗时那一列:随机森林训练 272 毫秒,线性回归 1.4 毫秒。
  ! 这就是「选模型」真实的形状:不是找误差最小的那个,是在误差、耗时、可解释性、
  维护成本之间挑一个。

  ③ 一次切分不算数:五折交叉验证
  --------------------------------------------------------------------

| 模型 | 五折平均 | 标准差 | 五次分别是 |
|---|---:|---:|---|
| 线性回归 | 54.6918 | 1.3745 | 52.72/55.03/56.90/54.85/53.95 |
| 岭回归 alpha=1 | 58.4492 | 2.0029 | 57.50/59.58/60.14/54.94/60.09 |
| 决策树 depth=4 | 64.3202 | 3.5177 | 58.33/62.47/65.80/67.68/67.33 |
| 随机森林 100 棵 | 57.7409 | 2.0355 | 55.04/55.72/59.56/58.25/60.14 |
| 梯度提升 100 棵 | 58.4502 | 2.1634 | 56.69/55.20/60.02/59.33/61.00 |

  ! 交叉验证做的是:把数据切成五份,每次拿四份训练、一份测试,转五圈取平均。
  每一份数据都当过一次测试集,所以这个数比单次切分稳得多。
  ! 看标准差那一列:决策树的 3.52 最大,线性回归 1.37 最小。
  ! 标准差大说明「换一批测试数据,结果就变」。一个模型好不好,既要看平均分,也要看它稳不稳。

  ④ 标准化对线性模型的影响
  --------------------------------------------------------------------
  直接喂 -> 测试误差 56.9076
  先标准化 -> 测试误差 55.7106
  两者差 1.1970(标准化这边低了 2.10%)
  ! 这份数据整体已经标准化过一次,但在这 309 条训练子集上再标一遍,结果还是变了。
  因为 alpha=1 在两种尺度下不是同一件事:正则惩罚的是系数的绝对值,尺度一变,系数也跟着变,
  同样的 alpha 就成了不同的强度。
  ! 所以工程上的做法是:不管数据有没有标准化过,都套一个 StandardScaler。
  代价几乎为零,收益是「换个数据集也不会翻车」。
  ! 顺带一个纪律:StandardScaler 必须只在训练集上 fit。
  拿全量数据 fit 再切分,测试集的均值方差就漏进了训练过程,这叫数据泄漏,评估结果会虚高。

  ⑤ 结果有点意外:简单模型全赢了
  --------------------------------------------------------------------

| 维度 | 线性回归 | 梯度提升 100 棵 |
|---|---|---|
| 单次测试误差 | 55.6518 | 60.5819 |
| 五折平均 | 54.6918 | 58.4502 |
| 五折标准差 | 1.3745 | 2.1634 |
| 训练耗时 ms | 1.4 | 120.9 |

  ! 四项里线性回归赢了四项:误差更低、更稳、快 86 倍。
  ! 最可能的解释是:这 10 个特征和标签的关系本来就以线性成分为主。
  线性模型能到 55.6518,说明大部分规律已经被它抓住了;
  复杂的模型没有额外的东西可学,只多拟合了一点噪声。
  ! 这正是「先跑一个基准模型」的价值。
  如果一上来就上梯度提升,你会看到它比决策树好(60.5819 对 66.2915),
  以为调得不错——实际上还不如几毫秒就能算完的线性回归。
  ! 教材 17.1 讲「选定最佳的演算法/模型」,选的标准就是这个:在同一份切分、同一套指标下,
  谁的数字好看选谁,不许换着指标比。

  ⑥ 训好的模型怎么存
  --------------------------------------------------------------------
  梯度提升 -> 128.2 KB,加载 4.02 ms
  线性回归 -> 0.6 KB,加载 0.33 ms
  读回来的梯度提升预测误差 60.5819(和存之前一样)
  ! 体积差了 208 倍。这个差会直接反映到镜像大小和冷启动时间上——第 15 章那些部署参数,根子在这儿。
  ! pickle 有个坑:它把类的路径也存进去了。
  用 scikit-learn 1.9 存的模型,拿到装了 1.5 的机器上可能读不出来,或者读出来行为不对。
  所以线上环境的 scikit-learn 版本要跟训练时钉成同一个。
  ! 存整个模型还是只存权重?第 13 章算过:RandomForest 存整个 1058.5 KB、只存权重 210.9 KB。
  这一课用 pickle 存整个,省事;要跨版本就存权重。

第二节耗时那一列波动较大,每次跑都不一样;误差那几列是稳定的。 第三节两个「最低」分别是随机森林和线性回归,差得还挺远。 第六节体积差 208 倍,这个数会决定镜像多大。

30 秒实验

  1. 把 test_size 改成 0.15 再跑一遍第二节,看五个模型的排序变了没有。
  2. 把随机森林的 n_estimators 从 100 改成 500,看测试误差降不降、耗时涨多少。
  3. 想一想:为什么标准化的 scaler 只能 fit 训练集?

常见误区

  1. 拿训练误差选模型:一定选到最复杂的那个。
  2. 只切一次就下结论:换个 random_state 排序就变。
  3. 不看标准差:平均分一样,稳定性可能差六倍。
  4. 直接上复杂模型:基准都没跑,不知道复杂模型是不是白忙。
  5. scaler 在全量数据上 fit:数据泄漏,评估虚高。
自测三问(点开对答案) 1. 为什么训练误差最低的模型不能选? —— 它会记住训练集,换一批数据就露馅;要看测试误差或交叉验证。 2. 交叉验证比单次切分好在哪? —— 每份数据都当过一次测试集,得到的是一组数,能看平均分和稳定性。 3. 为什么 scaler 只能 fit 训练集? —— 用全量数据 fit 会把测试集的均值方差漏进训练,评估结果虚高。

模型选定了,下一篇把这套流程做成一个别人能调的接口,先从契约写起。

相关笔记