简介:面向PyTorch与NLP初学者,资源包提供了基于TextCNN的中文文本分类与情感分析完整实现,涵盖数据预处理、模型构建、训练评估与推理全流程。包内共16个文件,以4个Python脚本(模型构建、数据加载、训练主程序)为核心,辅以3个TSV数据文件与1个CSV文件作为训练/验证/测试集,另有若干工程配置文件,整体仅4.7MB,轻量且便于直接运行调试。项目采用jieba分词与词嵌入方式将中文转为数字表示,通过多尺寸卷积核提取n-gram特征,并配合最大池化与全连接层完成情感二分类。资源目录将数据、模型与训练逻辑分离,结构清晰,适合作为课程设计或入门实践参考。目前已有1350人学习下载,按说明配置PyTorch、jieba等依赖后,即可快速跑通实验,并可进一步对照准确率、F1等指标调优模型与超参数。
1. Pytorch TextCNN做中文情感分析,为什么2025年还在用它
中文情感分析的需求一直没冷却过:电商评论、外卖评分、客服工单、社交平台舆情,太多业务需要快速知道“用户这句话是正面还是负面”。拿到一批标注好的中文文本,先别急着上BERT或者LLM做意图识别,多数时候一个轻量的TextCNN就能把准确率顶到接近下游可用线,而且训练时间从半小时砍到几分钟。TextCNN不是模型里的新面孔,但它在短文本场景下仍然是最难被替代的基线之一:训练快、占显存小、行为可解释,标注数据只有几千条也能稳定收敛。标题里强调“完整代码数据可直接运行”,本质上是指这一套流程——数据清洗、分词、建词表、搭模型、训练、评估、落盘——每一步都有标准做法。这篇就是按这个流程把代码和参数掰开讲。
2. 中文文本分类的数据预处理:分词、padding与Label编码
2.1 数据格式选择:为什么csv比txt更适合当输入
文本分类项目的第一步是把数据整理成统一结构。常见做法是把标注好的语料放在csv文件里,三个字段就够了:label表示情感标签,text表示原始文本,id可留可不留,但保留有利于排查坏样本。用csv而不用纯txt的原因很实际:csv天然按行对齐,一个样本一条记录,后续做多分类时标签列可以不只放“正/负”,还可以扩展成“中性/愤怒/惊喜”等多粒度标注,改动成本为零。
下面这个代码块是一个可以直接落地的读取与样本统计逻辑:
import pandas as pd df = pd.read_csv("train.csv", encoding="utf-8") # 兼容常见两种列名写法 if "label" not in df.columns: df.rename(columns={"sentiment": "label"}, inplace=True) print(df["label"].value_counts()) label2id = {label: idx for idx, label in enumerate(df["label"].unique())} id2label = {idx: label for label, idx in label2id.items()} df["label_id"] = df["label"].map(label2id)value_counts()用来确认类别分布,如果正负样本比接近1:1可以直接训,否则要在后文提到的损失函数或采样策略上做调整。label2id把中文标签映射为整数,这一步决定了后面损失函数计算的是几分类问题。
2.2 中文分词与文本清洗:jieba和正则的边界
中文分类和英文最大的差异在分词。英文按空格切,中文必须分词或按字切,否则“很不好”和“不很好”会被拆成完全不同的token串。当前TextCNN实现多数用jieba做粗分词,再配合正则过滤噪声字符。清洗规则需要根据业务来源来定:爬来的评论可能有HTML实体、@用户、URL,这些对情感判断帮助有限,但对词表大小影响很大。代码可以这么写:
import re import jieba def clean_and_tokenize(text: str, max_len: int = 128) -> list[str]: # 统一换行符、去除URL text = text.replace("\n", " ").strip() text = re.sub(r"https?://\S+", "", text) # 去手机号、邮箱、连续标点 text = re.sub(r"\d{11}", "", text) text = re.sub(r"[^\u4e00-\u9fffA-Za-z0-9,。!?、]", " ", text) # 全角转半角 text = text.translate(str.maketrans(",。!?", ",.!?")) # jieba返回生成器,这里转列表 tokens = [t for t in jieba.cut(text) if t.strip() and t not in STOPWORDS] return tokens[:max_len]正则表达式保留中英文和基本标点,\d{11}删除手机号,避免模型把数字当成分类特征。注意jiejba分词后要过滤纯空格和停用词,常见的停用词表可以在GitHub上直接搜“中文停用词”获得,也可以自己基于高频无意义词构造。有一个经验点:对情感分析任务,“不”“没”“别”这类否定词绝不能进停用词表,否定了它们模型基本废了。
2.3 构造Dataset与DataLoader:max_len、padding与batch
分完词之后要建词表,然后把每条样本转成等长向量。等长的实现原理是取一个固定长度max_len,超过截断,不足补0。词表构建常用torchtext或collections.Counter手写,推荐手写,逻辑透明且不引入额外版本依赖问题。核心是下面这段:
from collections import Counter from torch.utils.data import Dataset, DataLoader import torch def build_vocab(tokenized_texts, min_freq=1): counter = Counter() for tokens in tokenized_texts: counter.update(tokens) # 0留给pad,1留给unknown vocab = {"<pad>": 0, "<unk>": 1} for word, freq in counter.items(): if freq >= min_freq: vocab[word] = len(vocab) return vocab class SentimentDataset(Dataset): def __init__(self, tokenized_texts, labels, vocab, max_len=128): self.data = [(self.encode(seq, vocab, max_len), l) for seq, l in zip(tokenized_texts, labels)] def encode(self, tokens, vocab, max_len): ids = [vocab.get(t, 1) for t in tokens[:max_len]] if len(ids) < max_len: ids += [0] * (max_len - len(ids)) return torch.tensor(ids, dtype=torch.long) def __len__(self): return len(self.data) def __getitem__(self, i): return self.data[i] def collate_fn(batch): inputs = torch.stack([x[0] for x in batch]) labels = torch.tensor([x[1] for x in batch], dtype=torch.long) return inputs, labels这里面两个细节最容易出错:一是<unk>占位符的id必须固定,否则验证阶段遇到词表外词会错位;二是截断策略,对情感分析场景,开头和结尾往往比中间信息量大——开头是观点总起,结尾是总结性评价。如果max_len设得不够,优先保头和保尾的截断方式值得一试,比如tokens[:max_len//2] + tokens[-(max_len//2):]。collate_fn手动stack是因为每条样本都已在Dataset里做了padding,不需要再去动态pad。
3. 用Pytorch从零搭建TextCNN模型:Embedding与多尺寸卷积核
3.1 TextCNN的结构设计:卷积核尺寸分别捕捉什么
TextCNN的核心假设是短语决定情感极性。一个卷积核覆盖kernel_size个连续词,本质是抓n-gram:kernel_size=2抓二元词组,比如“难吃”;kernel_size=3抓三元词组,比如“非常好吃”;kernel_size=4则更偏短语级模式。这就是为什么TextCNN要在多个尺寸上并行做卷积,然后把结果拼接起来,而不是只用一个卷积核。
整体前向流程是:词向量矩阵 → 多尺寸一维卷积 → ReLU → 全局最大池化 → 拼接 → Dropout → 全连接输出logits。关键设计有二:一是Embedding层可以随机初始化也可以加载预训练向量,两种做法在调优后会对比;二是nn.Conv1d的输入形状是(batch, embed_dim, seq_len),卷积核在时间维度上滑动,通道维度对应词向量维度,这与CV里的Conv2d直觉略有差异。
3.2 TextCNN的PyTorch实现与shape推导
以下是一个可以直接跑的TextCNN模型定义,兼容二分类和多分类:
import torch import torch.nn as nn import torch.nn.functional as F class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim, num_filters, filter_sizes, num_classes, dropout=0.5, max_len=128): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.convs = nn.ModuleList([ nn.Conv1d(in_channels=embed_dim, out_channels=num_filters, kernel_size=size) for size in filter_sizes ]) self.fc = nn.Linear(len(filter_sizes) * num_filters, num_classes) self.dropout = nn.Dropout(dropout) def forward(self, x): # x: (batch, max_len) emb = self.embedding(x) # (batch, max_len, embed_dim) emb = emb.transpose(1, 2) # (batch, embed_dim, max_len) pooled = [] for conv in self.convs: c = conv(emb) # (batch, num_filters, max_len - kernel_size + 1) c = F.relu(c) p = F.max_pool1d(c, c.size(2)) # (batch, num_filters, 1) pooled.append(p.squeeze(2)) cat = torch.cat(pooled, dim=1) # (batch, num_filters * len(filter_sizes)) out = self.fc(self.dropout(cat)) return out形状推导值得逐行核对:输入x是(batch_size, max_len)的token id矩阵。经过nn.Embedding变成(batch_size, max_len, embed_dim)。转置后成为(batch_size, embed_dim, seq_len),这才是Conv1d期望的布局。每个卷积的输出长度是max_len - kernel_size + 1,经过max_pool1d会把整个时间维度压成单个最大值,因此每个卷积核最终只输出num_filters个数。全连接层的输入维度是len(filter_sizes) * num_filters,这里的乘数是卷积核尺寸的种类数,不是卷积核总量,容易搞混。
3.3 为什幺选max-pooling而不是average-pooling
这里有一个几乎每次面试都会被问到的点:max_pooling保留的是整个序列里最强烈的信号。对情感分析而言,“太棒了”出现在句子末尾而前面全是客观描述时,max-pooling能定位到“棒”这个强特征。average-pooling会把大量平淡词汇的特征拉进来稀释语义。当然max-pooling的代价是只保留一个最大值、丢失位置信息,所以TextCNN在长文本里效果衰减很快,这也是它适合短文本的原因之一。
写模型时padding_idx=0不能省略,否则模型会去学习<pad>符号的向量,白白浪费参数还容易过拟合。filter_sizes推荐从[2, 3, 4]起步,num_filters从128调起。
4. 训练与调参:Pytorch训练循环、早停与模型保存
4.1 训练框架搭建:优化器、损失函数与评估指标
训练代码的骨架在文本分类任务里高度统一,但要跑出稳定结果,有几个细节必须处理好。第一个是优化器,AdamW比Adam多了权重衰减解耦,配合weight_decay能明显限制过拟合。第二是损失函数,CrossEntropyLoss自带softmax同时接受原始logits,不要在模型里先过softmax再进loss。第三是类不平衡,如果负样本占比大,给CrossEntropyLoss传weight参数,按类别样本量倒数归一。
from torch.optim import AdamW from sklearn.metrics import accuracy_score, f1_score def train_one_epoch(model, dataloader, optimizer, criterion, device): model.train() total_loss, all_preds, all_labels = 0, [], [] for inputs, labels in dataloader: inputs, labels = inputs.to(device), labels.to(device) optimizer.zero_grad() logits = model(inputs) loss = criterion(logits, labels) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() total_loss += loss.item() all_preds.extend(torch.argmax(logits, dim=1).cpu().tolist()) all_labels.extend(labels.cpu().tolist()) return total_loss / len(dataloader), accuracy_score(all_labels, all_preds), f1_score(all_labels, all_preds, average="macro")clip_grad_norm_这一行的作用是梯度裁剪。Embedding层的梯度波动比卷积层大,尤其在加载预训练词向量时,偶尔一个大梯度会让整条loss曲线陡增,clip到1.0或0.5可以显著提升稳定性。训练结束后打印f1_score(average="macro"),类别不均衡时F1比accuracy可信得多。
4.2 必调参数表:learning_rate、batch_size、num_filters与dropout
训练TextCNN时调参优先级最高的是下面几个参数,按影响从大到小排列:
| 参数 | 推荐范围 | 调参现象说明 |
|---|---|---|
| learning_rate | 1e-3 ~ 1e-4 | 超过1e-3容易震荡,低于5e-5收敛太慢 |
| batch_size | 32 ~ 128 | 显存允许下优先64起步 |
| num_filters | 100 ~ 256 | 调大提升效果但显存线性增长 |
| dropout | 0.3 ~ 0.6 | 数据量小于1万时用0.5以上防过拟合 |
| embedding_dim | 100 ~ 300 | 用字向量或词向量时取对应维度 |
learning_rate的选择与优化器强相关。AdamW的默认lr是1e-3,但在小数据集上经常降到5e-4更稳。一个实际判断标准是观察前100个batch的loss:如果loss在下降但偶尔突刺,说明lr偏大;如果下降曲线太平滑且幅度小,可以调到1e-3再试。训练过程中每轮跑完做一次验证,如果验证loss连续三轮不降,立刻回滚到上一轮的模型参数,而不是继续训下去。
4.3 早停策略与完整训练流程
早停(early stopping)的判定可以基于验证集F1或loss,推荐基于loss,因为F1在epoch数少时波动较大容易误判。保存模型时只保存state_dict和vocab、label2id,不要用torch.save(model)保存整个对象,后续升级代码时兼容性差得多。
best_loss = float("inf") best_state = None for epoch in range(10): train_loss, train_acc, train_f1 = train_one_epoch(model, train_loader, optimizer, criterion, device) val_loss, val_acc, val_f1 = evaluate(model, val_loader, criterion, device) print(f"epoch {epoch} | train_loss {train_loss:.4f} | val_loss {val_loss:.4f} | val_f1 {val_f1:.4f}") if val_loss < best_loss: best_loss = val_loss best_state = {k: v.cpu().clone() for k, v in model.state_dict().items()} model.load_state_dict(best_state) torch.save({ "model_state_dict": best_state, "vocab": vocab, "label2id": label2id, "config": { "embed_dim": embed_dim, "num_filters": num_filters, "filter_sizes": filter_sizes, "max_len": max_len, } }, "textcnn_sentiment.pt") print("saved best model, val_loss=%.4f" % best_loss)参数说明:best_state需要clone到CPU再存,否则在后端继续训练时GPU显存被这些历史权重占用。config字段是必须的,推理阶段加载模型时要根据它重新实例化TextCNN,否则vocab_size对不上直接报错。早停的轮数上限建议设为总epoch的1/3,比如计划训20轮,连续5轮不降就停。
5. 模型评估与坏例分析:让准确率停在“能上线”而不是“跑通”
5.1 用混淆矩阵定位模型盲区
Accuracy和F1只能告诉你整体水平,没法告诉你模型错在哪。拿到validation结果后,第一件事是画混淆矩阵,按“真实类别 × 预测类别”做交叉统计。情感分析里最常见的偏差是模型把负面样本预测成正面,原因往往是训练的负面样本中有大量反讽表达,词面是褒义词但真实情感是负的。
from sklearn.metrics import confusion_matrix import numpy as np def confusion_report(model, dataloader, id2label, device): model.eval() y_true, y_pred = [], [] with torch.no_grad(): for inputs, labels in dataloader: inputs = inputs.to(device) logits = model(inputs) y_pred.extend(torch.argmax(logits, dim=1).cpu().tolist()) y_true.extend(labels.tolist()) cm = confusion_matrix(y_true, y_pred) for i, true_label in enumerate(id2label.values()): for j, pred_label in enumerate(id2label.values()): if cm[i][j] >= 3 and i != j: print(f"真实{true_label} 被预测为 {pred_label}: {cm[i][j]} 条") return cm这个输出的价值在于直接告诉你错得最集中的那对类别组合。然后从数据集里挑出这些被分错的样本,逐条看原始文本,判断是标注质量问题还是模型特征学习偏了。标注错误常见到几乎每个项目都有,一条标反的数据抵得上十条正常数据,因为模型为了拟合它学到的特征非常反直觉。
5.2 短文本与网络口语:TextCNN在真实评论上的数据增益技巧
真实场景里的中文评论和公开数据集的规范文本差距很大:大量叠词“哈哈哈哈”、标点连续使用“!!!”、网络新词“绝绝子”。处理这些口语化文本的一个技巧是加入字符级特征,即同时训练词级别和字级别的两种序列,分别过TextCNN后做特征拼接。
# 同一条样本,输出两个并行分支 词序列: [这家, 店, 的, 服务, 非常, 好] 字序列: [这, 家, 店, 的, 服, 务, 非, 常, 好]字级别的优势在于天然解决未登录词问题——“绝绝子”在词表里可能没有,但“绝”“子”都在字表里。字级别的向量维度可以比词级别低一些,比如词用embed_dim=200,字用embed_dim=100,两个分支的输出拼接后接全连接。这个方案在微博评论、B站评论这类短文本上提升明显,而分词器新增词汇维护成本几乎为零。
5.3 嵌入层选型:随机初始化、word2vec与动态微调
模型的最后一块拼图是Embedding初始化的策略。随机初始化处理不了低频词,学出来的向量语义空间没有结构。预训练词向量能给你的是语义先验:“好吃”和“美味”在向量空间里距离更近,这让模型在少量标注数据下也能把相似语义的样本聚在一起。推荐下载中文预训练词向量,用gensim加载后构造权重矩阵、赋给Embedding层。加载时需要注意词表对齐:
import gensim from torch import nn def load_pretrained_embedding(vocab: dict, w2v_path: str, embed_dim: int) -> nn.Embedding: w2v = gensim.models.KeyedVectors.load_word2vec_format(w2v_path, binary=False) embedding = nn.Embedding(len(vocab), embed_dim, padding_idx=0) init = torch.randn(len(vocab), embed_dim) * 0.1 hit = 0 for word, idx in vocab.items(): if word in w2v: init[idx] = torch.tensor(w2v[word], dtype=torch.float32) hit += 1 embedding.weight.data = init print(f"词向量命中率: {hit}/{len(vocab)} = {hit / len(vocab):.2%}") return embedding命中率低于80%时,说明vocab里的词多半是低频新词或切出来的碎片,这时加载预训练向量的收益有限,不如保留随机初始化并加大min_freq过滤掉低频词。预训练向量要不要冻结取决于标注数据量:数据量超过1万条,建议解冻微调;数据量只有几千条,冻结可以防过拟合。这个选择可以直接影响最终F1的1到3个百分点。如果环境受限、部署目标是边缘设备甚至pytorch fpga这类低资源场景,随机初始化加字级特征往往比强制加载大词表更务实。
本文还有配套的精品资源,点击获取