
简介这是一套面向计算机相关专业学生与项目实战学习者的中文情感分析系统源码采用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_vocab5000): # 字级别词表统计所有字符频率 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_len128): # 转 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 idsmax_vocab5000是经验值评论类数据集 3000 到 8000 都合理太小丢信息太大 embedding 参数量上去容易过拟合。max_len128覆盖 95% 以上的短评论超过的直接截断因为情感极性通常在前半句就定了。2.2 CNN 抽局部特征Bi-LSTM 抓长距离依赖模型结构分三层。第一层是 Embedding把字 id 映射成 128 维向量这个维度是调参重点64 太小表达不够256 在几千条数据上容易过拟合。第二层是 CNN用多个不同尺寸的卷积核比如 2、3、4在 embedding 序列上滑动每个尺寸抽 128 个特征图然后做 max-pooling。这一步抓的是「非常好」「太差了」这种局部搭配。第三层是 Bi-LSTM把 CNN 输出的特征序列再走一遍双向 LSTMhidden_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_dim128, num_filters128, filter_sizes(2, 3, 4), hidden_size128, num_classes2): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) # 多尺寸卷积每个尺寸抽 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_firstTrue, bidirectionalTrue) 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-fs1] c torch.max(c, dim2)[0] # [B, F] 池化 conv_outs.append(c) cnn_feat torch.cat(conv_outs, dim1) # [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的paddingsame让输出长度不变再送进 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, dim1).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_filters128、filter_sizes(2,3,4)、hidden_size128是我在几万条评论数据上比较稳的组合数据量小于 5000 条时把 num_filters 降到 64dropout 提到 0.6。2.3 数据划分与类别不平衡的处理毕设数据集常见的问题是正负样本不均衡好评远多于差评。直接训练模型会偏向多数类准确率看着高但差评全错。处理方式有两种一是损失函数加权重nn.CrossEntropyLoss(weighttorch.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_size0.2, random_state42, stratifyy) X_val, X_test, y_val, y_test train_test_split(X_tmp, y_tmp, test_size0.5, random_state42, stratifyy_tmp) train_loader DataLoader(TensorDataset(torch.tensor(X_train), torch.tensor(y_train)), batch_size64, shuffleTrue)stratifyy保证切分后各类比例一致random_state42固定结果方便复现。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_sizelen(vocab)).to(device) # 类别权重假设正类样本是负类的 3 倍 criterion nn.CrossEntropyLoss(weighttorch.tensor([1.0, 3.0]).to(device)) optimizer optim.Adam(model.parameters(), lr1e-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_norm5.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(dim1) correct (preds batch_y).sum().item() total batch_y.size(0) val_loss / len(val_loader) acc correct / total print(fEpoch {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) breakclip_grad_norm_的 max_norm 设 5.0 是 LSTM 类模型的常规操作不裁剪的话偶尔会出现 loss 突然变 NaN。学习率 1e-3 配合 Adam 在几千到几万条数据上都能收敛如果验证集准确率卡在 70% 上不去先检查数据标签有没有错再考虑调小学习率。3.2 关键参数表与调参顺序调参不要一次改一堆按影响从大到小来。下面这张表是我在毕设场景下总结的优先级和取值范围。参数推荐值影响调整方向embed_dim128字向量表达能力数据少降到 64num_filters128CNN 特征图数量数据少降到 64hidden_size128LSTM 记忆容量一般不动dropout0.5防过拟合过拟合提到 0.6lr1e-3收敛速度震荡降到 5e-4batch_size64梯度稳定性显存不够降到 32max_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(dim1).cpu().numpy() all_preds.extend(preds) all_labels.extend(batch_y.numpy()) print(classification_report(all_labels, all_preds, target_names[负向, 正向], digits4))如果负类 recall 低于 0.6说明模型对差评识别不够回去把损失权重再调高或者对负类做过采样。论文里报告结果时准确率和宏平均 F1 都要写只写准确率答辩容易被问住。4. 避坑与排查中文情感分析源码跑不通的 5 个常见问题4.1 现象loss 一直是 0.69 不降原因二分类交叉熵在模型输出接近随机时 loss 就是 ln(2)≈0.693。常见触发点是标签没对齐比如 embedding 的 padding_idx 设了 0但标签里也混进了 0 被当成负类。或者数据里全是同一个类别模型学不到区分边界。解决先打印标签分布Counter(y)确认两类都有。再检查padding_idx0和标签编码是否冲突标签用 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 下默认 gbkLinux 下 utf-8跨平台就乱。或者字表只从训练集构建测试集里出现了训练集没有的字全变成UNK。解决读文件统一open(path, encodingutf-8)。字表构建时把训练集和验证集合并统计测试集不参与构建但用同一个字表映射。UNK比例超过 5% 就说明字表太小调大 max_vocab。5. 把模型推到 90% 的进阶技巧预训练字向量与注意力池化基础版跑通后想再往上提几个点有两个方向性价比最高。第一个是换掉随机初始化的 embedding用预训练字向量。中文预训练词向量资源不少加载后冻结前几层或者用较小学习率微调在小数据集上提升明显。加载逻辑是读向量文件按字表顺序填进 embedding 矩阵没命中的字保持随机初始化。def load_pretrained_embedding(vocab, vec_path, embed_dim128): # vec_path 格式每行 字 v1 v2 ... vn embedding_matrix torch.randn(len(vocab), embed_dim) * 0.1 hit 0 with open(vec_path, encodingutf-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 scoresoftmax 后加权。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, dim1) # [B, L] return torch.sum(lstm_out * weights.unsqueeze(-1), dim1) # [B, H]把 LSTM 输出接这个池化层替代取最后时间步在长评论上通常能涨 1 到 2 个点。注意力权重还能可视化论文里放一张热力图答辩时很加分。我自己的习惯是基础版先跑通确认数据管道和评估没问题再上预训练向量最后加注意力。每一步都单独记录验证集 F1涨了才保留。毕设时间紧的话基础版加类别权重已经够用别一上来就堆模块调不通的时候连哪一层出的问题都定位不了。希望帮到你。本文还有配套的精品资源点击获取