基于CNN-Bi-LSTM的中文情感分析系统实战与模块化设计

📅 发布时间:2026/9/15 6:18:54
基于CNN-Bi-LSTM的中文情感分析系统实战与模块化设计
简介基于CNN与Bi-LSTM的中文情感分析系统完整源码面向人工智能、计算机、自动化等专业的高校学生与从业者既能用于毕业设计、课程设计也可作为深度学习入门实战项目并支持在现有基础上调整参数、更换数据或扩展功能。压缩包共46个文件约75.78MB以Python源码、CSV情感语料、TensorFlow模型文件和设计报告等为主其中提供多个Keras版本的模型脚本从基础结构到CNN与Bi-LSTM融合网络均有覆盖有助于对比不同设计方案的效果。数据集中包含酒店评论等标注文本配合评分脚本可完整完成从数据预处理、模型训练到测试评估的流程设计报告则给出了整体架构、实验对比和参考思路对撰写毕业论文或项目文档很有帮助。目前已有61人浏览学习适合需要参考完整代码、想快速搭建中文情感分析系统的同学下载使用。1. 从一条差评说起为什么中文情感分析需要 CNN-Bi-LSTM一个电商运营每天要面对几百条用户评论“物流太慢了差评”和“物流太慢了但包装很好好评”仅靠规则匹配很难准确区分更别说识别“性价比很高就是发货有点慢”这种混合情绪。人工标注成本高普通词袋模型又丢掉了词序和上下文于是深度学习模型成了情感分析的主流方案。而这其中CNN-Bi-LSTM 组合模型被大量用于中文情感分析任务因为它同时具备局部特征提取和上下文语义建模能力在准确率和训练效率之间取得了一个不错的平衡。这篇博文就围绕这样一个典型项目来展开如何从零搭建一个基于 CNN-Bi-LSTM 的中文情感分析系统包括文本预处理、模型构建、训练调参以及支持二次开发的模块化设计思路。如果你正准备做 NLP 方向的毕业设计或者想在项目中快速集成一个情感分类功能再或者只是想搞清楚“别人写的开源情感分析代码到底怎么跑起来”读完这篇你都能有一条清晰的技术路径可走。2. 中文文本预处理把“还行吧”变成模型能读的向量2.1 中文语料清洗不只是去空格那么简单中文文本预处理比英文多出不少麻烦没有天然空格分词全角半角混用还有网络用语和 emoji 干扰。常见的做法是先做基础清洗再考虑分词。我一般用 Python 的re模块完成这一步它能把乱七八糟的原始评论整理成干净的纯文本序列import re import jieba def clean_text(text: str) - str: # 去除URL、提及、HTML标签 text re.sub(rhttps?://\S|www\.\S, , text) text re.sub(r.*?, , text) # 统一全角为半角避免字符匹配错位 text text.replace(, ,).replace(。, .).replace(, !) # 只保留中英文和数字丢弃表情符号 text re.sub(r[^\u4e00-\u9fa5a-zA-Z0-9], , text) # 去除连续空白 text re.sub(r\s, , text).strip() return text text 物流太慢了差评 cleaned clean_text(text) print(cleaned) # 输出物流太慢了 差评 # 结巴分词去掉停用词 tokens [w for w in jieba.cut(cleaned) if w.strip()] print(tokens) # 输出[物流, 太慢, 了, 差评]代码说明这串预处理做了三件事——先清除噪声字符保证后续分词不被干扰再统一符号避免同一词因为全角半角差异被拆成两个特征最后只保留中英文和数字把表情符号过滤掉。分词后去除空 token这一步很关键因为 jieba 对某些特殊字符会切出空字符串。如果你把正负样本混在一起看会发现“太慢了”和“太快了”这种词序差异清洗后依然保留Bi-LSTM正好可以捕捉这种时序信息。预处理结果建议保存为/data/clean_corpus.txt这样的中间文件方便后续调试时对比原始语料。2.2 词向量选择训练自己的 Word2Vec 还是用现成预训练处理完原始文本后下一步要把中文分词的结果映射成数值向量。业界有两种主流方案自己训练 Word2Vec用项目的领域语料比如几万条电商评论训练一个 100~200 维的词向量。优点是领域性强缺点是语料少时得不到好的语义表示。加载现成预训练词向量比如腾讯开源的 900 万中文词向量或各种中文域预训练模型。优点是覆盖面广缺点是有时会遇到 OOVOOV 是指不在词典里的词。就毕设项目而言我通常会在小数据集上自己做 Word2Vec这样能在论文里体现“模型可训练性”和“端到端”的设计思路。以下是训练词向量的标准做法使用gensimfrom gensim.models import Word2Vec from gensim.models.word2vec import LineSentence # 假设已经分词并保存为每行一句话的文件 sentences LineSentence(data/clean_corpus.txt) w2v_model Word2Vec(sentences, vector_size128, # 词向量维度 window5, # 上下文窗口 min_count2, # 过滤低频词小于2的词丢弃 sg1, # 使用skip-gram对低频词友好 workers4, epochs10) w2v_model.save(models/w2v_128.model) # 构建词汇表到索引的映射 word2idx {word: idx 2 for idx, word in enumerate(w2v_model.wv.index_to_key)} word2idx[PAD] 0 # 用于补齐短句 word2idx[UNK] 1 # 用于替换OOV词 print(词汇表大小, len(word2idx))参数说明vector_size128是经验值对情感分析任务来说 100~150 维足够太小丢失语义太大增加训练开销window5表示取目标词前后各 5 个词作为上下文窗口对情感词来说窗口过大容易混入不相关信息sg1选 skip-gram在小规模语料上效果通常优于 CBOWmin_count2会把只出现一次的词丢弃避免噪声影响。构建好的word2idx是后续搭建模型的第一块积木。2.3 序列补齐与数据集划分搞不定长度就没法进模型RNN 和 CNN 都要求输入是固定形状的张量而评论文本长短差异巨大。一条“好”是 1 个字一条“这物流真的让人无语了”是 12 个字如果不做补齐DataLoader根本没法打包成一个 batch。一个常见的做法是设定max_len64超过部分截断不足部分用 0 补齐。但要注意截断的位置对结果影响很大尾截断会保留开头信息适合“开头表达观点”的语料头截断则保留结尾信息。中文评论往往重点在最后所以可以尝试头截断也就是只保留最后 64 个字。下面是完整的编码流程import numpy as np import torch from torch.utils.data import Dataset, DataLoader from sklearn.model_selection import train_test_split def encode_sentences(texts, word2idx, max_len64): encoded [] for text in texts: tokens [w for w in jieba.cut(clean_text(text)) if w.strip()] ids [word2idx.get(w, word2idx[UNK]) for w in tokens] if len(ids) max_len: ids ids[-max_len:] # 尾截断保留最后64个词 else: ids ids [0] * (max_len - len(ids)) # 补齐 encoded.append(ids) return np.array(encoded) # 加载原始数据csv格式comment,label import pandas as pd df pd.read_csv(data/comments.csv) X encode_sentences(df[comment].tolist(), word2idx) y df[label].values # 0-负向1-中性2-正向 X_train, X_val, y_train, y_val train_test_split( X, y, test_size0.2, stratifyy, random_state42) print(训练集形状:, X_train.shape, 验证集形状:, X_val.shape)这里你需要重点留意stratifyy它会按标签比例划分数据避免某个类别的样本全部落在训练集或验证集中。对于情感分析三个类别的样本数量往往不均匀如果简单随机划分可能某个类在验证集里只有一两条评估指标完全失去意义。3. 模型构建PyTorch 实现 CNN-Bi-LSTM 的每个细节3.1 为什么是 CNN 和 Bi-LSTM 的组合而不是单用其中一种先说结论CNN 擅长抓取“局部关键词”组合比如“价格实惠”里的“实惠”但它不关心这些词出现的顺序Bi-LSTM 擅长建模上下文顺序一句话里有转折词“但是”时后面的内容往往才是真实态度单向 LSTM 只能记忆前文双向 LSTM 能看到前后文对转折和否定语气的识别更精准。两者拼接的经典结构是Embedding 层把词索引转换成向量CNN 层用多个不同尺寸的卷积核提取 n-gram 级别的局部特征Bi-LSTM 层再对词序列做双向编码最后把 CNN 和 Bi-LSTM 的输出拼接起来接一个全连接层完成三分类。也有人尝试 CNN 和 Bi-LSTM 串行连接CNN 输出作为 LSTM 输入但我个人更推荐并行结构因为可以控制两个分支的维度并且防止梯度在深层串行传播时消失。实际效果上并行结构在多个中文评测集上是强基线方案尤其适合数据量在几万级别的分类任务。3.2 Embedding 层与 CNN 分支实现在 PyTorch 里写这一段不需要像在 TensorFlow 里那样做复杂的 Keras 层合并直接继承nn.Module写清楚就行import torch import torch.nn as nn import torch.nn.functional as F class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim, num_filters128, filter_sizes[2, 3, 4]): super(TextCNN, self).__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) # padding_idx0 表示索引0对应的向量全为0不参与梯度更新 self.convs nn.ModuleList([ nn.Conv2d(1, num_filters, (fs, embed_dim)) for fs in filter_sizes ]) self.dropout nn.Dropout(0.5) def forward(self, x): # x shape: (batch, seq_len) emb self.embedding(x) # (batch, seq_len, embed_dim) emb emb.unsqueeze(1) # (batch, 1, seq_len, embed_dim) conv_outputs [] for conv in self.convs: conv_out F.relu(conv(emb)) # (batch, num_filters, conv_seq_len, 1) # 池化取每个feature map的最大值 pooled F.max_pool2d(conv_out, (conv_out.size(2), 1)) pooled pooled.squeeze(3).squeeze(2) # (batch, num_filters) conv_outputs.append(pooled) cnn_out torch.cat(conv_outputs, dim1) # (batch, num_filters * len(filter_sizes)) return cnn_out代码说明nn.Conv2d(1, num_filters, (fs, embed_dim))中fs对应卷积核在序列方向的宽度。fs3就相当于扫描 3 个连续词组成的窗口等价于 bigram 级别的 n-gram 特征。多个filter_sizes同时扫描就能捕获不同长度的短语模式。max_pool2d的作用是取每个特征图的全局最大值保留最强烈的激活特征同时把变长输入变成定长输出。值得注意的是padding_idx0让PAD位置的 Embedding 向量始终为 0这些位置的卷积结果全是背景噪声但池化操作会找出最大值避免 padding 干扰真实特征。3.3 Bi-LSTM 分支实现与特征拼接Bi-LSTM 的实现代码相对简短但陷阱很多。最常见的问题是把batch_first参数设错导致输入输出维度对不上。完整代码如下class BiLSTMEncoder(nn.Module): def __init__(self, vocab_size, embed_dim, hidden_size128, num_layers1): super(BiLSTMEncoder, self).__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.lstm nn.LSTM(embed_dim, hidden_size, num_layersnum_layers, batch_firstTrue, bidirectionalTrue) self.dropout nn.Dropout(0.5) def forward(self, x): emb self.embedding(x) # (batch, seq_len, embed_dim) lstm_out, (hn, cn) self.lstm(emb) # lstm_out: (batch, seq_len, hidden_size*2) # 取双向最后一层的隐藏状态拼接作为句子表示 forward hn[-2, :, :] # 最后一层正向隐状态 backward hn[-1, :, :] # 最后一层反向隐状态 combined torch.cat([forward, backward], dim1) # (batch, hidden_size*2) return combined class CNNBiLSTM(nn.Module): def __init__(self, vocab_size, embed_dim, num_classes, hidden_size128, num_filters128, filter_sizes[2, 3, 4]): super(CNNBiLSTM, self).__init__() self.cnn_branch TextCNN(vocab_size, embed_dim, num_filters, filter_sizes) self.bilstm_branch BiLSTMEncoder(vocab_size, embed_dim, hidden_size) self.fc nn.Sequential( nn.Linear(num_filters * len(filter_sizes) hidden_size * 2, 128), nn.ReLU(), nn.Dropout(0.3), nn.Linear(128, num_classes) ) def forward(self, x): cnn_out self.cnn_branch(x) lstm_out self.bilstm_branch(x) combined torch.cat([cnn_out, lstm_out], dim1) logits self.fc(combined) return logits代码说明LSTM在双向模式下返回的hn形状为(num_layers * 2, batch, hidden_size)其中前半部分[0, num_layers)是各层正向的最终隐状态后半部分[num_layers, num_layers * 2)是各层反向的最终隐状态。因此代码里用hn[-2]取正向最后一层hn[-1]取反向最后一层。这里没有使用lstm_out的全部序列输出而是直接拼最后的隐状态可以理解为一种注意力池化的简化版本好处是减少参数、训练更快缺点是无法捕捉句中对分类贡献最大的子句位置。如果你的任务中转折句特别多建议考虑对lstm_out做 max pooling 代替取最后隐状态效果对比如下表所示池化策略适合场景计算开销情感分类效果取最后隐状态拼接句尾情感信息为主低中等对序列输出做平均池化全局信息均衡分布低中等对序列输出做最大池化关键词驱动型分类低较好注意力机制加权平均转折、否定、讽刺较多中最好需更多训练数据个人经验是数据在 5 万条以下直接用最大池化数据超过 20 万条再上注意力机制否则容易过拟合。这个结论也能写进你的毕设论文里能体现对比实验的思考深度。3.4 初始化与参数选择规则模型搭建完成后参数初始化往往被忽略但它直接影响模型能否收敛。PyTorch 的默认初始化对 Embedding 层和 Linear 层没有问题但 LSTM 的权重初始化最好用正交初始化def init_weights(module): if isinstance(module, nn.LSTM): for name, param in module.named_parameters(): if weight_ih in name: nn.init.xavier_uniform_(param) elif weight_hh in name: nn.init.orthogonal_(param) elif bias in name: nn.init.zeros_(param) model CNNBiLSTM(vocab_sizelen(word2idx), embed_dim128, num_classes3, hidden_size128) model.apply(init_weights) # 统计参数量用于论文实验对比 total_params sum(p.numel() for p in model.parameters()) trainable_params sum(p.numel() for p in model.parameters() if p.requires_grad) print(f总参数量: {total_params:,}, 可训练参数: {trainable_params:,})参数选择上毕设项目推荐一组低风险高稳定性的配置embed_dim128、hidden_size128、num_filters128、filter_sizes[2,3,4]。这套配置在大多数中文情感数据集上都能收敛且单 epoch 训练速度在 CPU 上也能接受。如果追求精度可以把hidden_size加到 256同时把num_filters降到 64防止参数量爆炸。4. 训练与评估跑通训练循环还要会看混淆矩阵4.1 损失函数与优化器选择的实际经验情感分类是标准的多分类任务用nn.CrossEntropyLoss即可。唯一需要注意的是类别不均衡如果正向样本占 70%负向和中性各占 15%模型会倾向把所有样本都预测为正向准确率虚高但仍不可用。最简单的解决方案是在损失函数里传入weight参数import torch.nn as nn import torch.optim as optim class_counts torch.bincount(torch.tensor(y_train), minlength3) class_weights 1.0 / class_counts.float() class_weights class_weights / class_weights.sum() * 3 # 归一化使均值为1 class_weights class_weights.to(device) criterion nn.CrossEntropyLoss(weightclass_weights) optimizer optim.Adam(model.parameters(), lr1e-3, weight_decay1e-5) scheduler optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemin, factor0.5, patience2)代码说明weight参数会被 PyTorch 内部应用到每个类别的 loss 上让少数类样本获得更大的梯度权重。weight_decay1e-5是做 L2 正则化能有效防止过拟合。ReduceLROnPlateau的作用是当验证集 loss 连续 2 个 epoch 不下降时把学习率减半避免训练后期在最优解附近震荡。一句话记住配置经验Adam 配合默认 beta 值学习率从 1e-3 起调loss 不降就把学习率减半。4.2 训练循环代码早停、最佳模型保存、梯度裁剪训练循环是一个项目里最容易写乱的部分。这里给出一个训练和验证都包含的完整骨架你可以在train.py里直接复用best_val_acc 0 patience_counter 0 max_patience 5 for epoch in range(20): # 训练阶段 model.train() total_loss, correct, total 0, 0, 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_norm1.0) optimizer.step() total_loss loss.item() correct (logits.argmax(1) batch_y).sum().item() total batch_y.size(0) train_acc correct / total train_loss total_loss / len(train_loader) # 验证阶段 model.eval() val_loss, val_correct, val_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) loss criterion(logits, batch_y) val_loss loss.item() val_correct (logits.argmax(1) batch_y).sum().item() val_total batch_y.size(0) val_acc val_correct / val_total val_loss_avg val_loss / len(val_loader) print(fEpoch {epoch1:02d} | Train Loss: {train_loss:.4f} | Train Acc: {train_acc:.4f} | Val Loss: {val_loss_avg:.4f} | Val Acc: {val_acc:.4f}) scheduler.step(val_loss_avg) if val_acc best_val_acc: best_val_acc val_acc torch.save(model.state_dict(), models/best_model.pt) patience_counter 0 else: patience_counter 1 if patience_counter max_patience: print(Early stopping triggered.) break代码关键点有两个。第一是clip_grad_norm_Bi-LSTM 在长序列上很容易出现梯度范数超过 10 的情况一旦发生训练 loss 瞬间变成 NaN这行代码是必备保险丝。第二是“只保存验证集最高 acc 的参数”而不是等训练结束后再保存因为训练后期模型可能在训练集上继续提高但验证集开始下降我们最终用的是泛化性能最好的那一版参数。4.3 评估指标准确率不能反映全部问题情感分类最常用的报告格式是精确率、召回率和 F1 值再加一个混淆矩阵。只报准确率在类别不平衡时会掩盖问题。下面的代码生成完整的分类报告from sklearn.metrics import classification_report, confusion_matrix import matplotlib.pyplot as plt import seaborn as sns model.load_state_dict(torch.load(models/best_model.pt)) model.eval() all_preds, all_labels [], [] with torch.no_grad(): for batch_x, batch_y in val_loader: batch_x batch_x.to(device) logits model(batch_x) all_preds.extend(logits.argmax(1).cpu().numpy()) all_labels.extend(batch_y.numpy()) target_names [负面, 中性, 正面] report classification_report(all_labels, all_preds, target_namestarget_names, digits3) print(分类报告: \n, report) cm confusion_matrix(all_labels, all_preds) plt.figure(figsize(8, 6)) sns.heatmap(cm, annotTrue, fmtd, cmapBlues, xticklabelstarget_names, yticklabelstarget_names) plt.xlabel(Predicted) plt.ylabel(True) plt.savefig(results/confusion_matrix.png, dpi150)拿到混淆矩阵后看对角线上的数值是否达标再看哪些类别互相混淆。中文情感分析里最常见的是“中性”被错分为“正向”因为很多中性表达本来就含褒义词比如“整体还行吧”里的“行”容易被模型当成正向信号。如果这种现象严重优先尝试第二章里提到的更大卷积核比如把 filter_sizes 改成[2,3,4,5]或者增加hidden_size。5. 支持二次开发如何把模型打包成别人能直接用的模块5.1 设计一个通用的情感分析接口毕设项目的评分标准里“支持二次开发”通常意味着模块化设计要清晰不把业务逻辑和数据耦合到一个文件里。我一般会把推理部分封装成一个类import jieba import torch import json class SentimentAnalyzer: def __init__(self, model_pathmodels/best_model.pt, config_pathmodels/config.json): with open(config_path, r, encodingutf-8) as f: config json.load(f) self.word2idx {word: int(idx) for idx, word in enumerate(config[vocab])} self.max_len config[max_len] self.device torch.device(cuda if torch.cuda.is_available() else cpu) self.model CNNBiLSTM( vocab_sizelen(self.word2idx), embed_dimconfig[embed_dim], num_classesconfig[num_classes], hidden_sizeconfig[hidden_size] ) self.model.load_state_dict(torch.load(model_path, map_locationself.device)) self.model.to(self.device) self.model.eval() self.idx2label {0: 负面, 1: 中性, 2: 正面} def predict(self, text: str): cleaned clean_text(text) tokens [w for w in jieba.cut(cleaned) if w.strip()] ids [self.word2idx.get(w, 1) for w in tokens] # 1为UNK if len(ids) self.max_len: ids ids[-self.max_len:] else: ids [0] * (self.max_len - len(ids)) input_tensor torch.tensor([ids], deviceself.device) with torch.no_grad(): logits self.model(input_tensor) probs torch.softmax(logits, dim1).squeeze() pred torch.argmax(probs).item() return { label: self.idx2label[pred], confidence: float(probs[pred]), probabilities: { label: float(probs[i]) for i, label in self.idx2label.items() } }这个类的好处是调用方只需两行代码就能完成预测analyzer SentimentAnalyzer() print(analyzer.predict(发货很快质量也很好下次会再来))代码说明把模型参数、词汇表、超参数全部保存到config.json而不是硬编码在代码里是二次开发的核心。其他人拿到项目后不需要看懂模型内部实现只需要修改配置文件里的路径和参数就能接入自己的数据。torch.softmax转换后的confidence可以直接展示在前端界面上作为人工审核的参考指标。5.2 数据接口规范化换数据集不换代码支持二次开发的另一个重要方面是别人拿到代码后能否只换一种格式的数据集就能重新训练我建议把数据加载统一封装成一个函数只接收(text, label)形式的可迭代对象def load_data_from_csv(path, text_colcomment, label_collabel): df pd.read_csv(path) return list(zip(df[text_col], df[label_col]))同时提供一个train_from_scratch()入口函数内部调用前面章节写的完整流程。这样别人想换一个情感分类领域比如微博负面评论、新闻评论、商品评价只需要把 CSV 文件准备好跑一个入口函数即可不需要代码级修改。这也是“毕设答辩加分项”里最容易体现工程能力的一处。5.3 模型热更新与增量训练技巧二次开发场景中最容易遇到的问题新数据来了要不要重新训练整个模型我的建议是不要全量重新训练可以做一个简单有效的小技巧——冻结 Embedding 和 CNN 层只微调全连接分类层。具体做法是在训练器里指定需要求梯度的参数# 选择性冻结二次开发的增量训练用 for name, param in model.named_parameters(): if embedding in name or convs in name or lstm in name: param.requires_grad False else: param.requires_grad True这样做的好处有三个计算量小训练时间从小时级降到分钟级防止新数据量少时破坏原有语义表示同时给二次开发者一个清晰的训练“边界”——哪些参数该动哪些不该动一目了然。等到增量数据积累到一定程度再全部解冻做一次全量微调。5.4 部署与性能观测最后是部署。毕设项目通常不需要写完整的 Dockerfile但至少要说明两种调用方式命令行调用和 HTTP API。这里给一个用 FastAPI 暴露接口的最小例子代码简洁且近年热度高from fastapi import FastAPI from pydantic import BaseModel app FastAPI() analyzer SentimentAnalyzer() class Req(BaseModel): text: str app.post(/predict) def predict(req: Req): result analyzer.predict(req.text) return {text: req.text, **result}这里有一个性能小提醒FastAPI 每次请求都会走一次forward如果并发量上来了需要给analyzer加锁或用进程池隔离。但在毕设场景下同步接口完全够用。你还可以做一个短暂的日志记录把每次请求的文本、预测标签、置信度、耗时写入文件到了答辩现场展示运行效果时这些记录就是最好的材料——因为它们证明了你的系统在真实环境中被调用过而不只是静态示例。在模型实际投入使用时别忘了返回内容的可解释性。你可以用第三层的卷积核激活值来生成一个简单热力图展示哪些词对预测结果贡献最大。别用太复杂的 Grad-CAM 之类的工具自己实现一个简单的“按词的 Softmax 激活加权”即可那会是很扎实的展示亮点。本文还有配套的精品资源点击获取