任务与先修:把 tokenizer 输出接到模型
本章处理最容易被训练曲线掩盖的接口:同一个 token 到底在哪个输入位置,被哪个输出预测,是否被重复计分。读者需掌握前三章的边界符、BPE 与负对数目标,并会半开区间切片。本项目使用长度上限二十四的独立窗口,不跨文档,每篇的每个待预测目标在一次完整评估中恰好出现一次;窗口之间不携带缓存,也不额外插入 BOS。这个选择减少重复计算,代价是窗口边界处丢失更早的上下文。它是一个明确的评估协议,而非长文完整前缀概率的近似无误实现。
令一篇编码后的序列为 ,长度为 。定义全体预测对输入 、目标 ,索引 从零到 。模型在位置 i 的输出预测下一项,绝不能把 也放进同一个位置可读取的输入。序列的最后一个 EOS 是目标,不再有“预测 EOS 之后的 token”这个额外训练对。有效目标总数恒为 。
非重叠窗口的索引证明
令窗口上限为 。窗口起点依次为零、C、二倍 C,直到小于 。对每个起点 ,终点 ;输入取 z[s:e],目标取 z[s+1:e+1],长度同为 。这些半开索引区间 [s,e) 两两不交,并集是 [0,M-1),所以每个预测对出现一次。尾窗不足 C 时保留它,不丢弃;因为 EOS 经常位于尾窗,丢尾窗会系统性少学终止事件。
算例一:尾窗和真实结束事件
用便于手算的 ASCII 文档“abcde”,基础编码是 [256,97,98,99,100,101,257],M 为七。设 C 为二,起点为零、二、四。第一窗输入 [256,97],目标 [97,98];第二窗输入 [98,99],目标 [99,100];第三窗输入 [100,101],目标 [101,257]。把三个目标列表顺序拼接得到 [97,98,99,100,101,257],恰好是原序列去掉 BOS。若正文换成“abcd”,最后窗口输入 [100]、目标 [257],长度一,也必须保留。
注意第二窗预测 c 时,只能读当前窗口中的 b,而不能读更早的 BOS 与 a;这与完整前缀条件不同。独立检查一是目标拼接逐项等于 z[1:],比只核对数量更强,因为数量正确仍可能重复一个、漏掉另一个。独立检查二是逐窗断言 inputs[j] 等于原序列 s+j,targets[j] 等于 s+j+1;它能发现某个窗口把目标错位两格,或者错误地对每个窗口重新添加开始符。
填充是角色,不是一个新的文本类别
批次需要规则矩阵。设一批有 B 个窗口,实际最长长度为 T,满足 T 不超过 C。collate 创建输入 x 形状 B×T,先用 EOS 编号二百五十七填满;目标 y 同形状,先用负一百填满,再把各窗口真实输入与目标放到左侧。没有独立 PAD token。有效掩码由 y 不等于负一百定义,或者由原始窗口长度定义,不能通过输入不等于 EOS 猜测。所有负一百都只是损失忽略标记,不会送入 embedding 查表。
模型输出 logits 形状 B×T×V。逐位置交叉熵后,把目标负一百对应项排除,分子累加其余项,分母是有效目标数。因果注意力在位置 t 只允许读取不晚于 t 的 key,所以右侧填充不会影响任何左侧有效查询;填充查询自己的输出可能任意,但它不计分。此实现没有显式 padding-key 接口,不支持把任意左填充当成同一协议。若左侧塞入 EOS,它会进入有效查询的过去,改变隐藏状态和位置编号。
算例二:把真实 EOS 误当 padding 的损失
同批两个窗口,甲输入 [256,97]、目标 [97,257];乙输入 [256]、目标 [257]。拼批后乙输入是 [256,257]、目标 [257,-100]。一共有三个有效目标,其中两个恰好是 EOS。设这三个真实目标的模型概率均为二分之一,总 NLL 是 、平均 。若程序按“目标等于 EOS 就忽略”遮掉结束事件,只剩 a 一个目标,表面平均仍可能恰好一样,但模型完全没有学停止。不能只用平均损失数值发现这个错误,必须检查有效计数与 EOS 目标数。
若三处概率分别是二分之一、四分之一、八分之一,正确平均是 ;错误地删除两个 EOS 后只剩 ,指标还会虚假变好。这个失败例说明测试要能区分实现:令被错误删除的项有不同难度,同时断言总数三、EOS数二。另一个边界是所有目标均被忽略的批次,它没有定义平均损失;模型应拒绝,而不是用除以一之类补丁生成零。实际数据窗口非空,抽样器也禁止空批。
import math
BOS,EOS=256,257
z=[BOS]+list(b'abcde')+[EOS]; C=2
windows=[]
for start in range(0,len(z)-1,C):
stop=min(start+C,len(z)-1)
windows.append((z[start:stop],z[start+1:stop+1]))
assert [v for _,y in windows for v in y]==z[1:]
batch=[([BOS,97],[97,EOS]),([BOS],[EOS])]
T=max(len(x) for x,_ in batch)
x=[a+[EOS]*(T-len(a)) for a,_ in batch]
y=[b+[-100]*(T-len(b)) for _,b in batch]
assert x==[[256,97],[256,257]] and y==[[97,257],[257,-100]]
valid=sum(t!=-100 for row in y for t in row)
eos_targets=sum(t==EOS for row in y for t in row)
loss=-sum(math.log(p) for p in [.5,.25,.125])/valid
assert valid==3 and eos_targets==2 and abs(loss-2*math.log(2))<1e-12
print('windows',windows,'valid',valid,'EOS targets',eos_targets,'loss',round(loss,6))
抽样器状态与训练目标的近似
项目把训练窗口打平,每轮用独立的 torch.Generator 产生一个随机排列,每次取最多四个窗口,最后不足四个也保留。游标到末尾才洗牌进入下一轮。一个完整轮次里每个窗口恰好见一次,但不等于每次更新有同样多的目标;长短窗混合使有效分母变化。更新时对当批有效目标求均值是常用随机近似,它与“把整个轮次 NLL 累加后只更新一次”不相同,因为参数在每批后改变,且小尾批的窗口会获得一次完整更新权重。
这并不要求为了严格平均而删除尾批。更合适的是承认训练更新协议,并记录 targets_seen,明确它计重复见到的有效目标,不是独特数据量。若要精确模拟若干批组成的大批,需要在固定参数下累计每批 NLL 总和,再按全部有效目标数缩放梯度,最后只更新一次;本项目没有做这种梯度累积。评估则无此近似:参数冻结,逐窗计算总 NLL 后按总有效目标数求平均。把两种操作混称“平均loss”会掩盖不同数学对象。
独立的未来扰动测试可以直接检验因果接口:给两个输入,前三个编号完全相同,第四个不同;前三个位置 logits 应相同。再比较长度二的输入与同样前缀后接右填充的输入,前两个位置 logits 在浮点容差内应一致。前者定位未来泄漏,后者定位填充影响;它们不能证明数据没有跨集合泄漏,也不能证明位置编码正确处理了左填充。每条测试只负责自己的故障模型,不能把一个绿色结果外推到全部序列语义。
本章项目增量是 make_windows、collate 与可保存的 Sampler。正式验收需要原始窗口起点、输入和目标可逐项重建,尾窗没有静默丢失。下一章把 B×T 的整数矩阵送进完整 decoder,并追踪 B×T×V 输出到所有参数的梯度;只有先证明“预测的确实是下一项”,参数更新和曲线下降才有清晰含义。第六章恢复时还要保存排列、游标、轮数和随机生成器状态,否则同样权重会从不同数据位置继续。
为了看清截断误差,设一个确定规律需要开头标记决定末尾符号,且两者相隔超过窗口长度。完整前缀模型理论上可利用那个标记;本项目末尾窗口只见局部后缀,两个文档若后缀相同但答案不同,便无法同时确定预测。窗口覆盖依然完全正确,验证NLL却有不可消除的局部歧义。因此“每个目标只计一次”是计数正确性,不是保留所有预测信息的证明。若需要保留更长历史,应改任务协议并重新训练和评估。
另一种评估做法是重叠滑窗,把较长历史作为输入,但每窗只评分新增后缀;它能减少上下文重置的损失,却必须用评分掩码防止同一目标多次计入。若简单把每个重叠窗全部位置loss累加,窗口中部目标权重会高于边缘。当前项目选择非重叠方案,是为了让索引证明和小CPU预算清楚;不声称它在语言模型评估中唯一正确,也不把未实现的后缀评分接口写成已有功能。
位置重置和文档重置也要分开。只有第一窗含BOS,其余窗直接从原文某个正文token开始,并把该token映射到窗内位置零;这不是伪造一个新的文档开始符。模型在这种输入分布上训练,评估也采用同样方式。若推理时用滚动窗口,截断后位置同样从零重算;若改用全局位置offset,learned表可能越界,并且与训练位置分布不同。代码和讲义都遵守同一个有限窗口协议。
当一篇恰好只有一个正文token时,目标是该token与EOS两项;当正文为空时只有EOS一项。本项目语料无空文本,但窗口函数的索引仍能产生正确边界对。对于无文档或空批次则不应继续,因为无法形成有效分母。测试要把这三种情况区分:空正文可以有结束目标,空集合没有任何目标,右填充并不凭空创造目标。这些边界能暴露把长度和有效性混为一谈的实现。
计算矩阵大小与评分目标数需要另一本账。例如四窗长度为二十四、二十四、四、三,拼批宽度二十四,输入共有九十六个位置,实际目标只有五十五个。其余四十一个填充位置仍可能产生激活与logits,占计算和内存,但不进入损失分母。若报告吞吐,应说有效目标每秒还是矩阵位置每秒;这两个数差近一倍。项目targets_seen只累加五十五,不会为了看起来吞吐更高而把填充计成训练token。
每个批次的真实长度应保留在窗口对象中,便于独立复算目标数,而不是事后从填充输入猜测。
python -m unittest -v tests.test_pipeline.PipelineTests.test_targets_exactly_once tests.test_pipeline.PipelineTests.test_padding_eos_is_not_ignored_eos
TRY IT YOURSELF
练习一:不整除尾窗
编码序列长度 M=10,C=4。写出所有 [s,e) 区间、每窗目标数和目标原序列索引。
提示 1
提示 2
对照参考解答
TRY IT YOURSELF
练习二:带不同长度的加权
两窗有效长度为4与1,冻结模型下均值loss为2与5。求合并后的平均,解释为什么不能平均成3.5。
提示 1
提示 2
对照参考解答
TRY IT YOURSELF
练习三:定位无位置与泄漏
某模型通过未来扰动测试,但对重复输入交换位置不能区分。能否说明序列建模完整?设计进一步检查。