简介:一套基于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", encoding="utf-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(r"https?://\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_freq=2, max_vocab=50000): 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_len=64): # 超长截断,先取前 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_len=64): 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_dim=100, num_filters=128, num_classes=2): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) # 三种高度的卷积核,分别对应 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, dim=1) # (batch, num_filters * 3) return self.fc(self.dropout(cat))逻辑说明:embedding 层把词 ID 变成 100 维向量,padding_idx=0 让 对应的向量始终为 0,不参与梯度更新。这里用的是 Conv2d,但卷积核形状是 (kernel_size, embed_dim),宽度和 embedding 维度一致,实际滑动方向只有句子长度一个方向,等价于一维卷积。每个卷积核输出后接 ReLU,再做 max_pool1d,池化核大小等于序列长度,把整条 feature map 压成一个值。最后三个池化结果拼起来过全连接层。
参数说明:num_filters=128 是每个卷积核的输出通道数,8G 显存跑这个配置很轻松,显存小可以降到 64。kernel_size 取 (2, 3, 4) 是 textCNN 的经典配置,不要改成 (5, 6, 7),中文评论句子短,超过 5 的 n-gram 组合基本是噪声。dropout=0.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(dim=1) correct += (pred == y).sum().item() return correct / len(loader.dataset) def train_model(model, train_loader, val_loader, epochs=10, lr=1e-3): optimizer = optim.Adam(model.parameters(), lr=lr) 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(f"epoch {epoch+1}: 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 下降但验证集不涨,说明开始过拟合了,可以提前停掉。
参数说明:lr=1e-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_dim=100, hidden_dim=128, num_layers=1, num_classes=2): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.lstm = nn.LSTM( embed_dim, hidden_dim, num_layers=num_layers, batch_first=True, bidirectional=True ) 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, dim=1) # (batch, 2*hidden) return self.fc(self.dropout(out_pooled))逻辑说明:batch_first=True 让 LSTM 的输入输出形状直接是 (batch, max_len, embed_dim),省去 permute 的麻烦。bidirectional=True 时 LSTM 每个时间步输出两个方向的隐状态拼接结果,所以输出最后一维是 hidden_dim 的两倍。这里对整条输出做 max pooling,取每个维度上所有时间步的最大值,比 h_n 的最后一步更能保留句子中段的强信号。
参数说明:num_layers=1 对毕设足够了,两层 LSTM 参数量翻倍,训练时间也翻倍,验证集准确率通常只涨 0.5 个点左右,还容易过拟合。hidden_dim=128 是性价比比较高的选择,降到 64 可以明显加快训练,但会损失一点表达力。一个易错点是 bidirectional=True 时 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(seed=42): 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.deterministic=True 让 cuDNN 使用确定性算法,benchmark=False 禁止它按输入形状动态选择最优卷积实现,这两个开关不加,即使设了随机种子,卷积运算结果仍可能不可复现。
参数说明:seed 取多少纯粹是习惯,重点是固定。如果你想让论文章节更扎实,可以分别取 42、2024、2025 跑三遍,报告平均值和标准差,这比单次结果有说服力得多。两个模型各自的训练轮数可以不同,用 early stopping 让它们停在自己最好的位置,比较的是各自的最优表现,不是同一轮数下的表现。
5. 踩坑记录:中文情感分析源码跑不通的 4 个高频原因
这一章是我自己复现同类项目时攒下来的问题清单,按“现象 → 原因 → 解决”写,每一条都真实踩过。做这个方向前先看一遍,能省下不少排查时间。
5.1 现象:loss 不降,准确率在 50% 附近抖动
原因通常是标签和模型输出没对上。最常见的是标签没有 squeeze,形状还是 (batch, 1),CrossEntropyLoss 内部会把它当成 (batch, 1) 的类别分布去匹配,计算出来的 loss 完全没意义。另一个高发原因是 DataLoader 里 shuffle=False,每个 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, HMM=False)逻辑说明:HMM 模式是 jieba 用来识别词典外新词的,代价是每个词都要跑一遍维特比解码。对评论语料,新词大多是品牌名和网络用语,识别错了反而引入噪声。参数说明:HMM=False 会损失少量新词召回,如果语料里网络新词特别多,可以先保留 HMM 跑一版对比再关。预处理缓存到 pkl 属于一次性投入,后面调参迭代省的时间远远超过这一次开销。
5.4 现象:GPU 显存 OOM,batch=64 直接崩
原因一是 max_len 设得太长,比如设了 128,但语料里大部分句子只有三四十个词,矩阵大半是无效 padding;原因二是验证集评估时漏写了 torch.no_grad(),中间变量全留在计算图里,显存每个 batch 都在涨,跑到后面必然爆。
解决:先把 max_len 降到语料长度分布的 90 分位数,再用梯度累积替代大 batch。排查 OOM 时,先看 evaluate 函数里有没有 with torch.no_grad(),这是我见过最多的一处疏漏。另外检查 DataLoader 有没有设置 pin_memory,在显存吃紧的机器上,pin_memory=True 会额外占用一部分显存,关掉能腾出空间。
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 万条,混淆矩阵长这样:
| 预测好评 | 预测差评 | |
|---|---|---|
| 实际好评 | 4500 | 500 |
| 实际差评 | 800 | 4200 |
整体准确率 87%,看起来不错。但看差评那一行,800 条差评被误判成了好评,差评召回率只有 84%。对情感分析应用来说,漏判差评的代价通常比误判好评高。把混淆矩阵和 precision、recall、F1 一起写进论文,比单独一张准确率图有说服力得多。进阶做法是训练完导出所有被预测错的句子,逐条看规律。我自己做这个之后发现,被误判的样本大多是反讽句,“太棒了,等了三天告诉我没货”,模型学不到反讽,因为语料里这种样本太少。这是数据问题,不是模型结构问题,写进论文反而能体现分析能力。
类别不平衡时,一个实用技巧是给损失函数加类别权重:
weights = torch.tensor([1.0, 2.0]).to(device) criterion = nn.CrossEntropyLoss(weight=weights)参数说明:weights 按“样本少的类别给更高权重”设置,比如差评数量只有好评的一半,差评权重就近似取 2。这会牺牲一点整体准确率,但差评召回率通常会明显改善。调权重时观察混淆矩阵,别只看准确率数字。
最后说一条我自己的教训:第一次跑这套源码时,跳过预处理直接喂原始文本给模型,拿到 60% 准确率,还以为是网络结构抄错了。后来老老实实把清洗、分词、停用词、词表这几步走完,同样的模型换了个数据集处理脚本,准确率到了 87%。那份源码的高分不在网络结构里,在数据准备里。希望帮到你。
本文还有配套的精品资源,点击获取