简介:面向自然语言处理入门开发者,这一项目基于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种宽度,让它们相互补充。下表是常见配置方式:
| 卷积核宽度 | 捕获特征 | 典型数值 |
|---|---|---|
| 2 | bigram,局部词搭配 | 2 |
| 3 | trigram,常见短语 | 3 |
| 4 | 4-gram,更长片段 | 4 |
| 每种宽度的核数量 | 每个尺度提取的特征通道数 | 100~256 |
核数太少模型表达能力不足,太大会让全连接层的参数急剧膨胀。文本分类这种任务里,每种宽度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.py:
import torch import torch.nn as nn class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim, num_filters, filter_sizes, num_classes, dropout=0.5): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) 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, dim=1) # [batch, 3 * num_filters] feat = self.dropout(feat) logits = self.fc(feat) return logits这段代码里有几个参数值得注意。filter_sizes=[2, 3, 4]时卷积核覆盖二元到四元词组,num_filters=100表示每个宽度各学习100个不同位置的特征模式;padding_idx=0让词表中索引0对应的向量始终为0,等于把pad位天然屏蔽在卷积计算之外。max_pool1d的out.size(2)在输入长度变化时也能自适应,所以模型不要求所有句子等长,只要同一个batch内补齐到相同长度即可。
2.3 为什么用ReLU和Dropout而不是其他
卷积后在池化之前加ReLU,是为了让网络具备非线性拟合能力。文本特征和图像不同,词与词的组合关系复杂,单纯的线性卷积很难表达「不便宜」这种否定结构。Dropout放在池化拼接与全连接之间,作用是对特征向量做随机置零,迫使全连接层不依赖某几个特定的强特征,这在数据量不大时能明显抑制过拟合。文本分类任务里dropout=0.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, vocab=None, max_len=64): 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', encoding='utf-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_len=64是评论场景里比较实用的长度上限,绝大多数短评在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(weight=torch.tensor([1.0, 4.0]))压低多数类梯度,比简单调整阈值更直接。weight的取值一般设为训练集中各类别样本数的倒数,再归一化到某个合理尺度。
4. 从训练到评估:指标解读与过拟合控制
4.1 四个指标在情感分析里的含义
很多人只盯准确率,但在情感分析里这是最容易误导人的数字。假设业务方想找出所有负面评论,而负面只占10%,全预测正面就能拿到90%准确率,但业务完全没有产出。这时要看精确率和召回率,并明确哪个指标优先:
| 指标 | 计算方式 | 侧重场景 |
|---|---|---|
| 准确率 | (TP+TN) / (TP+TN+FP+FN) | 类别均衡时参考 |
| 精确率 | TP / (TP+FP) | 误报代价高时更看重,如投诉工单分类 |
| 召回率 | TP / (TP+FN) | 漏报代价高时更看重,如负面舆情监控 |
| F1 | 2 * P * R / (P + R) | 精确率和召回率都不希望太差时使用 |
以负面情感检测为例,精确率高说明被判为负面的评论里真正负面的多,适合「宁缺毋滥」的场景;召回率高说明真正的负面评论被找出的比例大,适合「宁可误报也不漏报」的场景。常规做法是打印出这几项后,和业务方确认优先目标,再决定是否做下面的阈值修正。
4.2 测试集评估与单条样本预测
训练完成后,用测试集评估是最后一道验证关卡。而实际使用中更常见的需求是:给一段新评论,立即输出情感倾向。单条预测的完整流程是分词、查词表、填充、过模型,代码逻辑如下:
def predict(model, text, vocab, max_len=64, device='cpu'): 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, dim=1) pred = int(prob.argmax(1)) return pred, float(prob[0][pred].item())torch.softmax把logits转换成概率分布,两个类别的输出之和为1。返回的pred是0或1,prob[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对中小规模数据集比较合适。太小的patience=1容易被训练过程中的正常波动打断;太大的patience=5又可能白白多跑很多轮。另一种手段是学习率衰减,用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仍然是最稳的底座;先把它跑通上线,再把低置信度样本分流给大模型兜底,这种两层架构在实践中性价比最高。
本文还有配套的精品资源,点击获取