1. 为什么新闻分类这个老问题值得用RNN再走一遍新闻分类听起来像是自然语言处理里的Hello World——给定一段文本判断它属于体育、财经、科技还是娱乐。很多人第一次接触文本分类时用的都是朴素贝叶斯或者TF-IDF加逻辑回归几行代码就能跑出不错的效果。但当你真正面对一篇两千字的深度报道或者一条只有十几个字的快讯时传统方法的短板就暴露出来了它们几乎不关心词序把文本当成一个词袋球队战胜对手和对手战胜球队在词袋模型里几乎一模一样。这就是我决定用RNN重做一遍新闻分类的原因。循环神经网络的核心优势在于它天然处理序列——每个时间步读入一个词把前面的信息通过隐藏状态传递下去相当于带着上文记忆来理解当前词。对于新闻这种高度依赖上下文和语序的文本RNN及其变体LSTM、GRU能捕捉到虽然……但是……这类转折结构也能记住段落开头交代的主体是谁。这篇文章适合谁看如果你已经会用PyTorch搭全连接网络想进阶到序列建模或者你做过文本分类但准确率卡在某个瓶颈上不去想换一种思路再或者你只是好奇RNN到底怎么处理变长文本——那这篇内容都能给你一套可以直接复现的方案。我会从数据准备讲到模型训练、从超参数选择讲到踩坑排查把每一步的为什么都讲清楚而不是只丢一堆代码让你自己猜。需要提前说明的是新闻分类的标签体系因场景而异。本文以中文新闻的多分类任务为例假设有若干个类别比如体育、财经、科技、娱乐等输入是新闻的标题加正文输出是类别概率分布。整个流程包括数据清洗、分词、词表构建、序列填充、模型定义、训练循环、评估与调优每一环都有值得说道的细节。2. 数据管道的搭建从原始新闻文本到模型可吃的张量2.1 新闻语料的清洗与标签对齐拿到新闻数据的第一件事不是急着写模型而是把数据看清楚。实际项目里的新闻语料通常有几个特点正文长度差异极大短讯几十字深度报道几千字、包含大量HTML标签残留、存在重复报道、类别分布不均衡。我一般会先做一轮统计看看每个类别的样本数、文本长度的分位数分布这直接决定了后面序列截断长度怎么选。清洗步骤我通常按这个顺序来先去掉HTML标签和特殊符号用正则表达式处理再统一全角半角然后去除纯空白和超短样本比如正文少于10个字的直接丢弃因为信息量太低反而引入噪声。标签对齐方面如果原始数据是多级标签需要根据任务目标决定是保留粗粒度还是细粒度。比如篮球和足球可以合并为体育但如果你的应用场景需要区分那就保留细粒度。有一个容易被忽略的点标签泄漏。有些新闻正文里会直接出现类别名称比如据体育频道报道……如果这类模式在某个类别中特别集中模型可能学会走捷径——看到体育频道就预测体育而不是真正理解内容。我的做法是在清洗阶段检查每个类别的高频词如果发现类别名称本身排在前列就考虑把这些词替换成中性标记或者直接剔除。2.2 中文分词与词表构建的取舍中文和英文不同英文天然以空格分词中文需要先做分词。常用的分词工具有jieba、pkuseg等选择哪个取决于你的领域。通用新闻用jieba就够如果涉及大量专业术语比如医学、法律可能需要加载自定义词典。分词之后要构建词表。这里有几个关键决策词表大小太小会导致大量词被映射为UNK丢失信息太大会增加嵌入层参数量容易过拟合。我的经验是中文新闻分类任务词表控制在10000到30000之间比较合适具体看语料规模。最小词频出现次数低于某个阈值的词直接归入UNK。阈值一般设2到5低于2的词大概率是噪声或者拼写错误。特殊标记至少需要PAD填充、UNK未知词、BOS序列起始可选、EOS序列结束可选。对于分类任务BOS和EOS不是必须的但加上有时能帮助模型识别序列边界。构建词表的代码逻辑很直接遍历所有分词后的文本统计词频按频率从高到低排序取前N个然后建立词到索引的映射。注意索引0通常留给PAD因为PyTorch的嵌入层和填充操作默认用0作为填充值。from collections import Counter def build_vocab(tokenized_texts, max_size20000, min_freq2): counter Counter() for tokens in tokenized_texts: counter.update(tokens) # 特殊标记优先 specials [PAD, UNK] vocab {token: idx for idx, token in enumerate(specials)} for token, freq in counter.most_common(max_size - len(specials)): if freq min_freq: vocab[token] len(vocab) return vocab2.3 序列填充与截断一个影响准确率的隐藏变量RNN要求输入是定长序列在一个batch内所以需要把不同长度的新闻统一到相同长度。这里有两个策略截断truncation和填充padding。截断是取前N个词或者后N个词填充是在短序列后面补PAD。截断长度的选择需要看数据分布。我一般会统计所有文本长度的95分位数把它作为最大长度。比如95%的新闻在800个词以内那就设max_len800。这样既覆盖了绝大多数样本又不会让填充过多导致计算浪费。但这里有个细节截断方向。新闻的关键信息往往在开头标题和导语所以取前N个词通常比取后N个词更合理。不过有些新闻是倒金字塔结构结论在前细节在后那取前面也合适。如果你的数据是评论或者对话可能取后面更重要。这个需要根据具体场景判断。填充之后还要生成一个掩码mask标记哪些位置是真实词、哪些是填充。虽然简单的RNN分类任务可以直接把最后一个时间步的隐藏状态作为特征填充在末尾最后一个非填充位置才是真正的结尾但如果你用的是双向RNN或者注意力机制掩码就必不可少了否则模型会把填充当成真实信息处理。import torch from torch.nn.utils.rnn import pad_sequence def collate_fn(batch, vocab, max_len800): texts, labels zip(*batch) sequences [] for text in texts: ids [vocab.get(token, vocab[UNK]) for token in text[:max_len]] sequences.append(torch.tensor(ids, dtypetorch.long)) # 填充到batch内最大长度或固定max_len padded pad_sequence(sequences, batch_firstTrue, padding_valuevocab[PAD]) # 如果不足max_len继续补 if padded.size(1) max_len: pad_tensor torch.full((padded.size(0), max_len - padded.size(1)), vocab[PAD], dtypetorch.long) padded torch.cat([padded, pad_tensor], dim1) else: padded padded[:, :max_len] labels torch.tensor(labels, dtypetorch.long) return padded, labels注意填充值必须和嵌入层的padding_idx一致否则填充位置的嵌入向量会在训练中被更新引入无意义的噪声。通常设padding_idx0。3. RNN、LSTM还是GRU模型选型背后的真实考量3.1 朴素RNN的梯度问题不是理论恐吓朴素RNN的隐藏状态更新公式是 ( h_t \tanh(W_{hh} h_{t-1} W_{xh} x_t b) )。反向传播时梯度需要沿着时间步连乘如果权重矩阵的谱半径小于1梯度会指数衰减大于1则会指数爆炸。这不是理论上的小概率事件——在新闻分类这种序列长度动辄几百的任务里朴素RNN几乎必然遇到梯度消失导致它只能记住最近几个词的信息前面的内容全忘了。我实测过一个朴素RNN在新闻分类上的表现当序列长度超过50准确率就明显低于LSTM超过200几乎退化为随机猜测。所以除非你的序列特别短比如短标题分类否则不建议用朴素RNN。3.2 LSTM的门控机制到底解决了什么LSTM引入了三个门遗忘门、输入门、输出门以及一个细胞状态 ( C_t )。细胞状态像一条传送带梯度可以沿着它几乎无衰减地传播。遗忘门决定丢弃多少旧信息输入门决定写入多少新信息输出门决定暴露多少信息给下一层。用生活化的类比朴素RNN像一个记性不好的人每听一个新词就忘掉一些旧词LSTM像一个有笔记本的人重要的事情记在本子上细胞状态不重要的就划掉遗忘门需要的时候翻出来用输出门。在新闻分类任务中LSTM能捕捉到虽然A队上半场落后但下半场连进三球逆转这种长距离依赖——虽然和但之间的转折关系需要跨越几十个词。这是LSTM相比朴素RNN的核心优势。3.3 GRU参数更少效果常常不输LSTMGRU是LSTM的简化版把遗忘门和输入门合并为更新门另外有一个重置门。参数比LSTM少约三分之一训练更快在小数据集上过拟合风险更低。我的经验是数据量在几万条以下时GRU和LSTM效果差异不大GRU甚至因为正则化效果更好而略优数据量很大几十万条以上时LSTM的表达能力上限更高。但这不是绝对规律具体任务上最好两个都试。下面是一个可配置的RNN分类器实现支持切换rnn_typeimport torch.nn as nn class RNNClassifier(nn.Module): def __init__(self, vocab_size, embed_dim, hidden_dim, num_classes, num_layers2, rnn_typelstm, dropout0.3, padding_idx0): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idxpadding_idx) self.rnn_type rnn_type.lower() rnn_cls {rnn: nn.RNN, lstm: nn.LSTM, gru: nn.GRU}[self.rnn_type] self.rnn rnn_cls( input_sizeembed_dim, hidden_sizehidden_dim, num_layersnum_layers, batch_firstTrue, dropoutdropout if num_layers 1 else 0, bidirectionalTrue ) self.dropout nn.Dropout(dropout) self.fc nn.Linear(hidden_dim * 2, num_classes) # 双向 def forward(self, x): # x: (batch, seq_len) embedded self.dropout(self.embedding(x)) outputs, hidden self.rnn(embedded) # 双向拼接最后时间步的正向和反向隐藏状态 if self.rnn_type lstm: hidden hidden[0] # (num_layers*2, batch, hidden) # 取最后一层的正向和反向 forward_hidden hidden[-2] backward_hidden hidden[-1] combined torch.cat([forward_hidden, backward_hidden], dim1) combined self.dropout(combined) logits self.fc(combined) return logits3.4 双向RNN新闻分类的标配新闻分类和语言生成不同生成任务只能看到上文但分类任务可以看到全文。双向RNN同时从前往后和从后往前读一遍把两个方向的最后隐藏状态拼接起来相当于让模型同时拥有前文记忆和后文记忆。举个例子苹果发布了新款手机股价应声上涨。单向RNN读到苹果时不知道后面是手机还是水果但双向RNN在反向读取时先看到手机和股价再读到苹果时就能正确理解为公司。所以新闻分类任务我默认用双向RNN准确率通常比单向高3到5个百分点。4. 训练循环里的魔鬼细节从损失函数到学习率调度4.1 交叉熵损失与类别不均衡的处理新闻分类是多分类任务标准损失函数是交叉熵损失CrossEntropyLoss。PyTorch的nn.CrossEntropyLoss内部已经包含了Softmax所以模型输出logits即可不需要额外加Softmax层。但新闻数据的类别分布往往不均衡——体育新闻可能占40%而某个小众类别只占2%。这时候模型会倾向于预测多数类因为这样就能获得较低的损失。解决办法有两种加权损失给少数类更高的权重。权重可以设为类别频率的倒数或者用1 / sqrt(freq)这种温和的形式。重采样对少数类过采样或者对多数类欠采样。过采样可能导致过拟合欠采样会丢失信息需要权衡。我一般先用加权损失如果效果不够再考虑重采样。权重的计算方式from sklearn.utils.class_weight import compute_class_weight import numpy as np class_weights compute_class_weight(balanced, classesnp.unique(labels), ylabels) class_weights torch.tensor(class_weights, dtypetorch.float) criterion nn.CrossEntropyLoss(weightclass_weights)4.2 优化器选择Adam还是SGDAdam自适应调整每个参数的学习率收敛快对初始学习率不敏感适合快速实验。SGD加动量在调好学习率的情况下泛化能力可能更好但需要更多调参。我的做法是先用Adam跑通流程确定模型结构没问题再尝试SGD加学习率调度看能否提升。Adam的默认学习率1e-3通常是个不错的起点如果训练损失震荡降到3e-4或1e-4如果收敛太慢升到3e-3试试。另外权重衰减weight decay对RNN很重要。RNN参数多容易过拟合加上1e-5到1e-4的权重衰减通常能提升验证集表现。注意Adam的权重衰减实现和SGD不同PyTorch的Adam的weight_decay是L2正则而AdamW是解耦的权重衰减后者通常效果更好。4.3 梯度裁剪RNN训练的必备保险RNN的梯度爆炸问题比梯度消失更危险——梯度消失只是学得慢梯度爆炸会让损失变成NaN训练直接崩溃。梯度裁剪gradient clipping把梯度的范数限制在一个阈值内超过就按比例缩放。PyTorch里用torch.nn.utils.clip_grad_norm_实现torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0)max_norm设多少我一般从1.0到5.0之间试。太小会限制正常梯度太大会失去保护作用。可以先打印梯度范数的分布看看正常训练时梯度范数大概在什么量级然后设一个略高于常见值的阈值。4.4 学习率调度与早停策略学习率调度器能在训练后期降低学习率帮助模型收敛到更优的局部最小值。常用的有StepLR每隔几个epoch降一次、ReduceLROnPlateau验证集损失不降就降、CosineAnnealingLR余弦退火。我比较喜欢ReduceLROnPlateau因为它根据实际表现调整不需要手动设步数scheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemin, factor0.5, patience2, verboseTrue ) # 每个epoch后 scheduler.step(val_loss)早停是防止过拟合的另一个利器。如果验证集损失连续N个epoch不下降就停止训练保存验证集损失最低的模型。patience一般设3到5。注意早停的指标应该是验证集损失而不是准确率因为损失更平滑准确率可能因为阈值波动而抖动。5. 那些让我调了两天才发现的坑5.1 嵌入层要不要冻结预训练词向量如Word2Vec、GloVe能加速收敛并提升小数据集上的表现。但加载预训练词向量后嵌入层是冻结还是继续训练我的经验是如果训练数据少于5万条先冻结嵌入层训练几轮再解冻微调。冻结时只有RNN和分类层的参数更新能快速找到一个不错的解解冻后嵌入层根据任务微调通常能再提升1到2个百分点。如果数据量很大直接从头训练嵌入层也可以不一定需要预训练。另外预训练词向量的维度和词表必须和你的数据对齐。如果预训练词表里没有某个词就用随机初始化或者全零。我一般用均匀分布初始化范围是[-0.1, 0.1]。5.2 隐藏层维度和层数的搭配隐藏层维度hidden_dim和层数num_layers是两个关键超参数。hidden_dim太小模型容量不足欠拟合太大参数爆炸过拟合且训练慢。num_layers同理。我的调参顺序是先固定num_layers1把hidden_dim从64试到512每次翻倍找到验证集表现最好的量级然后固定hidden_dim把num_layers从1试到3。通常2层双向LSTM已经足够3层以上收益递减且容易过拟合。还有一个细节双向RNN的隐藏状态维度是hidden_dim * 2因为正向和反向拼接。所以如果你的分类层输入维度写错了会直接报维度不匹配的错误。这个坑我踩过不止一次。5.3 批次大小与序列长度的权衡批次大小batch_size影响训练稳定性和速度。太小如8导致梯度噪声大训练不稳定太大如512内存吃不消且泛化可能变差。我一般从32或64开始试。但RNN有个特殊之处显存占用和序列长度成正比。如果你的max_len800batch_size64显存可能直接爆掉。这时候有两个选择减小batch_size或者用梯度累积gradient accumulation——用小batch跑多次累加梯度后再更新参数等效于大batch。accumulation_steps 4 for i, (inputs, labels) in enumerate(train_loader): outputs model(inputs) loss criterion(outputs, labels) / accumulation_steps loss.backward() if (i 1) % accumulation_steps 0: torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step() optimizer.zero_grad()5.4 验证集准确率不涨反降的排查链路训练过程中最让人抓狂的情况是训练损失在降但验证集准确率不升甚至下降。这通常是过拟合的信号但也可能是其他原因。我的排查顺序是检查数据泄漏验证集是否混入了训练集标签是否错位检查评估代码模型是否处于eval模式Dropout是否关闭BatchNorm是否用了训练集的统计量检查学习率学习率是否太大导致在最优解附近震荡检查正则化Dropout、权重衰减是否足够检查数据分布验证集和训练集的类别分布是否一致有一次我调了半天模型最后发现是验证集的标签在预处理时被错误地映射了导致模型预测对了但标签对不上。所以永远先怀疑数据再怀疑模型。6. 从准确率到混淆矩阵评估不能只看一个数6.1 准确率的欺骗性准确率是最直观的指标但在类别不均衡时极具欺骗性。如果90%的样本是体育类模型全部预测体育就能拿到90%的准确率但它在其他类别上完全失效。所以必须看每个类别的精确率、召回率和F1值。sklearn.metrics.classification_report能一次性输出这些指标from sklearn.metrics import classification_report, confusion_matrix all_preds [] all_labels [] model.eval() with torch.no_grad(): for inputs, labels in val_loader: outputs model(inputs) preds torch.argmax(outputs, dim1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) print(classification_report(all_labels, all_preds, target_namesclass_names))6.2 混淆矩阵暴露的类别混淆模式混淆矩阵能直观看到哪些类别容易被搞混。比如科技和财经可能因为都涉及公司、股价而被混淆体育和娱乐可能因为都涉及明星而被混淆。看到混淆模式后可以针对性优化如果两个类别混淆严重考虑增加区分性特征比如加入关键词特征。如果某个类别的召回率特别低检查该类别的样本是否太少考虑过采样。如果某个类别的精确率特别低说明模型经常把其他类误判成它检查该类别的特征是否不够独特。我习惯把混淆矩阵归一化后画成热力图一眼就能看出问题所在。虽然这里不能用图表但你可以用seaborn.heatmap自己画。6.3 错误样本分析模型到底错在哪看错误样本是提升模型最直接的方法。我会把验证集中预测错误的样本挑出来按置信度从高到低排序——置信度高但预测错的样本最值得分析因为它们说明模型自信地错了背后往往有系统性原因。常见的错误模式包括标题党标题写得很夸张但正文内容和标题不符模型被标题误导。多主题新闻一篇新闻同时涉及体育和财经比如某球队被收购股价大涨标签本身就有歧义。短文本信息量太少模型难以判断。领域术语某些专业术语在训练集中没见过被映射为UNK丢失关键信息。针对这些模式可以分别处理标题党可以降低标题的权重多主题新闻可以考虑多标签分类短文本可以引入外部知识领域术语可以扩充词表或使用子词分词如BPE。7. 让模型再快一点的工程技巧7.1 打包序列pack_padded_sequence省掉无效计算前面提到填充到固定长度但填充部分的计算是浪费的——RNN在填充位置上也会更新隐藏状态只是这些状态最终被丢弃。pack_padded_sequence能把填充部分跳过只计算真实序列能显著加速训练尤其是当batch内序列长度差异大时。使用步骤先按序列真实长度降序排列batch。调用pack_padded_sequence。送入RNN。用pad_packed_sequence还原。from torch.nn.utils.rnn import pack_padded_sequence, pad_packed_sequence # lengths是每个序列的真实长度降序排列 packed pack_padded_sequence(embedded, lengths, batch_firstTrue, enforce_sortedFalse) packed_output, hidden self.rnn(packed) output, output_lengths pad_packed_sequence(packed_output, batch_firstTrue)注意enforce_sortedFalse会自动处理排序但内部还是会排序再还原所以传入的lengths必须准确。7.2 混合精度训练显存减半速度提升PyTorch的torch.cuda.amp支持自动混合精度AMP用float16做前向和反向传播float32做参数更新。显存占用能减少约一半训练速度提升20%到50%。对于RNN这种显存大户效果尤其明显。from torch.cuda.amp import autocast, GradScaler scaler GradScaler() for inputs, labels in train_loader: optimizer.zero_grad() with autocast(): outputs model(inputs) loss criterion(outputs, labels) scaler.scale(loss).backward() scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) scaler.step(optimizer) scaler.update()注意梯度裁剪要在scaler.unscale_之后做否则裁剪的是缩放后的梯度阈值就不准了。7.3 把嵌入层和RNN放在不同设备上的坑这个坑比较隐蔽如果你想把嵌入层放在CPU上因为嵌入层参数大但计算少RNN放在GPU上需要手动把嵌入层的输出搬到GPU。但PyTorch的自动设备管理不会帮你做这件事你得显式调用.to(device)。而且如果嵌入层在CPU上每次前向传播都要做一次CPU到GPU的传输可能比直接全放GPU还慢。所以除非显存实在不够否则不建议拆分。8. 关于RNN新闻分类我踩过之后想告诉你的几件事做过多轮RNN新闻分类之后我最大的体会是模型结构本身往往不是瓶颈数据和训练细节才是。同样的LSTM结构清洗做得细、超参调得准准确率能差出十几个百分点。几个我反复验证过的经验第一双向LSTM加注意力池化attention pooling通常比单纯取最后隐藏状态好因为注意力能让模型自己决定哪些时间步重要而不是假设最后一个词最重要。第二预训练词向量在数据量少于十万条时几乎总是有帮助的但要注意词表对齐。第三梯度裁剪的阈值不要设太大5.0是个比较安全的起点设成10.0以上基本等于没裁剪。还有一个容易被忽视的点随机种子的影响。RNN的初始化对结果影响不小同一个模型跑三次可能差2到3个百分点。所以比较不同方案时至少跑三次取平均否则很容易被随机波动误导。我一般会固定一个种子跑主实验然后用另外两个种子验证结论是否稳定。最后如果你的新闻分类任务对延迟敏感比如在线服务RNN的推理速度可能是个问题因为它是串行计算的不能像Transformer那样并行。这时候可以考虑用CNN做文本分类TextCNN或者用蒸馏把RNN的知识迁移到更小的模型上。但如果你追求的是准确率上限RNN及其变体在中等规模数据上依然是很有竞争力的选择。
阅读完成 · 觉得有帮助?