Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

2.8 应用导读:Lab 1 的音乐生成

前面七节把序列建模的技术史走完了:递推给出与长度无关的参数共享,代价是沿时间的连乘雅可比(2.2);门控用加法通道把长程依赖推到上百步(2.3);注意力不再压缩历史,把任意两点的路径长度压到 O(1)O(1)(2.4),Transformer 把它堆成主干(2.5);位置编码与高效注意力处理顺序与平方复杂度(2.6),状态空间、线性注意力与稀疏专家给出三条现代替代路线(2.7)。但结论落到能跑的代码上,中间隔着几件极具体的工程事:数据怎么变成 token、损失怎么「错位一位」、生成时怎么从分布里取出一个 token、结果不好看时先怀疑什么。

这一节就是那座桥:它不引入新理论,而是把 Lab 1 的第二部分(字符级 RNN 音乐生成)拆成可逐步对照的动作,并说清每一步「对应讲里哪一条结论、写错了会看到什么现象」。例题选音乐,是因为它把自回归任务的骨架暴露得最干净:旋律是一串离散符号,既有很强的局部语法(音程、节奏),又有真实的长程结构(主题再现、段落反复),而数据只有几 KB——小数据的好处是「没学会」的现象非常直白,你不会把它错怪在算力上。读法建议:先按 Lab 1 跑起来,再回来对照小节;2.8.8 给出「理论 → 代码」的总表。

2.8.1 任务定义:从旋律到 token 序列

定义 2.8.1 · 符号音乐序列与 token 化

符号音乐(symbolic music)指以离散事件而非波形描述的音乐。两种常见表示:

  • ABC 记谱的字符流:一个音符写成一到几个 ASCII 字符(音名 C D E F G A B、升 / 降号 ^ _、时值 /2 /4、小节线 |)。做字符级建模时,一个字符 = 一个 token,词表 VV 就是出现过的字符集合,整段旋律是一条字符序列。
  • MIDI 的事件序列:一串带时刻的事件(note_on / note_off、音高 0–127、力度、通道)。喂给序列模型前通常先把时间离散化(如每 1/161/16 拍一格),再表示成「音高 + 时长组合的事件」序列。

形式化地,给定 token 序列 x1,x2,…,xTx_1,x_2,\dots,x_T(xt∈{1,…,V}x_t\in\{1,\dots,V\}),自回归模型建模的是联合分布

pθ(x1:T)=∏t=1Tpθ(xt∣x1:t−1),p_\theta(x_{1:T})=\prod_{t=1}^{T}p_\theta\bigl(x_t\mid x_{1:t-1}\bigr),

即把「整段旋律的概率」分解为 TT 个「给定前缀预测下一个符号」的条件概率之积。这与文本语言模型的对象逐字相同:两者都是离散符号序列,因此同一个递推 / 注意力结构、同一套交叉熵损失可以直接搬过来——差别只在词表从哪来。

为什么音乐是序列模型的好素材

音乐在两种尺度上都有规律,而且都能被耳朵快速验证:

  • 局部语法(几拍之内):音程倾向(大跳之后常反向级进)、惯用的时值组合(附点、切分)、调内音级分布。这类规律统计上很强,几百个 token 就能学到,学到与否的证据是续写「不走音」。
  • 长程结构(几十到几百拍):主题再现、段落反复(AABA)、模仿与卡农。这类规律要求模型在很久之后仍「记得」前面的素材,正好落在 2.2 的连乘雅可比与 2.3 的门控要处理的区域上。

于是音乐成了一个分层可控的实验台:想考察短程统计,看局部是否像样;想考察记忆,看主题有没有回来。文本任务同样能做这两件事,但「像不像」要人来判;音乐里跑调几乎立刻能听出来,反馈回路短得多。

2.8.2 数据表示与词表

词表与形状约定

记词表大小 VV、批大小 BB、时间步 TT、嵌入维 ded_e、隐状态维 dhd_h。按 batch_first=True 的约定(与 Lab 1 的代码一致),一次前向的全部形状如下:

量 形状 含义
tokens B×TB\times T 一批 token id(整数张量),第 1 维 batch、第 2 维时间
emb(tokens) B×T×deB\times T\times d_e Embedding 查表:每个 id 换成一行向量
ht\boldsymbol h_t B×dhB\times d_h 逐步更新的状态(定义 2.2.1)
logits B×T×VB\times T\times V 每个位置对下一个 token 的未归一化分数

三条实现语义必须记牢:

  1. Embedding 就是一次查表:nn.Embedding(V, d_e) 内部是一张 V×deV\times d_e 的矩阵,输入 id ii 就取出第 ii 行。它数学上等价于先把 id 变成 one-hot 1i\boldsymbol 1_i 再做一次线性变换 ei=WembT1i\boldsymbol e_i=W_{\text{emb}}^{\mathrm T}\boldsymbol 1_i;两种写法参数量相同(VdeVd_e),但查表只做一次索引,不做 VV 维的稀疏乘。
  2. 输出维度必须是 VV:nn.Linear(d_h, V) 把每个位置的隐状态映射成 VV 个 logits,softmax 之后才是分布 p^(⋅∣x≤t)\hat p(\cdot\mid x_{\le t})。
  3. 损失按位置独立:把 logits 与目标 id 各自展平成 (BT)(BT) 个位置,一次 cross_entropy 算完(见 2.8.4)。

词表设计决定模型要学什么(本节最容易跳过、却最影响结果的一步):

  • 一个音 = 一个字符:词表小(几十),序列长(每音符 1–3 个 token)。模型必须自己从字符组合里拼出「音高与时长」的耦合;好处是数据需求低、可直接套用 char-RNN 的代码,代价是时值信息被切碎——一个长音符的音高字符与它的时值字符相隔若干 token。
  • 音高 + 时长合成一个事件 token:词表变大(音高数 ×\times 时长档数),序列变短(每音符 1 个 token)。时序结构更干净,但组合爆炸摊薄了每个 token 的样本数:VV 从 30 涨到 300,平均每 token 的观测数掉 10 倍,而参数量里 VdeVd_e 与 dhVd_hV 两项反而变大。
  • 折中:音高与时长各占一个位置、但成对出现(把时长字符限制在一个很小的集合,如 1 / 2 / 4)。多数入门实现默认走这条。

这里有一条与 2.1 一脉相承的账:词表大小 VV 与序列长度 TT 要一起算。VV 大、TT 小,序列短但每个 token 的样本少;VV 小、TT 大,样本多但单步预测更「碎」。几 KB 的数据上,先把 VV 压小几乎总是更稳的选择——这也是 Lab 1 直接采用字符流的原因。

2.8.3 模型:字符级 RNN 与一个更小的 Transformer

Lab 1 的默认实现是一层 Embedding + 一层 GRU + 一层线性输出。把它写清楚(与 Lab 1 的实现一致,这里补上形状注释):

import torch, torch.nn as nn

class RNN(nn.Module):
    def __init__(self, vocab, hidden=128):
        super().__init__()
        self.emb = nn.Embedding(vocab, 32)                   # (B,T) -> (B,T,32):一次查表
        self.rnn = nn.GRU(32, hidden, batch_first=True)      # 门控递推(2.3):内部逐步更新 h_t
        self.out = nn.Linear(hidden, vocab)                  # (B,T,d_h) -> (B,T,V):每个位置的下一 token logits

    def forward(self, x, h=None):
        z, h = self.rnn(self.emb(x), h)                      # h: (B,d_h),可在 batch 间复用、必须按需断开
        return self.out(z), h                                # 返回未归一化的分数,softmax 留给采样或损失

三点实现问答(每条都对应讲里的结论):

  • 为什么用门控单元而不是朴素 RNN:字符流里的长程依赖要靠状态通道传下去,而朴素 RNN 的梯度是 diag⁡(ϕ′)Whh\operatorname{diag}(\phi')W_{hh} 的连乘(定理 2.2.2),几百个 token 的片段上很容易衰减到不起作用;GRU 的更新门提供加法通道(2.3.1)——这是「2.2 的代价 → 2.3 的解法」第一次在实验里见效。
  • 为什么 hidden=128 这种小模型就够:数据只有几 KB、词表几十,瓶颈在数据里的统计规律而不在参数量。参数远超数据量只会换来训练集损失漂亮、自由生成崩坏。
  • 为什么 out 不接 softmax:训练用 F.cross_entropy(内部含 log-softmax,数值更稳),推理时再手动 softmax 或直接在 logits 上做截断与采样(2.8.5)。

把 GRU 换成 Transformer 也是可选路线(Lab 1 扩展任务 1),但规模要跟着数据缩小。下面是一个 2 层、d=96d=96、4 头的小解码器,直接复用 2.5.7 的 PreLNBlock(Pre-LN + 因果掩码):

class TinyTransformer(nn.Module):
    """可选路线:Pre-LN 块 + 因果掩码 + 学习式位置编码(2.5.3 / 2.4.5 / 2.6)"""
    def __init__(self, vocab, d=96, H=4, layers=2, d_ff=256, max_T=64):
        super().__init__()
        self.emb = nn.Embedding(vocab, d)                    # (B,T) -> (B,T,d)
        self.pos = nn.Embedding(max_T, d)                    # 注意力置换等变,顺序必须显式注入
        self.blocks = nn.ModuleList([PreLNBlock(d, H, d_ff) for _ in range(layers)])
        self.norm = RMSNorm(d)                               # Pre-LN 主干尺度随深度累积,收尾必须归一化
        self.out = nn.Linear(d, vocab, bias=False)           # (B,T,d) -> (B,T,V)

    def forward(self, x):
        h = self.emb(x) + self.pos(torch.arange(x.shape[1], device=x.device))   # (B,T,d)
        for blk in self.blocks:
            h, _ = blk(h)                                    # blk 内 causal=True:一次算完全部位置
        return self.out(self.norm(h))                        # (B,T,V)

同一个小数据集上的对比(GRU 侧 V=30V=30、de=32d_e=32、dh=128d_h=128;Transformer 侧 d=96d=96、2 层、4 头、dff=256d_{\text{ff}}=256):

项目字符级 GRU小 Transformer
参数量约 6.7×1046.7\times10^{4}:嵌入 30×32=96030\times32=960;GRU 3(32+128)×128+6×128=62,2083(32+128)\times128+6\times128=62{,}208;输出 128×30+30=3,870128\times30+30=3{,}870约 1.8×1051.8\times10^{5}:每块 4d2+2d dff+2d=86,2084d^2+2d,d_{\text{ff}}+2d=86{,}208,2 层 172,416172{,}416;嵌入 2,8802{,}880;位置 6,1446{,}144;输出 2,8802{,}880;收尾归一化 9696
每步训练开销更小(一次门控矩阵乘)更大,但沿 TT 维可并行
典型现象几百步内损失稳定下降,续写很快「像」训练数据的局部搭配同样步数下损失往往还很高,需要更多步才可比,且更容易先过拟合
长程行为受状态容量与 BPTT 连乘限制,主题再现常失败路径长度 O(1)O(1),数据足够时主题再现更容易出现

标注:「典型现象」一列属于工程经验 / 本书判断,不是论文事实——它依赖数据、超参与随机种子。正确的用法是当作预期管理(几 KB 数据上先跑 GRU),而不是当作可引用的结论;要得到自己的结论,就按 Lab 1 扩展任务 1 在同参数量下比较收敛速度与生成质量。

2.8.4 训练:teacher forcing 与逐位置交叉熵

定义 2.8.2 · teacher forcing 与自回归训练目标

给定训练片段 x1,…,xTx_1,\dots,x_T(TT 个 token,共 T−1T-1 个预测位置),teacher forcing 指:第 tt 个位置以数据中的真实前缀 x≤tx_{\le t} 为条件预测下一个 token,损失为所有位置的负对数似然之和

L(θ)=−∑t=1T−1log⁡pθ(xt+1∣x≤t).\mathcal L(\theta)=-\sum_{t=1}^{T-1}\log p_\theta\bigl(x_{t+1}\mid x_{\le t}\bigr).

条件里的 x≤tx_{\le t} 全部来自数据,不含模型自己的输出;因此训练可以「一次前向、T−1T-1 个位置同时算」。这一条与 定义 2.2.3 是同一件事,实现上它等价于错位一位:

logits = model(tokens)[0]                                    # (B,T,V)
loss = F.cross_entropy(logits[:, :-1].reshape(-1, V),        # 用 x_t 处的输出
                       tokens[:, 1:].reshape(-1))            # 预测真值 x_{t+1}(右移一位)

对应的自由跑(free-running)模式则是把模型上一步采样出的 token 接回输入——两个模式的输入分布不同,这个偏移就是 定义 2.2.3 的 exposure bias。

「错位一位」为什么是全节最该检查的一行

语言模型训练不需要按时间写循环,只需要把同一段序列右移一位当标签:第 tt 个位置的输出对应第 t+1t+1 个 token。代价是这行代码的两种典型错误都不会报错、只会让损失偏高或偏低:写成 logits[:, :-1] 对 tokens[:, :-1] 变成「预测自己」(损失会降得异常快,生成却只会复读);写成对 tokens[:, 2:] 则是跳过一格(损失停在较高水平)。因此 Lab 1 的排错清单把「损失配对」放在第 2 条——先确认标签,再调学习率。

定理 2.8.1 · 交叉熵与困惑度

设模型在 NN 个预测位置上的平均交叉熵为 HH(单位为 nat,即自然对数下的每 token 平均负对数似然),定义困惑度(perplexity, PPL)

PPL=exp⁡(H)=exp⁡(−1N∑i=1Nlog⁡pθ(yi∣prefixi)).\mathrm{PPL}=\exp(H)=\exp\Bigl(-\frac{1}{N}\sum_{i=1}^{N}\log p_\theta\bigl(y_i\mid \text{prefix}_i\bigr)\Bigr).

读法是:模型在每个位置上相当于在多少个「等概率候选」之间犹豫。若分布是在 VV 个候选上的均匀分布,则 H=log⁡VH=\log V、PPL=V\mathrm{PPL}=V;因此 PPL 的取值范围是 [1,V][1,V],下界 1 表示把所有概率都压给正确 token。

平均交叉熵 HH PPL=eH\mathrm{PPL}=e^{H} 直觉读法
1.01.0 2.722.72 约在 2.7 个候选间犹豫——相当确信
2.02.0 7.397.39 约 7 个候选
3.03.0 20.120.1 约 20 个候选
4.04.0 54.654.6 比 V=30V=30 的均匀分布(log⁡30≈3.40\log30\approx3.40)还差
把「均匀分布基准」画进损失曲线

未经训练或训练失败的模型会输出接近均匀的分布,于是损失停在 log⁡V\log V 附近:V=30V=30 时是 3.403.40。若损失高于这条线,说明模型不是「不会」而是在系统性地给错答案——最常见的原因是标签错位、padding 位置被计入了损失、或 token 映射表与数据预处理不一致。把 log⁡V\log V 当作一条水平线画在损失曲线上,是这个实验里成本最低、信息量最大的一次对照。

同时要记住:困惑度是「猜得准不准」,与「好不好听」没有直接关系(见 2.8.6)。

训练循环还差三个动作,它们都可以在 Lab 1 的排错清单里找到对应位置:

for tokens in loader:                                # tokens: (B,T)
    logits, h = model(tokens, h)                     # h 每段从零开始(①),段末 h = h.detach()(③)
    loss = F.cross_entropy(logits[:, :-1].reshape(-1, V), tokens[:, 1:].reshape(-1))
    opt.zero_grad(); loss.backward()
    nn.utils.clip_grad_norm_(model.parameters(), 1.0); opt.step()   # ② 裁剪必须在 backward 与 step 之间

三点说明:① 隐状态不跨片段(更不必说跨 epoch)复用,这是「片段之间没有真实连续性」的直接后果(强行传下去会让 BPTT 图跨越整个训练集);② 裁剪是序列任务的安全网,治爆炸不治消失(定理 2.2.3);③ detach() 忘记写时,症状是显存随训练步数缓慢增长、最后 OOM——与「模型不学习」是两回事。

2.8.5 采样:温度、top-k 与 top-p

定义 2.8.3 · 温度采样

设模型在某一步给出的 logits 为 z=(z1,…,zV)\boldsymbol z=(z_1,\dots,z_V),温度 τ>0\tau\gt0 下的采样分布为

p^i=exp⁡(zi/τ)∑j=1Vexp⁡(zj/τ).\hat p_i=\frac{\exp(z_i/\tau)}{\sum_{j=1}^{V}\exp(z_j/\tau)}.

等价说法:温度就是把 logits 除以 τ\tau 再做 softmax。三条边界行为:τ→0+\tau\to0^{+} 时 p^\hat{\boldsymbol p} 趋近 one-hot,即趋近贪心(argmax)解码;τ=1\tau=1 时是原分布,不做任何修改;τ>1\tau\gt1 时分布被摊平,低概率候选获得可观的概率质量。

对任意两个候选有 p^i/p^j=exp⁡((zi−zj)/τ)\hat p_i/\hat p_j=\exp\bigl((z_i-z_j)/\tau\bigr)——温度不改变大小关系,只改变「分数差」被放大的倍数。

温度是风格旋钮,不是要「调对」的超参

低温度下模型只敢选概率最高的音符,于是容易陷入循环(「总在同一个音上打转」);高温度下低概率音符获得更多机会,旋律更多变,也更容易蹦出训练数据里没见过的组合。所以温度没有「正确值」,只有风格:本实验常用 0.5 / 1.0 / 1.5 各生成一段来对比(Lab 1 提交要求第 3 条;Lab 1 的温度图 画出了同一组 logits 在 τ=0.5,1.0,2.0\tau=0.5,1.0,2.0 下的分布差异)。

一个容易忽略的推论:τ→0\tau\to0 与 τ=1\tau=1 的 argmax 完全相同(softmax 保序),所以「把温度调到 0 更安全」是错的——它只是把多样性换成确定性与复读。

定义 2.8.4 · top-k 与 top-p(核采样)

两种截断采样(truncation sampling),都先按概率从大到小排序、只保留一个子集 S\mathcal S,再把 S\mathcal S 上的概率重新归一化:

  • top-k:固定候选个数,S=\mathcal S= 概率最大的 kk 个 token,kk 是超参;
  • top-p(核采样,nucleus sampling):固定概率质量,取最小的 S\mathcal S 使 ∑i∈Sp^i≥p\sum_{i\in\mathcal S}\hat p_i\ge p(即把概率降序累计到刚跨过 pp 为止,跨过阈值的那个 token 保留),p∈(0,1)p\in(0,1) 是超参;然后用 p~i=p^i/∑j∈Sp^j\tilde p_i=\hat p_i/\sum_{j\in\mathcal S}\hat p_j 采样。

适用差异:当分布很尖(模型确信)时,top-p 会自动缩小 S\mathcal S;当分布很平(模型犹豫)时它自动扩大 S\mathcal S——候选数随模型的不确定性自适应。top-k 的候选数恒定,实现更简单、行为更可预测,但在「该确信」的位置上会强行保留一批垃圾候选,在「该发散」的位置上又可能砍掉合理选项。因此实践上常以 top-p 为主,top-k 作为上界备用(两者可以叠加使用)。

截断做的是「把长尾的概率重新分配」

温度改变的是整个分布的形状,top-k / top-p 改变的是分布的支撑集:它们把「累积概率很小的一大堆候选」直接删掉,再把删掉的质量摊给留下来的候选。于是两者的作用可以叠加理解:温度决定分布的平坦程度,截断决定长尾里有没有机会。具体的组合效果在本节的 例 2.8.1 里手算。

一段可运行的采样实现(自回归、逐步把新 token 接回输入):

@torch.no_grad()
def generate(model, idx, n_new, tau=1.0, top_p=None, top_k=None):
    """idx: (B,n0) 初始片段;每步采样一个 token 再把它接回输入(自由跑)"""
    for _ in range(n_new):
        logits, _ = model(idx)                        # (B,T,V),只用最后一个位置
        probs = (logits[:, -1, :] / tau).softmax(-1)  # 温度 = 把 logits 除以 τ(定义 2.8.3)
        if top_p is not None:                         # 核采样:按概率质量截断
            sp, si = probs.sort(-1, descending=True)  # sp:降序概率,si:对应 token id
            keep = (sp.cumsum(-1) - sp) <= top_p      # 保留「前缀质量未超阈值」的候选(含跨过阈值者)
            sp = torch.where(keep, sp, torch.zeros_like(sp))
            probs = torch.zeros_like(probs).scatter(-1, si, sp)
            probs = probs / probs.sum(-1, keepdim=True)         # 重新归一化
        if top_k is not None:                         # top-k:按候选个数截断
            kth = probs.topk(top_k, -1).values[:, -1:]
            probs = torch.where(probs >= kth, probs, torch.zeros_like(probs))
            probs = probs / probs.sum(-1, keepdim=True)
        idx = torch.cat([idx, torch.multinomial(probs, 1)], dim=1)   # 采样:不是 argmax
    return idx
例 2.8.1 · 一个 4 类分布的 τ 与 top-p 手算

某一步模型给出四个候选 token 的 logits z=(2.0, 1.0, 0.5, −1.0)\boldsymbol z=(2.0,\ 1.0,\ 0.5,\ -1.0)。计算(保留三位小数):(1) τ=1\tau=1 的 softmax 分布;(2) τ=0.5\tau=0.5 的分布;(3) τ=2\tau=2 的分布;(4) τ=1\tau=1 下取 top-p=0.9p=0.9 的截断集合与重新归一化概率,被丢弃的质量是多少?

解

(1)(2)(3) 按 p^i∝exp⁡(zi/τ)\hat p_i\propto\exp(z_i/\tau) 逐个算,指数项与分母都列在表里(τ\tau 越小,指数项的跨度越大、概率越集中于少数候选):

温度 z/τ\boldsymbol z/\tau 指数项 分母 p^\hat{\boldsymbol p}
τ=1\tau=1 (2.0, 1.0, 0.5, −1.0)(2.0,\ 1.0,\ 0.5,\ -1.0) (7.389, 2.718, 1.649, 0.368)(7.389,\ 2.718,\ 1.649,\ 0.368) 12.12412.124 (0.609, 0.224, 0.136, 0.030)(0.609,\ 0.224,\ 0.136,\ 0.030)
τ=0.5\tau=0.5 (4.0, 2.0, 1.0, −2.0)(4.0,\ 2.0,\ 1.0,\ -2.0) (54.598, 7.389, 2.718, 0.135)(54.598,\ 7.389,\ 2.718,\ 0.135) 64.84164.841 (0.842, 0.114, 0.042, 0.002)(0.842,\ 0.114,\ 0.042,\ 0.002)
τ=2\tau=2 (1.0, 0.5, 0.25, −0.5)(1.0,\ 0.5,\ 0.25,\ -0.5) (2.718, 1.649, 1.284, 0.607)(2.718,\ 1.649,\ 1.284,\ 0.607) 6.2586.258 (0.434, 0.263, 0.205, 0.097)(0.434,\ 0.263,\ 0.205,\ 0.097)

(4) top-p=0.9p=0.9(τ=1\tau=1)。 把 p^\hat{\boldsymbol p} 降序累积:候选 1 为 0.6090.609、候选 2 为 0.8340.834、候选 3 为 0.9700.970(首次跨过 0.90.9,该候选保留)、候选 4 为 1.0001.000(其前缀质量 0.9700.970 已超阈值,丢弃)。于是

S={1,2,3},∑i∈Sp^i=0.970,p~=(0.629, 0.231, 0.140),丢弃质量=0.030.\mathcal S=\{1,2,3\},\qquad \sum_{i\in\mathcal S}\hat p_i=0.970,\qquad \tilde{\boldsymbol p}=(0.629,\ 0.231,\ 0.140),\qquad \text{丢弃质量}=0.030.

三句可以带走的观察。 ① 温度只改变「分数差 → 概率比」的放大倍数:p^1/p^2=exp⁡(1/τ)\hat p_1/\hat p_2=\exp(1/\tau) 在三个温度下分别是 2.722.72、7.397.39、1.651.65,与表中数值一致;② 三个温度下 argmax 都是候选 1,但「候选 1 有多确信」从 43%43\% 变到 84%84\%——这正是温度改变生成风格、却不改变贪心结果的原因;③ 温度与截断作用在不同层面,并且互相耦合:把阈值降到 top-p=0.8p=0.8,τ=1\tau=1 下候选 3 的前缀质量 0.8340.834 已超阈值,核只有 {1,2}\{1,2\};换成 τ=2\tau=2 后前缀质量被压到 0.4340.434、0.6980.698,两者都在阈值内,核扩大到 {1,2,3}\{1,2,3\}——「分布变平 → 核变大」正是核采样的自适应行为。■\blacksquare

∎

2.8.6 评估:生成质量怎么看

自动指标与人工指标

生成质量没有单一指标。可行的做法是「一个主指标 + 三个体检项 + 一次人工试听」,每项都要知道它能发现什么 / 不能发现什么:

指标 怎么算 能发现什么 不能发现什么
验证集困惑度 在留出的片段上算平均负对数似然再取指数(定理 2.8.1) 模型对训练分布内的下一 token 预测是否变准;词表 / 标签错位这类硬 bug(明显高于 log⁡V\log V 就说明有问题) 音乐质量;自由跑时的误差累积;模型自己采样出的序列有多好
n-gram 重复率 统计生成片段里重复出现的 nn(如 3、4)元组占比 低温度 / 过训练导致的复读与「卡在一个音上打转」 复读有时是训练数据的真实结构(反复记号),需要对照训练集基线
音高 / 时长的分布距离 生成片段的音高直方图、时值直方图与训练集的距离(如总变差距离、KL) 系统性偏差:整体跑调、时值全变成同一个值、只敢用少数几个音 顺序信息——分布完全一致但先后顺序错乱的旋律仍然难听
训练集外 token 比例 生成序列中出现在训练集里的 token / 组合占比 token 映射表错误、词表与生成器不一致、截断后采样到越界 id 组合层面的怪:单个 token 合法但搭配从未出现过
人工试听 A/B 同一初始片段、不同设置各生成一段,盲听后投票 真正关心的东西:像不像训练数据、有没有主题再现、是否复读 不可复现、成本高;样本量小时结论不稳

三条使用纪律:① 用留出集算困惑度,用自由跑的产物看质量——两者衡量的是不同的事;② 报告困惑度时必须同时给出「均匀分布基准 log⁡V\log V」,否则数字无法解释;③ 音高 / 时长分布这类指标先算训练集自身的基线,否则不知道多远算远。

必须写清的一条:困惑度不是音乐质量指标。它只回答「给定真实前缀,模型把多少概率分给了正确的下一个 token」——一个把训练片段背下来的模型可以有极低困惑度,而它在自由跑时可能三步之内就开始复读;反过来,一个被温度调得更「敢猜」的模型在采样时更好听,它的困惑度却完全没变(困惑度是模型分布与数据之间的量,与采样策略无关)。

顺带把一个术语陷阱说清楚:Lab 1 扩展任务 4 提到「对比不同温度的 PPL」,严格的说法是必须先固定评测协议——常见两种口径:(i) 用温度为 τ\tau 的重标定分布 pτp_\tau 在留出片段上算平均 NLL(衡量「这个被重新塑形的分布还给正确 token 留了多少质量」,τ\tau 变小会让它变差,因为分布偏离了数据的真实统计);(ii) 用温度为 τ\tau 生成一段序列,再计算这段自己生成的序列在其模型下的平均 NLL(衡量生成结果的「自信程度」,τ\tau 越小这值越小)。两者都叫「温度的 PPL」,结论却相反——报告时务必写明口径。

2.8.7 常见失败模式与排查顺序

排查树

前五条与 Lab 1 的排错清单逐条对应,后四条是序列生成任务特有的。按序查,每一步都要求一个可观测的判据,不要同时改三个超参。

  1. 数据编码(最优先):token -> id 映射有没有错位?判据:把 id 反解回字符,与原始片段逐字符比对;检查词表里是否混入了不该有的字符(换行、空格、文件头)。
  2. 损失配对:是否严格「用 xtx_t 的输出预测 xt+1x_{t+1}」?判据:写一个单样本小例子,让模型过拟合 8 个 token 的片段——若损失不能降到接近 0,标签或掩码一定有问题。同时检查 padding 位置是否被计入了损失(它们必须被 mask 掉)。
  3. 学习率:太大 → 损失震荡或出现 NaN;太小 → 几千步不动。判据:先试 10−310^{-3} 与 10−410^{-4} 各跑 200 步比较曲线形状。
  4. 隐状态:h0h_0 是否每次从零开始?是否在段 / epoch 之间 detach()?判据:打印 h.shape 与显存占用随时间的变化——忘记 detach 的典型症状是显存缓慢增长直至 OOM。
  5. 序列长度:截断太短学不到长程结构(主题再现永远不会出现);太长则显存与梯度连乘长度一起爆。判据:固定其它设置,把窗口从 16 改成 64 看损失与生成结果。
  6. teacher forcing 与推理不一致(exposure bias,定义 2.2.3):训练损失已经很低,但自由跑几步就散。判据:同时报告 teacher-forced 损失与自由跑的损失;两者的差距就是这一项的大小。缓解:课程式地混入模型自己的预测、对输入加噪、或把单步错误率压得更低。
  7. 小数据过拟合的判据:「训练集困惑度很低、自由跑立刻发散」几乎总是这一条。定量的判据是:留出集 PPL 先降后升(或远高于训练集),而生成结果的重复率 / 分布距离同时变差。此时减小模型(dhd_h、层数)或加 dropout 比继续训练更有效。
  8. 采样参数组合:温度与 top-p 一起放大时(如 τ=2\tau=2 且 top-p=0.98p=0.98),截断几乎不起作用,生成会变成随机拼接;两者都收得极紧(τ=0.3\tau=0.3 且 top-p=0.5p=0.5)则必然复读。判据:固定初始片段,只改一个参数各生成三段对比。
  9. 评估协议本身:是否用训练集算的 PPL?是否把「自由跑的差」当成模型缺陷,而实际上只是 exposure bias?是否忘了对照 log⁡V\log V 基准与训练集自身的 n-gram 基线?判据:把评估脚本逐项写在报告里,让每一步可被别人重跑。

2.8.8 从本讲理论到 Lab 的对应表

把 2.1–2.7 的结论按「实验里落在哪一步」排一遍。左列是理论,右列是 Lab 1 中的具体位置;跨页引用指向对应小节。

理论Lab 1 里的落点做错时的可观测现象
定义 2.1.1 序列建模的三个要求(变长 / 保序 / 参数共享)把旋律当作一串 token 建模,而不是把定长向量喂给 MLP(Lab 1 §3 的建模思路)用 MLP 摊平输入 → 换个长度就报形状错误,且不能外推
定义 2.2.1 RNN 的隐状态递推self.rnn = nn.GRU(...) 那一行——GRU 是 2.3 的门控单元(定义 2.3.2),骨架仍是递推;换 nn.LSTM 可做同参数量对比(扩展任务 1)逐步循环里漏掉 ht−1h_{t-1} 的传递 → 模型退化成逐 token 的独立分类器
定理 2.2.2/定理 2.2.3 BPTT 连乘与消失 / 爆炸判据损失不降 / 出现 NaN 的诊断;裁剪夹在 backward() 与 step() 之间(按全局范数裁剪)把学习率一再调小治 NaN(无效);或不写裁剪直接训长序列
定义 2.2.3 teacher forcing 与 exposure bias训练循环里用真实前缀(logits[:, :-1] 对 tokens[:, 1:]);「训练好、自由跑崩」的诊断只报 teacher-forced 损失 → 误判模型可用
定义 2.4.4 缩放点积注意力换 Transformer 时的注意力层(2.5.7 的 MHA)漏掉除以 dh\sqrt{d_h} → 训练初期损失平台期更长
定义 2.4.5 因果掩码PreLNBlock 内的 causal=True:训练时一次算完所有位置漏掩码 → 训练损失漂亮、生成立刻崩坏(最隐蔽的 bug)
2.5.1 多头注意力TinyTransformer 的 MHA;同参数量下与 GRU 对比不加位置编码 → 输出与输入顺序几乎无关
2.5.3 Pre-LN 与 RMSNormx = x + self.attn(self.n1(x)) 与收尾的 RMSNorm把 LN 放错位置 → 深层训练变差,误判为学习率问题
2.6.4 位置编码(含 RoPE)若改用 RoPE 替掉 self.pos:分数只依赖相对位移学得更长上下文时外推失败(位置表长度 max_T 用尽)
定义 2.8.3 温度采样z = logits[:, -1, :] / tau 与最后的 torch.multinomial用 argmax 生成 → 复读、越多人听越单调
定理 2.8.1 困惑度留出片段上的平均 NLL 取指数(扩展任务 4);必须给 log⁡V\log V 基准用训练集 PPL 选模型 → 选出过拟合最严重的那个
2.7 状态空间 / 线性注意力 / MoE本节不展开:想进一步压长序列或算力时的替代方向在小数据上直接上大结构 → 参数量暴涨而质量无提升

2.8.9 陷阱与勘误

本节五个常见误解
  1. 「用 argmax 生成最安全」。argmax 是 τ→0\tau\to0 的极限(定义 2.8.3),它把分布整段丢掉只留一条路径,结果高度确定、极易复读;而且它与 τ=1\tau=1 的 argmax 完全相同(例 2.8.1(1)(2) 的候选 1 都是 argmax,但「有多确信」从 61%61\% 变成 84%84\%)。采样不是「引入噪声」,而是按模型自己的概率行事。
  2. 「训练损失下降 = 生成质量提升」。损失是 teacher forcing 下的一步预测精度(定义 2.8.2),生成是自由跑下的多步决策;两者之间隔着 exposure bias 与采样策略。只看损失下降就宣布成功,是这个实验里最常见的误判(对应 2.8.7 第 6 条)。
  3. 「温度是唯一要调的超参」。温度只改变分布形状;top-k / top-p 改变支撑集(定义 2.8.4);数据窗口长度、隐蔽的 detach、截断与评估口径各自影响完全不同的环节。把三个变量同时改,等于没有任何可归因的结论。
  4. 「忘了掩码 / 忘了错位一位」。这两个 bug 的共同特征是不报错:漏因果掩码时训练损失异常低而生成崩坏(2.8.8 表里「定义 2.4.5 因果掩码」那一行);错位一位写成「预测自己」时损失降得比正常快、生成只会复读(定义 2.8.2)。
  5. 「把音乐当 NLP 词表随意切分」。音乐的 token 由记谱格式决定,而不是由统计分词器决定(定义 2.8.1):把音高与时长拆开、或把时值写成不同长度的字符串,都会直接改变「模型要学什么」以及每类 token 的样本数(词表与形状约定)。词表是设计决定,不是预处理细节。
即时自测 · 2.8

Q1 写出字符级音乐模型的训练损失,并说明「错位一位」具体指哪两列的配对;若把它写成「用 xtx_t 预测 xtx_t」,会观察到什么现象?

Q2 某模型在留出集上的平均交叉熵为 2.52.5。它的困惑度是多少(保留两位小数)?若词表 V=40V=40,均匀分布基准是多少?这个模型的 PPL 说明什么、不说明什么?

Q3 同一组 logits,把 τ\tau 从 11 改成 0.50.5、再改成 22,生成结果分别会怎样变化?为什么在 top-pp 截断下,把分布调平往往会使保留的候选数变多?(用 例 2.8.1 里 p=0.8p=0.8 的情形验算。)

Q4 一个模型训练集困惑度 1.31.3、自由跑生成的旋律三步之内开始复读,且重复率随温度升高反而下降。给出至少三条排查动作,并说明每条期望看到什么。

参考要点

Q1 L=−∑t=1T−1log⁡pθ(xt+1∣x≤t)\mathcal L=-\sum_{t=1}^{T-1}\log p_\theta(x_{t+1}\mid x_{\le t});配对是 logits[:, :-1](第 tt 个位置的输出)对 tokens[:, 1:](第 t+1t+1 个 token)。若写成 tokens[:, :-1],任务变成「复制输入」:损失会下降得比正常更快、更低(模型学会输出当前 token 的特征),但推理时它没有「当前 token」可看,只能重复刚刚生成的那个符号——复读。

Q2 PPL=e2.5≈12.18\mathrm{PPL}=e^{2.5}\approx12.18。均匀分布基准 log⁡40≈3.689\log 40\approx3.689(PPL ≈40\approx40)。它说明模型在留出片段上平均相当于在约 12 个候选之间犹豫,明显好于均匀(12 远小于 40);它不说明生成的旋律好听、也不说明自由跑时误差不会累积——困惑度只与「给定真实前缀的下一个 token 预测」有关。

Q3 τ=0.5\tau=0.5:分布更尖,输出更保守、更确定,重复增多、更少「惊喜」;τ=2\tau=2:分布更平,更多变、也更容易跑调或出现训练数据里没有的怪组合。叠加 top-pp 时核的大小随分布自适应:分布被摊平后,需要更多候选才能凑够同样的概率质量(例 2.8.1(3) 里把阈值取成 p=0.8p=0.8:τ=1\tau=1 时核是 {1,2}\{1,2\},τ=2\tau=2 时扩大到 {1,2,3}\{1,2,3\}),反之分布很尖时核会自动收缩到一两个候选。

Q4 ① 先分离两种模式:同时报告 teacher-forced 损失与自由跑的损失,差值大小就是 exposure bias 的量级(期望看到自由跑损失远高于训练集损失);② 检查是否过拟合:比较训练集与留出集 PPL,若留出集明显更差,减小模型规模或加 dropout(期望留出集 PPL 下降、重复率下降);③ 检查采样组合:τ=0.5\tau=0.5 的强截断会直接制造复读,把它调到 0.8∼1.20.8\sim1.2 并只保留 top-p∈[0.9,0.95]p\in[0.9,0.95] 作为上界(期望重复率下降、但音高分布仍接近训练集);④ 检查窗口长度与数据本身:窗口太短会让模型无法学到「段落」级结构,只能学局部 n-gram——把窗口从 16 调到 64 后主题再现开始出现,是这类问题的典型判据。注意:最后一条才是「模型学不会长程依赖」与「训练已经饱和」的分界线。

延伸阅读
  • 本书 Lab 1(音乐生成):本节每一条都能在实验里找到对应的代码与排错步骤,建议与 2.8.8 的对照表一起读。
  • MIT 6.S191 Introduction to Deep Learning(官网 introtodeeplearning.com,Lab 仓库 github.com/aamini/introtodeeplearning):母本课程与配套的音乐生成实验。
  • Cho et al., Learning Phrase Representations using RNN Encoder–Decoder(2014, arXiv:1406.1078)——GRU 的原始论文,Lab 1 默认使用的循环单元。
  • Jozefowicz, Zaremba & Sutskever, An Empirical Exploration of Recurrent Network Architectures(2015, ICML)——在大规模对比中 GRU 与 LSTM 表现相近、门控结构的选择并非决定性因素这一经验结论的来源。
  • Olah, Understanding LSTM Networks(2015, colah.github.io)——用图解讲清门控与状态通道,配合 2.3 读。
  • Vaswani et al., Attention Is All You Need(2017, arXiv:1706.03762)——把 Lab 1 的扩展任务 1(换小 Transformer)接回 2.5 的原始论文。

本节小结:Lab 1 的音乐生成把整讲的主线收成一条可执行的流水线——旋律 → token 序列 → 门控递推(或小 Transformer)→ teacher forcing 的逐位置交叉熵 → 温度 / top-k / top-p 采样 → 用困惑度、重复率、分布距离与人工试听共同判断质量。它同时演示了整讲的主线逻辑:递推给出参数共享与 O(1)O(1) 推理状态(2.2),门控用加法通道把梯度从连乘里救出来(2.3),注意力用 O(1)O(1) 路径长度与训练并行换取 O(T2)O(T^2) 开销(2.4、2.5),而位置编码、高效注意力、状态空间与稀疏专家(2.6、2.7)是在同一批约束上继续做交换。最后请记住三件事:困惑度不是音乐质量指标;温度是风格旋钮而不是待调参数;「训练好、自由跑崩」几乎总是 exposure bias 或漏掩码。下一讲把同样的自回归与采样框架搬到像素上,从卷积骨干走到视觉中的注意力混合(第 3 讲);再下一讲(第 4 讲)系统展开生成建模,自回归、扩散与流匹配会在同一个「逐步生成」的框架里被并列比较。