系列 《LLM 时代的经典机器学习:只讲它在哪里重现》 第 1 / 11 篇 入门
系列总览 — 为什么这样组织、读它需要什么、读完能做什么 · 幻灯片 — 整个系列的精华,一份可分享的 deck
学习地图 — AI 算法工程师学习地图 第 3 / 12 个系列
上一个系列:算法工程师的工具箱下一篇:线性回归——最小二乘、梯度下降与 Ridge / Lasso
“机器学习”三个字里最重要的是”学习”,而学习的定义只有一句话:从有限的样本推断没见过的样本。一个模型把训练数据全背下来不叫学习,能在没见过的数据上表现好才叫——这个能力叫泛化。这一篇用一个能画出来的例子(30 个点拟合一条正弦曲线)建立整个算法地图最基础的四个概念:数据怎么划分、什么是过拟合与欠拟合、误差从哪来(偏差-方差)、什么是正则化。然后把它们对到 LLM 上:benchmark 污染是测试集泄漏,reward hacking 是在一个过拟合的评估器上做优化。
全篇的核心问题是:
一、总览
1. 四个概念一张图
本文按”一次实验的时间顺序”组织:先有数据(怎么切),再拟合模型(拟合得太少还是太多),再看误差从哪里来(偏差还是方差),最后看怎么修(正则化)。四个概念之间的关系是:
%% 图:从数据到误差:四个概念在一次实验里出现的顺序
flowchart LR
D[一批数据] -->|划分| T[训练集<br/>拟合参数]
D -->|划分| V[验证集<br/>选超参数]
D -->|划分| S[测试集<br/>只看一次]
T --> M[模型容量 ↑]
M --> U["欠拟合:训练、验证误差都高<br/>(偏差大)"]
M --> O["过拟合:训练低、验证高<br/>(方差大)"]
U & O --> E["误差 = 偏差² + 方差 + 噪声"]
E -->|降方差| B[集成:多个模型平均]
E -->|拉向简单解| R[正则化:weight decay、早停、KL 惩罚]
2. 本文的章节安排
| 章 | 主题 | 内容 |
|---|---|---|
| 二 | 学习与泛化 |
|
| 三 | 划分 |
|
| 四 | 过拟合与欠拟合 |
|
| 五 | 测试集泄漏 |
|
| 六 | 偏差-方差 |
|
| 七 | 正则化 | 任何把模型拉向简单解的项;KL 惩罚、LoRA 也是 |
| 八 | reward hacking | 在过拟合的评估器上做优化;经典解法逐条对应 |
| 九 | 案例:加州房价 | 20,640 个真实街区,同一个 KNN,随机划分 vs 按地区划分,误差差 65%——划分方式必须模仿上线后的数据 |
| 十 | 本文小结 | |
| 十一 | 自测 | 八道题 |
3. 这套规矩从哪来
“留一份数据只看一次”今天像常识,但它是被一次次翻车逼出来的。几个节点:
| 年代 | 谁 / 什么 | 当时的问题 | 留下的东西 |
|---|---|---|---|
| 1930s | R. A. Fisher,实验设计 | 农业试验里怎么判断一个肥料真有效、不是碰巧 | “随机分组、事先定好怎么比”——用没参与决策的数据下结论 |
| 1968–1974 | Vapnik & Chervonenkis | 为什么把训练数据全拟合对了,新数据上还会错 | 泛化误差与模型容量(VC 维)的关系:容量越大、数据越少,训练误差与泛化误差的差越大——这是第四章那条”先降后升”曲线的理论 |
| 1974–1975 | Stone、Geisser | 数据只有几十条,切一份出来选模型就没得训了 | 交叉验证:轮流当验证集,每条数据都训过也都验过 |
| 2006–2009 | Netflix Prize | 一百万美元奖金,几万支队伍会把公开排行榜当验证集反复试 | 把测试集再切成 quiz(公开榜)与 test(只在最后揭晓);2009 年决赛两队在公开榜上一前一后,在只看一次的 test 上打平,靠提交早 20 分钟分出胜负 |
| 2010s | Kaggle,Kaufman 等 2012《Leakage in Data Mining》 | 大量比赛冠军方案靠的是”泄漏”(特征里藏着答案、同一用户的记录切在两边) | 泄漏成为一个有名字的错误类别;按时间切、按组切成为默认 |
| 2020s | LLM benchmark | 测试题本身在预训练语料里 | 污染检测、私有测试集、动态更新的题库——第五章 |
这张表要说的一件事:每一条规矩对应一次翻车。Fisher 之前人们在同一批数据上既提假设又验假设;VC 之前没人能说清”拟合得好”和”预测得准”为什么是两回事;Netflix 之前没人想到排行榜本身会被过拟合。今天 LLM 评测遇到的每个问题,几乎都能在这张表里找到七八十年前的版本。
二、学习与泛化
1. 定义
有一批数据 \((x_i, y_i)\):\(x\) 是输入(一段文本、一张图、一行表格),\(y\) 是想预测的东西(类别、数值、下一个 token)。监督学习是找一个函数 \(f\) 使 \(f(x) \approx y\)——但不是在这批数据上,而是在同一来源的、没见过的数据上。
一个最小的例子贯穿全篇:真实规律是 \(y = \sin 2\pi x\),但我们看不到它,只拿到 30 个点,每个点的 \(y\) 还加了标准差 0.3 的随机噪声(测量误差)。任务是从这 30 个点猜出那条曲线,让它在新的 \(x\) 上也预测得准。
先说清”误差”怎么量。损失(loss)是一个数,衡量一次预测错了多少。本文用的损失是均方误差(MSE,mean squared error):每个样本”预测值减真值”的平方,再对所有样本取平均。三个点的预测分别差 0.1、−0.2、0.3,MSE 就是 \((0.01 + 0.04 + 0.09) / 3 = 0.047\)。平方有两个作用:去掉正负号(差 −0.2 与差 0.2 一样糟),以及让大错误的代价不成比例地大(差 0.3 的代价是差 0.1 的 9 倍)。
两个误差:训练误差是 \(f\) 在训练数据上的平均损失;泛化误差是它在”全部可能遇到的数据”上的期望损失——想象把将来所有会遇到的样本都拿来算一遍 MSE 的平均。我们能算前者、关心后者。两者的差距叫泛化差距——它是本篇一切讨论的对象。
噪声的量级用标准差描述:30 个点的 \(y\) 在真实曲线上下随机偏移,偏移量的”典型大小”是 0.3——大约三分之二的点偏移不超过 0.3。标准差的平方叫方差:\(0.3^2 = 0.09\)。它与 MSE 是同一种量(都是”差的平方的平均”),所以噪声标准差 0.3 对应的 MSE 是 0.09——这个数字后面会反复出现,它是任何模型在这批数据上都无法突破的下限。
2. scikit-learn 的三个方法
本系列的代码用 scikit-learn,它的所有模型只有三个方法:
model.fit(X_train, y_train) # 学:在训练数据上找参数
model.predict(X_new) # 用:对新输入给预测
model.score(X_test, y_test) # 评:在一批数据上算一个分数(分类是准确率,回归是 R²)
X 是 [样本数, 特征数] 的二维数组——每一行一个样本、每一列一个特征(如 30 个点、1 个特征就是 [30, 1]);y 是 [样本数],每个样本一个答案。R²是回归任务的默认分数。整个系列不需要更多接口。
3. fit 到底在做什么
本文的模型是多项式:\(f(x) = w_0 + w_1 x + w_2 x^2 + \cdots + w_d x^d\),次数 \(d\) 越高、能画出的形状越复杂。”拟合”就是找一组系数 \(w\),让 30 个点上的 MSE 最小。它只有三行:
def fit_poly(x, y, degree):
A = np.vander(x, degree + 1, increasing=True) # ① 设计矩阵 [n, d+1]:每行 1, x, x², …, x^d
w, *_ = np.linalg.lstsq(A, y, rcond=None) # ② 最小二乘:让 ||A w − y||² 最小的 w
return w
def predict_poly(w, x):
return np.vander(x, len(w), increasing=True) @ w # ③ 预测:同样的特征乘系数
- ① 把每个 \(x\) 展开成一行 \([1, x, x^2, \ldots, x^d]\)——例如 \(x = 0.5\)、3 次多项式,这一行是 \([1, 0.5, 0.25, 0.125]\);30 个点就是一个 30 行、\(d + 1\) 列的表格,叫设计矩阵 \(A\);
- ② 这一行的每个数乘上对应的系数再相加,\(1 \cdot w_0 + 0.5 w_1 + 0.25 w_2 + 0.125 w_3\),就是 \(f(0.5)\)。
lstsq求一组 \(w\),让 30 个这样的预测与 30 个 \(y\) 之差的平方和最小——这就是最小二乘(least squares),第二篇会用 3 个点手算它并推导为什么有闭式解; - ③ 预测新点时用同样的展开乘上 \(w\)(
@是矩阵乘法:每行与 \(w\) 对应相乘再相加)。
scikit-learn 里对应的写法是 make_pipeline(PolynomialFeatures(d), LinearRegression()),两者在 1000 个新点上的预测最大相差 \(1.3 \times 10^{-14}\)——同一个算法。之后的实验都用 scikit-learn 的写法,但每次看到 fit,它做的就是上面这三行。
三、划分
1. 三份数据
经典机器学习的第一课是把数据分成三份:
| 份 | 用来 | 能看几次 |
|---|---|---|
| 训练集 | 拟合参数(fit) |
随便 |
| 验证集 | 选超参数(模型复杂度、学习率、正则强度)、早停 | 每试一个配置看一次 |
| 测试集 | 报告最终的泛化性能 | 一次 |
为什么要三份而不是两份:验证集参与了决策(选了哪个配置),它上面的分数就带了”选择偏差”——你挑的是在它上面最好的那个,那个分数偏乐观。测试集是唯一没有参与任何决策的数据,它报出的数字才是泛化误差的无偏估计。测试集一旦参与了任何决策,它就变成了验证集。
2. 一次划分
300 个点,先切出 60% 训练,剩下的对半分成验证与测试:
Xtr, Xtmp, ytr, ytmp = train_test_split(X, y, test_size=0.4, random_state=0)
Xva, Xte, yva, yte = train_test_split(Xtmp, ytmp, test_size=0.5, random_state=0)
for d in range(1, 16): # 在验证集上从 1 次试到 15 次
m = poly_model(d).fit(Xtr, ytr)
va = mse(m, Xva, yva)
... # 记下验证 MSE 最小的 d
训练 180 / 验证 60 / 测试 60
用验证集选出次数 6(验证 MSE 0.085);测试集只在最后看一次:测试 MSE 0.084
在验证集上把多项式次数从 1 试到 15、选出 6,然后测试集只看一次——0.084 与验证的 0.085 接近,说明验证集上的选择没有过度乐观。
3. 怎么切:四种划分
train_test_split 是随机切,它默认一个前提:样本之间彼此独立。这个前提经常不成立,于是有另外三种切法:
| 切法 | 什么时候用 | scikit-learn |
|---|---|---|
| 随机切 | 样本独立同分布 | train_test_split、KFold |
| 分层切(stratified) | 分类任务、类别不平衡:保证每份里各类的比例相同,否则小类可能全落在一边 | train_test_split(stratify=y)、StratifiedKFold |
| 按时间切 | 数据有时间顺序(日志、行情、用户行为):只能用过去预测未来,随机切会让模型”偷看未来” | TimeSeriesSplit |
| 按组切(group) | 多个样本来自同一个源(同一篇文档切出的多段、同一个用户的多条记录、同一张图的多个裁剪):同一组必须在同一侧 | GroupKFold、GroupShuffleSplit |
切错了分数虚高多少?下面的实验:60 个”源”点,每个源复制 5 份、加上极小的抖动,模拟”同一篇文档切成 5 段”——300 个样本里每个都有 4 个近似的兄弟。用随机 5 折交叉验证与按组 5 折交叉验证各估一次误差,再用 2000 个全新的点算真实误差:
| 次数 | 随机 5 折 CV | 按组 5 折 CV | 全新数据 |
|---|---|---|---|
| 3 | 0.060 | 0.066 | 0.101 |
| 9 | 0.061 | 0.088 | 0.098 |
| 15 | 0.060 | 0.088 | 0.102 |
随机切时,每个验证样本的 4 个兄弟都在训练集里,模型只要记住它们就能”预测”验证样本,三个次数的 CV 分数都是 0.060,比真实的 0.10 乐观 40%,而且完全分不出 3 次与 15 次谁更好。按组切的分数虽然也偏乐观(复制品共享噪声,组内 60 个源点本身就少),但至少排出了正确的顺序。LLM 的数据集里到处是”组”:同一个网页去重后的多个版本、同一道题的多种改写、同一个对话的多轮——按样本随机切,验证分数就是虚的。
4. 从一次划分到训练集内交叉验证
上一节的”一次划分”有个隐患:验证集只有 60 个点,选出来的次数带着这 60 个点的运气。把同一份数据换一个随机种子重切,选出的次数可能从 3 跳到 12。交叉验证(cross-validation,CV)的做法是把测试集先锁起来,剩下的”开发集”切成 \(K\) 份(折,fold),轮流拿 1 份当验证、其余 \(K-1\) 份训练,\(K\) 个验证误差取平均——每个样本都当过一次验证样本,没有哪 60 个点能单独左右结论:
%% 图:训练集内 5 折交叉验证选次数,测试集只在最后看一次
flowchart LR
A[全部数据 300] --> D[开发集 240]
A --> T[测试集 60:锁起来]
D --> F1[折 1 当验证<br/>其余 4 折训练]
D --> F2[折 2 当验证]
D --> F5[…折 5 当验证]
F1 --> M[每个次数 d:5 个验证 MSE 取平均]
F2 --> M
F5 --> M
M --> P[选平均 MSE 最小的 d*]
P --> R[用全部 240 个点重新拟合 d*]
R --> E[测试集上评估一次]
T --> E
核心就是三行:
def kfold_cv_mse(X, y, degree, k=5, seed=0):
scores = []
for tr, va in KFold(k, shuffle=True, random_state=seed).split(X):
m = poly_model(degree).fit(X[tr], y[tr])
scores.append(mse(m, X[va], y[va]))
return np.mean(scores), np.std(scores)
Xdev, Xte, ydev, yte = train_test_split(X, y, test_size=0.2, random_state=0)
- 切折:
KFold(5).split(X)每次吐出一对下标——tr是 4 折的样本、va是剩下 1 折;5 次循环把每个样本恰好轮到验证一次。 - 在 4 折上 fit:模型(连同它的所有预处理)只看
X[tr]。 - 在 1 折上打分:
va里的样本在这一轮模型 fit 时不在场,它的 MSE 才是泛化误差的估计。 - 测试集锁起来:
Xte在整个选次数的过程中不出现;5 折 CV 全在Xdev的 240 个点内部进行。
同样 300 个点,一次划分(180 训练 / 60 验证)与开发集内 5 折 CV 各选一次次数,再换 30 个随机种子看选出的次数稳不稳:
=== 5. 从一次划分到训练集内交叉验证:选次数只碰训练集,测试集到最后只看一次 ===
开发集 240(其中一次划分:训练 180 / 验证 60)/ 测试 60
次数 一次划分验证 MSE 5 折 CV 均值 5 折 CV 标准差
1 0.246 0.234 0.042
2 0.245 0.237 0.040
3 0.099 0.092 0.016
4 0.097 0.090 0.016
5 0.092 0.087 0.018
6 0.092 0.088 0.019
8 0.091 0.088 0.019
10 0.094 0.090 0.020
15 0.093 0.091 0.019
一次划分选出次数 7;5 折 CV 选出次数 5(CV MSE 0.087 ± 0.018)
用全部 240 个开发样本重新拟合 5 次多项式,测试集只看这一次:测试 MSE 0.087
换 30 个随机种子重选:一次划分选出的次数 [3, 4, 5, 6, 7, 8, 9, 12],对应全新数据 MSE 0.095–0.099
5 折 CV 选出的次数 [5, 6, 7, 8],对应全新数据 MSE 0.095–0.096
全新数据上真正最好的次数是 5(MSE 0.095)
测试集从头到尾只参与了最后一行的那一次评估;再拿它选次数,它就变成了第二个验证集(见第 3 节)
两列的差别不在平均值(都在 0.09 附近),在稳定性:换 30 个种子,一次划分选出的次数从 3 到 12 都有,5 折 CV 只在 5–8 之间。全新数据上真正最好的是 5 次,两者选出的次数在全新数据上的 MSE 都是 0.095–0.099——这份数据里 3 到 12 次差别本来就小,所以一次划分的”不稳”没有酿成灾难;但在样本更少、或候选配置差别更大的场景,选错一档就是第四章 15 次多项式那种结果。同一份 240 个点,一次划分选出 7、5 折 CV 选出 5,为什么更该信 CV?3
为什么不能反复碰测试集:选次数的过程看了 15 个配置的分数,这 15 次”看”每一次都是一个决策。CV 把这 15 次决策全部关在开发集里,测试集只参与最后一行那一次评估。如果图省事直接在测试集上试 15 个次数,测试集就变成了第二个验证集,报出来的数字带着第五章要量化的那种选择偏差(200 次重复平均乐观 0.255)。标准差那一列也要会读:CV MSE 0.087 ± 0.018,说明 5 折之间的分数差到 0.02 是正常波动——两个配置的 CV 分数只差 0.001 时,CV 并没有真的分出高下。
5. 完整流程:每一步在哪份数据上 fit
真实数据不会只有一个 \(x\)。一条完整的建模流程通常是:补缺失值 → 标准化 → 选特征 → 拟合模型。前三步看起来是”预处理”,但每一步都要从数据里算出一些量并记住——这和模型的 fit 没有区别:
| 步骤 | 要记住什么 | 在哪份数据上算 | 对验证 / 测试数据做什么 |
|---|---|---|---|
| 缺失值填补 | 每列的填充值(均值 / 中位数) | 训练折 | 用训练折算出的值填,不重新算 |
| 标准化 | 每列的 \(\mu_j, \sigma_j\) | 训练折 | 用训练折的 \(\mu_j, \sigma_j\) 变换 |
| 特征选择 | 保留哪些列 | 训练折(用到了 \(y\)) | 只取同样的列 |
| 模型 | 参数 \(w\) | 训练折 | 预测 |
规则只有一条:所有从数据里算出来的量都只能来自训练折;验证折和测试集只被”变换”,不参与任何统计。写成代码就是 fit 与 apply 两个函数——fit 返回一包状态,apply 只用这包状态、不碰输入数据的任何统计量:
def preprocess_fit(Xtr, ytr, k=20):
fill = np.nanmean(Xtr, axis=0) # 每列均值(忽略 NaN)
Xf = np.where(np.isnan(Xtr), fill, Xtr)
mu, sd = Xf.mean(axis=0), Xf.std(axis=0)
Z = (Xf - mu) / sd
corr = np.abs(Z.T @ (ytr - ytr.mean())) / len(ytr) # 每列与 y 的相关量
keep = np.sort(np.argsort(corr)[-k:]) # 相关最强的 k 列
w = np.linalg.lstsq(np.c_[np.ones(len(Z)), Z[:, keep]], ytr, rcond=None)[0]
return {"fill": fill, "mu": mu, "sd": sd, "keep": keep, "w": w}
def preprocess_apply(state, X):
Xf = np.where(np.isnan(X), state["fill"], X)
Z = (Xf - state["mu"]) / state["sd"]
return np.c_[np.ones(len(Z)), Z[:, state["keep"]]] @ state["w"]
- 填充值:
np.nanmean对每列在非缺失的格子上取均值,形状(d,);这就是 scikit-learnSimpleImputer()默认(strategy="mean")记住的statistics_。 - 均值与标准差:对填好的训练折逐列算,形状各
(d,)——StandardScaler的mean_与scale_。 - 相关量:标准化后的每列与中心化的 \(y\) 做点积再除以 \(n\),是这一列与 \(y\) 的相关系数(乘了 \(y\) 的标准差);它用到了 \(y\),这一点下面会变得重要。
- 选列:取相关最强的 \(k\) 列的下标——
SelectKBest(k=20)的get_support()。 - 拟合:在选出的 \(k\) 列加一列 1 上做最小二乘(第二篇展开)。
- 对新数据只做三件事:用训练折的填充值补 NaN、用训练折的 \(\mu, \sigma\) 变换、取同样的列并乘 \(w\)。这个函数里没有一个
mean()。
把这套流程放进 5 折 CV,再故意让其中某一步”偷看”全部开发数据(在全部 100 个样本上算填充值 / 算 \(\mu, \sigma\) / 选列,然后才切折),看 CV 分数怎么变。数据故意造得极端:100 个样本、1000 个特征,只有前两个与 \(y\) 有关,其余 998 列是纯噪声,10% 的格子缺失:
=== 6. 完整流程:填补 / 标准化 / 特征选择各在哪份数据上 fit —— 有泄漏 / 无泄漏对照 ===
100 个开发样本、1000 个特征(只有前 2 个有用),10% 的格子缺失;测试 20
训练上记住的量:fill 形状 (1000,)、mu/sd 形状 (1000,)、保留列 20 个、系数 (21,)
第 0 列:训练均值 -0.051(全量 -0.023);保留的列里含 0、1 两列:True
哪一步在全部数据上 fit 5 折 CV R²
都只在训练折上 fit(无泄漏) 0.323
缺失值填补 0.322
标准化 0.323
特征选择(选 20 列) 0.531
无泄漏流程在测试集上:R² 0.460;在 20,000 个全新样本上:R² 0.313
全量上选出的 20 列与只在开发集上选出的 20 列重合 17 个(其中 998 个纯噪声列里,谁与 y 碰巧相关,取决于看到了哪些样本)
sklearn Pipeline 装进 cross_val_score:R² 0.323;先在全量上 SelectKBest 再 CV:R² 0.475
特征选择在全量数据上 fit 时,验证折的 y 已经参与了「选哪些列」,CV 分数就不再是泛化估计
读者可以自己指出泄漏发生在哪一步:三种”偷看”里,为什么只有特征选择把 CV 分数从 0.32 抬到 0.53,填补和标准化几乎没动?4 看 相关量 那一行——它用到了 \(y\)。在全部 100 个样本上选列时,验证折的 \(y\) 已经参与了”这 998 个噪声列里谁和 \(y\) 碰巧相关”的投票,被选中的噪声列在验证折上当然也”相关”;而填充值和 \(\mu, \sigma\) 只用 \(X\) 不用 \(y\),多看 20 个样本只是让均值从 −0.051 挪到 −0.023,对预测几乎没影响。所以 0.531 不是泛化估计,无泄漏的 0.323 才接近全新数据上的 0.313。两点边界声明:填补 / 标准化的泄漏在这份数据上小,不等于永远小——样本很少、特征分布有重尾时,用全量算出的 \(\sigma\) 可能明显不同;而用到 \(y\) 的步骤(特征选择、目标编码、按 \(y\) 分箱)泄漏时,分数虚高的幅度取决于候选特征有多少、样本有多少,1000 列对 100 个样本是最坏的一类情形。
scikit-learn 的 Pipeline 就是为这条规则而生:装进 cross_val_score 后,每一折都会在训练折上重新 fit 全部步骤、对验证折只 transform,得到同样的 0.323;而先在全量上 SelectKBest 再把选出的列交给 CV,得到的 0.475 同样是虚的(和手写版的 0.531 不同,是因为 f_regression 在 120 个样本、未标准化的数据上选出的噪声列不一样——虚高的具体数字取决于偷看的方式,虚高本身不取决于)。LLM 场景里同一条规则的样子:数据去重、质量过滤器、tokenizer 都在”训练语料”上拟合——如果这些步骤看过评测集,评测集就已经参与了决策;第五章讲的污染是其中最直接的一种。
四、过拟合与欠拟合
1. 一个能画出来的例子
用 30 个带噪声的点(\(y = \sin 2\pi x + \varepsilon\),噪声标准差 0.3)拟合多项式。先看三种容量画出来是什么样:
- 次数 1:一条直线,怎么放都离正弦很远——训练误差 0.272、验证误差 0.308 都高。模型太简单,这叫欠拟合。
- 次数 4:曲线贴着真实函数走,训练 0.077、验证 0.098。
- 次数 15:曲线拼命穿过每一个训练点——包括那些被噪声推偏的点——于是在点与点之间、尤其右端没有点的地方甩了出去。训练误差 0.049 更低了,验证误差 0.138 却更高。它在拟合噪声,这叫过拟合。
把次数从 1 扫到 25,每个次数记下训练 MSE 与验证 MSE(验证用另外 1000 个新点):
| 次数 | 训练 MSE | 验证 MSE | 判断 |
|---|---|---|---|
| 1 | 0.272 | 0.308 | 欠拟合(两个都高) |
| 3 | 0.082 | 0.094 | 合适 |
| 5 | 0.056 | 0.114 | 合适 |
| 9 | 0.051 | 0.105 | 合适 |
| 15 | 0.049 | 0.138 | 过拟合(训练低、验证高) |
| 25 | 0.040 | 0.313 | 过拟合(训练低、验证高) |
画成曲线,就是每本教材都有的那张图——训练误差一路向下,验证误差先下后上:
三段:
- 欠拟合(次数 1–2):训练与验证误差都高——模型太简单,连训练数据都描述不了。
- 合适(3–10):训练误差降下来,验证误差接近下限 0.09。
- 过拟合(11 以上):训练误差继续降(0.040——比噪声方差还低,说明它在拟合噪声),验证误差飙升(0.313,比直线还差)。模型容量大到能把 30 个点连同它们的随机噪声一起背下来,在没见过的点上乱跳。
这张图就是模型容量 vs 泛化的全部关系:训练误差随容量单调下降,验证误差先降后升,最低点就是合适的容量。(17 次以后两条曲线都走平,是因为 30 个点在 \([0,1]\) 上的高次幂彼此几乎相同,求解器把多出来的方向当成零丢掉了——第二篇讲”共线”时会再遇到它。)
2. 不可约误差
验证 MSE 最好也只有 0.094,降不到 0——因为数据本身有噪声方差 0.09。这部分叫不可约误差(irreducible error):不管模型多好,数据里的随机性不可能预测。语言模型也有同一个下限:下一个 token 本来就有多种合理可能(”今天天气”后面可以是”很好”也可以是”不错”),这种内在的不确定性叫熵,训练 loss 再低也降不到它以下;Chinchilla 论文的拟合公式里那一项常数 \(E = 1.69\),就是他们估出的这个下限。
3. 学习曲线:数据多了,过拟合就消失
上面固定了 30 个点、改变容量。反过来固定容量、改变数据量,得到的是学习曲线:
| 训练样本数 | 4 次:训练 / 验证 | 15 次:训练 / 验证 |
|---|---|---|
| 15 | 0.057 / 0.156 | 0.026 / 20.9 |
| 30 | 0.073 / 0.111 | 0.053 / 0.169 |
| 100 | 0.096 / 0.102 | 0.088 / 0.102 |
| 1000 | 0.092 / 0.097 | 0.088 / 0.093 |
两个规律:
- 验证误差随数据量下降、训练误差随数据量上升,两者最终都收敛到噪声方差 0.09 附近——数据多到模型背不下来的时候,它只能学规律。
- 15 次多项式在 15 个点上是灾难(验证 MSE 20.9),在 100 个点上就与 4 次一样好。过拟合是”容量相对于数据太大”,不是容量本身的罪。这就是为什么 LLM 几千亿参数却不怎么过拟合:15T token 的数据相对于参数量仍然是”数据多”的一侧——而它一到几千条 SFT 数据上训三个 epoch,就立刻回到左边那个 15 个点的世界。
4. 什么时候最容易过拟合
模型容量大、数据少。参数比样本多的时候,模型总有办法把训练数据背下来。经典的处理:更多数据(学习曲线往右走)、限制容量、正则化(第七章)、早停(验证误差开始升就停)。第八章会看到这四条在 LLM 上各是什么。
五、测试集泄漏
1. 在测试集上选模型,分数就不算数
如果偷懒不分验证集、直接在测试集上试次数 1 到 12 挑最好的,报出来的分数偏乐观多少?重复 200 次(每次一批新的 40 个点、20 训练 20 测试),每次记下”在测试集上挑出的最好 MSE”与”同一个模型在 2000 个全新点上的真实 MSE”之差:
200 次汇总下来三个数:报出的分数平均比真实乐观 0.255(MSE),68% 的情况下真实更差,中位数 0.015。中位数 0.015 看起来不大,平均值 0.255 却很大——直方图右边那条长尾:有几次 20 个测试点恰好让一个 10 次多项式”看起来”最好,选了它,在新数据上就是灾难。在 20 个测试点上”挑最好的”本身就是一种拟合——挑出来的那个配置恰好在这 20 个点上运气好。测试集泄漏(test set leakage)说的就是这个:测试数据以任何形式参与了训练或选择,它报出的数字就不再是泛化性能。
2. LLM 上的两种形态
规模大得多,但概念相同:
- 污染(contamination):预训练语料是从互联网抓的,GSM8K、MMLU 的题目与答案早就在网上,一个 15T token 的语料几乎一定包含它们的某种变体。模型”见过”测试题,分数就是记忆而不是能力。
- 过度调参:在同一个 benchmark 上反复调 prompt、调配方、选 checkpoint。即使题目没泄漏,benchmark 也变成了验证集——上面那个实验里的 0.255 就是它的缩小版。
3. 怎么查:n-gram 重叠
污染最常用的检测是 n-gram 重叠:把测试题切成连续 \(n\) 个词的片段,到训练语料里查有多大比例出现过。代码只有几行:
def ngrams(tokens, n):
return {tuple(tokens[i:i + n]) for i in range(len(tokens) - n + 1)} # ① 所有连续 n 个词的片段
def contamination_rate(test_text, corpus_ngrams, n=8):
grams = ngrams(test_text.lower().split(), n)
return len(grams & corpus_ngrams) / max(1, len(grams)) # ② 测试题的片段有多少出现在语料里
corpus_grams = set().union(*(ngrams(doc.lower().split(), 8) for doc in corpus)) # ③ 语料的全部 8-gram
用一个三句话的”语料”(其中一句是被抓进去的 GSM8K 风格原题)试三道题:
| 测试题 | 8-gram 重叠率 |
|---|---|
| 原题(被抓进语料) | 0.77 |
| 改写(数字换了) | 0.05 |
| 无关题 | 0.00 |
原题的重叠率 0.77(没到 1 是因为题目末尾的问句不在语料里);只把数字换掉,重叠率就掉到 0.05——n-gram 检测抓得住原文,抓不住改写。GPT-4 技术报告用 50 个字符的子串匹配,Llama 3 用 8-gram 的 token 重叠并报告了每个 benchmark 的”污染后 / 净”分数差。更强的办法:困惑度对比(模型在测试题原文上的 loss 异常低,说明背过)、留出私有测试集、动态 benchmark(题目定期更换或从线上新产生)。L4 数据工程与 L5 评测都会碰到它,但概念在这一层:没有干净的测试集,评测数字就没有含义。
六、偏差-方差
1. 先看一个能手算的例子
第四章说过拟合”在没见过的点上乱跳”,欠拟合”怎么放都离正弦很远”。这两种错误性质不同:一种是随机的——换一批训练数据,跳的方向就不一样;一种是系统性的——换多少批数据,直线还是直线,永远贴不上正弦。偏差-方差分解把误差拆成这两块。
先用几个数体会。真实值是 2.0,用三批不同的训练数据训出三个模型,它们在同一个 \(x\) 上的预测分别是 1.0、1.2、1.4:
| 计算 | 结果 | |
|---|---|---|
| 平均预测 | \((1.0 + 1.2 + 1.4) / 3\) | 1.2 |
| 偏差² | \((1.2 - 2.0)^2\)——平均预测离真值多远 | 0.64 |
| 方差 | \([(1.0 - 1.2)^2 + 0 + (1.4 - 1.2)^2] / 3\)——三个预测围着平均值抖多大 | 0.027 |
| 三个模型的平均误差 | \([(1.0 - 2)^2 + (1.2 - 2)^2 + (1.4 - 2)^2] / 3 = (1 + 0.64 + 0.36) / 3\) | 0.667 |
\(0.64 + 0.027 = 0.667\)——平均误差恰好等于偏差² 加方差(这里没有噪声)。这三个模型的问题主要是偏差:它们互相很接近(方差小),但一起偏低。如果三个预测是 0.5、2.0、3.5,平均仍是 2.0、偏差² 是 0,方差却是 1.5——每个模型都错得不少,但错的方向随机,平均起来反而对。第一种像欠拟合的直线,第二种像过拟合的 15 次多项式。
2. 分解公式
一般地,一个模型的期望误差可以拆成三项:
\[\underbrace{\mathbb{E}\big[(f(x) - y)^2\big]}_{\text{期望误差}} = \underbrace{\big(\mathbb{E}[f(x)] - f^*(x)\big)^2}_{\text{偏差}^2} + \underbrace{\mathbb{E}\big[(f(x) - \mathbb{E}[f(x)])^2\big]}_{\text{方差}} + \underbrace{\sigma^2}_{\text{噪声}}\]每个符号的意思:
- \(f^*(x)\):真实函数(这里是 \(\sin 2\pi x\));
- \(f(x)\):用某一批训练数据训出来的模型的预测。换一批训练数据,它就变——所以它是随机的;
- \(\mathbb{E}[f(x)]\):对”所有可能的训练批次”取平均——用很多批数据各训一个模型,把它们的预测平均起来;
- 偏差:这个平均预测离真实函数多远。模型太简单,不管拿哪批数据训,平均起来还是错的——系统性地错;
- 方差:单个模型的预测围着平均值抖多大。模型太敏感,换一批数据结果就完全不同——随机地错;
- \(\sigma^2\):数据噪声的方差,0.09,不可约。
这个等式为什么成立?把 \(f(x) - y\) 拆成三段之和:\([f(x) - \mathbb{E}f(x)] + [\mathbb{E}f(x) - f^*(x)] + [f^*(x) - y]\)——”单个预测离平均预测”、”平均预测离真值”、”真值离观测值(噪声)”。平方展开后三个平方项就是方差、偏差²、噪声;三个交叉项的期望都是零(第一段围着自己的均值抖、平均为零;第三段是与模型无关的噪声、平均为零),所以消掉了。上面的小例子就是这个等式在三个数上的验算。
3. 看得见的偏差与方差
用 100 批不同的 30 个点各训一个模型,把它们的预测曲线都画出来:
算出来的数字(对 200 个 \(x\) 取平均):
preds = np.array([poly_model(d).fit(*make_data(30, seed=s)).predict(xs) for s in range(100)]) # ① [100 个模型, 200 个 x]
bias2 = np.mean((preds.mean(0) - truth(xs[:, 0])) ** 2) # ② 平均预测与真值的差的平方
var = np.mean(preds.var(0)) # ③ 100 个预测在每个 x 上的方差
| 次数 | 偏差² | 方差 | 20 个模型平均后的方差 | 平均后的偏差² |
|---|---|---|---|---|
| 1 | 0.158 | 0.025 | 0.001 | 0.152 |
| 4 | 0.003 | 0.014 | 0.001 | 0.003 |
| 15 | 0.008 | 0.662 | 0.341 | 0.001 |
低次偏差大、高次方差大——这就是第四章那条”先降后升”曲线的分解:容量增加降偏差、升方差,合适的容量是两者之和最小的地方。
4. 集成只降方差
表的后两列:把 20 个(各用不同数据训的)模型的预测平均,1 次与 4 次的方差降到原来的几十分之一,偏差不变(1 次的 0.158 → 0.152)。这叫集成(ensemble);用重采样的数据训多个模型再平均叫 bagging,随机森林(第六篇)就是它。原理是”独立量的平均,方差除以 \(n\)“:掷一个骰子,结果在 1 到 6 之间乱跳;掷 20 个取平均,结果就稳稳地落在 3.5 附近——单个的随机波动在平均里互相抵消了。模型的方差是”随机地错”,平均掉的正是它;偏差是”一起朝同一个方向错”,平均多少个也抵消不了。15 次多项式的方差只降了一半而不是 20 倍,是因为 20 个模型用的数据有重叠、误差不独立。
集成在 LLM 上以三种形态出现:
- self-consistency:同一个问题采样多次、多数投票,把解码的随机性平均掉,数学题上常有几个点的提升;代价是推理成本乘以采样次数;
- judge 集成:多个 judge 模型或多次打分取平均,降低单个 judge 的随机性——但不能降低它们共同的系统偏差(比如都偏好长回答,第十篇);
- 模型融合:把同一个基座的多个微调版本的权重平均(model soup),在多任务上常优于任何单个版本,前提是它们在同一个 loss 盆地里。
5. 为什么多 seed 的结果差异那么大
小模型、小数据上的实验方差很高,两个配方的差异可能小于 seed 之间的差异——《算法工程师的工具箱(06):GPU 直觉与实验管理——两个上限、四块显存、能复现》里 20 步训练换个 seed 差 0.14 就是这个。这是”多 seed 报均值与方差”的理论依据:你看到的单个数字里有一大块是方差。
七、正则化
正则化(regularization)是给模型加一个”不要太相信训练数据”的先验——任何把模型拉向某个简单解的项。回到多项式的例子:15 次多项式之所以乱跳,是因为它的高次系数可以很大;如果在 loss 里加一项 \(\lambda \sum_j w_j^2\) 惩罚大系数,它就被迫画一条平滑的曲线——这是 Ridge 回归,第二篇会画出系数随 \(\lambda\) 缩小的路径。经典形态还有:
- weight decay(\(\frac{\lambda}{2}\lVert W \rVert^2\),就是上面的 Ridge):拉向零;
- dropout:训练时随机关掉一部分神经元,逼模型不依赖任何单个特征(《深度学习基础(07):正则化与泛化——为什么参数比样本多却不过拟合》);
- 早停:验证误差开始升就停,不让模型有时间背;
- 数据增强:人为制造更多样本——学习曲线上往右走。
L3 会讲它们在深度网络里的具体形态;这里要建立的是概念:看到一个新方法里的约束项,先问它在把模型拉向什么。RLHF 的 KL 惩罚是正则化(先验是参考模型,《算法工程师的数学(06):熵、交叉熵与 KL——从困惑度到 DPO》),LoRA 的低秩约束是正则化(先验是”改动很小”,《算法工程师的数学(03):正交与旋转、特征值与 SVD——从 RoPE 到 LoRA》),few-shot 示例数量的限制也可以看成正则化。
八、reward hacking
1. 奖励模型是过拟合的典型
奖励模型(第三篇会讲它就是逻辑回归):一个 8B 参数的特征提取器加一个线性头,偏好数据通常只有几万到几十万对。参数与样本的比例是经典机器学习里不敢想的——第四章”容量大、数据少”的极端情形,学习曲线最左端。结果是它学到的不是”什么是好回答”,而是训练集里好回答的表面特征:更长、更多列表、更客气的语气。在训练集上准确率一路上涨,在留出集上到 70–80% 就停——那就是它的验证误差开始升的地方。
2. 在过拟合的评估器上做优化
RL 阶段,策略模型专门优化这个奖励——它会找到奖励模型给高分但人类不认可的输出:把表面特征推到极端,回答越来越长、越来越多列表、越来越空洞。奖励一路上涨、真实质量下降。这就是 reward hacking,本质是在一个过拟合的评估器上做优化:评估器在训练分布之外的判断是随机的(第四章 15 次多项式在没有训练点的右端乱甩),优化器专门去那里找漏洞。
3. 经典解法逐条对应
| 经典解法 | 在 RLHF 里的形态 |
|---|---|
| 更多、更多样的数据 | 更多偏好对、覆盖更多类型的回答 |
| 正则化 | KL 惩罚:把策略拉在参考模型附近,不让它跑到奖励模型没见过的分布去 |
| 早停 | 限制 RL 的步数;监控真实质量而不只是奖励 |
| 集成(降方差) | 多个奖励模型取最小或均值 |
| 重新标注 | 定期用当前策略的输出收集新偏好、重训奖励模型(让评估器的训练分布跟上策略) |
SFT 也有同一个问题的温和版:几千条指令数据训三个 epoch 之后,模型开始逐字复述训练集的回答——这是第四章表里次数 25 那一行。
九、案例:加州房价——同一个模型,两种划分,差 65%
前面的实验都是合成数据,规律是我们自己造的。这一章用一份真实数据把第三章”怎么切”和第五章”泄漏”合在一起看一遍:划分方式必须模仿模型上线后会遇到什么数据,否则测出来的分数是虚的。
1. 问题与数据
加州 1990 年人口普查,把全州切成 20,640 个街区(block group,几百到几千人),每个街区一行:经度、纬度、房龄中位数、总房间数、总卧室数、人口、户数、收入中位数,目标是街区房价中位数(美元)。这是 Géron《Hands-On Machine Learning》第 2 章的数据,也是 scikit-learn 自带的 fetch_california_housing,可能是被跑过最多次的回归数据集。房价均值 20.7 万,标准差 11.5 万——”永远猜均值”的 RMSE 就是 11.4 万,这是所有模型要打败的基线。
2. 解决思路
先不谈模型好坏,只问一个问题:这个模型上线后要给谁估价?
- 情形 A:给这 2 万个街区里的新房源估价——训练时见过同一条街的邻居,是合理的;
- 情形 B:公司要扩展到新地区(比如整个内华达州还没有数据),模型要给没见过的地区估价——训练时就不该见过测试地区的任何邻居。
两种情形对应两种切法:情形 A 用随机切;情形 B 用按组切,组就是”地区”——把加州按 1°×1° 经纬度切成 55 个格子,整格留出 11 个做测试。然后用同一个模型在两种划分下各测一次,看数字差多少。模型选三个:只用经纬度的 KNN(k=10,预测 = 地图上最近 10 个街区的均价——它就是”抄邻居”)、用全部 8 个特征的 KNN、用全部 8 个特征的线性回归。
%% 图:同一批数据的两种划分——测的是两个不同的问题
flowchart LR
D["20,640 个街区"] -->|随机 20%| A["随机划分<br/>测试点混在训练点里<br/>= 给老地区的新房源估价"]
D -->|整格留出 11/55| B["按地区划分<br/>测试地区一个邻居都不在训练集<br/>= 给新地区估价"]
A --> M["同一个模型<br/>KNN / 线性回归"]
B --> M
M --> R["两个 RMSE<br/>哪个是'真的'?取决于上线后是 A 还是 B"]
3. 代码
完整脚本 case_01_housing_split.py,核心就是两种划分器和一个循环:
idx = np.arange(len(df))
tr_r, te_r = train_test_split(idx, test_size=0.2, random_state=0) # 随机切
cell = (np.floor(df.longitude).astype(int).astype(str) + "," # 1°×1° 格子编号 = "组"
+ np.floor(df.latitude).astype(int).astype(str))
tr_g, te_g = next(GroupShuffleSplit(test_size=0.2, random_state=3) # 按组切:同一格子只能在一边
.split(idx, groups=cell))
models = {
"KNN,只用经纬度": (["longitude", "latitude"], make_pipeline(StandardScaler(), KNeighborsRegressor(10))),
"KNN,全部 8 个特征": (NUM, make_pipeline(SimpleImputer(), StandardScaler(), KNeighborsRegressor(10))),
"线性回归,全部 8 个特征": (NUM, make_pipeline(SimpleImputer(), StandardScaler(), LinearRegression())),
}
for name, (cols, m) in models.items():
X = df[cols].values
r = rmse(y[te_r], m.fit(X[tr_r], y[tr_r]).predict(X[te_r])) # 同一个模型
g = rmse(y[te_g], m.fit(X[tr_g], y[tr_g]).predict(X[te_g])) # 两种划分各测一次
SimpleImputer 是因为 207 个街区的卧室数缺失,用训练集上算出的均值补上(scikit-learn 1.7 的 SimpleImputer() 默认 strategy="mean",第三章第 5 节的规则:填充值只来自训练集);StandardScaler 是因为 KNN 算距离,收入(0.5–15)和房间数(2–39,320)不在一个量级,不缩放的话距离全由房间数决定。
4. 效果
| 模型 | 随机划分 RMSE | 按地区划分 RMSE | 变差 |
|---|---|---|---|
| 基线:猜训练集均值 | 114,208 | 107,999 | — |
| KNN(k=10),只用经纬度 | 51,990 | 85,677 | 1.65× |
| KNN(k=10),全部 8 个特征 | 62,072 | 61,564 | 0.99× |
| 线性回归,全部 8 个特征 | 69,823 | 65,668 | 0.94× |
读这张表:
- 随机划分下,只用经纬度的 KNN 是三者最好(5.2 万),比用了全部特征的线性回归还好 26%。它什么都没学,只是在抄同一条街邻居的房价——随机划分保证了每个测试街区的邻居都在训练集里。这就是第三章那个”每个点有 4 个兄弟”实验的真实版本。
- 换成按地区划分,它一下子变成最差(8.6 万,接近基线):新地区里一个邻居都没有,”最近的 10 个街区”在几十公里外。
- 另外两个模型几乎不受影响。它们学的是”收入高的街区房价高、离海近的贵”这类能带到新地区的规律,而不是位置本身的查表。
如果这个模型要上线到新地区,随机划分报出的 5.2 万就是一个虚高 65% 的数字——而且它还会让你选错模型(选了只会抄邻居的那个)。反过来,如果上线场景就是情形 A,按地区划分又过于悲观。没有”正确的划分”,只有”和上线场景一致的划分”。
5. 训练集内选 k:测试集不参与
上面 KNN 的 k=10 是直接给定的。按第三章第 4、5 节的规则,k 应该在训练集内部用交叉验证选——而且因为上线场景是”新地区”,训练集内的折也要按格子切(GroupKFold),每一折留出的都是整块地区。预处理(填补、标准化)装在 Pipeline 里,每折只在训练折上 fit:
imp = SimpleImputer().fit(X8[tr_g])
for k in (3, 10, 30, 100, 300):
m = make_pipeline(SimpleImputer(), StandardScaler(), KNeighborsRegressor(k))
scores = cross_val_score(m, X8[tr_g], y[tr_g], groups=cell.iloc[tr_g],
cv=GroupKFold(5), scoring="neg_root_mean_squared_error")
- 填充值来自训练集:
SimpleImputer记住的是训练集上total_bedrooms的均值 537.3,全部数据是 537.9——差别很小,但规则不因差别小而改变。 - 按组切折:
groups=cell保证同一个 1°×1° 格子只出现在一折里,cross_val_score在每折内重新 fit 整条Pipeline。
训练集内按组 5 折交叉验证选 KNN 的 k(只用按地区划分的训练集,每一折按 1°×1° 格子切):
total_bedrooms 的填补值:训练集均值 537.3,全部数据均值 537.9 —— Pipeline 里 SimpleImputer / StandardScaler 的统计量只来自 fit 时看到的那份数据
k 按组 5 折 CV RMSE 事后对照:留出地区测试 RMSE
3 79,027 66,182
10 76,306 61,564
30 78,252 60,594
100 83,259 61,496
300 89,906 65,393
CV 选出 k=10;用整个训练集重新 fit 后,留出地区测试 RMSE 61,564(右列只是事后对照,选 k 时没有看它)
CV 选出 k=10,和事后在留出地区上最好的 k=30 相差不大(61,564 vs 60,594)。CV 的 RMSE(7.6 万)比测试 RMSE(6.2 万)高,不是 CV 算错了:每折留出的是训练集里不同的整块地区,有些格子(内陆、海岸)本身就难预测,5 折平均把这些难地区都算了进去,而测试集恰好是另外 11 个格子。两个数字都是”对新地区的估计”,差别反映的是地区之间的方差——这正是按组切要暴露的东西。右列的测试 RMSE 只是事后对照:选 k 时没有看它,否则它就不再是测试集。
6. 落地还差什么
这一章只回答了”怎么测才不骗自己”。真要上线一个估价模型,还有三件事这里没做:(一)数据是 1990 年的,房价分布早已漂移——模型要定期用新成交数据重训,并监控线上误差是否在往上爬;(二)目标在 50 万美元处被截断(965 个街区的值都是 500,001),模型在高价段系统性偏低,业务上要么去掉这些样本要么单独处理;(三)RMSE 6 万美元对 10 万的房子是 60% 的误差、对 50 万的是 12%,业务能接受哪一档要看用途——第二篇会把这份数据从基线一路做到 Ridge / Lasso,看误差还能降到哪里。
十、本文小结
- 学习 = 从有限样本推断没见过的样本;训练误差能算、泛化误差是目标;
fit就是”找让训练误差最小的参数”(多项式的情形是三行最小二乘)。 - 三份数据:训练集拟合、验证集选超参数、测试集只看一次——参与了任何决策的数据都不再是测试集。怎么切要看样本是否独立:有类别不平衡用分层切,有时间顺序按时间切,有”同源”样本按组切——随机切近重复数据,CV 分数 0.060 vs 真实 0.10。
- 交叉验证与完整流程:选超参数只碰开发集——5 折 CV 把每个样本都轮一次验证,换 30 个种子选出的次数只在 5–8 之间(一次划分 3–12),测试集只在最后评估一次。填补 / 标准化 / 特征选择都是
fit:统计量只来自训练折,验证与测试只被 transform;用到 \(y\) 的步骤偷看全部数据时 CV R² 从 0.32 虚高到 0.53,只用 \(X\) 的步骤在这份数据上几乎不变。 - 过拟合与欠拟合:容量增加,训练误差单调降、验证误差先降后升;30 个点拟合正弦,次数 25 的训练 MSE 0.040 低于噪声方差 0.09(在拟合噪声)、验证 MSE 0.313 比直线还差。不可约误差是下限。学习曲线:数据到 100 个点,15 次多项式与 4 次一样好——过拟合是容量相对于数据太大。
- 测试集泄漏:在 20 个测试点上挑最好的配置,中位数乐观 0.015、平均 0.255(长尾)。LLM 上两种形态:污染(测试题在预训练语料里,n-gram 重叠查原文、查不了改写)与过度调参(benchmark 变成验证集)。没有干净的测试集,评测数字就没有含义。
- 偏差-方差:误差 = 偏差² + 方差 + 噪声;偏差是”平均预测离真值多远”(系统性错),方差是”单个预测抖多大”(随机错);低次偏差大、高次方差大;集成只降方差——self-consistency、judge 平均、model soup 都是集成,消不掉系统偏差。
- 正则化是任何把模型拉向简单解的项:weight decay、dropout、早停、KL 惩罚、LoRA 的低秩。
- reward hacking = 在一个过拟合的评估器上做优化;经典解法(更多数据、KL 正则、早停、集成、重新标注)逐条对应。
- 案例:加州 20,640 个街区,只用经纬度的 KNN 在随机划分下 RMSE 5.2 万、按地区划分 8.6 万(差 65%)——它在抄邻居;划分方式必须模仿上线后的数据。
配套代码:本文全部数字与图由 classical-ml/01_learning_and_generalization.py(fit / learncurve / split / groups / contamination / leak / biasvar / cv / pipeline 九个子实验)与 case_01_housing_split.py(第九章案例,第一次运行下载约 1 MB 数据)产生,CPU 上一分钟跑完。
十一、自测
-
训练误差 0.05、验证误差 0.30,是欠拟合还是过拟合?该往哪个方向调?
答案
过拟合;减容量、加正则、加数据、早停。
-
为什么在验证集上选出的配置,验证集分数偏乐观,而测试集分数不偏?
答案
验证集参与了”挑最好”的决策,挑出的是在它上面运气好的配置;测试集没参与任何决策。
-
一个数据集里每个用户有 20 条记录,要预测新用户的行为。随机切 8:2 会怎样?该怎么切?
答案
每个验证用户的其他记录都在训练集里,模型记住用户就能”预测”,分数虚高(第三章实验里 0.060 vs 0.10);按用户分组切(
GroupKFold),验证集里的用户在训练集里一条都没有。 -
一个模型在 benchmark A 上从 60 涨到 65,团队在 A 上调了三个月的 prompt。这 5 个点可信吗?为什么?
答案
不可信,A 已经变成验证集(过度调参 = 泄漏的一种形态);要看一个没调过的私有测试集。
-
用 8-gram 重叠检测污染,一道被改写过(数字换了、句式换了)的题会被查出来吗?
答案
查不出来:改写后重叠率从 0.77 掉到 0.05。n-gram 只抓原文,改写要靠困惑度对比或私有测试集。
-
self-consistency 采样 10 次投票能不能修正模型”系统性地把某类题算错”?为什么?
答案
不能:集成只降方差(随机错),不降偏差(系统性错)。
-
把 RLHF 的 KL 系数 \(\beta\) 调大,从偏差-方差 / 正则化的角度看在做什么?
答案
加强正则化:把策略拉向参考模型这个”先验”,减少它在奖励模型不可靠区域的探索。
-
同事先对全部 10 万条数据做了 PCA 降到 50 维、又用全部数据的 \(y\) 挑出相关最强的 50 维里的 20 维,然后才切训练 / 测试做 5 折 CV,R² 0.9。泄漏在哪一步?哪一步更严重?
答案
两步都在验证折 / 测试集上 fit 了,都违反”统计量只来自训练折”;但 PCA 只用 \(X\)(像第三章第 5 节里的填补、标准化,泄漏通常小),用 \(y\) 挑特征那一步让验证折的 \(y\) 参与了选列,是把 CV 分数抬高的主因——对应实验里 0.32 → 0.53。正确做法是把两步都装进
Pipeline,在每折内重新 fit。
下一篇
下一篇讲最简单的模型——线性回归:本文 fit_poly 里那一行 lstsq 到底解了什么方程,为什么它有闭式解,梯度下降怎么走到同一个答案,以及给系数加惩罚(Ridge / Lasso)之后曲线怎么变平滑。
-
先问测试集是不是还是测试集。它一旦参与过任何决策(挑配置、挑 checkpoint)就变成了验证集,分数带选择偏差——20 个测试点上挑最好的配置平均乐观 0.255;LLM 上另一种形态是污染——测试题在预训练语料里,8-gram 重叠能查出原文(重叠率 0.77)、查不出改写(0.05)。检查的办法是换一份没参与过决策、确认不在语料里的题重测。详见第三章、第五章。 ↩
-
RM 是在有限偏好数据上拟合的分类器,它在数据分布外的判断不受约束——就像 15 次多项式在没有训练点的右端乱甩;RL 在它上面做优化就是专门去找它判断错的地方——在一个过拟合的评估器上做优化,经典机器学习叫「在验证集上选模型」,这里叫 reward hacking。80% 是分布内的准确率,说不了分布外的事。详见第四章、第八章。 ↩
-
因为 CV 的分数来自 240 个点各当一次验证样本的平均,一次划分的分数只来自固定的 60 个点。实验里把同一份开发集换 30 个随机种子重切:一次划分选出的次数散在 3 到 12,5 折 CV 只在 5–8 之间,而全新数据上真正最好的是 5。两者选中的次数在全新数据上的 MSE 都是 0.095–0.099,所以这份数据里选错代价不大;CV 更可信的是「选出来的东西换一批验证样本还成立」这个性质,不是它的平均值更低。详见第三章第 4 节。 ↩
-
看每一步用了什么:填充值与 μ、σ 只从 X 算,多看 20 个样本只把第 0 列均值从 −0.051 挪到 −0.023,预测几乎不变(0.322 / 0.323 vs 0.323);特征选择用到了 y——在全部 100 个样本上选列时,验证折的 y 参与了「998 个噪声列里谁和 y 碰巧相关」的判断,选中的噪声列在验证折上自然也相关,R² 虚高到 0.531,而无泄漏流程在 20,000 个全新样本上只有 0.313。所以泄漏发生在特征选择这一步,根因是它用了验证折的 y。详见第三章第 5 节。 ↩
系列 《LLM 时代的经典机器学习:只讲它在哪里重现》 第 1 / 11 篇 入门
系列总览 — 为什么这样组织、读它需要什么、读完能做什么 · 幻灯片 — 整个系列的精华,一份可分享的 deck
学习地图 — AI 算法工程师学习地图 第 3 / 12 个系列
上一个系列:算法工程师的工具箱下一篇:线性回归——最小二乘、梯度下降与 Ridge / Lasso
下一个系列:深度学习基础:从反向传播到残差 入门 · 11 篇,约 3.2 小时
本文由 arganzheng 创作,采用 CC BY 4.0 许可协议。在保留原文作者、署名以及完整原文链接(https://arganzheng.life/what-is-learning-splits-generalization-and-bias-variance.html)的前提下,欢迎各种形式的转载、翻译或商业引用。
COMMENTS
评论存放在 GitHub Discussions, 用 GitHub 账号登录即可发表,支持 Markdown。 想针对正文某句话说?选中那段文字,点浮出的「评论」即可划线评论;觉得哪里写错了,发表时勾上「同时提交 Issue」。 有人回复你时 GitHub 会按你的通知设置发邮件,不用守在这里。