预训练实验与能力评估
终于到了花预算的时候。
我们已经知道怎样整理数据、保存训练状态,也知道宽模型每一步更贵。现在准备一份小实验菜单:模型加宽一些,文本增加一些,或者修好文本中的颜色错配。在相同计算上限下,哪一项更有用?
先别急着押答案。实验真正有意思的地方,往往是它没有按照我们最顺手的解释发展。这一章保留全部对照结果,也保留“不够确定”和“暂时看不出差别”的地方。
1. 先把问题变成一组可以比较的运行
“大模型是不是更好”范围太广。我们把问题缩小:
对本书的颜色短句任务,在相同训练矩阵乘法预算下,增加宽度、增加不同文本、保持颜色一致,分别怎样影响验证表现?
仍然使用第二十一章的自编语料:
小林选了红色杯子,放到桌上。最后小林拿走了红色杯子。
人物、颜色、物品和地点各有四种,共 256 种组合。固定划分为 192 条候选训练文本、32 条验证文本、32 条测试文本。整句没有跨集合重复,但所有集合共享模板与词汇。因此,这个实验考察的是模板内的组合与对应关系,不是开放领域中文能力。
一个基线,三条变化方向
基线使用训练池前 48 条一致文本,宽度 32。接着安排以下对照:
| 配置 | 宽度 | 不同训练文本 | 第二次颜色 | 主要比较对象 |
|---|---|---|---|---|
| 48 条一致 | 32 | 48 | 与第一次一致 | 基线 |
| 48 条加宽 | 64 | 48 | 一致 | 基线:只增加宽度 |
| 192 条一致 | 32 | 192 | 一致 | 基线:只扩大文本池 |
| 192 条错配 | 32 | 192 | 在训练集内打乱 | 192 条一致:改变对应关系 |
| 48 条错配 | 32 | 48 | 在训练集内打乱 | 基线:改变对应关系 |
打乱操作只交换第二次出现的颜色,保持各自训练集的总字符计数和文本长度不变。有些颜色交换后恰好相同,所以“错配”表示执行了打乱操作,不是每条都必然错误。这样比较,比同时删掉一批句子、换掉词表、改短上下文更容易解释。
这种围绕一个因素安排的比较,常叫对照实验;拿掉或改变某个组成部分、观察贡献的做法,通常叫消融实验。但“一次只改一个因素”不等于其他现实条件自动完全相同:加宽后每步更贵,就必须减少更新次数。这恰好属于本章的问题定义。
2. 哪些条件需要固定
五组都使用同一份 35 项字符词表、两层 Transformer、4 个注意力头、32 个位置、有效批次 16,采用 AdamW、0.01 权重衰减和 1.0 梯度范数裁剪。输入嵌入与输出投影不共享;与第二十一章一样,网络没有 Dropout。
学习率前 10% 的更新逐渐升到 0.003,之后按余弦衰减到 0.0003。调度使用本次运行的完成比例,宽度不同的模型都在各自预算接近用完时衰减到终点。我们没有为加宽模型单独调学习率,这也是结果的适用条件。
预算采用上一章的矩阵 FLOPs 口径:
| 配置类型 | 更新次数 | 累计真实目标 | 预算使用率 |
|---|---|---|---|
| 宽度 32 | 400 | 172,800 | 100% |
| 宽度 64 | 109 | 47,088 | 99.46% |
每次用有放回抽样选 16 条文本。宽度 32 总共抽取 6,400 条次,来自 48 条池时平均每条约被抽取 133.3 次,来自 192 条池时平均约 33.3 次。这是平均数,具体文本次数不完全相同;脚本还记录实际见过的不同样本数,本次各组都覆盖了各自整个训练池。
数据池扩大,不等于训练总词元也扩大。 固定预算下,模型只是把同样多的阅读机会分配给更多不同句子。
验证在第 1 步以及大约 25%、50%、75%、100% 进度处进行。验证、训练集诊断评分、初始化、优化器逐元素计算等不在这份矩阵训练预算内;若目标是比较总耗时,就要另列它们的成本。
本例预先约定使用预算结束时的参数,没有从每条曲线里再挑一个最好时刻。训练种子取 11、22、33,控制初始化和批次采样;数据划分保持不变。每组报告全部三个种子,以平均验证损失选择配置,然后才评估所选配置的三个模型在测试集上的表现。
3. 三扇窗:训练是否正常、预测是否准确、任务是否完成
先看训练过程有没有异常
最基本的是损失与梯度是否为有限数。遇到 NaN 或无穷值,不该让它在一大串日志里悄悄过去。脚本会直接停止并报告问题。
接着看训练损失、验证损失和梯度范数的变化。如果训练损失也不降,可以从目标错位、掩码、学习率、梯度链条查起;如果训练损失下降而验证持续变差,才进一步检查过拟合、分布差异或数据泄漏。单个曲线形状不是诊断证明,需要配合最小测试。
图 1:每幅小图中实线是验证损失,虚线是训练损失。第一步的损失约为 3.5,为了看清后半程差异,本图从约四分之一预算处开始;完整记录保留第一步。
这里的训练损失是阶段结束后,在对应训练池上重新评分所得,不是最后一个随机批次的偶然损失。五组的训练数据不同,因此训练损失彼此不能直接当作统一排名;验证数据相同,才有共同的比较对象。
再看整个序列的预测质量
验证交叉熵仍以每个真实目标为单位求平均。因为词表、切分、上下文、边界和计分规则相同,各组数值可以直接比较。
若换了分词器,即使文本一样,“每词元平均损失”的单位也变了,不能直接沿用这个表的比较方式。困惑度是相同平均交叉熵的指数变换,也不会解决单位不一致的问题。
最后盯住真正关心的位置
本任务有一条具体要求:最后拿走的物品颜色,应当与前面选中的颜色相同。我们单独统计第二次颜色位置的预测是否正确。
指标计算时,模型仍拿到真实前缀,在指定位置选得分最高的词元。这是给定正确前缀的颜色预测准确率,不是自由生成整段文本的成功率。生成可能在前面已经写错人物或物品;第二十一章展示过这样的续写失败,不能用本章一个局部指标把它们抹掉。
这一指标也解释了为什么需要多扇窗。标点、固定模板和普通字词占了大多数位置,颜色关系只是其中一个。总体损失下降,某项关系仍可能没有学会。
4. 第五篇实践:固定预算跑完五组对照
环境沿用 Python 3.12、PyTorch 2.14.1,CPU 单线程与双精度。完整脚本会训练 15 个小模型;在普通电脑上需要几十秒到数分钟,具体取决于设备。无需 GPU,也不会下载模型或语料。
在仓库根目录运行:
uv run --python 3.12 --with-requirements requirements.txt python src/examples/pretraining_experiment.py
若单独下载,请把 pretraining_experiment.py、compute_budget.py、tiny_transformer.py 和 small_language_model.py 放在同一个目录;依赖见 requirements.txt。
本书环境的输出如下。± 后面是三个训练种子之间的样本标准差,不是置信区间:
预算上限=36,608,409,600 矩阵 FLOPs;种子=[11, 22, 33];验证/测试各 32 条
48 条一致:参数=28,480,更新=400,验证损失=0.295±0.016,颜色=89.6%
各种子验证损失:0.308, 0.278, 0.299
48 条加宽:参数=106,112,更新=109,验证损失=0.396±0.003,颜色=32.3%
各种子验证损失:0.399, 0.393, 0.398
192 条一致:参数=28,480,更新=400,验证损失=0.370±0.001,颜色=20.8%
各种子验证损失:0.370, 0.371, 0.370
192 条错配:参数=28,480,更新=400,验证损失=0.371±0.001,颜色=21.9%
各种子验证损失:0.370, 0.370, 0.372
48 条错配:参数=28,480,更新=400,验证损失=0.338±0.012,颜色=25.0%
各种子验证损失:0.325, 0.350, 0.338
加宽−基线:+0.090, +0.115, +0.099
增量−基线:+0.061, +0.093, +0.071
192 条:错配−一致:+0.000, -0.000, +0.002
48 条:错配−一致:+0.017, +0.072, +0.039
按平均验证损失选择:48 条一致
测试损失=0.288±0.010;各种子困惑度=1.348, 1.322, 1.333
测试颜色正确数:23/32, 32/32, 32/32
-0.000 表示一个很小的负差值四舍五入后的显示,不表示程序算出了“负的零损失”。符号前后比较的都是交叉熵之差。
可以另存全部曲线、梯度范数、不同样本数和各次运行指标:
uv run --python 3.12 --with-requirements requirements.txt python src/examples/pretraining_experiment.py --out runs/pretraining.json
展开完整代码:预算约束、五组实验与统一评估
"""第 25 章:相同矩阵乘法预算下,比模型宽度、数据量与颜色一致性。"""
import argparse
import json
from math import cos, exp, pi
from pathlib import Path
from statistics import mean, stdev
import torch
from torch import nn
from compute_budget import BUDGET, matrix_flops, parameters
from tiny_transformer import TinyTransformer
from small_language_model import PAD, corpus, vocabulary, make_windows, evaluate, corrupt_colors
SEEDS = [11, 22, 33]
def train(texts, validation, vocab, width, seed):
torch.manual_seed(seed)
model = TinyTransformer(len(vocab), width=width).double()
assert sum(p.numel() for p in model.parameters()) == parameters(width)
optimizer = torch.optim.AdamW(model.parameters(), lr=0.003, weight_decay=0.01)
generator = torch.Generator().manual_seed(seed+1000)
training = make_windows(texts, vocab, 32)
x, y, _ = training
cost = matrix_flops(width)
steps = BUDGET//cost
checkpoints = {max(1, round(steps*fraction)) for fraction in [0, .25, .5, .75, 1]}
history, seen = [], set()
for step in range(1, steps+1):
model.train()
batch = torch.randint(len(x), (16,), generator=generator)
seen.update(batch.tolist())
progress = step/steps
multiplier = progress/.1 if progress <= .1 else .1+.9*(1+cos(pi*(progress-.1)/.9))/2
for group in optimizer.param_groups:
group["lr"] = .003*multiplier
optimizer.zero_grad(set_to_none=True)
logits = model(x[batch])
loss = nn.functional.cross_entropy(logits.flatten(0, 1), y[batch].flatten(), ignore_index=PAD)
if not torch.isfinite(loss):
raise RuntimeError("非有限训练损失")
loss.backward()
norm = nn.utils.clip_grad_norm_(model.parameters(), 1.0, error_if_nonfinite=True)
optimizer.step()
if step in checkpoints:
val_loss, accuracy = evaluate(model, validation)
train_loss, _ = evaluate(model, training)
history.append(dict(step=step, compute=step*cost, train_loss=train_loss,
val_loss=val_loss, accuracy=accuracy, grad_norm=float(norm)))
# 预先规定用预算结束时的参数;不在每个种子中再挑选最好的一步。
val_loss, accuracy = evaluate(model, validation)
return model, dict(seed=seed, val_loss=val_loss, accuracy=accuracy, history=history,
unique_seen=len(seen), steps=steps, compute=steps*cost)
def run():
torch.set_num_threads(1)
torch.use_deterministic_algorithms(True)
train_texts, validation, test = corpus()
vocab = vocabulary(train_texts)
assert vocabulary(train_texts[:48]) == vocab
val_data, test_data = [make_windows(texts, vocab, 32) for texts in [validation, test]]
configurations = [("48 条一致", 32, train_texts[:48]),
("48 条加宽", 64, train_texts[:48]),
("192 条一致", 32, train_texts),
("192 条错配", 32, corrupt_colors(train_texts)),
("48 条错配", 32, corrupt_colors(train_texts[:48]))]
results, models = [], []
for name, width, texts in configurations:
trials, candidates = [], []
for seed in SEEDS:
model, trial = train(texts, val_data, vocab, width, seed)
candidates.append(model)
trials.append(trial)
results.append(dict(name=name, width=width, documents=len(texts), trials=trials,
mean_val=mean(t["val_loss"] for t in trials)))
models.append(candidates)
selected = min(range(len(results)), key=lambda index: results[index]["mean_val"])
# 只有选定配置后才访问测试评分;不根据测试结果调整配置或挑种子。
test_scores = [evaluate(model, test_data) for model in models[selected]]
return dict(budget=BUDGET, seeds=SEEDS, results=results, selected=selected,
test_scores=test_scores, vocab_size=len(vocab))
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--out", type=Path, help="可选:保存全部实验指标 JSON")
args = parser.parse_args()
report = run()
print(f"预算上限={BUDGET:,} 矩阵 FLOPs;种子={SEEDS};验证/测试各 32 条")
for result in report["results"]:
trials = result["trials"]
losses, accuracies = [[trial[key] for trial in trials] for key in ["val_loss", "accuracy"]]
print(f"{result['name']}:参数={parameters(result['width']):,},更新={trials[0]['steps']},"
f"验证损失={mean(losses):.3f}±{stdev(losses):.3f},颜色={mean(accuracies):.1%}")
print(" 各种子验证损失:" + ", ".join(f"{loss:.3f}" for loss in losses))
base = report["results"][0]["trials"]
wider = report["results"][1]["trials"]
clean = report["results"][2]["trials"]
noisy = report["results"][3]["trials"]
small_noisy = report["results"][4]["trials"]
for label, left, right in [("加宽−基线", wider, base), ("增量−基线", clean, base),
("192 条:错配−一致", noisy, clean),
("48 条:错配−一致", small_noisy, base)]:
differences = [a["val_loss"]-b["val_loss"] for a, b in zip(left, right)]
print(label + ":" + ", ".join(f"{value:+.3f}" for value in differences))
selected = report["results"][report["selected"]]
losses, accuracies = zip(*report["test_scores"])
print(f"按平均验证损失选择:{selected['name']}")
print(f"测试损失={mean(losses):.3f}±{stdev(losses):.3f};"
f"各种子困惑度=" + ", ".join(f"{exp(loss):.3f}" for loss in losses))
print("测试颜色正确数:" + ", ".join(f"{round(value*32)}/32" for value in accuracies))
if args.out:
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(json.dumps(report, ensure_ascii=False, indent=2))
print(f"实验记录:{args.out}")
if __name__ == "__main__":
main()
5. 从结果里读出什么,又读不出什么
图 2:每个点对应一个训练种子,短竖线标出平均值,横向误差线表示样本标准差。三个点不够描述完整的随机性,只是比单次运行多了一点观察。
加宽没有获胜
加宽模型约有 3.73 倍参数,却只能更新 109 次。它的平均验证损失更高,颜色准确率也较低。
因此,当前结果支持“在这份预算、训练计划与任务下,加宽没有带来改进”。它不支持“大模型一定更差”。为加宽模型调整学习率、延长预算或改变数据,都是新的实验,不能把还没跑的结果当作解释已经成立。
更多不同文本也没有获胜
192 条一致文本的验证损失约为 0.370,高于 48 条基线。两组的累计目标数相同,但每条文本的平均重复次数不同。
一种值得继续检查的解释是:这份预算与调度下,小训练池更容易学到模板中的对应关系;扩大训练池以后,模型还没走到相同阶段。但这只是根据曲线提出的后续假设。它也可能与固定的 48 条子集、初始化、优化路径等因素有关。要区分这些解释,可以换几个训练子集,或者对各组统一增加预算,再看趋势。
这里特意没有把“数据越多越好”改写成相反的口号。两个结论都超出了这份小实验。
质量差异,有时会被训练阶段遮住
对 48 条池,打乱颜色后,平均验证损失从约 0.295 上升到 0.338,颜色准确率从 89.6% 降到 25.0%。三个配对种子的损失都变差。
但在 192 条池中,一致组和错配组的颜色都没学好,损失差只有约千分之一量级。两组“差不多”,不能证明颜色一致性没有价值;可能是当前训练阶段尚未把这份信息转化为能力。
图 3:纵轴直接显示 32 个验证样本中答对多少个。三个点分别对应种子 11、22、33;相同位置的点稍作横向错开,数值没有修改。
32 条验证文本上,多答对一道题就是 3.125 个百分点。平均颜色得分 21.9% 比 20.8% 高,并不能支持“打乱颜色更好”:这点差别很小,而且三个种子的表现都不理想。
选配置,不挑最好看的种子
按预先约定的平均验证损失,选择 48 条一致配置。然后测试三个模型,颜色正确数分别为 23、32、32。
如果只展示其中的 32/32,就把种子差异藏起来了。平均损失的波动看起来不大,具体任务表现却仍可能明显不同。本书没有把这三个模型集成为一个系统;测试表报告的是三个独立模型。
6. 什么样的改进才值得相信
假设有人说:“我的方法把验证损失从 0.371 降到了 0.370。”先别急着鼓掌或否定,可以顺着这几个问题看:
- 两次使用的是同一份验证材料、分词器、上下文和计分方式吗?
- 是否用了更多训练计算,或者更广的调参搜索?
- 换一个训练种子,方向还一致吗?换一个数据划分呢?
- 主要任务指标改善了吗,还是只改善了容易预测的位置?
- 差异是否大到值得承担额外成本,是否只对一小类样本有效?
若两组使用同样的种子安排,可以先看配对差值。设第 次运行的差值为:
负值表示 A 的损失较低。配对能帮助观察变化,但相同种子号不保证不同架构的初始参数或全部随机操作具有逐项对应关系;它更不是自动完成了统计检验。
本例只有三个种子,数据划分固定,模板也固定。标准差只反映这几次训练的变化,没有覆盖“换一批文本”的不确定性。更强的结论需要更多独立运行、更多数据划分或外部任务;置信区间的构造方式也必须与这些不确定性来源对应。第四章关于实验不确定性的讨论,在这里又派上了用场。
7. 把实验继续往前推
这一篇的实践已经有完整的基线、预算、对照、日志和结果。下一次扩展,可以只选一个问题:
- 预算是否太紧? 对所有配置同时改成两倍预算,先写下预测,再运行。不要只给落后的一组补课后还称为原来的等预算比较。
- 48 条子集是否特殊? 固定其他条件,换几个来自训练池的子集;最终测试集继续保留到新方案选定后。
- 问题出在局部指标吗? 加入固定提示下的完整续写检查,同时统计人物、物品、颜色与句子终止,保留失败样本。
- 数据整理是否真的有帮助? 从第二十二章的流水线扩展出足够大的自编或合适授权语料,对过滤前后做明确的预算对照。
若已经反复根据当前测试集改进方案,它就不再是新的最终检验。继续研究时应准备新的独立评估材料,并诚实记录数据的使用历史。
一份实验报告可以很短,但应让别人知道:问题是什么、数据怎么分、实际花了多少计算、哪些配置被比较、所有运行得到了什么,以及还有哪种解释没排除。漂亮的表格是开头,能经得住追问的比较才是收获。
8. 停下来试一试
- 为什么本章不让两种宽度都更新 400 次?
- 192 条数据池比 48 条大四倍,累计真实目标为什么仍然相同?
- 如果把验证集最好的单次种子交给测试,会改变怎样的选择过程?
- 验证交叉熵下降了,为什么颜色正确率可能没变化?
- 在 32 道题上,增加一道正确答案相当于多少个百分点?
- 为什么不能根据 192 条两组差异很小,认定颜色错配无害?
- 三个种子的标准差很小,是否意味着换一本书、换一种句式也会稳定?
- 设计一个预算增加实验:预先写出比较对象、预算口径、保存参数的时机、选择规则和失败时仍要报告的结果。
参考答案与提示
- 单步成本不同,同时更新 400 次会给加宽模型约 3.65 倍的矩阵训练计算;那会回答另一个问题。
- 两组模型尺寸、批次与更新次数相同,只改变可抽取文本池的大小;不同文本数和累计阅读量是两回事。
- 会把种子也变成被调选的超参数。不是绝对不能这样做,但必须计入选择过程与成本,不能拿幸运运行代表配置的通常表现。
- 损失平均了全部有效位置,也衡量真实词元的概率大小;颜色准确率只看特定位置的最高分词元是否正确。
- 个百分点。
- 两组都尚未表现出良好的颜色关系预测,当前指标差异可能没有显现出数据中该信息的价值。
- 不能。固定划分上的训练随机性,不等于数据来源、句式和任务变化带来的不确定性。
- 例如五组都使用两倍原预算,继续使用预算结束时参数、三个相同种子和平均验证损失选择规则。报告全部运行及实际使用量,包括没有改善、颜色仍然错误或出现训练异常的情况。
9. 继续阅读
- Hoffmann 等,Training Compute-Optimal Large Language Models:把本章的小预算比较与大规模研究的问题组织方式联系起来。
- Penedo 等,The FineWeb Datasets:观察数据处理选择如何通过成组实验检验。
到这里,我们已经能让一个小语言模型接受预训练,并对它花掉的计算和得到的能力作出有限而清楚的解释。下一篇换一个问题:会接着写的模型,怎样更好地按照人的指令完成任务?