ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

循环神经网络RNN核心原理与序列建模实践详解

循环神经网络RNN核心原理与序列建模实践详解 大概几个月前我重刷了吴恩达老师在Coursera上的《深度学习专项课程》第五门《序列模型》第一周的内容标题是“循环序列网络”。说实话当年第一次看这套课的时候我还在跟图像识别较劲对序列模型的感觉就是“RNN能做机器翻译和语音识别挺厉害的”但具体怎么厉害、网络内部到底在算什么脑子里是一团浆糊。这次带着实际项目里的问题回头看才发现第一周的信息密度非常高几乎每一段视频都是在把“为什么序列数据不能直接丢进普通神经网络”这件事往根上刨而且吴恩达反复强调的那些符号约定比如 ( x^{\langle t \rangle} )、( a^{\langle t \rangle} )、( T_x )、( T_y )看着繁琐但一旦上手写代码或者手推反向传播这些符号就是救命的坐标系。这篇笔记我整理得很“重”——不只是把视频里的幻灯片抄一遍而是把前向传播的计算过程、不同类型的RNN结构分别适合什么任务、以及反向传播里的梯度消失为什么必然发生都尽量用口语讲明白。面向的读者是想把理论吃透、最好能自己用NumPy或TensorFlow复现一遍的初学者也包括像我一样以前学过、但理解还浮在表面的工程师。1. 为什么普通神经网络处理不好序列数据1.1 序列数据三个要命的特征吴恩达在第一节课里用几个例子直接把我们拉进场景语音识别、音乐生成、情感分类、DNA序列分析、机器翻译、视频行为识别、命名实体识别。这些任务看起来五花八门但都有三个共同点。第一输入输出的长度是可变的。一段语音可能是1秒也可能是10秒一个英文句子可能有5个词也可能有50个词。如果把这样的数据强行喂给标准的全连接网络或者卷积网络通常要先把所有样本pad到固定长度这既浪费计算量又会让短样本被大量无意义的占位符污染。更麻烦的是输出长度同样可变——一段中文翻译成英文句子长度几乎没有一一对应关系。第二数据里存在非常强的顺序依赖。“我喜欢苹果”和“苹果喜欢我”用词完全相同但含义完全相反在视频里一个动作是否合理严重依赖前面几帧发生了什么。普通的神经网络默认输入是独立的这个假设在序列数据面前根本不成立。第三特征在时间或空间上是共享的。在图像里我们用卷积核来共享权重从而识别出平移不变的特征。序列数据里也有类似的需求句子开头的“猫”和句子结尾的“猫”它们的语义角色都是“名词”我们不希望网络针对不同位置各学一套完全独立的参数。1.2 一个简单的命名实体识别例子课程里花了不小的篇幅讲命名实体识别Named Entity Recognition我觉得这是理解序列建模最适合的入门任务。给你一个句子“Harry Potter and Hermione Granger invented a new spell.”你要让模型输出每个单词是否是人名的一部分通常用 ( y^{\langle t \rangle} \in {0, 1} ) 表示当前词是不是人名的一部分。句子中的“Harry”“Potter”“Hermione”“Granger”都应该输出1其余输出0。这个问题看起来简单但马上就能暴露普通网络的问题输入单词个数不是固定的所以如果你用一个固定窗口的滑窗模型窗口大小无论怎么选都别扭如果你把所有词padding到最大长度又会有大量无效输入混进来。RNN解决它的方式非常优雅——逐个词输入同时维持一个内部状态让前面看过的词的信息能流到后面的判断中去。我想强调一下这个例子是理解整周内容的锚点。后面你学前向传播、学习不同架构再回头看这个例子你会意识到输出 ( 1, 0, 1, 0 \dots ) 其实可以对应到 many-to-many 结构中的 ( T_x T_y ) 情形而“Harry”是“Potter”的上下文这件事本质上就是隐藏状态 ( a^{\langle t \rangle} ) 在起作用。2. 符号体系吴恩达课程的精髓也在这里2.1 时间步与括号上标上过吴恩达课的人应该都有体会他的符号系统可能比公式本身更重要。只要你能把符号读懂后面的计算图和代码实现基本就是顺水推舟的事情。对于序列数据假设一个训练样本是一个句子由 ( T_x ) 个单词组成。我们用 ( x^{\langle t \rangle} ) 表示第 ( t ) 个时间步的输入注意这里的尖括号 ( \langle \cdot \rangle ) 是吴恩达用来区分时间步索引和普通样本索引的特殊记号个小细节但非常有用。在Python里x[t]就是对应着这个符号。相应地在输出端每个时间步对应的标签是 ( y^{\langle t \rangle} )输出的总时间步数记为 ( T_y )。需要特别注意的是( T_x ) 和 ( T_y )不一定相等。机器翻译就是一个典型例子一个英文句子翻译成中文输入词数和输出词数往往不同。这就引出了后面要讲的多种RNN结构。再往下隐藏状态或者说“激活值”记为 ( a^{\langle t \rangle} )它承载了从第1个时间步到第 ( t ) 个时间步“看过的所有信息”的压缩编码。吴恩达在课程里用了一个很形象的描述RNN读取 ( x^{\langle 1 \rangle} ) 后生成 ( a^{\langle 1 \rangle} )它把第一个词的信息“记住”了读 ( x^{\langle 2 \rangle} ) 时网络同时看到 ( a^{\langle 1 \rangle} ) 和 ( x^{\langle 2 \rangle} ) 的新信息生成 ( a^{\langle 2 \rangle} )以此类推。( a^{\langle t \rangle} ) 就像是网络在看完整句子到当前位置时的“笔记”。2.2 每个时间步都共享同一套参数在RNN中每一时间步执行的运算完全一样用的是同一组权重矩阵。吴恩达在课里定义了三组核心参数( W_{ax} )从输入 ( x ) 到隐藏层 ( a ) 的权重矩阵形状是 ( (n_a, n_x) )( W_{aa} )从上一个隐藏状态 ( a^{\langle t-1 \rangle} ) 到当前隐藏状态的权重矩阵形状是 ( (n_a, n_a) )( W_{ya} )从隐藏状态到输出 ( y ) 的权重矩阵形状是 ( (n_y, n_a) )除此之外还有两个偏置项 ( b_a ) 和 ( b_y )。这种共享机制的好处值得展开说一下。如果每个时间步独立用一套参数那么参数总量会随着序列长度线性增长而且训练数据里不同位置的词很难互相“借鉴”统计信息。比如你的训练语料里“猫”总出现在开头“狗”总出现在结尾那网络可能只会机械地记忆位置而不是理解语义。共享参数迫使模型学习的是一个“通用的时间步转移规则”这正是RNN拥有强大泛化能力的基础。你会发现这和卷积网络里“共享卷积核”的哲学是完全一致的——不同位置的输入共享同一种特征提取方式。3. 前向传播计算流程逐行拆解3.1 一个时间步究竟算了什么现在咱们把注意力集中在一个时间步上。第 ( t ) 步时网络拿到两个输入——当前单词的向量 ( x^{\langle t \rangle} ) 和上一个时间步传过来的隐藏状态 ( a^{\langle t-1 \rangle} )。处理过程就两步。隐藏状态更新[ a^{\langle t \rangle} \tanh(W_{aa} a^{\langle t-1 \rangle} W_{ax} x^{\langle t \rangle} b_a) ]然后再由新的隐藏状态产生当前步的输出[ \hat{y}^{\langle t \rangle} \text{softmax}(W_{ya} a^{\langle t \rangle} b_y) ]这里我按吴恩达的写法把隐藏状态激活函数固定为 ( \tanh )。他在课里特别解释过虽然ReLU在某些场景下也可以做RNN的激活函数但 ( \tanh ) 的输出范围是 ((-1,1))在梯度传播上比ReLU更温和能稍微缓解梯度爆炸的问题。为了加深直觉可以把上述公式化简成更紧凑的形式。如果定义一个增广权重矩阵 ( W_a [W_{aa} \mid W_{ax}] )同时把 ( a^{\langle t-1 \rangle} ) 和 ( x^{\langle t \rangle} ) 拼接成一个向量那么隐藏状态的计算可以写成[ a^{\langle t \rangle} \tanh(W_a [a^{\langle t-1 \rangle}, x^{\langle t \rangle}] b_a) ]这个紧凑写法在吴恩达课后编程作业里很常见因为它能让你用一个大矩阵乘法替代两个小矩阵乘法代码写起来更简洁GPU利用率也更高。但理解概念时还是拆开成 ( W_{aa} ) 和 ( W_{ax} ) 更容易懂两者不冲突。3.2 循环展开一张无限共享权重的深网如果把时间维度的循环展开成计算图你会看到一条清晰的链( x^{\langle 1 \rangle} \rightarrow a^{\langle 1 \rangle} \rightarrow x^{\langle 2 \rangle} \rightarrow a^{\langle 2 \rangle} \rightarrow \cdots \rightarrow a^{\langle T_x \rangle} )。这条链上每个 ( a^{\langle t \rangle} ) 都依赖于前面所有输入等于每一时刻的网络都在“回看”历史。很多初学者第一次接触这个图时都会困惑这跟一个深层神经网络有什么区别区别就在于普通深度网络每一层有自己的权重而RNN展开后每一个时间步共用同样一套 ( W_{aa}, W_{ax}, W_{ya} )。所以从参数数量上看RNN的参数量是 ( O(n_a^2 n_a n_x n_y n_a) )与序列长度无关但从计算角度看它的计算量是随着序列长度线性增长的。这也是为什么实际训练RNN时我们通常要么固定最大时间步截断要么用梯度裁剪因为完整展开后网络的“深度”可能是几百甚至上千层反向传播走完这么长的路径数值稳定性非常容易出问题。吴恩达在课程里把 ( a^{\langle 0 \rangle} ) 初始化为零向量这是一个值得注意的细节。理论上你也可以随机初始化但零向量是惯例它其实是在告诉网络“我在句子开始处什么都不知道”让模型自己学着从零状态起步。3.3 一个小例子帮助理解前向过程假设我们有这样一个句子“let’s go to the park”并且我们已经训练好了网络想看一下前向传播到底输出了什么。为了方便演示假设隐藏单元数 ( n_a 3 )词典大小是10。( t1 )输入 ( x^{\langle 1 \rangle} ) 是 “let’s” 的 one-hot 编码形状 ( 10 \times 1 )( a^{\langle 0 \rangle} \mathbf{0} )。计算 ( a^{\langle 1 \rangle} \tanh(W_{ax} x^{\langle 1 \rangle} W_{aa} a^{\langle 0 \rangle} b_a) )。再计算 ( \hat{y}^{\langle 1 \rangle} \text{softmax}(W_{ya} a^{\langle 1 \rangle} b_y) )。这个输出会被解释为“模型认为第一个词是词典中每个词的概率分布”。训练时我们希望正确答案“let’s”对应的概率接近1。( t2 )输入 “go” 的 one-hot 向量但网络还带着 ( a^{\langle 1 \rangle} )所以它计算 ( \hat{y}^{\langle 2 \rangle} ) 时已经考虑了“let’s”这个信息。之后每一个时间步都类似直到句子结束。整个过程下来网络在每一个时间步输出一个概率分布整个前向传播就是一系列矩阵乘法加一次softmax变换的重复。如果你想在写作业或者复现时验证自己的前向传播对不对一个实用的检查方法是在随机初始化参数下用同一个输入跑两次前向结果应该完全一致如果第二次传入的是不同的序列顺序比如调换单词顺序输出必须跟着变化。前者验证代码无状态性问题后者验证网络确实对序列顺序敏感。4. 不同RNN架构与应用场景的对应关系4.1 五类典型结构吴恩达在第一周课程里把RNN按输入输出结构分成几类。这个分类不是理论洁癖而是非常现实的工程选型依据。many-to-many( T_x T_y )输入和输出序列长度相同。典型任务是视频逐帧行为识别、命名实体识别以及简单的逐词标注。这类结构最容易理解因为每一步都有监督信号。many-to-one( T_x 1, T_y 1 )输入是序列但只输出一个值。典型任务是情感分类——给定一段评论文本输出一个情感分数或分类标签。最后一步的隐藏状态 ( a^{\langle T_x \rangle} ) 被用来做最终预测因为它包含了整个序列的摘要信息。one-to-many( T_x 1, T_y 1 )输入是单个对象输出是序列。典型任务是图像描述image captioning输入一张图片的特征向量输出一段描述文字。在这种情况下网络的输出 ( \hat{y}^{\langle t \rangle} ) 经常被当作下一步的输入这叫“autoregressive”生成。many-to-many( T_x \neq T_y )最经典的例子就是机器翻译。输入是一个句子输出是另一种语言的句子长度没有保证。这种结构通常用两个RNN拼接在一起实现编码器Encoder读取完整输入把最终隐藏状态当作整个句子的“语义编码”解码器Decoder从这个编码出发逐个词生成输出期间每个词的生成都会影响后续词的生成。吴恩达用一个特别清楚的示意图展示了这几种结构我建议你自己画一遍自己动手画一遍比看十遍别人的图更能建立肌肉记忆。4.2 编码器-解码器结构为什么这么重要在课程里吴恩达把机器翻译视作一个“条件语言模型”来讲解。编码器RNN的任务很简单读取源语言的单词序列最后输出一个向量这个向量被视为“整个句子的意思”。解码器RNN的任务是从这个向量开始逐词生成目标语言的句子。这背后的信息论直觉很深刻——编码器把变长的输入压缩成一个固定长度向量解码器再把固定长度向量解压成变长的输出。课程里没有立刻展开注意力机制那是第三周围绕机器翻译的重点但第一周能把编码器-解码器这个框架理解透彻后面学注意力机制时你会轻松非常多。我自己在实际项目里用这种结构时有一个体会编码器的输出向量维度 ( n_a ) 不能设得太小否则会变成信息瓶颈导致翻译或生成质量下降。但设得过大又不一定能带来成比例的效果提升反而增加计算量。这跟找对象有点像向量维度太小装不下“内涵”太大又冗余实际中一般要从256试到512再试到1024观察验证集上的变化曲线来做决定。4.3 语言模型与采样生成RNN的“文科生”技能第一周课程还专门讲了语言模型Language Model的概念它的任务就是计算一个句子在语料中出现的概率。训练语言模型时输入是一个句子的前 ( t-1 ) 个词标签是第 ( t ) 个词。训练好之后就可以用它来“续写”句子——注意不是选概率最高的那个词每次重复那样只会生成死循环而是根据概率分布进行采样这样才能得到多样化的文本。这个采样过程在实操中是有讲究的。有时候直接用softmax输出的概率分布采样生成文本会过于随机有时候把概率分布取一个“温度”temperature再采样可以控制多样性。吴恩达这周课程里没有展开温度参数但你应该知道生成类RNN任务温度几乎是个必备旋钮这属于“课后作业之外的高级调参”。5. 反向传播与“梯度消失”这个老大难5.1 BPTT随时间展开反向传播RNN的反向传播有个专门的名字Backpropagation Through Time简称BPTT。它的核心思路一句话就够把RNN按时间展开成普通深度网络然后用标准的反向传播逐层计算梯度。展开之后你会发现从最后一个时间步 ( T_y ) 的损失出发梯度要一路传回第一个时间步中间要连续穿过多个 ( \tanh ) 激活函数和权重矩阵。这就引出了两个经典问题梯度消失和梯度爆炸。展开后的网络深度等于序列长度每个时间步都有两个乘法操作矩阵乘以 ( W_{aa} ) 和乘以 ( \tanh ) 的导数。( \tanh ) 关于输入的导数最大也就是1在输入很大的时候会趋近于0。所以每一层梯度都会被一个小于等于1的数缩放层数一深梯度微乎其微传到远处时间步的参数几乎得不到更新。5.2 为什么梯度消失是“硬伤”梯度消失的直接结果就是网络很难学习到“长期依赖”long-term dependence。比如英文句子里“The cat, which was very hungry, ... ate ...”“cat”是单数后面动词需要对应单数第三人称的形式中间可能隔着十几个词。要让网络把这些远处信息关联起来梯度就必须穿越很长的路径但梯度消失让这段路径的权重更新趋近于零。吴恩达在课上把RNN和深层网络做了类比说RNN在时间维度上天然就是一个非常深的网络所以梯度消失问题比前馈网络更严重。他用了很好的一个图解在many-to-many结构中损失函数 ( \mathcal{L}^{\langle t \rangle} ) 对早期参数 ( W_{ax} ) 的依赖路径特别长梯度路径上每次都要经过非线性压缩。数学上可以写成[ \frac{\partial \mathcal{L}^{\langle t \rangle}}{\partial W_{ax}} \sum_{k1}^{t} \frac{\partial \mathcal{L}^{\langle t \rangle}}{\partial a^{\langle t \rangle}} \left( \prod_{jk}^{t-1} \frac{\partial a^{\langle j1 \rangle}}{\partial a^{\langle j \rangle}} \right) \frac{\partial a^{\langle k \rangle}}{\partial W_{ax}} ]中间那个连乘项如果每项都小于1整个结果趋近于0如果每项都大于1结果又会爆炸。这才是RNN训练不稳定的数学根源。5.3 梯度爆炸的应对梯度裁剪梯度爆炸相对好处理因为它现象明显——训练时loss突然变成NaN或者出现非常大的数值。吴恩达建议的方法是梯度裁剪如果梯度的范数超过某个阈值就按比例缩放它让它的范数回到阈值以内。这个操作虽然“粗暴”但非常有效尤其是RNN训练时几乎成了标配。我自己训练RNN时候的习惯是先设置梯度裁剪阈值为5.0观察训练loss的情况如果loss出现NaN就把阈值往下调如果一切正常就逐步增大阈值让模型有空间迈更大的步子。很多开源的深度学习框架里比如PyTorch里就有torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm)这个函数一行代码的事。5.4 梯度消失的治本方案LSTM / GRU梯度消失的根治方法不是靠调参而是要改变网络结构。这就是第一周最后一节课引入LSTM和GRU的原因。LSTM的核心思路是引入一个独立的“记忆单元”memory cell( c^{\langle t \rangle} )让信息可以通过“门控”机制选择性地写入、保留和读取。关键点是记忆单元在时间步之间有一条“直通高速公路”它的梯度传播路径不经过 ( \tanh ) 压缩所以能长期保留。吴恩达在课程里用了“传送带”这个类比我觉得特别贴切普通的隐藏状态 ( a^{\langle t \rangle} ) 是一条普通公路LSTM的记忆单元是一条传送带信息在上面流动时不会衰减只有到了特定位置通过“门”决定要不要补充新信息或者擦除旧信息。LSTM有四个门更新门、遗忘门、输出门以及候选记忆单元。GRU是LSTM的简化版把两个门合并成一个更新门参数更少训练更快效果与LSTM在很多场景下接近。吴恩达没有要求你当场手推这两个结构的所有公式但建议你在课后至少自己推一遍。推荐一个学习方法用NumPy写一个没有优化过的LSTM前向传播再写一个BPTT虽然代码量不小但比起看PPT公式你的理解深度是几何级别的提升。6. 实操心得与小技巧6.1 源码阅读顺序建议如果你想把吴恩达课后的编程作业吃透我强烈建议你不要只盯着assignment里的填空位置而是花时间把整体框架读明白。作业代码通常有几个重要文件rnn_utils.py提供了一些基础函数、lstm_cell_forward、lstm_forward、rnn_cell_forward和rnn_forward。我的阅读顺序是先看rnn_cell_forward理解单步RNN的输入输出和形状。再看rnn_forward理解循环遍历的方式和时间步维度的布局。然后对比 LSTM cell 和 RNN cell 的差别重点看门的引入如何改变信息流。最后如果有兴趣跑一下IMDB情感分类或古诗词生成之类的toy experiment加深印象。6.2 维度的力量我最想跟初学者说的一句话学RNN型作业最容易翻车的地方是维度。以TensorFlow/PyTorch的习惯为例常见的张量形状是(batch_size, sequence_length, feature_dim)。如果转换到课程里的符号sequence_length对应 ( T_x )feature_dim对应 ( n_x )。你在写代码前一定要先想清楚每个张量在每一维的含义不然很容易出现transpose、reshape满天飞的问题。我有个屡试不爽的建议先按batch_size1把整个模型跑通再用随机数据测试batch_size4这样两次之间的维度错误基本能暴露出来。你再怎么觉得自己脑子清醒也逃不过维度错误那就让测试来帮你抓bug。6.3 一些容易踩的坑初始化很重要RNN的权重初始化一般用较小的随机值比如从均匀分布 ( U(-0.05, 0.05) ) 采样。如果把初始权重设得太大很容易一开始就让tanh饱和梯度直接归零。不要把单词直接喂给网络一定要先把单词映射成稠密向量word embedding再用嵌入向量作为输入。如果你的语料不大可以直接用训练好的预训练词向量比如GloVe。序列长度不一致时记得用掩码在batch训练中如果样本长度不一致通常要padding到同一长度。但计算loss时一定要把padding部分的loss屏蔽掉否则模型会被一堆“ ”标签带偏。吴恩达课里没有细讲这一点但工程上这是必备实操。RNN的“记忆”不是万能的即使有LSTM普通LSTM也很难捕获超过几百步的依赖。如果需要处理非常长的序列要考虑Transformer或层次化RNN等更复杂的结构这已经超出第一周的范围了。6.4 一个可直接用小数据集跑的验证小实验纸上谈兵再多不如动手做一个小实验。一个常见又便宜的验证方式是使用“求和学习”任务给定一个长度为 ( T ) 的数字序列让模型输出每个时间步之前所有数字的和。这个任务对普通RNN来说其实很难因为模型需要把某个信息长期保留在记忆中但对LSTM来说相对容易。你可以分别用基础RNN和LSTM训练这个任务观察在 ( T20 ) 和 ( T100 ) 时的表现差异相信对“梯度消失”的理解会直观很多。完成这个小实验以后再回头看吴恩达课程里提到的“猫吃掉了...很好吃”的例子就会明白为什么教科书老是拿“距离很远的单复数一致”当典型案例因为它确实是RNN梯度消失问题最直白的语言学表现。第一周的课看下来我个人一个非常强烈的感受是吴恩达刻意把RNN最核心的机制压缩在了前半段而把那些工程上的坑留在作业和后续课程里让你自己踩。所以如果你刚看完视频觉得懂了合上电脑又觉得模模糊糊千万别怀疑自己。RNN的反向传播和门控机制是需要结合公式推导和实际代码才能内化成常识的。我建议你按照笔记里的思路先把符号体系捋顺再手推一遍前向和反向最后动手跑一个小实验这套流程走完第一周的内容基本就焊在脑子里了。
返回列表