ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

中文情感分析实战:textCNN与Bi-LSTM文本分类对比全解析

中文情感分析实战:textCNN与Bi-LSTM文本分类对比全解析 简介一套基于Python的中文情感分析完整源码项目面向需要完成毕业设计、期末大作业或课程设计的计算机相关专业学生以CNN与BI-LSTM两种主流深度学习模型实现文本情感分类并附带项目说明与数据处理、训练、评估等配套内容。资源包为ZIP压缩格式共35个文件、约73.2MB其中包含13个Python源码文件模型构建、训练与推理脚本、10个TXT文本数据说明或情感语料、2个PB模型文件与2个data-00000-of-00001权重分片另有PNG/JPEG示意图、Markdown说明文档及配置文件目录结构清晰方便按模块逐一学习。目前已有73人学习下载虽然规模不大但项目完整度高适合作为快速上手的参考范例。项目代码带有详细注释新手也能看懂关键步骤自带中文评论语料如酒店评论、模型训练与评分脚本、CNN和BI-LSTM模型参数文件以及README和界面截图部署门槛低稍加修改即可整合到自己的课设或毕设中具备较高的实际参考价值。1. 中文情感分析毕设先别急着跑源码先看懂这三层拿到一份标着“高分毕设”的中文情感分析源码解压、装依赖、一跑不是缺这个库就是报形状错误好不容易跑通准确率只有 52%。这是做毕设最常见的开局。标题里写着 CNN、Bi-LSTM、文本分类听起来是三个独立技术点实际上是一条完整链路中文语料预处理、卷积网络做文本分类、双向循环网络做文本分类最后编一组对比实验写进论文。这个方案解决的是“给一段中文评论判断是正面还是负面”也可以扩展到多分类。适合谁适合需要交毕设、需要拿出真实对比数据、并且不想把别人的代码当黑盒跑一遍就交差的人。第一步不是装环境是把这条链路的每一层拆开看清楚。2. 数据和预处理中文情感分析的底座jieba 分词与词表构建文本分类模型只认数字不认汉字。英文按空格切词中文必须依赖分词器这一步的质量直接决定后面模型的天花板。我见过不少人把 80% 时间花在调网络结构上结果数据没洗干净换什么模型都白搭。磨刀不误砍柴工这一章把从原始评论到模型输入张量的完整路径走一遍。2.1 中文语料选型酒店评论还是电商评论常见做法是去公开的中文情感分析语料里挑一个而不是自己爬。酒店评论和电商评论是两个主流方向它们的特点差别很大直接影响到后面的清洗规则和模型表现。语料类型优点缺点适合场景酒店评论情感倾向明显句子完整度高领域词汇集中换领域会失效毕设演示、二分类基准实验电商评论口语化强噪声大更贴近真实场景标签可能不准确反讽多进阶实验、多分类扩展选型上我一般建议用酒店评论起步2 万到 5 万条正负样本尽量平衡。类别平衡比样本总量更重要如果差评只有 2000 条、好评有 2 万条模型大概率会把所有输入都判成好评准确率看起来很高实际没有意义。不平衡也不是不能做后面要在损失函数里加类别权重这个放到最后一章讲。2.2 清洗、分词、去停用词一套能直接用的预处理管线预处理管线常见有四步去 URL 和 HTML、把繁体转简体如果语料里有、分词、去停用词。分词用 jieba 的精确模式这是最稳妥的选择。去停用词需要一份通用中文停用词表网上有很多版本选一份常用的即可自己维护也可以核心是把“的、了、是、在”这类无信息量的词滤掉。import re import jieba STOPWORDS set() # 常见做法是加载一份通用中文停用词表按行读取 with open(stopwords.txt, r, encodingutf-8) as f: for line in f: word line.strip() if word: STOPWORDS.add(word) def clean_text(text: str) - str: # 去掉 URL 和 HTML 标签 text re.sub(rhttps?://\S|www\.\S, , text) text re.sub(r[^], , text) # 把非中英文、数字的字符替换为空格避免分词时标点和汉字粘连 text re.sub(r[^\u4e00-\u9fa5a-zA-Z0-9], , text) # 合并连续空格并去掉首尾空白 text re.sub(r\s, , text).strip() return text def cut_and_filter(text: str) - list: cleaned clean_text(text) # jieba 精确模式适合文本分类场景 words jieba.lcut(cleaned) # 过滤停用词和单字符保留有实际语义的词 return [w for w in words if w not in STOPWORDS and len(w.strip()) 1]逻辑说明clean_text 里两个正则承担了主要清洗工作第一个去掉 URL 和标签第二个把标点、符号全部替换成空格这样分词器不会切出“好吃。”这种词。cut_and_filter 返回的是过滤后的词列表后面构建词表、编码都用这个结果。参数说明这里有个取舍要特别注意。len(w.strip()) 1会过滤掉单字词对于大部分语料是合理的因为单字大多是语气词或无意义字符。但如果你的语料里有“好”“差”“烂”这类单字情感词而且出现频率不低就不要用这个过滤条件改为只过滤停用词。要不要保留单字先跑一版统计看看高频词里有没有情感单字再决定不要直接抄网上的预处理代码。2.3 构建词表与 padding把句子变成模型能吃的张量词表构建的核心是控制规模。5 万条语料做全量词表能到十几万个词大部分只出现一两次全部保留只会让 embedding 层参数爆炸。常见做法是按词频截断只保留出现次数不小于 2 的词词表上限 5 万。句子长度也要统一因为 CNN 和 Bi-LSTM 都要求 batch 内张量形状一致短句要 padding长句要截断。from collections import Counter import torch from torch.utils.data import Dataset def build_vocab(all_texts, min_freq2, max_vocab50000): counter Counter() for words in all_texts: counter.update(words) # pad 用于补齐短句unk 用于替换词表外的词 vocab {pad: 0, unk: 1} for word, freq in counter.most_common(max_vocab - 2): if freq min_freq: vocab[word] len(vocab) return vocab def encode_and_pad(words, vocab, max_len64): # 超长截断先取前 max_len 个词 ids [vocab.get(w, vocab[unk]) for w in words[:max_len]] if len(ids) max_len: # 短句在右侧补 pad ids [vocab[pad]] * (max_len - len(ids)) return ids class SentimentDataset(Dataset): def __init__(self, texts, labels, vocab, max_len64): self.data [encode_and_pad(t, vocab, max_len) for t in texts] self.labels labels def __len__(self): return len(self.data) def __getitem__(self, idx): x torch.LongTensor(self.data[idx]) y torch.LongTensor([self.labels[idx]]) return x, y逻辑说明build_vocab 用 Counter 统计词频按频次降序遍历min_freq 过滤低频词。encode_and_pad 把词列表映射成 ID 列表长度不足补 0长度超了直接截断。SentimentDataset 返回一个 LongTensor 类型的词 ID 序列和标签后面 DataLoader 可以直接按 batch 取。参数说明max_len 的选择要看你语料的长度分布而不是拍脑袋。常见做法是用 numpy 统计每条文本分词后长度的 90 分位数比如 90% 的评论在 60 词以内max_len 取 64 就够。设太短会丢关键上下文设太长会让矩阵里大部分是 浪费显存还干扰卷积计算。min_freq 取 2 对中等规模语料够用语料只有几千条时可以降到 1。3. textCNN 文本分类把句子当图像卷卷积核怎么设才有意义textCNN 是中文情感分析里最容易跑出 Baseline 的模型结构简单、训练快适合做第一个能出结果的版本。但很多人的实现里卷积核设置不合理导致效果和朴素 Bayes 差不多。这一章把原理和参数讲透。3.1 为什么 CNN 能读文本一维卷积与 n-gram 的对应关系CNN 处理文本的思路是把一条评论看成一个矩阵行是每个词的 embedding 向量列是 embedding 维度。卷积核在行方向滑动每次扫过 k 个词本质上就是在提取 k-gram 特征。比如高度为 3 的卷积核扫过“不 好吃”能学到否定词和形容词的组合模式这是情感分析里最重要的局部特征。多个不同高度的卷积核可以同时捕捉多种粒度的特征。常见做法是用 2、3、4 三种高度对应二元、三元、四元特征对中文短文本覆盖得比较全。池化层的作用是从每个卷积核输出的 feature map 里取最大值相当于在整条句子里找最强烈的那个局部信号不管它出现在句首还是句尾。这个设计让 textCNN 对位置不敏感而情感词的位置恰好不那么重要这是它在这个任务上有效的根本原因。3.2 完整模型实现embedding 三尺寸卷积核 池化import torch import torch.nn as nn import torch.nn.functional as F class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim100, num_filters128, num_classes2): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) # 三种高度的卷积核分别对应 2-gram、3-gram、4-gram self.convs nn.ModuleList([ nn.Conv2d(1, num_filters, (kernel_size, embed_dim)) for kernel_size in (2, 3, 4) ]) self.dropout nn.Dropout(0.5) self.fc nn.Linear(num_filters * 3, num_classes) def forward(self, x): # x 形状: (batch, max_len) emb self.embedding(x) # (batch, max_len, embed_dim) emb emb.unsqueeze(1) # (batch, 1, max_len, embed_dim) # 卷积核宽度等于 embed_dim结果形状 (batch, num_filters, conv_len, 1) conved [F.relu(conv(emb)).squeeze(-1) for conv in self.convs] # 每个卷积结果形状 (batch, num_filters, max_len - kernel_size 1) pooled [F.max_pool1d(c, c.size(2)).squeeze(-1) for c in conved] # 每个池化结果形状 (batch, num_filters) cat torch.cat(pooled, dim1) # (batch, num_filters * 3) return self.fc(self.dropout(cat))逻辑说明embedding 层把词 ID 变成 100 维向量padding_idx0 让 对应的向量始终为 0不参与梯度更新。这里用的是 Conv2d但卷积核形状是 (kernel_size, embed_dim)宽度和 embedding 维度一致实际滑动方向只有句子长度一个方向等价于一维卷积。每个卷积核输出后接 ReLU再做 max_pool1d池化核大小等于序列长度把整条 feature map 压成一个值。最后三个池化结果拼起来过全连接层。参数说明num_filters128 是每个卷积核的输出通道数8G 显存跑这个配置很轻松显存小可以降到 64。kernel_size 取 (2, 3, 4) 是 textCNN 的经典配置不要改成 (5, 6, 7)中文评论句子短超过 5 的 n-gram 组合基本是噪声。dropout0.5 对防过拟合很关键尤其是几万条这种小数据集去掉它验证集准确率能掉 3 到 5 个点。3.3 训练参数学习率、batch 与 early stopping训练循环是全项目复用率最高的一段代码CNN 和 Bi-LSTM 都用它。有两个细节很容易翻车标签形状和模型保存时机。import torch.optim as optim from torch.utils.data import DataLoader device torch.device(cuda if torch.cuda.is_available() else cpu) def evaluate(model, loader): model.eval() correct 0 with torch.no_grad(): for x, y in loader: x, y x.to(device), y.to(device) y y.squeeze(-1) pred model(x).argmax(dim1) correct (pred y).sum().item() return correct / len(loader.dataset) def train_model(model, train_loader, val_loader, epochs10, lr1e-3): optimizer optim.Adam(model.parameters(), lrlr) criterion nn.CrossEntropyLoss() best_acc 0.0 for epoch in range(epochs): model.train() total_loss 0.0 for x, y in train_loader: x, y x.to(device), y.to(device) y y.squeeze(-1) # 从 (batch, 1) 压成 (batch,) optimizer.zero_grad() logits model(x) loss criterion(logits, y) loss.backward() optimizer.step() total_loss loss.item() val_acc evaluate(model, val_loader) print(fepoch {epoch1}: loss{total_loss/len(train_loader):.4f}, val_acc{val_acc:.4f}) if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_model.pt)逻辑说明训练循环里每个 batch 都把标签从 (batch, 1) 压缩成 (batch,)因为 CrossEntropyLoss 不接受二维标签这是最常见的报错来源。每个 epoch 后在验证集上算准确率只有验证集表现最好的权重会被保存这才是能拿去写论文的模型。看训练日志时如果 loss 下降但验证集不涨说明开始过拟合了可以提前停掉。参数说明lr1e-3 对 Adam 是稳妥的起点第一个 epoch loss 震荡不降就降到 3e-4。batch 常见取 64语料小于 1 万条时取 32 更稳太大容易收敛到尖锐极小值。early stopping 的常见做法是记录连续 3 个 epoch 验证集不提升就 break而不是固定跑满 10 轮。4. Bi-LSTM 文本分类双向语义与长依赖和 CNN 的互补关系Bi-LSTM 和 textCNN 是情感分析毕设里最经典的对比组合。CNN 抓局部特征Bi-LSTM 抓序列依赖两者的错误样本往往不重叠写论文时互补性很强。这一章先讲原理再给实现最后说对比实验怎么做才严谨。4.1 Bi-LSTM 为什么适合中文情感分析正反向语义与梯度问题LSTM 解决的是 RNN 的梯度消失问题能记住较远位置的信息。但这还不够对“酒店位置很好但是隔音很差”这种句子情感转折在“但是”正向 LSTM 读到“但是”时已经积累了大量关于“好”的上下文单靠正向最后的隐状态容易忽略后面“差”的信号。Bi-LSTM 的做法是让两个方向独立读句子正向从第一个词读到最后一个词反向从最后一个词读回第一个词。两个方向的隐状态拼接后模型同时看到了句子的前后语境对转折句和否定句的建模能力强很多。做文本分类时常见做法不是只取最终时间步而是对双向输出整条序列做池化这样句子中间出现的强情感词也能被捕捉到。对中文短评论Bi-LSTM 通常比单层 LSTM 高 2 到 3 个点这是复现源码时最值得留意的结构差异。4.2 模型实现embedding BiLSTM 池化 分类class BiLSTM(nn.Module): def __init__(self, vocab_size, embed_dim100, hidden_dim128, num_layers1, num_classes2): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.lstm nn.LSTM( embed_dim, hidden_dim, num_layersnum_layers, batch_firstTrue, bidirectionalTrue ) self.dropout nn.Dropout(0.5) # 双向拼接后维度翻倍 self.fc nn.Linear(hidden_dim * 2, num_classes) def forward(self, x): # x 形状: (batch, max_len) emb self.embedding(x) # (batch, max_len, embed_dim) out, (h_n, c_n) self.lstm(emb) # out: (batch, max_len, 2*hidden) # 对整条输出序列做 max pooling比只取最后一步更稳 out_pooled, _ torch.max(out, dim1) # (batch, 2*hidden) return self.fc(self.dropout(out_pooled))逻辑说明batch_firstTrue 让 LSTM 的输入输出形状直接是 (batch, max_len, embed_dim)省去 permute 的麻烦。bidirectionalTrue 时 LSTM 每个时间步输出两个方向的隐状态拼接结果所以输出最后一维是 hidden_dim 的两倍。这里对整条输出做 max pooling取每个维度上所有时间步的最大值比 h_n 的最后一步更能保留句子中段的强信号。参数说明num_layers1 对毕设足够了两层 LSTM 参数量翻倍训练时间也翻倍验证集准确率通常只涨 0.5 个点左右还容易过拟合。hidden_dim128 是性价比比较高的选择降到 64 可以明显加快训练但会损失一点表达力。一个易错点是 bidirectionalTrue 时 h_n 的形状是 (2*num_layers, batch, hidden)要取正反向最后一步得自己切分直接用 max pooling 就可以绕开这个坑。4.3 和 textCNN 的对比实验固定种子、同数据、同词表对比实验要做严谨才能说服答辩老师。常见做法是两个模型共用同一个词表、同一个数据集划分、同一套训练参数唯一变量是模型结构。这里有个典型的对照陷阱如果 CNN 和 Bi-LSTM 用不同词表那准确率差异可能来自词表质量而不是模型结构这一组对照就白做了。固定随机种子也是必须的否则同一模型跑两次结果都可能差两个点对比结论站不住。import random import numpy as np import torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) # 关闭 cuDNN 自动调优保证卷积结果可复现 torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False逻辑说明set_seed 在模型初始化之前调用一次。cudnn.deterministicTrue 让 cuDNN 使用确定性算法benchmarkFalse 禁止它按输入形状动态选择最优卷积实现这两个开关不加即使设了随机种子卷积运算结果仍可能不可复现。参数说明seed 取多少纯粹是习惯重点是固定。如果你想让论文章节更扎实可以分别取 42、2024、2025 跑三遍报告平均值和标准差这比单次结果有说服力得多。两个模型各自的训练轮数可以不同用 early stopping 让它们停在自己最好的位置比较的是各自的最优表现不是同一轮数下的表现。5. 踩坑记录中文情感分析源码跑不通的 4 个高频原因这一章是我自己复现同类项目时攒下来的问题清单按“现象 → 原因 → 解决”写每一条都真实踩过。做这个方向前先看一遍能省下不少排查时间。5.1 现象loss 不降准确率在 50% 附近抖动原因通常是标签和模型输出没对上。最常见的是标签没有 squeeze形状还是 (batch, 1)CrossEntropyLoss 内部会把它当成 (batch, 1) 的类别分布去匹配计算出来的 loss 完全没意义。另一个高发原因是 DataLoader 里 shuffleFalse每个 epoch 模型按同样的样本顺序更新梯度方向有偏loss 容易卡住。解决先打印一个 batch 的 logits 和标签形状确认 logits 是 (batch, 2)、标签是 (batch,)再把 DataLoader 的 shuffle 改为 True。这两处都改完 loss 还不降检查是不是标签全是一个类——数据集没打乱之前前几个 batch 可能全是好评模型直接学偏了。5.2 现象报错 IndexError: index out of range in self原因几乎都出在 embedding 层和词表长度不一致。常见场景是加载了预训练词向量之后又往词表里加了 或 导致 embedding 矩阵行数小于词表里的最大索引。另一种情况是把未知词映射到了 0和 混在一起虽然不一定报错但模型学不到“这个词是未知的”信号复现效果差一截。解决在构建模型和词表的地方加一条形状断言把问题暴露在训练之前。assert len(vocab) model.embedding.num_embeddings, 词表长度与 embedding 行数不一致更稳的编码逻辑是未知词映射到 1、padding 映射到 0两者分开。虽然 torch 的 embedding 不会因为索引 0 被 pad 和 unk 共用而报错但语义上它们应该完全不同分开才能让模型学到正确信号。5.3 现象jieba 分词极慢5 万条语料跑了半小时还没结束原因有几种一是在 for 循环里重复初始化 jieba分词器每次都重新加载词典二是没有对日志做处理jieba 的每次初始化信息都往终端刷三是默认开启了 HMM新词识别带来额外开销。解决脚本开头调用一次 jieba.initialize()预处理完整跑完后把分词结果存成 pkl之后训练直接读缓存不再分词。HMM 在情感分析这种任务上收益很小关掉能换明显速度提升。jieba.setLogLevel(20) # 关闭 jieba 的 INFO 日志 words jieba.lcut(cleaned, HMMFalse)逻辑说明HMM 模式是 jieba 用来识别词典外新词的代价是每个词都要跑一遍维特比解码。对评论语料新词大多是品牌名和网络用语识别错了反而引入噪声。参数说明HMMFalse 会损失少量新词召回如果语料里网络新词特别多可以先保留 HMM 跑一版对比再关。预处理缓存到 pkl 属于一次性投入后面调参迭代省的时间远远超过这一次开销。5.4 现象GPU 显存 OOMbatch64 直接崩原因一是 max_len 设得太长比如设了 128但语料里大部分句子只有三四十个词矩阵大半是无效 padding原因二是验证集评估时漏写了 torch.no_grad()中间变量全留在计算图里显存每个 batch 都在涨跑到后面必然爆。解决先把 max_len 降到语料长度分布的 90 分位数再用梯度累积替代大 batch。排查 OOM 时先看 evaluate 函数里有没有 with torch.no_grad()这是我见过最多的一处疏漏。另外检查 DataLoader 有没有设置 pin_memory在显存吃紧的机器上pin_memoryTrue 会额外占用一部分显存关掉能腾出空间。5.5 现象训练集 99%验证集只有 60%原因是过拟合这是小数据情感分析的头号问题不是模型坏了。几万条语料对深度学习模型来说很小模型容量一大embedding 层能直接“背下”训练集的词组合方式。解决优先级从低到高依次是——把 dropout 加到 0.5把 embedding 维度从 300 降到 100把训练轮数从 20 压到 8 并配合 early stopping最后再考虑加载预训练词向量替换随机初始化。训练集和验证集差距大于 20 个点基本就是过拟合调模型结构之前先减容量往往比换网络更有效。提示训练日志里如果 loss 持续下降而 val_acc 停滞不要急着加正则先确认best_model.pt是在验证集最优的 epoch 保存的。很多人最后交上去的模型是最后一个 epoch 的权重那不是最优模型。6. 验证与进阶从准确率到可信度把混淆矩阵和 bad case 写进论文只盯着 val_acc 调参是我见过最多的毕设误区。准确率只能回答“整体对不对”回答不了“模型是不是把大量差评判成了好评”。假设验证集有 1 万条混淆矩阵长这样预测好评预测差评实际好评4500500实际差评8004200整体准确率 87%看起来不错。但看差评那一行800 条差评被误判成了好评差评召回率只有 84%。对情感分析应用来说漏判差评的代价通常比误判好评高。把混淆矩阵和 precision、recall、F1 一起写进论文比单独一张准确率图有说服力得多。进阶做法是训练完导出所有被预测错的句子逐条看规律。我自己做这个之后发现被误判的样本大多是反讽句“太棒了等了三天告诉我没货”模型学不到反讽因为语料里这种样本太少。这是数据问题不是模型结构问题写进论文反而能体现分析能力。类别不平衡时一个实用技巧是给损失函数加类别权重weights torch.tensor([1.0, 2.0]).to(device) criterion nn.CrossEntropyLoss(weightweights)参数说明weights 按“样本少的类别给更高权重”设置比如差评数量只有好评的一半差评权重就近似取 2。这会牺牲一点整体准确率但差评召回率通常会明显改善。调权重时观察混淆矩阵别只看准确率数字。最后说一条我自己的教训第一次跑这套源码时跳过预处理直接喂原始文本给模型拿到 60% 准确率还以为是网络结构抄错了。后来老老实实把清洗、分词、停用词、词表这几步走完同样的模型换了个数据集处理脚本准确率到了 87%。那份源码的高分不在网络结构里在数据准备里。希望帮到你。本文还有配套的精品资源点击获取
返回列表