大概几个月前我重刷了吴恩达老师在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。
- ( t=1 ):输入 ( 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。
- ( t=2 ):输入 “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_{k=1}^{t} \frac{\partial \mathcal{L}^{\langle t \rangle}}{\partial a^{\langle t \rangle}} \left( \prod_{j=k}^{t-1} \frac{\partial a^{\langle j+1 \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_size=1把整个模型跑通,再用随机数据测试batch_size=4,这样两次之间的维度错误基本能暴露出来。你再怎么觉得自己脑子清醒,也逃不过维度错误,那就让测试来帮你抓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训练这个任务,观察在 ( T=20 ) 和 ( T=100 ) 时的表现差异,相信对“梯度消失”的理解会直观很多。
完成这个小实验以后,再回头看吴恩达课程里提到的“猫吃掉了...很好吃”的例子,就会明白为什么教科书老是拿“距离很远的单复数一致”当典型案例,因为它确实是RNN梯度消失问题最直白的语言学表现。
第一周的课看下来,我个人一个非常强烈的感受是:吴恩达刻意把RNN最核心的机制压缩在了前半段,而把那些工程上的坑留在作业和后续课程里让你自己踩。所以如果你刚看完视频觉得懂了,合上电脑又觉得模模糊糊,千万别怀疑自己。RNN的反向传播和门控机制,是需要结合公式推导和实际代码才能内化成常识的。我建议你按照笔记里的思路,先把符号体系捋顺,再手推一遍前向和反向,最后动手跑一个小实验,这套流程走完,第一周的内容基本就焊在脑子里了。