☰
RNN文本分类实战:从词袋到序列建模的情感分析
2026/10/20 15:32:07 网站建设 项目流程

手头有个历史遗留的文本分类需求:一堆电商平台的商品评论,要自动分成好评、差评、中性。规则词典跑了半年,准确率卡在82%上不去,负面召回尤其难看——用户骂"这耳机漏音漏得像广场舞音响",词典里没有"漏音"这个词,规则直接放过去了。后来换成RNN做分类,准确率冲到94%,负面召回从68%提到89%,顺带把分析报表里的人工复核工作量砍掉了六成。这篇文章就是把这个项目的完整过程拆开写一遍,从数据预处理到模型部署,包括我在这个过程中踩过的坑和反复调参的思路,给准备上手RNN做文本分类的同学一份尽量少绕弯路的参考。

1. 从"一袋子词"到"按顺序读文本":RNN的用武之地

先说清楚一个问题:为什么文本分类会想到用RNN?对比一下传统的文本分类做法,你就能明白RNN到底补上了什么短板。

早期做文本分类,最主流的方案是词袋模型(Bag of Words)配合统计分类器。词袋模型的做法很简单:把一篇文本拆成词,统计每个词出现的次数,得到一个稀疏向量,然后喂给逻辑回归、朴素贝叶斯这类分类器。这个方案的优点是简单、稀疏、好训练,但它的致命缺点是"丢顺序"。句子"我打你"和"你打我",词袋模型看到的是同一个向量。对短文本分类,比如垃圾邮件识别,词序影响不大,"中奖""免费领取"这些词出现基本就锁定了类别;但对情感分类、意图识别这类任务,词序和上下文是决定性的。

CNN做文本分类是另一个思路。它用卷积核在词向量序列上滑动,能捕捉到局部n-gram特征。比如"不好吃"这个三个词的局部组合,CNN可以用一个高度为3的卷积核抓出来。但CNN的感受野是有限的,要捕捉长距离依赖,必须堆很多层或者用空洞卷积,对文本这种变长的序列来说,灵活性不够好。

RNN的思路完全不同,它是一步一步按顺序读文本的。每个时间步读一个词,同时把之前读到的内容压缩成一个"记忆"传递给下一步。这个机制让RNN天然能建模序列关系:前文对后文的修饰,后文对前文的呼应,在RNN的视角里是连续的自然过程。

1.1 顺序信息在文本分类里的具体价值

用一个真实的例子说明。我之前处理的数据里有一条评论:"物流给力但包装烂得一批,好在商品没碰坏"。这条评论整体是正面还是负面?单独看"烂"这个强烈负面的词,分类器很容易把整条判成负面。但读完整个句子,会发现"烂"描述的是包装,"没碰坏"是结果,作者的主要态度是"物流给力""商品完好",整体应该是正面偏中性。

RNN读到"物流给力"时,把"物流+正面"记进隐藏状态;读到"包装烂"时,会在当前位置产生负面激活;但读到"好在商品没碰坏"时,语义焦点转移回商品本身,隐藏状态会重新加权前面的信息。最后拿全部时间段的状态做综合,才能捕捉到整句话的真实态度倾向。词袋模型没法做到这一点,它只会盲目地把"烂"和"给力""完好"等同对待,最后被"烂"一票否决。

1.2 和Transformer类模型的分工边界

现在提到文本分类,很多人第一反应是BERT或者XLNet这类预训练模型。确实,在多数公开榜单上,BERT的准确率比RNN高一截。但预训练模型有两个现实问题:一是显存和推理时延成本高,部署一台GPU服务器跑BERT做实时分类,吞吐量和成本压力不小;二是依赖预训练语料的覆盖度,垂直领域词(比如电商评论里的"起球""掉筒""闷汗")在通用语料里占比低,效果未必比一个针对性训练的RNN好。

RNN的定位恰恰在"数据量中等、计算资源有限、推理速度敏感"的场景。我这次做的评论分类,单条文本平均长度不到60个字,总共30万条标注数据,用LSTM训练15分钟能收敛,单条推理在普通CPU上不到5毫秒。这个性价比是BERT给不了的。

一句话总结:RNN不是所有场景的最优解,但它在"数据量不大、文本不长、算力有限"的夹缝里活得很好,而且作为序列建模的基础组件,理解清楚了RNN再去看注意力机制,会省力很多。

2. 拆开RNN的黑盒:循环机制到底在处理什么

动手写代码之前,必须把RNN的数学机制搞明白。不是要你用笔推公式,而是搞清楚它内部每一步在做什么,否则你连PyTorch的output和hidden该拿哪个都不知道怎么选。

2.1 隐藏在状态里的"接力棒"

RNN的核心思想非常简单:对序列里的每个元素做相同的变换,但每次变换都接收一个来自上一步的隐含状态作为额外输入。把输入序列记作( x_1, x_2, ..., x_t ),隐藏状态记作( h_t ),那么每个时间步的更新公式是:

[ h_t = \tanh(W_{ih} x_t + b_{ih} + W_{hh} h_{t-1} + b_{hh}) ]

这个公式在表达什么?x_t是当前时间步的词,h_{t-1}是前一个时间步留下的记忆,两者经过线性变换相加,再过一遍tanh激活函数,输出成当前步的隐藏状态h_t。然后这个h_t会作为下一步的"接力棒"继续传下去。

所以,RNN读一段文本的过程,就是一个逐词扫读、持续更新笔记的过程。最终时刻的隐藏状态h_T,理论上承载了整个序列的信息摘要——所有词的顺序、语义、修饰关系都被压缩在这一个固定长度的向量里。文本分类就是在序列结束后拿出这个向量,接一个全连接层做类别预测。

2.2 三种门控变体和一种朴素的"旧实现"

基础RNN有一个著名的硬伤:当序列很长(比如超过50步)时,反向传播的梯度在逐层传递时会出现梯度消失——靠近序列开头的词对最终预测的贡献趋近于零。1997年LSTM的论文提出用"门控"机制来解决这个问题,LSTM在基础RNN外面增加了三种门:遗忘门(决定丢弃多少旧记忆)、输入门(决定写入多少新信息)、输出门(决定释放多少内部状态给外部)。用生活化的类比就是:你读长篇小说时会自动记住主角线、忽略路人甲,LSTM做的就是这件事——它学会选择性地保留和遗忘信息,把语义关键的事件传递很远。

GRU(2014年)是LSTM的简化版,把三个门压缩成两个(更新门和重置门),参数量更少、训练更快,大多数任务上效果和LSTM持平甚至略好。在日常实践中,GRU在短文本分类上往往和LSTM打成平手,训练速度却快30%。选型时我一般这么定:文本短(不超过50词)且数据量小,LSTM稳;文本较短且需要快速迭代,GRU舒服;长文本(超过100词),双向LSTM是稳妥选择。

2.3 PyTorch返回值里最容易搞混的两个东西

上面公式干讲可能不够直观,来看PyTorch的具体输出:

import torch import torch.nn as nn rnn = nn.LSTM(input_size=100, hidden_size=64, num_layers=1, batch_first=True) # 模拟一个batch,每条样本30个词的序列,词向量维度100 x = torch.randn(16, 30, 100) # batch=16, seq_len=30, input_size=100 output, (h_n, c_n) = rnn(x) print(output.shape) # (16, 30, 64) print(h_n.shape) # (1, 16, 64),这里1是层数

output是每一个时间步的隐藏状态,形状(batch, seq_len, hidden_size),它记录了每个词读完后的所有中间状态,适合做序列标注、注意力权重的输入。

h_n是最后一个时间步的隐藏状态,形状(num_layers, batch, hidden_size),它只给出序列最后的结果。文本分类通常只需要拿最后一步的隐藏状态,所以用的是h_n[-1]。如果用了双向LSTM,则需要把正反向最后一步拼在一起,形状变成(batch, hidden_size * 2),再接分类层。

提示:一个非常常见的防呆问题——当你把batch_first=True设上之后,输入维度必须是(batch, seq_len, input_size),忘了这个参数的话,在文本数据上你将面对(seq_len, batch, input_size)的惯性形状,最终通过transpose换回来即可。

我是怎么记住这件事的?只需要记住一句话:output拿给"需要对每个位置做判断"的任务,h_n拿给"整句话做一个判断"的任务。文本分类是后者。

3. 跑通一个情感分类项目:数据、模型、训练的完整闭环

理论说清楚之后,直接上真实项目。我们以"电商评论正负面情感分类"为例,目标是把一条评论(不超过80个词)判成正面、负面、中性三类。完整流程分四步:数据预处理、词向量选择、模型搭建、训练评估。

3.1 数据预处理:清洗、分词、编码、统一长度

原始评论数据长什么样都有,全角半角标点混杂、表情符号、错别字。数据管道的顺序我固定这么走:

  1. 文本清洗:统一全角转半角,去掉URL、邮箱、HTML标签,保留中文、英文字母、数字和常用标点。表情符号在情感任务上是强信号,不要一删了之,我用一个映射表把它转成[happy]、[sad]这类特殊token。
  2. 分词:中文用jieba分词,英文按空格切分。分词器在垂直领域有坑——"买一送一"被切得七零八碎,我预先维护了一个自定义词典,把品牌名、产品专有名词、促销短语优先匹配进去。
  3. 构建词表:统计全部训练集词频,按词频降序排序,只保留前20000个高频词。为未知词预留<UNK>,为填充占位预留<PAD>,序列开头按需加` ``之类。词表映射关系存成JSON,离线推理时要复用。
  4. 序列编码与定长:把每个词映射成整数ID,不足固定长度(我定为64)用<PAD>补齐,超过则截断。

代码示意如下:

import re import json from collections import Counter import jieba def clean_text(text): # 全角转半角 text = unicodedata.normalize('NFKC', text) # 去URL text = re.sub(r'http\S+', '', text) # 去HTML标签 text = re.sub(r'<[^>]+>', '', text) return text.strip() def build_vocab(texts, vocab_size=20000): counter = Counter() for text in texts: words = jieba.lcut(clean_text(text)) counter.update(words) vocab = {'<PAD>': 0, '<UNK>': 1} for word, freq in counter.most_common(vocab_size - 2): vocab[word] = len(vocab) return vocab def encode(text, vocab, max_len=64): words = jieba.lcut(clean_text(text)) ids = [vocab.get(w, vocab['<UNK>']) for w in words] if len(ids) > max_len: ids = ids[:max_len] else: ids += [vocab['<PAD>']] * (max_len - len(ids)) return ids

有两点要强调。第一,max_len的选取要看训练集序列长度的分布,不用盲目取最大长度,取95%分位点即可。我统计下来,这条评论的95%分位点是52个词,所以我取64,留一点余量。如果非要把每一条都补齐到最长120,计算量浪费近一倍。第二,填充位置必须告诉模型,否则<PAD>会和普通词一样被RNN循环处理,干扰最后的语义表示,这点在第4节会细说。

3.2 词向量选择:从随机初始化到预训练

RNN输入层的设计有两种路径:

  • 随机初始化词向量:nn.Embedding(vocab_size, embedding_dim),embedding矩阵跟随训练一起更新。适合数据量10万级、词汇量集中的场景,优点是训练充分、领域适配度高,缺点是训练前期收敛慢、低频词表征差。
  • 预训练词向量初始化:用word2vec、GloVe或领域语料训练的词向量做初始化,训练时可以选择固定或微调。这个方案能带来5%~8%的准确率提升,但对算力和内存的消耗更大。

我这次的方案是先随机初始化训练一个基线模型,记下准确率;再加载一个用同领域电商评论语料(未标注)训练的word2vec向量重跑一版,对比后发现,预训练版本把负面类别的F1提高了4.3个百分点,特别是低频商品属性词("掉筒""起球")的向量空间分布明显更合理,该版本作为线上模型上线。

提示:预训练词向量用90%的量训练微调,固定10%的高频词向量,可以让低频词充分学习的同时保留通用语义结构。如果你用的是GPU,embedding矩阵一般不是瓶颈,直接全量微调即可。

3.3 模型搭建:从LSTM到分类层

我用的模型结构是三件套:Embedding层、双向LSTM层、全连接分类层。代码:

import torch.nn as nn class RNNClassifier(nn.Module): def __init__(self, vocab_size, embedding_dim, hidden_size, num_classes, num_layers=2, bidirectional=True, dropout=0.5): 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=num_layers, bidirectional=bidirectional, batch_first=True, dropout=dropout if num_layers > 1 else 0 ) # 双向LSTM的输出维度是 hidden_size * 2 self.fc = nn.Linear(hidden_size * 2, num_classes) self.dropout = nn.Dropout(dropout) def forward(self, x, lengths): embedded = self.dropout(self.embedding(x)) # (batch, seq_len, emb_dim) packed = nn.utils.rnn.pack_padded_sequence( embedded, lengths.cpu(), batch_first=True, enforce_sorted=False ) _, (h_n, _) = self.lstm(packed) # h_n shape: (num_layers * num_directions, batch, hidden_size) # 取最后一层的双向输出,沿hidden维度拼接 last_layers = h_n[-2:] # 正向最后一层和反向最后一层 final = torch.cat([last_layers[0], last_layers[1]], dim=1) # (batch, 2*hidden) final = self.dropout(final) logits = self.fc(final) return logits

这里有两个关键设计:

第一,为什么用pack_padded_sequence?RNN的时间步是序列长度。如果不处理长度差异,短样本的<PAD>也会被RNN逐个循环计算,浪费算力;更严重的是,最后一步若有padding,会把无意义的<PAD>状态当成句子语义。pack_padded_sequence的作用是把长度不同的样本动态打包,RNN只计算真实词的部分,算完再pad_packed_sequence解包回来。它要求提供每条样本的真实长度,通常和数据处理时的lengths一起从数据管道的batch_collate里取。

第二,双向LSTM为什么这样拼接?单向LSTM只能看到当前词之前的上下文。双向LSTM增加一层从右向左读取的循环网络,让每个词位置观察到的信息同时包括前后内容。对短文本来说,"上文看不清、下文补一手"的优势特别明显。h_n的形状是(num_layers*2, batch, hidden_size),最后一层两个方向分别对应索引-2和-1,把它们沿hidden维拼接,得到完整的整句表示。

3.4 训练循环:损失、优化器、精度计算

训练部分,我用nn.CrossEntropyLoss作为多分类损失,优化器选Adam,初始学习率2e-3,添加ReduceLROnPlateau调度器,在验证集损失连续3个epoch不下降时学习率减半。梯度裁剪max_grad_norm=5.0是必须项——LSTM的梯度幅度波动极大,不裁剪的话一轮爆炸loss直接变成几万,模型参数彻底归零。

import torch.optim as optim from torch.nn.utils import clip_grad_norm_ def train_one_epoch(model, dataloader, optimizer, criterion, device): model.train() total_loss, total_correct, total = 0.0, 0, 0 for batch in dataloader: input_ids = batch['input_ids'].to(device) lengths = batch['lengths'].to(device) labels = batch['label'].to(device) optimizer.zero_grad() logits = model(input_ids, lengths) loss = criterion(logits, labels) loss.backward() clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() total_loss += loss.item() total_correct += (logits.argmax(1) == labels).sum().item() total += labels.size(0) return total_loss / total, total_correct / total

评估指标不能只看准确率。情感分类的类别不平衡非常常见——这次数据里好评占58%,差评占21%,中性占21%。准确率就算不做任何模型、全部猜好评也有58%。我同时看macro-F1和每类别的Precision/Recall。线上主打的是负面类别的召回,目标是把"漏判差评"的比例降下来,最终模型差评召回到89%,整体Macro-F1从原先的0.76升到0.89。

4. 实测记录:训练曲线、收敛问题与三个容易踩的坑

这一节把我实际训练过程中的观察和踩坑记录下来,这些大概是官方教程不会告诉你的东西。

4.1 训练集曲线:loss在第二个epoch断层下降

第一版基线模型(随机词向量、单层双向LSTM、hidden_size=128)跑了15轮,loss和验证集准确率曲线我划了一下重点:

  • 第1-2轮:训练loss从2.4降到1.2,验证集准确率从63%升到78%。这阶段模型快速学会了高频sentiment词,比如"好""烂""值""差"。
  • 第3-5轮:准确率缓步升到84%,loss曲线出现小平台。这说明高频信号的收益开始枯竭,模型开始学习上下文组合模式。
  • 第6轮后:验证集准确率在86%附近震荡,训练loss持续下降但validation loss不再下降——过拟合的信号已经出现了。

针对过拟合,我把Dropout从0.3提到0.5,embedding矩阵加了一点L2正则,同时提前在验证集loss连续两轮不降时早停。最后验证集准确率定在91%。

4.2 坑一:batch内文本长度不一致导致的神秘低分

第一次跑通模型,准确率一直停在72%上不去。排查了词表、embedding、学习率,都没发现问题。最后检查DataLoader的collate函数,发现Padding是在整个batch统一pad到batch里最长样本的长度,lengths这个参数我忘记传给模型了。模型拿到的是(64, seq_len)的全填充序列,pack函数等于没有参与,<PAD>全被当成正常词训练。修复方式很简单:collate函数里对每条样本的真实长度单独记录,传入训练函数。

def collate_fn(batch): input_ids, lengths, labels = zip(*batch) # 每条样本的原始长度(不含padding) lengths = torch.tensor([len(x) for x in input_ids]) # 在batch内pad到最长 max_len = max(lengths).item() padded = torch.zeros(len(batch), max_len, dtype=torch.long) for i, seq in enumerate(input_ids): padded[i, :len(seq)] = torch.tensor(seq[:max_len]) return {'input_ids': padded, 'lengths': lengths, 'label': torch.tensor(labels)}

4.3 坑二:pack_padded_sequence的排序强迫症

pack_padded_sequence如果设置enforce_sorted=True(默认值),会强制要求batch内样本按长度降序排列,否则直接报错。我在训练代码里没用sort,因此必须显式设置enforce_sorted=False,这样PyTorch内部会自己排序。

这个坑藏在最不容易注意的角落,但一旦数据被故意打乱(shuffle=True),排名顺序被打乱,直接报错或者静默出结果错误。我统一按上面的写法,显式传enforce_sorted=False,同时在device上把lengths转成CPU tensor,因为pack函数不支持CUDA上的int64 tensor索引。

4.4 坑三:推理阶段忘记处理长度

训练时长度和padding是配套的,但到了模型上线做单条推理时,千万别把整个batch的逻辑照搬。单条推理时只有一条样本,padding与否影响不大;但如果你为了演示方便伪造一个batch,两条样本一长一短,短的pad到长的长度,却不传真实lengths,模型会把padding也当内容读进去。

上线推理时我干脆不padding,直接拿真实长度的序列过模型,不再走pack逻辑,只保留一条通路,减少出错面。

4.5 梯度裁剪:一个不得不养成的习惯

再提一次梯度裁剪。RNN的梯度不像CNN那样稳定,尤其是层数多、序列长时,梯度范数经常在正常值(10以内)和暴增到上千之间跳动。一旦梯度更新幅度过大,隐藏状态里积累的数值直接溢出,loss变成NaN,整个模型只能从头训。clip_grad_norm_把梯度范数强制压在5.0以内,相当于给优化器加了安全阀。我习惯在每次loss.backward()之后、optimizer.step()之前调用,这个顺序不能颠倒。

5. 从入门到实际部署:模型优化与选型建议

模型跑通和模型跑好是两码事。这一节讲线上部署前后做的几件事:模型轻量化、推理加速、以及不同任务场景下到底该选哪种架构。

5.1 模型结构优化:注意力层带来的提升

在LSTM接全连接层之前,加了一个简单的注意力层。基础版的做法是拿最后的时间步状态查询每个时间步的隐藏状态,算出权重再加权求和。代码如下:

class AttentionLayer(nn.Module): def __init__(self, hidden_size): super().__init__() self.query = nn.Linear(hidden_size * 2, hidden_size * 2) def forward(self, lstm_outputs): # lstm_outputs: (batch, seq_len, hidden_size*2) score = torch.tanh(self.query(lstm_outputs)) # (batch, seq_len, hidden_size*2) weights = torch.softmax(score.sum(dim=-1), dim=-1) # (batch, seq_len) context = torch.bmm(weights.unsqueeze(1), lstm_outputs).squeeze(1) return context

加了注意力之后,模型在"转折关系"样本上的表现明显提升,比如前文的"包装烂但商品好",注意力权重会自动把重心放在后半段的"商品好"上,而不是被前文的强烈负面词带偏。实测Macro-F1又涨了1.6个百分点。

5.2 推理加速:静态图、半精度、批量padding

线上部署我首先把PyTorch模型用TorchScript转成静态图,省去动态图的调度开销。单条CPU推理从5毫秒降到3.2毫秒。支持GPU的环境再开启半精度推理(FP16),推理速度再提升1.6倍,精度损失低于0.1%。

批量推理时,把长度接近的样本分到同一组里,可以显著减少padding浪费。比如线上请求分成了"短文本组"(平均10词)和"长文本组"(平均50词),分别padding到组内最长,有效计算量立刻减少了约35%。

5.3 不同任务架构怎么选

在做文本分类项目之前,先根据数据规模、任务复杂度选架构,能省大量试错时间。我根据自己的经验整理了一张决策表:

场景特征推荐架构理由
文本<50词,数据<20万条,二分类/三分类单层双向GRU + 注意力快,效果足够
文本>100词,内含长距离指代双层双向LSTM + 注意力长记忆能力更好,结构表达能力更强
类别多且语义粒度细(如商品属性标签)预训练词向量 + 双向LSTM + 注意力预训练向量提供通用语义,微调补充领域语义
数据量极大(百万级)、有GPU集群预训练语言模型(ERNIE类)微调大模型的优势在这时候才开始明显

有人会想,既然有BERT的路径,为什么还要费力调RNN?打个比方,BERT是一座精装房,拎包入住但物业费贵;RNN是一片毛坯地,你要自己画户型图铺水电,但成熟之后住得便宜且自由。你手里的预算和工期,决定你选哪个。

5.4 最终部署效果和后续扩展

线上运行了一个多月,模型平均准确率94.2%,负面评论召回率89.1%。相比之前的规则方案,主观评价类负面的覆盖范围大大拓宽——"一股塑料味""触感像磨砂玻璃"这类无法穷举的表述,RNN都能通过语义空间泛化捕捉到。

这个项目目前已经跑通了文本分类的完整闭环。后续可以做的扩展包括把LSTM替换成CapsuleNet做层次化编码,或者在注意力层引入外部知识库注入,但核心思路都还是"以序列建模理解上下文"。对于刚入门的工程师,把RNN跑熟,把这几节里谈到的Padding、Pack、梯度裁剪吃透,足以应付大多数实际的短文本分类需求。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询