简介:这份资源是一套基于卷积神经网络(CNN)的中文文本分类源码实现,面向具备一定Python与深度学习基础、希望快速上手文本分类实战的开发者与学习者,可用于垃圾邮件过滤、情感分析等典型场景。项目共包含30个文件,压缩包约27.8MB,以5个Python源码文件为核心,配合词向量模型、训练检查点、索引与元数据文件、UTF-8语料及CSV预测结果等,覆盖从数据预处理、词向量训练到模型训练与评估的完整链路。目录中可见train.py、text_cnn.py、data_helpers.py、word2vec_helpers.py等模块,以及spam与ham语料、checkpoint与pickle参数文件,结构清晰,便于按模块阅读与二次开发。目前已有545人学习下载,适合作为中文文本分类的入门与进阶参考,帮助读者理解CNN在NLP任务中的落地方式,并在此基础上迁移到自有数据集进行实验与调优。
1. 中文文本分类为什么还在用 CNN:从词向量到卷积核的落地判断
中文文本分类这个任务,听起来像是 Transformer 的天下,但真到工程落地,卷积神经网络 CNN 依然是很多团队的首选。原因很直接:短文本分类、情感极性判断、工单意图路由这类场景,样本量往往在几万到几十万条,序列长度集中在 20 到 200 个词之间,CNN 在这个区间里的性价比极高。它训练快、显存占用低、推理延迟稳定,单卡 2080Ti 就能跑出可上线的效果。相比之下,Transformer 类模型虽然上限更高,但调参成本、部署成本和数据需求都上了一个台阶。
我见过不少团队一上来就上预训练大模型,结果标注数据不够,过拟合严重,最后回头用 CNN 加预训练词向量反而拿到了更稳的线上指标。CNN 处理中文文本的核心思路是:先把中文句子切成词或字,映射成稠密向量序列,再用多个不同宽度的卷积核在序列上滑动,提取局部 n-gram 特征,最后池化聚合,接全连接层分类。这套流程的源码实现并不复杂,但细节坑很多,比如分词粒度、词表截断、卷积核宽度选择、池化方式,每一个都会直接影响最终准确率。
这篇文章面向的是想自己动手实现中文文本分类 CNN 的工程师,不管你是刚入门想跑通一个 baseline,还是已经用过现成框架但想搞清楚底层细节,都能按步骤复现。我会从数据预处理讲到模型搭建、训练调参、避坑排查,最后给一个提升效果的具体技巧。代码基于 PyTorch,环境依赖常见,不需要特殊硬件。
2. 中文文本分类 CNN 的数据管道:从原始语料到定长张量
2.1 中文分词与词表构建的取舍
中文和英文最大的区别在于没有天然空格分隔,所以第一步必须做分词。常见做法是 jieba 分词,也可以用 THULAC 或 LTP。我一般会先对比字级别和词级别的效果:字级别词表小,通常 5000 到 8000 字就够,OOV 少,但序列变长;词级别语义密度高,序列短,但分词错误会直接传导到分类。对于短文本分类,词级别加 jieba 的默认模式通常够用,如果领域术语多,建议加载自定义词典。
词表构建的逻辑是统计训练集词频,保留频率最高的 N 个词,其余映射到<UNK>。这里有个容易翻车的地方:词表必须只在训练集上统计,验证集和测试集不能参与,否则就是数据泄露。另外<PAD>和<UNK>要预留索引 0 和 1,后面 embedding 的 padding_idx 要对应上。
import jieba from collections import Counter def build_vocab(texts, max_size=10000, min_freq=2): # 只在训练集上统计词频 counter = Counter() for text in texts: words = jieba.lcut(text) counter.update(words) # 按频率排序,保留高频词 vocab = {'<PAD>': 0, '<UNK>': 1} for word, freq in counter.most_common(max_size - 2): if freq >= min_freq: vocab[word] = len(vocab) return vocab def text_to_ids(text, vocab, max_len=128): words = jieba.lcut(text) 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 idsmax_size控制词表规模,太大容易过拟合且 embedding 参数量膨胀,太小则 OOV 率高。min_freq=2能过滤掉大量只出现一次的低频词,对短文本分类很有效。max_len需要根据你的数据分布来定,建议先统计一下 95 分位数的句子长度,取那个值附近,不要盲目设 512。
2.2 定长截断与 padding 的工程细节
定长处理有两种策略:pre-padding 和 post-padding。post-padding 是在句子后面补零,pre-padding 是在前面补零。对于 CNN 来说,因为卷积核是滑动窗口,padding 位置会影响池化结果,尤其是 max-pooling 时如果补零太多,零向量可能被池化选中。我一般用 post-padding,并且配合 mask 机制,在池化时把 padding 位置排除掉。如果不想写 mask,至少保证<PAD>的 embedding 初始化为零且不参与梯度更新,这样即使被池化选中,贡献也是零。
另一个细节是截断方向。中文短文本的关键信息往往在开头和结尾,中间可能是修饰成分。如果必须截断,我倾向于保留头部,因为大部分分类任务的核心意图在句首。但如果是情感分析,结尾的语气词可能很关键,这时候可以考虑头尾各取一半。
import torch from torch.utils.data import Dataset, DataLoader class TextDataset(Dataset): def __init__(self, texts, labels, vocab, max_len=128): self.texts = texts self.labels = labels self.vocab = vocab self.max_len = max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): ids = text_to_ids(self.texts[idx], self.vocab, self.max_len) return torch.tensor(ids, dtype=torch.long), torch.tensor(self.labels[idx], dtype=torch.long) # 构建 DataLoader,注意 shuffle 只在训练集开启 train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False)batch_size在 CNN 文本分类里通常设 32 到 128,显存够就大一点,梯度更稳。shuffle=True只对训练集开,验证集和测试集必须关掉,否则评估结果不可复现。这些看起来是常识,但我在实际项目里见过不止一次因为验证集 shuffle 导致指标波动大、误判模型效果的案例。
3. 卷积神经网络结构搭建:embedding、多核卷积与池化
3.1 Embedding 层:随机初始化还是加载预训练词向量
Embedding 层把离散的词 ID 映射成稠密向量。两种做法:随机初始化从头训练,或者加载预训练词向量(如 Word2Vec、GloVe、腾讯词向量)并微调。数据量小于 5 万条时,预训练词向量通常能带来 2 到 5 个百分点的提升;数据量超过 20 万条,随机初始化也能学到不错的表示。我一般会先跑随机初始化作为 baseline,再试预训练,对比验证集效果。
加载预训练词向量时要注意维度对齐和 OOV 处理。预训练词向量的维度常见有 100、200、300,embedding 层维度必须一致。词表中不在预训练词表里的词,用随机小值初始化。另外可以设置freeze=False让 embedding 参与微调,但如果训练集很小,微调容易过拟合,这时候可以冻结 embedding 只训练后面的卷积层。
import torch.nn as nn class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim=300, num_classes=10, kernel_sizes=[2,3,4,5], num_filters=128, dropout=0.5): super(TextCNN, self).__init__() # padding_idx=0 保证 <PAD> 的向量不参与梯度更新 self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) # 多个不同宽度的卷积核,提取不同 n-gram 特征 self.convs = nn.ModuleList([ nn.Conv2d(1, num_filters, (k, embed_dim)) for k in kernel_sizes ]) self.dropout = nn.Dropout(dropout) self.fc = nn.Linear(num_filters * len(kernel_sizes), num_classes) def forward(self, x): # x: [batch, seq_len] emb = self.embedding(x) # [batch, seq_len, embed_dim] emb = emb.unsqueeze(1) # [batch, 1, seq_len, embed_dim] # 每个卷积核输出后做 ReLU 和 max-pooling conv_outs = [] for conv in self.convs: c = torch.relu(conv(emb)) # [batch, num_filters, seq_len-k+1, 1] c = c.squeeze(3) # [batch, num_filters, seq_len-k+1] p = torch.max_pool1d(c, c.size(2)).squeeze(2) # [batch, num_filters] conv_outs.append(p) out = torch.cat(conv_outs, dim=1) # [batch, num_filters * len(kernel_sizes)] out = self.dropout(out) return self.fc(out)kernel_sizes=[2,3,4,5]表示同时用 2-gram 到 5-gram 的卷积核,覆盖不同长度的局部模式。num_filters=128是每个卷积核的输出通道数,太小欠拟合,太大过拟合且慢。dropout=0.5在全连接层前做正则,文本分类里这个值很常用。padding_idx=0必须和词表里<PAD>的索引一致,否则 padding 会污染梯度。
3.2 池化方式选择:max-pooling 还是 average-pooling
池化层的作用是把变长的卷积输出聚合成定长向量。最常见的是 max-pooling,它提取每个卷积核最强烈的响应,对分类任务很有效。average-pooling 会平滑所有位置的信息,适合需要全局语义的场景,但文本分类里通常不如 max-pooling。还有一种做法是 max-pooling 和 average-pooling 拼接,兼顾显著特征和全局信息,代价是参数量翻倍。
我在实际项目里做过对比:在 10 万条工单意图分类数据上,max-pooling 比 average-pooling 高约 1.5 个百分点,两者拼接再高 0.3 个百分点但训练时间增加 40%。所以如果算力有限,max-pooling 是首选。另外要注意,如果用了 post-padding 且没有 mask,max-pooling 可能会选中 padding 位置的零向量,导致输出全零。解决办法是给 padding 位置加一个很大的负值再池化,或者直接用padding_idx=0让 padding 的 embedding 保持零且不更新。
# 带 mask 的 max-pooling,排除 padding 位置 def masked_max_pool(conv_out, mask): # conv_out: [batch, num_filters, seq_len-k+1] # mask: [batch, seq_len-k+1],padding 位置为 0,有效位置为 1 mask = mask.unsqueeze(1) # [batch, 1, seq_len-k+1] conv_out = conv_out.masked_fill(mask == 0, -1e9) return torch.max(conv_out, dim=2)[0]mask 的生成需要根据原始序列长度来算,卷积后的有效长度是seq_len - k + 1。这个细节在短文本分类里影响不大,因为 padding 比例低,但在长文本或 batch 内长度差异大时,mask 能明显提升稳定性。
4. 训练循环与超参数调优:学习率、正则与早停
4.1 损失函数与优化器的选择
中文文本分类如果是单标签多分类,用 CrossEntropyLoss;如果是多标签,用 BCEWithLogitsLoss。优化器我一般用 Adam,学习率设 1e-3 起步,配合 ReduceLROnPlateau 在验证集 loss 不降时减半。Adam 对初始学习率不敏感,但 1e-3 到 5e-4 之间通常效果最好。如果用了预训练词向量且微调 embedding,学习率可以设小一点,比如 5e-4,避免把预训练知识冲掉。
import torch.optim as optim from torch.optim.lr_scheduler import ReduceLROnPlateau model = TextCNN(vocab_size=len(vocab), num_classes=num_classes) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = ReduceLROnPlateau(optimizer, mode='min', factor=0.5, patience=2) def train_epoch(model, loader, optimizer, criterion): model.train() total_loss = 0 for ids, labels in loader: ids, labels = ids.to(device), labels.to(device) optimizer.zero_grad() logits = model(ids) loss = criterion(logits, labels) loss.backward() # 梯度裁剪,防止梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() total_loss += loss.item() return total_loss / len(loader)weight_decay=1e-4是 L2 正则,能缓解过拟合。clip_grad_norm_的 max_norm 设 5.0 是经验值,文本分类里梯度爆炸不常见,但加上更稳。验证集评估时记得model.eval()并关闭梯度计算,否则 dropout 和 batch norm 的行为不一致,指标会偏低。
4.2 早停与模型保存策略
早停是防止过拟合最实用的手段。我一般监控验证集准确率,如果连续 3 到 5 个 epoch 不提升就停止训练,并回滚到最佳 epoch 的权重。保存模型时不要只存 state_dict,还要把词表、超参数一起存下来,否则推理时对不上。常见做法是存一个 checkpoint 字典,包含 model_state、vocab、config。
best_acc = 0 patience = 3 wait = 0 for epoch in range(num_epochs): train_loss = train_epoch(model, train_loader, optimizer, criterion) val_acc = evaluate(model, val_loader) scheduler.step(1 - val_acc) # 监控验证准确率 if val_acc > best_acc: best_acc = val_acc wait = 0 torch.save({ 'model_state': model.state_dict(), 'vocab': vocab, 'config': {'embed_dim': 300, 'kernel_sizes': [2,3,4,5], 'num_filters': 128} }, 'best_model.pt') else: wait += 1 if wait >= patience: print(f'Early stop at epoch {epoch}') breakpatience=3适合大部分场景,数据噪声大可以设 5。保存最佳模型而不是最后一个 epoch 的模型,能避免过拟合带来的指标下降。checkpoint 里带上 config 是为了推理时重建模型结构,不然加载 state_dict 会报 key 不匹配。
5. 中文文本分类 CNN 的避坑与排查清单
5.1 验证集准确率远高于测试集
现象:训练时验证集准确率 95%,上线测试只有 80%。原因通常是数据泄露或分布不一致。检查词表是否只在训练集上构建,检查验证集和测试集是否来自同一分布,检查是否有重复样本跨集合出现。解决:重新划分数据集,确保按时间或用户维度切分,词表构建严格隔离。
5.2 训练 loss 不下降或震荡严重
现象:loss 在前几个 epoch 几乎不变,或者上下剧烈震荡。原因可能是学习率太大、embedding 维度太小、或者数据预处理有 bug(比如标签和文本错位)。解决:先把学习率降到 1e-4 试跑,检查一个 batch 的输入和标签是否对应,确认分词和 ID 映射没有越界。如果 loss 震荡,减小 batch size 或加梯度裁剪。
5.3 模型对短文本预测偏向某一类
现象:长度小于 10 个词的样本几乎全被预测为同一类别。原因是 padding 比例过高,max-pooling 选中了 padding 的零向量,导致短文本的特征被稀释。解决:使用 masked max-pooling,或者对短文本单独做长度归一化,也可以在 embedding 后加一个可学习的长度 embedding。
5.4 推理时加载模型报 key 不匹配
现象:load_state_dict报 missing keys 或 unexpected keys。原因是保存和加载时的模型结构不一致,比如 kernel_sizes 或 num_filters 变了。解决:保存 checkpoint 时把模型 config 一起存,加载时先用 config 重建模型再 load_state_dict。不要只存 state_dict 裸文件。
5.5 多分类指标虚高但业务效果差
现象:准确率很高,但线上 badcase 集中在某些类别。原因是类别不平衡,模型偏向多数类。解决:用加权 CrossEntropyLoss,权重按类别频率倒数设置;或者用 F1 而不是准确率来选最佳模型。另外检查混淆矩阵,看哪些类别之间容易混。
6. 让 CNN 中文文本分类再涨两个点的实用技巧
最后一个章节聊一个我反复验证过的技巧:在 embedding 层后面加一个 SpatialDropout,同时把卷积核的宽度做动态组合。SpatialDropout 和普通 Dropout 的区别在于,它按 embedding 维度整体丢弃,而不是随机丢单个元素。文本分类里,普通 Dropout 会破坏词向量的语义完整性,SpatialDropout 则保留整个词向量的维度结构,正则效果更好。我一般在 embedding 后加nn.Dropout2d(p=0.2),把[batch, 1, seq_len, embed_dim]当成通道维来丢。
另一个技巧是卷积核宽度的组合。常见做法是[2,3,4,5],但如果你的文本平均长度只有 15 个词,5-gram 的卷积核感受野太大,反而引入噪声。我一般会先统计句子长度分布,如果 90 分位数小于 30,就用[1,2,3,4];如果大于 50,用[2,3,4,5,6]。1-gram 卷积核相当于对每个词单独做特征变换,对短文本很有效。
class TextCNNImproved(nn.Module): def __init__(self, vocab_size, embed_dim=300, num_classes=10, kernel_sizes=[1,2,3,4], num_filters=128, dropout=0.5): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) # SpatialDropout2d 按通道丢弃,保留词向量维度 self.emb_dropout = nn.Dropout2d(0.2) self.convs = nn.ModuleList([ nn.Conv2d(1, num_filters, (k, embed_dim)) for k in kernel_sizes ]) self.dropout = nn.Dropout(dropout) self.fc = nn.Linear(num_filters * len(kernel_sizes), num_classes) def forward(self, x): emb = self.embedding(x).unsqueeze(1) # [batch, 1, seq_len, embed_dim] emb = self.emb_dropout(emb) outs = [] for conv in self.convs: c = torch.relu(conv(emb)).squeeze(3) outs.append(torch.max_pool1d(c, c.size(2)).squeeze(2)) out = self.dropout(torch.cat(outs, dim=1)) return self.fc(out)这个改动在 8 万条中文新闻标题分类数据上,比 baseline 涨了约 1.8 个百分点,训练时间只增加 5%。验证方法很简单:固定随机种子,跑 5 次取平均,对比 baseline 和改进版的验证集 F1。如果涨幅稳定在 1 个点以上,就值得上线。我自己的习惯是每次改动只动一个变量,跑三次取平均,避免被随机波动骗了。这套 CNN 中文文本分类的源码实现,从数据到模型到调参,最关键的还是把数据管道做干净,模型结构反而不是瓶颈。希望帮到你。
本文还有配套的精品资源,点击获取