从RNN到BiLSTM:循环神经网络原理与PyTorch文本分类实战

📅 发布时间:2026/10/22 3:57:01
从RNN到BiLSTM:循环神经网络原理与PyTorch文本分类实战
这些年陆陆续续带过不少同学入门深度学习发现几乎所有人绕不开的一道坎就是循环神经网络这一族。从RNN到LSTM再到BiLSTM名字一个比一个长初看公式也一个比一个劝退。但说实话等我把这三个模型真正串起来理解之后回头再看文本分类、序列标注、时间序列预测这类任务思路会一下子清晰很多。这篇笔记就是按我自己学习时的顺序整理出来的既有原理层面的“为什么”也有实操层面的“怎么调”希望能帮正在啃这块内容的朋友少走点弯路。适合读这篇笔记的人也很明确你已经知道神经网络的基本概念比如全连接层、反向传播但看到序列数据就不知道从哪下手或者你正在做NLP相关的入门项目被RNN、LSTM、BiLSTM这几个词来回折磨。我会尽量用大白话拆细节遇到公式也只保留必要的部分更多精力放在理解思路和跑通代码上。1. 先从“为什么需要循环结构”讲起1.1 普通神经网络处理序列数据的天然短板先回想一下最常见的全连接网络或者卷积网络。它们的输入是一个固定维度的向量输出也是一个固定维度的向量。对于一张图片、一条结构化样本这种“一次映射”完全够用。可一旦遇到文本、语音、股票价格这类序列数据问题就来了序列的长度不固定而且每个位置的数据之间是有关联的。拿一句话举例“我昨天在河边看到一只猫它特别怕水。”这句话里的“它”指的是“猫”如果模型只看当前这个字或者当前这个词没法知道“它”到底指谁。普通的前馈网络把每个词都当成独立的输入完全不共享参数也不保留任何历史信息所以它天然处理不了这种依赖关系。有人可能会说那我把过去几个词拼在一起当作输入不就行了比如用滑窗的方式只取前面三个词。这种思路在小范围内有一点效果但窗口大小很难选窗口太小了长距离依赖抓不到窗口太大了输入维度爆炸而且不同位置的词对当前词的影响权重也不一样简单的拼接解决不了“该记什么、该忘什么”的问题。这就是循环结构出现的根本原因我们需要一个能随序列不断更新的“记忆”让模型在处理当前时刻时能看到过去所有时刻的压缩信息。RNN就是这种思路最简单、最直接的实现。1.2 RNN如何用“隐藏状态”把过去的信息带到现在RNN的核心思想可以概括成一句话用一个隐藏状态向量 h_t 当作网络的“记忆”每读入一个新的输入 x_t就结合上一个隐藏状态 h_{t-1} 来更新当前隐藏状态 h_t。公式大概长这样h_t activation(W_h * h_{t-1} W_x * x_t b)也就是说当前隐藏状态由两部分决定上一时刻的隐藏状态和当前时刻的输入。然后这个 h_t 既可以拿去输出也可以继续往后传。它就像一个人读书时的短期记忆一边往后读一边把前面读过的内容浓缩在心里读到后面时这个心里状态自然会影响到对当前内容的理解。这种结构最大的好处是参数共享。不管序列多长始终只有那一组权重矩阵在参与运算。模型学会的是一套“如何更新记忆”的规则而不是针对某个固定位置的特殊处理。因此RNN可以处理任意长度的序列这比滑窗思路优雅得多。但RNN并不是没有问题。如果序列特别长训练时反向传播要沿着时间步一层一层传回去这被称为BPTTBackpropagation Through Time时间反向传播。梯度在传播过程中如果不断乘以小于1的数就会指数级衰减最终导致前面时间步的权重几乎收不到梯度信号。这就是经典的“梯度消失”问题。后果也很直接普通RNN记不住太长的历史信息实际应用中大概只能照顾到十几步以内的依赖。1.3 两个方向读序列BiLSTM出现的直接动机在文本任务里还有一个很微妙的问题当前词的意思往往不光取决于前面的词还取决于后面的词。比如“我昨天没吃饭今天特别饿”这个句子里“饿”这个状态虽然由前面的“没吃饭”引起但如果你只给模型看截至“今天”之前的内容它也能猜个大概可有些句子比如“银行”这个词前面出现了“存款”后面又出现了“利率”这个时候前后文都很重要。普通RNN或LSTM只能从左往右读隐藏状态里包含的是“截至当前位置”的历史信息。右边还没读到的内容它一点都不知道。为了解决这个问题就有了双向结构。BiLSTM的做法的确非常简单粗暴一个LSTM从左往右读另一个LSTM从右往左读然后把两个方向每个时间步的隐藏状态拼到一起作为该位置最终的表示。这样一来每个时间步的输出就同时包含了左边和右边的上下文信息效果自然比单向好尤其适合文本分类、命名实体识别、阅读理解这类“全文理解”型任务。2. 深度拆解LSTM核心机制与计算过程2.1 记忆主线 C_t 的设计思想先看LSTM内部到底多了什么。名字里的Long Short-Term Memory翻译过来就是“长短期记忆”。它比RNN多了一条被称为“细胞状态”C_t的传送带这条传送带贯穿整个序列信息可以在上面几乎无损地流动。想象一条流水线传送带上放着重要的货品每隔一段时间传送带旁边的小门会决定放走一些旧货、放进一些新货。货品就是细胞状态里的信息小门就是各种“门”结构。RNN的问题在于每次更新都会把旧记忆乘以一个权重矩阵这个操作容易导致记忆快速衰减而LSTM则专门划出了一条高速通道让信息可以跨越多步基本不打折扣从而缓解长距离依赖问题。h_t则不再简单等于当前记忆本身而是从传送带上挑出一部分内容输出。所以LSTM每一步实际上有两个输出一个是给下一时刻用的完整细胞状态C_t另一个是给当前任务用的隐藏状态h_t。理解这两条线的区别是看懂所有门结构的前提。2.2 遗忘门、输入门、输出门分别管什么LSTM有三个门每个门本质上都是一个sigmoid函数加上一个逐元素乘法操作。sigmoid输出在0到1之间0代表“完全忘掉”1代表“完全记住”这样就成了一个可学习的软开关。遗忘门决定从旧细胞状态里丢弃什么。它把上一个隐藏状态和当前输入拼接起来算出一个和细胞状态同样形状的向量再按元素乘到旧细胞状态上。公式就是 f_t sigmoid(W_f * [h_{t-1}, x_t] b_f)。这一步很符合直觉如果模型从当前输入里发现旧信息不再重要就把遗忘门的值压低让旧信息慢慢淡出。输入门决定把哪些新信息写进细胞状态。它分两步先用另一个sigmoid层算出候选值的保留比例 i_t再用一个tanh层算出真正的新候选值 C_t~最后把两者相乘加到细胞状态上。也就是说输入门不是直接让输入通过而是先决定“这个新信息值得记多少”再决定“记下来的具体内容是什么”。输出门决定从当前细胞状态里拿出多少信息作为隐藏状态。它会先算出一个 o_t然后对细胞状态做tanh压缩再和o_t相乘得到h_t。这一步的价值在于细胞状态里的信息可能很多但当前时刻输出不一定需要全部暴露输出门相当于一个“对外发布口径”。三个门配合起来就完成了一次完整的记忆更新忘掉一部分旧的加入一部分新的对外输出一部分当前的。整个过程全部可微可以正常反向传播训练。2.3 从BPTT看LSTM为什么能缓解梯度消失这一点是我学习时最觉得“通透了”的地方。前面提到RNN在反向传播时要连乘很多个导数项如果这些导数的模普遍小于1梯度就会越传越少最后前面的权重干脆不动。LSTM的厉害之处在于细胞状态这条路径上的传递方式是一条加法直通线。新细胞状态等于旧细胞状态乘以遗忘门再加上输入门和候选值的乘积C_t f_t * C_{t-1} i_t * C_t~在反向传播时梯度从C_t传到C_{t-1}主要经过的是遗忘门f_t这个缩放因子而不是一连串矩阵乘法。如果遗忘门的值接近于1那么梯度就能几乎原样地往回传长时间之后依然有力。这就是LSTM能记住长距离信息的数学根源。当然这也不是说LSTM就完全不会梯度消失。如果训练中学到的遗忘门长期接近于0梯度照样传不远。但相比普通RNN来说LSTM给梯度提供了一条更稳定的“高速公路”从工程实践上看长序列任务几乎无脑选LSTM就能比RNN好出一大截。这也是现在很少有人直接使用原始RNN的原因。2.4 BiLSTM的拼接到底拼接了什么前面讲了BiLSTM是前向LSTM加后向LSTM再把两个方向隐藏状态拼接。这里有个常见的疑问拼接之后表示向量的大小不是直接翻倍了吗确实翻倍了。如果单向LSTM的隐藏层维度是hidden_size128那么每个时间步的输出维度就是128。BiLSTM把前向和后向拼起来输出维度就是256。注意PyTorch里的LSTM层可以通过设置bidirectionalTrue来直接完成这个操作你不需要手动写两个LSTM再拼框架已经帮你做了。拼接这个操作本质上是在特征维度上做了“信息叠加”。前向特征编码的是过去的上下文后向特征编码的是未来的上下文两者拼接之后当前词向量既含有左边信息也含有右边信息。这在很多NLP任务里比单向表示有用得多。因为文本是一个整体理解某个词不仅靠前面的铺垫也靠后面的印证。另外还要注意一点BiLSTM在序列标注类任务里每个时间步都可以用拼接后的向量去预测标签但在文本分类任务里通常不会直接用每个时间步的输出而是把所有时间步的输出做一个池化或者取最后一个时间步的隐藏状态再送到全连接分类器。具体怎么用取决于你后面接什么任务。3. 实操用PyTorch搭建一个BiLSTM文本情感分类器3.1 数据准备与词表构建理论知识讲再多手不碰代码永远记不牢。下面我用PyTorch搭一个最简单的BiLSTM文本情感分类模型任务是判断一句中文评论是正面还是负面。这里不依赖任何云端大模型只展示最经典的“词向量 BiLSTM 全连接分类”套路。第一步是把原始文本变成模型能吃的数字。中文要先分词比如用jieba这类工具把“这家店服务很好”切成“这家 / 店 / 服务 / 很好”。分词后我们需要构建一个词表给每个词一个编号同时预留一个 给未登录词预留一个 给补齐位置。补齐这一步最容易忽略。一个batch里的句子长短不一PyTorch的LSTM要求同一个batch内的序列长度一致所以短句子后面要补 到和最长句子一样长。常见做法是给 分配编号0然后在模型里通过pack_padded_sequence来告诉LSTM哪些位置是无效填充。代码里的数据加载部分大概是这个思路class TextDataset: def __init__(self, texts, labels, word2idx, max_len64): self.texts texts self.labels labels self.word2idx word2idx self.max_len max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): tokens list(self.texts[idx]) # 简单按字切分中文里按字也够用 ids [self.word2idx.get(w, self.word2idx[UNK]) for w in tokens] ids ids[:self.max_len] length len(ids) ids ids [self.word2idx[PAD]] * (self.max_len - length) return torch.tensor(ids, dtypetorch.long), torch.tensor(self.labels[idx], dtypetorch.float), length这里我特意用了按字切分而不是按词切分。理由是很多入门项目用按词切分时会遇到词表爆炸、分词误差传播的问题而中文按字切分配合BiLSTM在情感分类这种任务上效果并不差而且代码更短。你可以理解为一种工程上的权衡。3.2 模型代码与形状对照模型本身很简单一个Embedding层、一个BiLSTM层、一个全连接输出层。我把关键部分写在下面并且把每个张量的形状变化写在注释里方便对照。import torch import torch.nn as nn import torch.nn.functional as F class BiLSTMClassifier(nn.Module): def __init__(self, vocab_size, embedding_dim100, hidden_size128, num_classes2): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idx0) self.lstm nn.LSTM( input_sizeembedding_dim, hidden_sizehidden_size, num_layers1, bidirectionalTrue, batch_firstTrue ) self.dropout nn.Dropout(0.3) self.fc nn.Linear(hidden_size * 2, num_classes) def forward(self, x): # x: [batch, seq_len] emb self.embedding(x) # [batch, seq_len, embedding_dim] lstm_out, (h_n, c_n) self.lstm(emb) # lstm_out: [batch, seq_len, hidden_size * 2] # 取最后一个时间步的输出前需要先处理padding对序列尾部的影响 # 这里为了方便理解先直接用全体输出做平均池化 avg_pool torch.mean(lstm_out, dim1) # [batch, hidden_size * 2] out self.dropout(avg_pool) logits self.fc(out) # [batch, num_classes] return logits有同学可能会问为什么取平均池化而不是直接取最后一个时间步的隐藏状态这就要提到一个很实际的坑因为句子做了PAD填充最后一个时间步很可能全是 如果直接取最后一个时间步的输出等于拿一堆无效信息去做分类效果自然差。平均池化把所有有效时间步都考虑进来了虽然简单但比直接“取最后”稳定得多。如果要更严谨地处理最后一个有效时间步应该用PackedSequence或者把填充部分对应位置的输出置为0再求平均。上面代码为了保持简洁没有引入PackedSequence但如果你的实际项目对效果要求更高还是建议用pack_padded_sequence来处理。3.3 训练中的关键设置和坑训练流程和普通分类任务没有本质区别交叉熵损失加上Adam优化器。但有几个细节非常影响最终效果我挨个说。学习率是最容易出问题的点。LSTM这类循环网络对学习率比较敏感我给的建议是初始设置1e-3附近如果训练损失震荡得很厉害尽快降到3e-4或1e-4。我自己做过对比同样的数据、同样的模型1e-3可能跑到第5轮就开始发散降到3e-4反而稳定收敛。梯度裁剪也建议直接加上。用clip_grad_norm_把整体梯度的范数限制在5.0以内这样能有效防止偶发的大梯度把参数炸飞。虽然LSTM比RNN更稳定但长序列训练中依然会出现个别时间步梯度特别大的情况。optimizer torch.optim.Adam(model.parameters(), lr3e-4) criterion nn.CrossEntropyLoss() for epoch in range(10): model.train() total_loss 0.0 for batch in dataloader: input_ids, labels, lengths batch optimizer.zero_grad() logits model(input_ids) loss criterion(logits, labels.long()) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() total_loss loss.item() print(fepoch {epoch} loss: {total_loss / len(dataloader):.4f})另外类别不平衡问题在情感分类里也常见。如果正负样本比例差距大可以在损失函数里加上类别权重或者用focal loss之类的变体。不过作为学习笔记先把标准流程跑通最重要调优手段可以后面再加。4. 高频问题与排查技巧实录4.1 形状错误最容易踩的四个位置循环网络相关的报错大多集中在张量形状上。我总结出四个高频出错点挨个检查基本能解决80%的问题。第一个是Embedding输入形状。LSTM希望输入一个三维张量[batch, seq_len, embedding_dim]但如果你忘记做Embedding直接把词编号塞进去就会报维度不匹配。解决办法是确认数据经过Embedding层之后再进LSTM。第二个是batch_first参数。PyTorch的LSTM默认输入是[seq_len, batch, feature]这种格式batch维度在中间非常不符合直觉。很多人用默认设置后输入却写成[batch, seq_len, feature]就会得到奇怪的错误或者结果。我习惯在定义LSTM时直接加上batch_firstTrue让维度按照[batch, seq_len, feature]来组织省心很多。第三个是双向输出的维度。单向LSTM输出最后一个时间步的隐藏状态维度是hidden_size双向的则是hidden_size * 2。如果你在双向LSTM后面接一个线性层输入维度忘乘2就会报错或导致维度对不上。第四个是loss计算时的类型问题。CrossEntropyLoss期望标签是long类型也就是整数索引而很多人在加载数据时用了float标签导致loss计算直接报错甚至静默出错。训练前最好print一下标签的dtype确认是torch.long。4.2 效果不好先检查这几件事如果你的模型训练起来了但验证集准确率一直上不去不要第一时间怀疑模型结构按下面顺序排查。先看数据。标签是否干净训练集和验证集有没有重叠中文分词/按字切分是否合理这类问题其实占比最高。很多入门项目用的是爬取数据标签噪声一大再好的模型也白搭。再看预处理。PAD方式是不是影响了信息如果序列里PAD占比太高比如平均长度只有20个有效字符却统一填充到128那么大量时间步都是空白池化时会把有效信息的占比稀释掉。解决办法有两种把max_len设置得离中位数近一点或者用mask机制。然后看模型容量。隐藏层维度是不是太小了如果embedding_dim50、hidden_size32模型表达能力可能不够。可以先用一个偏大的配置比如embedding_dim200、hidden_size256跑通看上限在哪再回来压缩。最后看看是不是过拟合。训练集loss很低但验证集很差可以增加Dropout比例、加权重衰减、用早停或者人工扩增数据。4.3 独家经验我用下来觉得最值得记住的点有几点是我踩过不少次坑之后才真正内化的单独拿出来分享。第一LSTM对输入的特征尺度比较敏感。如果输入特征本身波动范围很大比如时间序列预测时原始数值从0到10000最好先做归一化但在文本任务里输入是Embedding层学出来的分布式表示一般不用手动归一化。第二不要迷信LSTM层数越多越好。很多入门文章一上来就堆两层、三层LSTM实际上在中小规模数据上单层BiLSTM往往已经够用。层数增加意味着参数量上涨、训练难度骤增而且每多一层梯度就要多穿一层长距离信息的传递反而可能变差。我自己的经验是一般文本分类任务一层BiLSTM效果就已经很不错先保证一层跑通再试两层。第三隐藏状态初始化不用太纠结。PyTorch里LSTM可以自动初始化隐藏状态为全0向量大部分情况直接忽略这个输入即可。只有在做推理、要保留上一次状态做流式预测时才需要手动管理h_n和c_n的传递。第四要关注时间开销。BiLSTM因为是两个方向训练速度大约是单向LSTM的两倍不到一点。如果你的任务对实时性要求高、或者序列特别长可以考虑只用LSTM甚至改轻量模型但如果是离线文本分析BiLSTM带来的效果提升通常值得这份开销。5. 把笔记串成一句话回到最开始的问题RNN、LSTM、BiLSTM到底要怎么学才不会被绕晕。我的答案是先理清一条主线RNN解决“序列依赖”问题LSTM解决“长距离记忆”问题BiLSTM解决“双向上下文”问题。三个模型不是互斥的而是层层递进的关系。学的时候不要死磕公式先搞懂每个门在干什么、每一条信息通路是怎么流动的然后立刻上手跑一个分类Demo让代码帮你验证理解。还有一个小建议看再多笔记都不如自己复现一遍。你可以找一段公开的影评数据从头到尾走一遍分词、建词表、填充、建模型、训练、评估的流程。哪怕中途因为各种形状报错卡了两个小时这些错误本身就是最好的学习材料。我第一次跑通BiLSTM时最后一步全连接维度忘了乘2报错之后回去翻文档那一刻才算真正记住了双向结构输出维度的变化。这套笔记写到这儿基本就完整了。如果后面你有机会做序列标注任务比如命名实体识别你会发现BiLSTM加条件随机场是特别经典的组合原因就在于BiLSTM提供上下文特征而条件随机场能约束标签之间的合法转移。学习循环神经网络这件事没有太多捷径但只要你沿着“RNN到LSTM到BiLSTM”这条路径走一遍再回头看各类序列任务的论文至少不会被模型结构吓住了。