ARTICLE DETAIL

资讯详情

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

PyTorch实战:RNN、LSTM、GRU、CNN-GRU、ABLSTM五种循环网络对比与实现

PyTorch实战:RNN、LSTM、GRU、CNN-GRU、ABLSTM五种循环网络对比与实现 做序列建模的人绕不开循环神经网络这个话题。我最近在PyTorch里把RNN、LSTM、GRU、CNN-GRU和ABLSTM这五种结构完整实现并对比了一遍最大的感触是公式背得再熟都不如亲手跑一次代码来得深刻。这篇文章就围绕这五种结构的PyTorch实现展开既有核心原理也有可以直接复用的代码和踩坑经验适合正在做时间序列预测、文本分类或信号处理的人参考。很多初学者一上来就盯着Transformer但循环网络不是没有价值。它的参数量更小对中短序列的拟合能力其实很强而且在工业场景里一条序列几个百步、上千步的情况很常见这时候LSTM和GRU依然是稳定可用的选择。我希望这篇总结能帮你把五种结构串成一条线从最基础的RNN到带门控的LSTM和GRU再到把卷积和注意力机制叠进去的融合模型每一步都知道自己在做什么。1. 项目整体思路与技术选型1.1 五种结构之间的关系这五种结构不是互相独立的它们是可以串成一条演进链的。RNN是最原始的循环单元它把上一个时刻的隐藏状态传到下一个时刻理论上能处理任意长度的序列。但在实际训练中RNN很容易出现梯度消失或梯度爆炸导致模型记不住远距离的信息。LSTM在RNN的基础上加了三组门控输入门、遗忘门、输出门让信息可以选择性写入、保留和读取。GRU是LSTM的轻量变体把三个门压缩成更新门和重置门参数量更少训练速度更快很多场景下效果和LSTM差不多。CNN-GRU则是另一种思路先用一维卷积提取局部特征再用GRU建模时序依赖。为什么这么做因为纯循环网络在处理非常长的序列时计算是串行的效率不高。一维卷积可以在局部窗口内并行提取特征把序列长度压缩然后再交给GRU去捕捉长期关系。ABLSTM是双向LSTM加注意力机制的组合双向结构让每个时间步既能看过去也能看未来注意力层则对每个时间步的隐状态加权求和突出关键帧的影响。这种结构在文本分类、情感分析里特别常见。1.2 不同场景下的选型建议我在实际项目中习惯按序列长度和数据特性来选型。如果序列长度不超过几十步任务对长距离依赖要求不高直接用RNN或者简单的一层LSTM就够了。硬上复杂模型反而容易过拟合。如果序列有几百步甚至上千步并且存在明显的长期相关性比如股票走势、传感器故障预测LSTM和GRU是更稳妥的选择二者可以先跑一个GRU因为训练快效果差距往往不大。如果输入数据里存在明显的局部模式比如轴承振动信号里的冲击成分、语音信号里的音素特征先加一层Conv1d往往能帮助模型更快收敛这就是CNN-GRU的用武之地。而如果任务是文本分类、情感判断这类需要综合全句信息的场景ABLSTM的注意力池化比直接取最后一个时间步更合理因为一句话里的关键词可能出现在任意位置注意力机制能自动找到这些关键位置。1.3 为什么用PyTorch实现用PyTorch不是因为它是唯一选择而是因为它的动态图机制和Python风格最贴近人类的思考方式。我自己在调试时最常用的操作就是print中间张量的shape动态图可以让你随时把网络拆开看每一步的输出这在理解循环神经网络时太重要了。另外PyTorch官方的nn.RNN、nn.LSTM、nn.GRU都已经封装好了底层计算是高度优化的不需要自己写反向传播。对研究者和工程师来说把精力放在模型结构和数据处理上比手写CUDA或求导实际得多。2. 核心原理解读2.1 RNN最简单的循环单元RNN的核心思想是参数共享和隐状态传递。它一个时间步的更新公式可以写成[ h_t \tanh(W_{ih}x_t b_{ih} W_{hh}h_{t-1} b_{hh}) ]简单说当前时刻的输入(x_t)和上一时刻的状态(h_{t-1})一起做线性变换再经过tanh激活就得到当前时刻的隐藏状态(h_t)。这个隐藏状态被认为是网络对过去所有信息的压缩记忆。PyTorch里只要一行nn.RNN(input_size, hidden_size, num_layers, batch_firstTrue)就能建起来但很多人忽略了一个问题RNN的反向传播是沿着时间步展开的序列越长梯度连乘的次数越多。当序列长度超过二三十步时RNN的梯度很容易变得极小或极大。极小的梯度导致前面层几乎学不到东西极大梯度则让参数直接飞掉。这就是我一开始说“RNN记不住远距离信息”的原因。所以在工程上裸RNN一般只用于短序列或者作为基线模型。2.2 LSTM用三个门控制信息流动LSTM在RNN的思路上引入了一条“细胞状态”传送带(c_t)专门负责长期信息存储。三个门分别控制信息的三个操作遗忘门决定上一时刻细胞状态要保留多少输入门决定当前候选信息要写入多少输出门决定当前时刻的输出隐状态要暴露多少。如果用生活化的类比RNN就像一个只有便签纸的人只能看到最近一页LSTM则是一个带笔记本的工作台重要的内容可以随时翻出来不重要的内容可以直接划掉。LSTM的每一步计算包含候选状态(\tilde{c}_t)和细胞状态更新[ \tilde{c}t \tanh(W_cx_t U_ch{t-1} b_c) ] [ c_t f_t \odot c_{t-1} i_t \odot \tilde{c}_t ] [ h_t o_t \odot \tanh(c_t) ]三个门的计算方式本质上都是带sigmoid的线性层输出范围在0到1之间。这个0到1的“软开关”就是门控的含义0表示完全关闭1表示完全打开。由于门控机制的存在梯度在反向传播时可以用加法的形式跨时间步传递这就是LSTM能缓解梯度消失的关键。PyTorch里nn.LSTM返回的是output, (h_n, c_n)其中h_n是最后一个时间步的隐状态c_n是最后一个时间步的细胞状态很多人第一次用都会忽略这个细节。2.3 GRU把门控数量压缩到两个GRU把LSTM的三个门合并成两个更新门(z_t)和重置门(r_t)。更新门同时控制旧状态的保留程度和新状态的写入程度重置门则控制过去的状态多大程度影响当前候选状态。它没有了单独的细胞状态直接用隐状态承载所有信息。[ z_t \sigma(W_zx_t U_zh_{t-1} b_z) ] [ r_t \sigma(W_rx_t U_rh_{t-1} b_r) ] [ \tilde{h}t \tanh(W_hx_t r_t \odot (U_hh{t-1}) b_h) ] [ h_t (1 - z_t) \odot h_{t-1} z_t \odot \tilde{h}_t ]GRU参数量大约是LSTM的3/4训练速度更快而且在小数据集上往往更不容易过拟合。我在做几组对照实验时发现当数据量中等、序列长度在100步左右的时候GRU和LSTM的精度差距通常不到1个百分点但GRU的训练时间能省15%到20%。所以在没有充分理由的情况下我一般建议先试GRU再决定要不要换成LSTM。3. PyTorch实现与代码拆解3.1 环境准备和数据形状约定开始写代码前先把环境理清楚。PyTorch的CPU版本足够跑实验如果你的序列比较长、数据量比较大建议装GPU版本。安装之后我建议先用一个简单的随机数据集验证模型能跑通再换真实数据。代码风格上统一使用batch_firstTrue这样输入形状是(batch_size, seq_len, input_size)比默认的(seq_len, batch_size, input_size)更直观也能减少很多维度困惑。三种标准循环模型的核心配置是input_size、hidden_size、num_layers。input_size是每个时间步的特征维度比如单变量时间序列就是1多变量序列就是特征数量。hidden_size是隐状态维度也是模型容量最直接的体现。num_layers是循环层堆叠层数一般一层到两层就够了堆太多不仅慢还容易过拟合。3.2 RNN/LSTM/GRU的标准实现下面这段代码定义了三个非常标准的模型封装方式类似方便做对比实验import torch import torch.nn as nn class BasicRNN(nn.Module): def __init__(self, input_size, hidden_size, num_layers, num_classes): super().__init__() self.rnn nn.RNN(input_size, hidden_size, num_layers, batch_firstTrue) self.fc nn.Linear(hidden_size, num_classes) def forward(self, x): out, _ self.rnn(x) # out: (batch, seq_len, hidden) out out[:, -1, :] # 取最后一个时间步 return self.fc(out) class BasicLSTM(nn.Module): def __init__(self, input_size, hidden_size, num_layers, num_classes): super().__init__() self.lstm nn.LSTM(input_size, hidden_size, num_layers, batch_firstTrue) self.fc nn.Linear(hidden_size, num_classes) def forward(self, x): out, (h_n, c_n) self.lstm(x) # 注意返回三个值 out out[:, -1, :] return self.fc(out) class BasicGRU(nn.Module): def __init__(self, input_size, hidden_size, num_layers, num_classes): super().__init__() self.gru nn.GRU(input_size, hidden_size, num_layers, batch_firstTrue) self.fc nn.Linear(hidden_size, num_classes) def forward(self, x): out, h_n self.gru(x) # GRU返回 output 和 h_n out out[:, -1, :] return self.fc(out)这里有两个特别容易踩的坑。第一LSTM的返回值和GRU不一样LSTM是三元组nn.LSTM返回output, (h_n, c_n)GRU返回两个值。第二取最后一个时间步其实不止一种写法我习惯先取out[:, -1, :]它是所有时间步输出的集合张量-1代表最后一个时刻这种方式在多层和双向网络中都能正确工作。有些人直接拿h_n[-1]取最后一个时刻也能得到类似的结果但初学者容易在多层、双向混合时把索引搞混。3.3 CNN-GRU的融合实现CNN-GRU的设计思路是先用一维卷积压缩序列中的局部特征再交给GRU学习时序依赖。下面是具体实现class CNNGRU(nn.Module): def __init__(self, input_size, hidden_size, num_layers, num_classes, kernel_size3): super().__init__() self.conv nn.Sequential( nn.Conv1d(input_size, hidden_size, kernel_size, paddingkernel_size // 2), nn.ReLU(), nn.BatchNorm1d(hidden_size) ) self.gru nn.GRU(hidden_size, hidden_size, num_layers, batch_firstTrue) self.fc nn.Linear(hidden_size, num_classes) def forward(self, x): # x: (batch, seq_len, input_size) x x.permute(0, 2, 1) # (batch, input_size, seq_len) x self.conv(x) # (batch, hidden_size, seq_len) x x.permute(0, 2, 1) # (batch, seq_len, hidden_size) out, h_n self.gru(x) out out[:, -1, :] return self.fc(out)这里最容易出问题的是维度顺序。nn.Conv1d的输入格式是(batch, channels, seq_len)而循环网络期望的是(batch, seq_len, input_size)所以中间必须做两次permute。卷积核大小我通常会选奇数比如3或5配合同样大小的padding可以让序列长度不变这样后续GRU不需要处理长度变化。加BatchNorm1d是我的个人习惯它能让卷积输出的分布更稳定但要注意batch size不能太小否则统计量不准。如果序列特别长还可以在卷积层里设置stride2来主动降采样相当于让模型先看局部模式、再压缩时间维度这样可以大幅度提升训练速度。不过降采样有代价它会损失一些时间分辨率所以用在分类任务上问题不大但做逐点预测时就要慎重。3.4 ABLSTM的注意力实现ABLSTM是把双向LSTM和注意力机制结合起来。双向LSTM会生成两个方向的隐状态一个从前往后、一个从后往前最后把两个方向的输出拼接起来。它让每个时间步都能同时看到上下文对文本分类这类任务非常友好。但把所有时间步直接求平均或者只取最后一个时间步都不够灵活于是增加一个可学习的注意力层class ABLSTM(nn.Module): def __init__(self, input_size, hidden_size, num_layers, num_classes): super().__init__() self.lstm nn.LSTM(input_size, hidden_size, num_layers, batch_firstTrue, bidirectionalTrue) self.attn nn.Linear(hidden_size * 2, 1) self.fc nn.Linear(hidden_size * 2, num_classes) def forward(self, x): out, _ self.lstm(x) # out: (batch, seq_len, hidden_size * 2) # 计算每个时间步的注意力分数 weights torch.softmax(self.attn(out), dim1) # 加权求和得到序列向量 context torch.sum(weights * out, dim1) return self.fc(context)self.attn(out)的输出形状是(batch, seq_len, 1)对它做softmax时对dim1操作也就是在所有时间步上归一化这样每个时间步的权重加和等于1。weights * out会利用PyTorch的广播机制让每个时间步的隐状态乘以它对应的权重。最后对dim1求加和就得到整个序列的加权表示。注意力层本质上做了一件很朴实的事情自动判断哪些时间步对最终分类更重要。比如一句话里“特别”“差”这些词位置靠后但信息量很大注意力权重就会把它们放得更大。这种池化方式比直接取最后一步更合理也比平均池化更有表达力。它的代价是多了一个线性层参数量增加很少但训练时需要更小心因为注意力权重分布有时候会坍塌到少数几个位置。4. 实操过程与调参细节4.1 从原始数据到训练循环模型定义好之后整个训练流程其实和其他网络大同小异但数据准备阶段有几个容易忽略的地方。我用过一个传感器振动数据的例子原始数据是一段连续时间序列。做分类前需要用一个滑动窗口把长序列切成多个片段窗口长度就作为seq_len。注意切窗口时不要让数据泄漏到验证集或测试集里最稳妥的做法是先按时间顺序划分训练集和测试集再分别滑动窗口。每个窗口内的特征需要做归一化。循环网络对数值范围很敏感输入特征的尺度差异太大会导致梯度更新不稳定。如果输入是单特征时间序列我通常做Z-score标准化均值和方差只在训练集上计算再拿到验证集和测试集上应用。这听起来是老生常谈但确实有很多人在整个数据上做标准化造成轻微的数据泄漏导致模型效果虚高。下面是训练循环的简化版本import torch.nn.utils as utils def train_epoch(model, loader, optimizer, criterion, device, grad_clip1.0): model.train() total_loss 0.0 for x, y in loader: x, y x.to(device), y.to(device) optimizer.zero_grad(set_to_noneTrue) pred model(x) loss criterion(pred, y) loss.backward() # 循环网络必备的梯度裁剪 utils.clip_grad_norm_(model.parameters(), grad_clip) optimizer.step() total_loss loss.item() return total_loss / len(loader)分类任务一般用nn.CrossEntropyLoss回归任务用nn.MSELoss。优化器我会优先用Adam学习率从1e-3开始如果loss震荡严重就降到1e-4或5e-5。训练循环中还可以加早停当验证集loss连续若干个epoch不再下降时保存最优模型并停止训练。对循环网络来说过拟合发生得比想象中快尤其是LSTM这类表达能力强的模型。4.2 超参数选择经验超参数直接决定模型能不能收敛我按经验值给出一个参考范围。hidden_size从32到128之间起步再根据数据量增大。数据量小的时候比如几千条样本hidden_size设太大几乎没有好处只会让模型记住训练集。num_layers选2层是一个折中因为第一层可以提取低级特征第二层可以提取更抽象的时序模式但超过3层以后训练难度和收益就非常不成比例了。seq_len是很多人容易拍脑袋定的参数。它不是越长越好因为太长的窗口会引入大量噪声也让内存占用和训练时间成倍增长。我用过一个经验做法先观察原始数据的自相关看看信号在多少个时间步之后相关性衰减到很低的水平以这个数值作为窗口长度下限。如果完全没有头绪就先用64、128这些常见长度做几组对照实验不要一开始就在很长的序列上跑复杂模型。学习率调度也很关键。Adam虽然自适应调整学习率但循环网络在训练后期依然容易在loss曲面里震荡。我习惯在训练过程中使用ReduceLROnPlateau当验证loss连续5个epoch不再下降时把学习率减小到原来的0.5倍。这个调度器非常省心不需要手动精确控制衰减步数。4.3 训练稳定性相关的细节循环网络训练里最常规的稳定化手段是梯度裁剪。因为RNN的反向传播沿时间步展开梯度要么消失要么爆炸梯度爆炸是真正会在训练中断言失败的那种问题。clip_grad_norm_的原理很简单先计算所有参数的梯度二范数如果超过阈值就整体等比缩小到阈值范围之内。阈值的经验值在1.0附近调大等于关闭保护调小会限制模型的学习能力。另一个影响稳定性的因素是初始化。PyTorch自带的nn.LSTM和nn.GRU默认初始化已经做得不错一般情况下不需要手动初始化。但我见过不少人在自定义RNN时使用零初始化结果模型很难学习长途依赖因为初始状态全为零并没有关系参数才是关键真正的问题是层内权重初始化太差导致梯度消失。如果自己写循环单元建议对隐藏层权重使用xavier_uniform_或orthogonal_初始化。BatchNorm和循环网络一起用的时候要格外小心。nn.BatchNorm1d对卷积层效果很好但如果直接对RNN的时间步输出做BatchNorm不同时间步的统计量不同很容易造成不稳定。如果你确实想在RNN层之间加归一化建议用LayerNorm而不是BatchNorm。LayerNorm从层内计算均值和方差不依赖batch大小也不受时间步长度影响。5. 常见问题与排查技巧5.1 形状错误百分之八十的报错都在这我在调试循环网络时见过的报错里形状不匹配占了大多数。最常见的一条是Expected 3D tensor, got 2D。这通常意味着输入少了序列维度比如你传进去的是(batch, input_size)而模型期望的是(batch, seq_len, input_size)。解决办法自然是先判断你的数据是不是没做窗口切分或者忘了把单时间步数据增加一个维度。另一种高频问题是LSTM返回值的解包错误。很多人把nn.LSTM的输出写成了out, h_n self.lstm(x)但LSTM返回的第二个位置其实是一个元组(h_n, c_n)所以正确写法是out, (h_n, c_n) self.lstm(x)。GRU的返回值才是两个这种细微差别在刚切换模型时会反复触发报错。我的建议是每次切换模型后先打印模型的输出形状再用一个单batch的数据跑一遍forward确认没有维度错误再开始训练。5.2 梯度消失或梯度爆炸梯度消失的典型表现是loss下降缓慢训练很多轮之后模型几乎不变化。如果你用的是裸RNN先把这个结构换成LSTM或GRU通常能解决大部分问题。如果已经用了LSTM还可以检查一下hidden_size是否太小以及激活函数和权重的初始化方式。梯度爆炸的典型表现是loss突然变成NaN或者参数值在训练日志里疯狂增长。这时候最优先用梯度裁剪同时把学习率降下来。我遇到过一次比较隐蔽的NaN问题损失函数的输入里包含了NaN但模型参数本身正常。后来发现是原始数据里存在缺失值归一化时把缺失值直接填充成了均值导致某些窗口里出现全部相等的情况BN层计算出零方差再往后就变成了NaN。排查这类问题建议在训练循环前手动打印torch.isnan(x).any()和torch.isnan(y).any()先确保输入数据是干净的。5.3 模型不收敛怎么办模型不收敛先别急着调网络结构从最简单的问题开始排查。第一步看loss函数用的对不对文本分类和多标签分类都容易把softmax和sigmoid混淆。第二步看标签和输出的维度是否匹配分类器输出节点数量必须等于类别数。第三步看归一化和学习率数据尺度差好几个数量级时Adam也会很难受。如果这些都确认没问题那就做一次过拟合测试只拿训练集里的一小部分数据比如几十条样本不断训练看模型能不能把loss降到几乎为0。如果连这一点都做不到大概率是模型结构或代码在某个地方有bug而不是数据问题。这个技巧帮我节省过很多时间因为很多模型状态都是“训练loss在下降但验证集很差”这属于过拟合范畴和“完全不收敛”是两回事。下面整理成一张速查表方便你在遇到问题时快速对照现象可能原因优先排查方向损失变NaN梯度爆炸、数据有NaN梯度裁剪、检查输入数据训练loss不下降学习率过大或过小调整学习率、做小数据过拟合测试验证loss低但测试差过拟合减小hidden_size、加dropout、加正则输出全是一类的概率类别不平衡、输出层初始化问题检查类别分布、调整损失函数权重训练速度过慢序列过长、hidden_size过大缩短seq_len、降采样或压缩特征5.4 处理不定长序列的遗漏点实际业务里经常遇到不等长的序列比如文本的句子长度不同、传感器窗口不等长。PyTorch提供了pack_padded_sequence和pad_packed_sequence来处理这种情况原理是先按真实长度排序并打包循环网络只计算非padding部分计算完后恢复成带padding的张量。这个功能很实用但它的使用有点绕需要注意三点序列长度列表要按长度降序排列padding值不能让模型学到错误信息mask要和padding位置严格对应。如果你刚上手我建议先用定长序列把模型和训练流程跑通再优化成不定长版本。不要在一开始就叠加太多技巧排错会很痛苦。6. 一点个人体会与后续扩展方向现在再回头看这五种结构我最大的体会有两个。第一个是“别只盯着精度一个指标”循环网络在工程落地里训练速度、显存占用和推理延迟可能比零点几个百分点的精度更关键。GRU在中等长度序列上是性价比很高的选择CNN-GRU适合局部模式明显的输入ABLSTM则适合需要全序列建模的任务它们不是谁替代谁的关系。第二个体会是理解网络的最好方式就是把它一层一层拆开打印出来。我至今在调试自定义模型时还是会习惯性写一个测试函数构造随机张量打印每一层输出形状的变化。这个习惯帮我快速定位了无数次维度错误和维度混乱的问题。如果你能把上面的代码都跑一遍并且能不看代码解释清楚每个张量的形状为什么是这样你对循环网络的理解就已经超过大多数只背公式的人了。这个方向后续还可以继续扩展成带注意力机制的Seq2Seq模型或者把Transformer的Encoder层和LSTM混合使用。但不管怎么扩展RNN、LSTM、GRU这三个基础单元和注意力池化的思想都是绕不开的地基把这些地基打牢后面的路会顺很多。
返回列表