简介:一套面向自然语言处理初学者与文本分类入门者的评论情感分类研究资料,以文本卷积神经网络TextCNN为基线模型,适用于课程设计、实践练习或快速搭建情感分析原型。压缩包约12.4MB,包含表格格式的10万条已标注训练集与1万条未标注测试集、文本卷积神经网络模型脚本、词表文件以及集成开发环境工程配置等文件,解压后即可查看代码与数据组织方式。目前已有120人浏览学习。完整流程涵盖评论情感分类的各个环节:先对评论文本做清洗、分词,并依据词表完成数值化编码;再通过词嵌入将词语映射为稠密向量,输入TextCNN的卷积层与池化层提取局部语义特征,最后由全连接层输出正面、负面或中性等情感标签。训练集与测试集的明确划分,也便于学习者理解模型泛化能力的评估方法。配合工程中保留的调试配置,适合动手复现并进一步尝试调整超参数或替换模型。
1. 十行卷积换来的高准确率:这份评论情感分类工程包到底讲了什么
第一次拿到这份项目文件时,我其实有点意外:一个情感分类任务,没有上BERT,没有finetune Transformer,核心代码只靠一个textcnn.py脚本,配上10万条带标签的训练集nCoV_100k_train.labled.csv和1万条测试集nCov_10k_test.csv,就把一整套流程跑通了。后来我仔细读了一遍才意识到,这恰好是工程上性价比最高的文本分类方案——TextCNN用很小的参数量,在短文本情感判别上能做到接近甚至追平复杂预训练模型的效果,而且训练速度能快一个数量级。对于评论这种长度集中在几十个字的场景,CNN的局部n-gram特征提取天然匹配。这篇博文我会从数据处理、词表构建、模型实现到训练评估,把这个包里的每一步拆开讲清楚,包括踩过的坑和调参路径。
2. 评论数据的清洗与词表构建:nCoV_100k_train.labled.csv到vocab.txt的完整流水线
很多人拿到CSV就直接塞进模型,结果训练loss死活降不下去。问题往往出在数据预处理这一环没做干净。这个项目包里,nCoV_100k_train.labled.csv是人工标注过的训练语料,每条评论对应一个情感极性标签。先别急着读CSV,第一件事是把标签分布摸清楚。
2.1 先看标签分布再谈建模
我用pandas快速过了一遍数据的分布情况,这里直接给出可复现的检查脚本:
import pandas as pd df = pd.read_csv("nCoV_100k_train.labled.csv", encoding="utf-8") print(df.columns.tolist()) print(df["label"].value_counts(normalize=True))常见的列名有text、label、id等,具体以实际文件为准。这一步输出的是每个类别的占比,比如正面0.42、负面0.33、中性0.25。这个分布直接决定了后续要不要做类权重平衡。类别不均衡超过3:1时,我一般会在损失函数里加class_weight,而不是粗暴地欠采样丢数据。另外注意CSV编码,这个数据集是从线上评论整理来的,用utf-8读报错就换成utf-8-sig,不要默认用gbk。
提示:
value_counts(normalize=True)返回的是频率而不是原始数量,用于快速核对类别比例是否合理。如果发现某个标签占比超过0.9,先检查是不是标注口径出了问题,而不是急着训练。
清洗规则按优先级排序:去除URL和@用户、把全角字符统一成半角、去掉连续重复的标点、过滤长度小于2的评论。中文不需要去除停用词,因为TextCNN的卷积操作本身会学习到虚词的组合模式,强行去掉"的、了、吗"反而丢失了语气信息,而语气恰好是情感判别的重要特征。
2.2 分词边界:基于词还是基于字的取舍
这个项目包的vocab.txt是预处理的核心产物。两种构建方式:基于词表用jieba分词,基于字表直接按字符切分。我在复现时推荐基于字构建词表,原因有两个:第一,评论里大量网络新词(比如"yyds"、"绝绝子")分词器切不准,按字切不会有OOV问题;第二,TextCNN的卷积核大小是按词窗口设计的,但改成字窗口后,卷积核尺寸需要整体缩小,后面3.3节会说具体参数。
构建词表的完整代码:
from collections import Counter def build_vocab(texts, max_size=50000, min_freq=2): counter = Counter() for t in texts: counter.update(list(t)) # 按字切分 vocab = {"<pad>": 0, "<unk>": 1} for ch, freq in counter.most_common(max_size - 2): if freq >= min_freq: vocab[ch] = len(vocab) return vocab vocab = build_vocab(df["text"].tolist()) with open("vocab.txt", "w", encoding="utf-8") as f: for ch, idx in vocab.items(): f.write(f"{ch}\t{idx}\n")min_freq=2用来过滤只出现一次的噪声字符,max_size限制了词表体量防止过拟合。注意<pad>索引0在训练时要被mask掉,<unk>索引1兜底所有词表外字符。后面的textcnn.py加载词表时会读取这个索引映射。
2.3 DataLoader里的截断与padding策略
评论长度参差不齐,而CNN要求定长输入。这个包里的处理方式不复杂:先统计训练集的长度分布,取95分位数作为max_len。我跑出来的结果大概是64到128之间,短文本取64就够了。截断策略上,如果评论是"前面铺垫后面表态"的结构,保留尾部信息更重要;如果是带货类的营销文案,开头就是核心卖点,那就截头。
我的处理习惯是把max_len定成动态参数而非写死:
from torch.nn.utils.rnn import pad_sequence def text_to_ids(text, vocab, max_len=64): ids = [vocab.get(ch, vocab["<unk>"]) for ch in text] if len(ids) > max_len: ids = ids[:max_len] # 截头保留尾部,可改为ids[-max_len:]保留头部 return ids ids_list = [text_to_ids(t, vocab, max_len=64) for t in df["text"]] ids_tensor = pad_sequence( [torch.tensor(x) for x in ids_list], batch_first=True, padding_value=vocab["<pad>"] )pad_sequence会把同batch内较短的序列自动补到最长长度,padding_value指定用<pad>的索引0来填充。这样做的效率比手动循环高,而且后续在embedding层可以直接把padding_idx=0传进去,让模型对padding位不更新梯度。
3. textcnn.py内核拆解:多尺度卷积如何捕捉评论里的情感信号
TextCNN的思路其实很直白:把评论看成一维的字符或词语序列,用多个不同宽度的卷积核去扫描局部窗口,每个窗口就相当于一个n-gram片段。情感词通常以"太差了"、"非常棒"这种模式出现,3-gram、4-gram的卷积核正好能覆盖这些模式。这份工程的textcnn.py实现的网络结构就是围绕这个逻辑展开的。
3.1 为什么选TextCNN而不是BiLSTM或Transformer
看这份工程的时候,我确认了一下选型思路:训练集有10万条,类别是三分类,这个规模下BiLSTM的串行计算会明显拖慢训练速度,Transformer需要更精细的learning rate调度和warmup才能稳定收敛。TextCNN的卷积是并行计算的,GPU利用率高,显存占用小,在10万条这个数据量级上,训练一轮只要几十秒。它的代价是不能捕获长距离依赖,比如"虽然服务态度不好,但是味道确实可以"这种转折结构,普通卷积核窗口覆盖不到全句。但在大多数评论场景里,情感表达是局部的,长距离转折属于少数case,牺牲这部分换训练效率是划算的。
3.2 网络前向传播的源码级拆解
下面这段是textcnn.py的核心结构,我按PyTorch的写法重新组织了一遍,每一层的含义在注释里标清楚了:
import torch import torch.nn as nn import torch.nn.functional as F class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim=128, num_classes=3, max_len=64): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) # 三种卷积核尺寸:2-gram, 3-gram, 4-gram,每种100个滤波器 self.convs = nn.ModuleList([ nn.Conv1d(embed_dim, 100, kernel_size=2, padding=1), nn.Conv1d(embed_dim, 100, kernel_size=3, padding=1), nn.Conv1d(embed_dim, 100, kernel_size=4, padding=2), ]) self.dropout = nn.Dropout(0.5) self.fc = nn.Linear(300, num_classes) def forward(self, x): # x shape: (batch, seq_len) emb = self.embedding(x) # (batch, seq_len, embed_dim) emb = emb.transpose(1, 2) # (batch, embed_dim, seq_len) 转成Conv1d期望的格式 conv_outs = [] for conv in self.convs: c = conv(emb) # (batch, 100, seq_len) c = F.relu(c) c = F.max_pool1d(c, c.size(2)) # 全局最大池化取每个特征图的最大值 conv_outs.append(c.squeeze(2)) out = torch.cat(conv_outs, dim=1) # (batch, 300) out = self.dropout(out) return self.fc(out) # (batch, num_classes)注意nn.Conv1d接收的输入是(batch, channels, length),所以embedding之后要做一次transpose。每类卷积核输出100个通道,经过全局最大池化后压成一维向量,三个卷积块拼接得到300维特征,最后接全连接层输出3类logits。padding=1和padding=2是为了让max_pool1d前后长度与seq_len保持对齐,不同尺寸卷积核的输出长度恰好一致,拼接时维度才不会出问题。
注意:
padding_idx=0必须和vocab.txt里<pad>的索引保持一致,否则模型会把padding位当成有效特征学习。
3.3 卷积核尺寸与滤波器数量的调参规律
textcnn.py里的默认参数有明确的调参规律。卷积核尺寸对应n-gram的跨度:评论里"太好看了"是4个字,用kernel_size=3的卷积核扫到"太好看"和"好看了"两个窗口,都能捕捉到褒义信号。一般保持2、3、4三档组合就够了,增大到5、6对短文本提升有限,反而增加过拟合风险。滤波器数量决定每个窗口提取多少种特征模式,我通常从100起步,如果验证集F1涨得慢就把每档加到150或200,但显存占用会线性增长,10万条数据量级用100~150是性价比最高的区间。embedding维度建议128起步,降到50以下模型会欠拟合,升到300以上训练时间明显变长但指标提升很小。项目包里没给预训练词向量,直接用随机初始化的Embedding也能训到不错的效果,如果后续想提点可以加载Word2Vec或GloVe的向量做初始化。
3.4 损失函数与优化器选型
三分类情感任务用的是nn.CrossEntropyLoss,它内部把softmax和负对数似然合并了,所以网络最后一层不需要手动加softmax。如果标签分布不均衡,就像2.1节说的那样,给CrossEntropyLoss传weight参数:
class_weights = torch.tensor([1.0, 1.2, 0.8]) # 根据类别占比反比设置 criterion = nn.CrossEntropyLoss(weight=class_weights) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)优化器用Adam(学习率1e-3),比SGD收敛快且不需要精细的momentum调节。模型收敛稳定后,再把学习率降到1e-4继续训几个epoch做精细调整,这一招对TextCNN效果很明显。
4. 从训练到评估:在nCov_10k_test.csv上把F1稳定住
训练阶段的核心指标不是loss,而是验证集上的F1分数。这个工程包的测试集nCov_10k_test.csv是未标注的,需要先跑一次预测得到结果,再和真实标签对比计算指标。实际操作时,我会从训练集里切10%作为验证集,先用训练集调好超参数,最后才在测试集上做一次性的最终评估。
4.1 训练循环的写法与早停策略
训练代码框架相对固定,但有几个细节直接决定最终效果:
def train_one_epoch(model, dataloader, optimizer, criterion, device): model.train() total_loss = 0 for batch_ids, batch_labels in dataloader: batch_ids = batch_ids.to(device) batch_labels = batch_labels.to(device) optimizer.zero_grad() logits = model(batch_ids) loss = criterion(logits, batch_labels) loss.backward() optimizer.step() total_loss += loss.item() return total_loss / len(dataloader) def evaluate(model, dataloader, device): model.eval() preds, trues = [], [] with torch.no_grad(): for batch_ids, batch_labels in dataloader: logits = model(batch_ids.to(device)) preds.extend(logits.argmax(dim=1).cpu().tolist()) trues.extend(batch_labels.tolist()) return preds, trues关键点在于model.train()和model.eval()的切换。Dropout只在训练时生效,评估阶段如果不切到eval模式,每批次前向传播的随机失活会导致结果抖动。
早停策略:每个epoch结束后算一次验证集F1,如果连续5个epoch没有提升就保存当前最佳模型并停止训练。注意保存模型时不要整个模型对象都存,只存state_dict就行:
python train.py --train nCoV_100k_train.labled.csv --valid valid.csv --epochs 20 --lr 1e-3 --batch_size 256batch_size的选取要看显存和数据的综合情况。文本短的时候每条样本占用的显存不大,batch_size可以开到256甚至512,这样训练更快、梯度更稳定。数据量大时batch_size调大,但要同步调高学习率,习惯上按lr_new = lr_old * sqrt(new_batch/old_batch)去配平。
4.2 各类别F1的计算与混淆矩阵分析
三分类只看整体准确率会掩盖问题。典型情况是"中性"类被大量误判成正面或负面,导致准确率看着还行但F1掉得厉害。用sklearn直接输出细粒度指标:
from sklearn.metrics import classification_report, confusion_matrix print(classification_report(trues, preds, target_names=["negative", "neutral", "positive"])) print(confusion_matrix(trues, preds))classification_report会输出每个类别的precision、recall、F1和样本量。如果中性类的recall明显偏低,说明模型倾向于把中性评论归类到两个极性类别里,常见的修正办法是给中性类加一点loss权重,或者对中性类的训练样本做简单的过采样。混淆矩阵里的错配位置也值得关注——如果负面被大量误判为正面,意味着训练数据里有标注噪声或某些负面表达过于隐晦。
4.3 数据增强对鲁棒性的真实影响
10万条训练数据不算少,但评论里口语化表达五花八门,同一个意思能有几十种说法。做数据增强时要注意分寸——过度增强会让模型学到噪声。我实验中比较稳妥的两种增强手段:同义词替换,把"好"替换成"棒"、"赞";随机删除,以0.1的概率删除句子中的某些字。这两种操作都不会改变句子原有的情感极性(同义词替换方向一致,随机删除只去掉辅助字词),数据量能扩大1.5到2倍,测试集F1能提升1~2个百分点。注意增强只用于训练集,测试集保持原始数据。
4.4 在测试集上做最终评估的注意事项
当所有调参工作完成后,在nCov_10k_test.csv上做最终预测。测试集没有标签时该怎么办?没有标签就没法算指标。这份工程里nCov_10k_test.csv可能是没有标注的,我会用训练好的模型先预测出一份标签文件,然后交给业务方标注或抽样验证。要是测试集本身带标签,那就直接复用4.1节的evaluate函数。
提示:最终评估只做一次。如果根据测试集结果反复调参,测试集就变成了验证集,评估结果会被污染,F1虚高。
5. 模型部署时的推理加速与预测结果校验
5.1 单条评论推理时重建输入管线
训练和推理最大的区别在于:推理时不能依赖DataLoader的batch处理,需要单独实现一条和训练完全一致的预处理链路。我一般单独写一个predict.py,封装好清洗、转id、padding三个步骤:
def predict_single(text, model, vocab, device, max_len=64): text = clean_text(text) ids = text_to_ids(text, vocab, max_len) ids_tensor = torch.tensor([ids], dtype=torch.long, device=device) model.eval() with torch.no_grad(): logits = model(ids_tensor) prob = torch.softmax(logits, dim=1) pred = logits.argmax(dim=1).item() return pred, prob.squeeze().tolist() # 调用示例 pred, prob = predict_single("这家店的服务态度太差了,东西也不新鲜", model, vocab, device) print(pred, prob) # 输出类别索引,如1(负面),以及各概率这段代码里的clean_text要和2.1节训练用的清洗函数保持一致,text_to_ids的截断方向也要相同,否则推理时的输入分布跟训练分布不一致,结果自然不可信。返回的prob是三个类别的概率,在输出日志时建议直接展示概率分布,而不是只看最终类别。比如正面0.6、中性0.35、负面0.05,说明模型对"这家的味道很亲切"这样的评论有判断倾向,但还不够确定。这类样本需要单独抽出来看,往往藏着语言歧义。
5.2 类别离散阈值修正与业务层面衔接
三分类模型输出的是一个概率分布,默认取最大概率对应的类别,但工程上常常需要把阈值改得更保守。比如业务方要求高准确率优先,那就要求最高概率必须大于0.7才输出类别,否则归类为"待人工处理"。这只是阈值上调,不会改变模型权重,但能明显减少误报。这种调整在代码里就是一行比较逻辑:
max_prob, pred = prob.max(dim=1) if max_prob.item() < 0.7: pred = -1 # 标记为低置信度样本训练集里如果标注了中性类,那么阈值策略还可以做得更细:正面和负面之间的界限远没有正面和中性的界限容易划分,可以为两对边界分别设阈值。
5.3 量化推理把单条耗时压到毫秒级
TextCNN模型本身已经很小,但部署到CPU端做实时推理时还能再压缩一步。PyTorch的torch.quantization可以把模型从FP32量化到INT8,模型体积缩小3/4,单条推理速度提升2~3倍,准确率基本不掉。量化最省事的是torch.jit加动态量化:
import torch model.eval() quantized_model = torch.quantization.quantize_dynamic( model, {torch.nn.Linear, torch.nn.Embedding}, dtype=torch.qint8 ) torch.jit.save(torch.jit.script(quantized_model), "textcnn_quantized.pt")quantize_dynamic只量化和embedding层和全连接层,不改卷积层,原因是Conv1d的动态量化支持其实不如Linear稳定,我们在纯CPU环境下验证时卷积层保持FP32反而更可靠。量化后加载方式也变了,要用torch.jit.load还原模型,再走一遍predict_single的推理管线验证输出是否和量化前一致。如果发现量化后某个类别全被推成同一个标签,大概率是padding位参与了embedding量化计算,回到3.2节检查padding_idx是否设置正确。
本文还有配套的精品资源,点击获取