
简介基于TextCNN与PyTorch实现的中文文本分类及情感分析完整项目附带可直接使用的定制数据集适合自然语言处理初学者以及希望快速落地文本分类功能的技术人员。压缩包体积仅为5.53兆共包含9个文件其中有4个脚本程序分别负责模型搭建、训练流程、数据加载与主入口另有4份表格数据文件训练集、验证集、测试集及补充数据和1份说明文档整体结构清晰方便按顺序阅读与调试。项目从TextCNN原理讲起详细展示了词嵌入、多宽度卷积核、激活函数、全局最大池化及全连接层的实现并覆盖中文分词、词汇表构建、定长序列处理、损失函数与优化器选择等关键步骤附带的数据集支持直接运行训练与评估可以自行查看准确率、召回率等指标也能为课程设计、毕业设计或实际情感分析场景提供可靠的参考。该资源已有859人学习下载学习过程中可以完整掌握文本分类链路并进一步迁移到其他短文本任务中。1. 为什么中文情感分析还在用 TextCNNPytorch 里的短文本卷积方案一条带情绪的短评往往只有几十个字外卖迟了、包装破损、客服话术冷硬。这类中文情感分析任务输入短、标签少、标注样本有限TextCNN 是 Pytorch 里实现成本最低的 baseline——不需要装额外的 Transformer 库不依赖预训练权重只在 nn.Embedding、nn.Conv1d、全局池化三层里打转CPU 上十几分钟就能跑完一轮训练。对 5 年以上经验的工程师来说它的价值不在 SOTA而在稳定新接一个领域先拿 TextCNN 把数据、词表、标签一致性验证一遍再把预算留给 BERT。下文用 Pytorch 从模型定义、中文数据预处理到训练与预测完整走一遍。2. 搭建 TextCNN 模型Pytorch 中 Embedding、Conv1d 与池化的组合2.1 为什么是 nn.Conv1d 而不是 nn.Conv2d文本分类里卷积核的作用是抓 n-gram。中文分词后“服务差”连续三个词同时出现往往直接决定一条评论的极性。卷积核长度为 k等价于在词序列上滑窗统计 k-gram 局部特征。TextCNN 原文用二维卷积Pytorch 里实现时一维卷积更直观。先明确维度Embedding 输出的形状是 [batch_size, seq_len, embed_size]这是 NLP 里最常见的“序列”顺序。而 nn.Conv1d 期待的形状是 [batch_size, channels, length]通道维要放在第二维。所以做完查表后必须先 transpose(1, 2)把 embed_size 当成通道数seq_len 当成扫描长度再送入卷积。下面这张表把每一步的张量形状变化列出来写代码时照着核对即可操作输入 shape输出 shape作用Embedding[B, T][B, T, D]token id 查表transpose[B, T, D][B, D, T]把词向量维度变成通道Conv1d(k)[B, D, T][B, F, T-k1]抽取 k-gram 特征ReLU max_pool1d[B, F, T-k1][B, F]每个通道取最大值concat每个核 [B, F][B, m*F]合并不同粒度特征FC[B, m*F][B, C]输出类别 logits其中 B 是 batchT 是句子长度D 是 embed_sizeF 是 num_filtersk 是卷积核大小m 是 kernel_sizes 的个数。如果用 nn.Conv2d(1, F, (k, D))等于把整行 embedding 当二维矩阵扫描卷积核的第二个维度必须等于 embed_size参数量被词向量的宽度撑大而且需要在 Pytorch 里引入额外的维度和 unfold 概念调试成本高。一维卷积把 D 放在通道上每个卷积核实际大小是 D*k理解成“对连续 k 个词的向量加权求和”逻辑更贴近 n-gram 语义。注意torch.nn.Conv1d 的第二个参数是输出通道数也就是每一种卷积核产出的特征图数量。TextCNN 里通常写nn.Conv1d(embed_size, num_filters, kernel_size)这里的 embed_size 是通道含义不是卷积核的滑动步长。2.2 TextCNN 正向传播的四个步骤模型前向过程可以拆成四步先对输入 token id 做 embedding 查表得到稠密向量序列然后调换维度把词向量通道放到第二维再用多个不同长度的卷积核并行抽取局部特征并做全局最大池化最后把所有卷积核的结果拼起来接一个全连接分类头。Pytorch 里完整的模型定义如下这也是大多数复现项目里 TextCNN 的核心写法import torch import torch.nn as nn import torch.nn.functional as F class TextCNN(nn.Module): def __init__(self, vocab_size, embed_size, num_classes, kernel_sizes(2, 3, 4), num_filters100, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, embed_size, padding_idx0) self.convs nn.ModuleList([ nn.Conv1d(embed_size, num_filters, k, padding0) for k in kernel_sizes ]) self.dropout nn.Dropout(dropout) self.fc nn.Linear(len(kernel_sizes) * num_filters, num_classes) def forward(self, x): emb self.embedding(x) # [B, T, D] emb emb.transpose(1, 2) # [B, D, T] features [] for conv in self.convs: c F.relu(conv(emb)) # [B, F, T-k1] pooled F.max_pool1d(c, kernel_sizec.size(2)).squeeze(2) features.append(pooled) # [B, F] out torch.cat(features, dim1) # [B, m*F] out self.dropout(out) logits self.fc(out) # [B, num_classes] return logits几个参数值得单独解释。kernel_sizes 是卷积核长度列表对应捕获不同范围的 n-gram。中文评论呈“局部强相关”分布二字词“难吃”“很棒”由 kernel_size2 抓三字短语“太差了”“很满意”交给长度为 3 的核更长的转折句“但是服务还行”需要 kernel_size4 或 5一般取 (2,3,4) 或 (3,4,5) 即可。padding0 意味着输出长度是 T-k1短文本上不需要刻意补边如果你偏好输出长度不变可以用 paddingk//2但池化层是全局最大池化对最后结果影响很小反而多算了边界值。F.max_pool1d(c, kernel_sizec.size(2)) 是“对整条特征图求最大值”也就是 max-over-time pooling在序列长度方向压榨每个通道最强的模式。因为后面接全局池化顶层卷积输出的宽度是多少无所谓输入句子长短不一致也能被消掉。2.3 Embedding 层与预训练词向量的选择上面代码里 nn.Embedding(vocab_size, embed_size, padding_idx0) 是随机初始化。token id 为 0 的 位置不会被更新梯度不会传到 embedding 矩阵的第 0 行padding 对最终特征没有贡献。数据量相对大大于几万条时随机初始化足够训练中会学到领域相关的词向量。数据量小或者专业领域词多时常见做法是用 word2vec 或腾讯词向量预训练矩阵初始化。Pytorch 的写法是embedding nn.Embedding.from_pretrained( torch.tensor(pretrained_matrix, dtypetorch.float32), padding_idx0, freezeFalse )pretrained_matrix 的尺寸是 [vocab_size, embed_size]每一行对应词表里的一个词顺序必须和 build_vocab 时的索引一致。freezeFalse 表示训练中继续微调词向量通常比 freezeTrue 效果好但显存占用更高。如果短文本只有两三千样本freezeTrue 相当于让卷积层在固定词向量上做特征筛选结果往往不稳定建议默认微调并配合较高 dropout。预训练词表里没有的词会落到 对应索引 1。加载前检查矩阵第 0 行和第 1 行是否为全 0避免把未初始化行当成有效特征。3. 中文情感分析数据集处理从原始文本到词汇表与 batch3.1 数据集选择ChnSentiCorp 与其他常用中文语料公开的中文情感分析语料里最常见的是 ChnSentiCorp酒店评论数据正向和负向各几千条两类分布大致均衡文件组织方式通常是 pos/neg 两个目录一条评论一行。它的好处是规模不大、标签干净适合做模型调试而不是刷分。如果想升级到多分类THUCNews 是新闻标题十多个类别一类一个目录。它更常用于文本分类 benchmark复现 TextCNN 时也常以它为准。另一个常见来源是电商评论京东、淘宝的用户评价清洗难度大标签噪声高但更接近真实场景也适合做“基于情感分析的电商商品评论”这类工程探索。自行采集时建议保存原始文本、标注时间、来源渠道便于后续复检。数据集领域标签类型典型规模组织格式ChnSentiCorp酒店评论正/负二分类约 1 万条pos/neg 目录THUCNews新闻标题10 类约 7 万条按类别分目录电商评论商品评价正/负/中自定csv 两列选型原则情感分析演示优先 ChnSentiCorp做对比实验用 THUCNews做产品原型建议从业务方收集近三个月的评论以周为单位切训练和验证避免时间漂移。3.2 jieba 分词、清洗与词表构建中文不像英文天然按空格分词。TextCNN 的卷积核需要离散的 token 边界先对整句分词再建立词表是主流做法。字符级输入更省事但 n-gram 信息会被拆散比如“好看”拆成“好”“看”就丢了“好看”的语义整体性。写清洗函数时训练和预测必须共用同一份逻辑否则线上文本多了 URL、表情、重复标点分词结果会和训练时完全不同。import re import jieba from collections import Counter def clean_text(text: str) - str: text text.lower() text re.sub(rhttps?://\S, , text) text re.sub(r\d, 0, text) # 数字归一化 text re.sub(r\s, , text).strip() return text分词后构建词表。这里用 Counter 按词频统计min_freq 可以直接过滤拼写错误、生僻人名等低频噪声。def build_vocab(texts: list[str], min_freq: int 1) - dict[str, int]: counter Counter() for text in texts: words jieba.lcut(clean_text(text)) counter.update(words) vocab {w: i 2 for i, (w, c) in enumerate(counter.items()) if c min_freq} vocab[pad] 0 vocab[unk] 1 return vocab词表里 0 固定是 、1 固定是 与 TextCNN 里 embedding 的 padding_idx0 对齐。embedding 矩阵的行数等于 len(vocab)index 0 是 paddingindex 1 是 OOV后续才是真实词。这样设计可以保证新建词表、加载模型时不出现索引错位。3.3 Dataset 与 collate_fn 动态 paddingPytorch 处理变长文本序列的常见做法是在 Dataset 里只做分词和转 id真正的 padding 放到 collate_fn 中按 batch 内最大长度补零。这样短句子不会浪费计算评论数据上训练速度提升明显。from torch.utils.data import Dataset class SentimentDataset(Dataset): def __init__(self, texts, labels, vocab, max_len128): 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): words jieba.lcut(clean_text(self.texts[idx]))[:self.max_len] ids [self.vocab.get(w, self.vocab[unk]) for w in words] return ids, self.labels[idx]collate_fn 把不等长的 id 列表整理成矩形补零在右侧import torch from torch.utils.data import DataLoader def collate_fn(batch): ids_list, labels zip(*batch) max_len max(len(ids) for ids in ids_list) padded torch.zeros(len(ids_list), max_len, dtypetorch.long) for i, ids in enumerate(ids_list): padded[i, :len(ids)] torch.tensor(ids, dtypetorch.long) return padded, torch.tensor(labels, dtypetorch.long) loader DataLoader(dataset, batch_size64, shuffleTrue, collate_fncollate_fn)max_len 设 128 对酒店评论足够覆盖大部分样本对新闻标题也是安全的。超长部分直接截断如果验证集上发现这类样本大多数被分错再把截断策略从前截改为后截或者提高到 256。注意 collate_fn 里 padding 值必须等于 0因为词表的 索引是 0同时 TextCNN 的 embedding padding_idx 也是 0三处保持一致才能确保 padding 向量零贡献。4. 训练循环与参数调优把中文情感分类准确率稳定在 90% 上下4.1 损失、优化器与学习率策略情感分析是分类任务直接用 nn.CrossEntropyLoss它内部会先做 log_softmax再把真实标签和 logits 做负对数似然。所以模型最后一层输出 logits不需要在 forward 里手动接 softmax。优化器选 Adam初始学习率 1e-3 是常见起点。训练中发现 loss 到某个值后反复震荡把学习率降到 5e-4。更省事的方法是用 ReduceLROnPlateau让验证 loss 两三个 epoch 不下降时自动减半。optimizer torch.optim.Adam(model.parameters(), lr1e-3) scheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemin, factor0.5, patience2 ) criterion nn.CrossEntropyLoss()scheduler 的 step 需要传入验证指标每个 epoch 验证结束后调用一次不要在 batch 循环里调用。4.2 train / evaluate 函数与 early stopping训练函数里做一次完整的前向和反向def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, total_correct, total 0, 0, 0 for input_ids, labels in loader: input_ids input_ids.to(device) labels labels.to(device) logits model(input_ids) loss criterion(logits, labels) optimizer.zero_grad() loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() total_loss loss.item() * input_ids.size(0) total_correct (logits.argmax(dim1) labels).sum().item() total input_ids.size(0) return total_loss / total, total_correct / totalclip_grad_norm_ 防止梯度爆炸。TextCNN 网络浅一般不会爆炸但小 batch 训练时加上对稳定性没有坏处。验证阶段不需要反向传播统一包在 torch.no_grad() 里torch.no_grad() def evaluate(model, loader, device): model.eval() correct, total 0, 0 for input_ids, labels in loader: input_ids input_ids.to(device) labels labels.to(device) logits model(input_ids) correct (logits.argmax(dim1) labels).sum().item() total labels.size(0) return correct / total早停条件直接看验证准确率。保存 best model 后不更新超过 patience 个 epoch 没有提升就 break避免在小数据集上过拟合到最后几个 epoch。best_acc 0 patience 3 bad_epochs 0 for epoch in range(30): loss, acc train_one_epoch(...) val_acc evaluate(...) print(fepoch {epoch}: loss{loss:.4f}, acc{acc:.4f}, val_acc{val_acc:.4f}) if val_acc best_acc: best_acc val_acc bad_epochs 0 torch.save(model.state_dict(), best_textcnn.pt) else: bad_epochs 1 if bad_epochs patience: break4.3 必调的 6 个超参范围和判定信号TextCNN 调参目标不是把训练集压到 100%而是保证验证集表现稳定。常用超参范围如下超参推荐范围直观影响embed_size100–300维度不够词义表达不充分过大在小数据上过拟合kernel_sizes(2,3,4) 或 (3,4,5)决定覆盖的 n-gram 长度范围num_filters100–256特征数量提升容量也提升显存和过拟合风险max_len64–128短评论 64 够用酒店评论建议 128batch_size32–128小 batch 更新频繁、噪声大但泛化不一定差dropout0.3–0.70.5 是默认平衡点判定信号比对训练损失下降但验证准确率不动过拟合。先加大 dropout再减小 num_filters或者加大 min_freq 过滤低频词。训练和验证的 loss 都徘徊不降学习率过大或过小。过大表现为震荡过小表现为缓慢爬行。把 lr 分别设 1e-4、5e-4、1e-3 各跑一个 epoch 看斜率。验证曲线抖动厉害batch_size 太小数据分布不均。先确认 shuffle再考虑增大 batch。某一个类别几乎全错先检查标签映射尤其是正负样本是否装反了再检查词表里 占比是否过高。这套数据流程和训练脚本在 ChnSentiCorp 上跑进 90% 的验证集准确率是常见结果特定领域甚至能到 95% 附近。不要拿它和 BERT 比绝对分数要比的是“从拿到数据到跑通”的时间差这才是 TextCNN 被保留在 Pytorch 分类项目里的理由。5. 预测与部署边界用训练好的 TextCNN 判断新评论的情感倾向5.1 保存 checkpoint 时把 vocab 一起存只存 model.state_dict() 的坏处是模型参数离不开词表。换机器或换环境后一旦加载了另一个版本的 vocab所有 token 索引错位embedding 行号对不上查询模型输出就废了。正确的做法是把词表和其他超参一起打包torch.save({ model_state_dict: model.state_dict(), vocab: vocab, embed_size: embed_size, kernel_sizes: kernel_sizes, num_filters: num_filters, max_len: max_len, num_classes: 2, }, textcnn_chnsenticorp.pt)加载时用 torch.load 取回字典再重新构造模型vocab_size 用 len(vocab) 计算。5.2 单条预测函数和三个高频事故预测函数要把文本走一遍和训练时完全相同的 clean_text、jieba、pad 流程def predict(text, model, vocab, device, max_len128): model.eval() words jieba.lcut(clean_text(text))[:max_len] ids [vocab.get(w, vocab[unk]) for w in words] if len(ids) max_len: ids ids [vocab[pad]] * (max_len - len(ids)) input_ids torch.tensor([ids], dtypetorch.long).to(device) with torch.no_grad(): logits model(input_ids) prob torch.softmax(logits, dim1)[0] return {pos: prob[1].item(), neg: prob[0].item()}三个事故第一个是词表对齐。训练时没保存 vocab推理端只能临时造一个OOV 词全部落到 长文本几乎等同全 unk预测结果必然失真。把 vocab pickle 进同一个 checkpoint 就能避开。第二个是文本清洗不一致。训练时替换了 URL、去掉了多余空格线上预测直接把原始字符串送去分词分词边界完全不同局部 n-gram 特征失真。把 clean_text 放到独立公共模块训练端和推理端 import 同一个函数。第三个是长度截断方向。对评论来说“但是”“没想到”这类转折词后面的内容往往是真实态度。只保留前 128 个字遇到超长好评后半段反转就判不准。常见做法是保留后 64 字或者前段和后段各留一部分动手前先统计验证集里超过 max_len 的样本比例再决定截断策略而不是一味调大 max_len。5.3 看置信度而非只看 argmaxsoftmax 输出的概率在二分类里经常接近 0.9 以上这时不要只取最大索引。低置信度样本大概率是标注噪声或 OOV 集中造成的把它们单独攒起来做人工复核比继续调模型更划算。把预测函数返回概率而不是标签日志里同时记录文本、概率、真值和时间戳积累一周后回看错误分析再决定是补词典、换分词器还是引入 BERT 做二阶段兜底。提示线上系统的 TextCNN 与训练时的 Pytorch 版本尽量保持一致。升级 torch 后先跑一遍同样的 checkpoint 和同样的验证集如果结果与之前有差异先检查 embedding 的 padding_idx 和 Conv1d 在 CPU/GPU 上的实现差异这类问题出现频率很低但排查起来非常隐蔽。本文还有配套的精品资源点击获取