
简介基于TextCNN与PyTorch的中文文本分类及情感分析资源包面向NLP入门开发者与需要快速落地文本情感分析任务的算法工程师。资源涵盖完整的TextCNN原理说明与PyTorch实现包含数据预处理、模型构建、训练评估与预测的闭环流程。包内共9个文件核心为Python源码涵盖数据处理、模型定义、训练入口等模块、3个tsv格式的训练/验证/测试数据集及1个csv辅助数据另有Markdown格式说明文档整包约5.53MB结构清晰便于按步骤学习。目前已有859人学习资源提供了从文本分词、词汇表构建到卷积池化全连接层实现的完整代码并配有可直接运行的示例数据读者可据此快速理解TextCNN的滤波器和全局最大池化机制也可以替换自己的数据集进行情感分析实验是动手实践中文NLP文本分类任务的实用参考。1. TextCNN中文情感分析为什么仍然是最实用的起点做中文评论情感分析很多人第一反应是去接大模型API或者上几GB的预训练模型但如果数据只有几万条线上又要求毫秒级返回TextCNN往往是比这些方案更划算的起点。它不是最时髦的模型却是最容易拿到稳定基线的做法训练快、参数少、卷积核对应的n-gram特征可以直接解释在短文本情感分类上效果并不差。下面的完整流程用Pytorch实现TextCNN做中文文本情感分析从数据集整理、分词词表、模型定义到训练与最终落地每一步都给出能直接改着用的代码。适合刚接触中文文本分类的工程同学也适合想快速为业务搭一条情感分析基线的团队。2. 中文数据集准备TextCNN训练前必须处理的三个环节2.1 中文情感数据集怎么选通用集与自采集中文情感分析没有特别统一的公开benchmark常见的是ChnSentiCorp酒店评论语料和weibo_senti_100k。前者来自酒店点评正负样本比较均衡句子短、口语化强后者来自微博噪声更大更接近真实舆情。两者都是文本加标签的结构读进来就能用。实际项目里如果做的是电商评论或景区舆情建议从业务自己的评论接口采集数据再人工标注几千条干净数据足够让TextCNN达到可用水平不需要一开始就追求大数据集。标注时先定好分类标准二分类还是三分类负面定义边界要写清楚。比如“一般般”算中性还是负面不同标注者很容易产生分歧。数据清洗阶段顺便做一遍去重和乱码过滤比在模型层面做任何处理都更有效。2.2 分词、清洗与截断把句子变成词表索引中文文本不能像英文那样直接按空格切分常规做法是先用jieba分词再做词表映射。情感分类任务对词表大小不敏感常见误区是把所有词都塞进词表低频词既增加embedding矩阵体积又容易过拟合一般限制词表在2万以内就好。import re import jieba from collections import Counter def clean_text(text: str) - str: text re.sub(r#.*?#, , text) # 去掉话题标签 text re.sub(rhttp\S, , text) # 去掉URL text re.sub(r\s, , text) # 合并空白 return text.strip() def tokenize_text(text: str, max_len: int 64) - list: tokens jieba.lcut(clean_text(text)) return tokens[:max_len] def build_vocab(texts, max_size20000, min_freq1): counter Counter() for text in texts: counter.update(tokenize_text(text)) vocab {pad: 0, unk: 1} for word, freq in counter.most_common(max_size): if freq min_freq: vocab[word] len(vocab) return vocab清洗函数里去掉话题标签和URL是针对微博这类来源的常见操作酒店评论可以去掉这两步。tokenize_text里直接tokens[:max_len]做硬截断因为TextCNN的卷积核覆盖范围有限句子超过128个token时后面的词对情感判断几乎没有贡献。pad固定为0是为了后续nn.Embedding里能直接设置padding_idx0来屏蔽填充位unk对应未登录词预测阶段遇到词表外的词就不会崩溃。2.3 用Dataset和collate_fn解决不定长序列句子长度不同进入模型前需要pad到同一长度。常见做法是在collate_fn里用pad_sequence按当前batch的最大长度做填充这样短batch不用白白pad到全局最大长度训练会快不少。import torch from torch.utils.data import Dataset from torch.nn.utils.rnn import pad_sequence class SentiDataset(Dataset): def __init__(self, texts, labels, vocab, max_len64): 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 [self.vocab.get(w, self.vocab[unk]) for w in tokenize_text(self.texts[idx], self.max_len)] return torch.tensor(ids, dtypetorch.long), \ torch.tensor(self.labels[idx], dtypetorch.long) def collate_batch(batch): seqs, labels zip(*batch) seqs_padded pad_sequence(seqs, batch_firstTrue, padding_value0) return seqs_padded, torch.stack(list(labels))pad_sequence默认输出形状是[seq_len, batch]batch_firstTrue才改成[batch, seq_len]这个参数忘记设置是新手最常见的问题。padding_value必须和词表里pad的id一致否则填充位会变成某个真实词的embedding。DataLoader里把collate_fn传进去即可from torch.utils.data import DataLoader train_loader DataLoader(train_dataset, batch_size64, shuffleTrue, collate_fncollate_batch)输入文本分词结果映射后的id这家酒店位置不错但隔音一般这家/酒店/位置/不错/隔音/一般[45, 2, 651, 32, 208, 77, 0, 0, ...]上面表格里的id是示意值实际以词表为准。注意观察pad永远是0在embedding层会被直接踢掉。3. Pytorch实现TextCNN核心代码与卷积核参数设计3.1 TextCNN怎么把文本变成“图像”做卷积传统的CNN对图像做卷积是在宽高两个方向滑动TextCNN的做法简单很多句子经过embedding层后形状是[batch, seq_len, embed_size]交换维度变成[batch, embed_size, seq_len]然后每个卷积核沿seq_len方向滑动embedding维度被当成通道。此时kernel_size就对应n-gram窗口。kernel_size3的卷积核一次覆盖3个词相当于提取trigram特征再用多组卷积核并行扫一遍就能同时捕捉“不好吃”“服务差”“位置不错”这些长短不一的关键片段。最后在seq_len方向上做全局max-pooling每个kernel保留一个最强特征拼起来接全连接层输出分类概率。为什么不直接上BERT这类预训练模型TextCNN没有自注意力也没有位置编码但它把决策依据压缩到少数几个关键n-gram上这种归纳偏置对短文本情感分析反而是合适的。BERT适合推理资源充足、数据量大的场景TextCNN适合快速出基线和低延迟在线服务。3.2 可直接运行的Pytorch TextCNN模块import torch.nn as nn import torch.nn.functional as F class TextCNN(nn.Module): def __init__(self, vocab_size, embed_size100, num_filters128, kernel_sizes(3, 4, 5), num_classes2, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, embed_size, padding_idx0) self.convs nn.ModuleList([ nn.Conv1d(in_channelsembed_size, out_channelsnum_filters, kernel_sizek) for k in kernel_sizes ]) self.fc nn.Linear(num_filters * len(kernel_sizes), num_classes) self.dropout nn.Dropout(dropout) def forward(self, x): # x: [batch, seq_len] emb self.embedding(x) # [batch, seq_len, embed_size] emb emb.transpose(1, 2) # [batch, embed_size, seq_len] pools [] for conv in self.convs: conv_out torch.relu(conv(emb)) # [batch, num_filters, seq_len-k1] pooled F.max_pool1d(conv_out, conv_out.size(2)).squeeze(2) # [batch, num_filters] pools.append(pooled) out torch.cat(pools, dim1) # [batch, num_filters * len(kernel_sizes)] out self.dropout(out) return self.fc(out)卷积层没有加padding原因有二一是后面直接做全局max-pooling序列边缘信息不会因为卷积窗口滑不到而丢失二是避免padding值参与卷积计算污染特征。代价是当句子长度小于kernel_size时该卷积核输出为空所以max_len必须大于最大的kernel_size前面设定64已经足够。nn.ModuleList用来装多个卷积层不能用Python的list直接替代否则模型不会把子模块注册进去参数量会变成0。这个坑在自定义模型时很容易踩。3.3 卷积核、嵌入维度和dropout的参数参考表参数常见范围说明embed_size64 ~ 128中文小数据集不需要太大常用100num_filters128 ~ 256每个卷积核的通道数太大容易过拟合kernel_sizes(2,3,4) 或 (3,4,5)对应bi-gram到5-gramdropout0.3 ~ 0.60.5在全连接层前最常用max_len32 ~ 128按业务语料长度分布来定batch_size32 ~ 128CPU上推荐64调参顺序我一般固定max_len64和词表之后先试kernel_sizes(3,4,5)再微调num_filters最后动dropout。不要一上来同时改多个参数否则无法定位是哪个改动带来了提升。模型参数量通常只有几十万单卡CPU训练一轮十万条的数据也只要一两分钟Pytorch基础框架完全够用。4. 训练与评估Pytorch训练循环里的loss、优化器和三个坑4.1 训练主循环优化器选择与梯度裁剪TextCNN的训练代码不长但几个细节值得注意。优化器用AdamW而不是Adam前者对权重衰减的处理更干净学习率2e-4到3e-4是个安全起点。梯度裁剪在这里不是必须的但小batch下偶尔出现loss抖动加上clip_grad_norm_能让训练更稳定。import torch.nn as nn from torch.optim import AdamW from torch.optim.lr_scheduler import StepLR model TextCNN(vocab_sizelen(vocab), num_classes2) criterion nn.CrossEntropyLoss() optimizer AdamW(model.parameters(), lr2e-4) scheduler StepLR(optimizer, step_size2, gamma0.8) for epoch in range(10): model.train() for batch_ids, batch_labels in train_loader: optimizer.zero_grad() logits model(batch_ids) loss criterion(logits, batch_labels) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm2.0) optimizer.step() scheduler.step()CrossEntropyLoss内部已经包含softmax所以模型输出层不需要再套一层LogSoftmax。StepLR每2个epoch把学习率乘以0.8让后期收敛更平稳实际项目中也可以换成ReduceLROnPlateau在验证loss连续不降时再降学习率。4.2 验证与早停用F1而不是准确率选模型情感分类数据集往往存在类别不平衡比如负面样本只占30%模型全部预测正面准确率也有70%但业务上真正关心的负面评论一条都捞不到。所以验证阶段我用F1而不是accuracy来选模型具体的评估函数如下from sklearn.metrics import accuracy_score, f1_score def evaluate(model, loader): model.eval() preds, trues [], [] with torch.no_grad(): for batch_ids, batch_labels in loader: logits model(batch_ids) preds.extend(logits.argmax(1).cpu().tolist()) trues.extend(batch_labels.cpu().tolist()) return accuracy_score(trues, preds), f1_score(trues, preds, averagebinary)配合早停保存最优模型避免在最后一个epoch把最好的权重覆盖掉best_f1 0.0 patience, bad_epochs 2, 0 for epoch in range(10): # 上面的训练循环 acc, f1 evaluate(model, dev_loader) print(fepoch {epoch}: acc{acc:.4f}, f1{f1:.4f}) if f1 best_f1: best_f1 f1 torch.save(model.state_dict(), best_textcnn.pt) bad_epochs 0 else: bad_epochs 1 if bad_epochs patience: break二分类时averagebinary算的是正类的F1如果业务是找出负面评论就把负面类当成正类来处理。多分类情感分析则改成averagemacro。4.3 三个常见坑不平衡、词表和预训练词向量样本不均衡时最直接的处理是给损失函数加类别权重。做法不是手动改数据而是统计训练集里正负样本比例比如负样本只有正样本的四分之一权重就设成反比criterion nn.CrossEntropyLoss( weighttorch.tensor([1.0, 4.0]) )第二个坑是词表里低频词过多。build_vocab里的min_freq至少要设成2否则大量只出现一次的噪声词会让embedding学习不到有效语义训练集上表现得很好、验证集一塌糊涂。第三个坑是纠结要不要加载预训练word2vec向量。按我的经验几千到几万条的中文短文本场景随机初始化embedding并充分训练后效果和加载通用词向量差距很小却省掉了词向量和词表对齐的麻烦。先把随机初始化的模型跑通保留提升空间后面如果需要再考虑引入。注意很多教程会在训练循环里频繁调用model.cuda()如果机器没有GPU就跳过这行。CPU上跑这个规模的模型完全可行不需要为此先配GPU环境。5. 部署技巧置信度阈值、TorchScript导出和人工兜底5.1 用置信度阈值把低置信样本送人工模型上线后最怕的不是预测错而是模型对某条样本根本没把握却硬给了一个标签。对短文本情感分析来说“服务态度不错但上菜速度太慢”这种样本模型输出概率可能在0.5附近徘徊这时候强行归为正面或负面都没有意义。常见做法是设定一个置信度阈值低于阈值的输出交给人工审核而不是直接给业务方一个误导性结果。def predict(text: str, model, vocab, max_len64, threshold0.7): model.eval() ids [vocab.get(w, 1) for w in tokenize_text(text, max_len)] ids ids[:max_len] [0] * max(0, max_len - len(ids)) input_ids torch.tensor([ids], dtypetorch.long) with torch.no_grad(): probs torch.softmax(model(input_ids), dim1)[0] conf, pred probs.max(dim0) if conf.item() threshold: return -1, conf.item() # -1 表示交给人工 return pred.item(), conf.item()predict函数里先做分词和截断补0到max_len。返回-1的低置信样本在业务侧走人工审核队列舆情和客服工单这类误判代价高的场景特别适合这种设计。阈值从0.6调到0.75误报会明显减少但漏报也会增加具体要看业务容忍度。5.2 TorchScript导出服务端不依赖训练框架Pytorch模型上线时最简单的方式是用torch.jit.trace导出TorchScript格式。因为TextCNN的forward没有条件分支输入固定长度后可以安全tracemodel.load_state_dict(torch.load(best_textcnn.pt, map_locationcpu)) model.cpu().eval() example_input torch.zeros(1, max_len, dtypetorch.long) traced torch.jit.trace(model, example_input) traced.save(textcnn_senti_cpu.pt)这里必须用torch.zeros构造一个固定长度为max_len的示例输入导出的模型在输入长度不一致时不会报错因为padding逻辑已经包含在predict的预处理里。分词、词表映射、padding都放在服务端预处理阶段模型本身只接收id序列。最后提醒一下如果输入文本长度比max_len还短补0操作要放在分词之后否则截断时会把pad部分截掉导致长度不足而报错。本文还有配套的精品资源点击获取