先把“学到了”变成可以检验的命题
上一章已经训练并保存了完整模型,但训练损失下降只说明优化器改善了被反复使用的数据上的目标。本章要回答三个更严格的问题:它在未参与更新的文档上能否给出更高概率?变化来自哪个组件?这些结论依赖哪一个模型版本和评价范围?先修是第二章的似然、交叉熵,第八章的文档切分与检查点。模型训练、生成一段文字、得到一个分数,是三个不同的观察,不能互相代替。
我们先固定问题为“给定文档中真实的过去字符,预测下一个字符”。评价时喂入真实前缀,称为教师强制;这不是把模型自己生成的错误再喂回去。前者检查条件概率分布,后者检查长时间滚动的行为。一个模型可能有较好的条件概率,却在自由生成时重复;也可能偶然生成一句流畅文字,却给绝大多数保留文档很低的概率。两种检查都要做,但先分别写清输入与输出。
把文档按稳定编号分为训练、验证与测试三组。训练组参与梯度更新;验证组允许选择训练步数、学习率等设置;测试组只在这些选择冻结后作最终评价。若看过测试结果再改变设置,再次报告相同测试集的最好数字,它已经承担了验证角色。此时应保留这段选择历史,并另外准备尚未使用的测试资料,而不能继续称它为独立检验。
从每一个有效目标推导整体损失
设固定模型参数为 ,文档集合为 。文档 编码后包含 个需要预测的目标,其中不包含输入端的 BOS,包含课程约定的 EOS;右侧补齐位置没有目标。第 个目标记为 ,当前评分窗口内可用的真实前缀记为 。定义该目标的负对数概率 ,对数底数取自然底,因此损失单位是每个预测词元的 nat。
这条归约要求先加分子和分母,再相除。把每批的平均损失直接平均,通常会给一个只有两个有效目标的尾批,与一个有两百个目标的整批相同权重。只有各批有效目标数完全相等时,两条路线才等价。补齐长度、最后一批大小、不同长度文档都会破坏这个条件。项目的 evaluate 函数因此累积 loss_sum 和 token_count,而不是累积一个未经加权的 batch_loss 列表。
设第一批只有一个有效目标,损失为 ;第二批有九个有效目标,平均损失为 。错误的批次平均为 。正确分子为 ,分母为十,因此 。这个差别不是显示精度造成的,而是评价问题被改成了“均匀抽一个批次”,不再是“均匀抽一个有效词元”。样本权重的改变必须被明确命名。
损失的每个输入也可以由分数独立重算。模型为某位置输出词表长度的实数分数,先减最大值再指数归一化得到概率。对真实目标取负对数,等价于稳定的 log-sum-exp 减目标分数。这让我们能拿框架返回的均值,与逐位置明算的总和对照,发现标签偏移、忽略位置和归约轴错误。只看最终数字处于一个“合理范围”,不能发现这些问题。
import math
def nll(logits, target):
maximum = max(logits)
return math.log(sum(math.exp(v-maximum) for v in logits))+maximum-logits[target]
assert abs(nll([0.0, 0.0], 1)-math.log(2)) < 1e-12
assert abs(nll([1000.0, 1000.0], 1)-math.log(2)) < 1e-12
batches = [(0.1, 1), (18.0, 9)] # 每批的损失和、有效目标数
weighted = sum(s for s,n in batches)/sum(n for s,n in batches)
wrong = sum(s/n for s,n in batches)/len(batches)
assert abs(weighted-1.81) < 1e-12
assert abs(wrong-1.05) < 1e-12
print({'token_mean':weighted,'batch_mean_wrong':wrong,'ppl':math.exp(weighted)})
本项目把文档拆成目标不重叠的有限窗口,而不是为每个字符重算整个历史。相邻窗口在边界复用一个输入字符,目标却各算一次。后一个窗口的开始位置能读到的前缀较短,位置编号也重新从零开始。因此“每个目标恰好计分一次”与“每个目标都用尽最大可用历史”不是同一个性质。改变窗口长度或改为滑窗评分,可能改变分数,即使检查点不变;评价报告须把分窗规则作为协议的一部分保存。
另一个容易悄悄改变协议的位置是忽略标签。项目只忽略编号负一百的补齐目标,而 UNK 是编号零的有效目标。若为了让分数好看,把未知字符也忽略,最难的资料会从分母消失。可以事先另定义“已知字符子集”的指标,但应同时保留总体目标计数、未知率与原指标,不能把子集结果改名成整份资料的预测质量。
计算评价时还应同时切换评估模式并关闭梯度记录。前者影响 dropout 等层的行为,后者影响计算图和内存;它们不是同一个开关。保留训练模式却只关闭梯度,会使固定检查点的分数因随机掩码而改变。反过来只切评估模式,梯度仍可能被记录,浪费内存。与第二章手算对照时,也不要无声把极小概率截为某个下限;截断会给大损失设置人为上限,已是另一种评分规则。
困惑度的含义,以及比较时不变的单位
定义困惑度 。将损失定义代入,得到各位置真实目标概率倒数的几何平均。若每个位置都在 个词元上均匀预测,每个真实目标的概率都是 ,则 、困惑度等于 。它不是模型每一步实际列出的候选数量;非均匀分布也会得到同一数值。指数把对数评分换了一种刻度,并没有增加新的观测。
如果平均损失是一点八一,困惑度约六点一一;如果是一点零五,约二点八六。前一节的归约错误因此会让模型看起来好得多。再考虑两个概率各为四分之一的位置:损失为自然对数四,困惑度四。把两个位置并成一个“词元”,联合概率变为十六分之一,每个新词元的困惑度变成十六。模型与原文没有改变,单位改变了,所以不同分词器的困惑度不能直接比大小。
课程采用字符词表,但这也不自动等于原文的每字符编码长度。BOS 不计分,EOS 计分,未知字符被映射为 UNK;这些约定都改变了目标空间。把 nat/token 除以自然对数二,只得到 bit/目标词元。若要报告原始文本的 bit/字符或 bit/UTF-8字节,需要明确原始分母、终止符成本和未知字符的无损编码方式。把多个未知字都折成一个编号会丢失信息,不能拿折叠后的低损失宣称原文压缩能力。
因此项目同时报告验证与测试中的未知字符比例。假设十个测试字符中有两个不在训练词表,它们都变成 UNK;即使模型把这两个目标猜对,也没有区分原来的两个字。这不是软件异常,而是表示边界。修复方向可以是使用覆盖全部字节的编码或在其他课程实现子词编码;在本课的当前实验中,诚实的方法是保留未知率,并限制对字符语言能力的结论。
消融实验:每次改变的究竟是什么
消融指有控制地移除或替换一个机制,再观察事先定义的输出变化。这里必须区分两种问题:对已经训练好的模型临时关闭一个分支,检查的是该模型目前依赖这个分支的程度;从初始化开始训练一个没有该分支的新模型,检查的是在给定训练协议下,这种架构能学到什么。二者干预的时刻不同,不能把推理时的损坏直接解释为“另一种架构不可能训练成功”。
例如测试学习式位置嵌入。协议甲在固定检查点把位置向量置零,其他参数不动;协议乙重新初始化整机,并从头训练一个不加位置向量的版本。甲能定位已训练模型对位置通道的依赖,但会制造训练分布之外的内部状态。乙能比较两种训练系统,但优化轨迹、适配能力和参数数量也可能变化。报告中必须给出协议名称,而不是把两个实验都简写为“去掉位置后下降”。
再考虑删除注意力分支:每步运算量会下降。若两版都训练一千步,更新次数相同,却不保证计算预算相同;若都训练一分钟,较快的一版可能看到更多样本。哪一种公平,取决于待回答的问题。我们的第一轮对照固定文档切分、词表、初始化种子、批次顺序和更新次数,另外报告参数量与实际耗时。若讨论同计算预算,再另设预算,不能事后挑一个最有利的比较方式。
| 记录项 | 为什么需要 | 本课检查方式 |
|---|---|---|
| 模型与配置哈希 | 避免把不同版本的优点拼接 | 同一检查点、同一架构说明 |
| 数据及划分 | 固定归属、检查已知ID交叉 | ID隔离、整体内容哈希;正文去重另查 |
| 种子、采样状态、更新次数 | 明确优化轨迹的可比性 | 保存批次采样器与恢复记录 |
| 有效目标数与未知率 | 保证评分单位可解释 | 逐文档累积,不平均批均值 |
| 耗时与设备 | 质量对照不等于效率对照 | 实际计时与环境说明 |
不同编号下仍可能保存相同正文。ID隔离和整体语料哈希可以固定这份输入,却不会自动指出重复资料,更不能排除改了几个字的近重复。下段在项目目录中运行的独立审计先做Unicode NFC规范化、把连续空白折成一个空格,再逐篇比较规范化正文哈希;它能够检出这个明确规则下的精确重复。近重复、同一原始文档的不同切片或翻译副本还需要另行审查,不在这段精确比较的保证中。
import json,hashlib,unicodedata
from pathlib import Path
documents = [json.loads(line) for line in Path('data/documents.jsonl').read_text(encoding='utf-8').splitlines() if line.strip()]
seen = {}
for document in documents:
normalized = ' '.join(unicodedata.normalize('NFC',document['text']).split())
fingerprint = hashlib.sha256(normalized.encode('utf-8')).hexdigest()
assert fingerprint not in seen, (seen.get(fingerprint), document['id'])
seen[fingerprint] = document['id']
print({'documents':len(documents),'normalized_exact_duplicates':0,'near_duplicate_check':'not implemented'})
把消融接到项目时,先保留原检查点,调用ablate模块:默认只深复制当前模型,将位置表置零,在相同验证窗口评价;它不覆盖原权重。开启四十步配对重训后,两臂从同一初始state开始,分别重设全局随机状态,并使用同种子、同窗口编号轨迹的独立采样器。无位置臂把位置表永久置零并冻结,排除“初始化为零但随后重新学出位置”的混淆。该臂的可训练参数更少,报告需同时列总参数和可训练参数。
.venv/bin/python -m hero_transformer.ablate --checkpoint runs/my-model/checkpoint.pt --batch-size 8 --threads 1 --seed 7 --retrain-steps 40 --lr 0.003 --output evidence/my-position-ablation.json
报告里的intervention回答当前模型对位置的依赖,paired_retraining回答这套初始化、数据轨迹和更新预算下的训练对照。检查数据轨迹相等、零位置表训练后仍为零、原模型权重没有被干预改写,再阅读每臂的初末损失、有效目标和实际耗时。位置消融可能提高、降低或几乎不改变这个小数据集上的分数;程序不以预设胜负为通过条件。若据此选择了一个方案,之后仍需在选择冻结后使用未见资料,不能把同一次验证重用成独立证明。
| 实验问题与版本 | 训练前验证loss | 干预后或训练后验证loss | 可训练参数 |
|---|---|---|---|
| 固定160步检查点,正常位置 | 5.435705 | 5.435705(参考) | 50368 |
| 同一检查点,副本位置置零 | 5.435705 | 5.436922(不再训练) | 不作优化 |
| 同初始state,从头训练40步 | 5.964481 | 5.556376 | 50368 |
| 同初始state,零位置冻结后训练40步 | 5.960539 | 5.433599 | 49856 |
这次检查点干预只让验证loss上升约0.001217,而四十步重训的零位置臂反而更低。它们并不矛盾:一个问当前参数是否依赖位置,另一个问不同训练轨迹在有限预算后得到什么。两条重训轨迹各处理4676个有效优化目标、窗口编号完全一致,但可训练容量不同,且只有一个种子和很小的验证集。结果能反驳“任何这种小任务里位置一定更好”的强说法,不能证明位置对一般语言建模无用。需要另外固定顺序敏感任务、扩大文档覆盖并做配对多种子实验,才能继续检验原因。
有限文档上的波动应怎样描述
测试集的平均损失是这组文档上的确定统计量,但我们通常关心相似来源的新文档。把相邻词元当成完全独立的样本,会忽略一个段落内的主题与重复结构。本课用文档作为重采样单位:每次从现有测试文档中有放回抽取同样数量的文档,重新计算总损失除以总词元数,重复得到一个经验分布。长文档仍按它的目标数进入最终归约。
这称为文档级 bootstrap,是评估有限数据波动的一种近似方法,不是无条件的置信保证。它假定所持有文档在相关意义上代表目标来源,并用经验分布替代未知总体;三个测试文档显然无法覆盖广泛中文主题。下面的百分位区间只描述当前文档重采样产生的范围。一个数字区间再窄,也不能修补来源偏差、内容泄漏或未知字折叠。
import random
documents = [(2.0, 2), (18.0, 9), (8.0, 4)]
def token_mean(records):
return sum(s for s,n in records)/sum(n for s,n in records)
rng = random.Random(17)
draws = sorted(token_mean([rng.choice(documents) for _ in documents]) for _ in range(2000))
lower, upper = draws[int(.025*(len(draws)-1))], draws[int(.975*(len(draws)-1))]
assert lower <= token_mean(documents) <= upper
assert abs(token_mean(documents)-28/15) < 1e-12
print({'observed':token_mean(documents),'document_resampling_range':[lower,upper]})
把数值结果接回同一交付物
最终结果应能追溯到一个冻结检查点,而不是某次日志里最漂亮的一行。生成样例、困惑度、梯度检查和恢复检查必须注明是不是同一版代码、配置和数据。训练前后的比较还要保留初始模型的评价记录;只给训练后的较低数字,没有对照时并不能说明改进来自训练。若修复了评分器,应重新评价所有候选,不能让旧结果继续与新定义混排。
本章提交一份评价清单:列出文档ID、有效目标数、损失和、未知字符数,给出总损失与困惑度,并写明至少一个失败样例。再完成一次预先设计的消融,记录控制项与变化项。下一章会让同一检查点自由生成,并验证缓存优化是否保持 logits;那是推理算法的等价性检查,与本章模型预测质量是两条独立的验收线。
TRY IT YOURSELF
练习一|手算评分单位
有两篇文档,第一篇有效目标数2、损失和1;第二篇有效目标数8、损失和12。计算正确平均损失和困惑度。若先平均各文档的平均损失,会得到什么?何时两者相等?
提示 1
提示 2
对照参考解答
TRY IT YOURSELF
练习二|诊断一份“更好的”成绩单
某版验证损失从2.1降到1.8,但它新建词表时读取了验证集全部字符,还把未见字符从20%降为0。可以把0.3全部解释为架构改进吗?设计一个能区分原因的重新评价。
提示 1
提示 2
对照参考解答
TRY IT YOURSELF
练习三|设计可被反驳的消融
你想检验“已经训练的模型需要位置嵌入”,以及“在同更新次数下,带位置的架构更容易学到顺序”。分别写出一次干预、控制项、观测和不能推出的结论。