Skip to content

第 15 章 把八个字符逐层合并:层接口与树形上下文 ​

上一部分的 MLP 把所有历史字符一口气拼成长向量,再交给第一层。上下文从 3 个字符扩到 8 个,输入宽度就从 3D 变为 8D。它当然能工作,但还可以选择另一种处理顺序:先让相邻两个字符互动,再合并相邻两组,最后得到覆盖八个字符的表示。

这就是课程第 6 讲搭建的树形模型。它与 WaveNet 的层级感受野有联系,但没有实现论文的完整门控、残差、跳连和音频系统。先把这个较小结构做对,下一章再明确它怎样对应卷积。第 6 讲官方说明

15.1 用固定长度上下文预测一个字符 ​

先保留原来的数据定义。每个训练样本是一个历史窗口 Xb,标签是紧接着的下一个字符;点号编号 0 同时用作左侧起始填充和单词终止符。以 anna、上下文长度 8 为例,窗口依次是 ........ → a、.......a → n、......an → n、.....ann → a、....anna → .。

每增加一个真实字符,窗口左移一格。上下文长度变了,标签定义并没有变。所有输入都来自当前目标之前,因此单样本模型不会偷看到待预测字符。按完整单词划分训练、验证、测试,再各自切窗口,能避免同一个单词的重叠窗口被随机分到训练和验证两边。

课程 notebook 先查看词数、最长词、字符映射,再打印前若干窗口。它们不是可省略的装饰输出:词表是否包含终止符、窗口是否右移正确、最长词是否被截断,都可以在这里查出来。官方 Colab 另有一个下载 names.txt 的单元;本书的小实验内置 12 个词,不依赖那个可变的 master 下载地址,也不把这 12 个词当作完整课程数据。

15.2 六种小类,分别负责一件事 ​

examples/part2/layers.py 实现六种教学类。它们遵循同一个约定:调用对象完成前向,parameters() 返回该对象需要梯度更新的张量。能统一遍历以后,训练循环便不需要知道模型到底有几层。

类输入到输出参数主要职责
Embedding(V,D)整数索引 [...,] → [...,D]V×D 表查字符向量
Linear(F,H)[...,F] → [...,H]权重与可选偏置在最后一维做相同仿射变换
BatchNorm(H)[...,H] → 同形状gamma、beta按最后一维通道统计前面的轴
Tanh任意浮点形状 → 同形状无逐元素非线性
FlattenConsecutive(n)B×T×C → B×(T/n)×(nC)无拼接相邻 n 个位置
Sequential首层输入 → 末层输出子层参数集合按顺序组合

Linear 对三维输入仍然可以直接 x @ weight。比如输入 B×4×8,权重 8×12,输出是 B×4×12。四个位置共用同一组 8×12 权重,并没有为每个位置单独建一个矩阵。这种权重共享正是随后与卷积联系的基础。

参数发现也值得认真检查。若忘记把 embedding 表放进 parameters(),前向照样能跑,梯度也可能存在,但优化器永远不会更新它。若共享同一个张量却把它加入列表两次,手写 SGD 会重复更新。本书的容器用张量身份去重。原课程较简单的列表展开适用于没有共享参数的那套结构;这项去重是本书补充。

这些类不是完整 nn.Module 的替代品。它们没有注册式参数管理、state_dict、自动设备搬移、hooks 或完整嵌套模块系统。理解了它们的职责后,转向原生 nn.Module、nn.Parameter、nn.Sequential 就更容易判断框架替我们管理了什么。PyTorch v2.6.0 Module 源码

15.3 FlattenConsecutive 不是平均池化 ​

假设 B=1,T=4,C=2,四个位置的向量是 [1,2]、[3,4]、[5,6]、[7,8]。FlattenConsecutive(2) 应输出两个位置:[1,2,3,4] 和 [5,6,7,8]。所有数都还在,也没有计算均值。它改变的是分组方式,让下一层同时看到两个相邻位置。

python
B, T, C = x.shape
if T % n:
    raise ValueError('time dimension must be divisible by group size')
x = x.reshape(B, T // n, C * n)
if x.shape[1] == 1:
    x = x.squeeze(1)

总元素数保持为 BTC。T//n 不是允许随意丢掉余数:不能整除时必须拒绝、补齐或采用另一个明确设计。本书选择拒绝。最后只剩一个时间位置时,squeeze(1) 把 B×1×C′ 变为 B×C′,以便输出普通分类 logits。不能无参数地调用 squeeze():当 B=1 时,它可能顺便删掉批次轴,让采样代码发生另一类形状错误。

课程使用 view,其前提是现有 stride 布局允许这个视图。切片、转置后的张量可能不连续;本书使用 reshape,必要时允许复制。配套测试用 torch.randn(2,4,6)[:,:,::2] 构造真正不连续的输入,先断言 not is_contiguous(),再把结果与逐组 cat 的值对照。只验证输出 shape,不足以保证分组顺序正确。

15.4 三层树,形状怎样一步步变 ​

本书小实验使用词表大小 V=8、embedding 宽度 4、隐藏宽度 12、上下文 8。批大小暂取 4,便于完整打印。网络为:

python
Sequential([
    Embedding(8, 4),
    FlattenConsecutive(2), Linear(8, 12, bias=False), BatchNorm(12), Tanh(),
    FlattenConsecutive(2), Linear(24, 12, bias=False), BatchNorm(12), Tanh(),
    FlattenConsecutive(2), Linear(24, 12, bias=False), BatchNorm(12), Tanh(),
    Linear(12, 8),
])
八个输入字符两两合并,经过三层形成单个上下文表示;同层不同位置共享权重
可切换原始尺寸,滚动查看细节

查看大图

尺寸读取中

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

阶段输出形状该位置能看到的原始字符数
字符索引4×81
embedding4×8×41
第一次拼接4×4×82
第一组 Linear、BN、Tanh4×4×122
第二次拼接4×2×244
第二组变换4×2×124
第三次拼接,压掉长度 1 的轴4×248
第三组变换4×128
输出分类层4×88

表中“能看到”的局部树形关系指 Linear 与非线性组成的局部路径;训练 BN 还会通过共享批统计量耦合不同位置和样本,第 16 章会说明为什么讨论严格因果关系时必须把它单独处理。推理 BN 使用固定统计量,不再增加这样的输入依赖。

手算参数量可以抓到不少建模错误。embedding 为 8×4=32;第一层权重 96,后两层各 288;三组 BN 的 gamma、beta 共 3×24=72;输出层权重加偏置为 12×8+8=104。总数 32+96+288+288+72+104=880,与代码发现的参数数目一致。BN 的运行均值、方差不计入可训练参数。

15.5 轴写错,程序也可能顺利训练 ​

原来的 BN 只接收 B×H,按 dim=0 求统计量。现在第一组激活是 B×T′×H。如果继续只按批次轴求均值,会得到 1×T′×H,等于每个位置各有一套均值和方差,而不是每个通道一套。

取最小的例子:B=2,T′=2,H=1。第一条序列为 ([0,10]),第二条为 ([2,14])。只对 dim=0 求均值,得到位置均值 ([1,12]);对 (0,1) 求均值,才得到共享通道均值 6.5。

实现统计对象均值形状这个例子的均值
旧的 mean(0)每个位置单独跨批1×2×11、12
修正的 mean((0,1))跨批和时间共享通道1×1×16.5

前者在数值上能广播,loss 也可能下降,所以没有异常堆栈提醒你。它并非对所有任务都毫无意义,但不符合这里打算实现的“共享通道 BN”。运行统计量还可能因此携带位置维度,使训练与不同形状推理产生难以察觉的行为差异。课程通过阅读文档和逐层查看 shape 找到这一错误。

我们的教学层约定通道在最后;原生 nn.BatchNorm1d 的三维输入约定是 N×C×L。不能把 B×T×H 直接送进它,再以为归约会自动选择对的轴。要么转置为 B×H×T 再转回来,要么明确采用本书的 channels-last 实现。即使轴正确,原生层和课程层的方差校正仍有第 11 章解释的差别。PyTorch v2.6.0 BatchNorm1d

15.6 训练循环没有换,模型内部换了 ​

数据进来以后,统一调用 model(Xb) 得到 B×V 的 logits,再求交叉熵、清梯度、反传、更新。把层装进容器的价值,在这里变得具体:增加一层或替换上下文组合方式,不需要重写整段训练器。

本书小实验只用 8 个训练词、2 个验证词、2 个测试词,分别切成 42、11、10 个样本。训练 400 次更新,批大小 16,seed 2026;前 300 次学习率 0.1,后 100 次为 0.03。输出层权重先乘 0.1,沿用降低初始置信度的做法。训练结束后冻结运行统计量,结果如下:

划分平均交叉熵
train0.459759
dev2.001545
test2.565572

训练样本很少,词表中的 m 还只出现在保留的词中,模型未在训练输入里学到它的使用。因此这些数字主要展示评估流程与明显的泛化差距,不能与完整姓名数据、27 类模型的 loss 排名。这里 test 只在固定实验完成后报告一次,没有拿它选择宽度或学习率。

seed 2036 采样得到 tania.、animtm.、tania.、nina.、anna.。其中有训练词,也有不自然组合,这正适合提醒我们:能生成熟悉词形不等于学到了稳健分布。采样设置最多 20 步,防止一个还没学好终止符的小模型让脚本无限循环;若达到上限,应视为截断,不伪装成自然终止。

训练检查点的批损失也不是单调的:本次第 300 步检查为 0.405144,第 400 步为 0.532451。两个检查点抽到的批次不同,不能据此断言模型退化。要比较性能,应看同一个固定评估集、同一个 eval 模式下的损失。

15.7 练习与答案 ​

  1. 补充。 将上下文改成 16,仍两两合并到一个位置,需要几层分组?最后是否还可以直接接分类层?
  2. 补充。 忘了把 embedding 加入参数列表,哪些检查会发现?
  3. 补充。 用上面的 rank-3 数值例子,分别写出按 (0,) 和 (0,1) 标准化的结果,说明两者表达的假设差别。

第一题需要四次分组:16→8→4→2→1;每次 Linear 输入宽度按拼接后的通道数调整,最终单位置表示可以接分类层。第二题中 loss 可能仍下降,最直接的检查是参数总数、参数身份列表,以及更新前后 embedding 的差值。第三题按位置统计会分别中心化两组 ([0,2]) 与 ([10,14]),而共享统计会中心化 ([0,10,2,14]);用统一的 correction 与 eps 才能公平比较数值。

完整可运行入口仍为 examples/part2/experiments.py,本章函数为 train_tree();层定义独立在 layers.py。这套结构把“上下文如何组合”变成了清楚可替换的模块,但还没有消除相邻预测窗口的重复计算。下一章从这里引出卷积。

来源与范围 ​

  • 第 6 讲视频与固定 notebook:数据窗口、六类接口、树形网络、BN 轴错误与修正、训练与采样。
  • 官方 Colab:与仓库源码的主要差异为标题链接及数据下载单元。
  • 本书容器去重、非连续张量测试、12 词实验与参数量核对属于教学补充;并非课程原始运行成绩。

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