系列 《LLM 时代的经典机器学习:只讲它在哪里重现》 第 1 / 11 篇
“机器学习”三个字里最重要的是”学习”,而学习的定义只有一句话:从有限的样本推断没见过的样本。一个模型把训练数据全背下来不叫学习,能在没见过的数据上表现好才叫——这个能力叫泛化。这一篇用一个能画出来的例子(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 的数据集里到处是”组”:同一个网页去重后的多个版本、同一道题的多种改写、同一个对话的多轮——按样本随机切,验证分数就是虚的。
四、过拟合与欠拟合
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 之间的差异——L1 第六篇里 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:训练时随机关掉一部分神经元,逼模型不依赖任何单个特征(L3 第四篇);
- 早停:验证误差开始升就停,不让模型有时间背;
- 数据增强:人为制造更多样本——学习曲线上往右走。
L3 会讲它们在深度网络里的具体形态;这里要建立的是概念:看到一个新方法里的约束项,先问它在把模型拉向什么。RLHF 的 KL 惩罚是正则化(先验是参考模型,L0 第六篇),LoRA 的低秩约束是正则化(先验是”改动很小”,L0 第三篇),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 个街区的卧室数缺失,用中位数补上;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. 落地还差什么
这一章只回答了”怎么测才不骗自己”。真要上线一个估价模型,还有三件事这里没做:(一)数据是 1990 年的,房价分布早已漂移——模型要定期用新成交数据重训,并监控线上误差是否在往上爬;(二)目标在 50 万美元处被截断(965 个街区的值都是 500,001),模型在高价段系统性偏低,业务上要么去掉这些样本要么单独处理;(三)RMSE 6 万美元对 10 万的房子是 60% 的误差、对 50 万的是 12%,业务能接受哪一档要看用途——第二篇会把这份数据从基线一路做到 Ridge / Lasso,看误差还能降到哪里。
十、本文小结
- 学习 = 从有限样本推断没见过的样本;训练误差能算、泛化误差是目标;
fit就是”找让训练误差最小的参数”(多项式的情形是三行最小二乘)。 - 三份数据:训练集拟合、验证集选超参数、测试集只看一次——参与了任何决策的数据都不再是测试集。怎么切要看样本是否独立:有类别不平衡用分层切,有时间顺序按时间切,有”同源”样本按组切——随机切近重复数据,CV 分数 0.060 vs 真实 0.10。
- 过拟合与欠拟合:容量增加,训练误差单调降、验证误差先降后升;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 七个子实验)与 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\) 调大,从偏差-方差 / 正则化的角度看在做什么?
答案
加强正则化:把策略拉向参考模型这个”先验”,减少它在奖励模型不可靠区域的探索。
下一篇
下一篇讲最简单的模型——线性回归:本文 fit_poly 里那一行 lstsq 到底解了什么方程,为什么它有闭式解,梯度下降怎么走到同一个答案,以及给系数加惩罚(Ridge / Lasso)之后曲线怎么变平滑。
-
先问测试集是不是还是测试集。它一旦参与过任何决策(挑配置、挑 checkpoint)就变成了验证集,分数带选择偏差——20 个测试点上挑最好的配置平均乐观 0.255;LLM 上另一种形态是污染——测试题在预训练语料里,8-gram 重叠能查出原文(重叠率 0.77)、查不出改写(0.05)。检查的办法是换一份没参与过决策、确认不在语料里的题重测。详见第三章、第五章。 ↩
-
RM 是在有限偏好数据上拟合的分类器,它在数据分布外的判断不受约束——就像 15 次多项式在没有训练点的右端乱甩;RL 在它上面做优化就是专门去找它判断错的地方——在一个过拟合的评估器上做优化,经典机器学习叫「在验证集上选模型」,这里叫 reward hacking。80% 是分布内的准确率,说不了分布外的事。详见第四章、第八章。 ↩
系列 《LLM 时代的经典机器学习:只讲它在哪里重现》 第 1 / 11 篇
本文由 arganzheng 创作,采用 CC BY 4.0 许可协议。在保留原文作者、署名以及完整原文链接(https://arganzheng.life/what-is-learning-splits-generalization-and-bias-variance.html)的前提下,欢迎各种形式的转载、翻译或商业引用。
COMMENTS
评论存放在 GitHub Discussions, 用 GitHub 账号登录即可发表,支持 Markdown。 想针对正文某句话说?选中那段文字,点浮出的「评论」即可划线评论;觉得哪里写错了,发表时勾上「同时提交 Issue」。 有人回复你时 GitHub 会按你的通知设置发邮件,不用守在这里。