Skip to content

第 7 章 让模型看见上下文:嵌入与 MLP ​

bigram 在 ana 和 ava 后面看到的都是最后一个 a,所以给出的下一字符分布完全相同。要让模型区分这两种情况,可以把最近三个字符一起输入。但直接给所有三字符上下文各建一行表,需要 V3 行、V 列,字符表稍微大一点,许多格子就永远没有数据。

这一章让多个上下文共用参数:先把字符映射成短向量,再把上下文里的向量连接起来,交给 MLP 预测下一字符。课程以 Bengio 等人 2003 年的神经概率语言模型为背景;本文实现的是简化的字符级版本,不是论文全部结构与实验的复现。原论文;课程 MLP notebook

7.1 一条字符串产生多条有上下文的记录 ​

记上下文长度为 K,这里 K=3。从三个起始符开始,每读一个字符,先记录旧上下文与当前字符标签,再把当前字符放进上下文最右边,并删掉最左边一个。

输入上下文下一字符标签
...a
..ab
.aba
aba.

这是 aba 的完整样本。不要先把字符加入上下文再记录标签,否则输入含有正在预测的答案,损失可以很低,模型却没有学会预测未来。

python
context = [0] * block_size
for char in word + '.':
    ix = stoi[char]
    X.append(context)
    Y.append(ix)
    context = context[1:] + [ix]

这里每次都创建新列表。若改成 append(context) 后原地修改同一个列表,先前所有记录可能引用同一个对象,最后一起变成相同内容。可以显式 .copy(),也可以像上面这样始终重建上下文。

一批有 B 条样本,输入 X 的形状是 (B,K),目标 Y 的形状是 (B,)。两者都是整数 ID。同一个上下文可能对应多个后继,模型应学出分布,而不是要求每条训练样本都获得概率 1。

7.2 嵌入表就是可以训练的查表 ​

设每个字符用 D 个数表示,嵌入矩阵 C 形状为 (V,D)。第 i 行是字符 i 的向量。查表

python
emb = C[X]

把 X 里的每个整数替换成一行向量,结果形状为 (B,K,D)。在课程最初的小例子里,V=27,D=2,K=3;32 条输入得到 (32,3,2)。完整版本改用 D=10,结果应为 (32,3,10)。代码与注释不一致时,以参数和实际 .shape 为准。

字符嵌入没有预设某一维是元音或某一维是名字结尾。它从随机值开始,通过预测损失更新。同一字符出现在许多位置时,共用同一行参数;这些位置的梯度都会加到该行。这是参数共享的具体含义。

上一章的 one-hot 查表等价在这里依然成立:(BK,V) 的 one-hot 矩阵乘 (V,D) 的嵌入表,得到 (BK,D),再恢复形状即可。但直接 C[X] 不用创建巨大的稀疏中间矩阵。language_lab.py 用重复字符索引核对,某一行出现两次且输出取和时,它每维收到的梯度都是 2。

7.3 把上下文连接起来,保留顺序 ​

隐藏层接收每条样本的 KD 个数,需要把最后两维合并:

python
flat = emb.reshape(B, K * D)
hidden = torch.tanh(flat @ W1 + b1)
logits = hidden @ W2 + b2
上下文 MLP 的张量形状;B 为批量,K 为上下文,D 为嵌入维数,H 为隐藏宽度,V 为词表大小
可切换原始尺寸,滚动查看细节

查看大图

尺寸读取中

适合窗口:完整显示图片,不放大超过原始尺寸。 按 Esc 关闭。

参数和中间结果如下:

名称形状含义
C(V,D)每字符一行表示
X(B,K)每条记录的有序上下文
emb(B,K,D)每个位置的字符向量
flat(B,KD)按位置连接后的输入
W1,b1(KD,H)、(H,)生成 H 个隐藏特征
hidden(B,H)经 tanh 的特征
W2,b2(H,V)、(V,)输出各字符 logits
logits(B,V)每条输入的一组打分

连接不是求平均。第一个字符的第 d 维与第二个字符的第 d 维位于不同列,分别乘 W1 的不同行,因此模型可以区分 ab 与 ba。数学上,对第 r 个样本和隐藏单元 q,

hrq=tanh⁡(b1q+∑k=0K−1∑d=0D−1CXrk,dW1,kD+d,q),

再对输出字符 j 计算

zrj=b2j+∑q=0H−1hrqW2,qj.

这些求和下标说明参数如何复用:相同字符用相同的 C 行,但它在上下文不同位置会乘不同的 W1 行。

7.4 view、reshape 与底层存储 ​

张量的形状并不等于一份新的底层数据。连续存储的 (B,K,D) 数据,往往可以只修改形状信息就作为 (B,KD) 使用。课程用 view(-1,6),其中 -1 让 PyTorch 根据总元素数推断批量大小。

view 要求新形状与原来的存储步幅兼容。转置后的张量经常不满足这一要求,不能只因元素总数相同就保证 view 成功。reshape 在可能时返回视图,必要时会复制。若确实需要连续存储,可用 contiguous(),但不要为了消掉错误盲目复制所有张量。PyTorch 2.6 张量接口源码

配套 training_diagnostics.py 先把一个连续的 (3,4) 张量转置成 (4,3),确认直接 view(12) 会报错,再核对 reshape(12) 得到的 12 个元素仍遵循转置后的逻辑顺序。它把这个内存限制变成一个可以重复运行的反例。

本书使用 emb.reshape(len(x),-1),并依靠明确的 K,D 设计检查实际宽度。它没有改变字符顺序,也没有 “自动寻找有意义的特征”;只是在重新组织同一批数字的索引方式。

7.5 这套网络有多少参数 ​

参数总数为

VD+(KD)H+H+HV+V.

各项依次是嵌入表、第一层权重、第一层偏置、第二层权重、第二层偏置。课程后期的 V=27,K=3,D=10,H=200 对应 270+6000+200+5400+27=11897 个参数。增加隐藏层宽度主要增加两层权重;增加嵌入维数同时增加嵌入表与第一层输入宽度。

与完整上下文表相比,MLP 的参数量不随 VK 指数增长,但它也没有能力为每一种上下文独立自由指定任意分布。这种共享带来约束,恰好让某个上下文的训练可能帮助另一个上下文;同时也可能造成欠拟合。

7.6 训练与生成使用同一个前向函数 ​

训练时直接把 logits 和标签交给 F.cross_entropy,由它处理 softmax 和 NLL。生成时没有真实标签,才对 logits 做 softmax,再抽一个 ID 接入上下文:

python
context = [0] * K
for _ in range(max_length):
    x = torch.tensor([context], dtype=torch.long)
    probs = model(x).softmax(dim=1)
    ix = torch.multinomial(probs, 1, generator=g).item()
    if ix == 0:
        break
    context = context[1:] + [ix]

外面的列表让输入形状为 (1,K),而不是 (K,),保留批量维。无论训练 B=32 还是生成 B=1,参数形状都不变。把批量大小写死在 .view 或 torch.arange(32) 里,是一个常见错误。原 MLP notebook 的早期损失示范就保留了 32 的交互式痕迹;完整可运行版本应从当前批量推导。

练习与解答 ​

补充题:B=5,K=3,D=4,H=8,V=6 时,各层形状是什么?答案是输入 (5,3)、嵌入 (5,3,4)、连接 (5,12)、隐藏 (5,8)、logits (5,6);参数总数 24+96+8+48+6=182。

补充题:把连接替换为三个位置的嵌入均值,是否还能区分排列?如果没有位置相关参数,aba 与 aab 的均值相同,后续确定性网络给出相同输出。这个改动丢掉了当前架构依赖的顺序信息。

来源与代码 ​

本章覆盖课程 MLP notebook 的数据窗口、嵌入查表、隐藏层、输出层、形状、参数计数与采样;也核对了官方 MLP Colab。它的部分训练与评估注释仍写旧的嵌入维数和隐藏宽度,正文已按实际参数修正。完整类 ContextMLP 在 examples/part1/language_lab.py。

保留各书来源与许可 · 示例运行条件以正文为准