简介面向自然语言处理入门开发者这一项目基于TextCNN实现中文文本分类与情感分析涵盖PyTorch模型搭建、数据集处理、训练评估与预测全流程。以电影评论、社交媒体等中文语料为训练数据通过嵌入层、卷积层、池化层与全连接层组合可有效提取n-gram特征并完成情感判别适合NLP学习者、算法工程师及竞赛选手参考。资源包共9个文件包含4个Python脚本模型构建、数据加载、训练与主程序、3个TSV数据集文件、1个说明文档及1个词汇表CSV压缩包大小仅5.53MB结构清晰便于按流程学习与二次开发。已有859人学习下载项目在PyTorch框架下完整可运行读者可从中掌握TextCNN原理、中文文本预处理方法及模型调优思路并直接使用附带数据集进行实践验证。1. 中文情感分类为什么不用BERT先看TextCNN的性价比拿一批中文评论做情感二分类时很多人第一反应是上BERT。如果公司有现成的GPU集群这没问题但如果只是单卡训练、甚至用CPU跑基线微调一个BERT-base要占掉8GB以上显存训练一轮以小时计而TextCNN在同样的数据上几十秒就能跑完一轮准确率往往只低一到两个点。对于短文本、标签明确的场景TextCNN至今仍是工业界最常用的强基线。这个项目正是以此为切入点用PyTorch从零实现TextCNN附带可直接训练的中文情感分析数据集适合想搞懂卷积网络如何作用在文本上的人也适合要做舆情监控、评论打标、意图识别快速验证的读者。2. TextCNN建模要点与PyTorch张量流转2.1 卷积核宽度与n-gram特征的对应关系TextCNN的思想源自图像CNN但把「像素邻域」换成了「词的局部窗口」。卷积核在文本上滑动时窗口内覆盖的是连续若干词因此一个宽度为filter_size的卷积核本质上就是在抽取一个filter_size-gram的局部特征。比如宽度为2的卷积核捕获二元词组搭配宽度为3捕获三元局部语义这种多尺度并行比单一窗口更能覆盖中文里灵活的词组表达。实际项目中通常同时使用3种宽度让它们相互补充。下表是常见配置方式卷积核宽度捕获特征典型数值2bigram局部词搭配23trigram常见短语344-gram更长片段4每种宽度的核数量每个尺度提取的特征通道数100256核数太少模型表达能力不足太大会让全连接层的参数急剧膨胀。文本分类这种任务里每种宽度100个核已经能跑出不错的效果先把模型跑通再加到256观察验证集增益是否值得增加训练时间。2.2 Embedding层到卷积层的维度变换2.2.1 输入张量的shape变化PyTorch里文本输入是一个整数序列shape为[batch_size, seq_len]每个元素是词在词表中的索引。经过nn.Embedding后变成[batch_size, seq_len, embed_dim]此时每个词从整数索引变成了稠密向量。但nn.Conv2d期望的输入是[batch_size, in_channels, height, width]所以要把embedding输出扩充一个通道维度变成[batch_size, 1, seq_len, embed_dim]。这里height相当于句子长度width相当于词向量维度。2.2.2 卷积核参数与池化拼接原论文用Conv2d实现卷积核的shape是(out_channels, in_channels, filter_size, embed_dim)宽度直接等于词向量维度意味着卷积核在embedding维度上不做滑动只在句子长度方向滑动。这样每个卷积核输出一个seq_len - filter_size 1长的向量再对长度方向做最大池化取其中最大值代表该窗口在整个句子中最强的响应。三个宽度的卷积核各输出一个池化值拼接后就是一个长度为3 * num_filters的特征向量最后过一个线性层输出类别数。下面是按这个思路实现的模型代码可以直接放进model.pyimport torch import torch.nn as nn class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim, num_filters, filter_sizes, num_classes, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.convs nn.ModuleList([ nn.Conv2d(1, num_filters, (fsz, embed_dim)) for fsz in filter_sizes ]) self.dropout nn.Dropout(dropout) self.fc nn.Linear(len(filter_sizes) * num_filters, num_classes) def forward(self, x): # x: [batch_size, seq_len] emb self.embedding(x) # [batch, seq_len, embed_dim] emb emb.unsqueeze(1) # [batch, 1, seq_len, embed_dim] pooled [] for conv in self.convs: out conv(emb) # [batch, num_filters, seq_len - fsz 1, 1] out torch.relu(out) out out.squeeze(-1) # 去掉embed_dim方向上的1 pooled.append(torch.max_pool1d(out, out.size(2)).squeeze(-1)) feat torch.cat(pooled, dim1) # [batch, 3 * num_filters] feat self.dropout(feat) logits self.fc(feat) return logits这段代码里有几个参数值得注意。filter_sizes[2, 3, 4]时卷积核覆盖二元到四元词组num_filters100表示每个宽度各学习100个不同位置的特征模式padding_idx0让词表中索引0对应的向量始终为0等于把pad位天然屏蔽在卷积计算之外。max_pool1d的out.size(2)在输入长度变化时也能自适应所以模型不要求所有句子等长只要同一个batch内补齐到相同长度即可。2.3 为什么用ReLU和Dropout而不是其他卷积后在池化之前加ReLU是为了让网络具备非线性拟合能力。文本特征和图像不同词与词的组合关系复杂单纯的线性卷积很难表达「不便宜」这种否定结构。Dropout放在池化拼接与全连接之间作用是对特征向量做随机置零迫使全连接层不依赖某几个特定的强特征这在数据量不大时能明显抑制过拟合。文本分类任务里dropout0.5是经验上比较稳的起点数据充足时可以降到0.3。3. 数据准备与训练流程实战3.1 项目文件结构与数据集分工拿到chinese_text_cnn-master.zip解压后核心文件就四个数据文件和四个Python脚本。先弄清楚每个文件的职责训练时才知道改哪里文件作用说明train.tsv训练集每行一条样本含文本和标签供模型学习参数dev.tsv验证集每轮训练后评估用于调超参和早停test.tsv测试集训练结束后做最终评估衡量泛化能力ch_auto.csv补充数据可能是自动抓取的原始评论可作为扩充训练集或待预测样本dataset.py数据读取实现Dataset类负责分词、词表构建、定长填充model.py模型定义实现上一章的TextCNN结构train.py训练入口训练循环、验证与模型保存main.py入口脚本整合参数解析与调用逻辑数据用tsv而不是csv是有考虑的。中文评论原文里可能包含逗号如果按逗号分隔会把一条文本拆成多列而用\t分隔则极少和正文冲突解析起来更稳。如果你手里的数据是csv导入时务必确认sep参数。3.2 分词、词表构建与定长填充中文文本和英文不同词之间没有天然空格。常见做法是先分词再把分词结果映射为词表索引。分词可以用jieba也可以用字符级切分。这个项目面向情感分析词级别的信息更丰富但字符级别对网络新词更鲁棒。以词级别为例dataset.py里的处理思路通常是这样的import torch from torch.utils.data import Dataset import jieba PAD_TOKEN pad UNK_TOKEN unk class TextCNNDataset(Dataset): def __init__(self, file_path, vocabNone, max_len64): self.max_len max_len self.texts, self.labels self._load(file_path) if vocab is None: self.vocab self._build_vocab(self.texts) else: self.vocab vocab def _load(self, path): texts, labels [], [] with open(path, r, encodingutf-8) as f: for line in f: parts line.strip().split(\t) if len(parts) 2: texts.append(parts[0]) labels.append(int(parts[1])) return texts, labels def _build_vocab(self, texts): from collections import Counter counter Counter() for text in texts: counter.update(jieba.lcut(text)) vocab {PAD_TOKEN: 0, UNK_TOKEN: 1} for word, _ in counter.most_common(49998): vocab[word] len(vocab) return vocab def __len__(self): return len(self.texts) def __getitem__(self, idx): tokens jieba.lcut(self.texts[idx])[:self.max_len] ids [self.vocab.get(w, self.vocab[UNK_TOKEN]) for w in tokens] ids ids [self.vocab[PAD_TOKEN]] * (self.max_len - len(ids)) return torch.tensor(ids), torch.tensor(self.labels[idx])这里_build_vocab用most_common限制词表大小到5万覆盖绝大多数高频词同时避免低频词把Embedding层撑得过大。max_len64是评论场景里比较实用的长度上限绝大多数短评在64个字以内。__getitem__里先截断再填充保证返回的序列长度严格等于max_len这样PyTorch才能把一批样本堆叠成矩阵。如果你的文本是长文本比如新闻正文建议先统计训练集的长度分布取95%分位数作为max_len而不是拍脑袋设64。3.3 训练循环与验证策略数据准备好后训练部分的核心是一个标准PyTorch循环前向传播、算损失、反向传播、更新参数外加验证集上做评估。关键是模型训练/验证模式的切换、梯度清零、以及只在验证集表现更好时保存模型import torch import torch.nn as nn from torch.utils.data import DataLoader def train_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, correct, total 0, 0, 0 for inputs, labels in loader: inputs, labels inputs.to(device), labels.to(device) optimizer.zero_grad() logits model(inputs) loss criterion(logits, labels) loss.backward() optimizer.step() total_loss loss.item() * len(labels) correct (logits.argmax(1) labels).sum().item() total len(labels) return total_loss / total, correct / total def evaluate(model, loader, criterion, device): model.eval() total_loss, correct, total 0, 0, 0 with torch.no_grad(): for inputs, labels in loader: inputs, labels inputs.to(device), labels.to(device) logits model(inputs) loss criterion(logits, labels) total_loss loss.item() * len(labels) correct (logits.argmax(1) labels).sum().item() total len(labels) return total_loss / total, correct / totaltrain_epoch里的optimizer.zero_grad()必须放在前向传播之前否则梯度会跨batch累积loss.backward()之后optimizer.step()完成参数更新。model.eval()会关闭Dropout并用torch.no_grad()阻断梯度计算验证阶段内存占用大幅下降。这两个函数合起来就是训练主循环的主体外层的epoch循环里只需要记录每轮的train/dev指标并比较dev上的准确率来决定是否覆盖保存best_model.pt。3.4 损失函数与优化器选择情感二分类直接用nn.CrossEntropyLoss就够了它内部把softmax和负对数似然整合在一起输入logits即可不需要手动过softmax。优化器首选Adam学习率1e-3它能在大多数数据集上快速收敛几乎不用调参。如果发现训练损失下降很慢可以把学习率提到3e-3如果验证集震荡严重则降到3e-4。类别不平衡时需要换成带权重的交叉熵。比如正样本占总量的80%负样本占20%模型全猜正类也有80%准确率此时用CrossEntropyLoss(weighttorch.tensor([1.0, 4.0]))压低多数类梯度比简单调整阈值更直接。weight的取值一般设为训练集中各类别样本数的倒数再归一化到某个合理尺度。4. 从训练到评估指标解读与过拟合控制4.1 四个指标在情感分析里的含义很多人只盯准确率但在情感分析里这是最容易误导人的数字。假设业务方想找出所有负面评论而负面只占10%全预测正面就能拿到90%准确率但业务完全没有产出。这时要看精确率和召回率并明确哪个指标优先指标计算方式侧重场景准确率(TPTN) / (TPTNFPFN)类别均衡时参考精确率TP / (TPFP)误报代价高时更看重如投诉工单分类召回率TP / (TPFN)漏报代价高时更看重如负面舆情监控F12 * P * R / (P R)精确率和召回率都不希望太差时使用以负面情感检测为例精确率高说明被判为负面的评论里真正负面的多适合「宁缺毋滥」的场景召回率高说明真正的负面评论被找出的比例大适合「宁可误报也不漏报」的场景。常规做法是打印出这几项后和业务方确认优先目标再决定是否做下面的阈值修正。4.2 测试集评估与单条样本预测训练完成后用测试集评估是最后一道验证关卡。而实际使用中更常见的需求是给一段新评论立即输出情感倾向。单条预测的完整流程是分词、查词表、填充、过模型代码逻辑如下def predict(model, text, vocab, max_len64, devicecpu): model.eval() tokens jieba.lcut(text)[:max_len] ids [vocab.get(w, vocab[UNK_TOKEN]) for w in tokens] ids ids [vocab[PAD_TOKEN]] * (max_len - len(ids)) input_tensor torch.tensor([ids]).to(device) with torch.no_grad(): logits model(input_tensor) prob torch.softmax(logits, dim1) pred int(prob.argmax(1)) return pred, float(prob[0][pred].item())torch.softmax把logits转换成概率分布两个类别的输出之和为1。返回的pred是0或1prob[0][pred]是对应类别的置信度。这里用torch.no_grad()包裹既省内存又能防止误把预测阶段纳入梯度图。如果返回的概率一直在0.5附近徘徊说明模型对该样本没有明确把握这种样本放到人工处理队列里比硬判效果更好。4.3 早停、Dropout与学习率调整过拟合在文本分类里表现很典型训练准确率不断逼近1.0验证准确率却连续几个epoch不涨甚至下降。最简单的拦截手段是早停把验证集准确率或损失作为监控目标连续3轮不提升就停止训练并恢复最佳参数best_dev 0.0 patience 3 bad_epochs 0 for epoch in range(20): train_loss, train_acc train_epoch(...) dev_loss, dev_acc evaluate(...) if dev_acc best_dev: best_dev dev_acc torch.save(model.state_dict(), best_model.pt) bad_epochs 0 else: bad_epochs 1 if bad_epochs patience: print(early stop at epoch, epoch) breakpatience是允许连续不提升的轮数设3对中小规模数据集比较合适。太小的patience1容易被训练过程中的正常波动打断太大的patience5又可能白白多跑很多轮。另一种手段是学习率衰减用torch.optim.lr_scheduler.ReduceLROnPlateau在验证loss连续2个epoch不变时把学习率缩小一半配合早停效果更稳。5. 把TextCNN接到线上意图识别时的三个工程细节模型在离线数据集上跑出95%准确率只是开始真正落地上线时还有几个细节会影响稳定性和响应速度。5.1 用空间换时间验证集特征提前向量化在线服务对延迟敏感但验证集在离线阶段是固定的没必要每次都重新分词和查表。可以在训练结束后把验证集所有样本过一次Embedding层把结果缓存成.pt文件。线上预测时只有新请求才走完整的分词和embedding流程而这部分在CPU上单条也就是毫秒级。如果服务流量大进一步的做法是把整个模型转成torch.jit.script导出推理时不再依赖jieba之外的Python对象延迟还能再降一截。5.2 标签不平衡时的阈值修正上一节提到离线评估时打印精确率和召回率到了线上就要把它们变成决策策略。如果模型输出负面的概率为0.6默认阈值0.5判为负面但业务方希望负面覆盖率更高可以把阈值下调到0.45此时更多样本会被判为负面召回率上升、精确率下降。调阈值不需要重新训练模型只需要在预测代码里把argmax改为和0.45比较positive_prob prob[0][1].item() label 1 if positive_prob 0.45 else 0阈值调多少合适可以在验证集上遍历0.3到0.7以F1最大或召回率达到某个目标来选择这一步往往能比调模型结构拿到更多业务收益。5.3 与BERT、LLM的边界划分TextCNN、BERT和大语言模型在意图识别上的分工本质是成本与能力的权衡。TextCNN的强项是短文本、标签固定、对延迟有硬性要求比如在线客服的工单预分类单条推理在CPU上亚毫秒完成不需要GPU就能上线。BERT适合样本量少但语义复杂的场景它能在预训练知识的基础上完成更精细的判别但需要GPU部署。LLM的优势在零样本和少样本一个新品类出现时不需要标注数据就能给出意图候选但单次推理成本和延迟都高出几个数量级更适合做离线批量分析或人工辅助标注而不是每一条实时请求都走一遍大模型。如果你的业务量级是每秒几十个请求TextCNN仍然是最稳的底座先把它跑通上线再把低置信度样本分流给大模型兜底这种两层架构在实践中性价比最高。本文还有配套的精品资源点击获取