我们已经学习了如何将文本进行分词,并通过词嵌入技术(如 Word2Vec)将每个独立的词元转换成一个静态的、稠密的词向量。这解决了模型输入的第一个问题,即文本数值化。接下来的第二个关键问题是如何从一个词向量序列中,有效地提取整个序列的特征。例如,对于一个意图识别任务,需要将指令“播放周杰伦的《稻香》”归类到“音乐播放”。目前已经能得到“播放”、“周杰伦”、“的”、“《稻香》”这几个词元各自的词向量,但如何将这些向量融合成一个能代表整句指令含义的“文本向量”,并送入分类器呢?
针对将词向量序列融合成一个定长的文本向量这一需求,早期的解决方案主要集中在对词向量的简单组合上。最直接的思路是像词袋法一样,将所有词向量相加或取平均。但是这种方法完全忽略了语序信息。“我爱你” 和 “你爱我” 会得到完全相同的文本向量。更重要的是,它将所有词语视为同等重要。在“播放周杰伦的《稻香》”这句话中,“播放”和“《稻香》”显然比“的”包含更多分类所需的关键信息,但简单的求和无法体现这种差异。另一个想法是使用全连接网络(Fully Connected Network, FCN)来处理这些词向量。具体的处理方式可以是先求和后 FCN,或者是先 FCN 后求和。但无论哪种方式,为了处理变长的句子,在每个时间步(即每个词元的位置)上使用的全连接层都必须共享同一套权重参数。这就导致了一个致命缺陷,在计算任意一个词的输出时(如“《稻香》”的输出),模型仅仅使用了“《稻香》”自己的词向量,完全没有考虑到它前面出现了“播放”、“周杰伦”、“的”这些词。每个词元依然是被孤立处理的,模型无法理解词元之间的顺序关系和上下文依赖,也就是没有捕获序列特征。
在处理序列数据(如文本)时,模型按顺序逐个处理序列中的元素(如词元)。时间步就是这个处理过程中的一个离散步骤。在文本处理的上下文中,一个时间步通常对应于处理一个词元。例如,对于句子“播放周杰伦的歌”,处理“播放”是时间步 1,处理“周杰伦”是时间步 2,以此类推。
面对全连接网络在处理序列数据时的局限性,人们开始尝试借鉴计算机视觉领域的经验。在图像处理中大获成功的 CNN 也可以用于文本。通过使用一维卷积核(窗口)滑过整个词向量序列,CNN 能够捕捉到词语的局部依赖关系(如n-grams)。不过,CNN 的缺陷在于它的感受野是固定的。一个大小为3的卷积核,只能看到附近3个词的关系。虽然可以通过堆叠多层CNN来扩大感受野,但对于句子开头和结尾的长距离依赖,CNN 仍然难以有效捕捉,无法预先设定一个适用于所有句子的“最佳”窗口大小。
为了解决上述问题,需要一种新的网络结构,它必须能够“记住”在处理当前词元之前都看过了哪些信息。循环神经网络(Recurrent Neural Network, RNN)就是为满足这一需求而诞生的。RNN 的思想是在处理序列的每一步时,网络不仅接收当前时间步的输入
这种最基础的 RNN 结构通常被称为**简单循环神经网络(Simple Recurrent Network,SRN)**或 Elman Network 1。如图 3-1 展示了 RNN 单元在单个时间步内的计算流程。需要注意,为了可视化,此图将隐藏状态拆分成了两个部分(Hidden State 和 Hidden Layer),但在计算上它们是紧密关联的。
可以将图中的流程分解如下,并与公式
(1)输入($x_t$): 左侧的 Input 块代表当前时间步的输入向量
(2)前一时刻的隐藏状态($h_{t-1}$): 紫色的 "Hidden State" 块(图中用
(3)当前时刻的隐藏状态($h_t$): 橙色的 "Hidden Layer" 块(图中用
(4)状态更新: 新计算出的隐藏状态 Hidden Layer 指向 Hidden State 的紫色虚线)。
(5)输出($o_t$): 当前的隐藏状态 Hidden Layer 指向 Output 的橙色虚线),这通常需要再经过一个独立的输出层。
通过这种循环,RNN 单元在每个时间步都融合了当前输入和历史记忆,实现了信息的持续传递。
在所有的时间步中,权重矩阵
$U$ (输入到隐藏层)和$W$ (隐藏层到隐藏层)是共享的。这一机制让 RNN 能够处理任意长度的序列,并且大幅减少了模型参数。
回到最初的例子,看看 RNN 是如何解决“播放周杰伦的《稻香》”的分类问题的。首先需要准备输入,将句子分词为 ["播放", "周杰伦", "的", "《稻香》"],并转换为 ID 序列 [23, 58, 102, 203](假设),再通过词嵌入得到 4 个词向量
(1)第1步(t=1):输入第一个词向量
(2)第2步(t=2):接下来输入第二个词向量
(3)第3步(t=3):紧接着,输入第三个词向量
(4)第4步(t=4):输入第四个词向量
当 RNN 处理完最后一个词元后,得到的最终隐藏状态
通过上面的例子,能够发现 RNN 解决了静态词向量的局限性,实现了从 Type 到 Token 的跨越。静态的 Type(词嵌入)体现在 Word2Vec 等模型中,它们学到的是词在字典中的静态含义,无论“周杰伦”出现在哪里,它的输入向量
这里的 token 并不是词元的概念。在语言学中,Type(类型)指抽象的、通用的概念,而 Token(标记/实例)指该概念在特定时空下的具体表现。例如,“我一边玩黑神话悟空,一边等黑神话钟馗”这句话中,有 2 个“黑神话”的 Token,但只有一个“黑神话”的 Type。
Jeffrey Elman 在其论文 1中通过实验发现,即使不给网络任何语法规则,仅仅通过训练 RNN 预测序列中的下一个词,网络的隐藏状态空间也会自发地组织出层级结构。动词和名词会被映射到隐藏空间的不同区域,在名词区域内部,有生命和无生命的名词又会进一步区分。甚至具体的词(如 "Boy")在不同句子位置的 Token 表示,也会根据句法角色(主语 vs 宾语)聚类在一起。这证明 RNN 不仅仅是在“记忆”历史,它实际上通过预测任务,隐式地学会了语言的句法和语义结构。
动态表示 vs. 动态词向量
如果你听说过 ELMo 可能会有疑问:“实现从静态到动态跨越的,究竟是 RNN 还是后来的 ELMo?”
- 从原理机制上讲,是 RNN:正如本节所述,RNN 的隐藏状态
$h_t$ 本质上就是一个随上下文变化的动态向量。它最早具备了“根据语境改变数值”的核心能力。- 从 NLP 发展范式上讲,是 ELMo:在 ELMo (2018) 之前,大家通常只把 Word2Vec 这种查表得到的向量称为“词向量”,而把 RNN 的输出视为特定任务的中间状态。ELMo 的贡献在于它确立了**“动态词向量”**这一概念,并将其作为一种通用的、可迁移的预训练特征,改变了 NLP 的开发模式。
所以,RNN 发明了机制,而 ELMo 普及了范式。
为了将理论付诸实践并加深理解,接下来从底层的数学公式出发,用 NumPy 手写一个 RNN。
为了与后续的代码实现保持一致,此处采用一个不含偏置项(bias)的简化版 RNN,核心计算公式如下:
其中,
上述公式采用的是数学通用的列向量表示法(即
$x_t$ 是列向量,矩阵$U$ 在左)。而在后续的代码实现中,为了利用计算机内存布局的优势,通常采用行向量表示法(即$x_t$ 是行向量,矩阵$U$ 在右,计算$x_t U$ )。两者在数学上是转置关系,本质一致。
在实现 RNN 的计算过程之前,首先需要准备输入数据。我们可以先定义一个简单的词表,并为句子“播放周杰伦的《稻香》”中的每个词生成一个随机的词向量,将它们组合成形状为 (1, 4, 128) 的张量,作为 RNN 模型的输入;同时也设置了一些基本参数(例如将隐藏节点数设为 3,即 H=3,以便和前文的 RNN 结构图对应,实际应用中一般会远大于 3),并通过 prepare_inputs 函数将这一数据准备过程封装起来。具体代码如下:
import numpy as np
# (B, T, E, H) 分别表示 批次/序列长度/输入维度/隐藏维度
B, E, H = 1, 128, 3
def prepare_inputs():
"""
使用 NumPy 准备输入数据
使用示例句子: "播放 周杰伦 的 《稻香》"
构造最小词表和随机(可复现)词向量, 生成形状为 (B, T, E) 的输入张量。
"""
np.random.seed(42)
vocab = {"播放": 0, "周杰伦": 1, "的": 2, "《稻香》": 3}
tokens = ["播放", "周杰伦", "的", "《稻香》"]
ids = [vocab[t] for t in tokens]
# 词向量表: (V, E)
V = len(vocab)
emb_table = np.random.randn(V, E).astype(np.float32)
# 取出序列词向量并加上 batch 维度: (B, T, E)
x_np = emb_table[ids][None]
return tokens, x_np为了将 RNN 的数学公式
(1)初始化: 创建一个全零的初始隐藏状态 h_prev,作为处理每个序列开始前的“空白记忆”。
(2)逐帧处理: 使用循环遍历序列中的每一个时间步(词元)。
(3)核心计算: 在循环内部,实现 tanh 激活函数,得到融合了上下文的新隐藏状态
(4)状态更新: 将当前计算出的 h_prev,作为下一个时间步的输入,实现“循环”传递。
(5)结果保存: 将每一步计算出的隐藏状态
具体实现如下:
def manual_rnn_numpy(x_np, U_np, W_np):
B_local, T_local, _ = x_np.shape
# 初始化 h_0 为零向量
h_prev = np.zeros((B_local, H), dtype=np.float32)
steps = []
# 按时间步循环
for t in range(T_local):
x_t = x_np[:, t, :]
# 核心公式实现
h_t = np.tanh(x_t @ U_np + h_prev @ W_np)
steps.append(h_t)
h_prev = h_t # 更新状态
return np.stack(steps, axis=1), h_prevPyTorch 提供了高度封装的 nn.RNN 模块,它内部完成了与我们手写版本相同的循环计算,但经过了优化,效率更高。
def pytorch_rnn_forward(x, U, W):
rnn = nn.RNN(
input_size=E,
hidden_size=H,
num_layers=1,
nonlinearity='tanh',
bias=False,
batch_first=True,
bidirectional=False,
)
with torch.no_grad():
# PyTorch 内部存放的是转置后的权重
rnn.weight_ih_l0.copy_(U.T)
rnn.weight_hh_l0.copy_(W.T)
y, h_n = rnn(x)
return y, h_n.squeeze(0)nn.RNN 参数解析:
-
input_size($E$): 输入特征$x_t$ 的维度。在 NLP 中,这通常是词嵌入的维度embedding_dim。 -
hidden_size($H$): 隐藏状态$h_t$ 的维度。这代表了 RNN “记忆”的容量,也是其隐藏层的节点数。 -
num_layers: RNN 的层数。默认是1。如果大于1,会构成一个“堆叠 RNN”,即前一层RNN在所有时间步的输出,会作为后一层 RNN 的输入。 -
bias: 是否使用偏置项。默认为True。如果为真,则公式会变为$h_t = \tanh(U x_t + b_{ih} + W h_{t-1} + b_{hh})$ 。在示例中设为False以便与手写版本对齐。 -
batch_first: 一个非常重要的维度顺序参数。默认为False,此时输入张量的形状应为(T, B, E)。在代码中设为True,使得输入形状为更符合直觉的(B, T, E),其中B是批次大小,T是序列长度。 -
bidirectional: 是否构建一个双向RNN。默认为False。双向RNN能同时考虑过去和未来的上下文,后续章节将对此进行介绍。
验证用 NumPy 手写的 RNN 和 PyTorch 官方的nn.RNN模块在给定相同输入和相同权重时,其输出是否完全相同。
# 将NumPy结果转回PyTorch张量
out_manual = torch.from_numpy(out_manual_np)
# 使用 allclose 进行浮点数精度下的严格比较
print("逐步输出一致:", torch.allclose(out_manual, out_torch, atol=1e-6))
# 输出: True得到 True 的结果,证明我们对 RNN 数学原理的理解是正确的,而且 nn.RNN 模块内部确实执行了所理解的循环计算过程。
在前面的讨论中,RNN 结构在处理序列时,信息是单向流动的——即在计算任意时刻
为了利用未来的信息,一种早期的尝试是引入“时间延迟”(Time Delay):即在预测
BiRNN 的原理很简单,它由两个完全独立的 RNN 构成,并将它们叠加在一起处理信息。BiRNN 包含一个正向 RNN,它按照从左到右的顺序读取输入序列(例如,从
通过这种方式,
为什么不直接训练两个独立的 RNN 然后合并结果?
当然可以分别训练一个正向 RNN 和一个反向 RNN,最后将它们的输出取平均(线性意见池)或几何平均(对数意见池)。但 BiRNN 的优势在于它是在同一个损失函数下同时训练两个方向的权重。所以正向和反向的特征提取是协同进行的,能够更好地适应目标任务,而不需要假设两个方向的预测是相互独立的。而且,BiRNN 彻底消除了对“时间延迟”参数
$M$ 的依赖,模型自动利用所有可用的过去和未来信息。
由于最终的输出是两个独立 RNN 隐藏状态的拼接,如果每个 RNN 的 hidden_size 都为 nn.RNN 模块中,只需将 bidirectional 参数设置为 True 即可轻松构建一个双向 RNN。其输出 y 的最后一个维度(特征维度)将是 hidden_size 的两倍,而最终隐藏状态 h_n 的第一个维度将是 num_layers * 2,分别存储了正向和反向 RNN 在最后一个时间步的隐藏状态。
BiRNN 的主要作用是它为序列中的每个元素都提取了更完整的上下文相关的特征,而不是像单向 RNN 那样只依赖于过去的信息,这使得它在许多 NLP 任务中(如命名实体识别、情感分析、机器翻译等)都取得了比单向 RNN 更好的效果。尽管如此,BiRNN 仍存在局限,因为首先,它并没有解决 RNN 的长距离依赖问题,无论是正向还是反向的 RNN,它们本身依然会面临梯度消失或梯度爆炸的挑战;其次,由于需要处理完整的序列才能计算反向信息,BiRNN 无法被用于需要实时预测的场景(例如,根据用户已输入的内容实时推荐下一个词)。
前面已经了解了 RNN 如何通过正向传播逐个处理序列信息。接下来,将探讨其训练机制。RNN 的训练实质是标准反向传播(Backpropagation, BP)在时间展开图上的直接应用,称为随时间反向传播(Backpropagation Through Time, BPTT) 3。具体而言,该方法将 RNN 沿时间维度展开,可视作一个各层参数共享的深层前馈网络,进而在此结构上执行通用的反向传播算法。
假设整个序列的总损失
主要的挑战在于计算单个时间步的梯度
其中,
这个连乘的形式正是 RNN 产生问题的根源所在。
尽管 RNN 在理论上可以捕捉长距离依赖,但在实践中,基于 BPTT 的反向传播在时间维度上的导数矩阵连乘,带来了以下问题。
BPTT 链式求导的关键,在于梯度的反向传播路径上会形成一个连乘项
其中
在 RNN 中,权重矩阵
梯度消失在实践中比梯度爆炸更棘手,它直接导致了长距离依赖问题(Long-term Dependency Problem) 4。
(1)反向传播视角:梯度消失意味着,模型无法学习到序列中相距遥远的词之间的依赖关系。正如 Werbos 所指出的,BPTT 本身是计算梯度的精确方法,但深层网络(或长序列)中的连乘效应是数学上的必然。例如,在句子 “孙悟空初到天庭时,被玉帝封为‘弼马温’,他嫌官小,心中不忿,便打出天门,返回花果山,自封‘齐天大圣’,后来又大闹天宫,搅乱了蟠桃盛会,偷吃了老君的金丹,最终被如来佛祖镇压在五行山下。五百年后,当有神仙旧事重提,用这个官职来称呼他时,依然是在嘲讽他。” 中,要正确理解结尾处“嘲讽”的含义,模型必须能关联到句子最开头的“弼马温”是一个低微官职这一事实。但由于两者之间间隔了极长的叙述,误差梯度在从“嘲讽”反向传播到“弼马温”时,可能已经衰减为零,导致模型无法捕捉到这种关键的远距离语义依赖。
(2)正向传播视角:也可以理解为信息遗忘或近期偏置。在正向计算过程中,每一步的信息都会被新的输入和循环权重
常规 RNN 的信息流是单向的,
要做哦,别偷懒 🙃
- 不使用大模型自行实现 RNN 手写代码,数据准备部分可直接复制
prepare_inputs
Footnotes
-
Elman, J. L. (1990). Finding structure in time. Cognitive Science, 14(2), 179-211. ↩ ↩2
-
Schuster, M., & Paliwal, K. K. (1997). Bidirectional recurrent neural networks. IEEE Transactions on Signal Processing, 45(11), 2673-2681. ↩
-
Werbos, P. J. (1990). Backpropagation through time: what it does and how to do it. Proceedings of the IEEE, 78(10), 1550-1560. ↩
-
Bengio, Y., Simard, P., & Frasconi, P. (1994). Learning long-term dependencies with gradient descent is difficult. IEEE Transactions on Neural Networks, 5(2), 157-166. ↩