ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

基于Python的中文情感分析:TextCNN与Bi-LSTM实现及避坑指南

基于Python的中文情感分析:TextCNN与Bi-LSTM实现及避坑指南 简介一套基于Python的中文情感分析毕业设计项目源码面向完成文本分类任务的计算机专业学生覆盖CNN与Bi-LSTM两种主流深度学习模型。代码实现从酒店评论语料加载、文本预处理、词向量构建到模型训练与评估的完整流程关键位置注释清晰新手亦可读懂通过对比两种模型在准确率与训练过程上的差异可直观理解不同网络结构的文本分类表现。项目说明还给出环境配置、参数调节建议以及评分报告脚本便于复现和调优资源共35个文件以Python脚本、txt说明、pb模型权重及data数据文件为主压缩包约73.2MB目录结构清晰可快速定位核心模块。目前已有73人学习适合作为毕业设计、期末大作业或课程设计的参考。内含酒店评论语料、训练好的模型文件及README说明能够展现从原始语料到情感分类的完整链路部署简单运行稳定具有较高工程实践价值。1. 中文情感分析这个毕设题到底在训练什么拿到基于python的中文情感分析这个毕设题时很多人第一反应是去下载一个情感词典给句子里的词打分最后加总出一个正负分。这方案看着能交差但答辩老师一句如果用户说不太满意但还能接受你的词典怎么处理转折就能让人卡住。真正值得做的方案是把它当成一个文本分类任务把中文句子切成一串词再用深度学习模型把这串词映射到预定义的标签比如正向、负向、中性。标题里的 CNN 负责抓短语级的局部特征Bi-LSTM 负责抓上下文顺序两者都属于可以在一张普通显卡甚至 CPU 上跑完的模型对毕业设计来说可控性很强。这篇笔记会按预处理 → 词向量 → 双模型 → 训练评估 → 避坑 → 答辩演示的路径走一遍代码全是 PyTorch 原生实现没有封装的库。读完你能得到一个能跑通、能出图、能讲清楚原理的完整方案。动手前先把基础环境准备到能 import torch的程度用 PyCharm 或 VS Code 配一个 Python 3.8 以上的虚拟环境就行没 GPU 也能跑只是训练时间会长一点。2. 中文文本要先进张量预处理与词向量决定模型上限英文文本天然按空格切词中文没有这种边界。这部电影真不错要是不先分词模型只能逐字去看丢失了不错这个整体语义。所以整个项目的第一步不是搭模型而是分词、构建词表、定长度策略、准备词向量。这一步做得糊后面模型再花哨也补不回来。2.1 分词与句子长度怎么定常见做法是用 jieba 的精确模式lcut做分词。分词之后要决定哪些词留下我一般先过滤掉标点和空字符再过滤掉单字词因为大部分单字没有情感判别力。但有一条重要例外否定词不没别是单字情感方向全靠它们反转必须单独加一个白名单保留。如果你图省事把所有单字都过滤掉模型会把不好用理解成好用这种错误在答辩演示时非常尴尬。句子长度不能拍脑袋定。先把所有训练样本分词后的长度统计出来画个分布图取 95 分位数作为max_len。多数评论类语料的 95 分位在 80 到 150 之间。设太短会把长评论里的反转信息截掉设太长则 batch 内大量 padding白白增加计算量。2.2 分词并构建词表先缓存别让电脑白转import pandas as pd import jieba import pickle from collections import Counter CACHE_PATH cache/tokenized.pkl MIN_FREQ 2 # 词频小于 2 的过滤掉 # 单字白名单保留最常见的否定词和程度词 KEEP_ONE_CHAR set(不没别很最太真挺超好差) def tokenize_and_build_vocab(df, text_colsentence): # 有缓存直接读避免每次训练都重新切分 try: with open(CACHE_PATH, rb) as f: texts, vocab pickle.load(f) return texts, vocab except FileNotFoundError: pass texts [] for line in df[text_col]: words jieba.lcut(str(line).strip()) filtered [] for w in words: w w.strip() if not w: continue if len(w) 1 or w in KEEP_ONE_CHAR: filtered.append(w) texts.append(filtered) # 统计词频构建词表预留 pad 和 unk counter Counter() for words in texts: counter.update(words) vocab {pad: 0, unk: 1} for word, cnt in counter.items(): if cnt MIN_FREQ: vocab[word] len(vocab) with open(CACHE_PATH, wb) as f: pickle.dump((texts, vocab), f) return texts, vocab逻辑说明jieba.lcut是 CPU 密集型操作几万条样本切一次就要好几分钟。训练脚本每启动一次就重切一次纯属浪费时间。把分词结果和词表缓存成 pickle 后后续所有实验共用这一份结果能省下大量重复劳动。词表里必须预留pad和unk分别用于 padding 和未登录词否则验证集里出现新词时vocab[word]会直接 KeyError 崩掉。参数说明MIN_FREQ在数据量小于 1 万条时建议设为 2防止有用低频词被误删数据量到几十万条时可以提到 5。KEEP_ONE_CHAR这个白名单是我根据评论语料里的高频情感词总结的你也可以在预处理后打印一份词频表自己补充。2.3 用 gensim 训练词向量初始化 embedding 矩阵import numpy as np from gensim.models import Word2Vec EMBEDDING_DIM 100 def build_embedding_matrix(texts, vocab, embedding_dimEMBEDDING_DIM): # 在语料上自己训练 Word2Vec保证词表里每个词都有向量 w2v Word2Vec(sentencestexts, vector_sizeembedding_dim, min_count1, workers4) # 随机初始化 embedding 矩阵避免查不到的 OOV 词全为零 embedding np.random.normal(0, 0.05, size(len(vocab), embedding_dim)).astype(np.float32) for word, idx in vocab.items(): if word in w2v.wv: embedding[idx] w2v.wv[word] # padding 位置全部置零让模型学习时忽略这些位置 embedding[0] np.zeros(embedding_dim, dtypenp.float32) return embedding逻辑说明直接用随机初始化的 Embedding 也能训练但收敛慢且指标通常低两三个点。在语料上自训一个 Word2Vec把词向量矩阵初始化好相当于给模型一个先验知识语义相近的词在向量空间里距离更近。注意min_count1必须设成 1保证词表里每个词都有对应向量否则w2v.wv[word]查不到的时候你还要额外处理。参数说明embedding_dim取 100 到 300 之间。毕业设计语料规模下 100 够用再大收益有限。embedding[0]置零是必须的否则模型会把 padding 位置也学出某种词义干扰后续的池化和注意力计算。训练时把 embedding 层的requires_grad设为 True让模型在反向传播中微调词向量效果会更好。2.4 Dataset 与动态 paddingcollate_fn 是关键import torch from torch.utils.data import Dataset, DataLoader class SentimentDataset(Dataset): def __init__(self, texts, labels, vocab, max_len128): self.texts texts self.labels labels self.vocab vocab self.max_len max_len def __len__(self): return len(self.labels) def __getitem__(self, idx): words self.texts[idx][: self.max_len] ids [self.vocab.get(w, self.vocab[unk]) for w in words] return ids, int(self.labels[idx]) def collate_fn(batch): ids_list [item[0] for item in batch] labels torch.tensor([item[1] for item in batch]) lengths torch.tensor([len(ids) for ids in ids_list]) max_len lengths.max().item() padded torch.zeros(len(batch), max_len, dtypetorch.long) mask torch.zeros(len(batch), max_len, dtypetorch.float32) for i, ids in enumerate(ids_list): padded[i, : len(ids)] torch.tensor(ids) mask[i, : len(ids)] 1.0 return padded, labels, lengths, mask train_loader DataLoader(dataset, batch_size64, shuffleTrue, collate_fncollate_fn)逻辑说明collate_fn的作用是把一个 batch 里长度不等的样本对齐。对齐方式是用pad索引 0填充到本 batch 的最大长度。mask矩阵标记真实词位置为 1、padding 位置为 0后面 Bi-LSTM 计算时要用它区分有效位置。注意这里最终要返回lengths给pack_padded_sequence用。参数说明max_len在 Dataset 内部先截断速度和内存都更友好。batch 大小取 32 到 64句子平均长度在 100 左右时64 的 batch 在 8G 显存上跑没问题如果用 CPU 训练建议降到 32 减少等待时间。对整个训练集统一 padding 到全局最大长度是初学者常犯的错动态 padding 到 batch 最大长度能省下大量无效计算。3. 两条主模型怎么搭TextCNN 与 Bi-LSTM 的实现与对比数据准备好了进入核心环节。这里选 TextCNN 和 Bi-LSTM 两个模型做对比实验原因很直白CNN 训练快、参数量小、适合抓局部短语特征适合当基线Bi-LSTM 能建模上下文顺序和长距离依赖适合当改进方案。两个都跑通毕设的实验对比部分就有东西可写。3.1 TextCNN把句子当图像用卷积核扫 n-gramimport torch import torch.nn as nn class TextCNN(nn.Module): def __init__(self, vocab_size, embedding_dim, embedding_matrix, num_filters128, filter_sizes(2, 3, 4), num_classes3, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idx0) self.embedding.weight.data.copy_(torch.tensor(embedding_matrix, dtypetorch.float32)) self.embedding.weight.requires_grad True # 每个卷积核尺寸对应一组 Conv1d 池化 self.convs nn.ModuleList([ nn.Sequential( nn.Conv1d(embedding_dim, num_filters, kernel_sizefs, padding1), nn.ReLU(), nn.AdaptiveMaxPool1d(1) ) for fs in filter_sizes ]) self.dropout nn.Dropout(dropout) self.fc nn.Linear(num_filters * len(filter_sizes), num_classes) def forward(self, x, maskNone): # x: [batch, seq_len] emb self.embedding(x) # [batch, seq_len, emb_dim] emb emb.transpose(1, 2) # Conv1d 需要 [batch, channel, seq_len] conved [conv(emb).squeeze(-1) for conv in self.convs] # 每个卷积输出 [batch, num_filters]拼起来过全连接 out torch.cat(conved, dim1) # [batch, num_filters * 3] out self.dropout(out) return self.fc(out)逻辑说明kernel_size2的卷积核每次扫相邻两个词的向量等价于提取 bigram 特征kernel_size3提取 trigramkernel_size4提取 4-gram。每个卷积核在句子上滑动然后AdaptiveMaxPool1d(1)把整条卷积结果压成一个最大值相当于这个句子中最强烈的 2-gram 特征是什么。多个卷积核的输出拼在一起过全连接层得到最终的类别概率。参数说明filter_sizes(2, 3, 4)是 TextCNN 的标准配置覆盖二元到四元短语。num_filters取 128参数总量不大训练速度快。padding1是我常用的设置好处是卷积后序列长度和输入基本一致池化时信息损失更小不设 padding 时边界词只被卷积核覆盖一次信息利用率低。dropout 取 0.5如果发现验证集掉点严重可以降到 0.3。3.2 Bi-LSTM让每个词同时看到前文和后文import torch.nn as nn import torch.nn.utils.rnn as rnn_utils class BiLSTM(nn.Module): def __init__(self, vocab_size, embedding_dim, embedding_matrix, hidden_size128, num_layers2, num_classes3, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idx0) self.embedding.weight.data.copy_(torch.tensor(embedding_matrix, dtypetorch.float32)) self.lstm nn.LSTM(input_sizeembedding_dim, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue, bidirectionalTrue) self.dropout nn.Dropout(dropout) self.fc nn.Linear(hidden_size * 2, num_classes) def forward(self, x, lengths, maskNone): emb self.embedding(x) # [batch, seq_len, emb_dim] # 按真实长度打包让 LSTM 跳过 padding 位置 packed rnn_utils.pack_padded_sequence(emb, lengths.cpu(), batch_firstTrue, enforce_sortedFalse) packed_out, (hn, _) self.lstm(packed) # hn: [num_layers * 2, batch, hidden_size] # 取最后一层正反向隐藏状态拼接 last torch.cat((hn[-2], hn[-1]), dim1) # [batch, hidden_size * 2] out self.dropout(last) return self.fc(out)逻辑说明双向 LSTM 的核心思想是让每个位置的输出同时包含从左到右和从右到左的信息。判断虽然前面夸得很猛但最后一句说难用这种转折时只看句首会误判反向 LSTM 能提前把句尾信息带到前面的 hidden state 里。pack_padded_sequence的作用是压缩掉 padding 位置避免模型白算一堆零向量。取最后有效位置的正反向隐藏状态拼接是因为拼接后的向量同时编码了整句正序和逆序的语义。参数说明hidden_size128是经验值数据量大可以提到 256。num_layers2的收益在情感分类任务上已经够用堆到 4 层训练慢且容易过拟合。batch_firstTrue让输入输出都保持[batch, seq_len, feature]的形状减少转置操作。enforce_sortedFalse是个容易忽视的参数它允许我们传入乱序的 batch否则必须把样本按长度降序排列才能运行。3.3 训练循环梯度裁剪、早停和 F1 指标def train_one_epoch(model, loader, optimizer, criterion): model.train() total_loss 0.0 for x, y, lengths, mask in loader: optimizer.zero_grad() if isinstance(model, BiLSTM): logits model(x, lengths, mask) else: logits model(x) loss criterion(logits, y) loss.backward() # 防止梯度爆炸LSTM 尤其需要 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() total_loss loss.item() return total_loss / len(loader)逻辑说明clip_grad_norm_是 LSTM 训练里必须的一步。RNN 结构在长序列上容易梯度爆炸loss 会突然变成 NaN。把梯度范数裁剪到 1.0 之后更新步长被限制在合理范围内。isinstance(model, BiLSTM)的判断是为了兼容两种模型不同 forward 的输入实际项目里建议写两个独立训练函数。参数建议优化器用 Adam初始学习率设1e-3如果 BiLSTM 训练不稳定降到5e-4。监控指标用 macro-F1 而不是准确率特别当你的数据里正负面样本不均衡时准确率会骗人。训练时保存验证集 F1 最高的那一份权重而不是训练集 loss 最低的。early stopping 建议 patience 设 3连续 3 个 epoch 验证 F1 不涨就调小学习率或停止训练。4. 避坑与常见问题预处理到训练反复出现的 6 个坑模型跑起来只是开始绝大多数毕设翻车都发生在细节里。这里按现象 → 原因 → 解决逐条写按预处理和训练评估分类。4.1 预处理相关的三个坑坑 1每次启动训练都重新分词等到怀疑电脑死机。现象程序启动后卡在第一个 epoch 之前日志没有任何输出一卡就是十分钟。 原因在 DataLoader 里每取一个样本就调一次jieba.lcut或者在训练脚本里没有使用缓存。 解决分词作为一个独立的预处理步骤只执行一次结果落到 pickle 文件训练脚本启动时直接 load生成的texts和vocab全程复用。坑 2把所有单字词都过滤掉把不字也过滤了。现象模型对不好用不方便这类样本预测成正向。 原因len(w) 1的过滤条件把所有单字都删了否定词恰恰是情感判定的关键特征。 解决加一个单字白名单保留不、没、别、很、太、挺、好、差等高频词汇再配合后续的词频统计调整。坑 3词表没留unk验证集直接 KeyError。现象训练集上一切正常换验证集时报KeyError: xgboost之类的未登录词错误。 原因用了vocab[word]而不是vocab.get(word, unk_id)。 解决构建词表时在索引 0 和 1 预留pad和unk查词统一用vocab.get(w, vocab[unk])。这个方法虽小但能让你避免在答辩演示时当场白屏。4.2 训练与评估相关的三个坑坑 4BiLSTM 训练到第 3 个 epochloss 突然变成 NaN。现象训练日志里 loss 正常下降然后某一步突然变成nan之后全部nan。 原因梯度累积导致爆炸大概率是学习率太大或没有梯度裁剪。 解决加上clip_grad_norm_学习率降到5e-4。如果还崩检查数据里是否有 NaN 值文本转索引前先做空值清洗。坑 5测试准确率 90%但混淆矩阵里某个类别一列全是零。现象整体 acc 很高但打印 confusion matrix 发现模型把所有样本都预测成占比最高的类别。 原因类别不均衡模型学会了全猜多数类这个捷径。 解决用WeightedRandomSampler对少数类过采样或者给 CrossEntropyLoss 传入class_weight。评估指标改用 macro-F1别让准确率掩盖问题。坑 6训练集 F1 到 96%验证集 F1 卡在 80% 上不去。现象训练和验证差距越来越大早停也没能挽回。 原因模型过大、dropout 太小典型的过拟合。 解决dropout 从 0.3 提到 0.5hidden_size从 256 降到 128num_layers降到 2。在数据量只有几千条时模型复杂度不是越高越好。早期停 3 个 epoch 没提升就保存当前最优权重退出。5. 把毕设从能跑提到能答辩混淆矩阵与一次性推理脚本训练结束拿到模型权重后还有两件事值得做一是用验证集做错误样本分析二是写一个能现场演示的单条推理接口。这两件事直接决定答辩时你能讲出多少细节。5.1 用混淆矩阵定位模型的薄弱类别from sklearn.metrics import confusion_matrix, classification_report y_true, y_pred [], [] for x, y, lengths, mask in val_loader: logits model(x, lengths, mask) pred torch.argmax(logits, dim1) y_true.extend(y.tolist()) y_pred.extend(pred.tolist()) classes [negative, neutral, positive] print(classification_report(y_true, y_pred, target_namesclasses, digits3)) print(confusion_matrix(y_true, y_pred))逻辑说明classification_report输出每个类别的精确率、召回率和 F1confusion_matrix给出误分类的分布。如果一个类别召回率明显偏低说明模型对该类特征捕捉不足需要针对性加数据或调整类别权重。对每个错误样本打印出原始句子和预测概率把典型的几条放进项目说明文档里答辩时有据可谈。5.2 写好predict_one接口现场演示不翻车def predict_one(text, model, vocab, max_len128): words jieba.lcut(text.strip()) ids [vocab.get(w, vocab[unk]) for w in words][: max_len] x torch.tensor([ids], dtypetorch.long) lengths torch.tensor([len(ids)]) mask torch.ones_like(x, dtypetorch.float32) model.eval() with torch.no_grad(): logits model(x, lengths, mask) if isinstance(model, BiLSTM) else model(x) prob torch.softmax(logits, dim1)[0] return {cls: round(p.item(), 4) for cls, p in zip([负向, 中性, 正向], prob)}逻辑说明推理时要走和训练完全一致的预处理流程分词、截断、映射索引一个都不能少。model.eval()和torch.no_grad()是为了关掉 dropout 和梯度计算保证预测稳定。返回值直接拆成三个类别各自的概率现场演示时往终端里打一句评论就能看到分布比只显示一个标签更有说服力。这个接口写完之后建议把它封成一个函数放在单独的inference.py里。答辩时打开终端输入任意中文句子看模型输出概率。如果预测错了不要慌先看是不是预处理阶段把否定词过滤了再看句子要不要做长度扩展。做毕设这几年我最深的感受是模型不是越花哨越好而是越能讲清楚越好。TextCNN 和 Bi-LSTM 的对比结果加上混淆矩阵和错误样本分析已经足够撑起整个实验章节。把基础做扎实就算指标不是最高答辩时也能让老师看到你理解每个环节在做什么。希望这篇整理能帮你少走几段弯路早点把项目跑顺。本文还有配套的精品资源点击获取
返回列表