人类语言天生具有顺序性:一句话的意思取决于词语的先后顺序,前面的词会影响后面的理解。传统的前馈神经网络(
Feedforward Neural
Network)无法处理这种顺序依赖,因为它们只能接受固定大小的输入,并且每次计算都是独立的。那么,如何让神经网络拥有"记忆",能够理解序列中的上下文信息?
循环神经网络( Recurrent Neural Network,
RNN)正是为此而生。它通过在网络中引入循环连接,使得当前时刻的输出不仅依赖于当前输入,还依赖于之前时刻的隐藏状态。这种设计让
RNN
能够处理任意长度的序列数据,在机器翻译、语音识别、文本生成等任务中大放异彩。
但 RNN
并非完美无缺。梯度消失和梯度爆炸问题限制了它对长序列的建模能力,直到
LSTM 和 GRU 的出现才真正解决了这一难题。本文将从 RNN
的基本原理出发,深入探讨梯度问题的根源与解决方案,详细剖析 LSTM 和 GRU
的门控机制,并通过 PyTorch
实现文本生成器和简单的机器翻译系统,带你全面掌握序列建模的核心技术。
RNN 基本原理
循环结构的直觉
想象你在阅读一本推理小说。每读完一句话,你不会忘记之前的情节,而是将新信息与已有记忆结合起来,不断更新对故事的理解。
RNN
的工作方式与此类似:它在处理序列的每个元素时,都会维护一个"隐藏状态"(
hidden state),用来存储之前看到的信息。
传统前馈网络的计算可以表示为:
$$
y = f(Wx + b) $$
其中 是输入, 是权重矩阵, 是偏置,
是激活函数。这个计算过程是无记忆的,每次都是独立的。
而 RNN 在此基础上增加了循环连接,使得当前时刻的隐藏状态 不仅依赖于当前输入 ,还依赖于上一时刻的隐藏状态 :
$$
h_t = f(W_{hh}h_{t-1} + W_{xh}x_t + b_h) $$
其中:
是时刻 的隐藏状态
是上一时刻的隐藏状态
是当前输入
是隐藏状态到隐藏状态的权重矩阵
是输入到隐藏状态的权重矩阵
是偏置向量
通常是 或 激活函数
在每个时刻, RNN 还会根据隐藏状态产生输出:
$$
y_t = W_{hy}h_t + b_y $$
参数共享的优势
RNN
的一个关键特性是参数共享 :无论序列有多长,所有时刻都使用相同的权重矩阵
、 和 。这种设计有三大优势:
1. 可处理变长序列
由于所有时刻共享参数, RNN
不需要为不同长度的输入设计不同的网络结构。无论输入是 5 个词还是 500
个词,都使用同一套权重。
2. 参数量大幅减少
如果用全连接网络处理长度为 100
的序列,需要为每个位置单独设置权重,参数量会随序列长度线性增长。而 RNN
的参数量只与隐藏层维度有关,与序列长度无关。
3. 位置无关的模式识别
共享参数意味着 RNN 在序列的不同位置学到的是同一种模式。例如,识别"not
good"这种否定短语时,无论它出现在句首还是句尾, RNN
都能用同样的权重来捕捉这个模式。
RNN 的展开视角
理解 RNN
的一个有效方式是将循环连接"展开"成时间维度的计算图。假设我们要处理长度为
的序列 ,展开后的计算过程为:
这里
是初始隐藏状态,通常初始化为零向量。展开后的 RNN
看起来像一个很深的前馈网络,但所有"层"之间共享相同的权重。这种视角对于理解反向传播和梯度问题至关重要。
基础 RNN 的 PyTorch 实现
让我们用 PyTorch 从零实现一个简单的 RNN:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 import torchimport torch.nn as nnclass SimpleRNN (nn.Module): def __init__ (self, input_size, hidden_size, output_size ): super (SimpleRNN, self).__init__() self.hidden_size = hidden_size self.W_xh = nn.Linear(input_size, hidden_size) self.W_hh = nn.Linear(hidden_size, hidden_size) self.W_hy = nn.Linear(hidden_size, output_size) self.tanh = nn.Tanh() def forward (self, x, h_prev=None ): """ x: (batch_size, seq_len, input_size) h_prev: (batch_size, hidden_size) 或 None """ batch_size, seq_len, _ = x.size() if h_prev is None : h_prev = torch.zeros(batch_size, self.hidden_size).to(x.device) outputs = [] h_t = h_prev for t in range (seq_len): x_t = x[:, t, :] h_t = self.tanh(self.W_xh(x_t) + self.W_hh(h_t)) y_t = self.W_hy(h_t) outputs.append(y_t.unsqueeze(1 )) outputs = torch.cat(outputs, dim=1 ) return outputs, h_t input_size = 10 hidden_size = 20 output_size = 5 seq_len = 15 batch_size = 32 model = SimpleRNN(input_size, hidden_size, output_size) x = torch.randn(batch_size, seq_len, input_size) outputs, final_hidden = model(x) print (f"输出形状: {outputs.shape} " ) print (f"最终隐藏状态形状: {final_hidden.shape} " )
这个实现清晰地展示了 RNN
的核心思想:通过循环更新隐藏状态,将序列信息逐步编码到固定维度的向量中。
梯度消失与梯度爆炸
问题的根源
尽管 RNN 在理论上可以处理任意长度的序列,但在实践中训练长序列的 RNN
极其困难。主要原因是梯度消失 ( Vanishing
Gradients)和梯度爆炸 ( Exploding Gradients)问题。
为了理解这些问题,需要看看 RNN 的反向传播过程。在展开的 RNN
中,假设我们要计算损失 对第
时刻隐藏状态 的梯度,根据链式法则:
由于隐藏状态的递推关系 ,每一步的梯度都包含
和激活函数的导数:
这里
是激活函数导数构成的对角矩阵。对于 激活函数,导数范围是 ;对于 ,导数范围是 。
梯度消失:渐进式数学推导
让我们通过一个具体例子,逐步理解梯度消失的数学本质。
问题设定 :考虑一个简单的 RNN,隐藏状态更新为:
$$
h_t = (W_{hh}h_{t-1} + W_{xh}x_t + b_h) $$
我们要计算损失 对第
时刻隐藏状态 的梯度如何传递到更早的时刻 。
步骤 1:单步梯度传递
根据链式法则, 对 的梯度为:
应用导数法则:
其中
是激活函数导数构成的对角矩阵。
步骤 2:
导数的范围
函数的导数为:
其值域为 。具体地:
-3
-0.995
0.010
-1
-0.762
0.420
0
0
1.000
1
0.762
0.420
3
0.995
0.010
可以看到,除了 附近,大部分区域的导数都远小于 1 。
步骤 3:多步梯度连乘
当梯度从时刻 回传到时刻
时,需要连乘:
\[\frac{\partial h_T}{\partial h_t} =
\frac{\partial h_T}{\partial h_{T-1}} \cdot \frac{\partial
h_{T-1}{\partial h_{T-2}} \cdots \frac{\partial h_{t+1}} {\partial h_t}
\]
步骤 4:梯度范数的界
使用矩阵范数的性质,梯度的范数满足:
假设: - (激活函数导数的上界) - (权重矩阵的谱范数)
那么:
步骤 5:数值示例
假设: - ( 导数的典型值)
- (权重矩阵的谱范数) - 总的衰减系数 梯度随时间步数的衰减:
时间跨度
梯度范数上界
数值
10
20
50
100
当 时,梯度已经小到机器精度无法表示!
步骤 6:实际影响
这种指数级衰减导致:
长距离依赖无法学习 :序列开头的信息对序列结尾的影响在反向传播时被"遗忘"。即使输入序列有关键信息在第
1 步,模型也无法学习到它对第 100 步输出的影响。
训练极其缓慢 :权重更新幅度
太小,模型需要极长时间才能收敛。实践中可能需要数百万步才能达到合理性能。
偏向短期记忆 :模型只能记住最近几个时刻的信息(通常
5-10 步)。对于需要记住更长历史的任务(如段落级文本生成),基础 RNN
完全无能为力。
为什么不能简单增大学习率?
如果强行增大学习率来补偿消失的梯度,会导致: -
近期时刻的梯度(未衰减)过大,引发梯度爆炸 - 训练不稳定,权重震荡
这就是为什么需要 LSTM 和 GRU 这样的架构性解决方案。
梯度爆炸
相反,如果
的最大特征值大于
1,梯度会指数级增长 。假设每一步梯度乘以常数 ,经过 步后:
例如 ,
时,梯度会爆炸到 。这会导致:
数值溢出 :梯度值超出浮点数表示范围,变成 或
权重剧烈震荡 :巨大的梯度导致权重更新幅度过大,跳过最优解
训练不稳定 :损失函数值暴涨暴跌,无法收敛
可视化梯度问题
可以通过一个简单实验来观察梯度消失:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 import torchimport matplotlib.pyplot as pltdef compute_gradient_norm (seq_len, hidden_size, w_scale ): """ 计算不同序列长度下的梯度范数 seq_len: 序列长度 hidden_size: 隐藏层维度 w_scale: 权重矩阵的缩放因子 """ W_hh = torch.randn(hidden_size, hidden_size) * w_scale grad = torch.ones(hidden_size) for _ in range (seq_len): activation_grad = 0.5 grad = W_hh.t() @ grad * activation_grad return grad.norm().item() seq_lengths = range (1 , 100 , 5 ) gradient_norms_small = [] gradient_norms_large = [] for seq_len in seq_lengths: gradient_norms_small.append(compute_gradient_norm(seq_len, 50 , 0.5 )) gradient_norms_large.append(compute_gradient_norm(seq_len, 50 , 1.5 )) plt.figure(figsize=(10 , 5 )) plt.semilogy(seq_lengths, gradient_norms_small, label='小权重(0.5) - 梯度消失' ) plt.semilogy(seq_lengths, gradient_norms_large, label='大权重(1.5) - 梯度爆炸' ) plt.xlabel('序列长度' ) plt.ylabel('梯度范数(对数坐标)' ) plt.title('RNN 梯度随序列长度的变化' ) plt.legend() plt.grid(True ) plt.show()
运行这个实验会发现:当权重较小时,梯度随序列长度指数衰减;当权重较大时,梯度指数增长。这正是
RNN 训练的核心困境。
缓解梯度爆炸:梯度裁剪
梯度爆炸相对容易解决。一个简单有效的方法是梯度裁剪 (
Gradient Clipping):当梯度范数超过阈值时,将其缩放到阈值大小。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 def clip_gradients (model, max_norm ): """ 裁剪模型梯度 model: PyTorch 模型 max_norm: 最大梯度范数 """ total_norm = 0.0 for param in model.parameters(): if param.grad is not None : param_norm = param.grad.data.norm(2 ) total_norm += param_norm.item() ** 2 total_norm = total_norm ** 0.5 clip_coef = max_norm / (total_norm + 1e-6 ) if clip_coef < 1 : for param in model.parameters(): if param.grad is not None : param.grad.data.mul_(clip_coef) return total_norm torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0 )
梯度裁剪确保了梯度更新的幅度可控,避免了数值不稳定。现代深度学习框架都内置了这一功能。
梯度消失的解决方案预览
梯度消失则更难解决,因为我们无法通过简单的后处理来"放大"消失的梯度。真正的解决方案需要改变网络架构本身,这就是LSTM 和GRU 的设计动机。它们通过门控机制,让梯度能够"跳过"某些时刻,避免长距离的连乘,从而保留长期依赖。我们将在后续章节详细探讨。
BPTT(反向传播穿越时间)
从空间到时间的反向传播
反向传播穿越时间( Backpropagation Through Time, BPTT)是训练 RNN
的核心算法。它本质上是标准反向传播在时间维度上的推广:将展开的 RNN
视为一个深层前馈网络,然后应用链式法则计算梯度。
回顾前馈网络的反向传播:损失函数 对第 层权重 的梯度为:
其中 是第 层的激活值。对于
RNN,需要对所有时刻的权重梯度进行累加,因为所有时刻共享同一套参数。
BPTT 的数学推导
假设我们的 RNN 处理长度为
的序列,每个时刻都有一个损失 (例如在语言模型中,每个时刻预测下一个词),总损失为:
$$
L = _{t=1}^{T} L_t $$
我们的目标是计算 $ 、 和
$。
首先计算损失对隐藏状态的梯度。对于时刻 $ t, h_t对 总 损 失 的 影 响 来 自 两 个 方 面 : 直 接 影 响 : 通 过 y_t影 响 L_t间 接 影 响 : 通 过 h_{t+1}, h_{t+2}, ,
h_T$ 影响后续损失
因此:
这是一个递归关系,从最后一个时刻开始反向计算:
其中:
有了 ,我们就可以计算权重梯度。对于
,每个时刻都有贡献:
类似地:
BPTT 的计算复杂度
完整的 BPTT 需要: 1. 前向传播 :从 到
计算所有隐藏状态和输出,时间复杂度 2.
反向传播 :从 到 计算所有梯度,时间复杂度
3. 存储所有中间状态 :需要保存所有 ,空间复杂度
当序列很长时(例如 ),这会导致两个问题: -
内存占用巨大 :需要存储 1000 个时刻的隐藏状态 -
计算缓慢 :梯度需要从时刻 1000 传播到时刻 1
截断 BPTT( Truncated BPTT)
实践中,我们常用截断
BPTT 来解决长序列问题。核心思想是将长序列切分成多个固定长度的子序列,每个子序列独立进行
BPTT,但隐藏状态在子序列之间传递。
假设我们将长度为 1000 的序列切分成 10 个长度为 100
的子序列,训练过程为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 def truncated_bptt (model, data, seq_length, truncate_len ): """ 截断 BPTT 训练 model: RNN 模型 data: 完整序列数据 seq_length: 完整序列长度 truncate_len: 截断长度 """ optimizer = torch.optim.Adam(model.parameters()) h = None for t in range (0 , seq_length, truncate_len): x_batch = data[t:t+truncate_len] y_batch = data[t+1 :t+truncate_len+1 ] if h is not None : h = h.detach() outputs, h = model(x_batch, h) loss = criterion(outputs, y_batch) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0 ) optimizer.step()
关键点在于
h = h.detach():这一步切断了梯度的传播路径,使得反向传播只在当前子序列内进行。虽然这会损失一些长距离依赖的学习能力,但大大降低了内存占用和计算时间。
BPTT 的 PyTorch 实现细节
实际上,当你在 PyTorch 中使用 RNN 时, BPTT
是自动完成的。但理解其原理有助于调试和优化:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 import torchimport torch.nn as nnrnn = nn.RNN(input_size=10 , hidden_size=20 , num_layers=1 ) seq_len = 50 batch_size = 32 x = torch.randn(seq_len, batch_size, 10 ) output, h_n = rnn(x) target = torch.randn(seq_len, batch_size, 20 ) loss = nn.MSELoss()(output, target) loss.backward() for name, param in rnn.named_parameters(): print (f"{name} : 梯度范数 = {param.grad.norm().item():.4 f} " )
PyTorch 的自动微分引擎会构建完整的计算图,并在调用
backward() 时自动进行 BPTT
。你不需要手动实现梯度的递归计算,但了解背后的原理能帮助你理解为什么 RNN
训练如此困难。
LSTM 详解
设计动机:突破梯度瓶颈
LSTM( Long Short-Term Memory)由 Hochreiter 和 Schmidhuber 在 1997
年提出,专门用于解决 RNN
的梯度消失问题。其核心思想是引入细胞状态 ( Cell
State)作为信息的"高速公路",让梯度能够畅通无阻地在时间维度上传播。
从生活类比理解 LSTM
想象你在整理家里的物品,需要决定:
哪些旧物品扔掉 (遗忘门):去年的日历、过期的优惠券
哪些新物品留下 (输入门):刚买的新书、重要的账单
向外展示什么 (输出门):把客厅收拾得整洁,而不是把所有东西都摆出来
LSTM
的门控机制就是这样的筛选过程:根据当前输入和历史状态,动态决定保留、更新和输出什么信息。
细胞状态:信息的传送带
LSTM 的关键创新是细胞状态 ,它像一条传送带,贯穿整个时间序列。与隐藏状态
不同,细胞状态通过加法 而非乘法更新,这使得梯度能够几乎无损地回传。
在时刻 ,细胞状态的更新规则为:
$$
C_t = f_t C_{t-1} + i_t _t $$
其中: -
是上一时刻的细胞状态 -
是遗忘门的输出(决定保留多少旧信息),取值范围 -
是输入门的输出(决定接受多少新信息),取值范围 -
是候选细胞状态(新信息的内容),取值范围 - 表示逐元素乘法( Hadamard 积)
为什么加法更新能解决梯度消失?
计算梯度时:
只涉及遗忘门 ,不涉及权重矩阵的连乘。如果遗忘门始终接近
1(学习保留信息),梯度就能长距离传播而不衰减。这是 LSTM
解决梯度消失的数学基础。
遗忘门:选择性遗忘
门的定义与作用
遗忘门决定从细胞状态中丢弃哪些信息。它接收上一时刻的隐藏状态 和当前输入 ,输出一个 0 到 1 之间的向量:
$$
f_t = (W_f + b_f) $$
其中: - 是 sigmoid
函数,输出范围为 - 是遗忘门的权重矩阵,维度 '_' allowed only in math mode [\text{hidden_size}, \text{hidden_size} + \text{input_size}] - 表示拼接向量,维度 '_' allowed only in math mode [\text{hidden_size} + \text{input_size}]
- 是偏置向量,维度 '_' allowed only in math mode [\text{hidden_size}] 遗忘门的值越接近
1,表示保留的信息越多;越接近 0,表示遗忘的信息越多。
直觉理解:阅读文章的例子
当你阅读一篇议论文时:
前半段观点 :"深度学习在图像识别上表现优异。"
细胞状态存储:图像识别 + 优异
遗忘门值: (保留大部分信息)
看到转折词"然而" :"然而,它在小数据集上容易过拟合。"
遗忘门检测到"然而",意识到要准备接受相反观点
遗忘门值: (部分遗忘之前的正面评价)
细胞状态更新:新 信 息 -
结果:保留"深度学习"和"图像识别"的主题,但减弱"优异"的强调
数学上的选择性保留
假设细胞状态
是一个向量,每个维度编码不同的语义信息:
$$
C_{t-1} = [{} , {} , {} , {} ] $$
遗忘门 可以选择性地保留不同维度:
$$
f_t = [{} , {} , {} , {} ] $$
更新后:
$$
f_t C_{t-1} = [0.72, 0.12, -0.21, 0.855] $$
主题和实体信息被较好保留,而情感信息被大幅削弱。
输入门:选择性记忆
输入门决定将哪些新信息存入细胞状态。它分为两步:
步骤
1:输入门激活(决定更新哪些值)
$$
i_t = (W_i + b_i) $$
输入门的输出 决定新信息的写入强度。
步骤
2:候选细胞状态(生成新的候选信息)
注意这里使用 而非 ,因为候选状态的值域是 ,可以表示正负变化。
联合作用:控制"写入"
最终,输入门和候选状态共同决定新信息的贡献:
$$
i_t _t $$
直觉理解 :记笔记的例子
你在听讲座,老师讲到一个新概念:
候选内容 :"卷积神经网络使用卷积层提取空间特征"
输入门的判断 $ i_t: 如 果 老 师 强 调 这 是 考 试 重 点 : i_t ( 强 烈 写 入 ) 如 果 只 是 顺 便 提 及 : i_t $(轻微记录)
实际写入 :Double subscripts: use braces to clarify i_t _t - 重点内容:内 容 强 记 录 -
轻微提及:内 容 弱 记 录
更新细胞状态:平衡旧与新
有了遗忘门和输入门,我们就可以更新细胞状态了:
$$
C_t = f_t C_{t-1} + i_t _t $$
这个公式是 LSTM 的核心,它实现了平衡的信息流动 :
项
含义
作用
保留旧记忆的一部分
遗忘不重要的历史信息
Double subscripts: use braces to clarify i_t _t
添加新信息的一部分
写入新的相关信息
数值示例 :
假设:
旧细胞状态:
遗忘门:
输入门:
候选状态: 更新过程:
( 保 留 部 分 旧 信 息 ) ( 添 加 部 分 新 信 息 ) ( 新 细 胞 状 态 )
可以看到: - 维度 0:旧信息 0.5 → 0.58(稍微增强) - 维度 1:旧信息
0.3 → -0.30(完全替换为新信息) - 维度 2:旧信息 -0.2 →
-0.09(部分保留,轻微更新) - 维度 3:旧信息 0.8 →
0.44(减弱,添加负向新信息)
输出门:选择性输出
最后,输出门决定基于细胞状态输出什么信息作为隐藏状态。
输出门的计算
$$
o_t = (W_o + b_o) $$
隐藏状态的生成
$$
h_t = o_t (C_t) $$
这里先用 将细胞状态压缩到
,再用输出门进行过滤。
直觉理解 :你的大脑存储了大量信息,但在回答一个具体问题时,你只会提取相关的部分。
对话示例 :
问题 :"卷积神经网络的主要特点是什么?"
细胞状态 :存储了关于 CNN
的所有知识(卷积层、池化层、全连接层、应用场景、历史演进等)
输出门 :根据问题"主要特点",输出门选择性地提取与"特点"相关的信息
输出 :"卷积神经网络的主要特点是使用卷积层提取空间特征,具有平移不变性。"(其他无关信息被过滤)
LSTM 的完整前向传播
汇总所有步骤, LSTM 在每个时刻的计算为:
( 遗 忘 门 ) ( 输 入 门 ) ( 候 选 状 态 ) ( 更 新 细 胞 状 态 ) ( 输 出 门 ) ( 更 新 隐 藏 状 态 )
计算流程图 (文字描述):
1 2 3 4 5 6 7 8 9 10 输入: x_t, h_{t-1}, C_{t-1} 1. 遗忘门: f_t = sigmoid(W_f * [h_{t-1}, x_t] + b_f) 2. 输入门: i_t = sigmoid(W_i * [h_{t-1}, x_t] + b_i) 3. 候选: C ̃_t = tanh(W_C * [h_{t-1}, x_t] + b_C) 4. 更新: C_t = f_t ⊙ C_{t-1} + i_t ⊙ C ̃_t 5. 输出门: o_t = sigmoid(W_o * [h_{t-1}, x_t] + b_o) 6. 隐状态: h_t = o_t ⊙ tanh(C_t) 输出: h_t, C_t
PyTorch 实现 LSTM
让我们从零实现一个 LSTM 单元:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 import torchimport torch.nn as nnclass LSTMCell (nn.Module): def __init__ (self, input_size, hidden_size ): super (LSTMCell, self).__init__() self.hidden_size = hidden_size self.W_f = nn.Linear(input_size + hidden_size, hidden_size) self.W_i = nn.Linear(input_size + hidden_size, hidden_size) self.W_C = nn.Linear(input_size + hidden_size, hidden_size) self.W_o = nn.Linear(input_size + hidden_size, hidden_size) def forward (self, x_t, h_prev, C_prev ): """ x_t: (batch_size, input_size) 当前输入 h_prev: (batch_size, hidden_size) 上一时刻隐藏状态 C_prev: (batch_size, hidden_size) 上一时刻细胞状态 """ combined = torch.cat([h_prev, x_t], dim=1 ) f_t = torch.sigmoid(self.W_f(combined)) i_t = torch.sigmoid(self.W_i(combined)) C_tilde = torch.tanh(self.W_C(combined)) C_t = f_t * C_prev + i_t * C_tilde o_t = torch.sigmoid(self.W_o(combined)) h_t = o_t * torch.tanh(C_t) return h_t, C_t class LSTM (nn.Module): def __init__ (self, input_size, hidden_size, output_size ): super (LSTM, self).__init__() self.hidden_size = hidden_size self.lstm_cell = LSTMCell(input_size, hidden_size) self.fc = nn.Linear(hidden_size, output_size) def forward (self, x ): """ x: (batch_size, seq_len, input_size) """ batch_size, seq_len, _ = x.size() h_t = torch.zeros(batch_size, self.hidden_size).to(x.device) C_t = torch.zeros(batch_size, self.hidden_size).to(x.device) outputs = [] for t in range (seq_len): h_t, C_t = self.lstm_cell(x[:, t, :], h_t, C_t) out = self.fc(h_t) outputs.append(out.unsqueeze(1 )) outputs = torch.cat(outputs, dim=1 ) return outputs model = LSTM(input_size=10 , hidden_size=20 , output_size=5 ) x = torch.randn(32 , 50 , 10 ) outputs = model(x) print (f"输出形状: {outputs.shape} " )
当然,实际应用中可以直接使用 PyTorch 内置的 LSTM:
1 2 3 lstm = nn.LSTM(input_size=10 , hidden_size=20 , num_layers=2 , batch_first=True ) x = torch.randn(32 , 50 , 10 ) outputs, (h_n, c_n) = lstm(x)
LSTM 解决梯度消失的原理
LSTM 为什么能解决梯度消失?核心是细胞状态的加法更新。计算梯度时:
注意这里没有权重矩阵的连乘!梯度通过 回传,只要遗忘门不全为
0,梯度就能保持一定的强度。相比之下,普通 RNN 的梯度需要连乘 ,导致指数衰减。
此外, LSTM
的门控机制让模型能够学习 何时保留、何时遗忘信息,而不是被动地受权重矩阵的影响。这种灵活性使得
LSTM 能够捕捉长距离依赖。
GRU 详解
简化的门控设计
门控循环单元( Gated Recurrent Unit, GRU)由 Cho 等人在 2014
年提出,可以看作是 LSTM 的简化版本。 GRU
的设计理念是:用更少的参数达到接近 LSTM 的性能。
GRU 相比 LSTM 有两个主要简化: 1.
合并细胞状态和隐藏状态 : GRU 只有一个隐藏状态 ,不再单独维护细胞状态 2. 减少门的数量 : GRU
只有两个门(更新门和重置门),而 LSTM
有三个门(遗忘门、输入门、输出门)
尽管结构更简单, GRU 在许多任务上的表现与 LSTM
相当,且训练速度更快。
更新门:控制信息更新
更新门( Update
Gate)决定从过去的隐藏状态中保留多少信息,以及接受多少新信息。它类似于
LSTM 的遗忘门和输入门的组合:
$$
z_t = (W_z + b_z) $$
更新门的输出范围是 : - 接近 1:更多地保留旧信息,忽略当前输入 - 接近
0:更多地接受新信息,遗忘旧状态
直觉理解 :在阅读文章时,如果遇到一个承上启下的句子,你会保留大部分之前的理解;如果遇到一个全新的话题,你会更新大部分记忆。更新门就是这个"平衡旋钮"。
重置门:控制历史依赖
重置门( Reset
Gate)决定在计算新的候选状态时,保留多少过去的信息:
$$
r_t = (W_r + b_r) $$
重置门的作用是让模型能够"忘记"不相关的历史信息: - 接近
1:完全考虑过去的隐藏状态 - 接近
0:忽略过去,只根据当前输入计算新状态
直觉理解 :在对话系统中,如果用户突然切换话题,重置门会关闭,让模型重新开始,不受之前对话的影响。
候选隐藏状态
有了重置门,可以计算候选隐藏状态:
注意这里 作用于 ,控制过去信息的影响程度。如果 接近 0,候选状态主要由当前输入 决定。
更新隐藏状态
最后,使用更新门在旧状态和候选状态之间插值:
$$
h_t = (1 - z_t) h_{t-1} + z_t _t $$
这个公式可以理解为: - 第一项 :保留旧状态 - 第二项 Double subscripts: use braces to clarify z_t _t :添加新状态
两者的权重由更新门 控制,且两者互补(权重和为 1)。
GRU 的完整前向传播
GRU 在每个时刻的计算步骤为:
( 更 新 门 ) ( 重 置 门 ) ( 候 选 状 态 ) ( 更 新 隐 藏 状 态 )
相比 LSTM 的 6 个公式, GRU 只有 4 个,且没有单独的细胞状态。
PyTorch 实现 GRU
从零实现一个 GRU 单元:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 import torchimport torch.nn as nnclass GRUCell (nn.Module): def __init__ (self, input_size, hidden_size ): super (GRUCell, self).__init__() self.hidden_size = hidden_size self.W_z = nn.Linear(input_size + hidden_size, hidden_size) self.W_r = nn.Linear(input_size + hidden_size, hidden_size) self.W_h = nn.Linear(input_size + hidden_size, hidden_size) def forward (self, x_t, h_prev ): """ x_t: (batch_size, input_size) 当前输入 h_prev: (batch_size, hidden_size) 上一时刻隐藏状态 """ combined = torch.cat([h_prev, x_t], dim=1 ) z_t = torch.sigmoid(self.W_z(combined)) r_t = torch.sigmoid(self.W_r(combined)) combined_reset = torch.cat([r_t * h_prev, x_t], dim=1 ) h_tilde = torch.tanh(self.W_h(combined_reset)) h_t = (1 - z_t) * h_prev + z_t * h_tilde return h_t class GRU (nn.Module): def __init__ (self, input_size, hidden_size, output_size ): super (GRU, self).__init__() self.hidden_size = hidden_size self.gru_cell = GRUCell(input_size, hidden_size) self.fc = nn.Linear(hidden_size, output_size) def forward (self, x ): """ x: (batch_size, seq_len, input_size) """ batch_size, seq_len, _ = x.size() h_t = torch.zeros(batch_size, self.hidden_size).to(x.device) outputs = [] for t in range (seq_len): h_t = self.gru_cell(x[:, t, :], h_t) out = self.fc(h_t) outputs.append(out.unsqueeze(1 )) outputs = torch.cat(outputs, dim=1 ) return outputs model = GRU(input_size=10 , hidden_size=20 , output_size=5 ) x = torch.randn(32 , 50 , 10 ) outputs = model(x) print (f"输出形状: {outputs.shape} " ) gru = nn.GRU(input_size=10 , hidden_size=20 , num_layers=2 , batch_first=True ) outputs, h_n = gru(x)
GRU 的梯度流
GRU 也能缓解梯度消失问题,原因与 LSTM
类似。在隐藏状态的更新公式中:
$$
h_t = (1 - z_t) h_{t-1} + z_t _t $$
计算梯度时:
第一项
提供了一条直接的梯度通路,不涉及权重矩阵的连乘。只要 不全为
1,梯度就能回传。这种设计让 GRU 能够捕捉长距离依赖。
LSTM vs GRU 对比
结构复杂度
特性
LSTM
GRU
门的数量
3 个(遗忘门、输入门、输出门)
2 个(更新门、重置门)
状态数量
2 个(隐藏状态 、细胞状态 )
1 个(隐藏状态 )
参数量
计算复杂度
更高( 6 个矩阵乘法)
更低( 3 个矩阵乘法)
其中 是隐藏层维度, 是输入维度。 GRU 的参数量约为 LSTM
的 75%。
性能对比
多项研究对 LSTM 和 GRU 进行了实验对比,结论如下:
1. 任务依赖性 -
长序列任务 (如语言模型、机器翻译): LSTM
通常略优,因为细胞状态提供了更强的记忆能力 -
短序列任务 (如情感分类、命名实体识别): GRU 与 LSTM
性能相当,且训练更快 - 小数据集 : GRU
由于参数少,泛化能力可能更强,不易过拟合
2. 训练效率 - GRU 的训练速度比 LSTM 快约
15-30%,因为参数更少、计算更简单 - 在相同时间预算下, GRU
可以训练更多轮,可能达到更好的最终性能
3. 超参数敏感度 - LSTM
对初始化和学习率更敏感,需要更仔细的调优 - GRU 相对鲁棒,更容易调参
何时选择 LSTM,何时选择 GRU?
选择 LSTM 的场景 : 1.
任务需要非常长期的记忆(例如段落级别的文本生成) 2.
数据量充足,可以充分训练更多参数 3. 对性能要求极致,愿意牺牲训练时间
选择 GRU 的场景 : 1. 快速原型开发,需要快速迭代实验
2. 计算资源有限,需要更快的训练速度 3. 数据量较小,担心过拟合 4.
任务的记忆需求不是特别长(例如词级别的标注任务)
实践建议 :对于新任务,可以先用 GRU
快速验证想法,如果效果接近上限但仍不够好,再尝试 LSTM
。两者的接口完全一致,切换成本很低。
混合使用
在实际应用中,还可以混合使用 LSTM 和 GRU 。例如: - 多层
RNN :底层使用 GRU 快速提取特征,顶层使用 LSTM 捕捉长期依赖 -
编码器-解码器 :编码器使用 GRU(速度快),解码器使用
LSTM(生成质量高)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 class HybridRNN (nn.Module): def __init__ (self, input_size, hidden_size, output_size ): super (HybridRNN, self).__init__() self.gru = nn.GRU(input_size, hidden_size, num_layers=2 , batch_first=True ) self.lstm = nn.LSTM(hidden_size, hidden_size, num_layers=1 , batch_first=True ) self.fc = nn.Linear(hidden_size, output_size) def forward (self, x ): gru_out, _ = self.gru(x) lstm_out, _ = self.lstm(gru_out) out = self.fc(lstm_out) return out
双向 RNN( Bi-RNN)
为什么需要双向?
前面介绍的 RNN
都是单向的:从左到右处理序列,当前时刻的输出只依赖于过去的信息。但在很多任务中,未来的信息同样重要。
例如,在句子"我喜欢___电影"中填空,答案可能是"看"。如果只看"我喜欢",你无法确定答案;但如果同时看到后面的"电影",就能准确推断。
双向 RNN( Bidirectional RNN,
Bi-RNN)通过同时从左到右和从右到左 处理序列,让每个时刻的输出都能感知到完整的上下文信息。
双向 RNN 的结构
Bi-RNN 由两个独立的 RNN 组成: 1. 前向 RNN :从 到 处理序列,计算前向隐藏状态 2. 后向
RNN :从 到 处理序列,计算后向隐藏状态 在每个时刻 ,将两个方向的隐藏状态拼接起来:
$$
h_t = [_t; _t] $$
最终的输出基于这个双向隐藏状态:
$$
y_t = W_y h_t + b_y = W_y [_t; _t] + b_y $$
前向和后向的独立性
需要注意的是,前向和后向 RNN
是完全独立 的:它们有各自的权重矩阵,不共享参数。前向
RNN 的隐藏状态更新为:
后向 RNN 的隐藏状态更新为:
PyTorch 实现 Bi-RNN
PyTorch 的 RNN 、 LSTM 、 GRU 都支持双向模式,只需设置
bidirectional=True:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import torchimport torch.nn as nnbi_lstm = nn.LSTM( input_size=10 , hidden_size=20 , num_layers=2 , batch_first=True , bidirectional=True ) x = torch.randn(32 , 50 , 10 ) output, (h_n, c_n) = bi_lstm(x) print (f"输出形状: {output.shape} " ) print (f"最终隐藏状态形状: {h_n.shape} " )
注意双向 RNN
的输出维度是单向的两倍 (因为拼接了前向和后向),最终隐藏状态的层数也翻倍。
如果要手动实现双向 RNN:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 class BiRNN (nn.Module): def __init__ (self, input_size, hidden_size, output_size ): super (BiRNN, self).__init__() self.hidden_size = hidden_size self.lstm_forward = nn.LSTM(input_size, hidden_size, batch_first=True ) self.lstm_backward = nn.LSTM(input_size, hidden_size, batch_first=True ) self.fc = nn.Linear(2 * hidden_size, output_size) def forward (self, x ): forward_out, _ = self.lstm_forward(x) x_reversed = torch.flip(x, dims=[1 ]) backward_out, _ = self.lstm_backward(x_reversed) backward_out = torch.flip(backward_out, dims=[1 ]) combined = torch.cat([forward_out, backward_out], dim=2 ) out = self.fc(combined) return out model = BiRNN(input_size=10 , hidden_size=20 , output_size=5 ) x = torch.randn(32 , 50 , 10 ) outputs = model(x) print (f"输出形状: {outputs.shape} " )
双向 RNN 的局限性
双向 RNN
虽然强大,但有一个重要限制:它不能用于在线生成任务 。
在语言生成、语音合成等任务中,需要逐个生成输出,当前时刻无法看到未来的输入。而双向
RNN 需要完整的序列才能计算后向隐藏状态,因此不适用于这类场景。
双向 RNN 主要用于编码任务 ,例如: -
文本分类(需要理解整个句子) - 命名实体识别(需要上下文判断实体边界) -
机器翻译的编码器(先理解完整的源语句)
在 Seq2Seq 架构中,通常编码器使用双向 RNN,解码器使用单向 RNN 。
多层 RNN 与残差连接
堆叠多层 RNN
单层 RNN 的表达能力有限,特别是在复杂任务中。类似于 CNN
的多层结构,可以堆叠多层 RNN 来增强模型的表征能力。
在多层 RNN 中,第 层的输出作为第 层的输入:
每一层都有自己的参数,层之间不共享权重。最终输出基于最顶层的隐藏状态:
$$
y_t = W_y h_t^{(L)} + b_y $$
多层 RNN 的优势
1. 更强的抽象能力
底层学习低级特征(如字符组合、词法模式),高层学习高级特征(如语义、语法结构)。这种分层学习在
NLP 任务中非常有效。
2. 更好的泛化能力
多层结构增加了模型容量,能够在大数据集上学到更复杂的模式,减少欠拟合。
3. 适应不同层次的任务
在多任务学习中,可以从不同层提取特征。例如,词性标注使用底层特征,句法分析使用高层特征。
PyTorch 实现多层 RNN
PyTorch 的 RNN 、 LSTM 、 GRU 通过 num_layers
参数支持多层:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 multi_layer_lstm = nn.LSTM( input_size=10 , hidden_size=20 , num_layers=3 , batch_first=True , bidirectional=True , dropout=0.3 ) x = torch.randn(32 , 50 , 10 ) output, (h_n, c_n) = multi_layer_lstm(x) print (f"输出形状: {output.shape} " ) print (f"最终隐藏状态形状: {h_n.shape} " )
注意 dropout 参数:它在 RNN 的层与层之间应用 Dropout
正则化,防止过拟合。不要在最后一层应用 Dropout ,
PyTorch 会自动处理这一点。
残差连接( Residual
Connections)
随着层数增加, RNN 也会遇到梯度消失问题。借鉴 ResNet 的思想,可以在
RNN 层之间添加残差连接 (也叫跳跃连接):
$$
h_t^{(l)} = {(l)}(h_t {(l-1)}) + h_t^{(l-1)} $$
残差连接为梯度提供了一条"高速公路",使得深层 RNN 也能有效训练。
PyTorch 没有内置残差 RNN,需要手动实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 class ResidualLSTM (nn.Module): def __init__ (self, input_size, hidden_size, num_layers ): super (ResidualLSTM, self).__init__() self.num_layers = num_layers self.lstm_layers = nn.ModuleList([ nn.LSTM(input_size, hidden_size, num_layers=1 , batch_first=True ) ]) for _ in range (num_layers - 1 ): self.lstm_layers.append( nn.LSTM(hidden_size, hidden_size, num_layers=1 , batch_first=True ) ) self.dropout = nn.Dropout(0.3 ) def forward (self, x ): out, _ = self.lstm_layers[0 ](x) out = self.dropout(out) for i in range (1 , self.num_layers): residual = out out, _ = self.lstm_layers[i](out) out = self.dropout(out) out = out + residual return out model = ResidualLSTM(input_size=64 , hidden_size=64 , num_layers=4 ) x = torch.randn(32 , 50 , 64 ) outputs = model(x) print (f"输出形状: {outputs.shape} " )
注意这里要求除第一层外,所有层的输入输出维度相同,才能进行残差相加。
深度 RNN 的最佳实践
1. 层数选择 - 小数据集: 2-3 层足够 - 中等数据集:
3-5 层 - 大数据集(如机器翻译): 4-8 层
过多的层会增加过拟合风险和计算成本。
2. 正则化 - 层间 Dropout: 0.2-0.5 - 权重衰减( L2
正则化):1e-5 到 1e-3 -
梯度裁剪:必须使用,阈值通常设为 5.0
3. 残差连接 - 当层数 ≥ 4 时,强烈建议使用残差连接 -
残差连接前后维度必须一致
Seq2Seq 架构(编码器-解码器)
序列到序列的挑战
在机器翻译、文本摘要、对话系统等任务中,输入和输出都是序列,且长度通常不同。例如:
- 输入: ( 3
个词) - 输出:你 好 吗 ? (
3 个字)
前面的 RNN
可以处理序列输入,但它假设每个时刻都有输出,且输入输出长度一致。需要一种更灵活的架构,能够:
1. 将变长输入编码成固定维度的表示 2. 基于这个表示生成变长输出
这就是Seq2Seq (
Sequence-to-Sequence)架构的设计目标。
编码器-解码器范式
Seq2Seq 由两个部分组成:
1. 编码器( Encoder)
编码器是一个 RNN(通常是 LSTM 或
GRU),它读取整个输入序列,并将其压缩成一个固定维度的上下文向量 (
Context Vector),也叫思想向量 ( Thought Vector):
这里
是编码器最后一个时刻的隐藏状态,它包含了整个输入序列的信息。
2. 解码器( Decoder)
解码器也是一个 RNN,它的初始隐藏状态设为上下文向量 ,然后逐步生成输出序列:
注意解码器的输入是上一个时刻的输出 (或真实标签,在训练时),这被称为自回归生成 。
特殊标记
Seq2Seq 使用特殊标记来控制生成过程:
<SOS>( Start of
Sequence):解码器的第一个输入,表示开始生成
<EOS>( End of
Sequence):当解码器生成这个标记时,停止生成
<PAD>(
Padding):用于填充不同长度的序列,使其能够批量处理
例如,翻译"How are you?"时: -
编码器输入:[<SOS>, How, are, you, ?, <EOS>] -
解码器输入:[<SOS>, 你, 好, 吗] -
解码器输出:[你, 好, 吗, ?, <EOS>]
PyTorch 实现 Seq2Seq
让我们实现一个完整的 Seq2Seq 模型:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 import torchimport torch.nn as nnimport randomclass Encoder (nn.Module): def __init__ (self, input_size, embedding_dim, hidden_size, num_layers=1 ): super (Encoder, self).__init__() self.embedding = nn.Embedding(input_size, embedding_dim) self.lstm = nn.LSTM(embedding_dim, hidden_size, num_layers, batch_first=True ) def forward (self, x ): """ x: (batch_size, seq_len) - 输入序列的词索引 """ embedded = self.embedding(x) outputs, (hidden, cell) = self.lstm(embedded) return hidden, cell class Decoder (nn.Module): def __init__ (self, output_size, embedding_dim, hidden_size, num_layers=1 ): super (Decoder, self).__init__() self.embedding = nn.Embedding(output_size, embedding_dim) self.lstm = nn.LSTM(embedding_dim, hidden_size, num_layers, batch_first=True ) self.fc = nn.Linear(hidden_size, output_size) def forward (self, x, hidden, cell ): """ x: (batch_size, 1) - 当前时刻的输入(单个词) hidden: (num_layers, batch_size, hidden_size) - 隐藏状态 cell: (num_layers, batch_size, hidden_size) - 细胞状态 """ embedded = self.embedding(x) output, (hidden, cell) = self.lstm(embedded, (hidden, cell)) prediction = self.fc(output.squeeze(1 )) return prediction, hidden, cell class Seq2Seq (nn.Module): def __init__ (self, encoder, decoder, device ): super (Seq2Seq, self).__init__() self.encoder = encoder self.decoder = decoder self.device = device def forward (self, src, trg, teacher_forcing_ratio=0.5 ): """ src: (batch_size, src_len) - 源语言序列 trg: (batch_size, trg_len) - 目标语言序列 teacher_forcing_ratio: 教师强制的概率 """ batch_size = src.shape[0 ] trg_len = trg.shape[1 ] trg_vocab_size = self.decoder.fc.out_features outputs = torch.zeros(batch_size, trg_len, trg_vocab_size).to(self.device) hidden, cell = self.encoder(src) input_token = trg[:, 0 ].unsqueeze(1 ) for t in range (1 , trg_len): output, hidden, cell = self.decoder(input_token, hidden, cell) outputs[:, t, :] = output teacher_force = random.random() < teacher_forcing_ratio top1 = output.argmax(1 ).unsqueeze(1 ) input_token = trg[:, t].unsqueeze(1 ) if teacher_force else top1 return outputs INPUT_VOCAB_SIZE = 1000 OUTPUT_VOCAB_SIZE = 1500 EMBEDDING_DIM = 256 HIDDEN_SIZE = 512 NUM_LAYERS = 2 device = torch.device('cuda' if torch.cuda.is_available() else 'cpu' ) encoder = Encoder(INPUT_VOCAB_SIZE, EMBEDDING_DIM, HIDDEN_SIZE, NUM_LAYERS) decoder = Decoder(OUTPUT_VOCAB_SIZE, EMBEDDING_DIM, HIDDEN_SIZE, NUM_LAYERS) model = Seq2Seq(encoder, decoder, device).to(device) src = torch.randint(0 , INPUT_VOCAB_SIZE, (32 , 20 )).to(device) trg = torch.randint(0 , OUTPUT_VOCAB_SIZE, (32 , 25 )).to(device) outputs = model(src, trg) print (f"输出形状: {outputs.shape} " )
教师强制( Teacher Forcing)
在上面的代码中,我们使用了教师强制 策略。在训练时,解码器的每个时刻的输入有两种选择:
1.
使用真实标签 (教师强制):即使上一步预测错了,也用正确答案作为当前输入
2. 使用模型预测 :用上一步的预测结果作为当前输入
教师强制的优点是训练更快、更稳定,因为模型不会因为早期的错误而积累误差。但缺点是可能导致暴露偏差 (
Exposure
Bias):模型在训练时看到的都是正确答案,但测试时必须用自己的预测,导致错误累积。
实践中,通常使用一个概率 (如
0.5)来随机决定是否使用教师强制,在训练后期逐渐降低 ,让模型适应自己的预测。
Seq2Seq 的瓶颈
Seq2Seq
的一个致命缺陷是信息瓶颈 :编码器必须将整个输入序列压缩到一个固定维度的向量
中。对于长句子,这个向量很难包含所有细节,导致信息丢失。
例如,翻译一个 50
词的句子时,编码器的最后一个隐藏状态很可能"忘记"了句首的信息。这就是注意力机制 (
Attention Mechanism)的设计动机,我们将在下一节预览。
注意力机制预览
Seq2Seq 的局限性
在标准 Seq2Seq 中,解码器只能访问编码器的最后一个隐藏状态 。这意味着: 1.
信息瓶颈 :所有信息必须压缩到固定维度的向量中 2.
长距离遗忘 :对于长序列,早期的信息可能丢失 3.
均等对待 :无法根据当前生成的词,有针对性地关注输入的某些部分
举个例子,翻译"I love natural language
processing"时,生成"自然语言"时应该重点关注"natural
language",而非整个句子。
注意力机制的核心思想
注意力机制 让解码器在每个时刻都能"回头看"编码器的所有隐藏状态,并根据当前需要,动态地聚焦于不同的输入部分。
具体来说,在解码器的第
个时刻,注意力机制计算一个注意力权重 ,表示生成第
个词时,对编码器第 个时刻的关注程度:
其中 是对齐分数 ,衡量解码器状态 与编码器状态 的相关性:
$$
e_{tj} = a(h_t^{dec}, h_j^{enc}) $$
函数 可以是点积、双线性形式、或小型神经网络。
有了注意力权重,我们计算上下文向量 (每个时刻都不同):
$$
c_t = {j=1}^{T {src}} _{tj} h_j^{enc} $$
解码器将上下文向量和当前隐藏状态结合起来生成输出:
$$
y_t = (W_y _t^{dec} + b_y) $$
注意力的直觉
想象你在翻译一篇文章。你不会一次性读完整篇文章,记在脑子里,然后开始翻译。相反,你会:
1. 大致浏览全文(编码器处理输入) 2.
翻译第一句时,重点看原文的第一句(注意力聚焦) 3.
翻译第二句时,重点看原文的第二句,同时参考第一句(注意力动态调整)
注意力机制模拟了这种"选择性聚焦"的能力,让模型能够根据当前任务,灵活地提取相关信息。
注意力带来的提升
实验表明,在机器翻译任务中,加入注意力机制能够: - BLEU
分数提升 5-10 个点 -
更好地处理长句 :注意力允许直接访问远距离的信息 -
可解释性 :通过可视化注意力权重,可以看到模型在关注输入的哪些部分
注意力机制的成功催生了Transformer 架构,它完全抛弃了
RNN,只依赖注意力机制,在 NLP 领域引发了革命。这将是后续文章的主题。
注意力机制的简化实现
虽然完整的注意力 Seq2Seq 较为复杂,这里展示核心的注意力计算:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 class Attention (nn.Module): def __init__ (self, hidden_size ): super (Attention, self).__init__() self.attn = nn.Linear(hidden_size * 2 , hidden_size) self.v = nn.Linear(hidden_size, 1 , bias=False ) def forward (self, hidden, encoder_outputs ): """ hidden: (batch_size, hidden_size) - 当前解码器隐藏状态 encoder_outputs: (batch_size, src_len, hidden_size) - 所有编码器隐藏状态 """ src_len = encoder_outputs.shape[1 ] hidden = hidden.unsqueeze(1 ).repeat(1 , src_len, 1 ) energy = torch.tanh(self.attn(torch.cat([hidden, encoder_outputs], dim=2 ))) attention_scores = self.v(energy).squeeze(2 ) attention_weights = torch.softmax(attention_scores, dim=1 ) context = torch.bmm(attention_weights.unsqueeze(1 ), encoder_outputs) context = context.squeeze(1 ) return context, attention_weights attention = Attention(hidden_size=512 ) hidden = torch.randn(32 , 512 ) encoder_outputs = torch.randn(32 , 20 , 512 ) context, attn_weights = attention(hidden, encoder_outputs) print (f"上下文向量形状: {context.shape} " ) print (f"注意力权重形状: {attn_weights.shape} " )
实战:文本生成器( PyTorch
实现)
任务描述
让我们构建一个字符级文本生成器 ,训练一个 RNN
来学习文本的风格和模式,然后生成相似的文本。我们将使用一段英文文本作为训练数据,让模型学会逐个字符生成文本。
数据准备
首先准备训练数据。我们使用一段简单的文本(实际应用中可以用小说、诗歌等):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 import torchimport torch.nn as nnimport numpy as nptext = """ Deep learning is a subset of machine learning which itself is a subset of artificial intelligence. The field of artificial intelligence is essentially when machines can do tasks that typically require human intelligence. It encompasses machine learning where machines can learn by experience and acquire skills without human involvement. Deep learning is a subset of machine learning where artificial neural networks adapt and learn from vast amounts of data. """ chars = sorted (list (set (text))) char_to_idx = {ch: i for i, ch in enumerate (chars)} idx_to_char = {i: ch for i, ch in enumerate (chars)} vocab_size = len (chars) print (f"词汇表大小: {vocab_size} " )print (f"文本长度: {len (text)} 字符" )text_encoded = [char_to_idx[ch] for ch in text]
创建训练数据
我们使用滑动窗口 创建训练样本:给定前
个字符,预测第 个字符。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 def create_sequences (text_encoded, seq_length ): """ 创建训练序列 text_encoded: 编码后的文本 seq_length: 序列长度 """ sequences = [] targets = [] for i in range (len (text_encoded) - seq_length): seq = text_encoded[i:i+seq_length] target = text_encoded[i+seq_length] sequences.append(seq) targets.append(target) return np.array(sequences), np.array(targets) seq_length = 50 X, y = create_sequences(text_encoded, seq_length) print (f"训练样本数: {len (X)} " )print (f"每个样本形状: {X[0 ].shape} " )X_tensor = torch.LongTensor(X) y_tensor = torch.LongTensor(y)
定义模型
使用 LSTM 构建字符级语言模型:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 class CharLSTM (nn.Module): def __init__ (self, vocab_size, embedding_dim, hidden_size, num_layers ): super (CharLSTM, self).__init__() self.hidden_size = hidden_size self.num_layers = num_layers self.embedding = nn.Embedding(vocab_size, embedding_dim) self.lstm = nn.LSTM( embedding_dim, hidden_size, num_layers, batch_first=True , dropout=0.3 ) self.fc = nn.Linear(hidden_size, vocab_size) def forward (self, x, hidden=None ): """ x: (batch_size, seq_len) """ embedded = self.embedding(x) if hidden is None : output, hidden = self.lstm(embedded) else : output, hidden = self.lstm(embedded, hidden) output = self.fc(output[:, -1 , :]) return output, hidden def init_hidden (self, batch_size, device ): """初始化隐藏状态""" h0 = torch.zeros(self.num_layers, batch_size, self.hidden_size).to(device) c0 = torch.zeros(self.num_layers, batch_size, self.hidden_size).to(device) return (h0, c0) EMBEDDING_DIM = 128 HIDDEN_SIZE = 256 NUM_LAYERS = 2 BATCH_SIZE = 64 EPOCHS = 100 LEARNING_RATE = 0.001 device = torch.device('cuda' if torch.cuda.is_available() else 'cpu' ) model = CharLSTM(vocab_size, EMBEDDING_DIM, HIDDEN_SIZE, NUM_LAYERS).to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=LEARNING_RATE)
训练模型
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 from torch.utils.data import TensorDataset, DataLoaderdataset = TensorDataset(X_tensor, y_tensor) dataloader = DataLoader(dataset, batch_size=BATCH_SIZE, shuffle=True ) model.train() for epoch in range (EPOCHS): total_loss = 0 for batch_x, batch_y in dataloader: batch_x = batch_x.to(device) batch_y = batch_y.to(device) outputs, _ = model(batch_x) loss = criterion(outputs, batch_y) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0 ) optimizer.step() total_loss += loss.item() if (epoch + 1 ) % 10 == 0 : avg_loss = total_loss / len (dataloader) print (f"Epoch [{epoch+1 } /{EPOCHS} ], Loss: {avg_loss:.4 f} " )
文本生成
训练完成后,可以用模型生成新文本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 def generate_text (model, start_str, length=200 , temperature=1.0 ): """ 生成文本 model: 训练好的模型 start_str: 起始字符串 length: 生成长度 temperature: 温度参数(控制随机性) """ model.eval () current_seq = [char_to_idx[ch] for ch in start_str] generated = start_str with torch.no_grad(): for _ in range (length): x = torch.LongTensor([current_seq[-seq_length:]]).to(device) output, _ = model(x) output = output / temperature probs = torch.softmax(output, dim=1 ).cpu().numpy()[0 ] next_char_idx = np.random.choice(len (probs), p=probs) next_char = idx_to_char[next_char_idx] generated += next_char current_seq.append(next_char_idx) return generated start_str = "Deep learning" generated_text = generate_text(model, start_str, length=300 , temperature=0.8 ) print (generated_text)
温度参数 的作用: - :输出更确定,选择最可能的字符(保守) - :正常采样 - :输出更随机,增加多样性(创造性)
结果分析
经过训练,模型能够生成语法基本正确、风格相似的文本。虽然不一定有意义,但已经学会了:
- 单词的拼写规律 - 空格和标点的使用 - 常见词组的搭配
这个简单的字符级模型展示了 RNN
的强大学习能力。如果使用更大的数据集和更深的网络,可以生成更高质量的文本。
实战:简单机器翻译
任务描述
让我们构建一个英语到德语 的简单翻译系统,使用 Seq2Seq
架构。为了简化,我们使用一个小型数据集,只翻译短句子。
数据准备
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 import torchimport torch.nn as nnfrom torch.utils.data import Dataset, DataLoaderimport randomparallel_corpus = [ ("I am a student" , "Ich bin ein Student" ), ("He is a teacher" , "Er ist ein Lehrer" ), ("She likes music" , "Sie mag Musik" ), ("We are learning" , "Wir lernen" ), ("They have a dog" , "Sie haben einen Hund" ), ("I love programming" , "Ich liebe Programmierung" ), ("The weather is nice" , "Das Wetter ist sch ö n" ), ("Good morning" , "Guten Morgen" ), ("How are you" , "Wie geht es dir" ), ("Thank you very much" , "Vielen Dank" ), ] def build_vocab (sentences ): vocab = {'<PAD>' : 0 , '<SOS>' : 1 , '<EOS>' : 2 , '<UNK>' : 3 } idx = 4 for sent in sentences: for word in sent.split(): if word not in vocab: vocab[word] = idx idx += 1 return vocab en_sentences = [pair[0 ] for pair in parallel_corpus] de_sentences = [pair[1 ] for pair in parallel_corpus] en_vocab = build_vocab(en_sentences) de_vocab = build_vocab(de_sentences) print (f"英语词汇表大小: {len (en_vocab)} " )print (f"德语词汇表大小: {len (de_vocab)} " )idx_to_de = {idx: word for word, idx in de_vocab.items()} def encode_sentence (sentence, vocab, max_len=20 ): tokens = ['<SOS>' ] + sentence.split() + ['<EOS>' ] indices = [vocab.get(token, vocab['<UNK>' ]) for token in tokens] if len (indices) < max_len: indices += [vocab['<PAD>' ]] * (max_len - len (indices)) else : indices = indices[:max_len] return indices class TranslationDataset (Dataset ): def __init__ (self, en_sentences, de_sentences, en_vocab, de_vocab ): self.en_sentences = en_sentences self.de_sentences = de_sentences self.en_vocab = en_vocab self.de_vocab = de_vocab def __len__ (self ): return len (self.en_sentences) def __getitem__ (self, idx ): en_encoded = encode_sentence(self.en_sentences[idx], self.en_vocab) de_encoded = encode_sentence(self.de_sentences[idx], self.de_vocab) return torch.LongTensor(en_encoded), torch.LongTensor(de_encoded) dataset = TranslationDataset(en_sentences, de_sentences, en_vocab, de_vocab) dataloader = DataLoader(dataset, batch_size=4 , shuffle=True )
定义 Seq2Seq 模型
我们使用之前实现的 Encoder 和 Decoder:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 class Encoder (nn.Module): def __init__ (self, input_size, embedding_dim, hidden_size ): super (Encoder, self).__init__() self.embedding = nn.Embedding(input_size, embedding_dim, padding_idx=0 ) self.lstm = nn.LSTM(embedding_dim, hidden_size, batch_first=True ) def forward (self, x ): embedded = self.embedding(x) _, (hidden, cell) = self.lstm(embedded) return hidden, cell class Decoder (nn.Module): def __init__ (self, output_size, embedding_dim, hidden_size ): super (Decoder, self).__init__() self.embedding = nn.Embedding(output_size, embedding_dim, padding_idx=0 ) self.lstm = nn.LSTM(embedding_dim, hidden_size, batch_first=True ) self.fc = nn.Linear(hidden_size, output_size) def forward (self, x, hidden, cell ): embedded = self.embedding(x) output, (hidden, cell) = self.lstm(embedded, (hidden, cell)) prediction = self.fc(output) return prediction, hidden, cell class Seq2Seq (nn.Module): def __init__ (self, encoder, decoder, device ): super (Seq2Seq, self).__init__() self.encoder = encoder self.decoder = decoder self.device = device def forward (self, src, trg, teacher_forcing_ratio=0.5 ): batch_size, trg_len = trg.shape trg_vocab_size = self.decoder.fc.out_features outputs = torch.zeros(batch_size, trg_len, trg_vocab_size).to(self.device) hidden, cell = self.encoder(src) input_token = trg[:, 0 ].unsqueeze(1 ) for t in range (1 , trg_len): output, hidden, cell = self.decoder(input_token, hidden, cell) outputs[:, t, :] = output.squeeze(1 ) teacher_force = random.random() < teacher_forcing_ratio top1 = output.argmax(2 ) input_token = trg[:, t].unsqueeze(1 ) if teacher_force else top1 return outputs EMBEDDING_DIM = 64 HIDDEN_SIZE = 128 device = torch.device('cuda' if torch.cuda.is_available() else 'cpu' ) encoder = Encoder(len (en_vocab), EMBEDDING_DIM, HIDDEN_SIZE).to(device) decoder = Decoder(len (de_vocab), EMBEDDING_DIM, HIDDEN_SIZE).to(device) model = Seq2Seq(encoder, decoder, device).to(device) criterion = nn.CrossEntropyLoss(ignore_index=0 ) optimizer = torch.optim.Adam(model.parameters(), lr=0.001 )
训练
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 EPOCHS = 200 model.train() for epoch in range (EPOCHS): total_loss = 0 for src, trg in dataloader: src, trg = src.to(device), trg.to(device) optimizer.zero_grad() output = model(src, trg) output = output[:, 1 :, :].reshape(-1 , output.shape[2 ]) trg = trg[:, 1 :].reshape(-1 ) loss = criterion(output, trg) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0 ) optimizer.step() total_loss += loss.item() if (epoch + 1 ) % 20 == 0 : avg_loss = total_loss / len (dataloader) print (f"Epoch [{epoch+1 } /{EPOCHS} ], Loss: {avg_loss:.4 f} " )
翻译推理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 def translate (model, sentence, en_vocab, de_vocab, idx_to_de, device, max_len=20 ): """ 翻译一个句子 """ model.eval () src = torch.LongTensor([encode_sentence(sentence, en_vocab)]).to(device) with torch.no_grad(): hidden, cell = model.encoder(src) trg_idx = de_vocab['<SOS>' ] translated = [] for _ in range (max_len): trg_tensor = torch.LongTensor([[trg_idx]]).to(device) output, hidden, cell = model.decoder(trg_tensor, hidden, cell) pred_token = output.argmax(2 ).item() if pred_token == de_vocab['<EOS>' ]: break translated.append(idx_to_de[pred_token]) trg_idx = pred_token return ' ' .join(translated) test_sentences = [ "I am a student" , "She likes music" , "Good morning" , ] for sent in test_sentences: translation = translate(model, sent, en_vocab, de_vocab, idx_to_de, device) print (f"英语: {sent} " ) print (f"德语: {translation} \n" )
结果与改进
由于数据集很小(只有 10
个句子),模型主要是记住了这些句子。但这个例子展示了机器翻译的完整流程。在实际应用中,可以通过以下方式改进:
使用大规模平行语料 :如 WMT
数据集(数百万句对)
添加注意力机制 :大幅提升长句翻译质量
使用 Beam
Search :解码时不只选最可能的词,而是保留多个候选,提高翻译质量
数据增强 :回译、词替换等技术
使用 Transformer :现代机器翻译的主流架构
❓ Q&A: RNN
与序列建模常见问题
Q1: 为什么 RNN 能处理变长序列,而前馈网络不能?
前馈网络的输入维度是固定的,无法处理不同长度的序列。而 RNN
通过参数共享和循环连接,对序列的每个元素应用相同的计算,因此天然支持变长输入。可以把
RNN
看作是"展开"后的深度网络,展开的层数等于序列长度,但所有层共享权重。
Q2: 梯度消失问题的根本原因是什么?
梯度在反向传播时需要连乘多个小于 1
的数(权重矩阵的特征值和激活函数导数)。经过多个时刻后,这些连乘会导致梯度指数级衰减到接近
0,使得模型无法学习长距离依赖。数学上,如果每步梯度乘以 0.9,经过 100
步后梯度变成 ,几乎消失。
Q3: LSTM 如何解决梯度消失?
LSTM 的关键是细胞状态的加法更新 。在反向传播时,梯度通过加法传递,不涉及权重矩阵的连乘,因此能够长距离传播。遗忘门
如果接近 1,梯度就能几乎无损地回传。这种设计让 LSTM
能够捕捉跨越数百个时刻的依赖关系。
Q4: LSTM 和 GRU 该如何选择?
如果需要快速迭代实验或计算资源有限,优先选择
GRU(参数少、训练快)。如果任务需要极长的记忆(如文档级建模)且数据充足,选择
LSTM(记忆能力更强)。实践中,两者性能往往相近,可以都试试,选表现更好的。大多数情况下,
GRU 是更高效的起点。
Q5: 双向 RNN 为什么不能用于文本生成?
文本生成是自回归的:生成第 个词时,只能看到前
个词,不能看到未来的词(因为还没生成)。而双向 RNN
需要完整的序列才能计算后向隐藏状态,因此不适用。双向 RNN
主要用于编码任务(如文本分类、序列标注),在这些任务中,完整的输入是已知的。
Q6: 什么是教师强制( Teacher
Forcing),它有什么问题?
教师强制是训练 Seq2Seq
时的一种策略:解码器的每一步输入使用真实标签,而非上一步的预测。这加速了训练,但会导致暴露偏差 :训练时模型总是看到正确答案,测试时却必须用自己的预测,导致错误累积。解决方法是在训练后期逐渐降低教师强制的概率,让模型适应自己的输出。
Q7: 注意力机制解决了 Seq2Seq 的什么问题?
Seq2Seq
的瓶颈是编码器必须将整个输入压缩到固定维度的向量,导致信息丢失。注意力机制让解码器在每个时刻都能"回头看"编码器的所有隐藏状态,动态聚焦于相关部分。这消除了信息瓶颈,大幅提升了长句翻译质量,并提供了可解释性。
Q8: 如何调试 RNN 训练时的梯度爆炸?
使用梯度裁剪 :torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0)。这会在梯度范数超过阈值时将其缩放,避免数值溢出。此外,检查学习率是否过大、权重初始化是否合理。如果问题仍然存在,考虑使用
LSTM/GRU 而非基础 RNN 。
Q9: 多层 RNN 的层数越多越好吗?
不一定。更多层意味着更强的表达能力,但也带来过拟合风险、训练难度增加、计算成本上升。实践中,
2-4 层通常足够。如果要堆叠更深(如 6-8 层),建议添加残差连接和 Layer
Normalization,否则很难训练。根据数据量选择:小数据集用 2-3
层,大数据集可以尝试 4-6 层。
Q10: RNN 在现代 NLP 中还有应用吗?
尽管 Transformer 在大多数任务上超越了 RNN, RNN 仍有其价值:(
1)在小数据集 上, RNN 的参数效率更高;(
2)在实时流处理 中, RNN 可以逐个处理输入,而
Transformer 需要完整序列;(
3)在资源受限的设备 上(如手机), RNN 更轻量。此外,
RNN 的思想(循环连接、状态更新)启发了许多新架构,如 Neural Turing
Machine 和 Differentiable Neural Computer 。
通过本文,我们从 RNN
的基本原理出发,深入探讨了梯度问题的根源与解决方案,详细剖析了 LSTM 和
GRU 的门控机制,介绍了 Seq2Seq
架构和注意力机制,并通过两个实战项目掌握了序列建模的核心技术。 RNN
家族虽然在某些任务上被 Transformer
超越,但其设计思想和问题解决方法对理解深度学习至关重要。掌握
RNN,为进一步学习 Transformer 和大模型打下坚实基础。