这些年陆陆续续带过不少同学入门深度学习,发现几乎所有人绕不开的一道坎,就是循环神经网络这一族。从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并不是没有问题。如果序列特别长,训练时反向传播要沿着时间步一层一层传回去,这被称为BPTT(Backpropagation 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_size=128,那么每个时间步的输出维度就是128。BiLSTM把前向和后向拼起来,输出维度就是256。注意,PyTorch里的LSTM层可以通过设置bidirectional=True来直接完成这个操作,你不需要手动写两个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_len=64): 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, dtype=torch.long), torch.tensor(self.labels[idx], dtype=torch.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_dim=100, hidden_size=128, num_classes=2): super().__init__() self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=0) self.lstm = nn.LSTM( input_size=embedding_dim, hidden_size=hidden_size, num_layers=1, bidirectional=True, batch_first=True ) 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, dim=1) # [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(), lr=3e-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_norm=5.0) optimizer.step() total_loss += loss.item() print(f"epoch {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_first=True,让维度按照[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_dim=50、hidden_size=32,模型表达能力可能不够。可以先用一个偏大的配置,比如embedding_dim=200、hidden_size=256跑通,看上限在哪,再回来压缩。
最后看看是不是过拟合。训练集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”这条路径走一遍,再回头看各类序列任务的论文,至少不会被模型结构吓住了。