多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

2024年TextCNN中文情感分析实战:PyTorch实现与避坑指南

2024年TextCNN中文情感分析实战:PyTorch实现与避坑指南 简介面向中文文本情感分析入门者与NLP开发者的完整实战资源包基于TextCNN卷积神经网络实现中文正负情感分类适合舆情监控、电商评论分析、社交媒体情绪研判等场景。资源共24个文件压缩包约89.49MB涵盖5个Python脚本、4个Jupyter Notebook、4个文本数据文件、1个训练好的h5模型及checkpoint、3张模型结构示意图等代码组织清晰数据、模型、说明文档齐备可直接运行。其中训练脚本、评估脚本、数据处理工具可独立复用中文停用词表与正负样本语料便于快速构造数据集Notebook逐步演示数据探索、模型构建与评估流程并输出可视化图表README对环境和依赖做了说明HTML报告则能辅助查看分析结果适合从零复现。已有499人学习下载实战价值高。借助该资源可快速掌握TextCNN在中文情感分析中的完整落地方法包括文本预处理、词向量构建、模型训练与效果评估还可基于现有代码进行参数调优或迁移至其他中文分类任务。1. 为什么到了 2024 年我还在用 TextCNN 做中文情感分析先给个反直觉的结论在句子级中文情感分类这个任务上TextCNN 经常能打得过比它“看起来高级”很多的 Bert 系模型而且是在训练时间快一个数量级的前提下。所谓“高级模型翻车TextCNN 兜底”的情况我在真实项目里见过太多次——数据量就几千条、标注还有噪声那种场景下 Bert 微调很容易过拟合而 TextCNN 这种结构简单的模型反而稳得出奇。这篇实战笔记要做的就是把“基于 TextCNN 的中文文本情感分析”从数据准备到模型训练再到最后的推理封装完整走通一遍。我提供了一个可以直接运行的完整方案包含代码和数据准备逻辑使用 PyTorch 实现 TextCNN在中文评论数据集上做二分类正向 / 负向情感分析。中途会把每个环节的参数为什么这么设讲清楚也会把最容易踩的坑指出来。这篇内容适合两类人一类是被课程作业或小项目逼着要快速出结果的学生另一类是手头有几千条国产数据、想先跑个基线模型看看效果的一线算法工程师。不适合谁不适合那些张口就是“大模型时代还在学 TextCNN 干嘛”的人——等你真正要上线一个低延迟、低资源占用的分类服务时就明白经典模型为什么一直没被淘汰。2. 文本预处理和词表构建中文情感分析的第一个分水岭2.1 为什么中文不像英文那样暴力 split 就够英文文本用空格split()就能切出基本可用的 token但中文不行。“这手机屏幕真清晰啊”这句话如果整句作为一个 token 去训练模型根本学不到泛化能力。文本情感分析的第一步处理直接决定上游模型能看到什么粒度按字切还是按词切要不要分词jieba 精度模式下 user dict 怎么维护这些都是工程上首先要回答的问题。我一般采用 jieba 精确模式做中文分词同时维护一个高频行业词表让分词结果更符合领域表达。比如数码领域“不卡”这个词会被 jieba 默认拆成“不”和“卡”情感表达的必要信息就断了。此时在user_dict.txt里加一行“不卡 5 n”就能强制合并。import jieba # 加载自定义词典 jieba.load_userdict(user_dict.txt) text 这手机用了一年半居然还不卡电池也扛得住。 tokens jieba.lcut(text) print(tokens) # 输出[这, 手机, 用, 了, 一年半, , 居然, 还, 不卡, , 电池, 也, 扛得住, 。]逻辑说明jieba.lcut返回的是 Python list比生成器jieba.cut更好调试和后续处理。自定义词典每行三个字段词、词频、词性词频越高分词时越倾向于合并成这个词。这个机制在处理“真香”“翻车”这类网络新词时特别有用。参数说明如果你的数据和数码无关就不要照抄“不卡”这个词典项。我会用一个小脚本把训练集里预测错误的高频词拉出来人工确认后写入user_dict.txt反复迭代两三轮分词质量基本就稳定了。2.2 词表构建过滤低频词、预留 PAD 和 UNK分词之后要把字符串序列映射成数值序列。这个环节的关键不是“能不能跑”而是词表的大小控制。我默认把词表上限设为MAX_VOCAB_SIZE 50000同时过滤掉出现次数只有 1 一次的 token——这些低频词几乎全是错分词和标点噪声留着只会增加 embedding 层的参数规模没什么收益。多提一句中文的标点符号“。”等在情感分析里不是完全没信息语气强烈的句子往往伴随连续的感叹号但我不单独处理标点而是把它们直接留给模型去学习。from collections import Counter def build_vocab(corpus, max_size50000, min_freq3): counter Counter() for tokens in corpus: counter.update(tokens) # 按出现频率从高到低排序截断到 max_size vocab_list [w for w, c in counter.most_common(max_size) if c min_freq] # 0: PAD, 1: UNK vocab {PAD: 0, UNK: 1} for idx, word in enumerate(vocab_list, start2): vocab[word] idx return vocab vocab build_vocab(train_corpus) print(词表大小:, len(vocab))逻辑说明Counter.most_common(max_size)先砍掉超长尾再通过c min_freq把低频词过滤掉。这里先截断再过滤的顺序是有原因的——如果先过滤再取most_common(max_size)那“第 50001 个词的词频是 3而第 49999 和 50000 个词频也是 3”这种情况会随机丢掉一些同频词行为不稳定。参数说明min_freq3是个相对稳的经验值。数据量只有 3000 条时min_freq2也行如果数据超过 5 万条min_freq5往往更干净。这个值本质是在词表覆盖率和噪声抑制之间做平衡。2.3 截断与 Padding序列长度到底设为多少序列长度选多少是 TextCNN 中文情感分析里最值得花时间的参数。设短了很多有效语义被截掉设长了卷积核覆盖的长距离特征一直是 PAD 区域计算浪费、还引入噪声。通用做法是看训练集的 token 长度分布取 95% 分位数。我会先用一个小循环把训练集所有分句后的 token 数量算出来画个长度分布直方图然后选定固定值。import numpy as np seq_lens [len(tokens) for tokens in train_corpus] print(95% 分位数:, int(np.percentile(seq_lens, 95))) print(最大长度:, max(seq_lens)) MAX_LEN int(np.percentile(seq_lens, 95)) # 设成分位数取整逻辑说明不用平均长度而用 95% 分位数是因为平均长度容易被大量短评拉低导致较长但语义完整的样本被截坏取 95% 分位数保证绝大多数样本完整进入模型。少部分超长样本的尾部被截断对情感判断影响不大——情感信息通常集中在开头和结尾这个特性在语料里很常见。参数说明MAX_LEN一旦定下就不要频繁改动因为后续动 embedding 层和卷积层的 shape 都要跟着变。一般评论数据 95% 分位数在 30 到 80 之间超过 150 的语料我建议先怀疑是不是分词粒度出了问题而不是直接拉长序列。import torch from torch.nn.utils.rnn import pad_sequence def encode_and_pad(tokens_list, vocab, max_len): ids_list [] for tokens in tokens_list: ids [vocab.get(w, 1) for w in tokens[:max_len]] # UNK id 为 1 pad_len max_len - len(ids) ids ids [0] * pad_len if pad_len 0 else ids ids_list.append(ids) return torch.tensor(ids_list, dtypetorch.long) train_ids encode_and_pad(train_corpus, vocab, MAX_LEN)逻辑说明先按max_len截断再计算需要补多少个PAD。vocab.get(w, 1)中第二个参数表示词不在词表时返回 1而UNK恰恰是 id1一个函数同时完成 OOV 词替换和截断逻辑。注意截断和补齐是同一个循环内完成的不要先 pad 后截断——那会把 PAD 符号当真词导致 batch 里所有短文本长度都是 max_len反而产生大量无效计算。3. TextCNN 模型结构从 PyTorch 代码读懂每一层在做什么3.1 为什么是“并列卷积 最大池化”而不是别的结构TextCNN 的核心思路特别朴素用多个不同宽度的卷积核去扫描文本序列每个卷积核看到的是不同 n-gram 的局部特征然后通过最大池化提取每个特征图里最“强”的信号。这句话展开说就是宽度为 2 的卷积核看到的是二元词组特征宽度为 3 的看三元词组宽度为 4 的看四元词组它们的特征图拼起来相当于模型同时从多个 n-gram 粒度观察句子。为什么不直接用 LSTM因为在句子级短文本任务上序列的长距离依赖并没有那么重要而 LSTM 的串行结构让训练和推理都更慢。TextCNN 的卷积操作天然可以并行计算显存利用率更高。为什么不直接用 Transformer数据集不够大时自注意力很容易记住训练集噪声而不是泛化特征。TextCNN 的结构约束更强反而更抗过拟合。我一般设置三个卷积核宽度2、3、4每个宽度有 256 个卷积核。这是 TextCNN 最经典的配置对于大多数中文点评类数据一句到三句覆盖的 n-gram 范围已经够用。3.2 核心模型代码Embedding、并列卷积、池化、拼接、分类现在给出完整的 PyTorch 模型实现。这里没有用 torch.nn.Sequential 硬堆而是按逻辑块拆开写方便你在每个子模块里打断点看 shape。import torch import torch.nn as nn import torch.nn.functional as F class TextCNN(nn.Module): def __init__(self, vocab_size, embedding_dim128, filter_sizes(2, 3, 4), num_filters256, num_classes2, dropout0.5): super().__init__() # 词嵌入层随机初始化不加载预训练向量 self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idx0) # 并列卷积层每种宽度一个 Conv1d self.convs nn.ModuleList([ nn.Conv1d(in_channelsembedding_dim, out_channelsnum_filters, kernel_sizek) for k in filter_sizes ]) # 全连接分类层 self.fc nn.Linear(num_filters * len(filter_sizes), num_classes) self.dropout nn.Dropout(dropout) def forward(self, x): # x shape: (batch_size, seq_len) x self.embedding(x) # (batch_size, seq_len, embedding_dim) x x.transpose(1, 2) # (batch_size, embedding_dim, seq_len) conv_outputs [] for conv in self.convs: c conv(x) # (batch_size, num_filters, seq_len - k 1) c F.relu(c) c F.max_pool1d(c, c.size(2)) # 全局最大池化得到 (batch, num_filters, 1) c c.squeeze(2) conv_outputs.append(c) # 把所有卷积核的特征拼接 x torch.cat(conv_outputs, dim1) # (batch_size, num_filters * len(filter_sizes)) x self.dropout(x) logits self.fc(x) return logits逻辑说明nn.Conv1d默认沿最后一个维度滑动所以 embedding 之后必须先transpose(1, 2)把序列长度换到最后。每个卷积输出经过 ReLU然后做全局最大池化F.max_pool1d(c, c.size(2))里的c.size(2)是当前特征图的序列长度这行代码拿整张特征图的最大值相当于把“这个 n-gram 模式在句子任意位置出现过的最强信号”提取出来。三个卷积核分别得到 256 维向量拼接成 768 维最终由全连接层映射到 2 类 logits。参数说明padding_idx0表示 id 为 0 的PAD位置在 embedding 层中被固定为全零向量并且在反向传播时它的梯度不计入。这个细节非常重要——不设PAD的零向量模型会从无意义位置学到虚假信号。dropout0.5是训练初期比较激进的设置如果发现训练集准确率都上不去先降到 0.3。3.3 损失函数、优化器和训练超参先让模型动起来再谈调优损失函数直接用CrossEntropyLossPyTorch 里的它自带 softmax 操作日志概率分布和真值标签做交叉熵。优化器我选择Adam原因是它对初始学习率不那么敏感适合快速跑通如果你确认要冲更高精度再换成 SGD momentum 慢慢调不迟。model TextCNN(vocab_sizelen(vocab)) loss_fn nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3)逻辑说明lr1e-3是我个人调出来的基准值。TextCNN 参数量不大1e-3 起步通常能正常收敛如果 loss 在前 100 步不降我一般先看数据和 padding 是否有问题而不是立刻把学习率调小。学习率调小是治标不治本。批次大小batch_size128基于经验句子级短文本分类的每条样本经过卷积池化后占显存不多128 的批次配合默认 GPU 可以稳定训练。如果你用 CPU 训练batch_size 降到 32 或 16否则每个 step 的时间会被拉得很长。训练轮数 epoch 设在 10 到 20 之间配合早停策略否则很容易过拟合。4. 训练循环与评估策略准确率之外还要看哪条曲线4.1 一个标准的 PyTorch 训练循环Train / Eval 分离训练循环看似简单但有一个坑模型在训练和评估时Dropout 和 BatchNorm 的行为完全不一样。PyTorch 里通过model.train()和model.eval()切换模式漏掉任何一行你会在 eval 阶段拿到一个带着随机 Dropout 的不稳定预测结果。from torch.utils.data import DataLoader, TensorDataset # 假设 train_ids, train_labels 是处理好的张量 dataset TensorDataset(train_ids, train_labels) loader DataLoader(dataset, batch_size128, shuffleTrue) model.train() for epoch in range(10): total_loss 0 for batch_x, batch_y in loader: optimizer.zero_grad() logits model(batch_x) loss loss_fn(logits, batch_y) loss.backward() optimizer.step() total_loss loss.item() avg_loss total_loss / len(loader) print(fEpoch {epoch1}, Loss: {avg_loss:.4f})逻辑说明optimizer.zero_grad()每步都要调否则梯度会在多个 batch 上累积如果你发现 loss 曲线剧烈震荡但方向逐渐偏离检查这一步是不是漏了。model(batch_x)直接返回 logits交给CrossEntropyLoss计算。注意batch_y的类型必须是torch.long否则损失函数内部会因为类型不匹配报错。4.2 评估指标不能只看准确率Precision / Recall / F1 必须一起看情感分析二分类里如果正向样本占 80%、负向样本占 20%模型全部预测正向就能拿到 80% 准确率——但那没有任何实用价值。我一般同时打印混淆矩阵和三个核心指标精确率、召回率、F1 分数且特别关注负向类的 F1因为负向评论通常才是业务上最需要揪出来的。from sklearn.metrics import classification_report, confusion_matrix def evaluate(model, eval_loader): model.eval() all_preds [] all_labels [] with torch.no_grad(): for batch_x, batch_y in eval_loader: logits model(batch_x) preds torch.argmax(logits, dim1) all_preds.extend(preds.cpu().tolist()) all_labels.extend(batch_y.cpu().tolist()) print(confusion_matrix(all_labels, all_preds)) print(classification_report(all_labels, all_preds, target_names[负向, 正向])) return all_preds, all_labels evaluate(model, eval_loader)逻辑说明torch.no_grad()是推理阶段必须开的它告诉 PyTorch 不需要构建计算图推理速度更快、显存占用更小。torch.argmax(logits, dim1)在 logits 的类别维度上取最大值下标得到预测标签。用confusion_matrix能直观看到哪些真实负向样本被预测成了正向——这是后续去优化分词规则的重要线索。评估还有一个细节model.eval()之后如果要继续训练必须再调用model.train()。这个顺序一旦弄反模型在无意间一直以 eval 模式训练Dropout 完全失效模型过拟合速度会明显加快。4.3 早停和模型保存训练到第几个 epoch 的模型最值得留TextCNN 训练在 5 到 10 个 epoch 就很容易在验证集上达到峰值再往后训练集准确率继续上升、验证集准确率开始下滑这就是过拟合的典型信号。早停策略是每轮验证结束如果 F1 score 超过历史最佳就把模型快照保存下来同时记录连续多少轮没有改善patience达到阈值就提前终止训练。from sklearn.metrics import f1_score best_f1 0.0 patience_counter 0 best_state None for epoch in range(15): # 训练代码... # 验证代码... preds, labels evaluate(model, eval_loader) val_f1 f1_score(labels, preds, averagebinary, pos_label0) if val_f1 best_f1: best_f1 val_f1 best_state {k: v.clone() for k, v in model.state_dict().items()} patience_counter 0 print(fEpoch {epoch1}: 保存最佳模型F1{val_f1:.4f}) else: patience_counter 1 print(fEpoch {epoch1}: F1 无提升patience{patience_counter}) if patience_counter 3: print(早停触发) break # 训练结束后恢复最佳权重 model.load_state_dict(best_state)逻辑说明用clone()深拷贝状态字典避免后续optimizer.step()反向传播时已经保存的权重被原地修改。这是 PyTorch 里保存模型时最常被忽略的坑——直接赋值best_state model.state_dict()到头来所有 epoch 存的是同一个指针。为什么 F1 取pos_label0因为我把负向类定义为 0负向识别在业务上更要紧所以要专门针对它做早停优化。5. 避坑中文情感分析里最常踩的 5 个坑5.1 分词把“不”和“好”切开模型学到的是“好”现象模型在验证集上对“这手机拍照不好”这类否定句的预测一直偏正向。原因jieba 默认分词把“不好”切成“不”和“好”TextCNN 的 2-gram 卷积核虽然能捕捉到“不好”的相邻关系但如果词表里“不好”经常和“不太行”“一般般”等变体混在一起模型很难把“不”这个前缀和负向语义稳定关联起来。解决自定义词典里加入领域常用否定组合词比如“不好”“不咋样”“很失望”。训练时jaccard 相似度不够强的否定表达会越来越少F1 优先回升。5.2 验证集随机划分导致数据泄露现象验证集 F1 高达 0.95上线后却只有 0.75而且误差集中在某几个类别上。原因数据是按“某个用户的所有评论”收集的但随机划分时同一个用户的不同评论被分到了训练集和验证集两处。模型在验证集上看到的文本风格和词汇在训练集里见过太多同类高估了泛化能力。解决改用GroupShuffleSplit把用户 ID 作为 group 传入保证同一个用户的评论只出现在训练集或验证集中的一个。如果数据没有用户 ID也可以用文本的句子编号聚类后再划分。5.3label0 和 1 的张量类型写错训练 loss 不为负数现象训练时 loss 徘徊在 0.7 上下完全不下降模型输出看着没问题但数值就是不动。原因nn.CrossEntropyLoss要求 target 是torch.long类型如果你的train_labels是从 CSV 读进来的 numpy int64直接转torch.tensor会被保留成 int64没问题但如果你用了torch.tensor(df[label].values, dtypetorch.float32)target 就变成 floatCrossEntropyLoss 内部会自动把它当多标签处理行为奇异。解决创建 TensorDataset 之前统一执行labels labels.long()。更稳妥的方式是在数据加载模块里直接torch.as_tensor(label_list, dtypetorch.long)。5.4 卷积核长度大于序列长度报错信息却很难看出原因现象某个 batch 的序列长度是 3几乎都是 PAD但卷积核宽度是 4MaxPool1d的内部运算报出RuntimeError: max_pool1d() input has non-empty dim。原因输入序列比卷积核还短的时候卷积输出特征图长度为负数池化操作无法执行。这在短文本数据里非常常见尤其是“嗯”“好”“行”这种一字评论。解决在encode_and_pad里保证max_len至少比最大的卷积核尺寸大 1一般直接设置MAX_LEN max(MAX_LEN, max(filter_sizes)) 1。如果仍然出现可以在forward里把长度小于卷积核的样本过滤掉但更推荐前者因为后者会导致 batch 里样本数量不稳定。5.5 CPU 训练慢到怀疑人生先查是不是 batch_size 太大现象用 CPU 跑 1 万条数据每个 epoch 要 20 分钟。原因Conv1d在 CPU 上的速度瓶颈主要在内存带宽batch_size 从 128 降成 32计算量确实小了但因为 PyTorch 的线程调度开销占比高总时间反而可能差不了多少。真正的瓶颈往往是 token 序列没有被 pad 到相同长度导致 DataLoader 无法把矩阵运算向量化。解决先把MAX_LEN的 95% 分位数值打印出来看一眼如果确有大量样本远短于MAX_LEN考虑把批次内的动态 paddingpad_sequence(..., batch_firstTrue)换成静态MAX_LEN。前者省算力但每次都要重新拷贝后者固定 shapeCPU 上的缓存利用率更高实测在很多场景下反而更快。6. 推理封装把训练好的模型接进真实业务训练完成后模型只是个state_dict要变成能直接服务的形式至少还要做三件事加载模型、封装 predict 函数、处理未知词的边界情况。我一般在项目里单独维护一个predict.py不把推理逻辑混在训练脚本里否则线上代码和训练代码互相污染后面维护特别痛苦。import jieba import torch LABELS {0: 负向, 1: 正向} # 推理阶段必须使用和训练一致的分词参数、词表、MAX_LEN def predict_sentiment(text, model, vocab, max_len): model.eval() tokens jieba.lcut(text) ids [vocab.get(w, 1) for w in tokens[:max_len]] pad_len max_len - len(ids) ids ids [0] * pad_len if pad_len 0 else ids x torch.tensor([ids], dtypetorch.long) with torch.no_grad(): logits model(x) prob torch.softmax(logits, dim1).squeeze() pred torch.argmax(logits, dim1).item() return LABELS[pred], prob[pred].item() # 加载训练阶段保存的最佳权重 model TextCNN(vocab_sizelen(vocab)) model.load_state_dict(torch.load(best_model.pt, map_locationcpu))逻辑说明推理前先model.eval()保证 Dropout 被关闭输出是确定性的。vocab.get(w, 1)处理 OOV 词——未知词统一映射为UNK的 id 1。这里有个细节torch.load必须带上map_locationcpu否则在只有 CPU 的服务器上加载 GPU 权重会直接报错。关于模型保存我还想补充一句不要只用 PyTorch 默认的torch.save(model.state_dict(), path)结束建议同时保存一份 vocab、分词器配置、MAX_LEN和最优 F1。这几个东西分开存会让复现和部署变得非常痛苦——我见过太多项目在半年后连“这个模型当初用的 max_len 是多少”都查不出来只能靠猜。这是我自己的血泪经验做一次就能体会到。把模型导出成 ONNX 再部署是另一个值得早做的事。PyTorch 在 CPU 上的推理速度其实有提升空间ONNX Runtime 通常能拿到 1.5 到 2 倍的加速。但注意导出前要把model.eval()开好用固定max_len的 dummy 输入走一遍torch.onnx.export过程中千万别让输入有动态维度——否则 ONNX 的图优化和内存预分配都会失效。本文的完整方案跑通之后你就有了一套可复现的中文情感分析基线。之后无论是换更好的预训练词向量还是换成更复杂的模型都是在它上面加增量。希望这个技术方向能帮你在自己的数据集上少折腾几轮——毕竟调模型的时间更值得花在业务问题的理解上。本文还有配套的精品资源点击获取
返回列表