简介:一份基于TextCNN的中文文本情感分析实战资源包,面向自然语言处理学习者、算法工程师及需要快速落地情感分析任务的开发人员,项目围绕中文文本情感二分类展开,提供了从数据预处理、模型构建、训练到评估的完整闭环,可直接运行,适合用来理解TextCNN在短文本分类中的应用。压缩包为zip格式,共24个文件,约89.49MB,包含Python脚本、Jupyter Notebook、数据集、模型文件及可视化图片等,其中py文件与ipynb覆盖训练、预测、数据分析全过程,txt提供中文正负样本与停用词表,h5与checkpoint保存训练好的模型权重与检查点,png为模型结构示意,目录结构清晰,便于按需查看。当前已有499人学习该资源。通过这份资料,可快速获得一套可复现的中文情感分析基准方案,也能参考其数据组织与Notebook步骤,省去环境搭建和语料准备时间,深入理解TextCNN网络结构。
1. 一个能直接跑起来的中文情感分析项目:TextCNN在解决什么问题
基于TextCNN的中文文本情感分析实战,这个项目拆开看就是两件事:把中文评论文本变成数字矩阵,再用一个轻量卷积网络判断情感倾向。和BERT这类动辄几亿参数的大模型相比,TextCNN只需要一张普通显卡甚至CPU就能跑完训练,而且对几万条规模的中小评论数据,效果足够作为一个可靠基线。很多读者拿到标着完整代码+数据可直接运行的项目,第一反应是跑通,但跑通之后往往不知道怎么改、怎么避坑。本文按任务定位、数据整理、模型实现、参数调整、踩坑复盘、推理验证的顺序展开,目标是你手里那套能运行的代码,最后变成你能解释清楚、能改结构、能上线验证的自己的项目。
2. TextCNN为什么适合中文短文本:卷积核、词向量与n-gram的选型逻辑
2.1 情感分析任务先定性:二分类还是三分类
拿到项目的第一步不是读模型代码,而是想清楚任务口径。中文情感分析最常见的是二分类设定,也就是判断一条评论是正面还是负面。另一种是三分类,在正负之间加一个中性类。项目数据里如果只有0和1两类标签,就做二分类,模型输出层把num_classes设为2。如果数据里中性样本占比不低,比如超过15%,三分类更贴近业务,但训练难度会上去——模型需要学会区分“没有明显情绪表达”和“明确喜欢或厌恶”。
代码结构上,二分类和三分类只有两处不同:数据集里的标签取值范围,以及全连接层的输出维度num_classes。模型本身不用动。所以最稳妥的落地路径是先用二分类把整条管线跑通,让每个环节都被验证过,再决定要不要扩展到三分类。很多实战项目的描述里只写“情感分析”不写类别数,默认就是二分类,因为标注成本和处理难度都低一截。
2.2 词向量初始化:随机Embedding先跑通,再考虑预训练词向量
TextCNN的第一层是embedding,也就是词向量表。这个项目能在普通机器上直接运行的关键,就是词向量表采用随机初始化。随机初始化意味着不依赖外部下载的预训练文件,整个代码包体积小、步骤少,训练过程中每个词的向量会随反向传播一起更新,让模型自己学出词语在这批评论里的分布特征。对“可直接运行”这个目标来说,这是性价比最高的起步方式。
随机初始化也有代价。词表里那些只在十几条评论里出现过的低频词,训练时更新机会少,向量质量不稳定。常见做法是统计词频,把出现次数低于2的词合并成 ,减少embedding参数里的噪声。如果后续发现模型效果差一点就到业务线,再考虑引入预训练词向量,但引入时必须保证预训练模型的分词方式和你当前的分词结果一致,否则同一个词在词表里查不到,效果不升反降。这块是很多人默认预训练一定更强的误区所在。
另一个容易忽略的是embedding的padding_idx参数。nn.Embedding里设置padding_idx=0,模型会把第0号ID对应位置的向量在梯度更新时自动排除,让补位字符不会对后续卷积产生噪声。词表里要固定留出 和 两个占位,分别放在ID 0和1,后续所有数据处理都沿用这套映射。
2.3 卷积核尺寸与中文n-gram特征的对应关系
TextCNN对文本做的是1D卷积。经过embedding和transpose之后,文本变成形状为(batch, embedding_dim, seq_len)的张量。embedding_dim相当于通道数,seq_len相当于宽度,一个宽度为k的卷积核沿着seq_len方向滑动时,每次查看相邻的k个词的向量组合。
所以卷积核宽度本质上是在捕捉n-gram特征。filter_size=2覆盖相邻两个词的搭配,比如“不好”“好吃”这样的组合都在一个窗口里;filter_size=3和4覆盖更大范围的短语结构。项目里常见设置filter_sizes=(2, 3, 4),就是让模型同时从二元、三元、四元词组里提取信号。对中文来说还有一重好处:即使分词偶尔把词切错,2-gram窗口仍能捕获相邻词的局部组合信息,这比按字做卷积的容错性高一些。
每个宽度后面跟一个全局最大池化,作用是找这条评论里最强烈的n-gram信号。一段评论是正面还是负面,往往由一两个关键词决定,最大池化把这些最强信号保留下来,和其他宽度的卷积结果拼接后交给全连接层。池化后的特征长度就是num_filters乘以卷积核种类数,这个数字决定了全连接层的输入维度,不需要显式初始化,PyTorch默认的均匀分布初始化就够用。
3. 数据准备与预处理:从原始评论到训练样本的完整流程
3.1 数据格式统一:CSV两列,label和text
数据是情感分析模型的地基。拿到项目后第一件事是打开数据文件看前几行,搞清楚分隔符、编码、标签含义。绝大多数中文情感分析实战数据会整理成CSV,核心两列:label和text。label用整数表示,0或1对应情感极性,text是原始评论文本。如果项目里的数据和这个结构有出入,第一步就是做字段对齐,不要直接跑训练脚本。
读数据有一个容易翻车的坑:编码。很多公开中文数据是utf-8,但一部分老数据集是gbk。直接pd.read_csv('data/raw.csv')报UnicodeDecodeError,追加encoding='gbk'一般能解决,如果还报错,用encoding='gb18030'做兜底。读取后我习惯顺手把多余列丢掉,只保留label和text,再打印标签分布,判断正负比例。这一步不花时间,但能让你后面对数据不平衡问题有数,而不是等到训练完看结果才莫名其妙。
import pandas as pd df = pd.read_csv('data/raw.csv', encoding='utf-8') df = df[['label', 'text']].dropna().reset_index(drop=True) df['label'] = df['label'].astype(int) print(df.head()) print(df['label'].value_counts(normalize=True))这里dropna()用于过滤空评论文本和空标签。astype(int)把标签统一成整数,避免有些脚本里读到字符串类型导致训练报错。value_counts(normalize=True)输出正负样本各自占比,后面设置类别权重时直接参考这组数字。
3.2 清洗、分词、去停用词与序列截断
中文文本清洗比英文少了几步,但有两个点很关键。第一,HTML标签和超链接要删掉,评论数据经常混入这些噪声;第二,标点不要全删光,尤其是感叹号在负面评论里出现频率很高,删掉会让模型失去一个廉价信号。合理做法是保留中文、英文、数字与常用标点,把多余空白折叠。
分词工具用jieba的精确模式,这是中文NLP项目的默认选择。停用词处理要克制:只去掉“的、了、吗、呢、啊”这类高频虚词,情感相关的副词“不”“很”“太”必须保留。不要去网上随便下载一个几万词的停用词大列表,那是为搜索引擎设计的,用在情感分析上会把否定词删掉,一条“不太行”删完“不”变成“太行”,情感极性直接反转,效果崩得莫名其妙。
import re import jieba STOPWORDS = {'的', '了', '也', '是', '在', '吗', '呢', '啊', '吧'} def clean_text(text: str) -> str: text = re.sub(r'<[^>]+>', ' ', text) text = re.sub(r'https?://\S+', ' ', text) text = re.sub(r'\s+', ' ', text).strip() return text def tokenize(text: str): cleaned = clean_text(text) return [w for w in jieba.lcut(cleaned) if w.strip() and w not in STOPWORDS]STOPWORDS用set类型存放,判断时哈希查找比list快得多,数据量到十万条时这个差异能明显感知。tokenize里最后的过滤条件同时做了空串检查和停用词过滤,一步到位。序列截断在分词之后做,max_len=50是短评场景的常用值,评论过长的就丢弃后面的内容,太短的后续在Dataset里统一补 。
3.3 词表构建与数据集切分
词表用Counter统计词频构建。这里有个关键顺序:先切分训练集和验证集,再在训练集上构建词表,最后把验证集映射到同一套词表。很多人习惯先建词表再切分,实际上验证集信息被偷偷泄漏进了词表,模型评估分数虚高,等换到真实新数据马上露馅。
from collections import Counter def build_vocab(tokenized_texts, min_count=2, max_size=50000): counter = Counter() for tokens in tokenized_texts: counter.update(tokens) vocab = {'<pad>': 0, '<unk>': 1} for word, cnt in counter.most_common(max_size - 2): if cnt >= min_count: vocab[word] = len(vocab) return vocabmin_count过滤低频词,max_size控制embedding参数量。embedding总参数量等于vocab_size乘以embedding_dim,词表从1万扩大到5万,参数量翻了五倍,但分类效果通常只提升零点几个点。所以先用max_size=20000跑通,再根据验证集表现决定要不要放大,不要一上来就把词表撑满。
数据集切分用scikit-learn的train_test_split,stratify参数按标签比例做分层抽样,避免某一次随机切分把负面评论都分到验证集里。然后封装成Dataset类:每条样本分词后转成ID列表,长度不足max_len时补 的ID,超过则截断。这样训练和验证阶段拿到的都是形状统一的整数向量。
import torch from torch.utils.data import Dataset class CommentDataset(Dataset): def __init__(self, texts, labels, vocab, max_len=50): self.texts = texts self.labels = labels self.vocab = vocab self.max_len = max_len def __len__(self): return len(self.texts) def __getitem__(self, index): tokens = tokenize(self.texts[index])[:self.max_len] ids = [self.vocab.get(w, self.vocab['<unk>']) for w in tokens] if len(ids) < self.max_len: ids = ids + [self.vocab['<pad>']] * (self.max_len - len(ids)) return torch.tensor(ids, dtype=torch.long), torch.tensor(self.labels[index], dtype=torch.long)from sklearn.model_selection import train_test_split train_texts, val_texts, train_labels, val_labels = train_test_split( df['text'].tolist(), df['label'].tolist(), test_size=0.2, stratify=df['label'].tolist(), random_state=42 )最后一步:
train_ds = CommentDataset(train_texts, train_labels, vocab) val_ds = CommentDataset(val_texts, val_labels, vocab)到这里,数据管线已经闭合,接下来就能把Dataset交给DataLoader开始训练。
4. 模型实现与训练:PyTorch版TextCNN的核心代码与参数
4.1 TextCNN模型定义:Embedding层、卷积层、池化层与全连接层
模型结构以经典TextCNN为骨架。先定义embedding层把词ID映射成稠密向量,再用三个不同尺寸的卷积核提取n-gram特征,每个卷积核后接全局最大池化,最后把三个池化结果拼接起来过全连接层。这个结构实现起来不到一百行,核心部分直接贴出来。
import torch import torch.nn as nn import torch.nn.functional as F class TextCNN(nn.Module): def __init__(self, vocab_size, embedding_dim=128, num_filters=256, filter_sizes=(2, 3, 4), num_classes=2, dropout=0.5): super().__init__() self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=0) self.convs = nn.ModuleList([ nn.Conv1d(embedding_dim, num_filters, size) for size in filter_sizes ]) self.fc = nn.Linear(num_filters * len(filter_sizes), num_classes) self.dropout = nn.Dropout(dropout) def forward(self, x): emb = self.embedding(x) # [batch, seq_len, embedding_dim] emb = emb.transpose(1, 2) # [batch, embedding_dim, seq_len] pooled = [] for conv in self.convs: c = torch.relu(conv(emb)) # [batch, num_filters, seq_len - k + 1] c = F.max_pool1d(c, c.size(2)).squeeze(2) # [batch, num_filters] pooled.append(c) out = torch.cat(pooled, dim=1) # [batch, num_filters * len(filter_sizes)] out = self.dropout(out) return self.fc(out)forward里每个张量的形状变化都写在注释里。conv(emb)对三个卷积核分别输出不同长度的特征图,比如seq_len=50、filter_size=3时,卷积后长度是48。F.max_pool1d(c, c.size(2))对整个特征图做全局最大池化,squeeze(2)把长度1的维度去掉。三个池化结果拼接后长度是num_filters乘以3,等于768,过dropout后交给全连接层。
模型实例化时vocab_size直接用build_vocab返回的字典长度,因为建词表时已经预留了 和 两个位置,词表下标和embedding行号完全对齐。padding_idx=0让 位置的向量在反向传播时不被更新,补位不会干扰卷积层学习真实词语特征。
4.2 训练循环与验证逻辑
训练部分用Adam优化器加交叉熵损失。训练循环里我习惯保留几个关键细节:weight_decay对全连接层做L2正则缓解小数据过拟合;clip_grad_norm给梯度范数设上限,防止embedding层在个别样本上梯度爆炸搅乱整个词向量空间;保存权重用state_dict而不是整个model,文件体积小,加载时也不依赖模型类的序列化。
import torch.nn as nn from torch.optim import Adam from torch.utils.data import DataLoader device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = TextCNN(vocab_size=len(vocab)).to(device) optimizer = Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) criterion = nn.CrossEntropyLoss() train_loader = DataLoader(train_ds, batch_size=64, shuffle=True) val_loader = DataLoader(val_ds, batch_size=64, shuffle=False) def evaluate(model, loader): model.eval() correct = total = 0 with torch.no_grad(): for ids, labels in loader: ids, labels = ids.to(device), labels.to(device) logits = model(ids) correct += (logits.argmax(1) == labels).sum().item() total += labels.size(0) return correct / total best_acc = 0.0 for epoch in range(10): model.train() for ids, labels in train_loader: ids, labels = ids.to(device), labels.to(device) optimizer.zero_grad() logits = model(ids) loss = F.cross_entropy(logits, labels) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm=3.0) optimizer.step() val_acc = evaluate(model, val_loader) print(f'epoch {epoch+1:02d}, val_acc {val_acc:.4f}') if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), 'best_model.pt')每个epoch结束做一次验证,目的不是看训练集loss归零,而是挑验证集准确率最高的权重保下来。训练后期模型会开始过拟合训练集,验证集分数回落,保留best_model.pt相当于给自己留了一颗后悔药。如果某个epoch后验证分数突然掉得很厉害,不用等它自己恢复,直接停下来调参数再跑。
4.3 训练参数与超参配置表
下面这组参数是中小规模中文评论数据上最常见的起点,我在多个项目里都用过,基本不会出现跑不动或者训练发散的问题。参数的意义不在于数值本身,而在于你知道哪个参数影响什么。
| 参数 | 常见取值 | 说明与调整方向 |
|---|---|---|
| embedding_dim | 128 | 数据量小用128,数据大且词表丰富可以上300 |
| filter_sizes | (2, 3, 4) | 覆盖二元到四元词组特征,短评可去掉4 |
| num_filters | 256 | 控制模型宽度,显存不够就降到100 |
| dropout | 0.5 | 过拟合时调大到0.6,欠拟合时调小到0.3 |
| batch_size | 64 | 显存不足降到32或16 |
| lr | 1e-3 | 配合Adam,训练震荡就降到1e-4 |
| max_len | 50 | 评论短就32,长文本评论多就100 |
| epochs | 10 | 配合早停,验证集连续3轮不升就停 |
这些参数不是玄学,但也没必要一开始就逐个调优。先固定一组跑到收敛,然后每次只改一个参数对比验证集分数。比如想试num_filters从256降到128的效果,其他全部不动,跑两遍看差异。同时改三个参数,出了问题你根本分不清是哪个改坏的。
5. 踩坑记录:训练不收敛、预测全一个类、复现不稳定的四个典型问题
5.1 Embedding层被意外冻结了
现象:训练代码运行正常,loss从0.7往下走,每个epoch都正常打印,val_acc也在提升,但提升到某个平台后完全停滞。提交测试集预测,结果几乎没有区分度。
原因:代码里如果加载了预训练Embedding,常见写法是加载权重后设置requires_grad=False,后续忘了恢复。模型只有卷积层和全连接层在更新,embedding层的向量停在初始状态,无论怎么训练都只是在一个固定空间里找分类面,表现自然受限。
解决:在模型初始化后加一行自检代码:
assert model.embedding.weight.requires_grad, 'embedding层被意外冻结了'放在第一个训练step之前执行,一旦冻结立刻报错,不用等到训练完才发现。如果确实想先冻结几个epoch再解冻,训练中途单独加一行恢复代码即可。
5.2 学习率过高导致loss过山车
现象:第1个epoch正常,从第2个epoch开始loss忽高忽低,同一个batch的loss第2轮是0.8,下一轮变成5.8,数据完全没变。
原因:Adam默认学习率1e-3在多数分类任务里合适,但TextCNN的embedding层参数规模大,梯度更新对噪声更敏感。低频词对应的向量梯度稀疏但数值偏大,乘上1e-3容易被推离合理区域,导致loss反复横跳。
解决:整体学习率降到1e-4通常能稳定下来。更精细的做法是给embedding层单独设置较低学习率,其余层维持1e-3:
optimizer = Adam([ {'params': model.embedding.parameters(), 'lr': 1e-4}, {'params': model.convs.parameters()}, {'params': model.fc.parameters()} ], lr=1e-3)这里第一组参数单独指定lr,后面两组走默认的1e-3。实测对比过,分开设置lr在多数数据集上能比一个统一学习率多一到两个点的验证集准确率。
5.3 数据不平衡导致模型全部预测多数类
现象:验证集准确率0.86,看着不错。拿训练好的模型预测新评论,输出结果全部是正面。进一步看分类报告,负面样本F1接近0,模型实际上是个空壳。
原因:标签分布正负比9比1。模型最优策略就是全部预测多数类,准确率天然0.9,交叉熵对这个无效策略几乎不构成惩罚压力,训练直接停在局部最优。
解决:先看value_counts的输出,少数类占比低于20%就要处理。在CrossEntropyLoss里传权重:
weights = torch.tensor([3.0, 1.0]).to(device) # 少数类权重更大 criterion = nn.CrossEntropyLoss(weight=weights)权重数值用多数类样本数除以少数类样本数估算,比如9比1就设置权重为[1.0, 9.0]左右。同时评估指标不能只看准确率,打印F1分数和混淆矩阵才能反映真实分类能力。
5.4 推理时重新换了一套词表
现象:训练时val_acc保持0.9,加载模型做推理时,同一个句子预测结果却明显变差。打印ID序列发现大量词变成了 。
原因:词表只在训练脚本里构建,推理脚本重新用Counter构建了另一套词表,word到ID的映射和训练时完全错位。模型权重里的embedding行号对应的是训练词表,推理新词表的ID和行号对不上,相当于鸡同鸭讲。
解决:训练结束后把词表保存成JSON归档:
import json with open('vocab.json', 'w', encoding='utf-8') as f: json.dump(vocab, f, ensure_ascii=False)推理脚本里唯一加载这个词表文件,不允许运行时重建。另一个隐蔽问题是要固定jieba版本。不同版本的jieba分词结果可能不同,同一个句子在训练环境和推理环境切成不同的词,模型没见过这些词,效果自然崩。实际生产环境里这是一个很难从loss曲线里发现的血泪经验,建议把依赖版本记录在requirements.txt里一并归档。
6. 把训练好的模型用起来:保存、加载与推理验证
模型训练到val_acc稳定在0.9附近后别急着收工,把模型权重和词表归档,再写一个独立推理脚本,用一条从没进过训练集的评论做端到端验证。这一步能暴露训练阶段埋下的数据泄漏和映射错位问题。
import json import torch from model import TextCNN with open('vocab.json', 'r', encoding='utf-8') as f: vocab = json.load(f) model = TextCNN(vocab_size=len(vocab), num_classes=2) model.load_state_dict(torch.load('best_model.pt', map_location='cpu')) model.eval() def predict(text, max_len=50): tokens = tokenize(text)[:max_len] ids = [vocab.get(w, vocab['<unk>']) for w in tokens] ids = ids + [vocab['<pad>']] * (max_len - len(ids)) x = torch.tensor([ids], dtype=torch.long) with torch.no_grad(): prob = torch.softmax(model(x), dim=1).squeeze(0) return prob test_cases = [ '物流很快,包装也很严实,整体满意', '质量太差了,用了两天就坏了,不推荐', ] for text in test_cases: prob = predict(text) print(text, '->', prob.tolist())predict函数复用训练阶段的tokenize和vocab,保证推理和训练在“字符到ID”的映射上完全一致。softmax输出的两个值分别对应负面和正面概率。业务上如果需要阈值控制,默认0.5就能用,但售后场景希望尽量抓负面,阈值往下调到0.35更合适,这个调优靠业务样本反馈而不是模型参数。
端到端验证做完后,再把测试集里预测错误的样本批量打印出来。错误样本往往集中在反讽、转折句和长文本上,比如“东西还行,客服态度真让人无语”。这些case暴露的正是TextCNN在上下文依赖上的短板,也提示了你下一步是否要换更强的模型。整个case分析不需要重新训练,几十行代码遍历测试集,把预测和真实标签不一致的样本写到文件即可。
我之前改一个项目版本时,忘了把vocab.json和best_model.pt一起归档,测试集准确率看着正常,换到新数据源后预测结果全乱,排查了半天才发现是两套机器上词表版本不一致。后来所有项目我都把词表、模型权重、分词器版本号放进同一个输出目录,作为一套不可拆分的产物。文本分类这类项目,模型结构只是其中一层,数据管线的稳定性才是上线后的主要成本。希望帮到你。
本文还有配套的精品资源,点击获取