简介这份资源面向深度学习与自然语言处理方向的初学者及进阶开发者提供一套基于TextCNN实现新闻文本分类的完整项目代码帮助读者理解卷积神经网络在文本分类任务中的落地方式。压缩包共12个文件约4.66MB以Python脚本为主包含数据加载、模型定义与预测运行等模块另附checkpoint、index、meta等TensorFlow模型权重与图结构文件以及少量编译缓存文件便于直接加载已训练模型进行推理或继续训练。目前已有460人学习下载。项目围绕卷积层提取n-gram特征、池化层降维、全连接层分类及ReLU激活等核心环节展开涵盖分词、词嵌入、序列填充、模型构建、训练评估与混淆矩阵绘制等流程读者可据此掌握新闻主题识别、情感分析等场景的建模思路并在此基础上调整参数与网络结构以提升分类性能。1. 新闻文本分类为什么还在用 TextCNN一个被低估的工程选择2014 年 TextCNN 被提出的时候做新闻文本分类的人还在跟 TF-IDF 加 SVM 死磕。十年过去Transformer 系模型几乎统治了 NLP 榜单但如果你真去翻一线内容平台、舆情系统、资讯聚合后台的代码仓库会发现 TextCNN 依然大量活着。原因不玄学新闻文本分类这个任务短文本、类别边界清晰、标注量大、推理延迟敏感TextCNN 的卷积核在 embedding 序列上滑一遍就能抓到「关键 n-gram 触发词」效果够用单卡 QPS 能压到个位数毫秒模型文件几百 KB 到几 MB部署成本极低。这篇笔记面向的是想用深度学习做新闻文本分类、但不想一上来就被 BERT 显存和推理延迟劝退的工程师。我会从数据准备、词向量、模型结构、训练参数、踩坑排查一路写到怎么验证模型真的学到了东西而不是只记住了类别关键词。读完你应该能自己跑通一个可用的新闻分类基线并且知道什么时候该换模型、什么时候该调参。2. TextCNN 做新闻分类从词向量到卷积核的选型逻辑2.1 为什么新闻标题和正文要分开处理新闻文本有个很实际的特点标题短、信息密度高、类别信号强正文长、噪声多、但能补充上下文。很多团队直接把标题和正文拼起来送进模型结果模型学到的全是标题里的类别词正文部分反而成了噪声。我一般会做两件事第一标题单独走一个 TextCNN 分支正文截断到固定长度走另一个分支最后 concat 分类第二如果算力有限只能用一个分支优先保标题正文只取前 128 个 token。这里涉及一个参数选择新闻正文截断长度。中文新闻正文平均 500 到 800 字但类别信号往往集中在前三段。我试过 64、128、256、512 四个档位128 是性价比拐点再往上 F1 提升不到 0.5 个点但显存和训练时间线性涨。如果你的类别里有「体育」「财经」这种靠实体词区分的64 也够如果是「社会」「法治」这种靠事件描述区分的建议 256 起步。2.2 词向量预训练还是从零训TextCNN 的 embedding 层是效果下限的关键。新闻分类任务里我强烈建议用预训练词向量初始化而不是随机初始化。原因很简单新闻领域有很多低频但关键的实体词随机初始化下这些词的向量在训练集里出现次数少学不好预训练词向量比如在新闻语料上训的 word2vec 或 fastText已经把这些词的语义关系编码进去了。具体做法是embedding 层加载预训练向量设置trainableTrue但给一个较小的学习率或者前几个 epoch 冻结、后面解冻。如果你们没有领域预训练词向量用公开的中文词向量也可以但要注意分词工具要和词向量训练时的分词方式一致否则词表对不上加载率可能只有 60% 到 70%。import numpy as np import torch import torch.nn as nn # 假设 vocab 是 {word: index}pretrained_embeddings 是 numpy 矩阵 vocab_size len(vocab) embed_dim 300 pretrained_embeddings np.random.randn(vocab_size, embed_dim).astype(np.float32) class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim, num_classes, kernel_sizes[2,3,4], num_filters128): super().__init__() # padding_idx0 表示 pad 不参与梯度更新 self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) # 用预训练向量初始化 self.embedding.weight.data.copy_(torch.from_numpy(pretrained_embeddings)) # 卷积层每个 kernel_size 对应一个 Conv1d self.convs nn.ModuleList([ nn.Conv1d(in_channelsembed_dim, out_channelsnum_filters, kernel_sizek) for k in kernel_sizes ]) self.dropout nn.Dropout(0.5) 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.permute(0, 2, 1) # [batch, embed_dim, seq_len] conv_outs [] for conv in self.convs: c torch.relu(conv(emb)) # [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, dim1) out self.dropout(out) return self.fc(out)这段代码里几个关键点padding_idx0让 padding 不产生梯度permute是因为 Conv1d 要求通道维在中间max_pool1d的窗口大小是动态的c.size(2)保证每个卷积核输出都池化成 1 个值。kernel_sizes 选 2、3、4 是新闻分类的常见配置对应 bigram、trigram、4-gram 触发词。num_filters 我一般从 128 起步类别多或者数据量大可以加到 256但再往上收益递减明显。2.3 卷积核尺寸和数量怎么定kernel_sizes 的选择直接决定模型能抓到多长的 n-gram。新闻分类里2 和 3 是必选的4 和 5 看情况。比如「股市」「涨停」这种 2-gram 就能区分财经「世界杯」「奥运会」也是 2-gram但「新能源汽车补贴退坡」这种需要 4-gram 甚至 5-gram。我的做法是先用 [2,3,4] 跑一版看混淆矩阵里哪些类别容易混如果混的是长实体类再加 5。num_filters 不是越多越好。我做过对比128 到 256 在 10 万条新闻上 F1 涨 0.8 个点256 到 512 只涨 0.2 个点但训练时间翻倍。所以除非你的类别超过 20 个否则 128 到 256 足够。另外每个 kernel_size 的 num_filters 可以不同比如 2-gram 给 128、3-gram 给 128、4-gram 给 64因为长 n-gram 的候选模式本来就少。3. 把新闻数据喂进 TextCNN预处理、词表与 batch 构造3.1 中文新闻分词和停用词处理中文新闻分类绕不开分词。我一般用 jieba 做基础分词但要注意两点第一新闻里的机构名、人名、地名要尽量保持完整jieba 的add_word可以补充领域词典第二停用词不要过度删除「不」「没」「未」这些否定词在法治、社会类新闻里是强信号删了反而掉点。import jieba import re def clean_text(text): # 去掉 HTML 标签和多余空白 text re.sub(r[^], , text) text re.sub(r\s, , text).strip() return text def tokenize(text, stopwordsNone): text clean_text(text) words jieba.lcut(text) if stopwords: words [w for w in words if w not in stopwords and len(w.strip()) 0] return words # 领域词典补充 jieba.add_word(新能源汽车) jieba.add_word(科创板)停用词表我建议只保留最通用的那一批的、了、在、是、和等不要用网上那种几千词的超大停用词表容易误杀。分词后统计词频把出现次数少于 3 次的词统一映射到unk否则词表太大embedding 层参数浪费。3.2 词表构建和序列截断的工程细节词表构建的顺序是先统计训练集词频按频率降序排列取前 N 个中文新闻分类一般 3 万到 5 万然后保留pad、unk两个特殊 token。注意验证集和测试集里出现但训练集没出现的词全部映射到unk不要动态扩词表否则线上推理时词表对不上。序列截断长度要统一。标题分支我一般设 32正文分支设 128。截断策略是「保头弃尾」因为新闻导语和标题通常包含最强类别信号。如果标题特别短用pad补齐到固定长度。from collections import Counter def build_vocab(tokenized_texts, max_size50000, min_freq3): counter Counter() for tokens in tokenized_texts: counter.update(tokens) # 按频率排序过滤低频词 words [w for w, c in counter.most_common() if c min_freq] words words[:max_size - 2] # 留两个位置给特殊 token vocab {pad: 0, unk: 1} for w in words: vocab[w] len(vocab) return vocab def encode(tokens, vocab, max_len): ids [vocab.get(w, vocab[unk]) for w in tokens] if len(ids) max_len: ids ids [vocab[pad]] * (max_len - len(ids)) else: ids ids[:max_len] return ids这里有个容易翻车的点pad的 id 必须是 0因为 embedding 层padding_idx0依赖这个约定。如果你把unk设成 0padding 的梯度会污染unk的向量。另外max_len 要跟模型里的卷积核尺寸匹配如果 max_len 小于最大 kernel_size卷积会报错。3.3 DataLoader 和类别不平衡处理新闻分类数据集通常类别不平衡比如财经类可能是体育类的 3 倍。直接训练会导致模型偏向多数类。我一般用两种手段一是 WeightedRandomSampler 做样本加权二是 loss 里加 class weight。两者选一个就行同时用容易过拟合。from torch.utils.data import Dataset, DataLoader, WeightedRandomSampler class NewsDataset(Dataset): def __init__(self, texts, labels, vocab, max_len): 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): tokens tokenize(self.texts[idx]) ids encode(tokens, self.vocab, self.max_len) return torch.tensor(ids), torch.tensor(self.labels[idx]) # 类别权重 from collections import Counter label_counts Counter(labels) weights [1.0 / label_counts[l] for l in labels] sampler WeightedRandomSampler(weights, num_sampleslen(weights), replacementTrue) loader DataLoader(dataset, batch_size64, samplersampler)batch_size 我一般设 64 或 128。太小梯度噪声大太大显存吃紧且泛化可能变差。如果显存够128 是新闻分类的甜点值。注意用了 sampler 之后不要再设 shuffleTrue两者冲突。4. 训练 TextCNN 新闻分类模型参数、早停与验证指标4.1 学习率、优化器和正则化配置TextCNN 参数量不大优化器用 Adam 就够了学习率 1e-3 是安全起点。如果加载了预训练词向量embedding 层的学习率可以单独设小一点比如 1e-4其他层 1e-3。PyTorch 里可以通过 param_groups 实现。optimizer torch.optim.Adam([ {params: model.embedding.parameters(), lr: 1e-4}, {params: model.convs.parameters(), lr: 1e-3}, {params: model.fc.parameters(), lr: 1e-3} ]) criterion nn.CrossEntropyLoss()正则化方面dropout 设 0.5 是 TextCNN 原论文的配置我试过 0.3 到 0.70.5 最稳。另外可以在 embedding 后加一点 word dropout即随机把一些词替换成unk相当于数据增强对小数据集有帮助。L2 正则化权重设 1e-4 到 1e-5太大欠拟合太小没效果。4.2 早停和模型选择看 loss 还是看 F1新闻分类里 accuracy 会被多数类带偏我建议用 macro F1 做模型选择指标。早停策略是验证集 macro F1 连续 3 个 epoch 不提升就停保存 F1 最高的那个 checkpoint。注意不要用测试集调参测试集只在最后跑一次。best_f1 0.0 patience 3 wait 0 for epoch in range(50): model.train() for x, y in train_loader: optimizer.zero_grad() logits model(x) loss criterion(logits, y) loss.backward() optimizer.step() model.eval() preds, golds [], [] with torch.no_grad(): for x, y in val_loader: logits model(x) preds.extend(torch.argmax(logits, dim1).cpu().numpy()) golds.extend(y.cpu().numpy()) f1 compute_macro_f1(golds, preds) if f1 best_f1: best_f1 f1 torch.save(model.state_dict(), best_model.pt) wait 0 else: wait 1 if wait patience: break这里有个血泪经验验证集 F1 波动大的时候不要只看单个 epoch可以用滑动平均。另外如果训练 loss 一直降但验证 F1 不涨大概率是过拟合加 dropout 或减 num_filters。4.3 混淆矩阵和分类报告怎么读训练完不要只看一个 F1 数字要打印 classification_report 和混淆矩阵。新闻分类常见的混淆对是「财经 vs 科技」「社会 vs 法治」「体育 vs 娱乐」。如果发现某两类互相混先看这两类的样本在词分布上有没有重叠再决定是加特征还是合并类别。from sklearn.metrics import classification_report, confusion_matrix print(classification_report(golds, preds, target_namesclass_names)) print(confusion_matrix(golds, preds))如果某个类别 recall 特别低说明模型漏检多可能是该类样本太少或者触发词不明显。可以针对性做数据增强比如同义词替换、回译但回译在新闻领域要小心容易改变事实。5. 避坑与排查TextCNN 新闻分类的 5 个真实翻车现场5.1 现象训练 loss 正常下降但验证集 F1 始终 0.3 左右原因最常见的是词表和预训练词向量对不上。比如你用 jieba 分词但词向量是用另外的分词工具训的加载率可能只有 50%大部分词都是随机初始化。另一个可能是标签编码错了比如把类别名映射成 id 时顺序乱了模型学的是乱标签。解决先打印词向量加载率低于 80% 就换词向量或统一分词工具。再检查标签映射确保训练集和验证集用的是同一个 label2id。最后看数据里有没有空文本或全 padding 的样本这些样本会让模型学不到东西。5.2 现象模型在测试集上 F1 很高但线上推理结果很差原因训练和推理的预处理不一致。比如训练时用了停用词过滤线上忘了或者训练时 max_len 是 128线上传了 512 的文本没截断再或者线上分词工具版本和训练时不一样。解决把预处理逻辑封装成一个函数训练和推理共用同一份代码。线上服务启动时先跑几条已知样本对比离线结果。如果差异大逐层检查分词、编码、截断。5.3 现象某些类别 F1 接近 0其他类别正常原因类别样本极度不平衡少数类被模型完全忽略。或者少数类的文本特征和多数类太像比如「科技」和「互联网」在新闻里经常混用。解决先用 WeightedRandomSampler 或 class weight 给少数类加权。如果还不行考虑把太相似的类别合并或者给少数类做数据增强。不要盲目加层数TextCNN 加层对少数类帮助有限。5.4 现象训练速度突然变慢GPU 利用率低原因DataLoader 的 num_workers 设成 0 了数据加载成了瓶颈。或者 max_len 设得太大卷积计算量暴涨。还有可能是 batch_size 太小GPU 一直在等数据。解决num_workers 设成 CPU 核数的 2 到 4 倍pin_memoryTrue。max_len 按前面说的 128 起步。batch_size 至少 64。如果还慢用 torch.utils.data 的 prefetch。5.5 现象模型文件几百 MB部署时加载慢原因词表太大embedding 层参数多。比如词表 10 万、embed_dim 300光 embedding 就 1.2 亿参数模型文件几百 MB。解决压缩词表低频词统一映射到unk词表控制在 3 万以内。embed_dim 可以从 300 降到 128 或 64新闻分类里 128 维和 300 维效果差不到 1 个点。还可以用 float16 保存模型文件大小减半。6. 让 TextCNN 真正可用的两个进阶技巧多尺度卷积和置信度校准TextCNN 跑通之后如果想再往上推一两个点我一般会做两件事。第一件是多尺度卷积核的加权融合。标准 TextCNN 是把不同 kernel_size 的池化结果直接 concat但不同 n-gram 对分类的贡献不一样。可以加一个注意力层让模型自己学每个尺度的权重。class AttentiveTextCNN(nn.Module): def __init__(self, vocab_size, embed_dim, num_classes, kernel_sizes[2,3,4,5], num_filters128): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.convs nn.ModuleList([ nn.Conv1d(embed_dim, num_filters, k) for k in kernel_sizes ]) # 注意力权重每个尺度一个标量 self.attn nn.Linear(num_filters * len(kernel_sizes), len(kernel_sizes)) self.fc nn.Linear(num_filters * len(kernel_sizes), num_classes) self.dropout nn.Dropout(0.5) def forward(self, x): emb self.embedding(x).permute(0, 2, 1) pooled [] for conv in self.convs: c torch.relu(conv(emb)) p torch.max_pool1d(c, c.size(2)).squeeze(2) pooled.append(p) cat torch.cat(pooled, dim1) # 计算注意力权重并加权 attn_weights torch.softmax(self.attn(cat), dim1) # [batch, num_scales] weighted torch.stack(pooled, dim1) * attn_weights.unsqueeze(2) # [batch, num_scales, num_filters] out weighted.view(weighted.size(0), -1) out self.dropout(out) return self.fc(out)这个改动增加参数很少但在类别边界模糊的数据集上通常能涨 0.5 到 1 个点。注意 attn 层的输入是 concat 后的向量输出维度等于 kernel_sizes 的数量。第二件事是置信度校准。TextCNN 输出的 softmax 概率往往偏高即模型说 90% 置信实际准确率可能只有 70%。线上如果按置信度做过滤或人工复核校准很重要。我一般用 temperature scaling在验证集上拟合一个温度参数 T推理时 logits 除以 T 再 softmax。# 温度缩放校准 class TemperatureScaler(nn.Module): def __init__(self): super().__init__() self.temperature nn.Parameter(torch.ones(1) * 1.5) def forward(self, logits): return logits / self.temperature # 在验证集上优化 temperature scaler TemperatureScaler() optimizer torch.optim.LBFGS([scaler.temperature], lr0.01, max_iter50) def eval(): optimizer.zero_grad() loss nn.CrossEntropyLoss()(scaler(val_logits), val_labels) loss.backward() return loss optimizer.step(eval)校准后置信度 0.9 的样本准确率能到 0.85 以上线上过滤阈值就好设了。这两个技巧都不复杂但能让 TextCNN 从「能跑」变成「敢用」。我自己做新闻分类项目时最大的教训是不要一上来就追新模型。TextCNN 加预训练词向量在 10 万条新闻、10 个类别的场景下F1 能到 0.92 左右推理延迟 5ms 以内。先把这条基线跑稳再考虑要不要上 BERT。很多时候数据质量和预处理的一致性比模型结构重要得多。希望帮到你。本文还有配套的精品资源点击获取