输入查几行,输出算整表:从 Token ID 到下一 Token
Tokenizer 编码结束,得到的是一串整数。它们还没有给出“接下来写什么”。
接下来的两个动作看起来都与词表有关,方向却不同:输入端按 ID 取出少数几行向量;输出端用上下文向量,对整个词表逐项计算分数。 即使输入里只有 a、b,输出层也会给 c、d、e 打分。再经过选择规则,分数才变成一个具体的下一 Token ID。
这篇沿着同一次计算,把输入、输出和选择规则接起来。三个职责先分清:
| 环节 | 接收什么 | 产出什么 |
|---|---|---|
| Tokenizer 编码 | 文本 | 与模型配套的 ID 序列 |
| 语言模型 | 当前 ID 序列 | 下一 Token 的候选分数 logits |
| 生成策略 | 分数,必要时加随机数 | 一个下一 Token ID |
先把 Tokenizer 接到模型上
GPT-2 的一个实际编码结果是:
Hi 你! → [17250, 220, 19526, 254, 0]这里共 5 个 Token:“你”的 UTF-8 字节分布在两个 Token 中,前面的空格另外占一个。 具体编解码过程见沿着 GPT-2 源码走完一次编解码。
总览取 GPT-2 的词表大小 V=50,257、向量维度 D=768。这串输入长度为 T=5,所以查出的向量是 5×768;
模型最后一个位置的上下文向量是 1×768,输出分数是 1×50,257。
encoder.json、vocab.bpe 是分词器的配置;Embedding、位置表、Transformer 和输出层是语言模型的参数。
Tokenizer 的“符号 ↔ ID”表和模型的“ID → 向量”表不是同一张表,但 ID 的含义必须一致。
图中的新 ID k 只是示意,没有运行预训练 GPT-2 来预测它。下面换成一个可以把数字展开的小模型;两套词表的 ID 不能混用。
小模型的约定:三个位置,五个候选,四维向量
词表为 a→0、b→1、c→2、d→3、e→4,输入是 a | b | a,即 [0,1,0]。
批量大小 B=1,正文省略批量维。模型只有一个头、一个 Pre-Norm Block,无 Dropout,位置表最多容纳 8 个位置。
所有权重都手工固定,没有训练过。它的作用是展示计算;69% 这样的概率不代表它学到了语言规律。
| 符号 / 对象 | 含义 | 本例 |
|---|---|---|
| Token ID | 词表条目的编号,也用于索引输入向量表 | [0,1,0] |
| 位置编号 | 当前输入中第几个位置,从 0 开始 | [0,1,2] |
T | 这次输入有多少个 Token | 3 |
V | 整个词表有多少个条目 | 5 |
D | 每个位置用多少个特征表示 | 4 |
E | 所有词表项的输入向量 | V×D = 5×4 |
P | 所有可用位置的向量 | 8×4 |
H | 模型算出的上下文表示 | T×D = 3×4 |
W_head、b | 独立输出层的权重、偏置 | 5×4、5 |
Z | 每个位置对整个词表的分数 | T×V = 3×5 |
Token ID 是索引,不是连续数值特征。 ID 4 并不比 ID 1 “大三倍”;模型用整数定位一行向量,再计算向量。
这里 T 专指序列长度,温度用 τ;词表大小 V 也要与注意力中的 Value 向量区分。
输入端:同一个 a,查同一行 E,加不同的 P
GPT-2 和这里的教学模型使用可学习的绝对位置向量。每个位置先查两张表,再逐元素相加:
X₀[t] = E[id[t]] + P[t]| 位置 t | Token / ID | E[id[t]] | P[t] | X₀[t] |
|---|---|---|---|---|
| 0 | a / 0 | [1,0,1,0] | [0.1,0.2,0,0] | [1.1,0.2,1,0] |
| 1 | b / 1 | [0,1,0,1] | [0,0.1,0.2,0] | [0,1.1,0.2,1] |
| 2 | a / 0 | [1,0,1,0] | [0,0,0.1,0.2] | [1,0,1.1,0.2] |
两个 a 都读取 E[0],但分别加上 P[0] 和 P[2]。位置编号要先查表,不是把整数 0、1、2 直接加到向量的每一维。
相加后每个位置仍有 4 个数,没有拼接成 8 维。
token_vectors = token_embedding(ids) # (1, 3, 4)positions = torch.arange(ids.shape[1], device=ids.device)position_vectors = position_embedding(positions) # (3, 4)x0 = token_vectors + position_vectors # (1, 3, 4)位置向量沿批量维广播。E、P 是参数表;查出的向量和 x0 是这次前向计算的值。
这个加法是本篇所用实现的位置处理方式,不能套成所有 Transformer 都使用同一张绝对位置表。
Transformer 把每一行变成上下文表示
本例按 Pre-Norm 的顺序计算:
X₁ = X₀ + Attention(LN₁(X₀))X₂ = X₁ + MLP(LN₂(X₁))H = LN_f(X₂)LayerNorm 沿每个位置自己的四个特征归一化,再使用可学习的缩放与偏移;不把不同位置混起来统计。
本例用 PyTorch 的默认 eps=1e-5,缩放初值为 1,偏移为 0。
注意力分支从归一化后的输入生成 Q、K、Value,计算 QKᵀ/√4,把未来位置的分数设成 −∞,按行 softmax,
再加权汇总 Value 并投影。实际注意力权重为:
读取位置 0 读取位置 1 读取位置 2查询位置 0 1.0000 0.0000 0.0000查询位置 1 0.0190 0.9810 0.0000查询位置 2 0.4683 0.0101 0.5216未来位置的权重为 0,每行之和为 1。这次 softmax 的候选是输入位置,最后一行在位置 0、1、2 之间分配读取权重。 末尾输出层的 softmax 才在词表候选之间分配概率。
MLP 是 Linear 4→16 → ReLU → Linear 16→4。同一套 MLP 在每个位置独立计算;位置之间的信息交流由注意力完成。
两次残差相加都保持 3×4 的形状。教学结构沿用 V7 的 ReLU,GPT-2 对应子层使用 GELU。
最后的位置 t=2 已经结合当前 a | b | a,得到:
h₂ = [0.9546803832, −1.0673931837, 1.0419750214, −0.9292621017]向量的四个数不是四个 Token 的概率。它们是交给下一层使用的四个特征,下一层才把 D 维映射成 V 个分数。
输出端:每个候选都有一行权重
这里的 lm_head 是语言模型的输出预测层,lm 是 Language Model。
它与 attention head 职责不同:一个把上下文表示变成词表分数,另一个在输入位置之间汇集信息。
本例的输出层是 nn.Linear(4, 5)。PyTorch 保存的权重形状是 输出维度×输入维度,所以 W_head 为 5×4:
z[j] = h₂ · W_head[j] + b[j]z = h₂ @ W_head.T + b
(1×4) @ (4×5) + (1×5) → (1×5)对所有位置一起算,得到 Z = H @ W_head.T + b,形状为 3×5;偏置广播到每一行。
第 j 个输出与词表 ID j 对齐。 输入只查过 E 的哪些行,不会限制输出层拥有的五行候选权重。
| 候选 / ID | W_head[j] | b[j] | 最后位置的 logit | softmax 概率 |
|---|---|---|---|---|
| a / 0 | [1,0,0,0] | 0 | 0.954680 | 22.03% |
| b / 1 | [1,0,1,0] | 0.1 | 2.096655 | 69.03% |
| c / 2 | [0,1,0,0] | 0 | −1.067393 | 2.92% |
| d / 3 | [−1,0,0,0] | −0.2 | −1.154680 | 2.67% |
| e / 4 | [0,0,0,1] | 0 | −0.929262 | 3.35% |
以 b / ID 1 为例,把点积真正展开:
z[1] = 0.954680×1 + (−1.067393)×0 + 1.041975×1 + (−0.929262)×0 + 0.1 ≈ 2.096655其余四个候选也做同样的点积与加偏置。矩阵乘法把它们一次算完,不需要挨个写五次循环。
logit 可以为负,它还不是概率。softmax 把整行分数转换为和为 1 的概率:
p[j] = exp(z[j]) / Σₖ exp(z[k])点积是匹配分数,但不等于余弦相似度
对于非零向量:
h · w = ‖h‖ ‖w‖ cos θ点积同时受方向和长度影响。这里没有把两者都除以各自的长度,独立输出层还会加偏置。 称它为“打分”,描述的是用途:形成可比较的候选 logits,交给 softmax 和选择规则。
GPT-2 为什么写成 hEᵀ
| 实现 | 输出公式 | 输入 / 输出权重 |
|---|---|---|
| V7 练习、本文五 Token 模型 | z = h W_headᵀ + b | E 与 W_head 独立,带输出偏置 |
| GPT-2、所对照的 nanoGPT | z = h Eᵀ | 复用输入 E,无输出偏置 |
GPT-2 的 model.py 用 tf.gather(wte, X) 查输入行,输出用 tf.matmul(h_flat, wte, transpose_b=True)。
nanoGPT 显式把 transformer.wte.weight 与 lm_head.weight 绑定。
共享权重就是同一张参数表承担两种用途:输入时查行,输出时让所有行参与点积。
只计算输入 / 输出权重矩阵,共享时是 VD 个参数,独立时是 2VD;偏置、位置表和其余网络参数不在这笔账里。
训练用各位置,生成用最后位置
教学文本是 a | b | a | b。取前面三项作为输入,后移一项作为目标:
输入:[0, 1, 0] → a, b, a目标:[1, 0, 1] → b, a, b| 位置 | 可用上下文 | 下一 Token 目标 | 实际交叉熵 |
|---|---|---|---|
| 0 | a | b / 1 | 0.409955 |
| 1 | a | b | a / 0 | 3.320039 |
| 2 | a | b | a | b / 1 | 0.370652 |
每个位置都提出一道“预测下一项”的题。因果 mask 保证位置 0 不能偷看位置 1 的答案;训练时三道题可以并行计算。
最后一处目标为 ID 1,损失是 −ln(p[1]) = −ln(0.690284…) ≈ 0.370652。
三处取平均:
L = (0.4099546 + 3.3200393 + 0.3706515) / 3 ≈ 1.36688185loss = F.cross_entropy( logits.reshape(-1, 5), # 3 行,每行 5 个原始 logits targets.reshape(-1), # 三个正确 ID:[1,0,1])loss.backward()cross_entropy 接收原始 logits,内部完成稳定的 log-softmax 与负对数似然;这里不用先采样,也不用先手动 softmax。
配套实验检查了 Embedding、位置表、Q/K/Value、MLP、lm_head 的权重梯度,均非零;只算了梯度,没有做优化器更新。
生成时,已有完整上下文 [0,1,0],要补的是它之后的一项,因此读取最后位置的分布。
V7 先算全部位置 logits,再取 logits[:, -1, :];nanoGPT 推理分支直接只对最后一个上下文向量运行输出层。
概率怎样变成一个 ID
贪心:取当前第一名
取最大的 logit,等价于取最大的 softmax 概率。本例得到 b / ID 1。
这只保证当前一步最高。假设第一步 A 为 0.6、B 为 0.4;A 后面最可能的下一项只有 0.5,B 后面某项却有 0.99。
两步路径的概率分别为 0.6×0.5=0.30 和 0.4×0.99=0.396。第一步选 A,并没有得到最高概率的整条路径。
概率采样:用一个随机数定位区间
为了手算,这一节明确使用四舍五入后的概率 [0.2203,0.6903,0.0292,0.0267,0.0335],它们的和恰好为 1。
把 [0,1) 按对应长度切成五段:
| Token / ID | 区间 | 长度 |
|---|---|---|
| a / 0 | [0, 0.2203) | 0.2203 |
| b / 1 | [0.2203, 0.9106) | 0.6903 |
| c / 2 | [0.9106, 0.9398) | 0.0292 |
| d / 3 | [0.9398, 0.9665) | 0.0267 |
| e / 4 | [0.9665, 1) | 0.0335 |
均匀抽取 u∈[0,1),落在哪段,就返回哪个 ID。u=0.50 选 b,u=0.05 选 a,u=0.93 选 c,u=0.95 选 d。
均匀随机数落入某段的概率等于区间长度,因此这个过程实现了按概率抽取。
from bisect import bisect_rightfrom itertools import accumulate
boundaries = list(accumulate(probabilities))boundaries[-1] = 1.0token_id = bisect_right(boundaries, u)bisect_right 找第一个严格大于 u 的累计边界。若 u 恰好等于 0.2203,会进入 b 的区间,符合左闭右开的约定。
长度为 0 的区间也不会被选中。
torch.multinomial(probs, num_samples=1) 按权重返回一列的索引;列与词表 ID 对齐时,这个索引就是下一 Token ID。
区间法解释抽样原理,不声称 PyTorch 内部逐行执行这段 Python。
固定这张分布,用 Python Random(12) 抽取 100,000 次,得到:
| Token | a | b | c | d | e |
|---|---|---|---|---|---|
| 次数 | 21,980 | 69,097 | 2,996 | 2,597 | 3,330 |
| 频率 | 21.980% | 69.097% | 2.996% | 2.597% | 3.330% |
有限次抽样不要求频率与目标概率完全相等。这也不是生成了十万个连续 Token:真实自回归生成每追加一个 ID,就换了上下文,下一步会重新计算分布。
★ 温度:从概率之比看它改了什么
softmax 之前,把 logits 除以同一个正数 τ:
pᵢ(τ) = exp(zᵢ/τ) / Σⱼ exp(zⱼ/τ),τ > 0比较 b 和 a,共同分母消掉:
p_b(τ) / p_a(τ)= exp(z_b/τ) / exp(z_a/τ)= exp((z_b − z_a)/τ)本例分数差约为 2.096655 − 0.954680 = 1.141975:
| τ | b 相对 a 的概率倍数 | a | b | c | d | e |
|---|---|---|---|---|---|---|
| 0.5 | 9.82 | 9.20% | 90.29% | 0.16% | 0.14% | 0.21% |
| 1 | 3.13 | 22.03% | 69.03% | 2.92% | 2.67% | 3.35% |
| 2 | 1.77 | 25.83% | 45.71% | 9.40% | 9.00% | 10.07% |
较低温度放大领先者的相对优势;较高温度缩小差距。表中标签经过舍入,计算使用完整精度。
正温度不改变排序,所以本例贪心始终选 b。 变的是抽取时的概率,不是模型权重。
当最高分唯一,τ→0⁺ 时分布集中到它;若最高分并列,极限会在并列最高项之间分配。
代码不能直接除以 0,需要贪心就显式用 argmax。
稳定计算时先减去最大 logit:exp((zᵢ−max(z))/τ)。共同因子会在归一化中约掉,数学结果不变,又能避免指数上溢。
top-k:限定数量,再归一化
温度固定为 1,按概率从高到低排列为 b、a、e、c、d。k=2 保留 b、a,其余三项在本次抽取中的概率变为 0。
保留下来的原概率之和约为 0.6903+0.2203=0.9106。这还不是一个和为 1 的新分布,需要再除以这部分总质量:
p′_b = p_b / (p_b + p_a) ≈ 0.758042p′_a = p_a / (p_b + p_a) ≈ 0.241958p′_c = p′_d = p′_e = 0k=2 是两个候选参加抽取,不是一次输出两个,也不是各有一半机会。 归一化后两者的概率比保持不变。
k=1 时只有最高项,在本例中与贪心一致。
nanoGPT 的实现先取第 k 大的 logit,再把比它小的项设成 −∞,随后 softmax:exp(−∞)=0,保留项自动重新归一化。
它使用“严格小于阈值”的比较,边界若有并列分数,可能保留多于 k 项。本例无并列;下方教学代码按排名取恰好 k 项,并列按 ID 排序。
top-p:包含跨过阈值的那一项
top-p 按概率降序累加,取累计概率至少达到阈值 p 的最短前缀,再对保留项归一化。
这里采用 Nucleus Sampling 论文 §3.1 的式 (2)、(3):累计条件是 ≥ p。
| 排名 | Token / 原 ID | 原概率 | 累计概率 |
|---|---|---|---|
| 1 | b / 1 | 69.03% | 69.03% |
| 2 | a / 0 | 22.03% | 91.06% |
| 3 | e / 4 | 3.35% | 94.41% |
| 4 | c / 2 | 2.92% | 97.33% |
| 5 | d / 3 | 2.67% | 100.00% |
阈值 0.90:只有 b 不够,加 a 后到 91.06%,保留 [1,0]。
阈值 0.92:b+a 不够,再加 e,保留 [1,0,4]。
阈值 0.95:前三项只到 94.41%,还要加 c,保留 [1,0,4,2]。
排序改变展示顺序,不会改变 Token 原本的 ID。
top-k 固定数量,top-p 的数量跟着分布变化。p=0.90 时,若第一名已有 96%,只需保留它一个;若五项各有 20%,则要保留全部五项。
这两个筛选只决定当前这一步哪些 ID 参加抽取,不会删除 Tokenizer 的词表条目。 下一步随上下文重新计算,被筛掉的 ID 仍可能回来;这与 Unigram 训练时缩小持久词表的删词操作不同。
源码边界:旧版 GPT-2 的同名函数少留了一项
所对照的 GPT-2 src/sample.py(提交 9b63575)先计算降序累计概率,再用下面的索引选阈值:
阈值下标 = max(满足 cumulative_probs <= p 的项数 − 1, 0)本例 p=0.90 时,只有第一项的累计值不超过 0.90,项数为 1,阈值下标为 0。
于是按第一项 b 的分数作为门槛,只保留 b;论文规则会把让累计值跨过 0.90 的 a 一起留下。
本文代码和交互采用论文规则。另用 Python 独立复现了旧源码的阈值索引,确认上述差别;没有运行它的 TensorFlow 版本。
判断行为要看边界条件,不能只看函数叫不叫 top_p_logits。
一段能复算的筛选代码
下面的最小版本假定输入是非空、有限 logits;top-k 与 top-p 分别应用。 完整实验还包含区间抽样、参数校验、重复抽取,以及旧 GPT-2 的边界对照。
import math
def softmax(logits, temperature=1.0): if not math.isfinite(temperature) or temperature <= 0: raise ValueError("temperature must be finite and positive") maximum = max(logits) weights = [math.exp((z - maximum) / temperature) for z in logits] total = sum(weights) return [w / total for w in weights]
def ranked_ids(probs): return sorted(range(len(probs)), key=lambda i: (-probs[i], i))
def top_p_ids(probs, threshold): if not 0 < threshold <= 1: raise ValueError("top-p must be in (0, 1]") selected, mass = [], 0.0 for token_id in ranked_ids(probs): selected.append(token_id) mass += probs[token_id] if mass >= threshold: break return selected
def renormalize(probs, selected): allowed = set(selected) mass = sum(probs[i] for i in allowed) return [p / mass if i in allowed else 0.0 for i, p in enumerate(probs)]
logits = [0.9546803832054138, 2.0966553688049316, -1.067393183708191, -1.1546803712844849, -0.929262101650238]probs = softmax(logits)
kept_k = ranked_ids(probs)[:2] # [1, 0]kept_p = top_p_ids(probs, 0.92) # [1, 0, 4]after_k = renormalize(probs, kept_k)after_p = renormalize(probs, kept_p)
assert math.isclose(sum(after_k), 1)assert math.isclose(sum(after_p), 1)输出概率仍按原 ID 顺序放置,没入选的项填 0。这样后续抽出来的下标可以直接当 ID 使用。 如果把候选压缩成一个新数组,就必须再通过保留的 ID 列表映射回原词表。
从完整模型开始复现
下载五 Token 模型完整代码和采样实验完整代码,放在同一目录。 第一个文件包含所有手工权重,前向、梯度检查和数值记录;第二个读取它输出的 JSON,只依赖 Python 标准库。
python gpt_model_interface_demo.pypython gpt_sampling_demo.py第一个实验需要 PyTorch;本次复核使用 Python 3.9.6、PyTorch 2.2.2、CPU float32。 运行后在脚本旁生成两份同名 JSON。图表显示值经过舍入,实验与两个演示使用未舍入的 logits;只有区间手算和十万次固定分布抽取采用明确列出的四位概率。
本次核对包括:GPT-2 的五个 ID 与文本往返、输入查表相加、因果 mask、注意力按行归一化、输出矩阵乘法、交叉熵和梯度、
温度后的概率和与排序、top-k / top-p 的候选集合,以及固定种子的十万次抽取。源码对照为 GPT-2 9b63575 与 nanoGPT 3adf61e。
最后把循环接回去
已有 ID 序列 → 模型:最后位置的 logits → 贪心;或温度 → 筛选 → 归一化 → 抽取一个 ID → 追加到上下文 → 检查结束标记或长度上限;继续时重新预测工程实现可以组合温度、top-k、top-p,但执行顺序属于算法的一部分:后一步读取前一步处理过的分布。 例如先改变温度,top-p 所需的候选数量也可能改变。这里把两种筛选分别演示,便于看清每条规则。
需要显示文字时交给 Tokenizer 解码。单个 Token 未必包含完整字符,因此流式显示也要正确处理未凑齐的字节。 生成循环的结束由结束标记、长度上限等规则控制,与 BPE 编码时“没有可合并对”是两个层次。
输入查表回答“这些 ID 对应哪些向量”;模型计算回答“在这个上下文下,各候选得多少分”;生成策略再把这张分数表变成这一步实际追加的一个 ID。