资讯动态

循环神经网络RNN详解:从梯度问题到LSTM/GRU实战

发布时间:2026/10/16 9:05:08 来源:尧图企业网站定制
咱们做深度学习也有一阵子了多数人入门学的都是CNN那套做图像分类、目标检测一套组合拳打下来挺顺手。可真碰上文本、语音、时间序列这些带“先后顺序”的数据时卷积那套就不太灵了。你没法把一个句子倒过来看还能保持原意也没法把一段音频里的帧随便打乱再重新排列。这时候就要接触循环神经网络RNN了。我这篇笔记就是围绕一套非常经典的深度学习入门课程里循环神经网络那部分展开的把当时听课、做作业、再对照源码调试踩过的坑放到一起整理成一份能直接照着用的参考。这套笔记适合谁两种人。一种是刚学完CNN对序列数据一头雾水想知道RNN到底怎么把“记忆”塞进网络里的初学者另一种是做NLP或者时序预测已经有一阵子但对LSTM、GRU内部结构有点“好像是这么回事但又说不清”的人。我会把网络结构、前向传播、反向传播以及梯度问题讲明白再补上实操里必须注意的细节。1. 核心场景与设计思路1.1 为什么传统神经网络处理不了序列先想一个问题给你一个电影评论“这部电影的剧情虽然老套但演员演技在线整体看下来还是值得推荐的”让你判断情绪是正面还是负面。这话的重点在“演员演技在线”和“值得推荐”而“剧情老套”只是让步。一个句子里的核心信息往往藏在靠后的位置甚至依赖前面几个词的修饰才能确定含义。传统神经网络全连接和CNN有个很别扭的设定输入的长度是固定的。图像可以是224x224x3固定下来没问题但自然语言里句子长短不一。强行把所有句子padding到同一个长度等于往模型里塞了很多无效信息而且网络根本看不出词与词之间的先后依赖。卷积核在空间上做局部感知提取特征但它不知道“先出现的是主语后面才是谓语”这种时间上的秩序。RNN的设计思路其实很朴素让网络在处理当前输入的时候同时看到一个“状态向量”。这个状态向量相当于网络自身的记忆把之前看到过的内容压缩成一组数字。每读取一个新词网络就更新一次状态把当前词的信息和旧记忆融合生成新记忆。这个过程就叫“随时间展开”。它的优雅之处在于无论一个句子多长网络始终用同一组权重从头滚到尾参数数量根本不受序列长度影响。1.2 生活化类比边读边记笔记理解RNN最省力的方式是想象一个正在读文章的人。每读一个词他会做两件事一是写下当前这个词给他的印象二是结合之前已经记下的内容更新一下自己脑子里的“摘要”。等读完整篇这个“摘要”就包含了全文所有信息的压缩版本。在RNN里这个“摘要”就是隐状态hidden state。用数学一点的话说h_t activation(W_hh * h_{t-1} W_hx * x_t b_h)其中h_t是当前时间步的隐状态h_{t-1}是上一步的状态x_t是当前输入。W_hh负责“记忆过去”W_hx负责“吸收当下”b_h是偏置。每一步的隐状态同时承担两个角色作为网络内部记忆继续传递到下一步同时也能被用来计算当前步的输出。这个设计就是整门课里反复强调的“参数共享”。同一个W_hh在每个时间步上都被用一遍所以不管序列多长模型容量不会越来越大。这跟CNN在空间维度上共享卷积核权重异曲同工只不过CNN共享的是空间平移不变性RNN共享的是时间上的先后变换规则。2. 网络结构与计算细节拆解2.1 三组权重的直觉理解刚开始看RNN结构图很容易被那种“左边折叠右边展开”的示意图绕晕。这里我直接给结论一个标准循环层核心只有三组权重。W_ax输入x_t到隐状态h_t的变换矩阵。可以理解为“当前输入如何影响我的记忆”。W_aa上一个隐状态h_{t-1}到当前隐状态h_t的变换矩阵。可以理解为“过去的记忆如何被保留和演化”。W_ya隐状态h_t到输出y_t的变换矩阵。可以理解为“当前的记忆如何被解读成输出”。如果输入是词向量维度是词向量大小隐状态维度是n_a输出维度取决于任务那W_ax的形状就是n_a乘以输入维度W_aa是n_a乘以n_aW_ya是输出维度乘以n_a。初次接触的人最容易搞混的是W_aa和W_ax怎么拼接。有些实现把它们写成一个更大的矩阵W_h [W_aa | W_ax]然后把h_{t-1}和x_t拼接成一个长向量一次性乘过去。两种写法数学上完全等价但写代码的时候后者的矩阵运算效率更高。2.2 前向传播与时间步展开拿一个最简单的例子输入“猫 在 垫子 上”四个词。每个词用预训练词向量表示依次喂给网络。时间步t1输入第一个词“猫”。初始隐状态h_0是零向量。计算h_1 tanh(W_aa * h_0 W_ax * x_1 b_h)。然后算输出y_1 softmax(W_ya * h_1 b_y)。但这个输出在“判断整句话情感”的任务里没什么用真正有用的是最后一个时间步的h_T或者是把所有时间步输出做池化。课程里给的标准结构是“多对一”和“多对多”两类多对一就是只取最后隐状态做分类多对多则是每个时间步都产出一个标签比如词性标注。时间步展开的计算里有一个容易忽略但又特别关键的细节所有时间步的激活函数都是同一个tanh。别小看这个固定选择tanh的输出范围在-1到1之间能保证隐状态在传递过程中的数值不会像线性变换那样膨胀得太快。我按照课程作业的向量化逻辑简化过一版前向传播伪代码def rnn_forward(x, W_aa, W_ax, W_ya, b_h, b_y): # x: (序列长度, 输入维度) n_a W_aa.shape[0] n_timesteps x.shape[0] h np.zeros((n_timesteps 1, n_a)) y np.zeros((n_timesteps, output_dim)) for t in range(n_timesteps): h[t1] np.tanh(np.dot(W_ax, x[t]) np.dot(W_aa, h[t]) b_h) y[t] softmax(np.dot(W_ya, h[t1]) b_y) return h, y2.3 损失函数与序列形态RNN的损失函数取决于任务类型。课程里讲得最多的是两个多对一分类比如情感分析、文本分类只需要对最后一个隐状态接一个softmax然后计算交叉熵。多对多生成比如字符级语言模型、机器翻译每个时间步都输出一个概率分布每个时间步都计算交叉熵然后全部加起来。字符级语言模型是一个特别容易上手的练手项目。把训练文本拆成字符序列每个时间步的输入是前一个字符预期输出是下一个字符。训练好之后每次从当前字符预测下一个字符的概率分布再根据概率采样一个字符把这个字符当作下一步的输入循环循环再循环就能生成一段看起来有模有样的文本。这门课里有一个用恐龙名字做字符级语言模型的作业帮我做的事是把一堆恐龙名字喂进网络然后让它生成新恐龙名。这个作业最迷人的地方在于RNN没有看到任何语法规则但训练到一定程度生成出来的名字天然带有“某种语言的拼写风格”。2.4 反向传播穿行在时间里的梯度RNN的反向传播叫BPTTBackpropagation Through Time跟普通网络的差别在于误差不仅要沿着网络层往回传还要沿着时间步往回传。下图里的思路其实是“把展开后的网络当成一个非常深的前馈网络”每一时间步都是一层所以反向传播自然就变成了沿着所有时间步走一遍。标准前向传播是h_t tanh(W_ax * x_t W_aa * h_{t-1} b_h)那么误差对W_aa求梯度时不仅要考虑当前步h_t对W_aa的直接影响还要追溯所有后续时间步对h_t的依赖。用链式法则表达就是∂L/∂W_aa Σ_t ∂L/∂h_t * ∂h_t/∂W_aa这个符号看着还好真写在代码里就会发现因为每个h_t都包含h_{t-1}的函数梯度里会不断出现乘法链。序列长度一长梯度就是一系列矩阵乘法的累积。这也是整个RNN训练里最微妙、最需要小心的地方。3. 梯度问题小与结构演进3.1 梯度消失和梯度爆炸从哪儿来RNN最大的毛病就是非常难训练长序列原因是梯度在时间维度上的连乘效应。具体来说隐状态的梯度里会包含类似这么一串东西∂h_t / ∂h_k Π_{jk1}^{t} (W_aa的转置 * diag(tanh (pre_activation)))这里的关键在于tanh的最大值是1通常远小于1。多个小于1的数乘在一起梯度迅速坍缩到零。这就是梯度消失——网络根本学不到长距离依赖。反过来如果W_aa的特征值大于1梯度又会指数膨胀变成梯度爆炸训练直接发散loss变成NaN。吴恩达在课程里给过一个很形象的比喻梯度消失就像一个人要记住很久以前的一个信息中间每传递一步就丢失一点点到最后彻底想不起来了。RNN的直观理解就是隐状态机械地从左往右传中途没有任何“刻意保留”的机制。3.2 LSTM给记忆加三道门为了缓解梯度问题LSTM出现了。LSTM的核心想法是引入一个“细胞状态”cell state并配三扇门遗忘门、输入门、输出门。这三扇门的本质都是sigmoid函数输出范围在0到1之间。值为0代表完全关闭、什么也不通过值为1代表完全打开。于是细胞状态的更新公式就变成了C_t f_t * C_{t-1} i_t * ẑ_t其中f_t是遗忘门决定上一步的细胞状态有多少能被保留i_t是输入门决定当前新信息有多少能写入ẑ_t是当前步生成的候选记忆。这个结构最厉害的地方在于遗忘门和输入门提供了一条从C_{t-1}到C_t的“近路”梯度可以沿着这条路顺利回流而不需要经过那串危险的矩阵连乘。我在实操里一直把LSTM理解为“有选择地记住该记的丢掉该丢的”。比如预测一段天气数据某天突然出现异常高温模型可以选择在之后的几步里保留这个信息等影响过去了再“忘掉”它。3.3 GRU更轻量但够用GRU把LSTM的三扇门简化成两扇更新门和重置门。更新门相当于同时控制遗忘和写入重置门决定旧状态有多少被用来计算当前候选状态。参数比LSTM少计算量也小。课程里有一句话我记得很清楚GRU是LSTM的简化版在语料不是特别大的情况下效果差距不大但训练速度更快。实践中我的经验也验证了这一点。中小规模任务比如我拿RNN做商品评论情感分类GRU和LSTM准确率差别基本在1个点以内但GRU训练一个epoch的时间短了大概20%。所以不要在每一处都无脑上LSTM先试GRU常常是更务实的选择。4. 双向RNN与深层结构的细节4.1 双向RNN如何补足上下文刚才说的RNN信息流只有从前到后一个方向。这在有些任务里是致命的。拿命名实体识别来说判断某个词是不是人名光看前面的词不够后面出现的“教授”“主任”这类身份词才是关键线索。单向RNN处理这种情形就明显力不从心。双向RNNBRNN解决得特别干净跑两个独立的RNN一个正向读序列一个反向读序列然后把两个方向在每个时间步的隐状态拼接起来作为最终输出。反向RNN看到的信息是“未来的”但计算方式跟正向一模一样只是把输入序列倒过来喂。注意力机制里常用的自回归Transformer也借鉴了这种“前后都看”的思想。需要注意双向RNN不适用于所有任务。比如在线预测场景——打一个字立刻给出下一个字的预测或者语音识别里的实时转写——因为反向层必须看到整个序列才能开始计算。一旦序列不完整反向层的输出就是不可用的。如果是离线分析比如整篇文档分类那随便双向。4.2 深层RNN怎么叠单个RNN层对复杂模式的拟合能力有限。课程里介绍了深层RNN把多个RNN层垂直堆叠起来每层输出的隐状态作为下一层的输入。举个例子第一层RNN读原始词向量输出h_1序列第二层RNN把h_1序列当作输入输出h_2序列如此类推。最后通常接一个softmax做输出。深层RNN的训练难度比单层大不少主要在于梯度要同时穿过“层数”和“时间步”两个维度计算图的深度是两者乘积。此外训练速度显著变慢。我的建议是从单层开始逐步往上加不要一上来就叠三层。对绝大多数业务场景两层通常已经够用。再多收益会变得不明显反而容易过拟合。4.3 注意力机制让网络学会盯住关键处严格来说注意力机制不是循环神经网络的一部分但吴恩达把它放在序列模型这个专题里我觉得非常合理因为两者天然互补。RNN的隐状态是“压缩记忆”长句子的信息全都挤在一个固定长度的向量里前面的内容很容易被稀释。注意力机制的做法是解码器每生成一个输出就计算当前状态与编码器所有时间步隐状态的匹配度根据匹配度对这些隐状态做加权求和得到上下文向量。之前在做机器翻译练习时我用过一个简单的加性注意力。效果对比很直观没有注意力机制时句子超过十五个词就开始丢信息加了注意力之后翻译长句子的质量提升得特别明显。注意力本质上给了模型一个“回头看原句在哪里找答案”的能力而不是指望最后那个压缩向量一切全包。5. 实操过程中的经验与避坑5.1 词向量怎么用RNN的输入是词向量。实操里有两个路线随机初始化词向量或者在预训练词向量上做微调。随机初始化最大的问题是模型要同时学习语法和语义对语料量的要求很高。语料小的时候效果明显不如预训练词向量。预训练方案在课程里是用“词嵌入”讲的简单说就是把每个词对应一个高维稠密向量使得语义相近的词在向量空间里也接近。我最常用的做法是采用现有的预训练词向量做初始化训练时打开微调开关让它随着主任务更新。如果语料领域比较小众比如生物医学文本那最好自己用Word2Vec或者GloVe在领域语料上重新训练词向量通用词向量在这类场景下不太能帮上忙。5.2 梯度裁剪防爆炸最实用的一招梯度爆炸相比梯度消失反而是更好解决的那一个。最经典有效的方法就是梯度裁剪gradient clipping。算法非常简单算完梯度之后如果梯度的L2范数超过某个阈值就把它缩放回阈值大小。在代码里实现大概是这样grad_norm np.linalg.norm(grad) if grad_norm threshold: grad grad * (threshold / grad_norm)阈值的选择有一定规律可循。我一般看loss的曲线如果某一步loss突然变成NaN说明梯度爆了先调小学习率不行的话就把裁剪阈值调低。经验上阈值设在1到5之间比较常见。这招虽然简单效果却立竿见影尤其是在语言模型训练一开始权重还没稳定下来那段时间几乎每天都要靠它救命。5.3 序列长度与mini-batch的组织RNN实操里最容易被忽视的其实是数据怎么组成一个batch。因为一个batch内每个样本的长度必须一致所以要做padding把短的样本用特殊字符补到跟最长的一样长。但RNN会被padding字符影响尤其是padding部分也参与隐状态更新时。虽然严格来说网络可以学习把padding字符的输入向量映射成零向量但训练成本上升了不少。更常见的做法是使用mask机制在计算损失时对padding位置的输出不计算loss对状态传递时也通过mask把padding部分的隐状态重置为0。课程作业里提供了封装好的框架但在自己写代码时很容易漏掉这个细节。漏掉的后果是模型会把padding当普通文本学习特别是当batch里有一条特别长的样本时其他样本的padding区间都会变成噪声。我习惯把batch内样本按长度排序长度相近的样本放在一起这样每个batch里的padding比例不会太高。如果数据集句子长度分布特别不均匀可以再分桶每个桶内做padding。5.4 训练速度优化RNN是出了名的难加速因为时间步上的计算天然是串行的必须算完h_1才能算h_2。但实践中有几个办法可以明显提速。第一没到万不得已别用太深的网络单层或者两层通常就够。第二用LSTM/GRU替代基础RNN虽然实际参数没多多少但收敛效率更高。第三在GPU上尽量用框架内自带的高效实现在主流深度学习框架里RNN底层不是简单的“循环矩阵乘法”展开内部做了大量融合优化直接使用框架的RNN接口比自己用底层算子写循环快得多。第四如果序列长度实在过长查看它是不是能被截断。比如我只做情感分类单个10万字的文档我会先按句切分对每句话单独过RNN之后再融合而不是一口气跑完所有字符。5.5 过拟合与正则化RNN参数不少但输入通常又高维所以在小数据集上很容易过拟合。课程里推荐的方案是dropout但强调一点RNN的dropout不能直接加在隐状态的循环连接上否则会破坏记忆传递。正确做法是只对输入到隐状态的连接和隐状态到输出的连接做dropout而对时间维度的循环连接不做。在主流深度学习框架里能看到一个参数专门控制这个行为新手容易误以为在RNN里加了dropout就万事大吉实际效果往往是训练集loss一路狂降验证集迟迟不动。简单说把dropout放在时间维度的“外面”放在层与层之间不要放在“内部”的循环边上。6. 从笔记到应用场景的延伸课程这部分的内容虽然以理论为主但几乎每个模块都能对应到真实项目。语言模型方面常见的输入法联想、语音识别转文字、机器翻译底层都离不开序列建模的能力。我自己带过一个小项目任务是阅读理解里答案抽取。用户给出的是一篇几千字的文章加一个问题要模型输出正确答案。当时我用了一个两层双向GRU做编码再配合注意力机制去定位答案的起止位置整体效果比之前用纯CNN的方案强了一档。时序预测是另一类天然适配RNN的场景。比如预测某个指标接下来几天的走势。用RNN的好处是它不需要像ARIMA那样做繁琐的平稳性检验和差分预处理而是直接从历史序列里学模式。当然RNN在超长序列上仍然不占优势这也是为什么后来Transformer架构越来越流行。不过做中小长度序列RNN的向量化实现轻量、易调试依然是很好的选择。现在的工程实践里很少有人单独用RNN打天下了。主流做法是把RNN当作编码器的一部分或者跟注意力机制结合。如果你理解RNN的原理再迁移到Transformer相对好理解得多——本质上Transformer也是在做“根据过去的上下文预测未来”这件事只是它用自注意力替代了循环结构让所有位置可以并行计算。所以RNN并不“过时”它是理解现代NLP架构的一把钥匙。7. 常见问题速查表现象可能原因处理办法训练loss变成NaN梯度爆炸使用梯度裁剪阈值设置在1~5之间验证集效果远不如训练集dropout加在循环连接上改为只对输入/输出连接做dropout长句子效果差梯度消失换用LSTM/GRU或引入注意力机制训练很慢序列过长/网络过深截断序列、减少层数、用预训练词向量单词顺序敏感的任务不理想只用单向RNN改双向RNN注意是否适合离线场景小数据集效果差随机初始化使用预训练词向量打开微调我自己在原始课程作业里遇到过几个很有代表性的问题也在这里一并分享一下。第一个是“初始状态忘记清零”。RNN处理完一个batch之后隐状态应该重置。如果不重置上一个batch的末尾状态会混进下一个batch的开头等于是给模型注入了不存在的上下文。我当时有个模型表现忽高忽低查了很久才发现是状态没清零导致的。第二个是“解码阶段用真实标签还是预测值”。训练时每个时间步的输入来自训练数据里的真实标签这叫teacher forcing。但用训练好的模型做生成时输入只能是模型自己上一步的输出。如果训练和生成阶段完全割裂模型一旦生成偏了就会越偏越远。课程里提到过schedule sampling的思路训练时以一定概率把真实标签替换成模型自己的输出让模型逐步适应推理阶段的输入分布。这个细节在生成式任务里影响特别大。第三个是“损失函数怎么算”的小细节。多分类任务里每个时间步的损失加起来求平均还是求和会直接影响学习率的感觉。如果序列长度不固定求和会让长序列的损失更大模型倾向把解码能力集中在长序列上。我通常会对每个样本的损失除以序列长度再做batch平均。最后说一个我自己的个人习惯如果拿到模型第一件事永远是用随机权重和一小批数据跑通前向和反向确认梯度能正常计算出来。特别是自己手写RNN实现的时候这一步能避免90%以上的低级错误。说到底循环神经网络最值得学习的并不是那张循环结构图而是“状态传递”这个核心思想网络在每个时间步更新自己的内部状态同时把新信息压缩进记忆里。不管是LSTM、GRU还是后来蓬勃发展的Transformer都是在思考“如何更有效地管理这个需要不断更新的状态”。把这一步想透了以后碰到再复杂的序列模型你也能一眼看穿它的骨架。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑