如需转载,请附上链接:https://zhenghuisheng.blog.csdn.net/article/details/161878085

一、深入理解RNN的底层原理

上一篇我们讲完了《神经网络入门篇》,知道了神经网络的核心价值在哪儿:它不是靠程序员一条条写规则,而是通过大量数据,自动学习输入和输出之间的规律。从神经元、权重、偏置、激活函数,到前向传播、反向传播、梯度下降,整条链路都串起来了。

在这里插入图片描述

这一篇继续往下走。在 Transformer 出现之前,AI 圈有一个非常重要的阶段性答案,用来处理"一句话"、“一段语音”、"一串用户行为"这种带顺序的数据,它就是今天的主角:RNN(循环神经网络)

1. 回顾上篇前馈神经网络

上一篇的结论一句话就能概括:前馈神经网络,**本质上就是一个通过大量数据、不断微调参数,最终学会正确预测的过程。核心就两件事:微调和大量训练数据。**他的一个的计算公式长这样:

输出 = 激活函数(x1·w1 + x2·w2 + ... + xn·wn + 偏置)

假设我们在招一个AI 算法工程师,想用一个神经网络来给每个候选人打分。一开始权重是拍脑袋定的(真实训练里是随机初始化,思路一样)

特征 初始权重
会 Python 10
会机器学习 8
会前端 4
会唱歌 4
会跳舞 4
整体门槛(偏置) -10

按这个权重算一遍,会唱歌跳舞会前端但是不会python和机器学习的的候选人也能拿到不低的分数。显然不合理——但模型一开始并不知道。然后我们给模型喂一批真实数据:过去招进来的人里,谁表现好、谁表现一般。模型跑一轮后会发现:

  • 真正干得好的,几乎都是机器学习基础扎实的;
  • 会不会唱跳,和工作表现完全没关系

于是模型通过大量的样本,通过预测值和真实值的对比,以及正向反馈和逆向反馈,将自己把权重往这个方向调:

特征 调整后权重
会 Python 15
会机器学习 20
会前端 2
会唱歌 接近 0
会跳舞 接近 0
偏置 -15

整个过程,没有程序员手动写过一句"唱歌不重要"的规则。模型是从数据里自己学出来的。这样会唱歌跳舞会前端的人只能拿到2分,远小于15,肯定是预测不能通过面试,减小误差。其核心实现如下图

在这里插入图片描述

其训练本质就是预测 → 对比 → 反向传播,输入进来 → 一次性算完 → 输出结果 → 完事。每次预测都是从零开始,它不会记得上一次看过什么,也分不清"哪个信息先发生、哪个后发生"。

比如判断判断一张图片是不是猫,判断一个用户是不是高风险,判断一封邮件是不是垃圾等,基本一次性读完就行;但是遇到词和词有先后顺序等需要按顺序展开的,就会出现问题。比如以下例子

  • 我不觉得这个产品好” 和 “我觉得这个产品不好”——一个"不"字的位置不同,语气从"有保留"变成"直接差评";
  • 小明把球给小刚,因为他跑得快"——这个"他"是谁?要回头看前半句才知道。

其缺陷主要就是两个,一个是没有记忆,一个是分不清顺序

2. RNN 的核心原理

2.1 RNN 是什么?

RNN,全称 Recurrent Neural Network,中文叫循环神经网络。它和普通前馈网络最大的区别,可以用一句话概括:普通前馈网络只把输入往前传, RNN 会把上一步形成的“记忆”继续传给下一步。

前馈网络是看一眼快照,就下结论;RNN 是按顺序看一段录像,边看边理解。你可以把 RNN 想象成一个正在读书的人:他不是把整本书拍成照片一次性塞进脑子,而是一页一页读,读的时候脑子里会持续更新对剧情的理解。
在这里插入图片描述

如上图所示,每一不执行前会形成一个初始记忆h1,携带记忆h1一直往下一步走,每走完一步,都会将记忆不断的更新,直到更新成hn,然后形成最终的理解ht。

在这里插入图片描述

2.2 RNN 为什么叫"循环"?

第一次听到"循环神经网络"这个名字,写代码出身的同学很容易下意识联想到 while(true) 那种死循环。但 RNN 这里的"循环"完全不是这个意思。它真正想表达的是:同一个 RNN 单元被沿着时间轴展开,每一个时间步都复用同一套参数。

我们用一句话拆开来看:我/喜欢/你。它在 RNN 里的处理过程是这样的(忽略权重版):

  • 第1步:输入x1 = "我",由于前面什么都还没读过,通常会先给一个初始隐藏状态 h0 ,这一步得到隐藏状态 h1
  • 第2步:输入x2 = "喜欢",同时把上一步的 h1 一起送进同一个 RNN 单元,得到新的 h2
  • 第3步:输入 x3 = "你",同时把上一步的h2送进去,得到h3

抽象成统一的形式,每一步其实都在做同一件事(暂时先忽略权重):

x1            → h1
x2 + h1       → h2
x3 + h2       → h3
...
x_t + h_{t-1} → h_t

关键点在于:每一步用的都是同一个 RNN 单元、同一套权重 W_x、W_h、b。不是每个时间步都新建一个网络,而是同一个网络在时间维度上被反复跑了 N 次。

类似于 Java,可以这样类比:RNN 单元就像一个有状态的对象,处理序列就是不停调用它的 step(x_t, h_{t-1}) 方法。每次调用都把上一次的输出 h_{t-1} 继续传进来;对象本身(参数 W_x、W_h、b)从头到尾只有一份。

在这里插入图片描述

就是每一步都会又一个输入值x,然后会将这一步的输入值x的总结h记忆携带给下一步,然后重复携带,最终通过计算得出结论

// w1和w2两个权重值不相等
f(x) = w1x + w2h + b
// 最终计算公式如下
h_t = tanh(W_x x_t + W_h h_{t-1} + b_h)

2.3 隐藏状态(记忆h)是什么?

隐藏状态(hidden state) 的严谨说法是:它是 RNN 在当前时间步对前文信息形成的一份压缩记忆。它会尽量保留对后续判断有用的信息,但并不保证完整记住所有历史内容。

它更像是一份不断被覆盖、融合、更新的上下文摘要:每来一个新输入,旧记忆会被 W_h 重新塑形一次,和新信息相加后再过一次激活函数,压回同样大小的向量里继续往下传。

把这几点合起来看,RNN 的本质其实可以浓缩成一句话:

状态在时间上流动。 h1 传给 h2h2 传给 h3……整句话的理解,就是这条隐藏状态链一路被新输入推着往前走、不断更新的结果。模型对一句话的最终判断,并不是某一步突然"看懂"的,而是这条状态流在最后一步呈现出来的样子。

在这里插入图片描述

但流动是有代价的,固定大小的隐藏状态要承担越来越多的历史信息,越早出现的内容,被覆盖和稀释的次数就越多。在短序列里这套机制工作得相当不错;一旦序列拉长到几十步、上百步,原始 RNN 很难稳定记住长距离信息——前面提到的内容会在隐藏状态里慢慢被稀释掉。这就是 RNN 后面会遇到的"长距离依赖问题",也是 LSTM 引入门控机制的直接原因,再往后 Transformer 干脆放弃这条状态链、改用注意力机制,思路也是从这里一路演进过来的。

在这里插入图片描述

2.4 RNN 的基本计算流程(了解即可)

这一节不堆推导,但要把 RNN 一步的计算讲到"看得见、摸得着"的程度。先看最抽象的形式:

h_t = f(x_t, h_{t-1}) //每一步的记忆值
y_t = g(h_t) //每一步的输出值

它本质上是一整套神经网络计算——里面有权重矩阵(压缩记忆)、偏置项(阈值)、激活函数,和我们上一篇讲的前馈网络一层做的事情是同一类。换句话说,RNN 没有发明全新的运算,它仍然是神经网络计算,只是在原有的"加权求和 + 激活"之外,额外把上一步的隐藏状态 h_{t-1} 也作为输入塞了进来

符号一个个解释:

  • x_t:当前时间步的输入向量。如果是 NLP 任务,这一步是先把词通过 embedding(粗略理解为"把一个词查表换成一个固定长度的向量",比如 256 维浮点数)转成向量,再交给 RNN——模型从头到尾处理的都是向量,而不是汉字本身
  • h_{t-1}:上一步留下的隐藏状态,也就是"前文压缩出来的记忆";
  • h_t:当前时间步更新后的隐藏状态;
  • y_t:当前时间步的输出(不是每一步都要输出,看任务,比如句子分类只在最后一步输出);
  • fg:本质上各自就是一小段神经网络计算(权重 + 偏置 + 激活函数)。

抽象形式看完,下面是**经典 RNN(vanilla RNN)**实际用的具体形式:

h_t = tanh(W_x · x_t + W_h · h_{t-1} + b)

这一行就是 RNN 的"心脏"。逐项拆开:

  • W_x:作用在当前输入上的权重矩阵(输入参数的权重值);
  • W_h:作用在上一步隐藏状态上的权重矩阵(当前步骤的权重值);
  • b:偏置向量(阈值);
  • tanh:激活函数,把数值压到 -1 到 1 之间,给整个变换加上非线性;
  • h_t:经过这一轮加权 + 激活后,当前时间步的新隐藏状态

h_{t-1} 不是被"原样"带过来的,它会先经过 W_h 这个权重矩阵重新加权一次,得到 W_h · h_{t-1}——也就是说,上一步的隐藏状态并不会被直接丢弃,但也不会被原样保留,而是被 W_h 重新塑形后,再参与当前时间步的计算。这一点后面讲梯度消失时会反复用到,先记下:信号在时间步之间穿越,每过一步都要被 W_h 乘一次。

把整个加权和拆开看,更直观一点:

  • W_x · x_t 这一项代表"当前输入对新状态的影响"——这一步读到的新信息,给 h_t 注入新的成分,就是在上一次的记忆中注入当前文本的记忆,最后形成新的h_t
  • W_h · h_{t-1} 这一项代表"历史上下文对新状态的影响"——之前积累的记忆,经过重新加权后继续作用在当前判断上;
  • 把这两项相加,再加上偏置 b,最后过一次 tanh,就得到新的隐藏状态 h_t

注意它们是相加,不是拼接、不是相乘。一加就把"当前输入"和"历史记忆"放进了同一个加权和里,相当于让两路信号在同一个空间里融合,然后经过非线性激活,再压回固定大小的向量继续往下传。

也就是说,RNN 的核心其实不是"存档",而是"不断改写状态"h_t 不是把旧记忆原样拷一份留下来,而是在旧记忆的基础上重新整理一次当前理解——每读一个新词,模型都会把"已经知道的"和"刚刚读到的"重新揉一遍,得到一份新的上下文。换句话就是:每一步的输入除了本身的输入值和权重之外,还会携带对前一步记忆的总结一起计算,然后二者结合之后再将总结的记忆带给下一步,不会将前文具体的往下带,而是将总结的记忆往下携带

在这里插入图片描述

2.5 RNN电商场景案例

前面讲了 RNN 的概念和流程,但全是定性描述。这一节我们用真实数字把一个最简化的 RNN 跑一遍,让你看到每一步到底在算什么。还是和之前一样在电商系统中预测用户是否会下单,假设我们有一个极简版 RNN,用来判断用户这次会话的购买意图。用户在电商 App 上产生了 3 步行为:

浏览商品 → 加入购物车 → 领取优惠券

模型读完这 3 步之后,需要输出一个"购买意图分数",分数越高代表越可能下单。为了让计算可以手算跑通,做几个简化设定:

项目 设定
输入向量维度 长度为 2(真实模型一般是几十到几百维)
隐藏状态维度 长度为 2
激活函数 tanh(把数值压到 -1 ~ 1)

3 个行为先编码成数字向量。在真实系统里,行为通常会先映射成 embedding 向量,这些 embedding 参数会随着训练不断更新(原理和前馈网络里调权重一样,只不过那边调的是 W 矩阵,这边调的是每个行为对应的向量)。这里为了让你能手算把 RNN 的计算流程跑清楚,直接人为指定三个 2 维向量,不代表真实的业务含义:

时间步 行为 输入向量
t1 浏览商品 x1 = [1.0, 0.0]
t2 加入购物车 x2 = [0.0, 1.0]
t3 领取优惠券 x3 = [1.0, 1.0]

RNN 每一步的计算公式如下,上面有详细的讲解每个值的意义:

h_t = tanh(W_x · x_t + W_h · h_{t-1} + b)

公式里出现了三个参数 W_xW_hb,它们各自分工不同:

参数 形状 作用
W_x 2 × 2 矩阵 当前输入 x_t 如何影响隐藏状态
W_h 2 × 2 矩阵 历史记忆 h_{t-1} 如何影响当前状态
b 长度 2 向量 整体偏移量

假设这套 RNN 已经训练好了,参数如下:

输入权重矩阵 W_x

W_x = [[0.5, 0.0],
       [0.0, 0.5]]

隐藏状态权重矩阵 W_h

W_h = [[0.8, 0.0],
       [0.0, 0.8]]

偏置向量 b

b = [0.0, 0.0]

偏置直接设成 0,是为了让手算更清晰,不影响理解流程。同时 W_xW_h 都被刻意设成对角矩阵——两个维度互不交叉,每一维独立计算。真实训练出来的矩阵一般是稠密的,但那样手算会变成噩梦。b 这里可以先粗略理解成前馈神经网络里的偏置项,作用是给加权和做一次整体偏移或门槛修正(数学上等价于"负阈值",但今天严谨地说就叫偏置)。

初始隐藏状态 h0

h0 = [0.0, 0.0]

序列还没开始读,记忆里什么都没有。


第 1 步:读"浏览商品"

当前输入

x1 = [1.0, 0.0]

上一步记忆

h0 = [0.0, 0.0]

当前输入贡献:W_x · x1

W_x · x1 = [0.5×1.0 + 0.0×0.0,
            0.0×1.0 + 0.5×0.0]
         = [0.5, 0.0]

历史记忆贡献:W_h · h0

W_h · h0 = [0.8×0.0 + 0.0×0.0,
            0.0×0.0 + 0.8×0.0]
         = [0.0, 0.0]

合并结果(加上偏置)

[0.5, 0.0] + [0.0, 0.0] + [0.0, 0.0] = [0.5, 0.0]

过激活函数 tanh

h1 = [tanh(0.5), tanh(0.0)] = [0.46, 0.0]

得到新隐藏状态

h1 = [0.46, 0.0]

这一步发生了什么?

  • 历史记忆为空,W_h · h0 全是 0,对当前结果没有贡献;
  • 当前输入 x1 通过 W_x 直接写入隐藏状态的第一维;
  • 第二维仍然为 0,因为 x1 在第二维上没有信号;
  • 隐藏状态从 [0, 0] 演化成 [0.46, 0],购买意图开始形成。

其详细的计算流程如下,关于矩阵的算法页、也贴在旁边


在这里插入图片描述

第 2 步:读"加入购物车"

当前输入

x2 = [0.0, 1.0]

上一步记忆

h1 = [0.46, 0.0]

当前输入贡献:W_x · x2

W_x · x2 = [0.5×0.0 + 0.0×1.0,
            0.0×0.0 + 0.5×1.0]
         = [0.0, 0.5]

历史记忆贡献:W_h · h1

W_h · h1 = [0.8×0.46 + 0.0×0.0,
            0.0×0.46 + 0.8×0.0]
         = [0.37, 0.0]

合并结果(加上偏置)

[0.0, 0.5] + [0.37, 0.0] + [0.0, 0.0] = [0.37, 0.5]

过激活函数 tanh

h2 = [tanh(0.37), tanh(0.5)]
   = [0.35, 0.46]

得到新隐藏状态

h2 = [0.35, 0.46]

这一步发生了什么?

  • 上一步的"浏览"信号没有被丢弃:h1 经过 W_h 加权,再过 tanh 重新塑形,留下 0.35 在第一维;
  • 当前的"加购"信号通过 W_x 写入第二维,得到 0.46;
  • 历史记忆和当前输入在同一个隐藏状态里融合。这里因为 W_xW_h 都是对角矩阵,看起来像是不同信号分别落在不同维度上;真实模型里各维度通常是混合的,不能简单理解为"某一维就等于某个行为";
  • 可以近似理解为h2 同时带有"浏览过"和"加购了"两条上下文。

其详细流程可以看下图

在这里插入图片描述


第 3 步:读"领取优惠券"

当前输入

x3 = [1.0, 1.0]

上一步记忆

h2 = [0.35, 0.46]

当前输入贡献:W_x · x3

W_x · x3 = [0.5×1.0 + 0.0×1.0,
            0.0×1.0 + 0.5×1.0]
         = [0.5, 0.5]

历史记忆贡献:W_h · h2

W_h · h2 = [0.8×0.35 + 0.0×0.46,
            0.0×0.35 + 0.8×0.46]
         = [0.28, 0.37]

合并结果(加上偏置)

[0.5, 0.5] + [0.28, 0.37] + [0.0, 0.0] = [0.78, 0.87]

过激活函数 tanh

h3 = [tanh(0.78), tanh(0.87)]
   = [0.65, 0.70]

得到新隐藏状态

h3 = [0.65, 0.70]

这一步发生了什么?

  • h2 继续通过 W_h 向后传递,把"浏览 + 加购"的历史信息带到 h3 里;
  • 当前输入 x3 = [1, 1] 在两个维度上都注入了信号;
  • 历史信号和当前信号在每个维度上分别叠加,再过 tanh;
  • h3 的两个维度都被推到了较高水平,形成了一份完整的上下文摘要。
    在这里插入图片描述

最终输出:购买意图分数

读完整条序列后,用最后一步的隐藏状态 h3 来做最终判断。这里我们把输出层做到最简,就是把 h3 的两个维度加权求和:

购买意图分数 = 0.6 × h3[0] + 0.4 × h3[1]
             = 0.6 × 0.65 + 0.4 × 0.70
             = 0.39 + 0.28
             = 0.67

分数 0.67,比较高,模型判断:这个用户购买意图较强

真实任务里,输出层通常不会这么裸。回归任务一般会再过一次线性层,二分类会接 sigmoid 把分数压到 0~1,多分类会接 softmax 输出每个类别的概率。这里为了让你看清"隐藏状态怎么变成最终结果",先简化处理。

对比:如果用户只浏览了就走了呢?

假设另一个用户的行为序列只有一步:

浏览商品 → 离开

那模型只跑到 h1 = [0.46, 0.0] 就结束了:

购买意图分数 = 0.6 × 0.46 + 0.4 × 0.0
             = 0.28

分数 0.28,明显低很多。模型判断:这个用户购买意图不强

把整个过程串起来看

时间步 行为 输入 上一步记忆 当前记忆 购买意图趋势
t1 浏览商品 [1.0, 0.0] [0.0, 0.0] [0.46, 0.0]
t2 加入购物车 [0.0, 1.0] [0.46, 0.0] [0.35, 0.46] 中等
t3 领取优惠券 [1.0, 1.0] [0.35, 0.46] [0.65, 0.70] 较强

画成图:

  x1=[1,0]      x2=[0,1]      x3=[1,1]
  "浏览"        "加购"        "领券"
    │              │              │
    ▼              ▼              ▼
  ┌────┐  h1    ┌────┐  h2    ┌────┐  h3
  │RNN │──────▶ │RNN │──────▶ │RNN │──────▶ 购买意图=0.67
  └────┘[.46,0] └────┘[.35,.46]└────┘[.65,.70]
    ▲              ▲              ▲
  h0=[0,0]       h1=[.46,0]    h2=[.35,.46]

最终购买意图分数聚合流程可以参考下图

在这里插入图片描述

通过这个例子可以得到以下结论:

  • 隐藏状态确实在传递h1 → h2 → h3 一路向后流动,每一步都基于前一步的结果继续计算,不是各自孤立地在算;
  • 历史信息确实参与了后续计算h2 的算式里出现了 W_h · h1h3 的算式里出现了 W_h · h2,前文信息会以压缩后的形式继续参与后续计算,但不会原样完整保留;
  • 同一套参数被反复复用:从 t1 到 t3,三步用的都是同一份 W_xW_hb,没有为某个时间步单独建一份网络;
  • 当前输入和历史状态共同决定新状态:每一步的 h_t 都是 W_x · x_tW_h · h_{t-1} 这两路信号融合后再过 tanh 得到的,缺一不可;
  • RNN 的本质是状态流,不是状态存档:模型并没有把每一步的输入原样存下来;它做的是不断在旧状态上叠加新输入、再压回固定大小的向量继续往下传。

2.6 同样的行为,顺序不同,含义就不同

上面已经用数字把电商场景跑过一遍了,这一节我们换个角度:抛开具体计算,看看"顺序"本身在用户行为建模里有多重要。

先看两条用户的会话:

  • 用户 A:浏览商品 → 加入购物车 → 领取优惠券 → 下单
  • 用户 B:领取优惠券 → 浏览商品 → 离开页面

我们从"是否发生过"这个维度看一下:

维度 用户 A 用户 B
是否浏览过商品
是否领过券

如果用前馈网络来建模,通常会把会话压扁成这种"是否发生过"的字段:是否浏览、是否加购、是否领券……特征工程做到这一步,模型看到的就只剩"什么动作出现过"。

但这两条序列描述的根本不是同一种用户:

  • 用户 A 是典型的购买路径:被商品吸引 → 决定下单 → 顺手领个券再付款;
  • 用户 B 更像羊毛党或纯薅券型用户:先冲着券来 → 看了一眼商品发现不感兴趣 → 直接走人。

把"先后"这个维度抹掉以后,前馈网络看这两条会话长得几乎一样,自然没法区分。RNN 不一样。它是按时间步一步步读的,隐藏状态会随着行为顺序一路演化下来:

  • 用户 A 的隐藏状态序列大致演化成"浏览 → 浏览+加购 → 浏览+加购+领券 → 完整购买路径",最终的 h_t 承载的是一段比较强的购买意图;
  • 用户 B 的隐藏状态序列则是"先领券 → 领券+浏览 → 领券+浏览+离开",最终的 h_t 承载的更像是一段"低意图、薅券型"的会话。

用户做了同样的几件事,但因为做的顺序不一样,RNN 在最后一步给出的隐藏状态可以差得很远,下游任务(比如预测下单概率、识别羊毛党)也就能给出不同的结论。

也就是说:

RNN 读到的不是"发生过哪些动作",而是**“这些动作以什么顺序、什么节奏发生”**。在很多任务里,顺序本身就是信号。

文本里词的先后、语音里帧的先后、系统日志里事件的先后、时间序列里数值的先后,本质上都是同一回事——RNN 把"顺序"这件事第一次系统性地纳入了神经网络。

在这里插入图片描述

3. RNN 是怎么训练的?

讲完 RNN “怎么前向算”,自然要问一句:这一套 W_x、W_h、b 是怎么训练出来的? 别担心,思路和上一篇几乎一模一样,只是多了一点点"时间"的味道。

3.1 核心训练流程

上一篇讲过,神经网络训练的核心循环就四步:

步骤 名字 在 RNN 里做的事
前向传播 用当前参数把整条序列从头跑到尾,得到最终输出
计算损失 把模型输出和真实标签对比,算出误差大小
反向传播 误差沿计算图一路回传,给每个参数算一份"应该怎么调"的梯度
梯度下降 沿着让损失变小的方向,把参数挪一小步

RNN 本质上还是神经网络,训练流程完全一样:

预测 → 计算损失 → 反向传播 → 调整参数 → 换下一条样本 → 继续循环。

唯一的不同在于:RNN 处理的是一整条序列,而不是一个孤立的样本。所以反向传播时,除了要在"层与层"之间回传,还要在"时间步与时间步"之间回传——多了一条"沿时间往回看"的轴。这条轴上发生的事情,就是下一节要讲的 BPTT。

3.2 BPTT沿着时间展开的反向传播

RNN 的反向传播有个专门的名字:

Backpropagation Through Time(BPTT),中文叫"沿时间的反向传播"。

听起来唬人,但本质并不神秘:

BPTT 不是一种全新的训练方法。它只是把 RNN 沿时间展开成一个很长的网络,然后在这个展开后的网络上做普通的反向传播。

普通神经网络的反向传播是"从输出层往输入层,一路回头看哪里错了";RNN 的反向传播则是"从最后一个时间步往前,一路回头看前面哪些词、哪些行为对这次预测产生了多大影响"。前者沿"层"展开,后者沿"时间步"展开——用的还是同一套链式法则,没有引入任何新的训练范式。

一个电商例子:误差是怎么往前追的

沿用前面的电商场景,假设输入序列是:

浏览商品 → 加入购物车 → 领取优惠券 → 下单

模型读完整条序列后预测"不会下单",但真实标签是"下单"。这时候 BPTT 做的事情大概是:

  1. 最后一步算出误差:预测和真实标签对不上,得到一个 loss;
  2. 往前一步:检查"下单"这个时间步的隐藏状态有没有充分保留前面累积的购买意图信号,给当前步的参数算一份梯度建议;
  3. 再往前:检查在读"领券"时,前一步累积到隐藏状态里的"加购"信息有没有被有效带过来,再给参数算一份梯度建议;
  4. 再往前:检查在读"加购"时,隐藏状态里"浏览"留下的信号是否被有效保留;
  5. 一直追溯到序列开头:每一个时间步都参与一次梯度计算。

注意上面所有步骤里都没有"模型意识到 / 模型觉得"这种说法——BPTT 做的事其实非常机械:沿计算图一路回传,按链式法则算偏导。所谓"前面的信号是否被保留",物理上对应的就是隐藏状态在这一步累积了哪些数值,以及这些数值经过 W_h 重新加权后还剩多少。

关键性质:参数是共享的,梯度要"汇总"

这里有一个最容易被读者误解的点,必须单独强调:

RNN 每个时间步用的是同一套参数 W_x、W_h、b 所以 BPTT 回传时,每个时间步会各自给这同一组参数贡献一份梯度,这些梯度最后累加到一起,再统一更新一次。

也就是说,并不是每个时间步各自更新一份参数,更不是给每个时间步单独建一套独立权重。这一点和普通深层网络区别很大——普通深层网络每一层有自己独立的 Wb,每层的梯度只更新自己那一份;RNN 是同一组参数被反复用了 T 次,对应的 T 份梯度要先求和再下降。

为什么必须共享参数? 因为序列的长度是不固定的:一句话可能 5 个词,也可能 50 个词;一次会话可能有 3 个行为,也可能有 30 个。如果每一步都用一套独立参数,模型就只能处理固定长度的序列,而且每加长一步就要多学一组权重——既不现实,也学不动。参数共享是 RNN 能处理变长序列的根本前提,也是"循环"二字最实在的含义。

把整条流程画出来

可以这样想象 BPTT 的工作方式(建议在脑子里画一张时间展开图:横轴是时间步,纵轴上每一格是一个 RNN 单元,所有格子共享同一套 W_x、W_h、b):

   前向:x1 ──▶ h1 ──▶ h2 ──▶ h3 ──▶ y ──▶ loss
                                                │
   反向:                                       ▼
          ▲          ▲         ▲         ▲    误差
          │          │         │         │
        梯度₁       梯度₂      梯度₃     梯度₄
          └──────────┴─────────┴─────────┘
                      │
                      ▼
          梯度₁ + 梯度₂ + 梯度₃ + 梯度₄
                      │
                      ▼
            更新同一组 W_x / W_h / b

每个时间步都给同一组参数贡献一份梯度,最后求和、统一下降——这就是 BPTT 区别于普通反向传播的全部"特殊性"。

用一张表总结:RNN 训练 vs 前馈网络训练

对比项 前馈网络训练 RNN 训练(BPTT)
反向传播方向 沿"层"从输出层往输入层 沿"时间步"从最后一步往前
参数是否共享 每一层各有各的参数 所有时间步共享同一组 W_x、W_h、b
梯度怎么处理 每层梯度更新自己的参数 各时间步梯度求和后统一更新共享参数
处理的样本 单条样本(一个特征向量) 一整条序列(多个时间步)
用的算法 链式法则 + 反向传播 同样的链式法则,只是沿时间展开

一句话总结 BPTT:

把 RNN 按时间展开成一个很长的网络,再用上一篇那套反向传播跑一遍;不同时间步算出来的梯度,会汇总到共享参数上一次性更新。

具体的偏导推导这里就不展开了,记住这个直觉就够用。


不过这里其实埋着一个隐患:误差从最后一步往前回传时,每跨一个时间步都要乘一次 W_h。序列短的时候没事,可一旦时间步拉到几十、上百,这条乘法链上的数值会变得非常不稳定——这正是后面 RNN "记不住远距离信息"的根源所在,留到第 6 章再展开。

4. RNN 解决了什么?

把前面讲的东西拎出来,RNN 相对于普通前馈网络,带来了三件事。

在这里插入图片描述

4.1 它让神经网络可以按顺序读数据

前馈网络更适合处理已经整理好的固定长度特征——如果不额外设计结构,它并不天然理解时间顺序。RNN 则是"一步一步把序列读完,每读一步都基于前一步的隐藏状态继续往下算"。这意味着:

  • 文本可以按"词的先后"来处理;
  • 语音可以按"帧的先后"来处理;
  • 用户行为可以按"动作发生的时间"来处理;
  • 股价、日志、传感器数据,也都能顺着时间看。

不能说 RNN 就此完美解决了序列问题,但至少在结构上,它第一次让神经网络具备了按时间步处理序列的能力——这是结构层面的一次大升级。

4.2 它引入了隐藏状态,让模型拥有了短期记忆

隐藏状态 h_t 是 RNN 最核心的创新。它像一个随时间滚动更新的小本本,把前面读过的信息压缩成一份摘要,传给下一步。和前面 2.3 节强调过的一样:它不是完整记录,而是一份不断被覆盖、融合、更新的压缩摘要

因此:

  • 读"他"的时候,能带着前文里"小明"和"小刚"的信息;
  • 读"下单"的时候,能带着前面"浏览、加购、领券"的上下文;
  • 读一段语音的第 100 帧时,能带着前 99 帧积累下来的语境。

这份"短期记忆",就是 RNN 真正让神经网络具备"理解序列"的关键。

4.3 它拓宽了神经网络的应用场景

有了"顺序"和"记忆"这两个能力,RNN 在一段时间里,几乎是序列任务的事实标配:

  • 文本分类:情感分析、意图识别;
  • 语言模型:根据前文预测下一个词;
  • 机器翻译:把一句中文变成一句英文;
  • 语音识别:把一段波形识别成一串文字;
  • 时间序列预测:股价、销量、温度走势;
  • 用户行为预测:下一步会不会点击、会不会下单、会不会流失。

可以毫不夸张地说:

在 Transformer 大规模流行之前,RNN 及其改进版 LSTM、GRU,长期是序列建模任务里的主流方案。

不过,这份"顺序 + 记忆"的能力是有代价的。

RNN 必须按时间步一步一步串行处理h2 要等 h1 算完,h3 要等 h2 算完,没法跳过、没法并行。同时,所有历史信息都要被压进同一个固定大小的隐藏状态里——序列短的时候没事,序列一长,早期的信息就容易被一层层覆盖、稀释掉。

这两条限制——串行计算 + 长距离信息容易被冲淡——也就自然引出了后面的故事:

  • LSTM、GRU 在隐藏状态这条链上加门控,让模型自己决定"什么该记住、什么该忘掉";
  • 再后来 Transformer 干脆放弃这条状态链,改用注意力机制,把"按时间串行"也一起拆掉了。

至于这两条限制具体是怎么发作的、为什么 LSTM 能缓解、Transformer 又是怎么彻底换思路的,留到第 6 章和后面几篇专门展开。

5. RNN 和普通前馈网络的区别

最后用一张表把两者对比清楚:

对比项 前馈神经网络 RNN
数据流向 输入层 → 输出层,单向走完一次 沿时间步串行展开,每步带着上一步的隐藏状态
是否保留历史 不天然保留,每次预测都从零开始 通过隐藏状态把前文上下文持续传递下去
是否理解顺序 不天然理解,"先后"信息要靠人工特征塞进去 顺序本身就是输入的一部分,模型天然能感知
参数使用 每一层各有各的参数 同一套参数 W_x、W_h、b 在所有时间步上反复复用
适合数据 固定长度特征、结构化数据 长度可变的序列数据
典型任务 图片分类、结构化预测、风控打分 文本、语音、系统日志、时间序列、用户行为
类比 看一张快照 看一段录像

在这里插入图片描述

不过,正因为 RNN 必须按时间步一步一步算,它天然就是串行的:h2 必须等 h1 算完,h3 必须等 h2 算完,没法跳过、没法并行。这条性质同时带来了两件事:

  • 好的一面:让 RNN 拥有了"顺序感"和"上下文记忆",这是它处理序列数据的根本;
  • 坏的一面:训练慢、难以利用 GPU 并行,并且时间步一拉长,早期信息很容易在隐藏状态里被一层层覆盖掉,造成长距离依赖建模困难。

6. RNN 的问题痛点

RNN 很强,但它不是终点。前面几节我们埋了好几次伏笔——长序列遗忘、梯度沿时间反复相乘、串行没法并行——这一章集中收一收。这里不展开数学,也不深入 LSTM、GRU、Transformer 的细节,只把"RNN 为什么不是终点"讲清楚,给下一篇做铺垫。

6.1 长序列容易忘

RNN 的隐藏状态是固定大小的(比如 128 维)。每读一步新输入,旧记忆都要被 W_h 重新塑形,再和新信息融合一次,最后压回同样大小的向量继续往下传。

这就带来一个直接后果:越早出现的内容,被覆盖和稀释的次数就越多。

  • 序列只有 3-5 步(比如一次电商会话的"浏览 → 加购 → 领券"),RNN 通常能把上下文有效带到最后一步;
  • 序列拉到几百字的长文,模型就经常"忘掉开头说过什么";
  • 序列拉到几千字,就更吃力了。

这不是工程实现的问题,而是原始 RNN 结构本身的限制:固定大小的容器要装下不断增长的历史信息,前面的信号必然会被反复压缩、稀释。

在这里插入图片描述

6.2 训练时梯度容易出问题

这个问题本质上是 6.1 的"数学版本",根源就藏在 BPTT 里。

3.2 节我们提过:误差从最后一步沿时间往回传时,每跨一个时间步,梯度都要乘一次 W_h。一条几十步、上百步的长序列,就等于把同一组 W_h 反复连乘几十、上百次。连续相乘会带来两种极端:

  • 如果整条乘法链上的尺度不断缩小,梯度回传到前面的时间步时几乎为 0 → 梯度消失:早期时间步的参数收不到有效更新,模型学不到长距离依赖;
  • 如果整条乘法链上的尺度不断放大,梯度会变成非常大的数值 → 梯度爆炸:一次更新就能把训练崩掉。

工程上对付梯度爆炸有梯度裁剪这种现成的招;但梯度消失更难治——它是结构层面的问题,不是补丁能彻底解决的。

在这里插入图片描述

6.3 串行计算效率低

RNN 的计算天然是串行的:

必须先算完 h1,才能算 h2;
必须先算完 h2,才能算 h3;
......

这条依赖关系是硬的,没法跳过。结果是:

  • 一条长序列,只能从头到尾一步一步跑;
  • GPU 擅长的"大规模并行矩阵运算"在序列内部基本用不上;
  • 模型一大、序列一长,训练时间就被这条串行链路拖住,没法跟 GPU 数量线性扩展。

模型规模小、序列短的时候这还能忍;一旦想做"更长上下文 + 更大模型",这条短板就变成硬伤。

在这里插入图片描述

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐