本章任务:给内容读取加上信息与位置契约
第三章的每个查询都能看全部位置,直接用于下一 token 训练会提前读取目标。本章在同一计算链上规定因果集合、拆合多个头,并加入可学习绝对位置,最后比较整段与缓存前向。数据协议以真实项目 data.py 为准:UNK 零、BOS 一、EOS 二,训练字符 Unicode 排序,无独立 PAD 编号;输入右填 EOS,目标右填 −100。需要会前章的矩阵索引、softmax 与局部反向。这些构件仍不等于包含归一化、前馈及优化器的完整训练模型。
记批大小 B、序列位置数 T、模型宽度 D、头数 H,每头宽度 d=D/H。位置索引从零开始,文中的“P第 t 行”均指索引 t,必须有 D 能被 H 整除。输入位置 t 的向量在本章对应已经看见的第 t 项,并用于预测下一项,因此允许读取当前位置本身。所有因果结论都在固定参数的一次前向中讨论;训练更新共享参数后,另一次前向当然可能变化,这不是未来信息沿计算图泄漏。
一、先定义许可集合,再计算 softmax
通用序列系统可以把 j≤i 与“键 j 有效”取交集。本项目却有更窄、明确的约定:仅右填充,若文档有效长度为 ℓ,则有效查询 i<ℓ,填充键 j≥ℓ 必有 j>i,已被因果性排除。因此项目 forward 不接收任意左填充或显式 padding-key mask。本章参考函数的 allowed 真表示允许;项目 causal_attention 的 future 真表示禁止,两者互补,不能把布尔数组直接互换。非法项加负无穷后归一化,与先选合法分数计算等价。
以三位置、全部分数为零为例:第零行许可 [真,假,假],权重 [1,0,0];第一行许可 [真,真,假],权重 [1/2,1/2,0];第二行全许可,权重各三分之一。若先对全部位置 softmax,再把未来项清零,第一行会变成 [1/3,0,0],权重和不是一。必须先限制候选,再在合法集合上归一化,或清零后重新归一化到相同定义。
如果误把 ≤ 改成 <,第零位置没有任何合法键,softmax 就没有分母。此任务的第零输入是 BOS 一,允许读取它并不泄漏首字符;首字符仍只存在于目标。有效查询至少可读自身,额外构造的全遮罩行应报错。右填充查询本身在项目 forward 中仍会正常产生输出,没有被强制置零;它的目标为 −100,所以不参与损失。不能把这种未计分输出与有效全空行混为一谈。
PAD 在这里仅是填充角色,不是第四种特殊 token。collate 将短文“雨”补成输入 [1,4,2]、目标 [4,2,−100]。前两个查询有效,第三个不计分;真实 EOS 目标二在第二位置必须计分。输入末尾的二不能据其值触发全局屏蔽,项目也没有把 EOS 当 padding_idx。因果关系让右填充不会影响有效查询,目标 −100 则让这些填充查询不参与优化。若改成左填充,这一推理不再适用,需要新的 mask 与位置契约;当前 forward 不声称支持。
二、多头改变子空间,不改变时间顺序
Q、K、V 的全宽投影先输出 B×T×D。把最后一维拆成 H 个连续的 d 维段,再把头轴移到时间轴之前,得到 B×H×T×d。第 h 个头的第 a 个坐标,对应原特征编号 h·d+a。仅改变形状而不交换轴,可能把不同时间的数据交叉放入同一个头,数值形状仍可能合法,因此要按索引逐项验证。
每个头在自己的 d 维点积上除以 √d,分别对合法键位置归一化,得到 B×H×T×d 的读取结果。然后把头轴移回,按原头序拼成 B×T×D,并乘输出矩阵 W_O,形状 D×D。W_O 无偏置,负责混合不同头的结果;它不是可省略的展示步骤。头数增加并不自动使每头学到某种指定语义,分工需要观察和实验。
固定模型宽度时,四个全宽投影各有模型宽度平方个参数,改头数并不自动增加这些矩阵的总参数量;改变的是每头读取的子空间与独立归一化。若显式保存每个头的注意力表,头数增加又会增加这部分存储。因此头数、总宽度、算子成本必须分别记账,不能只看到更多头就推断模型更大或运行更快。
算例一:两头读取与输出混合完整计算
暂时关闭位置项以方便手算,把这个小矩阵中 BOS 一与春三的嵌入分别设为 [1,0,0,1]、[0,1,1,0],D=4、H=2、d=2,三个投影都是四阶单位矩阵。第一头读前两维,第二头读后两维。第零查询只能读自身,拼接结果仍为 [1,0,0,1]。第一查询两把键的分数均为 [0,1/√2],设 a=1/(1+exp(1/√2))、b=1−a。这些手工向量用于算子检查,不是项目已训练出的嵌入。
数值 a 约为 0.330238,b 约为 0.669762。第一头值为 [1,0] 和 [0,1],输出 [a,b];第二头值为 [0,1] 和 [1,0],输出 [b,a],故拼接为 [a,b,b,a]。注意两个头本例碰巧拥有相同权重,不代表多头结构要求权重相同;它们读的值坐标已经不同。
选择输出映射把 [c零,c一,c二,c三] 变成 [c零+c二,c一+c三,c零−c二,c一−c三]。于是第零输出为 [1,1,1,−1],第一输出为 [1,1,a−b,b−a],后两项约为 [−0.339523,0.339523]。加权读取的凸组合边界只在各头输出处成立,经过 W_O 后可以出现负数或更大幅度,不再是“概率向量”。这一计算也检验了拼接轴与输出投影确实被使用。
三、可学习绝对位置怎样进入模型
主项目使用位置表 P,形状为 C×D,C 是最大可表示位置数;它与 token 嵌入表 E 分开存储。若输入编号为 x_bt,绝对位置为 o_b+t,其中 o 是这段输入之前已有有效前缀的长度,则输入表示为 E[x_bt]+P[o_b+t]。两项同宽才能相加。位置向量参与学习,但“第七位置”不是一个需要模型预测的 token,也不会被加入词表目标。
本章程序为位置表手工设小数值,只验证加法和索引,没有训练它。真正反向时,dX 同时流向两张表:字符表按 token 编号累加,位置表按绝对位置累加。例如一个批次两条序列的首位置上游梯度分别为 [1,2]、[−3,1],同一 P零行收到 [−2,3];若二者首字符不同,字符表的两行则分别收到各自梯度。位置共享与字符共享是不同索引规则。
绝对位置表对超出 C 的位置没有定义,必须拒绝或采用另行声明的扩展策略;取模重复位置、把越界位置截成最后一行都会改变模型。表中某些位置若从未在训练中被使用,不能因为初始化过参数就声称学到了长文本。可学习位置是本项目选择,而非相对位置方法永远更差的结论。
算例二:有 mask 并不自动解决所有次序问题
考虑单层无位置的注意力,比较 [甲,乙,丙] 与 [乙,甲,丙] 的最后位置。两者最后查询相同,最后行的因果许可又包含全部三个位置;前两个键和值只是一起交换顺序。softmax 权重会随键交换,乘上同样交换的值再求和,最终输出相同。这个具体结构无法在最后一行区分前两项的排列,即使它严格不读取未来。
加入位置后,甲与乙交换会分别带上不同 P 行,键和值通常不再只是简单交换,因此有机会区分这两种前缀。程序将验证所给数值中的差异。不要将这个单层反例扩大成“所有有 mask 无位置模型完全看不见顺序”:因果许可本身非对称,多层上下文还可能携带其他顺序线索。我们需要的是明确的位置表示与可检验的建模能力,不能仅凭有三角遮罩就宣布序列结构已完整。
RoPE 是另一个位置方案,主要通过按位置旋转查询和键来改变匹配内积,而不是把本章 P 直接加到输入。它需要另外固定旋转配对、频率和相对点积的推导。本章仅给出比较阅读入口,不在主项目中混入第二套位置实现,也不以原论文的结果替代本项目实测。后续受控消融才可以在相同训练条件下比较两种方案。
四、因果性需要覆盖整个计算链
在固定参数下,初始 X 第 i 行只依赖该位置 token 与位置编号。因果注意力第 i 行仅从零到 i 的输入行计算;输出投影只混合同一位置的特征,因此不会新增未来路径。后面的逐位置归一化、前馈和残差若也不跨时间读取,就能按层递推保留这个性质。这是架构推理,未来扰动则是在具体实现上的检错工具,二者互相支持但不是同一种证据。
未来扰动检查固定长度和前缀,只把后缀换成明显不同的 token 或连续向量,比较所有前缀输出。合法实现应在浮点容差内不变。还要运行一个故意关闭因果许可的版本,确认相同测试会观察到变化;否则可能因为输入退化、输出被常量覆盖或测试位置选错而产生虚假通过。只检查权重右上角为零,无法发现上游跨时间归一化等其他泄漏。
五、逐 token 缓存为何需要位置 offset
推理时可以保存每个头已有位置的 K、V,只为新 token 计算新的查询、键和值。对一个只有因果读取的固定模型,加入未来 token 不会改变旧位置表示,所以缓存的旧 K、V 可以复用;多层模型需在每层分别保存。新位置仍须读取全部有效历史,而不能只读取新 token。缓存是计算复用,不能省略模型原本需要的信息。
若缓存已有三个有效 token,新 token 位置是三,不是零,应加索引三的位置向量并投影新键和值。每次只输入一项却把位置重置零,会让位置语义错误,不能因数组长度都是一就认为等价。把同一序列整段前向,再逐项进入缓存,比较每个输出即可检错。本章缓存只处理单条无填充输入流;项目缓存长度就是所存键值长度,不具备按样本跳过左填充的接口,也不应把训练中的右填充后缀当成需要续写的真实前缀。
六、独立程序:分离四类证据
import math
D=4; HEADS=2; d=D//HEADS
UNK, BOS, EOS, IGNORE = 0, 1, 2, -100
TOKENS=["<unk>","<bos>","<eos>","春","雨","风"]
# 索引与工程一致,数值是仅用于手算的固定嵌入。
E=[[40.,-30.,20.,-10.],[1.,0.,0.,1.],[.2,.1,.3,-.1],
[0.,1.,1.,0.],[.5,.5,-.5,.5],[1.,0.,0.,1.]]
P=[[.1*(t+1),.03*t,.07*(t%2),-.02*t] for t in range(8)]
WO=[[1.,0.,1.,0.],[0.,1.,0.,1.],[1.,0.,-1.,0.],[0.,1.,0.,-1.]]
def linear(x,W): return [sum(x[i]*W[i][j] for i in range(len(x))) for j in range(len(W[0]))]
def split(x): return [x[h*d:(h+1)*d] for h in range(HEADS)]
def masked_softmax(scores,allowed):
legal=[v for v,ok in zip(scores,allowed) if ok]
if not legal: raise ValueError("有效查询没有合法键")
m=max(legal); e=[math.exp(v-m) if ok else 0. for v,ok in zip(scores,allowed)]
return [v/sum(e) for v in e]
def read(q,keys,values,allowed):
scores=[sum(a*b for a,b in zip(q,k))/math.sqrt(d) for k in keys]
weights=masked_softmax(scores,allowed)
return [sum(w*v[j] for w,v in zip(weights,values)) for j in range(d)]
def embed(token,position,use_positions):
if not 0<=position<len(P): raise ValueError("位置越界")
return [a+(b if use_positions else 0.) for a,b in zip(E[token],P[position])]
def full(ids,use_positions=True,causal=True):
if not ids: raise ValueError("空输入")
X=[embed(token,t,use_positions) for t,token in enumerate(ids)]
# Q/K/V 为无偏置单位投影;无 padding mask 参数,所有查询均计算。
parts=[split(x) for x in X]; outputs=[]
for t in range(len(ids)):
allowed=[j<=t or not causal for j in range(len(ids))]
concat=[]
for h in range(HEADS):
keys=[row[h] for row in parts]
concat+=read(parts[t][h],keys,keys,allowed)
outputs.append(linear(concat,WO))
return outputs
def difference(A,B): return max(abs(a-b) for ra,rb in zip(A,B) for a,b in zip(ra,rb))
a=1/(1+math.exp(1/math.sqrt(2))); b=1-a
manual=[[1.,1.,1.,-1.],[1.,1.,a-b,b-a]]
assert difference(full([BOS,3],False),manual)<1e-12
base=full([BOS,3,4]); perturbed=full([BOS,3,UNK])
assert difference(base[:2],perturbed[:2])<1e-12
unmasked_difference=difference(full([BOS,3,4],causal=False)[:2],full([BOS,3,UNK],causal=False)[:2])
assert unmasked_difference>1e-6
# collate 的短文“雨”输入与目标;不能按 input==EOS 判断。
short_inputs=[BOS,4,EOS]; short_targets=[4,EOS,IGNORE]
short_valid=[target!=IGNORE for target in short_targets]
assert short_valid==[True,True,False]
assert [target for target in short_targets if target!=IGNORE]==[4,EOS]
assert difference(full(short_inputs)[:2],full([BOS,4]))<1e-12
assert difference(full(short_inputs)[:2],full([BOS,4,UNK])[:2])<1e-12
assert len(full(short_inputs))==3 # 填充查询输出存在,只是不计分。
try: masked_softmax([0.,1.],[False,False])
except ValueError: pass
else: raise AssertionError("全遮罩未被拒绝")
no_position_difference=difference([full([3,5,4],False)[-1]],[full([5,3,4],False)[-1]])
with_position_difference=difference([full([3,5,4])[-1]],[full([5,3,4])[-1]])
assert no_position_difference<1e-12 and with_position_difference>1e-6
def new_cache(): return {"keys":[[] for _ in range(HEADS)],"values":[[] for _ in range(HEADS)]}
def step(token,cache,position_override=None):
offset=len(cache["keys"][0])
position=offset if position_override is None else position_override
parts=split(embed(token,position,True)); concat=[]
for h in range(HEADS):
cache["keys"][h].append(parts[h][:]); cache["values"][h].append(parts[h][:])
concat+=read(parts[h],cache["keys"][h],cache["values"][h],[True]*(offset+1))
return linear(concat,WO)
cache=new_cache(); incremental=[step(token,cache) for token in [BOS,3,4]]
cache_error=difference(incremental,base)
assert cache_error<1e-12
bad_cache=new_cache(); step(BOS,bad_cache); step(3,bad_cache)
bad=[step(4,bad_cache,position_override=0)]
reset_position_error=difference(bad,[base[-1]])
assert reset_position_error>1e-6
print("two_head_manual_output",manual)
print("future_perturbation_error",difference(base[:2],perturbed[:2]))
print("unmasked_negative_control_change",unmasked_difference)
print("right_padding_valid",short_valid,"scored_targets",[4,EOS])
print("permutation_no_position",no_position_difference,"with_position",with_position_difference)
print("cache_equivalence_error",cache_error,"reset_position_error",reset_position_error)
print("all_causality_position_checks_passed")程序没有更新位置表、投影或词表参数,证明的是参考算子的结构检查。它对齐真实项目的六项示例词表、右填充和目标 −100,但没有新增项目不支持的 valid 参数或左填充能力。单位 Q/K/V 只为手算,第三章仍保留非单位投影反向参考。实际主项目使用自写 PyTorch decoder-only、preLN、GELU 前馈、可学习绝对位置和全部投影联合训练,不用 nn.Transformer 替代教学实现。
七、递进练习与解答
TRY IT YOURSELF
练习一|复算每头缩放与输出投影
沿用两头算例,算第一查询在第一头的两个分数、权重、读取向量和最终四维输出。如果误用 √D 代替 √d,权重向哪一个方向改变?
提示 1
提示 2
对照参考解答
TRY IT YOURSELF
练习二|修复两个容易混淆的边界错误
某实现让首位置只看 j<i,并在每次生成新 token 时都使用 P零行。分别解释为什么不符合本章契约。已有前缀长度四,新 token 应读哪一行位置表?如何用独立参考发现第二项错误?
提示 1
提示 2
对照参考解答
TRY IT YOURSELF
练习三|设计能区分因果与顺序能力的验收
构造两组测试,第一组检验未来可见性,第二组检验本章单层无位置模型的次序退化。每组写出保持什么、改什么、应观察什么,并说明为什么一组通过不能代替另一组。
提示 1
提示 2
对照参考解答
本章增量与下一章依赖
项目现在新增因果合法读取、仅右填充的有效性约定、多头拆合、W_O、绝对位置索引,以及前缀扰动和缓存等价检查。填充查询是否计分由 targets≠−100 决定,真实 EOS 保留。后续章节把相同算子放进 preLN 残差块和带偏置 GELU 前馈,让交叉熵梯度进入全部参数。整模仍须重跑因果检查:单层通过不保证后来新增的归一化、数据窗口或缓存没有引入错误,位置表长度、头数和词表版本也须共同保存。