简介基于Python的中文情感分析完整项目源码融合卷积神经网络CNN与双向长短期记忆网络BI-LSTM两种深度学习模型完成文本分类任务面向计算机相关专业毕业设计、期末大作业及课程设计场景尤其适合需要快速落地运行的中级开发者。压缩包共三十五个文件其中十三个Python脚本覆盖数据预处理、模型训练、评估与预测流程十个文本文件提供项目说明与运行指引还有训练好的模型文件、数据文件、配置及图片示例等整体大小约73MB结构清晰便于按模块查阅。项目附带酒店评论等实验数据与评分报告脚本代码注释完整新手也能看懂核心逻辑部署简单稍作配置即可直接演示可作为高分毕业设计或课程设计成果提交。目前已有七十三人浏览学习适合作为文本情感分析方向的项目参考。1. 中文情感分析用CNN还是BI-LSTM先跑通再谈选择把中文情感分析做成毕业设计的人十有八九卡在同一个问题CNN和BI-LSTM到底选哪个。结论先说两个模型在短文本分类上得分通常差不到三个点真正拉开差距的是数据预处理和训练细节。网上能搜到大量cnn代码和模板大多基于英文影评换到中文数据上分词、编码、padding每个环节都会冒新问题。用python跑通中文情感分析本质是一条完整的文本分类流水线词表构建、序列对齐、模型实现、训练评估任何一步出错都会让复现翻车。我按这条线拆开讲先做预处理再用PyTorch分别实现Text-CNN和BI-LSTM最后给训练参数和避坑记录。新手能照着跑通老手可以直接跳第5章看坑、第6章看预训练词向量替换。2. 中文文本预处理分词、词表与序列对齐的三个必调参数2.1 数据读取与标签分布检查我习惯让数据集固定成两列csvtext是原始句子label是情感标签0负向、1正向三分类就改成2。不要用空格分隔的txt评论里一旦有逗号或空格解析就乱套pandas读csv是毕设里最省心的做法。import pandas as pd df pd.read_csv(senti_data.csv, encodingutf-8) # 固定两列text 是句子原文label 是情感标签0 负向 / 1 正向 df df[[text, label]].dropna() df[label] df[label].astype(int) print(df[label].value_counts())读csv最常见的坑是编码。utf-8报错就换encodinggbk还不行就先用open读前几行看看实际编码不要轻易加errorsignore那会悄悄丢掉整行数据。label如果是“pos/neg”字符串先df[label].map({pos: 1, neg: 0})转一下。value_counts()这一步很多人跳过但它能提前暴露类别极端不均衡的问题第5章的坑1就来自这里。2.2 jieba分词与停用词过滤否定词不能进表中文词之间没有空格分词是第一步。我选jieba理由是对口语化评论和网络新词的兼容度比简单按字切分好太多而且安装快、不挑环境。分词下面这一段是整套代码里最容易被低估的部分。import jieba stopwords set() with open(stopwords.txt, encodingutf-8) as f: for line in f: stopwords.add(line.strip()) def cut_text(text): words [w for w in jieba.cut(text) if w.strip() and w not in stopwords] return wordsstopwords.txt用的就是网上常见的通用中文停用词表几十到几百行那种。但有一个必须手动处理的地方检查表里有没有“不”“没”“别”“无”这类否定词有就删掉。原因很实在情感分析里“不好吃”去掉“不”之后就变成了“好吃”情绪直接反转。这是直接套通用停用词表翻车的典型案例我见过不止一次。jieba.cut(text)返回生成器w.strip()顺带滤掉空串和纯空格。对中文情感分析来说默认分词粒度就够不需要切得太细。如果用字级别切分可以完全不依赖jieba但“很不好”三个字会被拆散模型在小数据量下很难自己学回组合关系词级别的可解释性也更好一点。2.3 词表构建与序列编码三个必调参数模型吃的是定长整数序列分词结果必须先映射成id再padding到同一长度。这一步决定了后面所有层的输入形状写错直接全盘报错。from collections import Counter from sklearn.model_selection import train_test_split word_count Counter() for text in df[text]: word_count.update(cut_text(text)) max_vocab 30000 vocab {w: i 2 for i, (w, _) in enumerate(word_count.most_common(max_vocab))} vocab[PAD] 0 vocab[UNK] 1 max_len 80 def encode(words): ids [vocab.get(w, 1) for w in words[:max_len]] return ids [0] * (max_len - len(ids)) X [encode(cut_text(t)) for t in df[text]] y df[label].tolist() X_train, X_val, y_train, y_val train_test_split( X, y, test_size0.2, random_state42, stratifyy )most_common(max_vocab)只保留频率最高的30000个词剩下全部落到UNK。这是刻意为之低频词大多是错别字、人名和生僻表达让模型硬学它们的向量只会增加过拟合。i 2是因为0和1已经被PAD和UNK占用词表从2开始编号。encode先截断后补0保证每条样本长度正好是max_len。预处理阶段我调整最频繁的是下面三个参数直接给默认值和调整依据。参数常用值调整依据max_len80先跑一遍句子长度分布取95%分位数。电商短评60-80长文本往128以上走max_vocab30000总样本量万级时够用几千条的小数据改成5000-10000否则UNK占比太高test_size0.2样本量少于5000时改成0.1测试集太小评估结果不可信stratifyy让训练集和验证集的正负样本比例与全量保持一致类别不均衡时这个参数尤其关键。random_state42固定划分方式保证你换机器重跑结果一致答辩时这是一个能主动讲的稳定性细节。补充一个判断方法max_len不建议拍脑袋。先跑一遍[len(cut_text(t)) for t in df[text]]画个分布或者直接看分位数取95%分位数就是既保留绝大多数信息、又不让padding比例过高的选择。max_vocab设小了会有什么表现看encode后id为1的比例如果超过15%说明UNK太多词表该扩容了。2.4 为什么不直接用pad_sequences早期我图省事用tensorflow.keras.preprocessing.sequence.pad_sequences后来模型换到PyTorch就为了一行padding函数把整个tensorflow留着实在不划算。PyTorch生态里有torch.nn.utils.rnn.pad_sequence但它生成的是变长tensor对CNN和BiLSTM来说反而增加后续处理的复杂度。毕设项目里先把长度统一成max_len再进模型是最简单、心智负担最小的做法。模型和训练代码都不用考虑变长逻辑排查问题的时候少一个变量。代码里多几行手动padding换来的是全程可控这笔账划算。注意通用停用词表里几乎都带“不”“没”用之前先检查否则情感词会被误删。3. 用PyTorch搭建Text-CNN多尺寸卷积与参数选择3.1 Text-CNN在卷积什么很多人以为文本CNN和图像CNN一样在二维平面上滑窗实际不是。句子经过embedding后变成[batch, seq_len, embedding_dim]的矩阵一维卷积的窗口只在seq_len这一个维度上滑动embedding_dim是通道数。卷积核高度等于embedding_dim宽度等于filter_size所以filter_size3就是每次看连续3个词的向量组合本质是一组trigram特征。在中文情感分析里trigram能抓到“不好吃”“太差了”这类局部情绪短语这就是Text-CNN在这个任务里依然能打的最直接原因。CNN训练快、参数量少短文本上通常和BI-LSTM打个平手但每个卷积核只关心局部窗口长距离依赖确实抓不到。如果你的数据是几百字的长评论这一章的结构会明显吃亏第4章的结构就更合适。3.2 TextCNN模型代码import torch import torch.nn as nn import torch.nn.functional as F class TextCNN(nn.Module): def __init__(self, vocab_size, embedding_dim, filter_sizes, num_filters, num_classes, dropout0.5): super().__init__() # padding_idx0 表示 id 为 0 的位置向量恒为 0 且不参与训练 self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idx0) # 每种 filter_size 对应一个独立的一维卷积 self.convs nn.ModuleList([ nn.Conv1d(embedding_dim, num_filters, kernel_sizefs) for fs in filter_sizes ]) self.fc nn.Linear(len(filter_sizes) * num_filters, num_classes) self.dropout nn.Dropout(dropout) def forward(self, x): # x: [batch, seq_len] emb self.embedding(x) # [batch, seq_len, embedding_dim] # Conv1d 要求通道维在第二维把 embedding_dim 换过去 emb emb.transpose(1, 2) # [batch, embedding_dim, seq_len] pooled [] for conv in self.convs: c F.relu(conv(emb)) # [batch, num_filters, seq_len - fs 1] p F.max_pool1d(c, c.size(2)) # 对整个序列取最大得到 [batch, num_filters, 1] pooled.append(p.squeeze(2)) cat torch.cat(pooled, dim1) # [batch, len(filter_sizes) * num_filters] logits self.fc(self.dropout(cat)) return logitspadding_idx0这个参数很重要PAD位置不会产生梯度模型不会在无效位置上浪费学习能力。transpose(1, 2)是因为PyTorch的Conv1d输入要求[batch, channels, length]这里channels就是embedding_dimlength就是seq_len。卷积做完每个filter的输出长度是seq_len - fs 1然后max_pool1d把整段压成一个值相当于“这个卷积核在整个句子里找到的最强烈特征”。用max而不是average的原因很直接文本情感往往由少数词决定“难吃”出现一次就足以判负平均值会把两次“好吃”和一个“难吃”拉平max保留的是最强信号。这也是Text-CNN位置不敏感的来源——不管“难吃”在开头还是结尾max_pool都能抓住它。3.3 实例化与形状自检model TextCNN( vocab_size30002, embedding_dim128, filter_sizes[2, 3, 4], num_filters128, num_classes2, dropout0.5 ) dummy torch.randint(0, 30002, (4, 80)) print(model(dummy).shape) # torch.Size([4, 2])dummy是随机id张量4条样本、每条80个id。forward输出[4, 2]说明结构通了。刚写完模型先拿随机数据自检能避开一大半形状错误这是一个不用等训练就能打开黑匣子的习惯。我每次搭完网络都会跑这一句确认没问题再进训练。3.4 三个关键参数怎么调参数常用值调整说明filter_sizes[2, 3, 4]数据偏口语化可以加5再大意义不大num_filters128数据量大翻倍到256这个参数决定卷积层宽度embedding_dim128加载预训练向量时必须和向量文件维度一致filter_sizes不要超过句子平均长度的三分之一。比如短评平均40个字filter_size10基本没有统计意义max_pool的结果会被少数异常句子主导。这个参数比num_filters更影响效果改它的时候一定要结合2.3你算出来的长度分布一起看。还有一个认知误区要纠正这个模型绝大部分参数在embedding层30002 * 128约384万卷积和全连接加起来只有几千。也就是说加大filter_sizes或num_filters的边际成本主要在训练时间不在参数量爆炸。小数据集上真正要防的是embedding层过拟合手段是dropout和第6章的预训练向量。4. 用PyTorch搭建BI-LSTM双向状态拼接与最后一刻4.1 为什么情感分析要双向LSTM单向LSTM按顺序从左往右读“虽然贵但是值得”这句话读到“贵”时倾向负向读到句尾“值得”才修正但修正只体现在最后一个时刻的输出里中途的信息已经衰减。反向LSTM从右往左读句尾的“值得”会第一时间参与计算。两个方向拼起来模型同时看到“从头读到尾”和“从尾读到头”两种语义流。中文情感分析里转折句非常常见双向几乎是无痛提升代价只是参数量翻倍。这就是BI-LSTM在这个任务里比单向LSTM更受推荐的原因。4.2 BiLSTMClassifier模型代码class BiLSTMClassifier(nn.Module): def __init__(self, vocab_size, embedding_dim, hidden_size, num_layers, num_classes, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idx0) self.lstm nn.LSTM( embedding_dim, hidden_size, num_layers, batch_firstTrue, bidirectionalTrue, dropoutdropout if num_layers 1 else 0 ) self.fc nn.Linear(hidden_size * 2, num_classes) self.dropout nn.Dropout(dropout) def forward(self, x): # x: [batch, seq_len] emb self.embedding(x) # [batch, seq_len, embedding_dim] out, (h_n, c_n) self.lstm(emb) # out: [batch, seq_len, hidden_size * 2] # 取最后一层的两个方向正向最后时刻、反向最后时刻 h_fwd, h_bwd h_n[-2], h_n[-1] # 各为 [batch, hidden_size] h_cat torch.cat([h_fwd, h_bwd], dim1) # [batch, hidden_size * 2] logits self.fc(self.dropout(h_cat)) return logitsbatch_firstTrue让输入输出都是[batch, seq_len, features]不设的话输入得是[seq_len, batch, features]新手极容易在这里形状对不上。h_n的形状是[num_layers * 2, batch, hidden_size]双向让每层多出一份状态h_n[-2]是最后一层正向最后时刻h_n[-1]是最后一层反向最后时刻。反向LSTM的“最后时刻”对应句子开头因为它从右往左读但整个句子的信息已经被压缩进这个状态里。全连接输入维度必须是hidden_size * 2因为两个方向拼在一起。num_layers我默认设1。层数加到2参数量翻倍几千条评论的数据上通常只会加重过拟合。hidden_size取128和embedding_dim保持一致拼接后是256再接一个256到2的全连接这个容量对小数据集是合适的数据到十万级再考虑翻倍。4.3 hidden状态取法一个隐藏的坑双向LSTM的out保存了每个时间步的输出形状[batch, seq_len, hidden_size * 2]。很多人图省事直接取out[:, -1, :]当成最后时刻特征在单向LSTM里这没错双向里是错的。原因在时间步t上out[:, t, :]的前半段是正向第t时刻的输出后半段是反向第seq_len - 1 - t时刻的输出。所以out[:, -1]的后半段对应的是反向的第0时刻也就是句子开头的语义并不是真正的句尾信息。直接取最后一个位置等于把“正向句尾”和“反向句首”拼在一起信息错位。# 错误写法out[:, -1] 的一半是句首信息 out, _ self.lstm(emb) last out[:, -1, :]正确写法就是4.2里用h_n[-2:]拼接两个方向。如果你以后用pack_padded_sequence处理变长序列真正的句尾在out[:, real_len - 1, :]那个场景下取最后一个位置反而成立。毕设项目里统一padding成max_len用h_n方案最省心。4.4 CNN还是BI-LSTM按数据特征选我的判断标准三条句子平均长度超过64、转折结构多、论文里想讲清楚序列建模的时序性满足任意一条用BI-LSTM否则CNN做baseline性价比最高。毕设里更常见的路线是两个都实现做对比实验这也正好对应标题里的“CNN BI-LSTM”。别把两个模型的输出简单相加当成新模型答辩时很难讲清动机。分别调参、各自报准确率和F1、再分析模型在哪些样本上表现不同这个对比本身已经够撑起一个章节也比强行堆结构扎实得多。项目说明的写法我建议也是这个顺序先写baseline再写对比最后给结论。5. 训练、评估与避坑五个必踩的坑记录5.1 训练循环优化器、梯度裁剪与学习率训练是整个项目里代码最琐碎的部分我把完整循环放出来参数直接给默认值。import torch.optim as optim from sklearn.metrics import accuracy_score, f1_score, confusion_matrix def make_batch(X, y, batch_size): for i in range(0, len(X), batch_size): xb torch.tensor(X[i:ibatch_size], dtypetorch.long) yb torch.tensor(y[i:ibatch_size], dtypetorch.long) yield xb, yb model TextCNN( vocab_size30002, embedding_dim128, filter_sizes[2, 3, 4], num_filters128, num_classes2, dropout0.5 ) # 想对比就换成 BiLSTMClassifier(...) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr1e-3) scheduler optim.lr_scheduler.StepLR(optimizer, step_size5, gamma0.5) clip_value 2.0 batch_size 64 epochs 15 for epoch in range(epochs): model.train() train_loss 0.0 n_batches 0 for xb, yb in make_batch(X_train, y_train, batch_size): optimizer.zero_grad() out model(xb) loss criterion(out, yb) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), clip_value) optimizer.step() train_loss loss.item() n_batches 1 scheduler.step() print(fepoch {epoch1}, loss {train_loss / n_batches:.4f})优化器选Adam而不是SGD因为Adam对学习率不敏感小规模文本分类任务里省去大量调参时间。clip_grad_norm_给梯度设上限是LSTM防梯度爆炸的标准手段CNN加上也没坏处max_norm2.0这个值是我常用的起点。StepLR每5个epoch学习率减半配合15轮训练后期loss会比较平稳。想控制训练轮数可以在每个epoch结束后记录验证集loss连续3个epoch不下降就break这是最简单的早停写法。很多人会把验证集评估写进训练循环里我不建议训练循环保持单一职责评估单独写排查问题更快。5.2 评估指标准确率之外必须看F1训练完在验证集上完整跑一次评估。注意切到model.eval()关闭dropout和梯度计算。model.eval() preds, trues [], [] with torch.no_grad(): for xb, yb in make_batch(X_val, y_val, batch_size): out model(xb) pred out.argmax(dim1) preds.extend(pred.tolist()) trues.extend(yb.tolist()) print(acc:, accuracy_score(trues, preds)) print(f1:, f1_score(trues, preds)) print(confusion_matrix(trues, preds))只看acc很容易被骗。比如1000条样本里920条负向模型全部输出负向acc是92%F1却很难看。我在好几次实验里都被这种情况迷惑过后来养成习惯每次训练完先看F1和混淆矩阵再看acc。负向样本全对、正向样本全错混淆矩阵一眼就能看出来。5.3 五个必踩的坑坑1标签分布不均衡模型变成复读机 现象训练loss降得很稳验证集acc有85%F1只有0.2。 原因负向样本占90%模型只要全预测负向就赢了。 解决train_test_split必须加stratifyy如果还是不平衡用CrossEntropyLoss(weight...)给少数类加权或者对多数类做降采样。坑2BI-LSTM直接取out[:, -1]当句尾 现象两个模型对比时BI-LSTM比CNN还差一截长句子尤其离谱。 原因双向LSTM的out[:, -1]后半段是反向第0时刻即句首信息语义错位。 解决用h_n[-2:]拼接最后两向状态真要用变长输入配合pack_padded_sequence再取out[:, -1]。坑3训练loss一路掉验证loss震荡 现象前3个epoch正常第4个epoch开始验证loss反复横跳acc不稳定。 原因学习率偏大模型在最优解附近来回弹LSTM里最常见。 解决lr从1e-3降到5e-4或者把StepLR的step_size从5调到3。先降lr再换优化器别一上来怀疑数据。坑4max_len拍脑袋设200模型被PAD淹没 现象训练正常但验证集效果差且预测倾向某些类。 原因句子平均只有40个词max_len200意味着大部分位置是0。padding_idx虽然屏蔽了梯度但池化窗口被无效位置占满有效特征被稀释。 解决统计句子长度取95%分位数再回头改max_len。这跟2.3的建议呼应真正处理极长句时LSTM配合pack_padded_sequenceCNN就老实截断。坑5小数据上CNN过拟合 现象训练acc到99%验证只有72%差距越拉越大。 原因模型容量对数据量来说太大把训练样本背了下来。 解决dropout从0.5提到0.7embedding_dim从128降到64num_filters减半。这三招按顺序试一般第一招就见效。6. 预训练词向量初始化一份快速见效的文本分类提分改动6.1 加载word2vec并初始化embedding几千条标注数据下随机初始化的embedding要在训练中从零学词义很难学出“便宜”和“实惠”的相似性。换成预训练词向量模型开局就带着语言先验这是成本最低、最稳的提分方式。常见做法是找一个开源的中文词向量文件文本格式通常是“词 空格 浮点数”一行一个词。def load_vectors(path, vocab, dim): vectors {} with open(path, encodingutf-8) as f: for i, line in enumerate(f): if i 0: continue # 部分文件首行是词数维度 parts line.strip().split( ) word parts[0] if word in vocab: vectors[word] [float(x) for x in parts[1:dim1]] return vectors vectors load_vectors(word2vec.txt, vocab, 128) init model.embedding.weight.data init.normal_(0, 0.05) for word, idx in vocab.items(): if word in vectors: init[idx] torch.tensor(vectors[word]) model.embedding.weight.data init这段代码有三处要留意。首行忽略逻辑不一定通用有的文件没有首行先看前两行再定。切分用空格有些格式用tab我建议写个print看一眼再决定。dim必须和模型的embedding_dim一致加载128维文件就配128别混搭。词表里OOV的词保留随机初始化不影响整体。我自己做实验的习惯是把model.embedding.weight.requires_grad设成False跑一轮再设成True跑一轮。固定词向量时训练快、不易过拟合放开微调时上限更高但小数据上更容易过拟合。毕设里把两个结果都写上是很直观的对比实验。6.2 固定随机种子给答辩一个可复现结果最后补一个习惯训练前固定随机种子。PyTorch的模型初始化带随机性不固定的话两次训练可能差两个点。import random random.seed(42) torch.manual_seed(42)我的完整实验顺序是先用随机embedding把CNN跑通当baseline记录acc和F1再替换预训练词向量看涨跌最后上BI-LSTM做对比。每一步的代码单独存档模型文件也按版本命名答辩被问到“为什么这个设计”时每一步都有数据支撑。这个习惯救了我很多次希望帮到你。本文还有配套的精品资源点击获取