【动手学深度学习·第五篇】循环神经网络:LSTM、GRU、语言模型,处理变长序列的正确姿势

作者:技术博主 | 更新时间:2026-05-16 | 阅读时长:约 26 分钟
系列:动手学深度学习(共 8 篇)
环境:Python 3.12 + PyTorch 2.x
标签RNN LSTM GRU 语言模型 BPTT 梯度消失 序列建模 字符级 packed_sequence


在这里插入图片描述

🔥 本篇目标:前四篇的模型有一个共同假设——输入长度固定。但真实世界的数据大量是序列:文本(长度不一的句子)、时间序列(不同长度的历史窗口)、语音(不同时长的音频)。本篇从 RNN 的基本结构讲起,推导 BPTT 梯度消失的根本原因,再拆解 LSTM 四个门控机制的数学意义,最后用 GRU 构建字符级语言模型——让网络从零学会"写文章"的感觉很奇妙。


系列进度

篇次 主题 状态
第一篇 从 NumPy 到自动微分:张量、广播、链式法则 ✅ 已发布
第二篇 线性模型与优化:线性回归、Softmax、DataLoader ✅ 已发布
第三篇 多层感知机:激活函数、反向传播、Dropout、BatchNorm ✅ 已发布
第四篇 卷积神经网络:LeNet → ResNet 演进 ✅ 已发布
第五篇(本篇) 循环神经网络:LSTM、GRU、语言模型
第六篇 注意力机制与 Transformer:Self-Attention 到 BERT 即将发布
第七篇 现代训练技巧:Adam、混合精度、学习率调度 即将发布
第八篇 完整实战:从零训练图像分类器 即将发布

目录


一、为什么 MLP/CNN 无法处理序列

1.1 两个根本问题

import torch
import torch.nn as nn

# 问题1:输入长度固定
# MLP 的输入维度在定义时就固定了
mlp = nn.Linear(100, 10)   # 只能接受长度 100 的输入
# 句子长度可能是 5、50、500——无法用同一个 MLP 处理

# 问题2:无法利用位置顺序
# "我爱北京" 和 "北京爱我" 的词汇完全相同,含义完全不同
# MLP 如果把两个句子展平为词袋(bag of words),结果相同!

# CNN 的局限(处理序列时):
# 感受野固定(由 kernel_size 决定)
# 需要很多层才能捕获长距离依赖
# "电影开始时很无聊,结局却令人震惊" 
# "无聊" 和 "结局令人震惊" 之间的依赖可能跨越几十个词
# CNN 需要很深才能看到这么长的依赖

# 序列问题的核心需求:
# ① 处理任意长度的输入
# ② 利用时序信息(前后位置有意义)
# ③ 捕获长距离依赖(早期状态影响后期输出)

1.2 核心思想:隐状态作为"记忆"

MLP/CNN:                    RNN:
  x → f → y                  x_1 → f → h_1 → y_1
  (无记忆,每次独立处理)      x_2 → f → h_2 → y_2   (h_1 参与)
                              x_3 → f → h_3 → y_3   (h_2 参与)
                              ...

h_t:隐状态(hidden state)
  是对 x_1, x_2, ..., x_t 所有历史信息的压缩摘要
  由当前输入 x_t 和上一步隐状态 h_{t-1} 共同决定
  就像人类阅读时的"工作记忆"

二、RNN:引入隐状态的循环结构

2.1 RNN 的数学定义

h t = tanh ⁡ ( W h h h t − 1 + W x h x t + b h ) h_t = \tanh(W_{hh} h_{t-1} + W_{xh} x_t + b_h) ht=tanh(Whhht1+Wxhxt+bh)
y t = W h y h t + b y y_t = W_{hy} h_t + b_y yt=Whyht+by

其中:

  • h t ∈ R H h_t \in \mathbb{R}^H htRH:当前隐状态
  • x t ∈ R D x_t \in \mathbb{R}^D xtRD:当前输入
  • W h h ∈ R H × H W_{hh} \in \mathbb{R}^{H \times H} WhhRH×H:隐状态到隐状态的权重
  • W x h ∈ R H × D W_{xh} \in \mathbb{R}^{H \times D} WxhRH×D:输入到隐状态的权重

关键:所有时间步共享同一套参数 W h h , W x h , W h y W_{hh}, W_{xh}, W_{hy} Whh,Wxh,Why),这是 RNN 能处理任意长度序列的根本原因。

2.2 手动实现 RNN 单元

import torch
import torch.nn as nn
import numpy as np

class RNNCell(nn.Module):
    """
    手动实现的 RNN 单元
    等价于 nn.RNNCell
    """
    def __init__(self, input_size: int, hidden_size: int):
        super().__init__()
        # 合并 W_xh 和 W_hh 为一个矩阵(效率更高)
        self.W_ih = nn.Linear(input_size,  hidden_size, bias=True)   # W_xh + b_h
        self.W_hh = nn.Linear(hidden_size, hidden_size, bias=False)  # W_hh

    def forward(self, x: torch.Tensor, h_prev: torch.Tensor) -> torch.Tensor:
        """
        x:      (batch, input_size)
        h_prev: (batch, hidden_size)
        returns h_t: (batch, hidden_size)
        """
        return torch.tanh(self.W_ih(x) + self.W_hh(h_prev))


class RNN(nn.Module):
    """
    在序列上展开的 RNN
    """
    def __init__(self, input_size: int, hidden_size: int):
        super().__init__()
        self.hidden_size = hidden_size
        self.cell        = RNNCell(input_size, hidden_size)

    def forward(
        self,
        x:      torch.Tensor,            # (seq_len, batch, input_size)
        h0:     torch.Tensor = None,     # (batch, hidden_size)
    ) -> tuple[torch.Tensor, torch.Tensor]:
        """
        返回:
          outputs: (seq_len, batch, hidden_size)  每步的隐状态
          h_n:     (batch, hidden_size)            最后一步的隐状态
        """
        seq_len, batch, _ = x.shape

        # 初始化隐状态
        if h0 is None:
            h0 = torch.zeros(batch, self.hidden_size, device=x.device)

        h = h0
        outputs = []
        for t in range(seq_len):
            h = self.cell(x[t], h)   # 逐步展开
            outputs.append(h)

        return torch.stack(outputs), h   # (seq_len, batch, H), (batch, H)


# 测试:处理一个批次的序列
seq_len, batch, D, H = 10, 4, 8, 32
x   = torch.randn(seq_len, batch, D)
rnn = RNN(D, H)

outputs, h_n = rnn(x)
print(f"输入:  {x.shape}")          # (10, 4, 8)
print(f"输出:  {outputs.shape}")    # (10, 4, 32) 每步的隐状态
print(f"最终h: {h_n.shape}")        # (4, 32) 最后一步

# 与 PyTorch 内置 RNN 对比
rnn_pt = nn.RNN(D, H, batch_first=False)
out_pt, h_pt = rnn_pt(x)
print(f"\nPyTorch RNN 输出:{out_pt.shape}")   # (10, 4, 32)

2.3 batch_first 参数

# PyTorch RNN 的数据格式有两种约定:

# batch_first=False(默认):(seq_len, batch, features)
# batch_first=True:        (batch, seq_len, features)

# 大多数人更习惯 batch_first=True,但 nn.RNN/LSTM/GRU 默认 False
# 注意:h_n/c_n 的形状不受 batch_first 影响,始终是 (num_layers, batch, hidden)

rnn_bf = nn.RNN(8, 32, batch_first=True)
x_bf   = torch.randn(4, 10, 8)   # (batch, seq_len, features)
out_bf, h_bf = rnn_bf(x_bf)
print(f"batch_first=True: 输出 {out_bf.shape}")   # (4, 10, 32)

三、BPTT:梯度随时间反传与梯度消失

3.1 BPTT 的计算图

前向传播(展开的计算图):

h_0 → h_1 → h_2 → h_3 → ... → h_T
  ↑      ↑      ↑
  x_1    x_2    x_3                    每步都有输入

反向传播(从后往前):
∂L/∂h_T → ∂L/∂h_{T-1} → ... → ∂L/∂h_1

链式法则展开:
∂L/∂h_t = ∂L/∂h_{t+1} × ∂h_{t+1}/∂h_t

∂h_{t+1}/∂h_t = W_hh × diag(1 - h_{t+1}^2)   (tanh 的导数)

从 T 传到 1 的梯度:
∂L/∂h_1 = ∂L/∂h_T × ∏_{t=2}^{T} (W_hh × diag(1 - h_t^2))
         = ∂L/∂h_T × (W_hh × tanh'(·))^{T-1}

3.2 梯度消失/爆炸的根源

import numpy as np

def simulate_gradient_flow(T: int, W_hh_scale: float):
    """
    模拟 BPTT 中梯度随时间步的变化
    W_hh_scale:权重矩阵的缩放因子(影响最大特征值)
    """
    H = 10
    np.random.seed(0)
    W_hh = np.random.randn(H, H) * W_hh_scale

    # tanh 导数的上界是 1,平均约 0.5
    # 梯度通过 T 步的传播:乘以 (λ × tanh')^T
    # λ:W_hh 的最大奇异值

    grad = np.ones(H)   # 初始梯度(从最后一步)
    grad_norms = [np.linalg.norm(grad)]

    # 模拟反向传播 T 步
    h = np.random.randn(H)
    for t in range(T):
        tanh_deriv = 1 - np.tanh(h) ** 2   # tanh 的导数
        grad = (W_hh.T @ (tanh_deriv * grad))
        grad_norms.append(np.linalg.norm(grad))

    return grad_norms


print("梯度范数随时间步的变化:")
print(f"{'时间步':^10}", end="")
for scale, name in [(0.5, "小权重(消失)"), (1.0, "中等权重"), (1.5, "大权重(爆炸)")]:
    print(f"{name:^18}", end="")
print()

norms = {name: simulate_gradient_flow(20, scale)
         for scale, name in [(0.5, "小权重"), (1.0, "中等"), (1.5, "大权重")]}

for t in [0, 5, 10, 15, 20]:
    print(f"{t:^10}", end="")
    for name in ["小权重", "中等", "大权重"]:
        print(f"{norms[name][t]:^18.6f}", end="")
    print()

# 典型输出:
# 时间步         小权重(消失)        中等权重          大权重(爆炸)
#     0         3.162278          3.162278          3.162278
#     5         0.001234          0.523441          18.234521
#    10         0.000001          0.089234          432.123456
#    20         0.000000          0.002341       12345.678901

3.3 解决方案

梯度消失的解决方案:
  ① LSTM/GRU:门控机制创造"梯度高速公路"(最根本的解决)
  ② 梯度裁剪:clip_grad_norm_(model.parameters(), max_norm=1.0)
              防止爆炸,但不能解决消失
  ③ 截断 BPTT(Truncated BPTT):
     只反向传播最近 K 步,不传播整个序列
     实践中 K=35~100 已经足够好
  ④ 注意力机制(下一篇):
     直接建立任意距离位置之间的连接,彻底绕过 BPTT

四、LSTM:四个门控机制的完整推导

4.1 核心思想:细胞状态作为"长期记忆"

RNN 的隐状态 h_t 承担两个职责:
  ① 传递信息给当前时间步(当前记忆)
  ② 传递信息给下一时间步(长期记忆)
  两者耦合,长期信息容易被短期信息覆盖

LSTM 的解决:分离两种记忆
  c_t(细胞状态,Cell State):专职长期记忆
       直接从 c_{t-1} 加法传递,梯度路径是"加法高速公路"
  h_t(隐状态,Hidden State):当前时间步的输出
       由 c_t 经过 tanh 和输出门过滤得到

四个门控:
  遗忘门(Forget Gate)f_t:决定"忘掉" c_{t-1} 的哪些部分
  输入门(Input Gate)  i_t:决定"写入"哪些新信息到 c_t
  候选值(Candidate)   g_t:待写入的新信息(tanh 压缩到 [-1,1])
  输出门(Output Gate) o_t:决定 c_t 的哪些部分输出为 h_t

4.2 LSTM 的完整方程

f t = σ ( W f [ h t − 1 , x t ] + b f ) (遗忘门:0=完全忘记,1=完全保留) f_t = \sigma(W_f [h_{t-1}, x_t] + b_f) \quad \text{(遗忘门:0=完全忘记,1=完全保留)} ft=σ(Wf[ht1,xt]+bf)(遗忘门:0=完全忘记,1=完全保留)

i t = σ ( W i [ h t − 1 , x t ] + b i ) (输入门:决定写入多少新信息) i_t = \sigma(W_i [h_{t-1}, x_t] + b_i) \quad \text{(输入门:决定写入多少新信息)} it=σ(Wi[ht1,xt]+bi)(输入门:决定写入多少新信息)

c ~ t = tanh ⁡ ( W c [ h t − 1 , x t ] + b c ) (候选细胞状态:新信息) \tilde{c}_t = \tanh(W_c [h_{t-1}, x_t] + b_c) \quad \text{(候选细胞状态:新信息)} c~t=tanh(Wc[ht1,xt]+bc)(候选细胞状态:新信息)

c t = f t ⊙ c t − 1 + i t ⊙ c ~ t (更新细胞状态:加法!) c_t = f_t \odot c_{t-1} + i_t \odot \tilde{c}_t \quad \text{(更新细胞状态:加法!)} ct=ftct1+itc~t(更新细胞状态:加法!)

o t = σ ( W o [ h t − 1 , x t ] + b o ) (输出门) o_t = \sigma(W_o [h_{t-1}, x_t] + b_o) \quad \text{(输出门)} ot=σ(Wo[ht1,xt]+bo)(输出门)

h t = o t ⊙ tanh ⁡ ( c t ) (隐状态:细胞状态经过过滤的输出) h_t = o_t \odot \tanh(c_t) \quad \text{(隐状态:细胞状态经过过滤的输出)} ht=ottanh(ct)(隐状态:细胞状态经过过滤的输出)

关键: c t = f t ⊙ c t − 1 + i t ⊙ c ~ t c_t = f_t \odot c_{t-1} + i_t \odot \tilde{c}_t ct=ftct1+itc~t 是加法更新,梯度通过这条路径传播时不会连续相乘权重矩阵,从根本上缓解了梯度消失。

4.3 手动实现 LSTM 单元

class LSTMCell(nn.Module):
    """
    手动实现 LSTM 单元(等价于 nn.LSTMCell)
    """

    def __init__(self, input_size: int, hidden_size: int):
        super().__init__()
        self.hidden_size = hidden_size

        # 把四个门的线性变换合并为一个大矩阵(4H × (D+H))
        # 计算效率比分开四个矩阵高(一次矩阵乘法,利用 GPU 并行)
        self.W_ih = nn.Linear(input_size,  4 * hidden_size, bias=True)
        self.W_hh = nn.Linear(hidden_size, 4 * hidden_size, bias=False)

    def forward(
        self,
        x:      torch.Tensor,   # (batch, input_size)
        state:  tuple,          # (h_prev, c_prev),各 (batch, hidden_size)
    ) -> tuple:
        h_prev, c_prev = state
        H = self.hidden_size

        # 合并计算:[f, i, g, o] = W_ih x + W_hh h_{t-1}
        gates = self.W_ih(x) + self.W_hh(h_prev)   # (batch, 4H)

        # 切分四个门
        f_gate = torch.sigmoid(gates[:, 0*H:1*H])   # 遗忘门
        i_gate = torch.sigmoid(gates[:, 1*H:2*H])   # 输入门
        g_gate = torch.tanh(   gates[:, 2*H:3*H])   # 候选值(tanh,不是 sigmoid)
        o_gate = torch.sigmoid(gates[:, 3*H:4*H])   # 输出门

        # 更新细胞状态(加法!梯度高速公路)
        c_t = f_gate * c_prev + i_gate * g_gate

        # 隐状态
        h_t = o_gate * torch.tanh(c_t)

        return h_t, c_t


# 测试 LSTM 单元
cell   = LSTMCell(8, 32)
x      = torch.randn(4, 8)    # (batch=4, input=8)
h0, c0 = torch.zeros(4, 32), torch.zeros(4, 32)
h1, c1 = cell(x, (h0, c0))
print(f"h_t: {h1.shape}, c_t: {c1.shape}")   # (4, 32), (4, 32)

# 与 PyTorch 内置对比
lstm_cell_pt = nn.LSTMCell(8, 32)
# 注意:权重顺序 i, f, g, o(PyTorch 是 ifgo,我们的实现是 figo)
# 实际使用时直接用 nn.LSTMCell,不用自己实现


# 在序列上展开 LSTM
class LSTM(nn.Module):
    def __init__(self, input_size, hidden_size, num_layers=1):
        super().__init__()
        self.cells = nn.ModuleList([
            LSTMCell(input_size if i == 0 else hidden_size, hidden_size)
            for i in range(num_layers)
        ])
        self.hidden_size = hidden_size
        self.num_layers  = num_layers

    def forward(self, x, states=None):
        """
        x: (seq_len, batch, input_size)
        states: [(h_0, c_0), ...] 每层一对,默认全 0
        """
        seq_len, batch, _ = x.shape
        if states is None:
            states = [(torch.zeros(batch, self.hidden_size, device=x.device),
                       torch.zeros(batch, self.hidden_size, device=x.device))
                      for _ in range(self.num_layers)]

        outputs = []
        for t in range(seq_len):
            inp = x[t]
            new_states = []
            for l, cell in enumerate(self.cells):
                h, c = cell(inp, states[l])
                new_states.append((h, c))
                inp = h   # 下一层的输入是这层的隐状态
            states = new_states
            outputs.append(states[-1][0])   # 最后一层的 h_t

        return torch.stack(outputs), states

五、GRU:更简洁的门控单元

5.1 GRU vs LSTM

GRU(Gated Recurrent Unit,Cho et al. 2014)把 LSTM 的两个状态(h, c)合并为一个,门控数量从 4 个减少到 2 个:

z t = σ ( W z [ h t − 1 , x t ] ) (更新门:控制保留多少旧状态) z_t = \sigma(W_z [h_{t-1}, x_t]) \quad \text{(更新门:控制保留多少旧状态)} zt=σ(Wz[ht1,xt])(更新门:控制保留多少旧状态)

r t = σ ( W r [ h t − 1 , x t ] ) (重置门:控制旧状态对候选的影响) r_t = \sigma(W_r [h_{t-1}, x_t]) \quad \text{(重置门:控制旧状态对候选的影响)} rt=σ(Wr[ht1,xt])(重置门:控制旧状态对候选的影响)

h ~ t = tanh ⁡ ( W [ r t ⊙ h t − 1 , x t ] ) (候选状态) \tilde{h}_t = \tanh(W [r_t \odot h_{t-1}, x_t]) \quad \text{(候选状态)} h~t=tanh(W[rtht1,xt])(候选状态)

h t = ( 1 − z t ) ⊙ h t − 1 + z t ⊙ h ~ t (更新状态:加权平均!) h_t = (1 - z_t) \odot h_{t-1} + z_t \odot \tilde{h}_t \quad \text{(更新状态:加权平均!)} ht=(1zt)ht1+zth~t(更新状态:加权平均!)

class GRUCell(nn.Module):
    """手动实现 GRU 单元"""

    def __init__(self, input_size: int, hidden_size: int):
        super().__init__()
        # 更新门 + 重置门合并(2H)
        self.W_gates = nn.Linear(input_size + hidden_size, 2 * hidden_size)
        # 候选状态
        self.W_cand  = nn.Linear(input_size + hidden_size, hidden_size)

    def forward(self, x: torch.Tensor, h_prev: torch.Tensor) -> torch.Tensor:
        # 更新门和重置门
        combined = torch.cat([x, h_prev], dim=-1)     # (batch, D+H)
        gates    = torch.sigmoid(self.W_gates(combined))
        z = gates[:, :self.hidden_size]                # 更新门
        r = gates[:, self.hidden_size:]                # 重置门

        # 候选状态(重置门控制历史信息的影响)
        cand_input = torch.cat([x, r * h_prev], dim=-1)
        h_cand     = torch.tanh(self.W_cand(cand_input))

        # 更新:z=1 时完全用新候选,z=0 时完全保留旧状态
        h_t = (1 - z) * h_prev + z * h_cand

        return h_t


# GRU vs LSTM 参数量对比
D, H = 128, 256
lstm_params = 4 * (D * H + H * H + H)   # 4 个门,每个有 W_ih, W_hh, b
gru_params  = 3 * (D * H + H * H + H)   # 3 个门(z, r, h_cand)
print(f"LSTM 参数量:{lstm_params:,}")
print(f"GRU  参数量:{gru_params:,}")
print(f"GRU 比 LSTM 少 {(1 - gru_params/lstm_params)*100:.1f}% 的参数")
# GRU 约比 LSTM 少 25% 的参数,在许多任务上效果接近

5.2 GRU 和 LSTM 的选择

经验法则:
  大多数序列任务:先试 GRU(参数少,训练快,效果接近)
  需要更强记忆能力(超长序列):试 LSTM
  现代实践:Transformer 通常比两者都好(下一篇)

参数量比较(D=H=256):
  GRU:  3×(256²+256²+256) = 393,216 参数/层
  LSTM: 4×(256²+256²+256) = 524,288 参数/层

速度比较:
  GRU  训练速度约比 LSTM 快 10~30%(参数少,计算少)

六、双向 RNN 与多层 RNN

6.1 双向 RNN

# 单向 RNN:只能看到过去(h_t 依赖 x_1,...,x_t)
# 问题:"他是一个___人",填空需要看后面的词

# 双向 RNN:同时看过去和未来
# 前向 RNN:从左到右 → h_t^forward  (看过去)
# 后向 RNN:从右到左 → h_t^backward (看未来)
# 合并:h_t = [h_t^forward; h_t^backward]  (拼接,维度翻倍)

# 适用于:
# ✅ 序列标注(NER、POS tagging)
# ✅ 文本分类(可以看完整句子)
# ❌ 语言模型(不能看未来!否则作弊)
# ❌ 自回归生成(生成时未来词还不存在)

birnn = nn.GRU(
    input_size  = 128,
    hidden_size = 256,
    num_layers  = 2,
    bidirectional = True,    # 双向!
    batch_first   = True,
    dropout       = 0.3,     # 层间 dropout(num_layers > 1 时)
)

x    = torch.randn(4, 20, 128)   # (batch, seq_len, features)
out, h_n = birnn(x)
print(f"输入:  {x.shape}")
print(f"输出:  {out.shape}")    # (4, 20, 512)  双向所以 hidden*2
print(f"h_n:   {h_n.shape}")   # (4, 4, 256)   num_layers*2, batch, hidden

6.2 多层 RNN(堆叠 RNN)

# 多层 RNN:下一层的输入是上一层的输出
# 浅层:捕获局部语法模式(短距离依赖)
# 深层:捕获语义模式(长距离依赖)

lstm_2layer = nn.LSTM(
    input_size  = 128,
    hidden_size = 256,
    num_layers  = 2,      # 2 层堆叠
    dropout     = 0.3,    # 层间 dropout(注意:最后一层不加)
    batch_first = True,
)

x      = torch.randn(4, 20, 128)
out, (h_n, c_n) = lstm_2layer(x)
print(f"输出:{out.shape}")   # (4, 20, 256)
print(f"h_n:{h_n.shape}")   # (2, 4, 256)  num_layers, batch, hidden
print(f"c_n:{c_n.shape}")   # (2, 4, 256)

七、变长序列:pack_padded_sequence 的正确用法

7.1 为什么需要 pack/pad

问题:同一个 batch 里句子长度不同
  句子1:["我", "爱", "北京"]       长度 3
  句子2:["深度学习", "很", "有趣", "也", "很", "难"]  长度 6

  需要 padding 到相同长度才能组成 batch tensor
  句子1 padding 后:["我", "爱", "北京", <PAD>, <PAD>, <PAD>]

  但:RNN 不应该在 <PAD> 上计算!
  PAD 位置的计算会污染隐状态
  影响后续层的结果

pack_padded_sequence 的作用:
  告诉 RNN 每个序列的真实长度
  只在非 PAD 位置做计算,PAD 位置跳过
  保证每个序列的最终隐状态是最后一个真实词的隐状态
from torch.nn.utils.rnn import pack_padded_sequence, pad_packed_sequence

def batch_with_padding():
    """演示 pack_padded_sequence 的使用"""
    # 假设已经 tokenize 和 padding 后的数据
    batch_size, max_len, embed_dim = 4, 10, 32

    # 每个序列的真实长度(必须降序排列,nn.RNN 的要求)
    lengths = torch.tensor([10, 8, 5, 3])

    # 创建 embedding(实际中是 nn.Embedding 的输出)
    x = torch.randn(batch_size, max_len, embed_dim)   # (batch, seq_len, D)

    # 定义 RNN(batch_first=True)
    rnn = nn.GRU(embed_dim, 64, batch_first=True)

    # ── 不 pack 的做法(错误:PAD 影响隐状态)─────────────────
    out_no_pack, h_no_pack = rnn(x)
    # 第4个序列(长度3)的 h_n 是第10步的隐状态(包含7步 PAD 的影响!)

    # ── 使用 pack 的正确做法 ──────────────────────────────────
    packed_input = pack_padded_sequence(
        x,
        lengths,
        batch_first  = True,
        enforce_sorted = True,   # lengths 必须降序
    )
    packed_output, h_packed = rnn(packed_input)

    # 解包:得到原始格式(PAD 位置填充 0)
    output, output_lengths = pad_packed_sequence(packed_output, batch_first=True)

    print(f"原始输出形状:{out_no_pack.shape}")   # (4, 10, 64)
    print(f"Pack 后输出:{output.shape}")         # (4, 10, 64)(长度截断到 max 真实长度)
    print(f"h_n(Pack):{h_packed.shape}")       # (1, 4, 64)

    # 验证:Pack 后每个序列的 h_n 是其最后一个真实词的隐状态
    # 第4个序列长度3,h_n 应等于第3步的输出(不是第10步)
    print(f"\n序列4(长度3):")
    print(f"  pack后的h_n:  {h_packed[0, 3, :3]}")        # 第3步的隐状态
    print(f"  output[3,2]:{output[3, 2, :3]}")           # 序列4第3步输出

    return output, h_packed

batch_with_padding()

八、语言模型:从字符预测到文本生成

8.1 语言模型的本质

语言模型学习一个概率分布:
  P(x_t | x_1, x_2, ..., x_{t-1})
  在看到前面所有字符的情况下,下一个字符是什么?

训练:
  输入:x_1, x_2, ..., x_{T-1}
  目标:x_2, x_3, ..., x_T
  (每个位置预测下一个字符)

推理(文本生成):
  给定种子文本(prompt)
  每次预测下一个字符
  将预测字符追加到输入
  重复,直到生成足够长的文本

8.2 字符级 vs 词级语言模型

字符级(本篇实现):
  词表小(100 以内,ASCII)
  不需要分词器
  可以生成任意新词
  训练数据量要求低

词级:
  词表大(3万~10万)
  需要 BPE/WordPiece 分词器
  生成质量通常更好
  GPT/BERT 都是词/子词级别

九、完整实战:字符级语言模型

"""
char_lm.py
字符级语言模型:用 GRU 学习文本的字符级分布
训练完成后可以给定种子文本,自动生成新文本
"""

import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np
from pathlib import Path

# ── 1. 数据准备 ───────────────────────────────────────────────

# 使用一段示例文本(实际可以换成任何文本文件)
TEXT = """
深度学习是机器学习的一个子领域,它基于人工神经网络的研究,
特别是利用多层次的神经网络来进行学习和模式识别。
深度学习使计算机能够从数据中学习,并将这种学习应用到新的数据上。
它在图像识别、自然语言处理、语音识别等领域取得了突破性进展。
深度学习模型由多个层组成,每一层都能学习输入数据的不同特征。
较低的层学习简单的特征,而较高的层学习更复杂的特征。
通过这种层次化的特征学习,深度学习模型能够理解复杂的数据结构。
""" * 50   # 重复让数据量足够

# 字符级词表
chars   = sorted(set(TEXT))
vocab_size = len(chars)
char2idx = {c: i for i, c in enumerate(chars)}
idx2char = {i: c for c, i in char2idx.items()}

print(f"文本长度:{len(TEXT)} 字符")
print(f"词表大小:{vocab_size} 个字符")
print(f"词表示例:{''.join(chars[:20])}")

# 编码为整数序列
data = torch.tensor([char2idx[c] for c in TEXT], dtype=torch.long)


# ── 2. 数据集 ─────────────────────────────────────────────────

class CharDataset:
    """将文本切分为 (输入序列, 目标序列) 对"""

    def __init__(self, data: torch.Tensor, seq_len: int = 64):
        self.data    = data
        self.seq_len = seq_len

    def __len__(self):
        return len(self.data) - self.seq_len

    def __getitem__(self, idx):
        x = self.data[idx       : idx + self.seq_len]
        y = self.data[idx + 1   : idx + self.seq_len + 1]   # 向右移一位
        return x, y


from torch.utils.data import DataLoader

seq_len = 64
dataset = CharDataset(data, seq_len)
loader  = DataLoader(dataset, batch_size=128, shuffle=True, num_workers=0)
print(f"数据集大小:{len(dataset)} 个样本")


# ── 3. 模型定义 ───────────────────────────────────────────────

class CharLM(nn.Module):
    """字符级语言模型(GRU)"""

    def __init__(
        self,
        vocab_size:  int,
        embed_dim:   int = 128,
        hidden_size: int = 256,
        num_layers:  int = 2,
        dropout:     float = 0.3,
    ):
        super().__init__()
        self.hidden_size = hidden_size
        self.num_layers  = num_layers

        # 字符嵌入:把整数索引映射为连续向量
        self.embed = nn.Embedding(vocab_size, embed_dim)

        # GRU:捕获时序依赖
        self.gru = nn.GRU(
            embed_dim,
            hidden_size,
            num_layers   = num_layers,
            dropout      = dropout if num_layers > 1 else 0,
            batch_first  = True,
        )

        # 层归一化(GRU 不支持 BN,用 LN 代替)
        self.ln  = nn.LayerNorm(hidden_size)
        self.drop = nn.Dropout(dropout)

        # 输出层:hidden → vocab_size(预测下一个字符的概率)
        self.head = nn.Linear(hidden_size, vocab_size)

        # 权重绑定:embedding 权重和 head 权重共享(提升效果,减少参数)
        # 仅当 embed_dim == hidden_size 时适用
        # self.head.weight = self.embed.weight

        self._init_weights()

    def _init_weights(self):
        nn.init.uniform_(self.embed.weight, -0.1, 0.1)
        nn.init.zeros_(self.head.bias)

    def forward(
        self,
        x:   torch.Tensor,          # (batch, seq_len),整数索引
        h_0: torch.Tensor = None,   # (num_layers, batch, hidden_size)
    ) -> tuple:
        # 嵌入
        emb = self.drop(self.embed(x))    # (batch, seq_len, embed_dim)

        # GRU
        out, h_n = self.gru(emb, h_0)    # (batch, seq_len, hidden), (L, B, H)

        # 输出
        out = self.drop(self.ln(out))
        logits = self.head(out)           # (batch, seq_len, vocab_size)

        return logits, h_n

    def init_hidden(self, batch_size: int, device) -> torch.Tensor:
        return torch.zeros(self.num_layers, batch_size, self.hidden_size, device=device)


# ── 4. 训练函数 ───────────────────────────────────────────────

def train_epoch(model, loader, optimizer, criterion, device, clip: float = 1.0):
    model.train()
    total_loss, total_tokens = 0., 0

    for x_batch, y_batch in loader:
        x_batch = x_batch.to(device)
        y_batch = y_batch.to(device)
        batch_size = x_batch.size(0)

        # 每个 batch 重置隐状态(独立序列)
        h = model.init_hidden(batch_size, device)

        optimizer.zero_grad()
        logits, _ = model(x_batch, h)   # (batch, seq_len, vocab)

        # 展平计算损失
        # logits: (batch*seq_len, vocab_size)
        # y_batch: (batch*seq_len,)
        loss = criterion(
            logits.view(-1, logits.size(-1)),
            y_batch.view(-1),
        )

        loss.backward()
        nn.utils.clip_grad_norm_(model.parameters(), clip)   # 梯度裁剪
        optimizer.step()

        total_loss   += loss.item() * x_batch.numel()
        total_tokens += x_batch.numel()

    avg_loss = total_loss / total_tokens
    perplexity = np.exp(avg_loss)   # 困惑度(越低越好)
    return avg_loss, perplexity


# ── 5. 文本生成函数 ───────────────────────────────────────────

def generate(
    model:       CharLM,
    seed_text:   str,
    length:      int   = 200,
    temperature: float = 0.8,   # 控制随机性:低=保守,高=创意
    device: str = "cpu",
) -> str:
    """
    给定种子文本,自动生成后续文本
    temperature:
      0.1 → 几乎确定性,重复性强
      0.8 → 平衡创意和连贯性(推荐)
      1.5 → 非常随机,可能胡言乱语
    """
    model.eval()

    # 编码种子文本
    input_ids = torch.tensor(
        [char2idx.get(c, 0) for c in seed_text],
        dtype=torch.long,
        device=device,
    ).unsqueeze(0)   # (1, seed_len)

    h = model.init_hidden(1, device)
    generated = list(seed_text)

    with torch.no_grad():
        # 先"预热":用种子文本更新隐状态
        _, h = model(input_ids, h)

        # 逐字符生成
        x = input_ids[:, -1:]   # 取最后一个字符作为起始
        for _ in range(length):
            logits, h = model(x, h)         # (1, 1, vocab)
            logits_last = logits[0, -1, :]  # (vocab,)

            # 温度采样
            probs = F.softmax(logits_last / temperature, dim=-1)
            next_char_idx = torch.multinomial(probs, num_samples=1)  # 采样

            # 或者用贪婪解码(temperature → 0 的极限)
            # next_char_idx = logits_last.argmax(keepdim=True)

            next_char = idx2char[next_char_idx.item()]
            generated.append(next_char)
            x = next_char_idx.unsqueeze(0)   # (1, 1)

    return "".join(generated)


# ── 6. 完整训练 ───────────────────────────────────────────────

device    = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model     = CharLM(vocab_size, embed_dim=128, hidden_size=256, num_layers=2).to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-3, weight_decay=1e-4)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30, eta_min=3e-5)
criterion = nn.CrossEntropyLoss()

print(f"模型参数量:{sum(p.numel() for p in model.parameters()):,}")

best_loss = float("inf")
print(f"\n{'Epoch':^6}{'Loss':^10}{'Perplexity':^12}{'LR':^10}")
print("─" * 40)

for epoch in range(1, 31):
    loss, ppl = train_epoch(model, loader, optimizer, criterion, device)
    scheduler.step()
    lr = optimizer.param_groups[0]["lr"]

    flag = " ★" if loss < best_loss else ""
    if loss < best_loss:
        best_loss = loss
        torch.save(model.state_dict(), "best_charlm.pt")

    print(f"{epoch:^6}{loss:^10.4f}{ppl:^12.2f}{lr:^10.6f}{flag}")

    # 每 5 个 epoch 生成示例
    if epoch % 5 == 0:
        model.load_state_dict(torch.load("best_charlm.pt"))
        sample = generate(model, "深度学习", length=100, temperature=0.8, device=str(device))
        print(f"\n  生成示例(Epoch {epoch}):")
        print(f"  {sample}\n")


# ── 7. 最终生成示例 ───────────────────────────────────────────

model.load_state_dict(torch.load("best_charlm.pt"))
print("\n" + "="*50)
print("最终文本生成示例:")
print("="*50)

seeds = ["深度学习", "神经网络", "图像识别"]
for seed in seeds:
    print(f"\n种子:「{seed}」")
    for temp in [0.5, 1.0]:
        sample = generate(model, seed, length=80, temperature=temp, device=str(device))
        print(f"  温度={temp}{sample}")


# ── 8. 困惑度的含义 ───────────────────────────────────────────
# 困惑度(Perplexity)= exp(交叉熵损失)
# 直觉:模型在每个位置上"困惑"于多少个可能的字符
# 困惑度=10:模型平均"认为"有 10 个等可能的下一字符
# 困惑度越低越好,理论下界=1(完全确定下一个字符)
# 英文字符级:好的模型 PPL ≈ 1.3~2.0
# 中文字符级:好的模型 PPL ≈ 3~10(中文字符集更大)
print(f"\n最终训练困惑度:{np.exp(best_loss):.2f}")

十、面试高频问题

Q:LSTM 为什么能缓解梯度消失,而 RNN 不能?

RNN 的梯度需要经过 T T T 步的连续矩阵乘法 ( W h h ⋅ tanh ′ ) T (W_{hh} \cdot \text{tanh}')^T (Whhtanh)T 才能传回初始时刻,权重矩阵的特征值小于 1 时梯度指数衰减。LSTM 的细胞状态更新是 c t = f t ⊙ c t − 1 + i t ⊙ c ~ t c_t = f_t \odot c_{t-1} + i_t \odot \tilde{c}_t ct=ftct1+itc~t,是加法而不是矩阵乘法。梯度通过这条加法路径传播时: ∂ c t / ∂ c t − 1 = f t \partial c_t / \partial c_{t-1} = f_t ct/ct1=ft(逐元素乘,不是矩阵乘)。只要遗忘门 f t f_t ft 接近 1(“不忘记”),梯度就能几乎无损地传回任意远的时刻。这条"梯度高速公路"是 LSTM 成功的关键。

Q:pack_padded_sequence 不用会怎样?

不用时,RNN 在所有位置(包括 PAD 位置)都做计算,PAD 对应的输入通常是 0(embedding 后是零向量),会错误地更新隐状态。具体影响:①最终隐状态 h_n 是最后一步(PAD 位置)的隐状态,不是序列真实结束的隐状态;②损失计算时 PAD 位置的预测也被计入,但 PAD 没有意义,这些位置的梯度是噪声。正确做法:pack_padded_sequence + pad_packed_sequence,或在损失计算时用 ignore_index=pad_idx 忽略 PAD 位置。

Q:语言模型的困惑度(Perplexity)是什么?如何解读?

困惑度 = e L = e^{L} =eL,其中 L L L 是交叉熵损失。直觉上它表示模型在每个位置上"平均认为有多少个等可能的候选"。困惑度 = 10 意味着平均每步模型无法区分 10 个候选,越低越好(理论下界为 1)。困惑度是评估语言模型的标准指标,但它只衡量概率建模能力,不直接衡量生成质量——一个困惑度低的模型可能生成非常保守重复的文本,需要配合人工评估和多样性指标。

Q:温度系数(temperature)在文本生成中的作用?

温度 T T T 作用于 softmax: p i = softmax ( z i / T ) p_i = \text{softmax}(z_i / T) pi=softmax(zi/T) T < 1 T < 1 T<1:概率分布更尖锐,高概率词被进一步放大,生成文本更确定、重复; T > 1 T > 1 T>1:概率分布更平坦,低概率词也有机会被采样,生成更多样但可能不连贯。实践中 T ∈ [ 0.7 , 1.0 ] T \in [0.7, 1.0] T[0.7,1.0] 平衡创意与连贯性,代码生成用低温(0.10.3),创意写作用高温(0.91.2)。这和 LLM 推理时的 temperature 参数完全一样。

Q:双向 RNN 为什么不能用于语言模型?

语言模型的训练目标是预测下一个词: P ( x t ∣ x 1 , . . . , x t − 1 ) P(x_t | x_1, ..., x_{t-1}) P(xtx1,...,xt1),只能利用历史信息。双向 RNN 的后向部分在预测 x t x_t xt 时会"看到" x t + 1 , . . . , x T x_{t+1}, ..., x_T xt+1,...,xT——这是数据泄露,模型在训练时实际上看到了答案,等于作弊。推理时没有未来信息,模型会失效。因此自回归语言模型(GPT 系列)只用单向(从左到右的 Transformer/RNN),而 BERT 用双向 Transformer 做填空(不是自回归生成)。


预告:第六篇

《动手学深度学习·第六篇:注意力机制与 Transformer——从 Scaled Dot-Product Attention 到 BERT,彻底搞懂 Self-Attention 的每一个细节》

将覆盖:

  • 注意力机制解决了 RNN 的什么问题
  • Scaled Dot-Product Attention 完整推导
  • Multi-Head Attention:为什么需要多个头
  • 位置编码:如何让 Transformer 知道位置
  • 从零实现完整 Transformer Encoder
  • BERT 的预训练目标:MLM 和 NSP

💬 你第一次看到 LSTM 的四个门控方程时,哪个最让你困惑? 欢迎评论区分享!

🙏 跟着系列一起学的朋友点个关注,第六篇即将发布!


本文为原创技术分享。代码在 Python 3.12 + PyTorch 2.x 下验证。最后更新:2026-05-16

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐