想象一队接力运动员正在跑一条看不见终点的赛道。他们手里传递的不是普通接力棒,而是一本可以不断更新的“记忆笔记本”——每一位选手接过笔记本,翻看当前赛道边的风景(新的输入),结合前面写下的记录(旧记忆),在脑海中整理出新的心得,再传给下一位选手。这本笔记本就是 RNN 的“隐状态”,每一位选手就是网络在某个“时间步”上的处理动作。更棘手的是,不同小组的赛道长短还不一样:有的句子只有 5 个字,有的却有 100 个字。怎么让长短不一的比赛公平进行?我们可以给短赛道补上“虚拟路段”(padding),并请裁判忽略这些路段的成绩(masking);还可以安排两支队伍从两头同时开跑,让每个位置都知道前面和后面发生了什么(双向 RNN)。本篇文章将用这场接力赛,带你彻底看懂 RNN 循环结构如何优雅地处理变长序列。
序列建模是 AI 从业者的基本功:无论做聊天机器人、语音识别还是股价预测,理解 RNN 的循环与隐状态机制,你才能真正明白 Transformer 出现之前的世界长什么样,也为理解现代大模型铺好台阶。
可视化演示
本节配有交互动效(进入视口后自动加载,约 3KB)。
RNN前向传播:隐状态循环传递 隐状态更新公式隐状态编码当前输入+历史记忆 \u2192 传递给下一时间步所有时间步共享同一组参数 W_h, W_x, bh_t = tanh(W_h * h_{t-1} + W_x * x_t + b)2. 注意力权重:mask...
序列数据:有“先后”的世界
想象你正在读一本小说。你不可能同时看见第 1 页和第 100 页的内容,必须一页一页翻;而且第 100 页的剧情之所以精彩,恰恰因为你记住了第 1 页埋下的伏笔。文本、语音、股价、视频帧,这些数据天然带着“先后顺序”,统称为序列数据。与之相对,一张照片的所有像素是同时呈现的——图像模型可以“一眼看全”,但面对序列数据,模型必须学会“逐页阅读并记住前面讲了什么”。
用数学语言描述,一条序列就是一组按时间步排列的向量:第 t 个字(或第 t 秒的语音帧)用一个 d 维向量 \(x_t\) 表示,整条序列就是 \(x_1, x_2, \ldots, x_T\)。这里 \(T\) 是序列长度。注意一个重要事实:\(T\) 不固定——今天的新闻可能有 500 字,明天的可能只有 30 字。这种“长度可变”的特点,给深度学习模型的批量训练出了道难题:矩阵运算要求形状整齐,长短不一的序列怎么塞进同一个批次?
为了处理这类数据,RNN 引入了“隐状态” \(h_t\) 的概念:它是一个不断被更新的向量,记录“从序列开头到当前时刻”的浓缩信息。每读入一个新的 \(x_t\),RNN 就结合上一时刻的 \(h_{t-1}\),计算新的 \(h_t\)。输入顺序一旦颠倒,\(h_t\) 的内容就完全不同,输出也跟着变——这正是 RNN 能捕捉顺序信息的根本原因。
回到小说的比喻:RNN 就是你读书时的“工作记忆”。它逐页翻看,每翻一页就把前面的剧情与当前页的内容整合进脑海里。你不会忘记开头出场的人物,因为记忆已经随着翻页一路传递下来——这就是 RNN 的起点。
- \(x_t\)(第 t 步输入)句子中第 t 个词的向量表示,维度为 \(d\),就像小说第 t 页的画面。
- \(t = 1,\ldots,T\)(时间步索引)从第 1 步依次处理到第 \(T\) 步,而 \(T\) 可以随样本变化。
循环单元:接力棒运动员
现在把“读书”换成“接力赛”。每位接力运动员的职责是:接过上一棒递来的接力棒(旧记忆 \(h_{t-1}\)),看看当前跑道边的风景(新输入 \(x_t\)),在脑中迅速整理——“旧的哪些还要保留?新的哪些要记下来?”——然后带着更新后的记忆(新隐状态 \(h_t\))跑向下一棒。任何一棒手里拿着的记忆,都是“从起点到这里整段路程的浓缩”。
这个“接棒—融合—记忆”的动作,对应 RNN 单元里一个关键公式。它把上一隐状态 \(h_{t-1}\) 与当前输入 \(x_t\) 分别乘上各自的权重矩阵,相加后经过非线性激活函数压缩,得到新隐状态 \(h_t\)。
权重矩阵 \(W_{hh}\) 决定“上一记忆的影响力度”,\(W_{xh}\) 决定“当前输入的影响力度”,偏置 \(b_h\) 是运动员的“个人习惯”。tanh 激活函数把结果压到 \((-1, 1)\),防止记忆无限膨胀——就像运动员给自己打气时也要保持冷静,情绪值不能失控。
所以,一位接力运动员更新记忆的方式就是“旧记忆 × 重视程度 + 新画面 × 关注程度 + 个人习惯”,再压一压情绪。这套动作对所有接力手完全一致——这正是“权重共享”的含义。
- 旧记忆加权上一时刻的隐状态左乘 \(W_{hh}\),决定旧记忆对当前的影响强度。
- 当前输入加权当前输入左乘 \(W_{xh}\),决定新信息被记住多少。
- 相加并加偏置旧记忆与新信息合成“候选记忆”,偏置调整基准线。
- tanh 压缩把候选记忆压缩到 \([-1, 1]\),相当于给记忆“降温”,防止数值膨胀。
前向传播:逐棒传递
比赛开始了。第 1 棒起跑时手里没有接力棒——对应初始隐状态 \(h_0\) 通常设为全零向量。每一棒拿到接力棒后,除了更新记忆,还会举起一块“信息牌”(输出 \(y_t\)),让观众看到目前比赛进行到哪一步。所有运动员遵循同一本规则手册(同一套权重),跑完整个赛道,观众就看到了一串完整的信息牌序列。
在 RNN 中,“举牌”就是计算输出:得到隐状态 \(h_t\) 后,通过权重矩阵 \(W_{hy}\) 把它映射到输出空间(比如词典大小),再用 softmax 变成概率分布,表示“下一个词是词典中每个词的概率”。
关键点在于权重共享——不管序列多长,\(W_{hh}\)、\(W_{xh}\)、\(W_{hy}\) 在所有时间步复用。对比全连接网络,参数数量不会随输入长度增长,这让 RNN 面对超长句子不会参数爆炸。
整个前向传播就像一场接力赛的全程录像:从第 1 棒到第 \(T\) 棒,每位选手都按同一本规则手册接棒、更新记忆、举牌。观众最终看到的是每个时刻的“信息牌”。
- 当前记忆 \(h_t\)截止到第 t 个时间步浓缩的全部信息。
- 映射到输出空间\(W_{hy} h_t + b_y\) 把记忆向量变成每个候选词的得分,好比给每个词打分。
- softmax 归一化将分数变成和为 1 的概率分布,得分最高者就是模型预测的下一个词。
变长输入:不同长度的赛道
现在问题来了:一场训练中有三条赛道,长度分别是 100 米、50 米、200 米,裁判怎么同时发令?最笨的办法是“强行拉齐”——给短赛道补建“虚拟跑道”,让它看起来也是 200 米。补出来的路段没有真实选手,只有假人立在那里;裁判计成绩时忽略假人路段。补建虚拟路段的过程叫 padding,裁判忽略假人路段的做法叫 masking。
训练时,GPU 喜欢“方方正正”的矩阵。如果一批里有长句有短句,直接塞进模型会报错。所以常见做法是:找到这一批里最长的句子,把所有短句末尾补上特殊符号(通常是 0 或 [PAD]),让整批句子长度一致。masking 则是一个 0/1 掩码:真实位置是 1,padding 位置是 0。
如果不做 masking,模型会学到“句子末尾有个 [PAD] 符号”这种无意义规律,严重时把 padding 位置当成真实输出目标来优化。padding 在词嵌入后参与矩阵运算,所以“是否忽略它”必须由掩码显式告诉损失函数。
有了“虚拟跑道”和“裁判忽略”这两件法宝,长短不一的句子就能在同一条起跑线上公平竞赛。这也是工程中处理变长序列最核心、最常用的套路。
- 掩码 \(m_t\)真实位置记 1,padding 位置记 0,相当于“这段路是否算成绩”。
- 交叉熵项\(y_t \log \hat{y}_t\) 衡量真实标签 \(y_t\) 与预测概率 \(\hat{y}_t\) 的偏差。
- 对时间步求和把所有真实位置的损失累加起来。
- 除以真实位置总数只在真实位置上求平均,避免 padding 位置稀释梯度信号。
双向循环:双向接力赛
普通接力赛只从起点跑向终点,到了最后才知道全程发生了什么。但理解一句话往往需要“前后夹击”:句子“小明把李雷打了”——到底谁挨打,要看后半句才明白。如果能安排两支队伍同时起跑,A 队从左跑到右,B 队从右跑到左,两支队伍在中途相遇时交换记忆,那么每个位置都能同时拥有“前面的伏笔”和“后面的回响”。
这个想法就是双向 RNN(BiRNN)。它用两套独立的 RNN 参数处理同一个序列:前向 RNN 按 \(x_1 \to x_2 \to \cdots \to x_T\) 更新隐状态 \(\overrightarrow{h_t}\);后向 RNN 按 \(x_T \to x_{T-1} \to \cdots \to x_1\) 更新隐状态 \(\overleftarrow{h_t}\)。每个时间步的最终隐状态是两者的拼接。
拼接操作 [;] 把两个向量首尾相连。假设前向隐状态 100 维、后向 100 维,拼接后就是 200 维,同时携带“过去”与“未来”的信息。后文涉及的 BiLSTM、Transformer 中的双向注意力,都是对“只看一遍”的改进。
双向接力赛让每位选手都拥有“左右脑”——左脑知道前面发生了什么,右脑知道后面将要发生什么。对于翻译、序列标注等需要全局理解的任务,双向 RNN 往往是标配。
- 前向隐状态从序列开头按正序更新,浓缩“从第 1 步到第 t 步”的信息。
- 后向隐状态从序列末尾按逆序更新,浓缩“从最后一步到第 t 步”的信息。
- 拼接表示两个向量首尾相连,得到两倍维度的综合表示。
梯度传播:成绩复盘难题
比赛结束,教练要复盘了。教练想搞清楚:“第 1 棒选手训练时有没有偷懒?”但问题来了——第 1 棒的影响要传过第 2、3、……、第 T 棒才能显现,而每一棒传达时都会“打一点折扣”。如果打了 50 棒折扣,第 1 棒的存在感早就消失了。这就是 RNN 著名的“梯度消失”:训练时,离当前时间步太远的记忆几乎得不到有效的梯度更新。
训练 RNN 使用反向传播,但因为 RNN 每个时间步共享权重,梯度需要沿时间步回溯——术语叫 BPTT(Backpropagation Through Time,时间反向传播)。在数学上,从第 T 步到第 1 步的梯度是一个非常长的连乘式。
连乘式中每一项 \(\partial h_t / \partial h_{t-1}\) 与 tanh 的导数有关:tanh 导数最大为 1,如果权重矩阵的谱范数又小于 1,连乘几十步后梯度趋向 0——梯度消失;反之如果权重矩阵特征值大于 1,连乘结果爆炸——梯度爆炸。工程上常用梯度裁剪防止爆炸,而梯度消失则激发了 LSTM、GRU 等“带传送带”改进结构的诞生。
教练复盘时发现:第 T 棒的错误很容易传给第 T-1、T-2 棒,但要追溯到第 1 棒,中间隔了太多“折扣关卡”。要解决它,要么加一条“直达通道”(如 LSTM 的记忆单元),要么换一种“短距离传播”的架构(如 Transformer)——这就是后文将要展开的伏笔。
- 最终梯度(起点)损失对最后隐状态 \(h_T\) 的偏导,是回传的出发点。
- 连乘符号梯度经过每一个时间步都要乘一个中间项,从 \(t=2\) 连乘到 \(T\)。
- 单步雅可比\(\partial h_t / \partial h_{t-1}\) 是每步的“折扣因子”,内含 tanh 导数与权重矩阵。
- 连乘效果多个不超过 1 的数连乘趋近 0 → 梯度消失;多个大于 1 的数连乘趋向无穷 → 梯度爆炸。
AI 应用场景
文本情感分类
阶段:数据预处理与模型训练
解决的问题:用户评论长短不一,从“好”到上千字,批量训练要求所有样本形状一致。传统做法是截断或填充,但粗暴截断会丢失关键信息,填充不掩码又会让模型学到“句子末尾总是出现特殊符号”的伪规律。用 padding + masking 后,模型只在真实文本上计算损失,长句短句都能公平参与训练。
具体案例:某电商平台的评论打分系统。工程师把每条评论转成词向量序列,按批次统计最长评论长度并填充 [PAD],同时生成 0/1 掩码矩阵;送入双向 RNN 后,两个方向的隐状态拼接成最终表示,再接一个全连接层做情感二分类。短评“不错”和长评“物流很慢但客服态度很好”都能被正确处理。
机器翻译的编码端
阶段:模型结构与训练策略
解决的问题:逐词翻译会丢失语序与上下文。英文 “I love you“ 中 ”love“ 的翻译受左右词共同影响:左边是主语、右边是宾语,它更可能被译为”爱“而非”喜欢”。单向 RNN 只能看到左边的词,无法利用右侧信息。
具体案例:早期神经机器翻译系统的编码器使用双向 RNN:前向隐状态从句子开头读到结尾,后向隐状态从结尾读到开头,每个位置的最终表示是两者的拼接,再传给解码器。翻译“bank“时会同时考虑左边的主语和右边的介词,从而区分”河岸“与”银行”。
Python 演示
import numpy as np def sigmoid(x): return 1.0 / (1.0 + np.exp(-x)) def forward_rnn(X, W_hh, W_xh, b_h, W_hy, b_y): """朴素 RNN 前向传播: 逐时间步更新隐状态并输出""" d = W_hh.shape[0] h = np.zeros(d) # h0 初始化为全零向量 outputs = [] for t in range(len(X)): h = np.tanh(W_hh @ h + W_xh @ X[t] + b_h) y = sigmoid(W_hy @ h + b_y) outputs.append(y) return outputs, h def pad_and_mask(batch, max_len): """把长度不一的序列补齐到 max_len, 返回填充张量与掩码""" d = batch[0].shape[-1] X_pad = np.zeros((len(batch), max_len, d)) mask = np.zeros((len(batch), max_len), dtype=np.float32) for i, seq in enumerate(batch): L = len(seq) X_pad[i, :L] = seq mask[i, :L] = 1.0 return X_pad, mask def masked_loss(outputs, targets, mask): """只在真实位置(mask=1)上计算二元交叉熵损失""" loss, total = 0.0, 0.0 for y, tag, m in zip(outputs, targets, mask): if m == 1.0: loss += -(tag * np.log(y + 1e-9) + (1 - tag) * np.log(1 - y + 1e-9)) total += 1.0 return loss / total if __name__ == "__main__": np.random.seed(0) d, hidden, out = 4, 8, 1 W_hh = np.random.randn(hidden, hidden) * 0.1 W_xh = np.random.randn(hidden, d) * 0.1 b_h = np.zeros(hidden) W_hy = np.random.randn(out, hidden) * 0.1 b_y = np.zeros(out) X1 = np.random.randn(3, d) # 长度 3 X2 = np.random.randn(2, d) # 长度 2 X_pad, mask = pad_and_mask([X1, X2], max_len=3) print("pad 后 batch 形状:", X_pad.shape) print("mask 矩阵:\n", mask) outs1, h1 = forward_rnn(X1, W_hh, W_xh, b_h, W_hy, b_y) outs2, h2 = forward_rnn(X2, W_hh, W_xh, b_h, W_hy, b_y) print("样本1 输出数:", len(outs1), "最终隐状态:", h1.shape) print("样本2 输出数:", len(outs2), "最终隐状态:", h2.shape) targets = [1.0, 0.0, 1.0] # 样本1 的三个真实标签 loss = masked_loss(outs1, targets, mask[0]) print("带 mask 的损失:", round(loss, 4))
h = np.zeros(d)初始隐状态置零,象征第一棒运动员手里没有接力棒。h = np.tanh(W_hh @ h + W_xh @ X[t] + b_h)这是 RNN 核心更新公式:旧记忆与当前输入融合后压缩到 [-1, 1]。X_pad = np.zeros((len(batch), max_len, d))padding 的实现:先开辟“最长长度”的全零张量,再把每个样本填进去。mask[i, :L] = 1.0真实位置标记为 1,padding 位置保持 0,这就是 masking。if m == 1.0:只在真实位置上累加损失,实现“裁判忽略虚拟路段”的效果。
常见误区
误区“RNN 有隐状态能记住过去的所有信息,所以句子再长也没问题。”
正解隐状态只是一个有限维向量,就像接力运动员的记忆容量有限。更关键的是训练阶段的梯度消失——梯度沿时间步连乘后趋向零,早期信息得不到有效学习。朴素 RNN 实际只能记住几步之内的信息,长序列要依赖 LSTM/GRU 或 Transformer。
为何常见:RNN 公式里确实写入了 \(h_{t-1}\),形式上像“所有历史都被卷进来了”,让人误以为记忆无损。
误区“padding 之后模型自然知道哪些是填充位置,不需要额外处理。”
正解模型没有上帝视角,padding 位置和真实位置在数值计算上完全一样。如果不提供 mask,损失函数会把“预测出 [PAD]”当成合理目标去优化,模型会学到垃圾规律。必须手动把 mask 传给损失函数,才算真正实现“忽略填充位置”。
为何常见:很多框架的 API 中 [PAD] 看起来是特殊值,初学者误以为框架内置了忽略逻辑。
误区“双向 RNN 能看到未来信息,效果肯定比单向好,所以所有任务都该用双向。”
正解双向 RNN 要求完整序列已知,离线任务(如文本分类)没问题,但实时流式任务(逐字语音识别、实时股价预测)根本等不到“未来”到来。而且它的参数约是单向的两倍,训练和推理成本更高,在数据量小时还容易过拟合。
为何常见:离线 NLP 比赛中双向确实经常优于单向,这种经验被过度推广到所有场景。
误区“RNN 展开后每个时间步都是独立网络,各有一套自己的参数。”
正解RNN 的关键设计恰恰是权重共享——所有时间步复用同一套 \(W_{hh}\)、\(W_{xh}\)、\(W_{hy}\)。如果每个时刻一套参数,参数量会随序列长度线性膨胀,模型也无法泛化到训练中没见过的长度。展开图只是为了形象展示,真实参数只有一套。
为何常见:展开图把每个时间步画成独立节点,视觉上很像“多个网络串联”。
面试题
Q1. RNN 中用来“记忆历史信息”的核心结构是什么?
- A.全连接神经网络层
- B.隐状态向量 h_t← 正确答案
- C.卷积特征提取核
- D.注意力权重矩阵
答案:B。RNN 的“记忆”存储在隐状态 \(h_t\) 中,它沿时间步循环传递。全连接层没有时间维度;卷积核用于提取局部特征;注意力矩阵来自 Transformer 机制,与 RNN 隐状态无关。
Q2. 下列关于 RNN 权重共享的说法,正确的是?
- A.每个时间步独享一套权重
- B.所有时间步共享同一套权重← 正确答案
- C.输入层和输出层共享同一套权重
- D.不同隐藏层之间不共享权重
答案:B。权重共享是 RNN 区别于前馈网络的关键——所有时间步复用同一套 \(W_{hh}\)、\(W_{xh}\)、\(W_{hy}\),参数不随序列长度增加。C 错在输入层和输出层使用不同的权重矩阵。
Q3. 一个 batch 中有三个句子,长度分别为 5、10、7,padding 之后所有序列的长度是多少?
- A.补齐到最短的 5 步
- B.补齐到中间的 7 步
- C.补齐到最长的 10 步← 正确答案
- D.取三者的平均值 8 步
答案:C。padding 的目标是让 batch 内所有序列形状一致,通常取 batch 内最长序列长度,即 10。其余句子末尾补 [PAD],保证张量维度整齐。
Q4. 在处理变长序列时,masking 掩码最核心的作用是什么?
- A.提升模型推理时的计算速度
- B.忽略填充位置对训练的干扰← 正确答案
- C.增加隐藏层的神经元数量
- D.改变输入序列的读取顺序
答案:B。masking 用 0/1 掩码标识真实位置与 padding 位置,在损失函数中排除填充位置的贡献,避免模型把 [PAD] 当成真实内容学习。它不改变速度、容量或顺序。
Q5. 关于 padding 与 masking 的关系,下列说法正确的有?
- A.填充解决批次内形状不一致的问题← 正确答案
- B.掩码让模型忽略填充位置的损失← 正确答案
- C.填充操作会改变真实内容的语义
- D.被掩码的位置仍然参与损失计算
答案:AB。padding 解决 batch 内序列形状不一致,masking 让损失忽略填充位。C 错误:padding 不改变真实位置的语义;D 错误:被掩码的位置不参与损失计算。
Q6. 双向 RNN 在每个时间步如何组合两种方向的信息?
- A.前向与后向隐状态拼接或相加← 正确答案
- B.只取前向隐状态忽略后向结果
- C.两者相乘后取对数再归一化
- D.随机选择一侧隐状态作为输出
答案:A。BiRNN 将前向隐状态与后向隐状态拼接(或相加),得到同时含“过去”与“未来”信息的表示。B、C、D 都会丢失其中一侧信息,不是标准做法。
Q7. 朴素 RNN 出现梯度消失时,通常会伴随哪些现象?
- A.早期时间步几乎得不到有效训练← 正确答案
- B.模型难以学习长距离依赖关系← 正确答案
- C.反向传播梯度幅值呈指数级衰减← 正确答案
- D.只影响在线推理而不会影响训练
答案:ABC。梯度消失发生在训练阶段的反向传播,使早期时间步梯度趋近 0、无法学习长距离依赖。D 错误:推理阶段不涉及梯度回传,消失只影响训练。
Q8. RNN 单元里 tanh 激活函数的主要作用是什么?
- A.把隐状态压缩到区间防止数值失控← 正确答案
- B.把原始输出变成概率分布形式
- C.反向放大梯度以加速模型收敛
- D.减少网络的可学习参数的数量
答案:A。tanh 将融合结果压缩到 \([-1,1]\),防止隐状态在时间步更新中无限膨胀。把输出变成概率分布的是 softmax;tanh 不放大梯度,也不减少参数数量。
Q9. 哪些原因导致了朴素 RNN 的梯度消失?
- A.时间步之间连乘多个小于等于一的导数← 正确答案
- B.tanh 激活函数导数小于等于 1← 正确答案
- C.BPTT 需要从最后一步回传至首步← 正确答案
- D.该问题与序列长度的变化无关
答案:ABC。BPTT 中梯度从第 T 步回传到第 1 步,需要连乘多个 \(\partial h_t/\partial h_{t-1}\),其中含 tanh 导数(≤1),连乘后趋近 0。D 错误:序列越长连乘次数越多,梯度消失越明显。
Q10. 处理变长序列时,mask 通常不需要作用在哪个位置?
- A.损失函数对填充位置的计算
- B.注意力权重对填充位置的遮蔽
- C.隐状态初始化对填充位置的置零
- D.优化器更新参数时的梯度计算← 正确答案
答案:D。mask 用于损失计算、注意力遮蔽、初始隐状态置零等“数据相关”环节;优化器更新的是网络权重参数,与数据掩码无关,因此不需要 mask 参与。