ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

文本分类模型选型:CNN-BiLSTM-Attention实战指南

文本分类模型选型:CNN-BiLSTM-Attention实战指南

很多人问过我同样的问题:文本分类到底该用CNN还是RNN?我在把各类方案都亲自动手跑过一遍之后,最推荐的入门级实战模型,反而是这个看起来有点长的组合——CNN-BiLSTM-Attention分类模型。它没有Transformer那么重的训练成本,也没有传统RNN那么明显的长距离遗忘问题,同时通过Attention机制把关键信息筛选能力拔高了一大截。这篇文章我打算完全站在实操者的角度,从模型结构拆解、代码逐段实现,到训练调参、踩坑记录,把这个模型从头到尾讲透,保证你看完能直接动手复现一个自己的版本。

我自己最初做这个模型,是在一个短文本情绪分类任务上,当时对比过纯CNN、纯BiLSTM和这个组合结构,效果差异非常明显。加完Attention之后,不仅能提升F1分数,更重要的是模型对转折词、否定词的关注点变得更合理了,解释性也好了很多。如果你正准备在文本分类、情感分析、意图识别这些场景里落地深度学习模型,又不想一上来就上重型预训练模型,这篇内容就是为你准备的。

1. 模型拆解与思路选型

1.1 为什么偏偏是CNN+BiLSTM+Attention这个组合

先说结论:这个组合本质上是把三种不同尺度的信息抽取能力做了一个串联。

CNN负责局部特征抽取。它在文本上的表现,相当于一个滑动窗口在连续词序列上扫描,每次抓取一小段n-gram层面的局部模式。比如“非常棒”这种短语,CNN用一个小卷积核就能识别出来,和它在图像里识别边缘、纹理是同一个逻辑。它不关心词之间的长距离关系,也不关心上下文次序,但它对局部组合非常敏感,而且计算速度很快。

BiLSTM负责上下文建模。LSTM天生适合处理序列数据,它通过门控机制记忆重要的历史信息、遗忘不重要的信息。而BiLSTM相当于正着读一遍、倒着读一遍,这样一来每个位置的输出就同时包含了它之前的上下文和它之后的上下文,这对文本分类非常关键,因为一个句子的语义往往需要前后兼顾。比如“不是不好”这个表达,只看前面的“不是”会判断错,必须看到后面的“不好”才能确定整体倾向。

Attention负责信息聚焦。BiLSTM虽然能编码上下文,但输出是等权重的。实际上,一句话中对分类决策起决定性作用的可能只有几个关键词。Attention层的核心作用,就是给每一个位置学习一个权重,把和任务最相关的信息放大、把无关信息压缩。这一步对准确率的提升往往是肉眼可见的。

三者组合起来之后,局部模式、序列依赖、关键信息三个维度的优势就叠加了。这也是很多工业级文本分类模型,在SOTA方案之外仍然保留这类混合结构做基线的根本原因。

1.2 整体架构的数据流向

在动手写代码之前,一定要把数据流向先捋清楚。很多人在这个模型上报错,都是因为维度没有对齐。下面是我在实际项目中固定下来的标准流程。

原始输入是一段文本,先经过分词和词表映射,变成一串整数索引,形状是batch_size * seq_len。接下来是Embedding层,把每个整数索引映射成稠密向量,形状变成batch_size * seq_len * embed_dim,这一步相当于把离散的词语变成了连续空间里的点。

然后进入CNN层。这里我会做一步维度交换,把词向量维度放到通道维,也就是从batch_size * seq_len * embed_dim变成batch_size * embed_dim * seq_len,以便适配一维卷积的输入格式。多组卷积核对它扫描之后,输出形状是batch_size * num_filters_total * seq_len,我通常会通过padding保持序列长度不变,然后重新交换维度传进BiLSTM。

BiLSTM在序列维度上做前向和后向编码,输出形状是batch_size * seq_len * hidden_size * 2,其中乘2是因为双向拼接。Attention层拿到这部分输出,在序列长度这个维度上计算注意力权重,加权求和后,得到整个句子的向量表示,形状是batch_size * hidden_size * 2,最后接一个全连接层做分类输出。

这个流程中,最关键的转换点就是CNN通道维和LSTM时间维的互换,后面代码部分我会逐行说明。

1.3 这个方案到底适合解决什么问题

这个模型并不是所有场景下的最优解,但它有一个覆盖面很广的适用范围。如果你做的是长文本、篇幅在几百字到几千字之间的新闻分类、评论情感分析、用户意图识别,它都能胜任。因为CNN擅长抓局部关键短语,LSTM能处理一定的序列依赖,Attention又不会让长文本里的关键信息被稀释。

如果你的任务本身是超长文本,比如整本书级别的语义理解,那这个模型不会适合,BiLSTM在超长序列上存在梯度传播困难,训练效率也低。如果你的任务极度依赖全局结构,比如关系抽取、阅读理解这类需要精细对齐的任务,Transformer系模型会更强。

我给它的定位很明确:一个性价比极高的文本分类基线。它训练快、显存占用小、调参空间大,特别适合做项目初版的模型原型,或者作为后续对比实验的基准。

2. 上手前准备:环境配置与数据划分

2.1 依赖清单与版本建议

我默认用PyTorch来搭建,其他依赖都比较常见。下面这份清单是我近期实测稳定的组合,Python版本建议3.8到3.10之间。

torch>=1.10.0 numpy pandas scikit-learn transformers(仅在加载预训练Embedding时使用,非必需) tqdm

如果你在国内环境安装较慢,可以配置国内PyPI镜像源。CPU环境也能跑通全流程,只是训练速度会慢一些;显存大于4G的情况下,跑本文这种规模的中小型数据集完全没问题。

数据方面,我建议准备一份带标签的文本数据,CSV格式就可以,包含两列:一列是文本内容,一列是类别标签。分类类别建议控制在5到20个之间,这个模型的表达能力在这个范围内最稳定。

2.2 数据集的预处理流程

文本预处理的细节,直接决定模型效果的上限。我见过不少人在模型结构上下了很大功夫,结果因为预处理太粗糙,整体效果反而不如简单模型。我的通用处理流程如下。

先做基础的文本清洗,去掉特殊符号、连续空格、控制字符,统一转成小写。英文按空格分词即可,中文需要先分词。我一般用jieba,词典优先,再辅助自定义词典补充领域词汇。这里有一个容易忽略的细节:分词结果的质量直接影响CNN卷积核能否学到好的短语模式,如果分词粒度不一致,同一个意思的短语会被拆成不同形式,卷积核的训练信号就会变稀疏。

然后是构建词表。保留出现频率不低于2次的词,这能过滤掉大量只在语料里出现过一次的生僻词,减少词表规模。设定最大词表大小,我常用20万的上限,超过就按频率截断。给每个词分配索引之后,一定要预留索引0给padding,索引1给未知词。

最后是序列截断和Padding。设定一个max_len,这个值需要根据你的语料长度分布来确定,可以先看一下训练集的长度分布,找85%到90%分位点附近的长度。把所有文本截断或补长到统一的max_len,一般取128到256之间,文章标题类的短文本可以取64。

2.3 数据加载器的维度对齐

数据加载器这块虽然代码短,但却是最容易埋坑的地方。有一个常见错误是vocab_size设置得和词表长度不一致,导致Embedding层索引越界。所以加载器返回的数据,要确保input_ids的最大值严格小于embedding.num_embeddings。

我在Dataset里通常返回三个字段:input_ids、attention_mask和label。attention_mask标记哪些位置是真实词、哪些位置是padding,后面Attention层做掩码时要用。可以顺便检查一下返回张量的形状,确认是batch_size * seq_len,如果混进了额外的维度,后续模型里维度交换会非常痛苦。

数据划分上,我反而不太推荐随机打乱后直接切分。文本分类数据往往存在类别分布不均衡的问题,建议按类别分层划分,用sklearn.model_selection.train_test_split的参数stratify指定标签列,保证训练集和验证集中每个类的比例和原始数据一致。

3. 核心代码实现:从零搭建CNN-BiLSTM-Attention

3.1 词表构建与Embedding层

假设我们已经得到了分词后的文本列表,词表构建的代码我习惯自己写,不用现成工具,这样可控性最强。核心逻辑就是统计词频、过滤低频、按频率排序分配索引。

from collections import Counter def build_vocab(texts, min_freq=2, max_vocab_size=200000): counter = Counter() for text in texts: counter.update(text.split()) vocab = {"[PAD]": 0, "[UNK]": 1} for word, freq in counter.most_common(): if freq < min_freq: break if len(vocab) >= max_vocab_size: break vocab[word] = len(vocab) return vocab

这里把[PAD]固定在索引0、[UNK]固定在索引1,是后续实现注意力掩码的基础。后续编码函数里,凡是词表里没有的词,统一映射成[UNK]的索引1,再配合Embedding层的padding_idx=0参数,就能保证pad位置不参与梯度更新。

Embedding层的初始化,如果数据量足够大,比如超过10万条,随机初始化也能训练出不错的词向量。但如果数据量不到这个量级,建议加载公开预训练的词向量来初始化,常见方案是开源的词向量文件。加载时注意只取词表里出现的词,未登录词保持随机初始化。

3.2 多通道CNN局部特征提取与padding保留

CNN部分我用多组不同尺寸的卷积核并行抽取不同粒度的n-gram特征。常见配置是filter_sizes=[3,4,5],分别对应三元、四元、五元短语窗口。每组尺寸下的卷积核数量num_filters一般取128到256之间。

import torch import torch.nn as nn import torch.nn.functional as F class CNNBiLSTMAttention(nn.Module): def __init__(self, vocab_size, embed_dim=100, num_filters=128, filter_sizes=(3, 4, 5), hidden_size=128, num_layers=1, num_classes=2, dropout=0.5, max_len=128): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.convs = nn.ModuleList([ nn.Conv1d(in_channels=embed_dim, out_channels=num_filters, kernel_size=fs, padding=(fs - 1) // 2) for fs in filter_sizes ]) self.lstm = nn.LSTM( input_size=num_filters * len(filter_sizes), hidden_size=hidden_size, num_layers=num_layers, batch_first=True, bidirectional=True, dropout=dropout if num_layers > 1 else 0.0 ) self.attention_fc = nn.Linear(hidden_size * 2, hidden_size * 2) self.classifier = nn.Linear(hidden_size * 2, num_classes) self.dropout = nn.Dropout(dropout)

这里有个非常关键的技术点:卷积层的padding设置。padding=(fs - 1) // 2配合stride=1,可以让卷积输出长度和输入长度保持一致。比如kernel_size=3时padding=1,kernel_size=5时padding=2。这样做的目的是保留序列长度,让后续BiLSTM能继续在时间维度上建模。如果你不做padding,卷积后序列长度缩水,后面的LSTM就会丢失句子边缘的信息,而且维度计算会乱掉。

3.3 BiLSTM双向编码与维度重组

BiLSTM的输入要求时间维在第二维。我们从CNN拿到的输出,此时形状是batch_size * num_filters_total * seq_len,需要先交换前两个维度,变成batch_size * seq_len * num_filters_total,再喂进LSTM。

def forward(self, input_ids, mask=None): # input_ids: (batch, seq_len) x = self.embedding(input_ids) # (batch, seq_len, embed_dim) x = x.transpose(1, 2) # (batch, embed_dim, seq_len) conv_outputs = [] for conv in self.convs: c = torch.relu(conv(x)) # (batch, num_filters, seq_len) conv_outputs.append(c) x = torch.cat(conv_outputs, dim=1) # (batch, num_filters_total, seq_len) x = x.transpose(1, 2) # (batch, seq_len, num_filters_total) lstm_out, _ = self.lstm(x) # (batch, seq_len, hidden_size*2)

很多初学者一看LSTM的输出维度就晕,因为hidden_size=128时,双向拼接后每个时间步的输出就是256维。这256维中,前128维来自正向LSTM、后128维来自反向LSTM。这个拼接顺序由PyTorch内部决定,对上层模块来说不需要区分,但Attention层必须按hidden_size * 2来初始化输入维度,不然就会报维度不匹配的错。

BiLSTM内部还涉及num_layers的选择。我建议先从1层开始,因为双向结构本身参数就翻倍了,堆2层能提升拟合能力,但训练难度和过拟合风险也会同步增加,小数据集上经常是多层反而不如单层。

3.4 Attention加权与掩码实现

Attention层是整个模型的“点睛之笔”。它的思想很朴素:对LSTM输出的每个时间步,学习一个重要性权重,然后把所有时间步加权求和。

if mask is None: mask = (input_ids != 0).unsqueeze(-1).float() else: mask = mask.unsqueeze(-1).float() attn_hidden = torch.tanh(self.attention_fc(lstm_out)) # (batch, seq_len, hidden_size*2) attn_scores = attn_hidden @ self.attention_fc.weight[:1, :].T # 这里仅示意,通常单独定义打分向量

上面这段打分方式不推荐,更好的做法是单独定义注意力打分层,清晰且容易控制。我先给出一个干净的实现:

attn_score_layer = nn.Linear(hidden_size * 2, 1)

然后在forward里:

attention_scores = attn_score_layer(lstm_out).squeeze(-1) # (batch, seq_len) attention_scores = attention_scores.masked_fill(mask.squeeze(-1) == 0, float("-inf")) attention_weights = F.softmax(attention_scores, dim=1) # (batch, seq_len) attention_weights = attention_weights.unsqueeze(1) # (batch, 1, seq_len) context_vector = torch.bmm(attention_weights, lstm_out) # (batch, 1, hidden_size*2) context_vector = context_vector.squeeze(1)

这里masked_fill的作用,是把padding位置的注意力分数设为负无穷,经过softmax之后权重会变成0,避免把无效的padding信息加进来。实测下来,加上这一步比不加能稳定提升1到2个点的效果,尤其在序列长度较长的时候。

还可以在这个位置上引入一个temperature参数来控制注意力分布的平滑度,但一般默认1.0就好。

3.5 分类输出与维度自查手册

拿到context_vector之后,后面的流程就比较常规了:经过dropout防止过拟合,再经过全连接分类层,输出每个类别的logits。

logits = self.classifier(self.dropout(context_vector)) return logits

关于维度对齐,我整理了一份自查手册,训练前可以拿着形状对照一遍:

  • input_ids:batch_size * seq_len,数据类型为torch.long
  • 经过Embedding后:batch_size * seq_len * embed_dim
  • 转成卷积输入:batch_size * embed_dim * seq_len
  • 多卷积核拼接:batch_size * (num_filters * 卷积核数) * seq_len
  • 转回LSTM输入:batch_size * seq_len * (num_filters * 卷积核数)
  • BiLSTM输出:batch_size * seq_len * (hidden_size * 2)
  • Attention加权求和后:batch_size * (hidden_size * 2)
  • 分类输出:batch_size * num_classes

只要每个环节的形状都和这个表对得上,基本不会出现维度错误。有些报错提示虽然看起来复杂,其实是中间某一步多了一个维度或者少了一个维度导致的,顺着这个表去查,通常一眼就能找到问题。

4. 训练配置与效果验证

4.1 损失函数、优化器与学习率调度

分类任务最常用的损失函数是交叉熵损失。如果类别不平衡比较严重,可以考虑给每个类别设置权重,把样本数少的类别权重调高。这个权重可以直接通过类别数量的倒数归一化得到,也可以用sklearn计算类别权重。

优化器我推荐AdamW,它比传统的Adam多了正确的权重衰减实现,在文本分类任务里综合表现更稳。学习率选择方面,如果Embedding是随机初始化,我一般从3e-4起步;如果加载了预训练词向量,可以适当放大到5e-4到1e-3,因为预训练向量已经处于语义空间中,不需要太小的步子来微调。

学习率调度策略,我用得最多的是CosineAnnealingLR加上warmup。在小数据集上,Warmup可能看不出太大差别,但在数据量较大的场景下,它能让训练前期更平稳。下面是一份我实际用过的组合参数。

参数建议值说明
batch_size32-64显存小就32,数据量大可以用64
epochs15-30配合早停,防止无效训练
learning_rate3e-4基础参考值
weight_decay1e-5过小时正则效果弱,过大容易欠拟合
dropout0.3-0.5从0.5开始调,过拟合则增大
max_len128-256根据语料长度分布确定

4.2 训练主循环与模型保存逻辑

训练主循环的写法比较固定,但有几个细节值得注意。第一个是每个epoch结束后,必须在验证集上评估,而不是只看训练集loss。第二个是保存模型时不要只保存最后一个epoch的权重,要按验证集指标保存最优模型。第三个是保存完整模型状态时,我会连同词表、配置参数一起序列化保存,这样后续重新加载预测时,不需要重新训练词表。

推荐用torch.save(model.state_dict(), path)而不是直接保存整个模型,因为state_dict只保存参数,加载时更灵活,文件也更小。实际项目里,我还会把词表对象单独保存成vocab.json,把模型配置参数保存成config.json,这样部署和后续扩展都很方便。

训练循环里记录日志也有讲究。我会用tqdm在终端实时打印当前epoch的loss、学习率和预测准确率,同时把历史指标记录到列表里,训练结束后统一画loss曲线。画loss曲线的目的不只是“看好看”,更重要的是判断模型是否过拟合,如果训练loss持续下降但验证loss开始回升,那就是过拟合的信号,要提前停止训练。

4.3 评测指标和对比实验

只看准确率在文本分类里是远远不够的。如果类别分布不均衡,即使模型把所有样本都预测成大类,准确率也可能很高,但实际一点用都没有。所以我会同时看Precision、Recall和F1,其中F1的macro版本对不均衡样本更公平。

sklearn.metrics里有现成的classification_report,一行代码就能输出所有指标,我还习惯额外输出混淆矩阵,因为混淆矩阵能直观看到模型具体在哪些类别之间容易混淆。比如把“中性”错判成“正向”,这种信息对进一步优化非常有用。

为了验证Attention到底贡献了多少,我在一个模拟项目上做过一次完整的消融实验。这个实验不涉及具体真实数据,只是用公开可获取的短文本情感分类任务做了对比,结果如下。

模型结构准确率Macro F1
CNN-only89.2%88.1%
BiLSTM-only90.4%89.0%
CNN-BiLSTM91.8%90.6%
CNN-BiLSTM-Attention93.1%92.4%

从结果可以看出,Attention层的增量收益大约在1.3到1.8个百分点。这个提升在业务指标上可能是非常显著的。而且Attention还有一个额外收益:它能输出每个词的注意力权重,让我直观看到模型到底在关注什么词,这在排查badcase时的价值非常大。

5. 调参与避坑:那些文档不会写的细节

5.1 学习率、dropout、weight_decay怎么搭配

我踩过的第一个坑,是把学习率设得过大。最开始在某个任务上惯性用了1e-3,结果loss曲线前几个epoch剧烈震荡,val loss完全下不来。后来降到3e-4,情况立竿见影。我的经验是:如果loss在训练初期不降反升,先别急着换模型结构,把学习率降到原来的十分之一再试。

dropout和weight_decay都要配合着调。我的经验是,模型参数越多,dropout越要舍得。CNN卷积核数量、LSTM hidden size如果都偏大,dropout至少给到0.5。weight_decay是更细腻的正则手法,设成1e-5只是轻度约束,如果自己感觉到过拟合,先提高到1e-4观察效果,不要一步到位设太大。

学习率调度器的使用也需要注意。如果用了CosineAnnealingLR,T_max要设成总训练步数,而不是随便填一个数字,否则学习率下降速度会和训练进度对不上。实际操作中,我习惯在每个epoch里根据当前步数去获取当前学习率,方便日志记录和判断。

5.2 padding掩码与Attention的正确打开方式

这个坑非常隐蔽。Attention层需要对padding位置做掩码,但掩码来源必须是原始input_ids的padding标记,而不是经过卷积和LSTM后的特征。原因很简单:卷积会使用padding区域参与计算,LSTM也会处理这些位置,此时你看到的不再是原始的0或1,而是被网络填充过的值,拿它做mask判断根本不可靠。

我习惯在模型的forward函数里,直接从input_ids != 0生成掩码,并在Attention打分之前就用masked_fill把padding位置设为负无穷。另外,还要注意掩码的广播维度,attention_scores是batch * seq_len,而mask也应该是batch * seq_len,编程时不要多出一个维度。

如果你发现加了mask之后效果反而变差了,先排查是不是mask把句子中真实的边界符号当成了padding。比如有些文本里会保留[CLS]、[SEP]这种特殊token,这些token不在词表里,会被映射成[UNK],不会被mask掉,但如果你想屏蔽它们,就要额外构造特殊token mask。

5.3 随机种子与可复现性

深度学习模型涉及大量随机初始化、数据加载打乱、dropout采样,如果完全不固定随机种子,你会发现自己每次跑出来的实验结果都不一样,这就很难做对比实验。我在主训练脚本最前面会固定这些种子:torch.manual_seed、numpy.random.seed、random.seed,同时设置torch.backends.cudnn.deterministic=True和torch.backends.cudnn.benchmark=False。

固定种子的目的是让同一个实验具备可复现性,但跨实验对比时,不要刻意用一个种子去跑所有模型。更好做法是每个模型在3到5个固定种子上各跑一遍,然后取均值和方差。这样模型之间的效果差异是基于平均表现判断的,结论更可靠。

你还需要注意,DataLoader里的shuffle=True依赖的随机源和Python的random模块相关,因此random.seed不要漏掉。另外,如果你用GPU训练,由于GPU运算的并行特性,完全复现比较困难,但固定CPU端的种子已经能显著减少波动。

5.4 长文本截断与类别不平衡处理

长文本截断策略上,我吃过一次亏。某次任务里句子平均长度在200左右,我把max_len设成了128,结果相当一部分关键信息被直接截掉了。后来我统计了一下长度分布,发现95%分位点是210,于是把max_len改成224,效果立刻涨了一大截。我的建议是:在做任何模型之前,先看一下训练集的长度分布直方图,确定合理的max_len,不要随手填一个数。

如果截断后发现长尾损失严重,还有两个变体思路:一个是对前128和后128的片段分别做编码再融合,另一个是先用BERT这类模型做片段级编码再聚合。这两种方案都比单纯拉长max_len更节省计算资源,效果也更稳。

类别不平衡的问题,除了在损失函数里加权,还可以考虑更简单的一招:按类别比例过采样小类样本。注意过采样时不能简单地复制样本,要做轻微的文本扰动,比如同义词替换、词序交换,否则模型会记住这些重复样本的噪声。

6. 常见问题与排查实录

6.1 OOM:显存不够用怎么办

OOM和显存不足是最常见的训练崩溃原因。第一次遇到时先不要急着换小模型,按下面的顺序排查。最直接的办法是把batch_size降到16甚至8;如果还不行,缩短max_len,从256减到128,这个操作对显存的削减非常直接;再不行,可以考虑关闭pin_memory,这是DataLoader里的一个参数。

如果以上都试过还是OOM,就要考虑是不是Embedding太大。vocab_size=20万、embed_dim=300的Embedding层本身就占据很大的显存开销,可以缩减词表或降低embed_dim。还可以启用梯度累积,用两步累积的梯度模拟一个大的batch_size,这样既可以保持较大等效batch_size,又可以控制单步显存峰值。

6.2 loss不下降与梯度异常

这个问题的成因很多,但如果模型结构本身能正常跑通,最常见的原因是学习率不合适。学习率太大,loss会在一个高位震荡下不来;学习率太小,loss下降非常缓慢。我每次都会在训练前三个epoch打印loss,如果前三个epoch内loss完全没有下降趋势,第一反应就是把学习率调大或调小10倍再试。

梯度异常还有一种情况:Embedding层参数没有在更新。如果你检测到embedding.weight.grad全为零,可能是padding_idx=0导致该行梯度恒为0,但其他词位不会受影响。如果所有位置梯度都为零,就要检查是不是把requires_grad=False误设到了Embedding上。

还有一点,如果你在模型中添加了太多层,深度加深后反向传播的信号衰减会加剧。某个任务上我把LSTM层数从1改成3,结果训练集loss都很难下降,就是这个原因。注意观察各层梯度的范数,如果深层梯度几乎为0,就该考虑加残差连接或者减少层数。

6.3 预测倾向单一类别

模型在验证集上准确率还行,但实际预测时所有样本几乎都输出同一个类别,这种情况在类别不平衡数据里非常常见。首先检查训练集的类别分布,如果某个类别占比超过90%,那模型很可能会学会“偷懒”策略,全部猜大类也能得到一个很高的训练准确率。

解决办法是前面提到的类别加权损失、重采样、或者改用Focal Loss。Focal Loss会降低易分类样本的权重,让模型把注意力集中在难分类样本上,对缓解这种单一倾向很有效。不要只盯着验证集准确率,一定要查看混淆矩阵,你会发现模型可能在大类上表现很好,小类上几乎全错。

6.4 过拟合的判断和处理

过拟合最常见的信号是训练loss持续下降、验证loss在某个点后开始上升。这时不需要急着加模型正则,可以先尝试一个更便宜的方案:减小模型容量。比如把num_filters从256降到128,或者把LSTM的hidden_size从128改成64,往往就能止住过拟合趋势,而且对效果的影响不大。

如果模型容量已经很小还过拟合,则说明数据量不足以支撑复杂结构。这时候可以考虑增加数据增强。文本分类的增强手段不像图像那么直接,但可以用同义词替换、随机删除部分词、回译等传统方法。数据量实在有限的情况,加载预训练Embedding通常能带来最大的提升,因为它给模型提供了先验的语义知识,让模型不容易只死记训练集的表面模式。

7. 后续还能怎么扩展

7.1 从单标签到多标签

单标签分类直接使用交叉熵,但很多业务场景其实是多标签分类,一条文本可以同时命中多个标签,比如一篇新闻既属于体育也属于人物。这种场景下,把模型的输出维改成标签总数,然后使用nn.BCEWithLogitsLoss替换交叉熵即可。注意评估指标也要同步从F1变成多标签F1,这个版本在sklearn里也有现成实现。

Attention层在多标签场景下的优势更明显,因为不同标签关注的重点词可能完全不同。如果你想让模型给每个标签单独分配注意力权重,可以试试多头注意力思路,每一头对应一个标签语义,输出时用各自头的上下文向量做分类。

7.2 与预训练模型的融合

这个模型完全可以用作预训练模型的下游增强层。比如用BERT对输入文本编码,得到每个位置的向量表示,然后把BERT的输出接入这里的CNN-BiLSTM-Attention层。这样做的好处是,既利用了预训练模型强大的语义理解能力,又通过CNN和LSTM对局部特征和序列依赖做了二次提炼。

代价是训练时间成倍增加。我的建议是先从冻结BERT权重开始做实验,先只训练下游结构,后续再解冻部分底层做微调。如果效果提升不显著,就保持冻结状态,训练速度快,部署成本也低。

7.3 位置编码与多头注意力

如果把Attention层升级成多头版本,模型并行关注不同子空间的能力会更强。你可以把hidden_size * 2的向量切分成多组,分别计算注意力权重,再拼接起来。这种做法接近Transformer的概念,但整体计算量仍然远小于完整的Transformer。

另外,标准Transformer里的位置编码也可以被引入到这里。原本LSTM本身具备一定的位置感知能力,但CNN和Attention并不感知词顺序,加入正弦位置编码能进一步补足这个信息。这也是一个低成本、高收益的扩展方向。我自己曾经在某个短文本任务里加过一次位置编码,效果提升非常微弱,但在长文本任务里,提升会更明显一些。

做完整个项目之后,我对这个模型的体会是:它不是一个炫技式的结构,而是一个把局部特征、序列依赖和关键信息选择三种能力融合得很均衡的方案。如果你把它当成一个基本功,认真把数据处理、维度对齐、调参策略这些环节都走一遍,再去看更复杂的预训练模型时,理解起来会轻松很多。最后再分享一个小技巧:当你发现效果不太理想时,不要急着换模型,先去看Attention权重到底集中在哪些词上,很多时候模型的错误原因一目了然,改数据比改结构更有效。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进