ARTICLE DETAIL

资讯详情

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

从零手写RNN:循环神经网络核心原理与LSTM/GRU选型实战

从零手写RNN:循环神经网络核心原理与LSTM/GRU选型实战 我第一次真正“看懂”循环神经网络不是在看论文的时候而是在自己拿NumPy手写了一个最小RNN、对着一条正弦波反复训练之后。如果你现在对循环神经网络Recurrent Neural Network简称RNN已经有一点概念却总觉得它在干什么有点飘忽那这篇内容值得你看完。循环神经网络是专门用来处理序列数据的那一类网络。翻译、语音识别、文本生成、时间序列预测凡是输入输出带先后顺序的问题它都是经典方案里的常客。我会从RNN的核心设计讲起带你手写一个能跑的极简RNN整理训练时最常见的坑再把LSTM、GRU这些变体放在一起对比。无论你是刚入门的学习者还是想系统梳理的老手都能找到能直接落地的内容而不是停留在背公式的层面。1. 为什么要理解循环神经网络序列数据的前世今生1.1 现实世界里的序列问题长什么样序列数据在现实中到处都是。语音是一帧一帧按时间顺序排列的一句话的语义往往依赖前后词股价、气温、传感器读数都是时间序列明天的值常常和前面很多天的状态有关翻译任务里一个句子的每个词都要结合上下文才能确定如何转换。这些问题的共同特点是样本内部有顺序关系顺序翻转之后含义可能完全不同。“我爱你”和“你爱我”这三个字各自单独看没什么区别但调换顺序后意思就反过来了。如果按处理静态图片的思路把这些词一个个独立送进网络网络永远无法捕捉到这种顺序关系。这正是序列建模问题的核心挑战——要设计一种网络结构让它能记得“刚才听了什么”。RNN就是针对这种需求设计出来的。它的核心思路是维护一个隐状态hidden state像一个“记忆本”每读取一个新的输入就结合之前记忆的内容更新这个本子然后基于本子去做预测。这个做法被证明非常适合处理时间序列、自然语言等具有先后依赖的数据。你现在手机上用的语音输入法、聊天机器人的回复生成早年大部分都是靠这套思路打底。1.2 前馈神经网络为何处理不了序列要理解RNN的价值得先看看传统前馈神经网络面对序列时的尴尬。前馈网络接受一个固定维度的输入经过若干层变换后给出一个固定维度的输出。如果你要喂一个长度为3的窗口去预测第4个值那么网络结构就得根据窗口长度来定死。今天窗口是3明天窗口变成10结构就得重新设计。更关键的问题在于参数不共享。比如用前馈网络处理文本把一句话当成一长串词的拼接词的顺序信息其实被削弱了。“我”这个字出现在不同位置就会被不同的权重去拟合网络学到的是“第几个位置是什么词”这种局部规律很难泛化到任意长度和任意位置的序列。RNN做得不一样的地方是参数共享。它无论输入序列有多长都用同一套权重矩阵去处理每一个时间步。这样一来模型长度可以自由变化训练出来的参数具备时间平移不变性——在位置t学到的规律也能用在位置t10上。这个设计思想和卷积神经网络在空间上共享卷积核是同一个道理一个是共享时间维度的参数一个是共享空间维度的参数本质都是为了降低参数量、提升泛化能力。所以面对序列数据时RNN不是一种“更快”的方案而是结构上更匹配的方案。明白了这一点你再看后面那些公式和代码就会发现它们全是在为同一个目标服务——记住过去利用过去。2. RNN的核心设计状态、参数共享与时间展开2.1 一个循环单元在做的事RNN的最小单元能够浓缩成两行公式h_t tanh(W_x * x_t W_h * h_{t-1} b_h)y_t W_y * h_t b_yh_{t-1}是上一时间步的记忆x_t是当前时间步的输入两者拼在一起经过一个线性变换和激活函数得到当前时间步的隐状态h_t然后h_t再通过输出层得到y_t。这个循环结构在时间上展开之后其实就是把同一个网络重复运用了T次每次的输入除了当前的x_t还有上一个时间步的隐状态h_{t-1}。我习惯把这个过程比喻成接力传递每一棒的运动员时间步拿到上一棒递来的接力棒h_{t-1}再结合自己这一段路程的新信息x_t跑出自己这一段的时间成绩h_t并把接力棒继续递下去。最终你关心的预测结果y_t由当前接力棒的状态决定但接力棒里面装着的是前面这么多棒累积下来的信息。注意一个小细节h_t的维度是人为设定的隐藏层大小和输入维度没有必然关系。你把一个5维的输入映射到128维的隐状态完全没有问题。真正决定记忆容量的是h_t的维度——维度越高理论上能记住的信息越多但参数量和计算量也越大。2.2 为什么说RNN是“同一个网络在时间上重复使用”学习RNN时有个容易绕进去的点把网络展开之后它看起来明明是一堆“一样的层”叠在一起和深层前馈网络有什么区别区别在权重共享。前馈网络每层有自己的权重矩阵各不相同而RNN展开后的每一个时间步都用同一套权重矩阵。这种共享带来了两个直接好处第一模型参数量不随序列长度增长你处理任意长度的句子都只需要同一套参数第二模型学习到的是“状态更新的规律”而不仅是“某个位置的映射”所以对序列位置的偏移不敏感。我在带新人时发现一个普遍误区以为RNN在时间上展开后就是一个“计算图特别深的深层网络”。这个理解方向是好的——反向传播时你确实要沿着展开后的时间维度往回传形成BPTTBackpropagation Through Time时间反向传播。但这个网络的“深”是因为时间步数的叠加而不是真正意义上多了很多独立层。理解成同一个函数反复迭代T次心理负担会小很多梯度路径也更清晰梯度要穿过T个相同函数组合成的链每个函数都有自己的雅可比矩阵乘积累积的效果才是梯度消失和爆炸的根源。2.3 激活函数与初始化的选择逻辑为什么经典RNN用的是tanh而不是ReLU或者sigmoid这个问题在写代码之前就该想清楚。回顾梯度传播过程。反向传播时从时间步T往回走到时间步1梯度需要乘以T个tanh导数的连乘。tanh的最大值是1在0附近其他位置都小于1而且tanh的输出范围是(-1, 1)这天然能缓解多次迭代后数值爆炸的问题。sigmoid更糟糕它的导数最大值只有0.25连乘之后梯度衰减得更快深层时基本传不动。ReLU虽然解决了梯度衰减但它的线性增长特性在RNN反复迭代的场景下很容易让数值一路膨胀。所以经典RNN默认选择tanh是有道理的它把隐状态压缩到[-1, 1]这个有界区间让网络的反复迭代在数值上保持相对稳定。当然现代框架里你也可以用其他激活函数但新手入门最好还是沿着经典路线走等能解释清楚为什么偏离时再去偏离也不迟。参数初始化方面RNN比前馈网络要挑剔。通常做法是对输入权重和隐藏权重使用服从均匀分布的较小随机初值比如-1/sqrt(hidden_dim)到1/sqrt(hidden_dim)之间输出权重类似。初始化过大梯度爆炸更容易发生初始化过小梯度消失更快出现。如果你用PyTorch或TensorFlow封装层里的默认初始化在大多数情况下是够用的但自己手写RNN时这个范围一定要控制住。顺带说一句偏置b_h一般初始化为0。有些实现会把遗忘门的偏置初始化成正值这在LSTM里是为了让网络一开始倾向“记住”信息——这个细节在后面变体章节会展开讲。3. 从零实现一个最小RNNNumPy实战3.1 数据准备用正弦波验证记忆能力纸上谈兵聊再多都不如实际跑一个能动的RNN。我推荐第一个实验做正弦波预测而不是文本生成。原因很实际正弦波的数据是自己生成的干净、无噪声能让你把注意力全部放在网络本身而不是数据处理上预测目标明确可视化结果一眼就能看出学没学到东西而且正弦波天然具有周期性依赖RNN只要记住前面的相位就能比较准确地预测后续波形。构造数据时我倾向于这样的设计。输入是当前时间步的正弦值目标是预测下一个时间步的值。比如生成足够长的sin曲线数据每个批次随机切一段连续窗口让网络看当前正弦值输出下一步的正弦值。这里有个细节新手容易忽略训练时要保证切片的连续性不能把打乱后的单点喂进去否则序列依赖被破坏RNN根本无从学起。import numpy as np np.random.seed(42) def generate_sine_sequence(seq_len1000, step0.01): x np.arange(0, seq_len * step, step).reshape(-1, 1) data np.sin(x) return data def make_batches(data, input_len20, batch_size32): x_batch, y_batch [], [] for _ in range(batch_size): start np.random.randint(0, len(data) - input_len - 1) x data[start:start input_len] y data[start 1:start input_len 1] x_batch.append(x) y_batch.append(y) return np.array(x_batch), np.array(y_batch) data generate_sine_sequence(1000, 0.01) print(data.shape) # (1000, 1)这里seq_len取1000、间隔0.01的数据相当于生成约6个完整周期的正弦波。batch_size取32每个样本长度20个时间步。你拿这段代码跑一遍第一个epoch的loss大概率会比较大但训练几十轮之后loss会明显下降预测曲线也会逐渐贴合真实波形。3.2 前向传播实现RNN前向传播是整篇内容逻辑上最顺的一步代码量不大但每行都值得仔细读。我会把隐藏层维度设为16输入维度1输出维度1先初始化三组权重input_dim 1 hidden_dim 16 output_dim 1 lr 0.01 Wxh np.random.uniform(-0.5, 0.5, (hidden_dim, input_dim)) Whh np.random.uniform(-0.5, 0.5, (hidden_dim, hidden_dim)) Why np.random.uniform(-0.5, 0.5, (output_dim, hidden_dim)) bh np.zeros((hidden_dim, 1)) by np.zeros((output_dim, 1))前向传播的核心循环是这样def rnn_forward(x, hidden_prev): xs, hs, ys {}, {}, {} hs[-1] np.copy(hidden_prev) for t in range(len(x)): xs[t] x[t].reshape(-1, 1) hs[t] np.tanh(Wxh xs[t] Whh hs[t - 1] bh) ys[t] Why hs[t] by return xs, hs, ys你可能注意到我用了dict来存每个时间步的中间结果原因只有一个BPTT需要知道每个时间步的h状态前向必须把它们全部缓存下来。这种“用空间换梯度路径”的做法在真正的框架里同样存在只是框架帮你自动管理了。输出y[t]的维度是1也就是对下一个正弦值的预测。训练时我们会把y[t]和目标x[t1]做均方误差MSE然后计算梯度更新权重。还有一点hs[-1]hidden_prev这一步不能省。它定义了“第0个时间步之前”的记忆状态常见做法是初始化为全零向量。如果你希望模型每次从空白记忆开始那么每个样本都要重新初始化h而如果你的任务需要跨样本的连续记忆那就要考虑把上一个样本的最终状态传递到下一个样本。对首轮正弦波实验每个样本开头都从零状态开始就好。3.3 BPTT反向传播没你想的那么难反向传播是RNN新手最容易卡住的地方但它的思路其实很直接既然前向是沿着时间一步步算的反向就沿着时间一步步把梯度传回去这就是BPTT的全称——Backpropagation Through Time。从损失函数出发先求输出层Why和by的梯度这部分和普通全连接层完全一样。然后沿着时间步往回走对每一个t累加当前时间步的delta到隐状态梯度和权重梯度里。最关键的是h_{t-1}的梯度来自两条路径一条是当前时间步输出层对隐状态的依赖另一条是下一个时间步隐状态对h_{t-1}的依赖所以必须用一个循环累加。这一步如果漏了你的梯度就不完整训练时会死活不收敛。一个简化但结构完整的最小例子如下def rnn_backward(xs, hs, ys, targets): dWxh, dWhh, dWhy np.zeros_like(Wxh), np.zeros_like(Whh), np.zeros_like(Why) dbh, dby np.zeros_like(bh), np.zeros_like(by) dh_next np.zeros_like(hs[0]) for t in reversed(range(len(xs))): dy ys[t] - targets[t].reshape(-1, 1) dWhy dy hs[t].T dby dy dh Why.T dy dh_next dh_raw dh * (1 - hs[t] ** 2) # tanh的导数 dbh dh_raw dWxh dh_raw xs[t].T dWhh dh_raw hs[t - 1].T dh_next Whh.T dh_raw return dWxh, dWhh, dWhy, dbh, dby注意这里用了一个技巧dy直接用预测值减目标值来计算。因为损失是MSEMSE对输出的梯度就是预测值减目标值再乘以一个常数系数我把系数省掉了改用学习率去吸收。这在工程上很常见但你要清楚自己省掉了什么不然换损失函数时会绕晕。BPTT的时间复杂度是O(T)的单次前向加单次反向比直接对整个展开图做自动微分要快得多原因就是权重共享让梯度可以在每个时间步累加而不是重复计算。实际项目中如果用PyTorch你直接调loss.backward()就行框架自动帮你完成BPTT但理解这段纯NumPy代码能让你在出问题时快速定位是梯度路径错了还是学习率大了。3.4 训练与结果可视化训练循环本身不复杂。每个epoch切一批数据前向计算预测值计算loss反向拿到梯度用梯度下降更新权重。我把一个简洁的训练循环贴出来def train_rnn(epochs300, seq_len20, batch_size32): data generate_sine_sequence(1000, 0.01) for epoch in range(epochs): x_batch, y_batch make_batches(data, seq_len, batch_size) total_loss 0 for i in range(batch_size): h_prev np.zeros((hidden_dim, 1)) xs, hs, ys rnn_forward(x_batch[i], h_prev) targets y_batch[i] loss 0 for t in range(seq_len): loss (ys[t][0, 0] - targets[t][0]) ** 2 dWxh, dWhh, dWhy, dbh, dby rnn_backward(xs, hs, ys, targets) for param, grad in zip( [Wxh, Whh, Why, bh, by], [dWxh, dWhh, dWhy, dbh, dby] ): param - lr * grad total_loss loss if epoch % 50 0: print(fepoch {epoch}, loss: {total_loss / batch_size:.6f})实验做下来你会看到loss从最初的1.x往下降到0.01以下。这个收敛速度和你的hidden_dim、学习率都有关系。我试过hidden_dim从8升到64loss下降速度明显加快但超过128之后训练时间涨得厉害最终loss反而更容易卡住——因为模型容量变大后需要更多数据才能压住过拟合。可视化预测结果我一般这样做给模型一个初始序列比如真实正弦波的前10个点然后让模型用自己预测出的值作为下一步输入不断自回归地往前预测。如果模型真的学到了规律它能连续预测出几十步甚至上百步波形保持正确如果模型只是在死记训练样本自回归预测很可能会发散或者相位漂移。这也是检验RNN是否“学懂”序列依赖的一个很实际的手段。4. 训练RNN最常见的问题与排查技巧实录4.1 梯度爆炸与梯度裁剪所有人都会遇到这个问题。时间步一长RNN的梯度在反向传播时就像雪球一样滚。运气好滚到一个小值权重更新缓慢运气不好直接滚成一个天文数字loss瞬间飙到nan。解决办法最直接的是梯度裁剪。我习惯在每次反向传播之后检查一下梯度的范数如果超过阈值就按比例缩回去。阈值一般选1.0到5.0之间具体要试。PyTorch里对应torch.nn.utils.clip_grad_norm_用max_norm参数就能搞定。自己手写时就是很简单的一行判断grad_norm np.sqrt(sum((g ** 2).sum() for g in grads)) if grad_norm max_norm: scale max_norm / (grad_norm 1e-6) grads [g * scale for g in grads]这个操作看着简单但它是RNN训练稳定性的生命线。我见过很多刚开始写RNN的朋友loss莫名其妙变nan最后都是梯度爆炸导致的裁剪一下立刻好了。如果你手写的版本没有裁剪尤其要注意学习率不能设太大0.01对很多RNN任务来说已经是比较激进的值了。4.2 梯度消失与参数初始化、激活函数相比爆炸梯度消失更隐蔽。现象是训练时loss下降得极慢或者网络只能利用最近两三个时间步的信息更早的输入对预测几乎没有影响。电话传话游戏大家都玩过一句话经过十几个人转述后完全走样梯度经过很多时间步的连乘后也会变得“什么信息都带不动”。应对梯度消失有几个主流手段。短期来讲把激活函数从sigmoid换成tanh可以缓解一点把隐藏层权重初始化范围缩小到-0.1到0.1之间能让初始梯度路径大一些还可以减少序列长度让网络不要一次背太多东西。长期来讲直接用LSTM或GRU是更省心的选择它们用门控机制给梯度提供了“高速公路”这也是为什么现在纯RNN在实际项目中越来越少见。如果你手写的RNN训练速度特别慢先别急着调学习率可以先输出每个时间步的梯度范数看一眼。如果梯度范数随着时间步的倒退迅速缩到接近0那就是梯度消失没跑了赶紧把序列长度缩短验证一下或者换结构。4.3 序列长度该怎么选序列长度这个超参数经常被忽视但它对RNN的影响非常大。太长梯度问题更严重训练更慢太短网络没有足够上下文学不到真正的依赖关系。我在做用户行为序列建模时发现一个规律先画出依赖关系的大致范围再看训练样本的长度分布序列长度选训练样本分布中能覆盖80%场景的值往往是一个不错的起点。拿文本生成举例如果你想基于前5个字符预测下一个字符那序列长度选10绰绰有余但如果想做韵律相关的生成可能得考虑20到30个字符。还有一种做法是动态序列长度训练时不把所有样本pad到同样长度而是每个batch取当前batch最长的样本作为长度剩余样本做mask。这在框架里很常见能让训练速度快不少也能少做很多无效计算。此外在真正的业务数据里序列长度经常不一致。比如用户行为序列短的只有2步长的有200步。如果统一截断到固定长度就会丢失长序列用户的上下文如果全部保留又会拖慢训练。我建议数据预处理阶段先做长度分布统计再决定是截断还是分桶。分桶训练可以显著减少padding带来的浪费但要注意每个桶内的样本数不能太少否则梯度更新不稳定。4.4 损失一直不降怎么办损失不动是最让人头疼的。我一般按下面这个顺序排查第一步看数据。确认输入和目标的对应关系对不对尤其是时间序列预测里你给网络的目标不能是“未来的未来”。曾经有个同事做股票预测代码里目标写错了一个偏移结果loss永远在一个高位不动排查了半天才发现是数据错位。第二步看梯度。如果梯度范数为0或接近0大概率是权重初始化太小或者激活函数饱和了。如果梯度范数异常大那是爆炸裁剪就好。第三步看学习率。学习率太大容易让loss在上面反复横跳太小又像蚂蚁搬家。我的经验是先用一个相对大的学习率比如0.01跑50个epoch观察loss有没有下降的趋势再逐步调小。第四步看网络容量。hidden_dim设成8还指望记住长距离依赖那确实强人所难。试试把hidden_dim调成64或128如果loss显著下降说明容量不够如果加了容量还是老样子那就要回到数据和结构上找问题。这四个步骤里我最常遇到的是第一种。模型本身没问题数据构造出了问题。你可以在训练前用很小的数据量跑一个overfit测试也就是让模型在10个样本上反复训练如果loss能降到接近0说明模型和代码路径是通的接下来再在大数据上调试才有意义。5. RNN的家族与选型LSTM、GRU、双向RNN5.1 LSTM的门控机制从存储单元到遗忘门RNN的梯度消失问题在长序列上几乎是致命的。LSTMLong Short-Term Memory的提出就是为了解决这个问题。它的核心不是直接让每个时间步的隐状态h_t一路连乘而是引入了一个独立的记忆单元c_t通过三个门结构来控制信息的写入、读取和遗忘。简单理解LSTM把“记忆”和“输出”分开处理。c_t像一条传送带可以把信息在时间步之间几乎无损地传递而遗忘门决定还有多少旧信息要继续保留输入门决定新信息有多少要写入输出门决定当前要输出什么。梯度反传时可以沿着c_t这条“高速公路”一路畅通这就是LSTM能在长序列上比RNN稳定得多的原因。我在学习LSTM时有个体会不要一上来死记公式可以先想清楚三个门各自负责什么再看公式就对得上号了。如果用PyTorch可以直接用torch.nn.LSTM搭模型等你要修改它内部结构时再细看源码。实际使用中LSTM还支持多层堆叠和双向设置这些超参对最终效果的影响比那四个门的公式细节大得多。5.2 GRU把三个门简化成两个GRUGated Recurrent Unit是LSTM的简化版本。它把LSTM的输入门和遗忘门合并成一个更新门又把记忆单元和隐状态合并最终只剩两个门重置门和更新门。因为它参数更少、计算更快在很多任务上和LSTM效果非常接近成了工程上很受欢迎的折中选项。如果LSTM是复杂的“记忆管理方案”GRU更像一个精简的“信息过滤方案”。更新门决定新旧信息的混合比例重置门决定要不要把之前的记忆清零重来。直观理解完这两个门之后你会发现GRU虽然简练该保留的信息一点也没少。实践中有个项目我用LSTM和GRU各跑了一版最终指标几乎相同但GRU的训练速度快了接近20%之后我在大部分序列建模场景下都默认先试GRU。5.3 RNN/LSTM/GRU对比与选型建议给一个我平时做技术选型时会参考的对比表模型参数量长依赖能力训练速度适用场景经典RNN少弱快短序列、入门教学、极简模型LSTM多强较慢长序列、语音、自然语言中的复杂依赖GRU中强中等大多数序列任务、效率和效果均衡这个表不是绝对标准但它是一个很好的出发点。经典RNN更多出现在教科书和论文里工程里直接落地的很少。LSTM是经典选择稳定可靠但要注意参数多了之后容易过拟合。GRU在参数量和效果之间做了很好的平衡我会优先考虑。如果你在纠结LSTM还是GRU我建议这样处理先用GRU快速验证任务可行性如果效果接近预期就直接用GRU如果GRU明显不够再换LSTM通常不会有特别大的差距。不要在一开始就把时间花在两套方案的精细调参上先跑通、再优化永远是工程上的第一原则。还有一个容易被忽略的细节序列数据的归一化。无论是做预测还是分类RNN对输入尺度很敏感。输入数值范围太大梯度容易不稳定范围太小激活函数可能长期处于饱和区。我习惯把特征缩放到零均值和单位方差或者至少保证在[-1, 1]区间。这在时间序列预测任务里尤其重要因为波浪形的数据天然就有正有负不做归一化的话预测结果会整体偏高或偏低。5.4 双向RNN与深层RNN的补充最后聊两个常见的补充结构。双向RNNBidirectional RNN是两个方向独立运行的RNN一个从前往后一个从后往前最后把两个方向的隐状态拼接或相加作为输出。它适合那些“前后文都很关键”的任务比如命名实体识别、情感分类因为一个词的含义往往由它左右两侧的内容共同决定。但它有一个要求必须拿到完整的序列才能做预测不适合流式或实时场景。深层RNN则是在时间维度之外再叠加多层RNN让每一层在不同抽象级别上建模。第一层可能在看词与词之间的相邻依赖第二层在看句子结构第三层可能在看更长的篇章关系。这种写法适合数据量足够大的任务小数据上堆层数反而会因为参数过多而训练困难。有一类问题在双向RNN中经常出现拼接两个方向的隐状态之后输出维度直接翻倍。如果你没有注意到这一点后面接全连接层时维度很容易写错。我的建议是先用单层双向RNN跑通流程再去研究多层和残差连接不然排查维度错误就够你头疼一阵。顺带提一句这两年Transformer在很多任务上已经取代了RNN家族但RNN的思想远没有过时。很多序列建模的基础概念——状态传递、时序依赖、门控机制——都沿袭自这些结构。理解RNN能让你在迁移到Transformer、状态空间模型时依然有清晰的对比坐标。比如Transformer的位置编码本质上就是在给模型注入“顺序信息”而RNN是天然用循环来承载顺序信息两者的目标是一样的。我个人试下来最有效的一个入门方式依然是那句老话不要只背公式动手把RNN的每一步算清楚。你可以先拿NumPy写一个不用任何深度学习框架的最小实现哪怕它速度慢、结构简陋但当你看着预测曲线慢慢贴合真实数据那种“我真正懂了它在干什么”的感觉是任何框架一行代替代不了。后续哪怕直接使用PyTorch的LSTM、GRU也会更有底气去调整参数和结构。
返回列表