循环神经网络 RNN 与词嵌入

文字是有先后的,全连接网络看不见这个先后。RNN 用一张随时被重写的便签把「刚才说了什么」带到下一步——这一页把词怎么变成向量、便签怎么被改写、以及它为什么记不住太远的话,全部拆开讲透。

30″30 秒看懂 RNN 与词嵌入

把 RNN 想成一个只有一张便签纸的速记员:你把一句话一个词一个词念给他听,他每听一个词,就把便签上的内容整张擦掉、重写一遍。他手上永远只有这一张便签,没有第二张,也不许回头翻记录。要他猜下一个词时,他就照着便签上此刻的内容猜。

这个速记员从头到尾只有一个人。后面所有展开图里画出来的三个、五个、一百个方块,画的都是同一个人在不同时刻——就像连环画里同一个角色出现在每一格,不代表有一百个角色。这是初学 RNN 时最容易看岔的一处。

图① 30 秒看懂:三个词轮流送进同一个神经元,便签被反复改写
图① 30 秒看懂:三个词轮流送进同一个神经元,便签被反复改写

还有一个前置问题:速记员听不懂汉字,他只认识数字。所以在开口念之前,得先把每个词翻译成一串数字——这就是词嵌入(embedding)干的活。它本质上是一本翻译词典:词表里有多少个词,词典就有多少页,每一页记着这个词对应的那一串数字。

比喻里的角色对应的技术概念它到底是什么
速记员本人RNN 单元全程只有一个,所有时间步共用同一套权重;展开图上的多个方块是同一个人的不同时刻
那张便签纸隐藏状态 h网络对「到目前为止听到了什么」的全部记忆,是一个固定长度的向量
擦掉重写一遍h_t = tanh(…)每一步用「上一步的便签」和「这一步的词」算出新便签,整张覆盖,不是追加
开工前的空白便签h0一般是全 0 张量,形状 (num_layers, batch, hidden_size)
翻译词典nn.Embedding一张 词表大小 × embedding_dim 的矩阵,按索引取行,本身也是会被训练的参数
词典的页码词表索引每个词在词表里唯一的编号;送进 embedding 的是编号,不是汉字
照便签猜下一个词全连接 + softmaxh 映射到「词表里每个词的分数」,再归一化成概率
念得太久,开头记不清了梯度消失 / 长依赖失效便签被反复整张重写,早期信息一步步被稀释掉
⛔ 整页只有一条铁律 便签只有一张,而且每一步都被整张重写。 RNN 的全部能力和全部缺陷都从这一句里长出来:正因为只有一张便签,它才能处理任意长度的句子、参数量还不随句子变长(能力);也正因为每一步都被整张重写,二十步之前那个词留下的痕迹早被冲淡了(缺陷)。后面讲的 h、BPTT、梯度消失、LSTM 的门控,全都是在这一句上做文章。
这一页和上一页的关系 张量与自动微分那一页定下的五步主循环(前向 → 算损失 → 清零 → 反向 → 更新)在这里一个字都不变。RNN 改的只是「前向」那一步里数据怎么流动:从「一次性喂进去」变成「一个词一个词喂,中间带着一张便签」。

01概念:序列数据,以及它凭什么需要新结构

什么算序列、全连接网络卡在哪两点、词怎么从汉字变成向量

1.1 什么是序列数据

序列数据的唯一判据是:后面的数据和前面的数据有关系,调换顺序意思就变了。 这句话比「按时间排列的数据」更准,因为序列不一定和时间有关:

数据一条样本长什么样顺序一动会怎样
文本「我 爱 你」三个词换成「你 爱 我」意思就反了;换成「爱 你 我」直接不通顺
股价连续 30 天的收盘价打乱之后涨跌趋势完全消失,只剩一堆数字
语音一段波形按帧切开帧序一乱,音节就拼不出词
用户行为浏览 → 加购 → 下单顺序本身就是意图信号,倒过来毫无意义
反例:一张表的特征列年龄、身高、收入换列顺序结果不变——这就不是序列数据,用全连接就够了

最后一行很关键:判断该不该上 RNN,先问「把输入打乱,答案会不会变」。会变,才谈得上序列建模;不会变,硬上 RNN 只是白白增加训练难度。

1.2 全连接网络卡在哪两点

全连接网络处理文本时,会同时撞上两堵墙,而且两堵墙都绕不过去:

1长度不定,输入层接不上

nn.Linear(in_features, …)in_features 建层时就写死了。可句子有三个词的,也有三十个词的。要么全部截断/补齐到同一长度(丢信息、浪费算力),要么根本建不出这个层。

2看不见顺序

就算强行补齐,全连接把所有词一次性铺平成一个长向量。「我爱你」和「你爱我」铺平之后是同一堆数字的不同排列,网络得靠权重硬记每个位置——换个位置就不认识了,这叫没有位置泛化能力

RNN 的解法是把这两堵墙一起拆掉:不一次性吃完整句话,改成一个词一个词地吃,中间带一张便签。于是句子多长都行(便签大小不变,循环多跑几步而已),而顺序天然被编码进了「谁先改写便签、谁后改写」里。

对比项全连接网络RNN
输入长度建层时固定死任意长度,循环次数随句子变化
参数量与句长的关系正比增长,句子越长参数越多无关,一套权重被所有时间步复用
顺序信息只能靠位置权重硬记由计算的先后顺序天然承载
能并行吗能,整层一次矩阵乘不能,第 t 步必须等第 t−1 步算完
长距离依赖理论上能连,实际难学近处好,远处会衰减——这是它最终被取代的原因

最后两行是伏笔:RNN 解决了长度和顺序,却新添了「不能并行」和「记不住远处」两个毛病。下一页的 Transformer 正是冲着这两条来的。

1.3 从 one-hot 到词嵌入

词要变成数字,最直觉的办法是 one-hot:词表有 N 个词,就用一个长度为 N 的向量,属于第几个词就把第几位置成 1、其余全是 0。这个方案有三个致命问题:

01维度爆炸

中文词表动辄五万起步,每个词就是一个五万维向量,其中 49999 个是 0。一句 20 个词的话,光输入就是 100 万个数。

02语义全丢

任意两个 one-hot 向量的点积都是 0。在这套表示里,「北京」和「天安门」的关系,跟「北京」和「香蕉」的关系一模一样——全都是零相关。

03没有可学的东西

向量里只有 0 和 1,是人为规定死的,训练过程改不动它,也就无法从数据里学到任何语义。

词嵌入把这三条一次解决:不再用「第几位是 1」表示一个词,而是给每个词分配一串稠密的小数(比如 128 个数)。这串数字是网络参数,会跟着训练一起被反向传播更新。训练充分之后,语义相近的词,它们的向量在空间里也会靠得更近。

onehot_vs_embedding.py —— 两种表示的正面对照
"""one-hot 与 embedding 的正面对照:为什么查表比稀疏编码划算。

结论可以纯算术推导,不需要跑起来也能验证:
    词表 50000 词、想要 128 维表示
    one-hot   : 每个词 50000 维,其中 49999 个是 0
    embedding : 每个词 128 维,全是有意义的小数
"""
import torch
import torch.nn as nn


def one_hot(idx, vocab_size):
    """手写 one-hot:开一排 0,只有第 idx 位是 1。"""
    vec = torch.zeros(vocab_size)
    vec[idx] = 1.0
    return vec


def main():
    torch.manual_seed(0)

    vocab = ['我', '爱', '你', '北京', '天安门']
    vocab_size, embed_dim = len(vocab), 4

    # ------------------------------------------------------------ one-hot
    print('=== one-hot ===')
    for i, w in enumerate(vocab):
        print('%6s' % w, one_hot(i, vocab_size).tolist())
    # 看得出来三件事:
    #   1) 维度 = 词表大小,词表一大就爆炸
    #   2) 任意两个词的向量点积都是 0,"北京" 和 "天安门" 一样不相关
    #   3) 全是 0 和 1,没有任何可学的东西

    # ------------------------------------------------------------ embedding
    print('=== embedding ===')
    embed = nn.Embedding(num_embeddings=vocab_size, embedding_dim=embed_dim)
    print('嵌入矩阵形状', embed.weight.shape)       # (5, 4)

    ids = torch.tensor([0, 1, 2])                   # 我 爱 你
    vecs = embed(ids)
    print('三个词一次查表的输出形状', vecs.shape)     # (3, 4)

    # 查表的本质:embedding 的输出 = one-hot 矩阵乘嵌入矩阵,
    # 只是没人会真去乘一个 99.998% 都是 0 的矩阵,直接按下标取行就行
    manual = torch.stack([one_hot(i, vocab_size) for i in [0, 1, 2]]) @ embed.weight
    print('按下标取行 与 one-hot 乘矩阵 是否一致:',
          torch.allclose(vecs, manual))             # True

    # ------------------------------------------------------------ 规模账
    print('=== 规模账(50000 词,128 维)===')
    big_vocab, dim = 50000, 128
    print('one-hot   单词向量元素数 :', big_vocab)
    print('embedding 单词向量元素数 :', dim)
    print('embedding 矩阵总参数    :', big_vocab * dim)   # 6,400,000
    print('压缩比                  : %.1f 倍' % (big_vocab / dim))  # 390.6 倍

    # 注意:embedding 矩阵是网络参数,会被反向传播更新。
    # 一开始是随机数,训练之后语义相近的词,向量也会靠得更近。
    print('嵌入矩阵需要梯度吗:', embed.weight.requires_grad)   # True


if __name__ == '__main__':
    main()

代码里有一行值得单独拎出来:one_hot 矩阵 @ 嵌入矩阵 的结果,和直接按下标取行完全相等。这就是词嵌入的数学本质——它就是一次 one-hot 乘矩阵,只是没人会真去乘一个 99.998% 都是 0 的矩阵,直接按行号取就行了。所以 nn.Embedding 常被称作「查表层」,它的前向传播比任何一层都便宜。

对比项one-hot词嵌入 embedding
向量长度= 词表大小(五万起步)人为指定,常见 64 / 128 / 256 / 768
稀疏还是稠密极度稀疏,只有一个 1稠密,每一维都有值
能否表达相似度不能,任意两词点积恒为 0,相近的词向量距离更近
参数没有参数,规则写死整张矩阵都是参数,随训练更新
怎么取值按规则构造按索引查表(等价于乘 one-hot)
embedding_dim 怎么选 没有公式,但有经验区间:小语料(几万词以内)用 64~128,中等规模用 256,预训练大模型通常 768 起步。选太小会挤不下语义,选太大在小数据上容易过拟合、还白白增加参数。先按 128 起步,再根据验证集调,这比纠结初始值有用得多。

02原理:从汉字到便签,再到便签为什么记不住远处

分词、查表、单元内部公式、API 形状、时间轴展开、梯度消失,以及门控的补救思路

2.1 分词与词表:一切的第一步

速记员只认数字,所以开工前必须先把文本加工成一串编号。这一步叫构建词表,只有三个动作:

① 分词把句子切成词
② 去重得到词表,每个词一个唯一编号
③ 转索引整段语料变成一串整数

中文没有天然空格,所以第一步要用分词器。工程上最常见的是 jiebajieba.lcut(text) 返回一个词列表。切分粒度直接决定词表大小——按词切词表大但每个单位语义完整,按字切词表小(中文常用字几千个)但需要模型自己学词的组合。短文本生成任务按字切往往更稳,因为不会遇到未登录词

切分粒度词表规模未登录词适合场景
按字几千几乎没有歌词/诗歌生成、小语料,模型要自己学词的边界
按词几万到几十万多,是主要痛点分类、检索等语义单位更重要的任务
子词(BPE 等)三万左右没有,拆成碎片兜底今天的大模型几乎全用这一种,兼顾两者

去重这一步有个容易忽略的细节:必须保持稳定的顺序。用 list(set(words)) 去重虽然短,但 set 不保证顺序,每次跑同一份语料,同一个词拿到的索引可能都不一样——存下来的模型权重和新建的词表就对不上号了。正确做法是「不在列表里就追加」,保留首次出现的顺序。

词表必须和模型权重一起保存 模型学到的是「编号 37 对应的向量」,不是「『分手』这个词对应的向量」。词表一旦重建、编号一变,旧权重立刻变成乱码,生成出来的全是胡话。存 checkpoint 时把词表一起存下来,这是 NLP 项目最基本的工程纪律。

2.2 词嵌入层在做什么

翻译词典建好之后,查表就是一行 API。nn.Embedding 建的时候只关心两个数:

参数含义写错的后果
num_embeddings词表里一共多少个词 = 矩阵有多少行写小了,遇到大索引直接 IndexError;写大了白占参数
embedding_dim每个词用多少维表示 = 每行多长要和后面 nn.RNNinput_size 严格对齐,否则矩阵乘法报错
图② 词嵌入:文本 → 分词 → 词表 → 索引 → 查表取向量
图② 词嵌入:文本 → 分词 → 词表 → 索引 → 查表取向量

图里这条链要逐段读清楚,每一段都有一个具体的数据形态:

  • 原始文本 →(分词)→ 词列表。 这一步的产物是字符串列表,还没有任何数字。
  • 词列表 →(去重编号)→ 词表。 产物是两个字典:word_to_index 用于把文本转成索引,index_to_word 用于把模型输出的索引翻回汉字。两个方向都要留着,生成任务里缺一个就没法把结果打印出来。
  • 词表 →(查表取索引)→ 索引序列。 产物是一串 int64。注意送进 nn.Embedding 的必须是整型,传浮点会直接报错。
  • 索引序列 →(查嵌入矩阵)→ 词向量矩阵。 产物形状是 (词数, embedding_dim)。图底下那句话是判断形状对不对的口诀:词表有几个词,矩阵就有几行;每行长度就是 embedding_dim

形状的变化规律只有一条,记住就不会算错:embedding 层在输入张量的最后添加一个维度。输入 (batch, seq_len) 的索引,输出就是 (batch, seq_len, embedding_dim);输入一个标量索引,输出就是一个 (embedding_dim,) 的向量。

嵌入矩阵一开始是随机的 刚建出来的 embed.weight 是一堆随机数,这时候「北京」和「香蕉」的向量并不比随机两个词更接近。语义是训练出来的,不是建层时就有的。要开局就带语义,就加载预训练词向量(Word2Vec、GloVe)来初始化,小语料上这能省下大量训练量。

2.3 RNN 单元内部:便签到底怎么被改写

现在进到速记员的脑子里。每个时间步,他做的事可以写成一条公式——而且只有这一条:

h_t = tanh( x_t · W_ihT + b_ih  +  h_t−1 · W_hhT + b_hh )

公式看着长,拆开只有三件事:

部件形状在比喻里是什么
x_t(batch, input_size)这一步念给他听的那个词(已经查过表,是向量)
h_t−1(batch, hidden_size)上一步写完的那张便签
W_ih / b_ih(hidden, input) / (hidden,)「新听到的词该怎么记」的规则
W_hh / b_hh(hidden, hidden) / (hidden,)「旧便签该怎么保留」的规则
tanh把结果压进 (−1, 1),防止便签上的数字一路放大到失控
h_t(batch, hidden_size)新便签,整张覆盖旧的

两个加号是全部秘密所在:新便签 = 这一步的词贡献一份 + 上一步的便签贡献一份,加起来再压一压。「记忆」这件事没有任何神秘机制,就是这个加法。

还有一件事必须钉死:W_ihW_hhb_ihb_hh 这四个张量,在所有时间步里是同一份。句子有 100 个词,就用这同一套权重循环 100 次。这叫参数共享,也正是「只有一个速记员」在代码层面的含义。下面这份把 nn.RNN 的权重搬出来、自己按公式循环一遍,两边数值对得上才算真看懂:

rnn_cell_manual.py —— 手写 RNN 单元并与 nn.RNN 对账手算验证
"""手写一个 RNN 单元,再和 nn.RNN 对账。

单元内部只有一条公式:
    h_t = tanh(x_t @ W_ih^T + b_ih  +  h_{t-1} @ W_hh^T + b_hh)
把 nn.RNN 的权重搬过来自己算一遍,两边数值一致才算真看懂。
"""
import torch
import torch.nn as nn


def rnn_cell(x_t, h_prev, w_ih, b_ih, w_hh, b_hh):
    """一个时间步:吃「这一步的词向量」和「上一步的 h」,吐新的 h。"""
    from_input = x_t @ w_ih.T + b_ih        # 当前输入贡献的那一份
    from_state = h_prev @ w_hh.T + b_hh     # 历史记忆贡献的那一份
    return torch.tanh(from_input + from_state)   # 两份相加再压到 (-1, 1)


def main():
    torch.manual_seed(0)

    input_size, hidden_size, seq_len = 3, 4, 5

    rnn = nn.RNN(input_size=input_size, hidden_size=hidden_size, num_layers=1)

    # nn.RNN 把四个参数摊在 _l0 后缀的属性里,形状分别是:
    #   weight_ih_l0 (hidden_size, input_size)
    #   weight_hh_l0 (hidden_size, hidden_size)
    #   bias_ih_l0 / bias_hh_l0 (hidden_size,)
    w_ih, b_ih = rnn.weight_ih_l0.detach(), rnn.bias_ih_l0.detach()
    w_hh, b_hh = rnn.weight_hh_l0.detach(), rnn.bias_hh_l0.detach()
    print('w_ih', tuple(w_ih.shape), ' w_hh', tuple(w_hh.shape))

    # 一句话、batch=1,默认布局是 (seq_len, batch, input_size)
    x = torch.randn(seq_len, 1, input_size)
    h0 = torch.zeros(1, 1, hidden_size)      # 全 0 起步:还没有任何记忆

    # -------------------------------------------------- 官方实现
    output, hn = rnn(x, h0)

    # -------------------------------------------------- 自己按公式循环
    h = h0[0]                                # (batch, hidden_size)
    manual_outputs = []
    for t in range(seq_len):
        h = rnn_cell(x[t], h, w_ih, b_ih, w_hh, b_hh)
        manual_outputs.append(h)             # 每一步的 h 就是这一步的 output
    manual_output = torch.stack(manual_outputs)

    print('官方 output 形状', output.shape)           # (5, 1, 4)
    print('手写 output 形状', manual_output.shape)    # (5, 1, 4)
    print('两边是否一致 :', torch.allclose(output, manual_output, atol=1e-6))
    print('hn 就是最后一步的 h :',
          torch.allclose(hn[0], manual_output[-1], atol=1e-6))

    # 三个必须记住的结论:
    #   1) output 是「每一步的 h 摞起来」,hn 是「最后一步的 h」,
    #      单层网络里 output[-1] 和 hn[0] 是同一个东西
    #   2) 所有时间步共用同一套 w_ih / w_hh —— 这就是「同一个神经元」的含义
    #   3) 参数量与句子长度无关:句子再长,参数还是这四个张量
    total = sum(p.numel() for p in rnn.parameters())
    print('参数量 :', total,
          '= 4*3 + 4*4 + 4 + 4 =', hidden_size * input_size
          + hidden_size * hidden_size + 2 * hidden_size)


if __name__ == '__main__':
    main()

代码末尾那行参数量核对值得留意:hidden×input + hidden×hidden + 2×hidden整个式子里没有 seq_len。这就是 1.2 节那张表里「参数量与句长无关」的具体来源。

为什么是 tanh 不是 ReLU 便签要被反复重写上百次,如果激活函数不做压缩,数值会在连乘中迅速放大到溢出。tanh 把每一步的输出锁死在 (−1, 1),是 RNN 能稳定跑起来的前提。代价是它的导数最大只有 1、且大部分区域远小于 1——这直接埋下了 2.6 节梯度消失的根。ReLU 在 RNN 里不是不能用,但必须配合严格的梯度裁剪和初始化,默认选择仍是 tanh。

2.4 nn.RNN 的入参与返回:形状是这一节的全部难点

实际写代码不需要自己写循环,nn.RNN 一层就把整个序列跑完了。建层时四个参数:

参数含义怎么定
input_size每个时间步输入向量的维度必须等于上游 embedding_dim,这是最常见的对接点
hidden_size便签的长度,也是每步输出的维度自己定。越大记得越多、也越容易过拟合;常见 128 / 256
num_layers堆几层 RNN,默认 1第一层的每步输出当作第二层的每步输入。两层以上收益迅速递减
batch_firstbatch 维放不放最前面,默认 False默认布局是 (seq_len, batch, input_size)和直觉相反,这一条是踩坑重灾区

调用时传两个、收两个:output, hn = rnn(x, h0)。四个张量的形状必须背下来:

张量形状(默认布局)它是什么
x(seq_len, batch, input_size)输入序列:句长 × 句子数 × 词向量维度
h0(num_layers, batch, hidden_size)初始便签,一般全 0;第一维是层数不是句长
output(seq_len, batch, hidden_size)每一个时间步的 h 摞起来,只含最后一层
hn(num_layers, batch, hidden_size)最后一个时间步的 h,含每一层

outputhn 的关系最容易讲糊涂,用一句话钉住:output 是「横着切」——沿时间轴把每一步都留下来;hn 是「竖着切」——只留最后一刻,但每层都留。 单层网络里 output[-1]hn[0] 是同一个张量,上面那份手写代码已经用 allclose 验证过了。

选哪个,取决于任务:

1逐词预测 → 用 output

文本生成、序列标注、词性标注这类每个位置都要出一个结果的任务,把 output 整个送进全连接层。

2整句判断 → 用 hn

情感分类、意图识别这类一整句只出一个结果的任务,取 hn[-1](或 output[-1])当作整句的表示。

rnn_layer.py —— nn.RNN 的输入输出形状最小可跑
"""nn.RNN 的输入输出形状:搞错维度顺序是这一讲最常见的报错来源。"""
import torch
import torch.nn as nn

# input_size:每个词向量多少维;hidden_size:隐藏状态多少维;num_layers:堆几层
rnn = nn.RNN(input_size=128, hidden_size=256, num_layers=1)

# 默认布局是 (seq_len, batch, input_size)——句子长度在最前面,不是 batch
inputs = torch.randn(5, 32, 128)      # 5 个词、32 句话、每词 128 维
h0 = torch.zeros(1, 32, 256)          # (num_layers, batch, hidden_size),一般全 0 起步

output, hn = rnn(inputs, h0)

print('output 形状', output.shape)    # (5, 32, 256):每个时间步都有一个输出
print('hn 形状    ', hn.shape)        # (1, 32, 256):只有最后一个时间步的隐藏状态

# 如果你的数据是 (batch, seq_len, input_size),两条路二选一:
#   1) 建层时写 nn.RNN(..., batch_first=True)
#   2) 送进去之前 x.transpose(0, 1)
rnn_bf = nn.RNN(128, 256, 1, batch_first=True)
out_bf, hn_bf = rnn_bf(torch.randn(32, 5, 128))
print('batch_first 时 output 形状', out_bf.shape)   # (32, 5, 256)
⛔ 默认布局把 seq_len 放在第一维,不是 batch 这与 nn.Linearnn.Conv2d 的习惯完全相反,是这一讲报错率最高的地方。更糟的是,把 (batch, seq_len, dim) 直接丢进默认布局的 nn.RNN 往往不报错——只要两个数都合法,它就会把 batch 当成句长算下去,静默给出错误结果。要么建层时统一写 batch_first=True,要么送进去之前 x.transpose(0, 1),整个项目只选一种并贯彻到底。

2.5 时间步展开与 BPTT

把循环沿时间轴摊平画出来,就得到教科书上最常见的那张展开图:

图③ 时间步展开:每一步都吃「上一步的 h」和「这一步的词」
图③ 时间步展开:每一步都吃「上一步的 h」和「这一步的词」

照着图把「输入『我爱』预测『你』」这件事走一遍:

  1. 准备空白便签。 h0 初始化为全 0,形状 (1, batch, hidden_size)。此刻网络对这句话一无所知。
  2. 第一步:吃「我」。 「我」查表得到词向量,和 h0 一起送进单元,算出 h1
  3. 第二步:吃「爱」。 「爱」的词向量和 h1 一起送进同一个单元,算出 h2。此时 h2 里同时含着「我」和「爱」的信息。
  4. 出结果。h2 送进全连接层,映射成「词表里每个词的分数」,再 softmax 成概率,取概率最大的那个词——期望它是「你」。

图底下那句「每一步都吃两样:上一步的 h 和这一步的词」就是整张图的读法。注意第三个方块的输入画的是问号——生成时下一步吃的正是上一步刚吐出来的词,这叫自回归,是 4.3 节生成循环的依据。

反向传播为什么改叫 BPTT

训练时梯度要沿着这条链往回传。因为链是沿时间轴展开的,这个过程有个专门的名字:BPTT(Backpropagation Through Time,沿时间反向传播)。它和普通反向传播不是两种算法,就是同一套链式法则用在展开后的图上,只不过有两个特点:

  • 梯度要连乘 seq_len 段。 句子有 50 个词,从最后一步回到第一步就要乘 50 段。
  • 同一套权重收到 seq_len 份梯度,全部累加。 因为每个时间步都用了同一个 W_hh,它在每一步都要负一次责任。这里正好用上了自动微分那一页讲的「.grad 默认累加」——对 RNN 来说,这个累加不是坑,而是正确行为
truncated BPTT:句子太长时的标准做法 序列几千步时,完整 BPTT 的显存和时间都吃不消。工程上会把长序列切成固定长度的片段(比如 32 步)分别反传,片段之间只传递 h数值而不传梯度(用 h.detach() 切断)。这叫截断 BPTT,是所有长序列训练脚本的默认姿势。

2.6 梯度消失:便签为什么记不住远处

现在可以解释铁律的后半句了。BPTT 要把每一段的导数连乘起来,而每一段的大小大致是 |W_hh · tanh′|tanh 的导数最大值是 1,绝大多数区域远小于 1,所以单段系数通常小于 1。小于 1 的数连乘几十次会发生什么,算一遍就一目了然:

bptt_vanishing.py —— 连乘衰减的数字账纯算术可跑
"""梯度消失为什么会发生:把 BPTT 的连乘用纯算术跑一遍。

反向传播沿时间轴往回走时,梯度要一段一段连乘:
    dL/dh_1 = dL/dh_T  ×  (dh_T/dh_{T-1}) × … × (dh_2/dh_1)
每一段的大小大致就是 |w_hh · tanh'|。tanh' 最大是 1,一般远小于 1,
所以这串乘积的数量级完全由「单段系数」的 T 次方决定。
本文件只用标准库,跑出来的数字就是 factor ** step,可以拿计算器核对。
"""


def chain(factor, steps):
    """连乘 steps 段,返回每一段之后的累计梯度倍率。"""
    value, trace = 1.0, []
    for _ in range(steps):
        value *= factor
        trace.append(value)
    return trace


def main():
    steps = 50

    print('时间步    系数 0.5        系数 0.9        系数 1.0        系数 1.1')
    print('-' * 68)
    t05, t09, t10, t11 = (chain(f, steps) for f in (0.5, 0.9, 1.0, 1.1))
    for t in (1, 5, 10, 20, 30, 50):
        print('%5d  %14.6e  %14.6e  %14.6e  %14.6e'
              % (t, t05[t - 1], t09[t - 1], t10[t - 1], t11[t - 1]))

    print()
    print('读法:')
    print('  系数 0.5 → 第 20 步梯度只剩 %.3e,20 步以前的词等于没参与训练'
          % t05[19])
    print('  系数 0.9 → 衰减慢一些,第 50 步也只剩 %.3e' % t09[49])
    print('  系数 1.0 → 唯一不衰减也不爆炸的临界点,现实中碰不到')
    print('  系数 1.1 → 第 50 步放大到 %.3e,这就是梯度爆炸' % t11[49])

    # ------------------------------------------------------------ 半衰期
    # 想知道「多少步之后梯度掉到千分之一」,解 factor**n = 0.001
    import math
    for f in (0.5, 0.8, 0.9, 0.95):
        n = math.log(0.001) / math.log(f)
        print('系数 %.2f:约 %.1f 步之后梯度掉到千分之一' % (f, n))

    # 三条可以直接带走的结论:
    #   1) 梯度消失不是 bug,是连乘的必然结果,网络越深/句子越长越明显
    #   2) 梯度爆炸能救(裁剪梯度),梯度消失救不了——信息本身没传过来
    #   3) LSTM / GRU 的门控就是给这条链加一条「系数接近 1」的旁路,
    #      让远处的梯度有机会不被连乘吃掉
    print()
    print('梯度爆炸可以用 torch.nn.utils.clip_grad_norm_(params, max_norm) 压住;')
    print('梯度消失压不住,只能换结构(LSTM / GRU / Transformer)。')


if __name__ == '__main__':
    main()

这份代码不依赖任何框架,跑出来的数就是 系数 ** 步数,可以拿计算器逐格核对。几个值得记住的数:

单段系数第 20 步剩余第 50 步剩余含义
0.59.54e−078.88e−1620 步之前的词等于完全没参与训练
0.90.12165.15e−03衰减慢一些,但 50 步外仍然可忽略
1.01.01.0唯一不衰减也不爆炸的临界点,现实中碰不到
1.16.73117.4梯度爆炸,loss 直接变 nan

两种失效的处理难度完全不同,这一点常被混为一谈:

1梯度爆炸:能救

现象是 loss 突然飙到 nan。一行 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) 把梯度范数压回去就行,信息本身没丢,只是步子迈太大

2梯度消失:救不了

现象是 loss 降到某个值就不动、模型只会用最近几个词。裁剪、调学习率都没用——远处的信息压根没传过来,不是步子大小的问题。只能换结构。

回到比喻:便签每一步都被整张重写,二十步前那个词留下的痕迹,早就被后面二十次重写冲得干干净净。这不是 RNN 没训练好,这是它的结构决定的天花板。

2.7 LSTM 与 GRU:给便签加几道闸门

既然问题出在「每一步整张重写」,改法的方向就很清楚了:别整张重写,让网络自己决定哪些该留、哪些该删、哪些该写。这就是门控(gate)的全部思想。

所谓「门」,就是一个用 sigmoid 算出来的、取值在 0 到 1 之间的向量,拿它去逐元素乘另一个向量:乘 0 就是彻底关掉,乘 1 就是原样放行,乘 0.3 就是放行三成。名字听着高级,做的就是这件事。

结构门的数量状态核心改动
RNN0h每步整张重写,远处信息被反复稀释
LSTM3(遗忘 / 输入 / 输出)h + c另开一条细胞状态 c,它主要靠加法更新,给梯度留了一条系数接近 1 的旁路
GRU2(更新 / 重置)h把 LSTM 简化:不另设 c,用一个门同时管「忘多少」和「写多少」

LSTM 能缓解梯度消失的关键,不在于门多,而在于细胞状态 c 的更新以加法为主。加法的导数是 1,连乘一百个 1 还是 1——这就绕开了 2.6 节里那串小于 1 的连乘。门控给的是「可以不衰减」的通道,不是「一定不衰减」的保证,超长序列上 LSTM 同样会力不从心,这一点不要夸大。

lstm_gru_compare.py —— 三种循环层的接口与参数量对照
"""RNN / LSTM / GRU 三者的接口与参数量对照。

三个层的用法几乎一样,换名字就能换结构,区别只有两处:
    1) LSTM 的隐藏状态是 (h, c) 两个张量,RNN 和 GRU 只有 h
    2) 参数量:RNN 一份,GRU 三份,LSTM 四份 —— 门越多参数越多
"""
import torch
import torch.nn as nn


def param_count(layer):
    return sum(p.numel() for p in layer.parameters())


def expected(gates, input_size, hidden_size):
    """按公式推参数量:gates × (hidden×input + hidden×hidden + 2×hidden)。

    每个门都是一套完整的「输入权重 + 状态权重 + 两个偏置」。
    """
    return gates * (hidden_size * input_size
                    + hidden_size * hidden_size
                    + 2 * hidden_size)


def main():
    torch.manual_seed(0)

    input_size, hidden_size = 128, 256
    batch, seq_len = 8, 12
    x = torch.randn(batch, seq_len, input_size)      # batch_first 布局

    rnn = nn.RNN(input_size, hidden_size, batch_first=True)
    lstm = nn.LSTM(input_size, hidden_size, batch_first=True)
    gru = nn.GRU(input_size, hidden_size, batch_first=True)

    # ---------------------------------------------------------- 返回值
    out_rnn, hn_rnn = rnn(x)
    print('RNN  output', out_rnn.shape, ' hn', hn_rnn.shape)

    out_gru, hn_gru = gru(x)
    print('GRU  output', out_gru.shape, ' hn', hn_gru.shape)

    # LSTM 多一个细胞状态 c:h 是「对外说的话」,c 是「自己记的账」
    out_lstm, (hn_lstm, cn_lstm) = lstm(x)
    print('LSTM output', out_lstm.shape, ' hn', hn_lstm.shape, ' cn', cn_lstm.shape)
    # 写成 out, hn = lstm(x) 也不报错,但 hn 会是一个元组,
    # 下一步拿它当张量用就会抛 AttributeError —— 换 LSTM 最常见的坑

    # ---------------------------------------------------------- 参数量
    print()
    print('%-6s %-10s %-12s %s' % ('层', '门数', '实际参数量', '公式推导'))
    for name, layer, gates in (('RNN', rnn, 1), ('GRU', gru, 3), ('LSTM', lstm, 4)):
        print('%-6s %-10d %-12d %d'
              % (name, gates, param_count(layer),
                 expected(gates, input_size, hidden_size)))
    # 三行数字完全对得上,说明「门控 = 复制几套同样的权重」这个理解是对的。
    # 代价也很直白:LSTM 的参数和计算量是同规模 RNN 的 4 倍。

    # ---------------------------------------------------------- 手动传状态
    print()
    h0 = torch.zeros(1, batch, hidden_size)
    c0 = torch.zeros(1, batch, hidden_size)
    out, (hn, cn) = lstm(x, (h0, c0))        # LSTM 要成对传,元组不能拆
    print('显式传 (h0, c0) 之后 output', out.shape)
    # 不传初始状态时 PyTorch 自动填一份全 0 的状态,效果一样;
    # 逐词生成时必须自己带着状态走,否则每一步都从零记忆开始。

    print()
    print('选型建议:短序列先用 GRU(参数比 LSTM 少 1/4,效果常常持平);')
    print('需要更强的长期记忆再上 LSTM;纯 RNN 基本只用于教学和极短序列。')


if __name__ == '__main__':
    main()

代码里参数量那张表一算就清楚:门越多,参数越多。同规模下 GRU 是 RNN 的 3 倍、LSTM 是 4 倍,计算量同比例增长。所以选型不是「越高级越好」:

✅ 三句话选型 nn.RNN 基本只用于教学和极短序列,生产上很少直接用 · 先上 GRU:参数比 LSTM 少四分之一,多数任务上效果持平,训练更快 · 确实需要更强的长期记忆再换 LSTM。三者 API 几乎一致,换名字就能换结构,唯一要改的是 LSTM 的隐藏状态是 (h, c) 元组
换成 LSTM 之后最常见的一个报错 照着 RNN 的写法写 output, hn = lstm(x) 不会报错,但此时 hn 是一个元组 (h, c),下一步拿它当张量用(比如 hn[-1].shape 之后再送进 Linear)就会抛出莫名其妙的类型错误。正确写法是 output, (hn, cn) = lstm(x)

03最小代码:把词表和查表各跑一次

先用两段最短的代码确认「文本 → 索引 → 向量」这条链是通的,再去看完整案例

3.1 建词表:文本变成一串整数

这一段没有网络、没有训练,只把 2.1 节那三个动作跑一遍:分词、去重编号、转索引。它不依赖任何第三方分词器,没装 jieba 也能跑通,真实项目里把 tokenize() 换成 jieba.lcut 即可。

vocab_build.py —— 分词、去重编号、转索引最小可跑
"""构建词表:把一段文本变成「词 → 索引」的字典和一串索引。

这一步在任何 NLP 任务里都排第一位,做错了后面全错。
本文件不依赖 jieba,用一个极简的中文单字 + 英文单词切分器代替,
这样没装分词器也能跑;真实项目把 tokenize() 换成 jieba.lcut 即可。
"""


def tokenize(line):
    """极简切分:中文按单字切,连续的英文字母/数字算一个词。"""
    tokens, buf = [], ''
    for ch in line:
        if ch.isascii() and ch.isalnum():
            buf += ch                      # 英文数字先攒着,凑成一个完整单词
            continue
        if buf:
            tokens.append(buf)
            buf = ''
        if not ch.isspace():
            tokens.append(ch)              # 中文和标点一个字一个词
    if buf:
        tokens.append(buf)
    return tokens


def build_vocab(lines):
    """返回四件套:index_to_word / word_to_index / vocab_size / corpus_idx。"""
    index_to_word = []                     # 下标 i 对应第 i 个词,顺序即索引
    all_lines = []

    for line in lines:
        words = tokenize(line)
        all_lines.append(words)
        for word in words:
            # 用「不在就追加」保持首次出现的顺序,结果可复现;
            # 换成 set() 会丢顺序,每次跑索引都不一样,调试时对不上号
            if word not in index_to_word:
                index_to_word.append(word)

    # 换行符本身也要进词表,否则拼接语料时无法表示句子边界
    if '\n' not in index_to_word:
        index_to_word.append('\n')

    word_to_index = {w: i for i, w in enumerate(index_to_word)}

    corpus_idx = []                        # 整个语料摊平成一串索引
    for words in all_lines:
        corpus_idx.extend(word_to_index[w] for w in words)
        corpus_idx.append(word_to_index['\n'])

    return index_to_word, word_to_index, len(index_to_word), corpus_idx


if __name__ == '__main__':
    corpus = ['我爱你', '我爱北京']

    i2w, w2i, vocab_size, idx_seq = build_vocab(corpus)

    print('词表      :', i2w)
    print('词表大小  :', vocab_size)
    print('词到索引  :', w2i)
    print('语料索引串:', idx_seq)

    # 两行文本一共 3 + 4 = 7 个词 + 2 个换行 = 9 个索引,
    # 去重后是 我 爱 你 北 京 \n 共 6 个词,所以 vocab_size = 6。
    assert vocab_size == 6, '词表大小推导不符,先检查 tokenize'
    assert len(idx_seq) == 9, '语料索引串长度推导不符'

    # 反查:索引串还原回文本,验证映射是双向可逆的
    print('还原文本  :', ''.join(i2w[i] for i in idx_seq))

这份代码的输出可以完全手推,不用猜:语料是「我爱你」和「我爱北京」两行,按字切分后去重得到 我 爱 你 北 京 五个字,再加上表示换行的一个符号,词表大小恰好是 6。语料索引串则是 3 + 1 + 4 + 1 = 9 个整数。代码末尾的两行 assert 就是把这两个推导钉死——能写出 assert 的地方就别用 print 靠眼睛看,改坏了会立刻炸出来。

产物类型干什么用
index_to_word列表把模型输出的索引翻回汉字,生成任务里没有它就打印不出结果
word_to_index字典把输入文本转成索引,喂给 embedding
vocab_size整数同时决定 nn.Embedding 的行数和输出层的宽度
corpus_idx整数列表整个语料摊平成的一长串索引,供数据集切片

最后一行「还原文本」是一个廉价但极有用的自检:索引串能原样翻回原文,说明映射是双向可逆的。这一步不做,后面生成出乱码时你分不清是模型没学好还是词表就错了。

3.2 查表:索引变成向量

词表就位之后,词嵌入只有两行:建层、传索引。

embedding_min.py —— nn.Embedding 的最小用法最小可跑
"""词嵌入最小例子:文字 -> 索引 -> 向量。"""
import torch
import torch.nn as nn
import jieba

text = '北京冬奥的进度条已经过半,不少外国运动员在完成自己的比赛后踏上归途。'

# 1. 分词:中文没有天然空格,先切成词
words = jieba.lcut(text)
print('分词结果:', words)

# 2. 去重得到词表:词表有几个词,嵌入矩阵就有几行
unique_words = list(set(words))
print('去重后词数:', len(unique_words))

# 3. 建词嵌入层:num_embeddings 是词表大小,embedding_dim 是每个词用几维表示
embed = nn.Embedding(num_embeddings=len(unique_words), embedding_dim=4)
print('嵌入矩阵形状:', embed.weight.shape)     # (词表大小, 4)

# 4. 查表:传进去的是索引(int64),拿回来的是那一行向量
for i, word in enumerate(unique_words[:5]):
    print('%4s' % word, embed(torch.tensor(i)).detach())

# 这些向量一开始是随机的,靠训练才会把语义学进去——
# 嵌入矩阵本身就是网络参数,会被反向传播更新

这段代码需要 jieba。它演示的三件事各对应 2.2 节的一条结论:嵌入矩阵形状是 (词表大小, embedding_dim)传进去的是索引张量而不是汉字拿回来的向量一开始全是随机数。最后那句注释点出了最关键的一点:embed.weight 本身就是网络参数,会被反向传播更新,所以语义是训练出来的。

索引越界是这里唯一的报错 nn.Embedding(num_embeddings=100, …) 只接受 0 ~ 99 的索引,传 100 进去会抛 IndexError: index out of range in self报这个错八成是词表大小和建层时写的数对不上——常见于「用训练集建词表,预测时遇到新词」,或者「词表重建过但模型还是旧的」。工程上的标准解法是留一个 <unk> 索引兜底所有未登录词。

3.3 两段代码合起来是什么

把 3.1 和 3.2 串起来,就得到任何 NLP 模型的前两层:

文本'我爱你'
索引[0, 1, 2]
shape (3,)
词向量shape (3, 128)
送进 RNN再补上 batch 维

形状的推导值得再走一遍,因为下一节全靠它:一批 32 句话、每句 10 个词,索引张量是 (32, 10);过 nn.Embedding(vocab, 128) 之后变成 (32, 10, 128);如果 nn.RNN 用默认布局,还要 transpose(0, 1) 转成 (10, 32, 128) 才能送进去。这三个形状写在纸上比背 API 文档管用。

环境要求 Python 3.10 以上,pip install torch(CPU 版即可跑完本页大部分代码)。分词相关的两份需要 pip install jiebavocab_build.pybptt_vanishing.py 只用标准库,装不上任何东西也能跑。是否有 GPU 不影响本页任何结论。

04完整案例:歌词生成器

从一堆歌词文本出发,训练一个能续写的模型,再讲清「选词」这一步为什么决定成败

这个案例把前面所有零件串起来:词表、词嵌入、循环层、全连接层、训练循环、逐词生成。任务本身很直白——给一个起始词,让模型一个词一个词往下写。它的本质是多分类:每一步都在词表里选一个词,词表有多少个词就是多少分类。

4.1 建词表与切样本

训练数据不是一句一句的,而是整个语料摊平成的一长串索引,再从这条长串上按固定长度切窗口。切法是本案例最巧妙的一处:

取值说明
输入 xcorpus_idx[i : i+32]连续 32 个词
目标 ycorpus_idx[i+1 : i+33]把输入整体右移一位

为什么右移一位就是标准答案?因为模型在每个位置都要预测「下一个词」:读到第 1 个词时该猜第 2 个,读到第 2 个词时该猜第 3 个……把这些答案排起来,正好就是原序列右移一位。一条 32 词的样本,同时提供了 32 个训练信号——这是自监督的典型做法,不需要任何人工标注,语料本身就是答案。

这就是今天大模型预训练的雏形 「输入一段文本,预测下一个词,标签由文本自身右移得到」这套范式,从这个几百行的歌词生成器,到参数以千亿计的大模型,目标函数完全一样。变的只是数据规模、模型结构(RNN 换成 Transformer)和工程手段。理解了这里,后面模块讲预训练时就不会觉得抽象。

__getitem__ 里还有一行防御性代码值得学:start = min(max(idx, 0), word_count - num_chars - 2)。它把起点夹在合法区间里,避免取到语料末尾时 y 越界。不写这一行,训练跑到最后一个批次才崩,前面几分钟白等。

4.2 三层模型与训练

模型结构只有三层,每一层的职责在前面都讲过了:

01词嵌入层

nn.Embedding(word_count, 128)。把索引翻译成 128 维向量。输入 (batch, seq_len),输出 (batch, seq_len, 128)

02循环网络层

nn.RNN(128, 128, 1)。提取上下文,把每个位置的词向量变成「带着前文记忆的表示」。

03全连接输出层

nn.Linear(128, word_count)输出维度必须等于词表大小——每个位置都要给词表里每个词打一个分。

lyrics_generator.py —— 歌词生成完整脚本:词表、数据集、模型、训练、生成完整案例
"""完整案例:用 RNN 生成歌词。

流程:建词表 -> 切成定长样本 -> 嵌入层 + RNN + 全连接 -> 训练 -> 逐词生成。
"""
import time
import jieba
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader

DATA = 'data/jaychou_lyrics.txt'


# ------------------------------------------------------------------ 词表
def build_vocab(file_name=DATA):
    unique_words, all_words = [], []

    for line in open(file_name, 'r', encoding='utf-8'):
        words = jieba.lcut(line)
        all_words.append(words)
        for word in words:
            if word not in unique_words:
                unique_words.append(word)

    word_count = len(unique_words)
    word_to_index = {word: idx for idx, word in enumerate(unique_words)}

    # 把整个语料摊平成一串索引,行与行之间用空格这个词隔开
    corpus_idx = []
    for words in all_words:
        temp = [word_to_index[word] for word in words]
        temp.append(word_to_index[' '])
        corpus_idx.extend(temp)

    return unique_words, word_to_index, word_count, corpus_idx


# ------------------------------------------------------------------ 数据集
class LyricsDataset(torch.utils.data.Dataset):
    """从语料里切定长片段:输入是第 i 到 i+n 个词,目标是整体右移一位。"""

    def __init__(self, corpus_idx, num_chars):
        self.corpus_idx = corpus_idx
        self.num_chars = num_chars
        self.word_count = len(corpus_idx)
        self.number = self.word_count // self.num_chars

    def __len__(self):
        return self.number

    def __getitem__(self, idx):
        # 夹一下起点,避免尾部越界
        start = min(max(idx, 0), self.word_count - self.num_chars - 2)
        x = self.corpus_idx[start: start + self.num_chars]
        y = self.corpus_idx[start + 1: start + 1 + self.num_chars]   # 右移一位
        return torch.tensor(x), torch.tensor(y)


# ------------------------------------------------------------------ 模型
class TextGenerator(nn.Module):
    def __init__(self, word_count, embed_dim=128, hidden_dim=128):
        super().__init__()
        self.hidden_dim = hidden_dim
        self.ebd = nn.Embedding(word_count, embed_dim)       # 词 -> 向量
        self.rnn = nn.RNN(embed_dim, hidden_dim, 1)          # 提取上下文
        self.out = nn.Linear(hidden_dim, word_count)         # 给词表里每个词打分

    def forward(self, inputs, hidden):
        # inputs: (batch, seq_len) -> embed: (batch, seq_len, embed_dim)
        embed = self.ebd(inputs)
        # nn.RNN 默认要 (seq_len, batch, embed_dim),所以转置前两维
        output, hidden = self.rnn(embed.transpose(0, 1), hidden)
        # 把时间步和 batch 压到一起再过全连接:(seq_len*batch, hidden_dim)
        output = self.out(output.reshape(-1, output.shape[-1]))
        return output, hidden

    def init_hidden(self, bs=1):
        return torch.zeros(1, bs, self.hidden_dim)


# ------------------------------------------------------------------ 训练
def train(epochs=10, seq_len=32, lr=1e-3):
    _, _, word_count, corpus_idx = build_vocab()
    lyrics = LyricsDataset(corpus_idx, seq_len)

    model = TextGenerator(word_count)
    criterion = nn.CrossEntropyLoss()
    optimizer = optim.Adam(model.parameters(), lr=lr)

    for epoch_idx in range(epochs):
        dataloader = DataLoader(lyrics, shuffle=True, batch_size=1)
        start, iter_num, total_loss = time.time(), 0, 0.0

        for x, y in dataloader:
            hidden = model.init_hidden(bs=1)
            output, hidden = model(x, hidden)

            # 目标值要和 output 的排列方式对齐:
            # y 是 (batch, seq_len) -> 转成 (seq_len, batch) -> 拉平成一维
            y = torch.transpose(y, 0, 1).contiguous().view(-1)
            loss = criterion(output, y)

            optimizer.zero_grad()
            loss.backward()
            optimizer.step()

            iter_num += 1
            total_loss += loss.item()

        print('epoch %3d  loss %.5f  time %.2fs'
              % (epoch_idx + 1, total_loss / iter_num, time.time() - start))
        torch.save(model.state_dict(), 'data/lyrics_model_%d.pth' % (epoch_idx + 1))


# ------------------------------------------------------------------ 生成
def predict(start_word, sentence_length, weight='data/lyrics_model_10.pth'):
    index_to_word, word_to_index, word_count, _ = build_vocab()

    model = TextGenerator(word_count)
    model.load_state_dict(torch.load(weight))
    model.eval()

    hidden = model.init_hidden()
    word_idx = word_to_index[start_word]      # 起始词必须在词表里,否则 KeyError
    generated = [word_idx]

    with torch.no_grad():
        for _ in range(sentence_length):
            # 每一步只送一个词,但 hidden 要一路带着走,记忆才不断
            output, hidden = model(torch.tensor([[word_idx]]), hidden)
            word_idx = torch.argmax(output).item()
            generated.append(word_idx)

    return ''.join(index_to_word[i] for i in generated)


if __name__ == '__main__':
    train()
    print(predict('分手', 50))

逐段拆解

代码位置在干什么 / 为什么这么写
if word not in unique_words保持首次出现顺序去重。用 set 更短但顺序不稳定,下次跑同一份语料索引就变了,存下来的权重会对不上。
temp.append(word_to_index[' '])在行与行之间插一个分隔词,否则上一句的末尾会和下一句的开头直接粘成一个不存在的搭配。
embed.transpose(0, 1)嵌入层输出是 (batch, seq_len, 128),而 nn.RNN 默认要 (seq_len, batch, 128)这一行就是 2.4 节那条铁律的现场
output.reshape(-1, output.shape[-1])把时间步和 batch 压平,从 (seq_len, batch, 128) 变成 (seq_len*batch, 128),再过全连接。等于把「一批句子的所有位置」当成一堆独立的多分类样本
y.transpose(0,1).contiguous().view(-1)标签也要按同样的顺序压平,必须和 output 的排列方式严格对齐transpose 之后内存不连续,所以要 contiguous() 才能 view
nn.CrossEntropyLoss()预测下一个词是多分类问题,所以用交叉熵。它内部已含 softmax,模型最后一层不要再加
hidden = model.init_hidden(bs=1)每个批次开头重新给一张空白便签。不重置的话,上一批句子的记忆会串到这一批
optim.Adam(lr=1e-3)词表大、各词频率悬殊,用梯度自适应的 Adam 比朴素 SGD 收敛快得多。
torch.save(model.state_dict(), …)每轮存一次权重。训练可能中途被打断,存盘是唯一的保险;存 state_dict 而不是整个模型对象。
这里的 batch_size=1 不是笔误,但也不是最优 脚本里 DataLoader(batch_size=1) 让每次只处理一个样本,好处是不用处理变长补齐、代码最简单,坏处是慢,而且梯度噪声大。真实项目会把 batch_size 提到 32 或 64,同时把 init_hidden(bs=batch_size) 跟着改。改 batch 时 h0 的第二维必须同步改,这是最常见的一处漏改。

怎么判断这次训练成功了

训练脚本打印的是每轮的平均交叉熵损失。判断标准不能只看它降没降,要看三件事:

  • loss 的量级是否合理。 随机初始化时,模型对词表里每个词一视同仁,此时的交叉熵约等于 ln(词表大小)。词表一万词时,起始 loss 应该在 9.2 附近——如果第一轮就远低于这个数,多半是数据泄漏或者标签对错了位;如果一直不降,检查是不是忘了 optimizer.step()
  • 生成结果是否通顺。 这是最终标准。loss 降了但生成一团糟,通常是采样策略的问题(见 4.3),不一定是模型的问题。
  • 是不是背下来了。 小语料上跑很多轮,模型会把训练文本整段背出来。loss 极低、生成「完美」,实则是过拟合。拿一个训练集里没有的起始词试一试,立刻现形

4.3 逐词生成与采样策略

生成阶段的循环只有四步,并且要一路带着同一张便签走

① 送一个词起始词转成索引
② 拿到分数词表上每个词一个分
③ 选一个词argmax 还是采样
④ 当作下一步输入hidden 继续带着走

第四步是新手最容易写错的地方:每一步只送一个词,但 hidden 必须在循环外面接住、下一轮传回去。写成每轮都 init_hidden(),模型就成了失忆症患者,每个词都从零记忆开始猜,生成结果必然是胡话。

第三步则决定了生成质量的上限。最简单的选法是 argmax——永远挑分数最高的那个词:

argmax_demo.py —— 从一排分数到选出一个词
"""argmax:把一排分数变成「选哪个词」。"""
import torch

# 假设词表只有 5 个词,模型给出这一步的 5 个分数
logits = torch.tensor([1.2, -0.3, 3.4, 0.8, 2.9])
vocab = ['我', '爱', '你', '的', '歌']

prob = torch.softmax(logits, dim=0)
print('概率分布', prob)
print('概率之和', prob.sum().item())          # 1.0

idx = torch.argmax(logits)                    # 分数最大的下标
print('选中下标', idx.item(), '对应词', vocab[idx])

# 注意:argmax 每次都挑最高分,生成出来的句子会很单调、容易循环。
# 要多样性就改成按概率采样:
sampled = torch.multinomial(prob, num_samples=1)
print('按概率采样到', vocab[sampled.item()])

argmax 有一个致命性质:它是确定性的。同样的输入永远给同样的输出,于是一旦模型进入某个「自己指向自己」的状态,就会原地打转,生成出「你你你你你」或者一句话反复循环。这不是模型没训练好,是选词方式的必然结果。

解法是引入随机性,同时控制随机的程度——这就是温度采样top-k

sampling_temperature.py —— argmax、温度采样、top-k 三种选词方式对照实验
"""采样策略:argmax、温度采样、top-k 三种选词方式的差别。

生成任务里,模型每一步给出的是「词表上的一组分数」,
真正决定生成效果的,是你怎么把这组分数变成一个词。
这一步不参与训练,纯粹是推理时的选择,改它不用重训模型。
"""
import torch


VOCAB = ['我', '爱', '你', '的', '歌', '雨', '天']
# 假设某一步模型给出这 7 个分数(logits,没过 softmax 的原始分)
LOGITS = torch.tensor([1.2, -0.3, 3.4, 0.8, 2.9, 0.1, -1.5])


def softmax_with_temperature(logits, temperature):
    """温度缩放:先把 logits 除以 T,再做 softmax。

    T < 1:差距被放大,分布更尖,输出更保守
    T = 1:原样
    T > 1:差距被压缩,分布更平,输出更发散
    """
    return torch.softmax(logits / temperature, dim=-1)


def main():
    torch.manual_seed(0)

    print('原始分数 :', LOGITS.tolist())
    prob = torch.softmax(LOGITS, dim=-1)
    print('softmax  :', [round(p, 4) for p in prob.tolist()])
    print('概率之和 :', round(prob.sum().item(), 6))    # 恒为 1.0

    # ---------------------------------------------------------- argmax
    idx = torch.argmax(LOGITS).item()
    print('\nargmax 选中 :', VOCAB[idx], '(分数最高的 3.4)')
    # argmax 是确定性的:同样的输入永远给同样的词。
    # 后果是生成的句子容易原地打转 —— 「你你你你」这种循环就是这么来的。

    # ---------------------------------------------------------- 温度
    print('\n不同温度下的概率分布:')
    print('%-6s' % 'T', ''.join('%7s' % w for w in VOCAB))
    for t in (0.5, 1.0, 1.5, 3.0):
        p = softmax_with_temperature(LOGITS, t)
        print('%-6.1f' % t, ''.join('%7.3f' % v for v in p.tolist()))
    # 看最后一行:T=3 时最高分和最低分的概率已经拉不开,
    # 生成会开始胡言乱语;T 太小又退化成 argmax。常用区间是 0.7 ~ 1.0。

    # ---------------------------------------------------------- 按概率采样
    print('\n按 T=1.0 概率采样 10 次:')
    p = softmax_with_temperature(LOGITS, 1.0)
    picks = [VOCAB[torch.multinomial(p, 1).item()] for _ in range(10)]
    print(' '.join(picks))
    # multinomial 按概率抽签:高分词抽中的次数多,但低分词也有机会,
    # 这就是生成结果有变化的来源。注意它要的是概率不是 logits。

    # ---------------------------------------------------------- top-k
    print('\ntop-k 采样(k=3):')
    k = 3
    topk_vals, topk_idx = torch.topk(LOGITS, k)
    print('候选词  :', [VOCAB[i] for i in topk_idx.tolist()])
    topk_prob = torch.softmax(topk_vals, dim=-1)        # 只在候选里重新归一化
    print('候选概率:', [round(v, 4) for v in topk_prob.tolist()])
    chosen = topk_idx[torch.multinomial(topk_prob, 1)].item()
    print('抽中    :', VOCAB[chosen])
    # top-k 的价值:先砍掉长尾里那些明显不通顺的词,再在靠谱的候选里随机,
    # 兼顾「不跑题」和「不重复」。工程上常和温度一起用。


if __name__ == '__main__':
    main()
策略怎么做确定性典型问题
argmax永远取最高分完全确定单调、易循环
温度 T < 1分数先除以 T 再 softmax接近确定T→0 退化成 argmax
温度 T = 1按原始概率抽签随机长尾里的怪词偶尔被抽中
温度 T > 1差距被压平很随机T 过大开始胡言乱语
top-k只在前 k 个候选里抽可控随机k 选太小又回到单调

工程上最常见的组合是 top-k 加温度:先用 top-k 砍掉长尾里那些明显不通顺的词,再在靠谱的候选里按温度抽签,兼顾「不跑题」和「不重复」。这三个参数都在推理阶段生效,改它们不需要重新训练模型——生成效果不好时,先调这里,比重训划算得多。

✅ 这一节和后面模块的接口 temperaturetop_ktop_p 这几个名字,后面调用大模型 API 时还会原样遇到。它们背后就是这一节讲的东西:模型永远只输出一组分数,怎么从分数变成词,是调用方的选择。

05骨架模板:拿去改就能用

一份同时覆盖「逐词生成」和「整句分类」两类序列任务的模型骨架

4.2 的歌词生成器把词表、数据集、模型、训练搅在一个文件里,适合通读,不适合改造。下面这份只保留模型部分,并把两类序列任务的分叉点标成 TODO——序列任务九成的差异都集中在那三处,其余部分一个字不用动

rnn_skeleton.py —— 序列模型骨架,只改 TODO 处可复用模板
"""序列模型骨架模板:词表、数据集、模型三件套。"""
import torch
import torch.nn as nn


class SeqModel(nn.Module):
    def __init__(self, vocab_size, embed_dim=128, hidden_dim=256,
                 num_layers=1, n_class=None):
        super().__init__()
        self.hidden_dim = hidden_dim
        self.num_layers = num_layers

        self.embedding = nn.Embedding(vocab_size, embed_dim)

        # TODO: 序列长、要记更久就把 nn.RNN 换成 nn.LSTM / nn.GRU,
        #       接口一致,LSTM 的隐藏状态是 (h, c) 两个张量
        self.rnn = nn.RNN(embed_dim, hidden_dim, num_layers, batch_first=True)

        # TODO: 逐词生成时输出维度 = 词表大小;整句分类时 = 类别数
        self.out = nn.Linear(hidden_dim, n_class or vocab_size)

    def forward(self, inputs, hidden=None):
        # inputs: (batch, seq_len) 的词索引
        x = self.embedding(inputs)               # (batch, seq_len, embed_dim)
        output, hidden = self.rnn(x, hidden)     # (batch, seq_len, hidden_dim)

        # TODO: 逐词预测保留所有时间步;整句分类只取最后一步 output[:, -1, :]
        logits = self.out(output)
        return logits, hidden

    def init_hidden(self, batch_size=1):
        return torch.zeros(self.num_layers, batch_size, self.hidden_dim)


if __name__ == '__main__':
    model = SeqModel(vocab_size=1000)
    x = torch.randint(0, 1000, (4, 10))          # 4 句话,每句 10 个词
    logits, hidden = model(x, model.init_hidden(batch_size=4))
    print(logits.shape, hidden.shape)            # (4, 10, 1000)  (1, 4, 256)

三个 TODO 分别怎么改

位置逐词生成 / 序列标注整句分类
循环层类型nn.RNN 起步,长序列换 nn.GRU同左;换 LSTM 时记得隐藏状态变成 (h, c) 元组
输出层宽度 n_class= 词表大小,每个位置在整个词表里选一个= 类别数,比如情感三分类就是 3
取哪一部分输出保留所有时间步 self.out(output)只取最后一步 output[:, -1, :]batch_first=True 时)
标签形状(batch, seq_len),算损失前压平成一维(batch,),直接用
损失函数nn.CrossEntropyLoss()同左(都是多分类)

配套的训练循环长什么样

模型之外的训练部分,和上一页那份通用骨架完全一致,五步主循环一个字不变。序列任务只多两件事要照顾:

1每批重置隐藏状态

每个 batch 开头调用 model.init_hidden(batch_size),否则上一批的记忆会串味。最后一个 batch 可能不满,用 len(bx) 动态取 batch 大小,别写死。

2加一行梯度裁剪

backward() 之后、step() 之前插入 torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)。序列越长越有必要,它专治 2.6 节那种 loss 突然变 nan

把这两条加进去,顺序就是:前向 → 算损失 → 清零 → 反向 → 裁剪 → 更新。裁剪必须夹在反向和更新之间,放在 step() 之后等于没裁,放在 backward() 之前则无梯度可裁。

✅ 模板里替你固化的四个习惯 统一写 batch_first=True,全项目只用一种布局,从源头躲开 2.4 节那条铁律 · init_hiddennum_layershidden_dim 从属性里取,改超参时不用改两处 · forwardhidden 参数默认 None,PyTorch 会自动填一张全 0 的便签,训练时可以不传、生成时再手动带着走 · 输出层宽度用 n_class or vocab_size 兜底,不传就按生成任务处理。
模板没替你做的事 没有变长序列的补齐与 pack_padded_sequence、没有验证集、没有梯度裁剪、没有 GPU 搬运。前两项是因为不同任务差别太大,需要按数据选;后两项在上面已经说明怎么加。先用最朴素的版本把数据跑通,再逐样加,一次性堆齐所有工程细节反而定位不了问题。
变长序列怎么办 一批句子长短不一时,常规做法是补齐到最长,再用 nn.utils.rnn.pack_padded_sequence 告诉 RNN「每句真实长度是多少」,让它跳过补出来的位置。不做这一步不会报错,但补位的 0 会被当成真词参与便签改写,句子越短被污染得越厉害。下一页的 Transformer 用掩码解决同一个问题。

06易错点汇总

按「词表 / 形状 / 隐藏状态 / 训练与生成」四类归并,每条都给现象和修法

⚠️ 一、词表与词嵌入

  • list(set(words)) 去重。 现象:同一份语料两次跑出来的索引不一样,旧权重加载后生成全是乱码。set 不保证顺序。修法:用「不在列表里就追加」保留首次出现顺序,或 sorted(set(words)) 强制稳定。
  • 词表没和权重一起保存。 现象:模型文件还在,但重建词表后预测结果毫无意义。模型学的是「编号 37 的向量」,不是「『分手』的向量」。修法:存 checkpoint 时把 index_to_word 一起序列化。
  • 索引越界 IndexError: index out of range in self 现象:训练好好的,预测时突然崩。多半是预测数据里有训练时没见过的词。修法:词表里留一个 <unk> 索引兜底,查表时用 word_to_index.get(w, unk_idx)
  • nn.Embedding 传了浮点张量。 现象:RuntimeError: Expected tensor for argument #1 'indices' to have scalar type Long修法:索引必须是 int64,用 torch.tensor(idx_list)(整数列表自然是 int64),别做 .float()
  • embedding_dimnn.RNNinput_size 对不上。 现象:矩阵乘法维度报错。修法:这两个数必须相等,把它抽成一个变量传给两处,别各写各的字面量。

⚠️ 二、形状与维度顺序

  • (batch, seq_len, dim) 直接丢进默认布局的 nn.RNN 现象:不报错,但 batch 被当成句长算,模型永远学不好。这是本讲最阴险的一类问题。修法:全项目统一 batch_first=True,或送进去前 transpose(0, 1),二选一并贯彻到底。
  • h0 第一维写成了 batch。 正确形状是 (num_layers, batch, hidden_size),层数在最前。现象:RuntimeError: Expected hidden size …修法:记住「h0 的第一维跟着层数走,不跟着数据走」。
  • num_layers 改了但 h0 没跟着改。 现象同上。把 nn.RNN(..., num_layers=2) 之后仍传 torch.zeros(1, batch, hidden) 就会报。修法init_hidden 里从 self.num_layers 取值,别写死 1。
  • 混淆 outputhn output 是所有时间步、只含最后一层;hn 是最后一个时间步、含所有层。整句分类误用 output 全量、逐词生成误用 hn,都会让损失算在错误的东西上。
  • transpose 之后直接 view 现象:view size is not compatible with input tensor's size and stride修法:中间补一个 .contiguous(),或改用 reshape
  • 逐词任务算交叉熵时形状没压平。 CrossEntropyLoss 要的是 (N, 类别数) 的预测和 (N,) 的标签。修法logits.reshape(-1, vocab_size)targets.reshape(-1),且两边的压平顺序必须一致

把报错现场跑一遍

上面这些条目读一遍记不住,自己触发一遍就记住了。下面这份把四类形状问题全部原地复现,用 try/except 抓住并打印报错原文,跑到底不会中断:

rnn_shapes_debug.py —— 四类形状问题的原地复现与修法排错速查
"""RNN 四类形状报错的原地复现:每一段都用 try/except 抓住,跑到底不中断。

序列模型的报错九成出在「谁在第几维」上,自己触发一遍比背十遍管用。
"""
import torch
import torch.nn as nn


def show(title):
    print('\n' + '=' * 12 + ' ' + title)


def main():
    torch.manual_seed(0)
    vocab_size, embed_dim, hidden_dim = 100, 16, 32
    batch, seq_len = 4, 7

    embedding = nn.Embedding(vocab_size, embed_dim)
    rnn_seq_first = nn.RNN(embed_dim, hidden_dim, num_layers=1)
    rnn_batch_first = nn.RNN(embed_dim, hidden_dim, num_layers=1, batch_first=True)

    tokens = torch.randint(0, vocab_size, (batch, seq_len))   # (batch, seq_len)
    x = embedding(tokens)                                     # (batch, seq_len, embed_dim)
    print('嵌入之后的形状', x.shape)

    # ---------------------------------------------------------------- 一
    show('一、batch 和 seq_len 放反')
    # nn.RNN 默认要 (seq_len, batch, input_size),直接把 (batch, seq_len, dim) 丢进去
    # 不一定报错——它会把 batch 当成句长、把句长当成 batch,静默算错
    out_wrong, _ = rnn_seq_first(x)
    print('不报错,但 output 形状是', out_wrong.shape,
          '→ 它把', batch, '当成了句长')
    out_right, _ = rnn_seq_first(x.transpose(0, 1))
    print('转置之后才对   ', out_right.shape)
    out_bf, _ = rnn_batch_first(x)
    print('或者建层时写 batch_first=True', out_bf.shape)
    # 这是本讲最阴险的一类问题:没有任何异常,只是模型永远学不好

    # ---------------------------------------------------------------- 二
    show('二、h0 的第一维写成了 batch')
    try:
        bad_h0 = torch.zeros(batch, 1, hidden_dim)     # 写反了
        rnn_seq_first(x.transpose(0, 1), bad_h0)
    except RuntimeError as e:
        print('RuntimeError:', str(e).split('\n')[0])
    good_h0 = torch.zeros(1, batch, hidden_dim)        # (num_layers, batch, hidden)
    out, hn = rnn_seq_first(x.transpose(0, 1), good_h0)
    print('正确的 h0 形状', good_h0.shape, '→ hn', hn.shape)

    # ---------------------------------------------------------------- 三
    show('三、num_layers 改了但 h0 没跟着改')
    rnn2 = nn.RNN(embed_dim, hidden_dim, num_layers=2)
    try:
        rnn2(x.transpose(0, 1), torch.zeros(1, batch, hidden_dim))
    except RuntimeError as e:
        print('RuntimeError:', str(e).split('\n')[0])
    out2, hn2 = rnn2(x.transpose(0, 1), torch.zeros(2, batch, hidden_dim))
    print('两层时 output', out2.shape, ' hn', hn2.shape,
          '→ output 永远只有最后一层,hn 才是每一层都有')

    # ---------------------------------------------------------------- 四
    show('四、逐词预测时 CrossEntropyLoss 的形状没对齐')
    head = nn.Linear(hidden_dim, vocab_size)
    logits = head(out_bf)                       # (batch, seq_len, vocab_size)
    targets = torch.randint(0, vocab_size, (batch, seq_len))
    criterion = nn.CrossEntropyLoss()
    try:
        criterion(logits, targets)              # 三维预测 + 二维标签,直接不收
    except (RuntimeError, ValueError) as e:
        print('报错:', str(e).split('\n')[0])
    # 正确做法:把 batch 和时间步压平,变成「一共 batch*seq_len 条独立的多分类」
    loss = criterion(logits.reshape(-1, vocab_size), targets.reshape(-1))
    print('压平之后可以算:', logits.reshape(-1, vocab_size).shape,
          targets.reshape(-1).shape, '→ loss 是标量:', loss.dim() == 0)


if __name__ == '__main__':
    main()
第一段才是真正危险的那一种 后三段都会抛异常,报错就能修。第一段不报错——它只是默默把 batch 当成句长算下去。训练能跑完、loss 也会降一点,只是模型学到的东西毫无意义。养成「送进 RNN 之前打印一次 x.shape」的习惯,一秒钟的事。

⚠️ 三、隐藏状态

  • 生成时每一步都重新 init_hidden() 现象:生成结果前言不搭后语,像失忆。因为每个词都从零记忆开始猜。修法hidden 要在循环外面接住、下一轮传回去,一路带着走。
  • 训练时忘了每批重置。 和上一条相反:批与批之间不重置,上一批句子的记忆会串进这一批。修法:每个 batch 开头 hidden = model.init_hidden(len(bx))
  • 改了 batch_sizeinit_hidden 里写死了 1。 现象:Expected hidden size (1, 32, 128), got (1, 1, 128)修法:batch 大小动态传入;最后一个 batch 往往不满,用 len(bx) 而不是配置里的 batch_size
  • hidden 跨批次传下去却没 detach() 现象:第二个 batch 反向传播时报 Trying to backward through the graph a second time,或者显存持续增长。因为计算图一直挂着没释放。修法:需要跨批保留记忆时写 hidden = hidden.detach(),只传数值不传图——这就是截断 BPTT。
  • 换成 LSTM 后仍写 output, hn = lstm(x) 现象:不报错,但 hn 是元组,后续当张量用时抛类型错误。修法output, (hn, cn) = lstm(x)

⚠️ 四、训练与生成

  • 模型最后一层加了 softmax。 现象:loss 降得异常慢或者卡住。nn.CrossEntropyLoss 内部已经含了 softmax,再加一层等于做了两次。修法:输出层直接返回 logits。
  • loss 突然变 nan 序列越长越容易遇到,是梯度爆炸。修法:在 backward()step() 之间插 clip_grad_norm_(model.parameters(), 1.0)注意位置:放 step() 之后等于没裁。
  • loss 降到某个值就不动,模型只会用最近几个词。 这是梯度消失,裁剪和调学习率都没用修法:换 GRU / LSTM,或者直接上下一页的 Transformer。
  • 拿 loss 绝对值判断好坏。 词表一万时,随机初始化的交叉熵约 ln(10000) ≈ 9.2;词表大小不同,可比的基线也不同。修法:先算出 ln(vocab_size) 当参照,再看降了多少;最终标准始终是生成结果是否通顺。
  • 生成结果原地打转,反复输出同一个词。 多半不是模型问题,是 argmax 的确定性导致的。修法:改用温度采样或 top-k,这一步不需要重新训练
  • 生成时忘了 model.eval()torch.no_grad() 现象:结果不稳定(Dropout 仍在生效)、显存持续增长(计算图被保留)。修法:预测函数开头 model.eval(),循环包在 with torch.no_grad(): 里。
  • 起始词不在词表里。 现象:KeyError修法:查表用 .get(word, unk_idx),或在调用前先校验并给出友好提示。

07自测题

点击题目展开答案;这 11 题都能说清楚,这一页就通了

一、序列与词嵌入
怎么判断一份数据该不该用序列模型?

问一句:把输入打乱,答案会不会变。 会变(文本、股价、语音、用户行为)就是序列数据;不会变(一张表的年龄、身高、收入三列)就不是,用全连接就够了。硬给非序列数据上 RNN,只是白白增加训练难度。

全连接网络处理文本时卡在哪两点?RNN 各自怎么解决?

① 长度不定nn.Linearin_features 建层时写死,而句子有长有短。RNN 改成一个词一个词地吃,句子多长就多循环几步,参数量不随句长变化
② 看不见顺序:全连接把所有词铺平,「我爱你」和「你爱我」只是同一堆数字的不同排列。RNN 里顺序由「谁先改写便签」天然承载。
代价是 RNN 新添了两个毛病:不能并行、远处会衰减。

one-hot 有哪三个问题?词嵌入分别怎么解决?

① 维度爆炸:长度等于词表大小,五万词就是五万维、其中 49999 个是 0 → 词嵌入用人为指定的稠密维度(常见 128)。
② 语义全丢:任意两个 one-hot 点积恒为 0,「北京」和「天安门」的相关性与「北京」和「香蕉」一样 → 词嵌入里相近的词向量距离更近。
③ 没有可学的东西:0 和 1 是人为规定死的 → 嵌入矩阵本身就是网络参数,会被反向传播更新。

nn.Embedding 的本质是什么?为什么说它是「查表」?

它的输出等价于 one-hot 向量乘嵌入矩阵,只是没人会真去乘一个 99.998% 都是 0 的矩阵——直接按行号取那一行就行了,所以叫查表层。形状规律是在输入张量最后添加一维:输入 (batch, seq_len) 的索引,输出 (batch, seq_len, embedding_dim)

二、RNN 原理
展开图上画了三个方块,是三个神经元吗?

不是,是同一个神经元的三个时刻。 就像连环画里同一个角色出现在每一格。所有时间步共用同一套 W_ih / W_hh / b_ih / b_hh,这叫参数共享,也正是「参数量与句长无关」的来源。

RNN 单元每一步吃什么、吐什么?写出那条公式。

吃两样:上一步的隐藏状态 h_t−1 和这一步的词向量 x_t吐一样:新的隐藏状态 h_t(它同时也是这一步的输出)。
公式:h_t = tanh(x_t·W_ih^T + b_ih + h_t−1·W_hh^T + b_hh)
两个加号就是全部秘密:这一步的词贡献一份 + 上一步的便签贡献一份,加起来再用 tanh 压一压

h0 一般取什么?形状是什么?为什么第一维不是 batch?

一般取全 0,表示开工前网络对这句话一无所知。形状是 (num_layers, batch, hidden_size)——第一维跟着层数走,不跟着数据走,因为每一层都需要自己的一张初始便签。把 batch 写在第一维是最常见的报错之一。

outputhn 分别是什么?什么任务用哪个?

output横着切:沿时间轴把每一步的 h 都留下来,形状 (seq_len, batch, hidden_size),但只含最后一层hn竖着切:只留最后一刻,形状 (num_layers, batch, hidden_size),但含每一层
逐词预测(文本生成、序列标注)用 output;整句判断(情感分类、意图识别)用 hn[-1]。单层网络里 output[-1]hn[0] 是同一个张量。

(batch, seq_len, dim) 直接丢进默认布局的 nn.RNN,会报错吗?

往往不报错,这才是最危险的地方。默认布局是 (seq_len, batch, input_size),只要两个数都合法,它就会把 batch 当成句长一路算下去,训练能跑完、loss 也会降一点,但模型学到的东西毫无意义。修法:全项目统一 batch_first=True,或送进去前 transpose(0, 1),二选一并贯彻到底。

三、长依赖与训练
为什么 RNN 记不住太远的话?用铁律解释一遍。

因为便签只有一张,而且每一步都被整张重写。反向传播要把每一段的导数连乘起来,每段大小约为 |W_hh·tanh′|,而 tanh 的导数最大才 1、多数区域远小于 1。单段系数 0.5 时,第 20 步只剩 9.5e−07——20 步之前的词等于完全没参与训练。这不是没训练好,是结构决定的天花板。

梯度爆炸和梯度消失,哪个能救、哪个救不了?

梯度爆炸能救:现象是 loss 突然变 nan,一行 clip_grad_norm_(model.parameters(), 1.0) 就压住了,信息本身没丢,只是步子迈太大。注意它必须夹在 backward()step() 之间。
梯度消失救不了:现象是 loss 降到平台、模型只会用最近几个词,裁剪和调学习率都无效——远处的信息压根没传过来。只能换结构:GRU / LSTM / Transformer。

LSTM 靠什么缓解梯度消失?GRU 和它怎么选?

关键不在门多,而在另开的细胞状态 c 以加法为主更新。加法的导数是 1,连乘一百个 1 还是 1,于是绕开了那串小于 1 的连乘。注意这是「可以不衰减」的通道,不是保证,超长序列上 LSTM 同样会力不从心。
选型:先上 GRU(2 个门,参数比 LSTM 少四分之一,多数任务效果持平且更快),确实需要更强长期记忆再换 LSTM(3 个门,参数是 RNN 的 4 倍)。

歌词生成的标签是怎么来的?为什么说它是今天大模型预训练的雏形?

标签就是把输入整体右移一位:读到第 1 个词时该猜第 2 个,读到第 2 个词时该猜第 3 个。一条 32 词的样本同时提供 32 个训练信号,不需要任何人工标注,语料本身就是答案
「输入一段文本、预测下一个词、标签由文本自身右移得到」这套自监督范式,从这个几百行的脚本到千亿参数大模型目标函数完全一样,变的只是数据规模、模型结构和工程手段。

生成结果反复输出同一个词,是模型没训练好吗?

多半不是。 这是 argmax 确定性的必然结果:同样输入永远给同样输出,一旦进入「自己指向自己」的状态就原地打转。修法是换选词方式——温度采样或 top-k,工程上常把两者组合:先用 top-k 砍掉长尾怪词,再按温度抽签。这些都在推理阶段生效,改它们不需要重新训练模型,比重训划算得多。

术语表

术语含义
序列数据后面的数据和前面的数据有关系、调换顺序意思就变的数据;判据是「打乱输入,答案会不会变」
RNN循环神经网络,一个词一个词地处理序列,中间带着一个会被反复重写的隐藏状态
隐藏状态 h网络对「到目前为止听到了什么」的全部记忆,是一个固定长度的向量;每一步被整张覆盖
h0初始隐藏状态,一般全 0;形状 (num_layers, batch, hidden_size),第一维是层数不是 batch
参数共享所有时间步复用同一套 W_ih / W_hh / b_ih / b_hh;这是参数量与句长无关的原因
时间步 time step序列里的一个位置;「展开」就是把循环沿时间轴摊平画出来
分词 tokenize把文本切成词或字;中文没有天然空格,工程上常用 jieba,大模型则用子词切分
词表 vocabulary去重后的词与唯一编号的映射;必须和模型权重一起保存,重建后编号变了旧权重就作废
one-hot只有一位是 1 的稀疏编码;维度等于词表大小,任意两词点积恒为 0,无法表达语义相似度
词嵌入 embedding用一串稠密小数表示一个词;本质是 one-hot 乘矩阵,实现上直接按索引取行,所以叫查表层
num_embeddings嵌入矩阵的行数 = 词表大小;索引超出这个数会抛 IndexError
embedding_dim每个词用多少维表示;必须等于 nn.RNNinput_size,常见 64 / 128 / 256
hidden_size隐藏状态的长度,也是每个时间步输出的维度;越大记得越多也越容易过拟合
batch_first决定张量布局;默认 False 时是 (seq_len, batch, input_size),与其他层的习惯相反
outputnn.RNN 的第一个返回值:所有时间步的 h,只含最后一层;逐词任务用它
hn第二个返回值:最后一个时间步的 h,含所有层;整句分类任务用它
BPTT沿时间反向传播;就是链式法则用在展开图上,梯度要连乘 seq_len 段,同一套权重收到多份梯度累加
截断 BPTT把长序列切成片段分别反传,片段之间只用 detach() 传数值不传梯度
梯度消失单段系数小于 1 时连乘趋零,远处的词等于没参与训练;裁剪和调学习率都无效,只能换结构
梯度爆炸单段系数大于 1 时连乘放大,loss 变 nan;用 clip_grad_norm_ 压住即可,须夹在反向与更新之间
门 gate用 sigmoid 算出的 0~1 向量,逐元素乘另一个向量:乘 0 关掉、乘 1 放行、乘 0.3 放行三成
LSTM三个门 + 额外的细胞状态 cc 以加法更新,给梯度留了一条不衰减的旁路;参数是 RNN 的 4 倍
GRU两个门、只有 h;参数是 RNN 的 3 倍,多数任务上与 LSTM 效果持平而更快,常作首选
自回归把上一步吐出来的词当作下一步的输入,逐词往下写
argmax永远取分数最高的词;完全确定性,容易让生成原地打转
温度 temperaturesoftmax 之前先把分数除以 T:T<1 更保守、T>1 更发散;常用区间 0.7~1.0
top-k只在分数最高的 k 个候选里按概率抽签,先砍长尾再随机;常与温度组合使用
✅ 一句话收束本页 词嵌入把汉字翻译成向量,RNN 用一张随时被重写的便签把前文带到下一步——能力和缺陷都来自「便签只有一张,每步整张重写」这一条:所以它能吃任意长度的句子、参数还不随句长增长,也所以它记不住二十步以外的话。门控给便签加了几道闸门算是缓解,真正的解法在下一页:干脆不要便签,让所有词直接互相对话。