第一次在实体识别任务里把循环神经网络换成双向结构,效果直接涨了好几个百分点。当时模型经常把“李明在北京出差”这种人名片段切错,明明后文已经给出了线索,网络却只盯着前面看。后来把LSTM换成BiLSTM,这类错误明显少了大半。也是从那次之后,我意识到循环神经网络的这三件套——RNN、LSTM、BiLSTM——本质上是一步一步把“记忆”这件事做得更精细。
这三种结构是序列建模里绕不开的基础。很多刚接触深度学习的同学,第一反应是图像用卷积、文本语音用循环网络。但实际做项目时你会发现,循环网络不只是“处理序列的工具”,它背后真正的问题是:模型如何利用已经看过的信息,去理解当前这一时刻的内容。RNN提出了循环共享的思想,LSTM解决了长距离记忆的传递,BiLSTM则把未来信息也借了过来。这篇文章适合正在入门自然语言处理、时序预测,或者想弄清楚这三种结构到底差在哪的读者。我会从算法思路本身出发,结合平时踩过的坑和实际选型经验,把这三个模型讲明白。
1. 为什么序列建模绕不开循环结构
1.1 普通网络处理不了序列的三个硬伤
先说一个很直观的问题:全连接网络根本没法处理变长序列。
假设你要对一条用户评论做情感判断,句子有长有短。全连接网络的输入维度是固定的,你只能把所有词向量拼成一个大向量,或者干脆取平均。取平均的问题特别明显:“猫追狗”和“狗追猫”这两个意思完全相反的句子,词向量取平均之后几乎一样。顺序信息在平均这一步就彻底丢了。
拼长向量看起来保留了顺序,但实际上是把序列强行压进一个固定尺寸的容器里。句子长一点就放不下,短一点又浪费大量位置。更麻烦的是,全连接网络里每个位置都有自己独立的一组权重,词出现在第一个位置和第三个位置用的是完全不同的参数。这意味着网络没法把“在开头学到的模式”迁移到“在中间出现的情况”上。
卷积网络能缓解一部分局部顺序问题。一维卷积可以看一个固定窗口内的词,比如窗口为3就能同时看到“猫追狗”。但窗口大小一固定,想看到跨句子级别的长期依赖,就得堆很多层卷积,感受野才有机会覆盖到远处。这个成本非常高,而且远距离信息经过多层卷积后衰减得很厉害。
所以核心矛盾是三个:输入长度不定、参数不能在时间维上共享、长距离顺序关系难捕捉。循环神经网络的出现,就是冲着解决这三个问题去的。
1.2 循环连接到底在做什么
循环神经网络的核心是一个递归调用的单元。在当前时间步 t,网络的隐藏状态 h_t 由当前输入 x_t 和前一步的隐藏状态 h_{t-1} 共同决定。这个过程可以写成:
h_t = tanh(W_hh · h_{t-1} + W_xh · x_t + b_h)
这个公式是RNN家族的“地基”,后面所有的花样都是在这个递归关系上做文章。
你可以把循环结构理解成在看连续剧。如果不记得前几集发生了什么,只看单集很难理解人物之间的恩怨。循环网络里的隐藏状态,本质上就是一个“剧情总结”:每来一幕新剧情,它就把之前的总结和当前这一幕的内容合并,生成一份新的总结。这个总结会一直往下传,信息也就不停在时间维度上流动。
另一个关键设计是权重共享。同一个单元在每一个时间步都在被重复使用,不管序列长度是10还是1000,参与计算的参数总量是不变的,只有同一个矩阵 W_hh 和 W_xh 在反复做运算。这带来两个好处:参数量不会跟着序列长度线性增长,同时模型学会的是一种“时间上的规律”,而不是某个固定位置上的特征。
对变长输入来说,循环结构几乎是天然适配的。它不需要把句子pad到固定长度,只需要按时间步逐个喂进去,最终无论序列多长都能产出对应的输出。
1.3 一次前向计算:从输入到输出的完整路径
把上面的公式展开来看一次完整的前向计算。假设输入序列是 x_1, x_2, ..., x_T,每个 x_t 是一个维度为 d 的向量。
初始时刻 t=0 时,隐藏状态 h_0 通常初始化为全零向量。然后对 t=1 到 T 循环执行两步操作:
第一步更新隐藏状态:h_t = tanh(W_hh · h_{t-1} + W_xh · x_t + b_h)。
第二步根据隐藏状态产生输出:y_t = W_hy · h_t + b_y。
这里 W_hh 是隐藏状态到隐藏状态的转移矩阵,形状是 hidden_size × hidden_size;W_xh 是输入到隐藏状态的矩阵,形状是 hidden_size × input_size;W_hy 是隐藏状态到输出的矩阵,形状由输出维度决定。
在分类任务里,不一定要每个时间步都产生输出。比如情感分析,你只需要最后一个隐藏状态 h_T,把这个向量丢到一个全连接层加softmax里即可。但比如语言模型,每个时间步都需要预测下一个词,那就得每个时间步都输出一个概率分布。
用维度来解释可能更直观。假设一个batch里有 batch_size 句话,每句话长度是 seq_len,每个词嵌入成 input_size 维向量,那么输入张量形状是 batch_size × seq_len × input_size。循环神经网络本质上是沿着seq_len这一个维度做逐步运算,每一步取出一个时间步的切片 x_t,形状是 batch_size × input_size,乘上输入矩阵,加上上一个时间步的隐藏状态 h_{t-1},得到当前步的 h_t,形状是 batch_size × hidden_size。整个过程中 seq_len 这个维度被“逐步消费”掉,而隐藏状态就是网络携带的记忆容器。
这个流程看起来简单,但真正训练起来的时候麻烦就来了。下一节具体看RNN是怎么训练的,以及它卡在什么地方。
2. RNN:最简单的循环思想
2.1 权重共享和时间步展开
RNN的“循环”其实很朴素:在每一个时间步重复使用同一个网络单元。但很多初学者会困惑,既然只有一个单元,那网络“深”在哪儿?
把时间维度展开就清楚了。把 h_1 到 h_T 的计算全部画出来,这其实是一个有 T 层的网络结构。第 t 层接收两个输入:一个是 x_t,另一个是上一层的输出 h_{t-1}。所以RNN在结构上等价于一个深度为 T 的前馈网络,只是网络在不同层之间共享同一套权重。
这里能引出一个经验点:RNN参数量小是真的小,一个简单的RNN层可能只有几百个参数,却能处理任意长度的序列。这在资源受限的场景里很有吸引力。不过代价就是它的表达能力有限,因为循环权重是要被所有时间步共享的,相当于它假设“时间规律是平稳的”。也就是过去学到的转移规律,在未来依然适用。绝大多数序列数据符合这个假设,比如语言的语法规则不会因为你在第100个词还是第3个词而改变。但如果处理的是有明显分布漂移的数据,比如金融时序数据,这个假设就得打问号了。
实际做项目时,我喜欢先把RNN当做一个极简基线。不是因为RNN效果多好,而是因为它结构足够简单,跑起来足够快。如果RNN都能达到勉强可用的效果,说明任务的时序依赖不难,后续可以放心上LSTM或者Transformer;如果RNN完全学不动,那就要先怀疑数据或特征有没有把序列信息表达好。
2.2 训练过程:BPTT把梯度送回过去
RNN的训练用的是时间反向传播,缩写为BPTT,全称 Backpropagation Through Time。它并不是另一个新算法,本质上就是反向传播在时间维度上的推广。
前向计算时,网络会按时间顺序算出一整套隐藏状态,把这些状态全部缓存下来。反向传播时,从最后一个时间步开始计算损失对当前时间步输出的梯度,然后沿着 h_T → h_{T-1} → ... → h_1 这条路径把梯度一步步往回流。展开后网络的深度就是序列长度,所以反向传播回传的路径长度也等于序列长度。
这里有个细节很容易忽略:BPTT计算的损失是每个时间步损失的总和。分类任务可能只有一个最终损失,但语言模型或者序列标注任务,每个时间步都会产生损失,这些损失会同时影响梯度。反向传播时要把来自后续所有时间步的梯度叠加起来。公式上,对权重 W_hh 的梯度可以写成各个时间步贡献的累加:
∂L/∂W_hh = Σ_t (∂L_t / ∂W_hh)
每个时间步的贡献又依赖前面所有时间步的传递路径,所以同一个权重矩阵沿着一条很长的链反复被求导。
这意味着什么呢?意味着RNN虽然参数量小,训练时的计算图却特别长。序列长度一旦到几百甚至上千,反向传播的内存开销和计算开销都很大。所以实际工程里,很少有人真的在超长序列上从头训一个RNN,通常会用截断BPTT:只把梯度回传到最近的一两个窗口步长内。
2.3 RNN的短板:长距离依赖学不动
如果只看前向传播和BPTT,RNN似乎已经足够优雅。但真正训练过一次你就会发现,序列一长,效果会崩。
问题出在梯度回传路径上的连乘结构。h_t 对 h_{t-1} 的导数里包含 tanh 的导数项和 W_hh 本身。tanh 的导数最大只有1,当输入落在饱和区时导数直接趋近于0。假设每一步的梯度都乘以一个小于1的数,经过 T 步之后,梯度是原来的 0.9 的 T 次方。序列长度50时大约是 0.9^50 ≈ 0.005,梯度基本衰减没了;序列长度100时直接趋近于0。
这就是经典的梯度消失问题。梯度消失的直观后果是,网络根本没有办法把“远处的信息”学进参数里。注意力机制虽然能综合当前信息,但梯度传不回来,更新不了前面时间步对应的模型权重,所以模型只能记住相邻几个时间步内的依赖关系。
另一种情况是梯度爆炸:如果 W_hh 的主特征值大于1,连乘结果会指数增长,梯度直接溢出变成NaN。工程上梯度爆炸比较好处理,梯度裁剪抓一下就行。但梯度消失基本没法靠后处理解决,必须从结构层面改变传播路径。
用传话游戏类比最贴切:一个人对下一个人低声说一句话,每传一次都会折损一点或者添油加醋一点,传了十几次之后,最后那个人听到的基本是另一个版本了。经典RNN就是这么个状态,长距离依赖它真的学不动。这也是LSTM被设计出来的根本动机。
3. LSTM:用门控管理记忆
3.1 从单条线性传递到细胞状态
LSTM全称是长短期记忆网络,思路很直接:给RNN加一条贯穿时间轴的“传送带”。这条传送带叫细胞状态,通常记为 c_t。
在经典RNN里,隐藏状态每个时间步都要经过 tanh 变换,信息被压缩在 [-1, 1] 区间,一旦更新就被扰动,很难原样不动地传下去。而细胞状态这条传送带的与众不同之处在于,它允许信息以接近线性的方式流过整个时间轴,中间不需要经过非线性激活函数。这就好比你在仓库里修了一条主传送带,平时货物不管要不要处理,都会顺着传送带流过,只有特定位置才有分拣员决定是否把某件货物拿走或者放上新货。
这三个分拣动作,对应LSTM里的三个门控:遗忘门决定从细胞状态里丢弃多少旧信息,输入门决定把多少新信息写入细胞状态,输出门决定这一时刻的隐藏状态对外暴露多少记忆。整套机制就是围绕着“传送带上的信息管理”来设计的。
这个设计带来的第一个直观变化是,模型有了独立的“记忆存储”和“外部输出”的分离。隐藏状态 h_t 是给当前任务看的“工作记忆”,细胞状态 c_t 是留给长期保存的“长期记忆”。工作记忆可以被当前输入覆盖,长期记忆则经过精心挑选才更新。
3.2 三个门控的职责分工
LSTM的计算过程比RNN复杂不少,但每个部分职责都很明确。先看公式,再逐个解释。
遗忘门:
f_t = σ(W_f · [h_{t-1}, x_t] + b_f)
输入门:
i_t = σ(W_i · [h_{t-1}, x_t] + b_i)
候选记忆:
c̃_t = tanh(W_c · [h_{t-1}, x_t] + b_c)
细胞状态更新:
c_t = f_t ⊙ c_{t-1} + i_t ⊙ c̃_t
输出门:
o_t = σ(W_o · [h_{t-1}, x_t] + b_o)
隐藏状态输出:
h_t = o_t ⊙ tanh(c_t)
σ 是sigmoid函数,输出在0到1之间,表示“开门程度”,0代表完全关闭、1代表完全打开。⊙ 是逐元素乘法。
遗忘门 f_t 看的是当前输入 x_t 和上一个隐藏状态 h_{t-1},输出一个0到1的向量,分别对应细胞状态每个维度的遗忘程度。它告诉网络:旧的记忆里哪些部分已经过期,可以清理掉了。比如处理一篇新闻报道时,前一个段落讲的是天气,现在开始讲交通,网络可以通过遗忘门把关于天气的记忆系数调低。
输入门 i_t 不对记忆做修改,它只是“审核员”。真正的内容是由候选记忆 c̃_t 提供的,c̃_t 通过 tanh 生成了一个新的候选向量,范围在-1到1之间。输入门决定这个候选向量有多少会真的写入细胞状态。这就像一个部门提交了一份提案,得经过审批才能落地。
输出门 o_t 的职责有点特殊。它不直接影响细胞状态,而是决定当前细胞状态 c_t 里哪些信息可以被暴露给外界。h_t 既要传给下一时间步,又要用来生成当前输出。如果所有记忆全部暴露出来,可能反而让当前任务被无关的历史信息干扰。输出门就是这个筛子,用来控制对外可见的记忆内容。
细胞状态更新的公式 c_t = f_t ⊙ c_{t-1} + i_t ⊙ c̃_t 是整个LSTM最核心的地方:先按遗忘门清理旧记忆,再按输入门写入新记忆。整个过程是线性叠加,而不是重写。
3.3 LSTM为什么能对抗梯度消失
LSTM比RNN能记住更长距离的信息,关键在于细胞状态的更新是加法操作。
在回传梯度时,c_t 对 c_{t-1} 的导数是 f_t,再加上其他路径的贡献。理想的传播路径下,遗忘门接近1,那么细胞状态在时间维上几乎是无损的直线,梯度可以在这条路径上畅通无阻地往回传。与经典RNN里每一步都乘一个 tanh 导数和矩阵权重不同,LSTM的这条主路径没有强制性的非线性压缩。
这跟残差网络里“跳跃连接”的思路很像。残差网络通过把上一层的输出直接加到这一层的输出上,让梯度有一条高速公路可以绕过非线性变换直接回流。LSTM的细胞状态就是这条高速公路。它就是给梯度提供了一个不受连乘影响的旁路。
但要注意,LSTM并没有完全消除梯度消失。如果遗忘门长期打开,梯度确实通畅;但如果遗忘门训练到了接近0的状态,代表网络主动选择遗忘之前的信息,这一条路径上的梯度同样会被截断。这种截断是任务需要的结果,不是纯粹的缺陷。理解这一点很重要,LSTM不是“永远记着一切”,而是“学会决定要记什么”。当它选择遗忘某些旧记忆时,相关位置的梯度路径自然就断了。
实际项目中还会有另一个体会,LSTM的参数量大约是RNN的四倍,训练速度明显变慢。如果你只想做一个快速基线,GRU是个不错的折中方案。GRU把三个门缩减成更新门和重置门两个门,参数更少,在很多中等规模任务上跟LSTM效果差不了多少。但本文以LSTM为主线,因为理解LSTM的门控逻辑之后,GRU几乎是顺带就能看懂的东西。
4. BiLSTM:把后文也纳入视野
4.1 单向模型的“左侧依赖”局限
很多新手刚接触时默认循环网络是“从前往后”看的。这个习惯继承自阅读顺序。但读句子理解语义时,你往往需要往前翻一翻才能确定当前词的意思。
举一个实体识别里非常典型的例子:“李明的报告已经提交,他在会上做了说明”。如果模型只看“他”之前的文本,它知道“他”大概率是指代某个人。但具体指代李明还是另一个没说名字的人,取决于后文“在会上的说明内容”。后文信息就是解锁指代的关键。单向LSTM在这个任务上天生吃亏,它只能利用当前词左侧上下文,右侧信息必须靠模型预测出来,但那相当难。
情感分析里也有类似案例。“虽然这家店环境很好,但服务态度让人崩溃”。只看前半句“环境很好”,模型会觉得是正面的;转折词“但”之后内容才是真正要传达的态度。单靠前文运行到最后一个时间步时,模型很难准确判断整句的立场,因为最后一个词只是结尾,并不携带完整的全局信息。
所以有一类任务天然“需要看后文”。实体识别、关系抽取、句子分类、阅读理解,这些任务的共同特征是:当前位置的理解依赖整个句子甚至整个文档的上下文,而不仅仅是从左到右的过去信息。
4.2 双向结构:两个方向同时编码
BiLSTM的思路朴素到有点讨巧:既然单向只能看左边,那就再跑一个方向从右往左看,把两个方向的信息拼起来。
具体做法是准备两套独立的LSTM参数。前向LSTM按 x_1, x_2, ..., x_T 的顺序逐个读入,得到一组前向隐藏状态 h_1^f, h_2^f, ..., h_T^f。后向LSTM按逆序 x_T, x_{T-1}, ..., x_1 逐个读入,得到另一组后向隐藏状态 h_1^b, h_2^b, ..., h_T^b。然后对每一个时间步 t,把前向和后向两种状态拼接成一个向量:
h_t = [h_t^f ; h_t^b]
这个拼接后的向量就是 BiLSTM 在位置 t 的表示。它的含义是:当前词的信息加上来自左侧的上下文信息,再加上来自右侧的上下文信息,三者共同编码在一起。
还是用“李明在北京出差”这个例子。前向LSTM读到“李”的时候,当前语境里还不知道后面是个“明”字;但后向LSTM从右往左读的时候,先看到“明”,再看到“李”,两个方向的信息一汇合,模型就知道“李”和“明”是同一个实体片段里的字。这个能力不是单向模型通过调参能模仿出来的,它真的是结构上带来了右侧信息。
所以在实际使用中,第 t 个位置的表示质量会明显高于单向LSTM的输出。这也是为什么在大量自然语言处理任务上,BiLSTM成为标配。毫不夸张地说,预训练语言模型出现之前,BiLSTM加CRF几乎就是序列标注任务的标准答案。
4.3 双向的代价与现实取舍
双向结构效果更好,代价也实打实地摆在面前。
参数量直接翻倍。前向一套 LSTM,后向一套 LSTM,两套完全不共享参数。内存占用和训练时间都大约变成两倍。在长序列上,这个开销尤其明显。
更大的限制是推理模式的改变。单向LSTM可以做流式处理:来一个输入时间步,更新一次隐藏状态,立刻输出当前结果。BiLSTM做不到这一点,因为后向那一半需要未来信息,你必须在拿到完整序列之后才能运行后向LSTM。这也是为什么语音识别里实时解码的模型很少用BiLSTM作为主结构,延迟不可接受。等你把整句话都收完再解码,用户已经等不住了。
另外,双向结构不天然适合某些增量的预测场景,比如实时键盘输入预测、逐字手写识别,这类任务是“只见过过去、没见过未来”的,结构上就不该使用双向编码。
实用建议是:先想清楚任务到底是离线还是在线。离线自然语言处理任务,基本可以放心上BiLSTM。在线流式任务,只能单向结构加额外机制,或者用一些延迟可控的双向变体。很多坚持用单向LSTM的团队不是不理解双向的好处,而是实时性约束把他们卡死了。
5. 选型参考:RNN、LSTM、BiLSTM怎么选
5.1 按任务特性选结构
很多初学者会到处问“这个任务应该用哪个模型”。与其记结论,不如按任务特性来判断。
判断依据有三条:序列长度通常是多少、当前位置的理解是否依赖后文、推理时能不能等完整序列到齐。
根据这三条,我通常会这样选:
| 结构 | 适用场景 | 优势 | 明显短板 |
|---|---|---|---|
| RNN | 极短序列的快速基线、实时流式场景、计算资源受限 | 参数极少、实现简单、可在线推理 | 长距离依赖记不住 |
| LSTM | 时序预测、常规序列分类、语言建模、在线推理需求 | 能记住较长依赖、训练稳定、可流式 | 只能看到左侧上下文 |
| BiLSTM | 离线自然语言处理、实体识别、关系抽取、句子级分类 | 左右上下文都能利用、表示质量高 | 参数翻倍、无法流式推理 |
注意这个表不是死的。比如RNN其实也有双向版本,LSTM也可以做成双向。但选型时抓住“数据依赖是不是长距离的”和“要不要用后文”这两个核心矛盾就够了。
我自己还会补充一个习惯:先用BiLSTM跑一版看效果上界,再用单向LSTM对比。如果单向和双向差距很小,说明这个任务可能更依赖全局统计而非顺序关系,后续可以直接考虑换成并行化更高的结构。如果双向优势明显,说明左右上下文都很关键,这个任务值得继续在序列模型上投入。
5.2 实操中常见的坑
讲几个我在实际工程里踩过的坑,都是文档里不会写的事。
第一个是mask问题。序列被 padding 成固定长度后,模型把填充位置当成了真实输入。这会让最后的隐藏状态包含无效信息。最简单的方法是记录每个样本的真实长度,在取 h_T 时通过索引取出真实最后一步的隐藏状态,而不是天真地使用序列最后一个位置的输出。这个细节在Transformer里有专门处理,但在LSTM年代很多人都会忽略。
第二个坑是初始化遗忘门的偏置。默认把遗忘门偏置初始化为0,模型一开始遗忘门输出是0.5,相当于先丢掉一半历史。常见做法是初始化为1或2,让遗忘门在训练初期接近打开状态,保留足够的信息量。这个设置在我做长文本分类时帮助非常明显。
第三个是序列长度差异过大的batch。假设一个batch里最长句子100个词,最短的只有5个词,大量空白padding位置浪费了计算资源,还会影响模型稳定性。实践中按长度排序并做长度分组(也叫bucket)会让训练快很多,梯度也更加稳。这对LSTM尤其重要,因为LSTM的计算是严格时间顺序的,没法像Transformer那样并行。
第四个是别一上来就堆多层。经常看到有人在LSTM外面叠三层甚至更多。实际经验是,这类序列模型加层数的收益递减非常快,但训练时间掉得很快。先跑通单层双向,把数据、特征和损失函数调好,再考虑加深。
第五个是关于dropout的位置。LSTM的dropout如果加在循环连接上,训练时会让隐藏状态抖动得很厉害。常规做法是在每个时间步的输入和输出部分加dropout,循环连接内部保持稳定。这个细节的实现方式取决于框架,但思路是一样的:给模型加正则,但不要打断时间维上的信息流动。
5.3 和注意力机制结合的习惯做法
近几年做自然语言处理,BiLSTM很少直接作为最终输出层使用,它更多承担的是“编码器”的角色。
习惯做法是BiLSTM编码完之后,接一个注意力层。为什么?因为BiLSTM最后时间步的输出只代表该位置看到的双向上下文,不代表整个句子的全局主题。文档级或者句子级的分类任务里,真正重要的情感词可能出现在第3个词、第17个词、第8个词这几个位置,简单把最后一个向量接全连接再丢softmax,等于假设“末尾信息能代表全句”。这个假设在许多任务上并不成立。
注意力层做的事情,是在全部时间步的表示上做一个加权求和,权重由每个位置跟当前任务的相关程度决定。比如“虽然这家店环境很好,但服务态度让人崩溃”这句话里,注意力权重会集中在“虽然”“但”“崩溃”这几个关键位置,加权求和之后得到的向量比直接取最后一个隐藏状态更能代表整句话的情感。
所以实际项目中,我常用这条路:BiLSTM把序列编码成每个位置的上下文表示,注意力层汇总这些表示成全局向量,再接一层全连接做分类。这套组合在预训练模型出现以前,是很多自然语言理解任务能够到当时最好效果的标准方案。即便现在用了Transformer类的预训练网络,理解BiLSTM的编码思路和注意力如何配合,依然能帮你更好地看懂更复杂模型的设计动机。
写在最后的一点体会
这三类模型的演进逻辑,本质上是在解决同一个问题:网络如何更合理地去组织和读取信息。RNN提出了“把历史信息装进隐藏状态”的思路;LSTM细化了信息管理流程,增加了细胞状态和门控机制;BiLSTM进一步补上了后文信息的通道。我早期喜欢把BiLSTM当万能模板,不管什么任务直接套。后来才发现,关键不是选哪个结构,而是先搞清楚数据的依赖关系到底在什么范围、需不需要后文、能不能接受延迟。这些问题答案清楚了,模型结构自然就清楚了。
还有一个想法是,理解LSTM的门控和细胞状态,并不会因为它老就过时。信息流如何被保留、被筛选、被利用,这个思维框架是很多后续模型的底层出发点。如果你在学习的时候是照着这个角度去理解的,那后面看注意力机制也会觉得顺理成章,因为它们关心的是同一件事。