CNN与RNN融合模型实现中文文本分类实战解析
2026/9/24 19:27:36 网站建设 项目流程

简介:面向Python机器学习初学者的中文文本分类项目源码包,聚焦字符级卷积神经网络与循环神经网络在中文语料上的实践,适合想快速掌握TensorFlow文本分类全流程的读者。压缩包内共18个文件,以Python脚本为主,涵盖模型构建、训练、预测、数据预处理等完整代码,另附4张网络结构图与训练曲线图、说明文档及运行环境依赖清单,整体仅410KB,轻量易部署。目前已有170人学习/下载。项目自带数据切分与聚合脚本,可从原始分类数据中自动生成训练集50000条、验证集5000条、测试集10000条的标准数据集;数据预处理模块还封装了词表构建、字符级表示、类别映射等常用函数,省去重复处理时间。源码同时提供CNN与RNN两套模型及独立训练入口,并配有架构图和准确率损失曲线,读者可对照理解两类网络在中文文本分类中的差异,也能基于现有代码调整参数或替换数据集,快速复现实验并扩展自己的分类任务。

1. 中文文本分类为什么偏偏要 CNN 和 RNN 一起用

中文文本分类是个老问题,但很多初学者第一次跑通“Python 基于 CNN 和 RNN 实现的中文文本分类”时,最容易掉进一个误区:以为 CNN 和 RNN 是二选一,选了 textCNN 就不再碰 LSTM。实际上,把卷积神经网络和循环神经网络接到同一个模型里,让 CNN 去抓局部关键词组合,让 RNN 去抓上下文顺序,对中文这种没有天然空格分隔、一词多义严重的语言来说,效果往往比单独用任何一个都好。这个项目源码加数据集的组合,正好是入门深度学习文本分类最完整的一条链路:从原始中文语料,到分词、词表构建、padding,再到模型训练和评估。适合刚学完 PyTorch 基础、想找一个完整项目练手的读者,也适合要做舆情分类、评论情感打标这类任务、需要一个可靠基线模型的工程师。本文就把这条链路拆开讲透,告诉你每一步怎么落、参数怎么调、坑在哪里。

2. CNN 和 RNN 在中文文本里的分工:一个抓词,一个抓顺序

2.1 textCNN 的工作原理:卷积核在词向量序列上滑动

中文文本要进卷积神经网络,第一步是把句子转成矩阵。假设一句话包含 n 个词,每个词查词表得到 embedding 向量,维度是 d,那么这句话就变成一个形状为 [n, d] 的矩阵。textCNN 的卷积核不是二维图像上的方形卷积核,而是高度为 h、宽度为 d 的卷积核。高度 h 表示每次看连续的 h 个词,宽度 d 必须和 embedding 维度一致,这样卷积核才能完整覆盖每个词的向量。例如 h=3 的卷积核,就是每次看连续的 3 个词,对这 3 个词的向量做加权组合,提取一个 n-gram 特征。

conv_input = embedded.unsqueeze(1) # [batch, 1, seq_len, embed_dim] conv = nn.Conv2d(in_channels=1, out_channels=num_filters, kernel_size=(filter_size, embed_dim)) conv_out = conv(conv_input) # [batch, num_filters, conv_seq_len, 1] conv_out = conv_out.squeeze(3)

这段代码是 textCNN 的核心。unsqueeze(1)是在第 1 维插入一个通道维度,因为 PyTorch 的 Conv2d 要求输入是四维 [batch, channel, height, width]。kernel_size=(filter_size, embed_dim)中的filter_size就是卷积核覆盖的连续词数,embed_dim必须和词向量维度相同,否则维度对不上直接报错。卷积后得到的conv_seq_len等于seq_len - filter_size + 1

卷积之后紧跟最大池化,作用是从每个卷积核产生的特征图中取出最大的那个值,表示“这个 n-gram 特征在整句话里最强烈的信号是什么”。多个卷积核并行,比如同时用 2、3、4 三种 filter_size,每种 128 个卷积核,就得到 384 维的特征向量,再接全连接层分类。这就是 textCNN 的完整逻辑:它不关心词的先后顺序,只关心哪些词的组合出现在句子里。

pooled = F.max_pool1d(conv_out, conv_out.size(2)).squeeze(2)

max_pool1d的池化窗口是整个序列长度,所以无论句子多长,只要出现过这个 n-gram 特征,就能被捕捉到。这也是 CNN 在文本分类里的优势:训练速度快、参数少、对短文本的效果特别好。但它的代价是丢失了词序信息,比如“不”和“好”的顺序被卷积核同时看到时,“不好”和“好不”可能被混为一谈。

2.2 BiLSTM 的补充:词序和上下文依赖靠它来兜底

循环神经网络处理文本的方式完全不同。RNN 按时间步逐个读入词向量,每一步都维护一个隐藏状态,这个隐藏状态相当于模型对“到目前为止读到的内容”的压缩记忆。但普通的 RNN 有梯度消失问题,序列一长,前面的信息就传不过来,所以实际工程里几乎不用原始 RNN,而是用 LSTM 或 GRU。LSTM 通过输入门、遗忘门、输出门机制,决定哪些旧信息要保留、哪些新信息要写入,这让它能把一句话里前后呼应的信息记下来。

lstm = nn.LSTM(input_size=embed_dim, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, bidirectional=True) lstm_out, (h_n, c_n) = lstm(embedded)

这段代码里的bidirectional=True意味着用双向 LSTM:一个 LSTM 从左往右读句子,另一个从右往左读,然后把两个方向的输出拼起来。这么做对中文特别重要,因为中文的语义依赖经常是后文决定前文的,比如“他跑了”和“他跑了业务”,同一个“跑”字在不同语境下含义完全不同。双向 LSTM 能让每个位置的输出同时包含它左边和右边的信息。

h_n是最后一层的隐藏状态,形状为 [num_layers * num_directions, batch, hidden_size]。项目里通常取最后一层的两个方向隐藏状态拼接起来,作为整个句子的语义表示。但这有个取舍:如果取lstm_out在最后一个时间步的输出,就只用了右向的最终信息和左向的最终信息,中间位置的强特征可能被淹没;如果对lstm_out做全局平均池化或最大池化,就能把每个位置的输出都利用上。常见做法是把双向的最后一个隐藏状态拼接,再接全连接层,这样得到的向量同时包含句首和句尾的信息,对整句分类来说已经够用。

2.3 两个分支融合:为什么 concat 优于相加或取平均

把 CNN 分支和 RNN 分支接起来时,最简单的做法就是把两边输出的特征向量直接拼起来,得到一个更高维的向量,再接一个全连接层做分类。有人会问,为什么不把两个向量相加或者取平均?原因在于,CNN 提取的特征偏重局部组合模式,RNN 提取的特征偏重全局顺序依赖,两者所在的特征空间差异很大,直接相加相当于强迫两个分布在同一个坐标系里对齐,反而互相干扰。concat 只是把两个特征空间拼在一起,让后面的全连接层自己去学怎么组合这些维度,灵活性更高。

combined = torch.cat((cnn_pooled, rnn_hidden), dim=1) # [batch, cnn_dim + rnn_dim] logits = fc(dropout(combined))

这里的cnn_pooled是 textCNN 经过池化后的输出,rnn_hidden是 BiLSTM 最后隐藏状态拼接后的结果。dropout加在全连接层前面,一般设 0.3 到 0.5 之间。fc的输出维度是类别数。从项目的实际效果来看,融合模型在中文新闻分类、情感二分类这类任务上,准确率通常比单独 textCNN 高 1 到 2 个百分点,但训练时间几乎是 textCNN 的 3 倍。所以如果你的任务对延迟敏感,或者数据量很小,单独跑 textCNN 也是个能接受的基线;数据量过万、文本长度超过 20 个词时,融合模型的优势才会真正体现出来。

3. 数据准备:中文文本进模型前必须过的四道关

3.1 原始数据集格式与标签处理:先搞清分类目标

这个项目附带的数据集,解压后常见的组织方式是:一个 data 目录,下面按类别分子目录,每个类别目录里放若干 txt 文件,每行是一篇文章或一条评论;或者一个 csv 文件,有 text 和 label 两列。先别急着写模型,第一步是搞清楚数据集的规模、类别分布、文本长度分布。用 pandas 读进来后,第一件事是看 label 的分布,因为中文分类数据集经常有类别极度不平衡的问题,比如“科技”类文章是“体育”类的 5 倍,这直接影响后面的评估方式和模型收敛行为。

import pandas as pd df = pd.read_csv('data/train.csv', sep='\t') print(df['label'].value_counts()) df['text_len'] = df['text'].apply(lambda x: len(x)) print(df['text_len'].describe())

value_counts()告诉你每类有多少条样本,如果发现某个类别只有几十条,后面训练时就要考虑类别加权或者干脆去掉这个类别。describe()看文本长度分布,主要关注中位数和 75 分位,这两个值决定你在做 padding 时把max_len设成多少。如果文本长度的中位数是 30,但 90 分位是 300,说明有少量超长文本,粗暴截断到 300 会损失大部分样本的信息,截断到 50 又会让长文本的尾部信息全部丢失。正确做法是画一个长度分布直方图,找一个能覆盖 90% 以上样本的长度作为max_len,剩下的 10% 截断。

不同类别的文本长度差异也值得留意。新闻标题类数据普遍很短,可能 20 到 40 个字;影评、商品评论则动辄几百字。如果混合训练,短文本被 padding 到和长文本一样长,大量 padding 位是没意义的,这不仅是算力浪费,还会让模型把 attention 权重分配到无效位置。所以实际处理时,我会把max_len定为 128 或 200,既覆盖大部分短文本,也保留长文本的核心内容。对超长文本,直接取前 128 个字符通常效果尚可;如果任务依赖长距离语义,比如判断一篇长文章的整体情感倾向,可以用“头尾截取法”,即取前 64 个字加后 64 个字拼接,效果往往比只取前 128 个字好。

3.2 分词与词表构建:jieba 不是唯一选择,但默认用它

中文和英文最大的区别是没有空格,所以分词是绕不开的一步。这个项目默认用 jieba 分词,因为它是纯 Python 实现、安装方便、对绝大多数领域语料有不错的表现。分词前先做一个简单的清洗:去掉 HTML 标签、把全角字符转半角、过滤掉非中文字符和英文字母等。清洗规则不要写得太狠,比如把数字全部删掉,会损失“iPhone 15 值得买吗”这种含数字的语义;比较好的做法是把英文和数字保留为独立词元,让模型自己学它们的重要性。

import jieba def tokenize(text): text = text.lower() text = re.sub(r'<.*?>', '', text) words = jieba.lcut(text) words = [w for w in words if w.strip()] return words

jieba.lcut返回一个分词后的列表,比jieba.cut更适合直接做后续处理。去掉空字符是为了避免把空格、换行符当成词。这里有个常见的分工:有些项目不提前过滤停用词(的、了、是、在等),而是让模型自己学,因为对深度学习模型来说,停用词并非完全没有信息量,它们在特定上下文里能帮助判断句式;但保留全部停用词会拉长序列长度,而且高频词会让 word2vec 或 embedding 层的更新过于集中。我的做法是:先不过滤停用词把词表建出来,看词频排序,如果前 20 个高频词全是“的、了、是、在、我、你”之类的虚词,再考虑加一个 100 词左右的停用词表过滤;如果数据集本身是短文本,虚词占比不高,就不过滤。

词表构建是数据准备的核心环节。把分词后的列表统计词频,按词频降序排列,保留出现次数超过某个阈值(比如 2 次)的词,形成一个词表。词表里要预留三个特殊符号:<PAD>占位符、<UNK>代表未见过的词、<CLS>作为句子开头标记(如果用 BERT 风格的输入则必须有,textCNN 和 BiLSTM 一般用不到)。词表的开头三个位置固定给这三个符号:

from collections import Counter word_counter = Counter() for words in tokenized_texts: word_counter.update(words) vocab = {'<PAD>': 0, '<UNK>': 1} for word, freq in word_counter.most_common(vocab_size - 2): vocab[word] = len(vocab)

vocab_size是词表上限,一般设 20000 到 50000。设得太小,很多低频词变成<UNK>,模型对专有名词和生僻词完全失去辨识力;设得太大,词表尾部大量只出现一两次的词会让 embedding 矩阵稀疏且占用显存。一个常用的经验是:统计词频后,设置最小出现次数为 2,把所有出现次数大于等于 2 的词都保留,看最终数量,再决定要不要截断到 50000。

3.3 序列 padding 与 batch 划分:一个被低估的准确率影响因素

词表构建完成后,每一条文本都要从词列表转成索引列表,再做 padding 到统一长度。这是整个数据管线里最容易出错的环节。如果代码里忘了对max_len以上的文本做截断,或者只截断了没有限制长度,就会导致一个 batch 内不同样本的序列长度不一致,PyTorch 的nn.DataParallel会直接报错,或者更隐蔽的是:你用了pack_padded_sequence,却没正确传递长度信息,模型能跑但结果全错。

def encode_and_pad(words, vocab, max_len): ids = [vocab.get(w, vocab['<UNK>']) for w in words] if len(ids) > max_len: ids = ids[:max_len] else: ids = ids + [vocab['<PAD>']] * (max_len - len(ids)) return ids

vocab.get(w, vocab['<UNK>'])是关键,未登录词统一映射到<UNK>的索引 1,而不是直接报错或跳过。截断放在前面,padding 放在后面,顺序不能反。特别要注意:截断策略是只保留前面,还是头和尾都保留,不同任务结果差别很大。短文本分类任务用前截断就行;长文本情感分析任务使用头尾拼接的效果我在 3.1 里讲过。padding 的位置也有讲究:textCNN 和 BiLSTM 默认都把 padding 放在尾部,但如果用 Transformer 的 attention 机制,就需要显式构造 attention mask 来屏蔽 padding 位,否则 padding 位参与计算会污染向量表示。

train_dataset = TensorDataset(torch.tensor(X_train), torch.tensor(y_train)) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)

shuffle=True在训练时必须开,否则每个 epoch 内样本顺序固定,模型会学到 batch 的顺序信息,导致验证集准确率虚高。batch_size的选择取决于显存和序列长度,一般 32 到 128。如果训练过程中显存溢出,优先调小 batch_size,而不是调小max_len,因为后者直接影响模型效果。

数据准备好之后,记得把训练集、验证集、测试集按 8:1:1 或 7:2:1 划分。划分时用train_test_splitstratify参数,保证每个子集里类别分布一致。这一点对不平衡数据集尤其重要,如果不分层抽样,小类可能在验证集里一条也没有,导致评估结果完全失真。

4. 模型搭建与训练:从定义网络到跑通第一个 epoch

4.1 把 CNN 和 RNN 拼成一个完整模型类

定义融合模型的 PyTorch 类时,我习惯把 embedding、CNN 分支、RNN 分支、全连接层作为子模块,前向传播里依次调用。类的初始化接收几个关键参数:vocab_sizeembed_dimnum_filtersfilter_sizeshidden_sizenum_layersnum_classesdropout。这些参数在训练脚本里统一传入,方便做实验对比。

class HybridCNNRNN(nn.Module): def __init__(self, vocab_size, embed_dim, num_filters, filter_sizes, hidden_size, num_layers, num_classes, dropout): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.cnn_convs = nn.ModuleList([ nn.Conv2d(1, num_filters, (fs, embed_dim)) for fs in filter_sizes ]) self.lstm = nn.LSTM(embed_dim, hidden_size, num_layers, batch_first=True, bidirectional=True) self.fc = nn.Linear(len(filter_sizes) * num_filters + hidden_size * 2, num_classes) self.dropout = nn.Dropout(dropout) def forward(self, x): embedded = self.embedding(x) # [batch, seq_len, embed_dim] embedded = self.dropout(embedded) # CNN 分支 conv_input = embedded.unsqueeze(1) cnn_outs = [] for conv in self.cnn_convs: conv_out = conv(conv_input).squeeze(3) pooled = F.max_pool1d(conv_out, conv_out.size(2)).squeeze(2) cnn_outs.append(pooled) cnn_vec = torch.cat(cnn_outs, dim=1) # RNN 分支 lstm_out, (h_n, c_n) = self.lstm(embedded) rnn_vec = torch.cat((h_n[-2], h_n[-1]), dim=1) combined = torch.cat((cnn_vec, rnn_vec), dim=1) combined = self.dropout(combined) logits = self.fc(combined) return logits

padding_idx=0nn.Embedding里的作用是:索引为 0 的位置对应的向量在训练中始终为 0,不参与梯度更新。这样 padding 位不产生任何信息,避免把无意义的向量也丢进 LSTM 去干扰隐藏状态,这是一定要写的参数。CNN 分支里,每个 filter_size 对应一个卷积层,输出都做最大池化,最后把不同 filter_size 的结果拼起来。RNN 分支取h_n[-2]h_n[-1],因为bidirectional=True时最后一层有两个方向的隐藏状态,h_n的形状是[num_layers * 2, batch, hidden_size],倒数第二个是反向的最终隐藏状态,倒数第一个是正向的最终隐藏状态,两者拼接正好表示整句的上下文汇总。

self.fc的输入维度是len(filter_sizes) * num_filters + hidden_size * 2,其中hidden_size * 2是因为双向 LSTM 拼接了两个方向的输出。很多新手在这里算错维度,导致全连接层输入输出不匹配报错。建议在定义模型后先打印一层输出的形状,确认没算错再往后走。

4.2 训练循环:交叉熵、优化器与一个值得记录的细节

训练循环本身不复杂,但有几个细节决定了这个项目跑出来的结果能不能复现。损失函数用交叉熵,nn.CrossEntropyLoss()内部已经包含了 softmax 操作,所以模型的最后一层不需要再接nn.Softmax,直接输出 logits 就行。优化器用 Adam,学习率从 0.001 起步,配合ReduceLROnPlateau调度器,当验证集损失连续两个 epoch 不下降时把学习率缩小 10 倍。

optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='max', factor=0.5, patience=2 ) criterion = nn.CrossEntropyLoss() for epoch in range(epochs): model.train() total_loss = 0 for batch_x, batch_y in train_loader: batch_x, batch_y = batch_x.to(device), batch_y.to(device) optimizer.zero_grad() logits = model(batch_x) loss = criterion(logits, batch_y) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() total_loss += loss.item() # 验证阶段省略 scheduler.step(val_acc)

clip_grad_norm_是一个关键细节。RNN 分支在长序列训练时非常容易梯度爆炸,表现为 loss 突然变成 NaN 或者跳到天文数字。梯度裁剪把整个模型的梯度范数限制在 5.0 以内,超过就等比缩放,是防止这种情况最省事的手段。optimizer.zero_grad()放在前向传播之前,否则梯度会跨 batch 累加,这个错误不会报错但会让模型完全训不动。

训练过程中要同时记录训练集 loss 和验证集准确率。一个经验是:验证集准确率在前 5 个 epoch 内如果纹丝不动,先检查数据预处理和模型输出维度,不要急着调超参数;验证集能涨但很慢,才是调学习率和模型结构的时候。每个 epoch 结束时打印日志:

print(f'Epoch {epoch+1}/{epochs} | Loss: {total_loss/len(train_loader):.4f} | Val Acc: {val_acc:.4f}')

4.3 必调的四个超参数:embed_dim、filter_sizes、hidden_size、dropout

超参数是整个项目“玄学”浓度最高的地方,但并不意味着没有规律可循。先说embed_dim,也就是词向量维度。100 到 300 是常见范围,维度太小词义表达不够,维度太大在小数据集上容易过拟合。这个项目数据集通常在几万条到几十万条之间,200 是一个稳妥的中间值。如果显存紧张,降到 100 也能勉强用,但准确率一般会掉 1 到 2 个点。

filter_sizes决定 CNN 分支能捕捉多长的 n-gram。对中文来说,2、3、4 是覆盖度最广的选择,因为中文的惯用搭配和固定搭配以两字词和四字成语为主。如果你处理的是口语化评论,可以试试 1、2、3,因为口语里单个字和双字词的信息量更大。num_filters每个 filter_size 对应 100 到 256 个卷积核,太大容易过拟合且训练变慢,太小特征不够。一个判断方法是:设了 128 个卷积核,训练完后看 max pooling 输出的非零比例,如果大部分都是 0,说明卷积核太多,可以减半再试。

hidden_size控制 LSTM 隐藏状态的容量,常见 128 或 256。num_layers控制在 1 到 2 层,不要超过 3 层,因为 LSTM 每多一层参数量翻倍,训练时间大幅增加,而文本分类不是阅读理解,不需要太深的语义层级。dropout放 0.3 到 0.5 之间,0.5 是保险值。如果你的训练集很小(几千条),dropout 不但不能防过拟合,反而会让模型欠拟合,此时调小到 0.2 或者干脆去掉 dropout 层,配合 L2 正则更有效。

model = HybridCNNRNN( vocab_size=len(vocab), embed_dim=200, num_filters=128, filter_sizes=[2, 3, 4], hidden_size=128, num_layers=1, num_classes=len(label_to_idx), dropout=0.4 )

这里label_to_idx是类别到索引的映射,在数据准备阶段就建好,训练和推理阶段必须用同一个映射表。很多人训练完保存模型,推理时却忘了保存映射表,导致同样的文本预测结果错位。常见做法是把映射表存成 json 文件,和模型权重放在同一个目录,推理时一起加载。

5. 踩坑复盘:中文文本分类最常见的六个坑

5.1 数据集解压乱码和编码问题:现象、原因、解决

现象:用 pandas 读入 CSV 后,打印出来全是乱码,或者训练时 loss 不降,准确率一直在 10% 左右徘徊。

原因:Windows 环境下保存的中文文本多为 GBK 或 GB18030 编码,而 pandas 和 open 默认用 UTF-8 解码,导致读到一堆错乱的字符,模型等于在噪声上训练。

解决:读取文本时显式指定编码,先尝试用utf-8解码,抛异常时回退到gbk

def read_text(file_path): for enc in ['utf-8', 'gbk', 'gb18030']: try: with open(file_path, 'r', encoding=enc) as f: return f.read() except UnicodeDecodeError: continue raise ValueError(f'无法解码文件: {file_path}')

5.2 jieba 分词把标点当词,导致词表被垃圾占满

现象:词表建出来后,数量超过预期,而且高频词里大量是“,”“。”“!”等标点符号,模型准确率明显偏低。

原因:jieba 的lcut默认保留标点和空格,清洗阶段没有过滤掉。标点被当成词后,占据了词表大量位置,同时模型学到了“句号后一定是结束”这类无用规律。

解决:分词前用正则把标点替换成空格,或者在分词后再次过滤长度为一且只包含标点的词。

words = [w for w in jieba.lcut(re.sub(r'[,。!?、;:""''()\s]', ' ', text)) if w.strip()]

注意:不要把所有长度为一的词都过滤掉,因为“好”“坏”“不”这些单字词在情感分类里信息量极大,过滤掉它们等于自废武功。

5.3 词表里没有留 ,推理时遇到生词直接 KeyError

现象:训练一切正常,推理阶段输入一条新文本,程序直接崩了,报KeyError: 'xxxxx'

原因:编码函数里用了vocab[word]而不是vocab.get(word, vocab['<UNK>'])。训练集里出现的词在推理时未必都有,一旦遇到未登录词,字典访问直接抛异常。

解决:把所有词典访问全部改成.get()写法,同时保证<UNK>在词表里并且索引不为 0。这条我在 3.2 节代码里已经写了,但很多人写进项目时会因为“图省事”丢掉它,这里再强调一遍。

5.4 模型训练速度慢到无法接受,batch_size 和序列长度是元凶

现象:跑一个 epoch 要几十分钟,显卡利用率却只有 10%。

原因:max_len设得过大,比如 500,而数据集的文本平均长度只有 30,大量计算浪费在 padding 位上。LSTM 是按时间步展开的,序列越长计算量呈线性增长,padded 长度如果变成 500,实际计算量是必要计算量的 10 倍以上。

解决:先统计文本长度分布,把max_len压到覆盖 90% 样本的长度;同时检查batch_size,太小会导致 GPU 利用率上不去。另外一个隐蔽的优化是启用torch.backends.cudnn.benchmark = True,让 cuDNN 自动选择最优卷积算法。

torch.backends.cudnn.benchmark = True

这个开关只影响卷积层的性能,不影响数值结果,加上它几乎没成本。

5.5 类别不均衡导致准确率高但 F1 值极低

现象:准确率 95%,但查看混淆矩阵发现占 90% 的类别全部正确,剩下 10% 的类别几乎全部被预测成大类。

原因:数据集类别分布严重失衡,模型只要把所有样本都预测成大类,准确率就有 90%,但小类的召回率是 0。

解决:训练时给交叉熵加weight参数,权重设置按类别样本数反比。或者更简单一点:评估时同时看 macro-F1,而不仅是 accuracy。

from sklearn.utils.class_weight import compute_class_weight class_weights = compute_class_weight('balanced', classes=np.unique(y_train), y=y_train) class_weights = torch.tensor(class_weights, dtype=torch.float).to(device) criterion = nn.CrossEntropyLoss(weight=class_weights)

compute_class_weight'balanced'模式会自动计算每个类别的权重,样本数少的类别权重高,样本数多的类别权重低,不需要手动算。加上这个参数后,模型的训练目标从“尽量猜对大样本”变成“尽量均衡地猜对所有类”,小类的 F1 值通常能提升 10 到 20 个百分点。

5.6 模型保存与推理脱节:少存了映射表等于白训

现象:模型训练完保存,load 进推理脚本,结果预测结果驴唇不对马嘴,甚至维度对不上直接报错。

原因:训练脚本里model.state_dict()保存的是模型权重,它依赖词表的顺序——词表第几个位置对应的 embedding 是哪一行,是在训练时固定下来的。推理脚本如果重新构建词表,顺序和训练时不同,同一段文本查词表得到完全不同的索引序列,预测自然全错。

解决:把词表、label_to_idx映射和模型权重一起保存。PyTorch 官方推荐用torch.save打包成一个字典:

torch.save({ 'model_state_dict': model.state_dict(), 'vocab': vocab, 'label_to_idx': label_to_idx, 'max_len': max_len, 'model_config': config, }, 'model/checkpoint.pt')

推理时这样加载:

checkpoint = torch.load('model/checkpoint.pt', map_location='cpu') model.load_state_dict(checkpoint['model_state_dict']) vocab = checkpoint['vocab']

我把这个文件命名为checkpoint.pt而不是model.pth,因为里面不只是权重,还有一堆配套元数据。这样推理脚本不依赖训练脚本里的任何全局变量,换一台机器也能直接跑起来。这算是最有价值的一条踩坑记录,完整项目上线时如果在这一步翻车,所有训练时间都白费了。

6. 用混淆矩阵和错误分析挖掘模型优化方向

模型训练完毕,验证集准确率看起来不错,但离真正上线还差一步:理解模型哪里对、哪里错,以及有没有低成本优化的空间。直接看验证集准确率只是给自己一个定心丸,真正决定这个项目能不能被信任的,是混淆矩阵和人工错误分析。我习惯把最终保存的 checkpoint 加载回来,在测试集上跑一遍,生成混淆矩阵。

from sklearn.metrics import confusion_matrix, classification_report preds = [] labels_all = [] model.eval() with torch.no_grad(): for batch_x, batch_y in test_loader: batch_x = batch_x.to(device) logits = model(batch_x) pred = torch.argmax(logits, dim=1).cpu().numpy() preds.extend(pred) labels_all.extend(batch_y.numpy()) print(classification_report(labels_all, preds, target_names=list(label_to_idx.keys()))) cm = confusion_matrix(labels_all, preds)

classification_report会输出每个类别的 precision、recall、F1,一眼就能看出哪些类别容易被混淆。比如“体育”和“娱乐”经常互相误判,通常是因为这两个类别的文本里有大量人名和活动名词,CNN 捕捉的组合特征高度重叠。cm是混淆矩阵,行代表真实类别,列代表预测类别,cm[i][j]表示真实类别 i 被预测成 j 的次数,适合定位高频误判对。

拿到混淆矩阵后,下一步是抽样检查被分错的样本。从测试集里挑出标签是“体育”但模型预测成“娱乐”的文本,逐条读,看是文本本身模棱两可,还是模型漏掉了关键信息。一类常见情况是:文本里同时出现了“篮球比赛”和“明星演唱会”两个实体,但模型的注意力被“演唱会”吸引,导致判错。这时可以考虑在数据预处理阶段加入领域词典,把“篮球”“足球”“赛事”等词统一映射为“体育类实体”标签,减少模型被无关词干扰的几率。如果错误样本里有大量被截断的长文本,说明max_len设短了,或者截断策略不对,改成 3.1 节提到的头尾拼接法再试一次。

还有一个很实用的验证技巧:对比融合模型和单独 textCNN、单独 BiLSTM 在同一个测试集上的表现。

def train_single(model_class, ...): # 复用训练主流程,只更换模型类 return val_acc, test_f1

用同一个数据管线和训练流程,分别跑三个模型,对比测试集上的 macro-F1。如果融合模型没有明显优势,那就要反思是不是参数没有对齐——比如 textCNN 的num_filters设了 128,BiLSTM 的hidden_size设了 128,但融合模型的这两个分支只有 64,导致融合模型容量不足,反而打不过单模型。这个对比不只是在验证模型好坏,也是在验证超参数是否公平。我在本地跑实验的时候,经常发现融合模型比单独 BiLSTM 慢一倍,但准确率只高 0.5 个百分点,如果你的任务对推理速度敏感,这可能不是一个划算的交易。

记住一个习惯:每做完一次实验,把模型配置、准确率、F1、训练时间记录到一个文本文件里,标上日期。这个项目我最初跑的时候只记了最终准确率,隔了三天再回来调参,已经记不清上一组参数是什么了,所有的对比都失去了意义。从此之后,我每跑一个实验都强迫自己写一行配置到日志里,这个习惯比任何技巧都值钱。希望这份笔记能帮你在这条路上少踩几个坑,也祝你跑通之后能在这个框架上做出自己的改进。

本文还有配套的精品资源,点击获取

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

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

立即咨询