☰
CNN-BiLSTM中文情感分析:毕设源码实战与调参避坑指南
2026/10/5 2:54:40 网站建设 项目流程

简介:这是一套面向计算机相关专业学生与项目实战学习者的中文情感分析系统源码,采用CNN-Bi-LSTM混合神经网络实现,适合作为毕业设计、课程设计或期末大作业的参考方案。资源包共35个文件,以13个Python源码文件为核心,涵盖数据预处理、模型构建与训练、评分报告等模块,另含10个txt说明文档、2个pb模型文件、2个data数据分片、2个index索引及png、jpeg截图与md说明,压缩包约73.2MB,目录结构清晰,便于按模块查阅与二次开发。项目围绕酒店评论数据展开情感倾向分类,配套训练权重与评估脚本,可帮助读者理解文本向量化、卷积特征提取与双向LSTM时序建模的完整流程。目前已有73人学习下载,适合希望快速上手深度学习文本分类、积累实战经验的学习者参考借鉴。

1. 中文情感分析系统:从 CNN-Bi-LSTM 源码到能跑通的毕设

电商评论、外卖评价、弹幕留言,这些中文短文本里藏着最直接的用户情绪。但中文没有空格分词,一句话里「不」和「好」隔了三个字,普通词袋模型直接翻车。我带过几届毕设,十个人里有六个选情感分析,最后能跑出 85% 以上准确率的,基本都用了 CNN 加 Bi-LSTM 这套组合。CNN 负责从词向量里抽局部 n-gram 特征,Bi-LSTM 负责把前后文顺序信息吃进去,两者拼起来对「虽然贵但是真好用」这种转折句特别管用。这篇笔记就按一份能直接复现的 Python 源码结构,把数据预处理、模型搭建、训练调参、避坑排查一路讲透,适合正在做毕设、需要一套能写进论文又能跑出结果的同学。

2. CNN-Bi-LSTM 中文情感分析:模型结构为什么这么搭

2.1 中文短文本的坑:分词、OOV 和否定词

中文情感分析和英文最大的区别在输入层。英文按空格切词就行,中文得先分词。常见做法是用 jieba 做切分,但毕设场景下我更推荐直接用字级别(char-level)建模。原因有三个:第一,字级别不需要维护分词词典,外卖评论里的「绝绝子」「yyds」这类新词不会变成 OOV;第二,中文单字本身携带情感极性,「好」「差」「烂」都是单字;第三,字级别序列长度可控,一般评论 50 到 100 字,padding 到 128 就够,显存压力小。

分词方案也不是不能用,但要注意停用词表别把否定词删了。「不」「没」「别」这些必须保留,否则「不好」被切成「好」,情感直接反转。我一般会在预处理阶段单独维护一个否定词白名单,分词后强制保留。

字表构建的逻辑是:遍历所有训练文本,统计字频,取 top 5000 作为词表,其余映射到<UNK>。padding 用<PAD>,索引 0 留给它。这样输入张量就是[batch_size, seq_len]的整数矩阵。

import jieba from collections import Counter def build_vocab(texts, max_vocab=5000): # 字级别词表:统计所有字符频率 counter = Counter() for text in texts: counter.update(list(text)) # 按字切分,不用 jieba # 保留最高频的 max_vocab 个字 most_common = counter.most_common(max_vocab - 2) vocab = {'<PAD>': 0, '<UNK>': 1} for char, _ in most_common: vocab[char] = len(vocab) return vocab def text_to_ids(text, vocab, max_len=128): # 转 id 并截断/填充到固定长度 ids = [vocab.get(ch, vocab['<UNK>']) for ch in text[:max_len]] if len(ids) < max_len: ids += [vocab['<PAD>']] * (max_len - len(ids)) return ids

max_vocab=5000是经验值,评论类数据集 3000 到 8000 都合理,太小丢信息,太大 embedding 参数量上去容易过拟合。max_len=128覆盖 95% 以上的短评论,超过的直接截断,因为情感极性通常在前半句就定了。

2.2 CNN 抽局部特征,Bi-LSTM 抓长距离依赖

模型结构分三层。第一层是 Embedding,把字 id 映射成 128 维向量,这个维度是调参重点,64 太小表达不够,256 在几千条数据上容易过拟合。第二层是 CNN,用多个不同尺寸的卷积核(比如 2、3、4)在 embedding 序列上滑动,每个尺寸抽 128 个特征图,然后做 max-pooling。这一步抓的是「非常好」「太差了」这种局部搭配。第三层是 Bi-LSTM,把 CNN 输出的特征序列再走一遍双向 LSTM,hidden_size 设 128,正向和反向各 128,拼起来 256 维,取最后一个时间步的输出接全连接分类。

为什么 CNN 在前 Bi-LSTM 在后?如果反过来,LSTM 输出的序列再卷积,局部特征已经被时序揉碎了,卷积核抓不到干净的 n-gram。CNN 先抽局部,Bi-LSTM 再串上下文,这个顺序在文本分类里是经过验证的。

import torch import torch.nn as nn class CNN_BiLSTM(nn.Module): def __init__(self, vocab_size, embed_dim=128, num_filters=128, filter_sizes=(2, 3, 4), hidden_size=128, num_classes=2): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) # 多尺寸卷积:每个尺寸抽 num_filters 个特征 self.convs = nn.ModuleList([ nn.Conv1d(embed_dim, num_filters, fs) for fs in filter_sizes ]) self.lstm = nn.LSTM(num_filters * len(filter_sizes), hidden_size, batch_first=True, bidirectional=True) self.dropout = nn.Dropout(0.5) self.fc = nn.Linear(hidden_size * 2, num_classes) def forward(self, x): emb = self.embedding(x) # [B, L, E] emb = emb.permute(0, 2, 1) # [B, E, L] 给 Conv1d conv_outs = [] for conv in self.convs: c = torch.relu(conv(emb)) # [B, F, L-fs+1] c = torch.max(c, dim=2)[0] # [B, F] 池化 conv_outs.append(c) cnn_feat = torch.cat(conv_outs, dim=1) # [B, F*3] # 这里 CNN 输出是 [B, F*3],需要 reshape 成序列喂 LSTM cnn_feat = cnn_feat.unsqueeze(1) # [B, 1, F*3] lstm_out, _ = self.lstm(cnn_feat) # [B, 1, 2H] out = self.dropout(lstm_out[:, -1, :]) return self.fc(out)

上面这段代码有个结构上的取舍需要说明:CNN 做完 max-pooling 后序列维度被压掉了,直接接 LSTM 其实只走了一个时间步,Bi-LSTM 的双向优势发挥不出来。更合理的做法是 CNN 不做全局池化,保留序列维度,用Conv1d的padding='same'让输出长度不变,再送进 LSTM。我一般会改成这样:

def forward(self, x): emb = self.embedding(x).permute(0, 2, 1) # [B, E, L] conv_outs = [torch.relu(conv(emb)) for conv in self.convs] # 每个 conv 输出长度不同,统一 padding 到最长 max_len = max(c.size(2) for c in conv_outs) padded = [torch.nn.functional.pad(c, (0, max_len - c.size(2))) for c in conv_outs] cnn_feat = torch.cat(padded, dim=1).permute(0, 2, 1) # [B, L', F*3] lstm_out, _ = self.lstm(cnn_feat) # [B, L', 2H] out = self.dropout(lstm_out[:, -1, :]) # 取最后时间步 return self.fc(out)

这样 Bi-LSTM 真正在序列上跑,前后文信息都能吃到。参数上num_filters=128、filter_sizes=(2,3,4)、hidden_size=128是我在几万条评论数据上比较稳的组合,数据量小于 5000 条时把 num_filters 降到 64,dropout 提到 0.6。

2.3 数据划分与类别不平衡的处理

毕设数据集常见的问题是正负样本不均衡,好评远多于差评。直接训练模型会偏向多数类,准确率看着高但差评全错。处理方式有两种:一是损失函数加权重,nn.CrossEntropyLoss(weight=torch.tensor([1.0, 3.0])),少数类权重调高;二是过采样,把少数类复制到和多数类相当。我一般先用权重法,简单且不引入重复样本。

数据划分按 8:1:1 切训练、验证、测试。注意要先 shuffle 再切,否则如果数据是按时间爬的,切出来分布不一致。验证集用来早停,测试集只在最后跑一次。

from sklearn.model_selection import train_test_split from torch.utils.data import DataLoader, TensorDataset # X: list of id 序列, y: list of 标签 X_train, X_tmp, y_train, y_tmp = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y) X_val, X_test, y_val, y_test = train_test_split(X_tmp, y_tmp, test_size=0.5, random_state=42, stratify=y_tmp) train_loader = DataLoader(TensorDataset(torch.tensor(X_train), torch.tensor(y_train)), batch_size=64, shuffle=True)

stratify=y保证切分后各类比例一致,random_state=42固定结果方便复现。batch_size 设 64,显存不够降到 32,但太小会让 BatchNorm 不稳定(这个模型里没用 BN,影响不大)。

3. 训练脚本与参数配置:能跑出 85% 准确率的实操步骤

3.1 训练循环与早停机制

训练脚本的核心是损失反传加验证集监控。优化器用 Adam,学习率 1e-3,这是文本分类的默认起点。如果 loss 震荡厉害,降到 5e-4。训练轮数设 30,但基本靠早停停在第 10 到 15 轮。早停的 patience 设 5,验证集 loss 连续 5 轮不降就停,同时保存验证集最优的模型权重。

import torch.optim as optim device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = CNN_BiLSTM(vocab_size=len(vocab)).to(device) # 类别权重:假设正类样本是负类的 3 倍 criterion = nn.CrossEntropyLoss(weight=torch.tensor([1.0, 3.0]).to(device)) optimizer = optim.Adam(model.parameters(), lr=1e-3) best_val_loss = float('inf') patience, patience_counter = 5, 0 for epoch in range(30): model.train() total_loss = 0 for batch_x, batch_y in train_loader: batch_x, batch_y = batch_x.to(device), batch_y.to(device) optimizer.zero_grad() logits = model(batch_x) loss = criterion(logits, batch_y) loss.backward() # 梯度裁剪,防止 LSTM 梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() total_loss += loss.item() # 验证阶段 model.eval() val_loss, correct, total = 0, 0, 0 with torch.no_grad(): for batch_x, batch_y in val_loader: batch_x, batch_y = batch_x.to(device), batch_y.to(device) logits = model(batch_x) val_loss += criterion(logits, batch_y).item() preds = logits.argmax(dim=1) correct += (preds == batch_y).sum().item() total += batch_y.size(0) val_loss /= len(val_loader) acc = correct / total print(f"Epoch {epoch}: train_loss={total_loss:.4f}, val_loss={val_loss:.4f}, val_acc={acc:.4f}") if val_loss < best_val_loss: best_val_loss = val_loss torch.save(model.state_dict(), 'best_model.pt') patience_counter = 0 else: patience_counter += 1 if patience_counter >= patience: print("Early stopping") break

clip_grad_norm_的 max_norm 设 5.0 是 LSTM 类模型的常规操作,不裁剪的话偶尔会出现 loss 突然变 NaN。学习率 1e-3 配合 Adam 在几千到几万条数据上都能收敛,如果验证集准确率卡在 70% 上不去,先检查数据标签有没有错,再考虑调小学习率。

3.2 关键参数表与调参顺序

调参不要一次改一堆,按影响从大到小来。下面这张表是我在毕设场景下总结的优先级和取值范围。

参数推荐值影响调整方向
embed_dim128字向量表达能力数据少降到 64
num_filters128CNN 特征图数量数据少降到 64
hidden_size128LSTM 记忆容量一般不动
dropout0.5防过拟合过拟合提到 0.6
lr1e-3收敛速度震荡降到 5e-4
batch_size64梯度稳定性显存不够降到 32
max_len128序列覆盖度长文本提到 256

调参顺序:先固定其他参数,只动学习率,找到 loss 下降最稳的值;再调 dropout 解决过拟合;最后微调 embed_dim 和 num_filters。不要一上来就网格搜索,毕设时间耗不起。

3.3 评估指标:准确率之外必须看 F1

准确率在类别不平衡时会骗人。正类 90%、负类 10% 的数据,全预测成正类也有 90% 准确率,但 F1 只有 0.47。所以评估必须看负类的 precision、recall 和 F1。用 sklearn 的 classification_report 一行出结果。

from sklearn.metrics import classification_report model.load_state_dict(torch.load('best_model.pt')) model.eval() all_preds, all_labels = [], [] with torch.no_grad(): for batch_x, batch_y in test_loader: batch_x = batch_x.to(device) preds = model(batch_x).argmax(dim=1).cpu().numpy() all_preds.extend(preds) all_labels.extend(batch_y.numpy()) print(classification_report(all_labels, all_preds, target_names=['负向', '正向'], digits=4))

如果负类 recall 低于 0.6,说明模型对差评识别不够,回去把损失权重再调高,或者对负类做过采样。论文里报告结果时,准确率和宏平均 F1 都要写,只写准确率答辩容易被问住。

4. 避坑与排查:中文情感分析源码跑不通的 5 个常见问题

4.1 现象:loss 一直是 0.69 不降

原因:二分类交叉熵在模型输出接近随机时 loss 就是 ln(2)≈0.693。常见触发点是标签没对齐,比如 embedding 的 padding_idx 设了 0,但标签里也混进了 0 被当成负类。或者数据里全是同一个类别,模型学不到区分边界。

解决:先打印标签分布Counter(y),确认两类都有。再检查padding_idx=0和标签编码是否冲突,标签用 0/1 时确保输入 id 里的 0 只代表 padding。如果数据确实单类,回去补数据。

4.2 现象:验证集准确率比训练集还高

原因:这不是好事,通常是验证集太小或者和训练集分布不一致。比如验证集里恰好多数是容易分的样本。另一个可能是 dropout 在验证时没关,model.eval()漏写了。

解决:确认训练循环里验证前调了model.eval(),训练前调了model.train()。验证集至少占总数据 10%,且用 stratify 保证分布一致。如果还异常,把验证集和训练集混在一起重新切。

4.3 现象:GPU 显存溢出 OOM

原因:max_len 设太大,或者 batch_size 太大。128 长度、batch 64、embed 128 的模型大概占 1.5G 显存,如果同时开了多个进程或者显卡本身小,就会 OOM。

解决:先把 batch_size 降到 16 试,能跑再往上加。max_len 从 128 降到 64 也能省一半显存,但会截断长评论。另外torch.no_grad()在验证阶段必须加,否则计算图一直累积。

4.4 现象:预测结果全是同一类

原因:类别权重设得太极端,比如负类权重给了 10,模型为了降 loss 全预测成负类。或者学习率太大,模型直接跳过最优解。

解决:权重从[1.0, 2.0]开始试,不要一上来给 10。学习率降到 5e-4 再跑几轮看。如果还是全同类,检查测试集标签是不是真的只有一类。

4.5 现象:中文乱码或字表覆盖不全

原因:读取文件时编码没指定,Windows 下默认 gbk,Linux 下 utf-8,跨平台就乱。或者字表只从训练集构建,测试集里出现了训练集没有的字,全变成<UNK>。

解决:读文件统一open(path, encoding='utf-8')。字表构建时把训练集和验证集合并统计,测试集不参与构建但用同一个字表映射。<UNK>比例超过 5% 就说明字表太小,调大 max_vocab。

5. 把模型推到 90% 的进阶技巧:预训练字向量与注意力池化

基础版跑通后,想再往上提几个点,有两个方向性价比最高。第一个是换掉随机初始化的 embedding,用预训练字向量。中文预训练词向量资源不少,加载后冻结前几层或者用较小学习率微调,在小数据集上提升明显。加载逻辑是:读向量文件,按字表顺序填进 embedding 矩阵,没命中的字保持随机初始化。

def load_pretrained_embedding(vocab, vec_path, embed_dim=128): # vec_path 格式:每行 "字 v1 v2 ... vn" embedding_matrix = torch.randn(len(vocab), embed_dim) * 0.1 hit = 0 with open(vec_path, encoding='utf-8') as f: for line in f: parts = line.strip().split() if len(parts) != embed_dim + 1: continue char = parts[0] if char in vocab: embedding_matrix[vocab[char]] = torch.tensor([float(x) for x in parts[1:]]) hit += 1 print(f"命中 {hit}/{len(vocab)} 个字向量") return embedding_matrix # 赋值给模型 model.embedding.weight.data.copy_(load_pretrained_embedding(vocab, 'vectors.txt')) # 冻结 embedding,只训练上层 model.embedding.weight.requires_grad = False

命中率低于 70% 就别用了,说明字表和预训练向量覆盖不一致。冻结 embedding 适合数据量小于 1 万条的情况,数据多的话可以放开微调,学习率设 1e-4。

第二个方向是把 CNN 的 max-pooling 换成注意力池化。max-pooling 只取最强烈的特征,注意力池化给每个时间步算权重再加权求和,能保留更多信息。实现上就是加一个线性层算 attention score,softmax 后加权。

class AttentionPooling(nn.Module): def __init__(self, hidden_dim): super().__init__() self.attn = nn.Linear(hidden_dim, 1) def forward(self, lstm_out): # lstm_out: [B, L, H] scores = self.attn(lstm_out).squeeze(-1) # [B, L] weights = torch.softmax(scores, dim=1) # [B, L] return torch.sum(lstm_out * weights.unsqueeze(-1), dim=1) # [B, H]

把 LSTM 输出接这个池化层替代取最后时间步,在长评论上通常能涨 1 到 2 个点。注意力权重还能可视化,论文里放一张热力图,答辩时很加分。

我自己的习惯是:基础版先跑通,确认数据管道和评估没问题,再上预训练向量,最后加注意力。每一步都单独记录验证集 F1,涨了才保留。毕设时间紧的话,基础版加类别权重已经够用,别一上来就堆模块,调不通的时候连哪一层出的问题都定位不了。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询