ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

中文情感分析系统实战:CNN-Bi-LSTM源码复现与毕业设计指南

中文情感分析系统实战:CNN-Bi-LSTM源码复现与毕业设计指南 简介这份资源是面向计算机相关专业学生与项目实战学习者的中文情感分析系统源码采用CNN-Bi-LSTM混合神经网络实现可作为毕业设计、课程设计或期末大作业的完整参考方案。项目经导师指导并通过评审代码结构完整、可运行适合希望快速上手深度学习文本分类的初学者对照学习。压缩包共35个文件约73.2MB包含13个Python源码文件、10个文本数据与说明文件、2个模型pb文件及配套权重索引文件另有png与jpeg图片、md说明文档等覆盖数据预处理、模型构建、训练与评估全流程。资源中提供了酒店评论数据集与评分报告脚本便于读者理解中文情感分析从语料加载到结果输出的完整链路。目前已有72人学习下载适合需要完整项目骨架、排错思路与目录结构参考的学习者使用。1. 中文情感分析系统从 CNN-Bi-LSTM 源码到可复现的毕业设计电商评论、舆情短文本、课程作业里的影评数据集只要涉及“这句话是正面还是负面”就绕不开中文情感分析。但真正动手时你会发现公开的 CNN-Bi-LSTM 源码要么只给一个模型文件要么预处理和训练脚本对不上跑起来 loss 不降、准确率卡在 50% 上下。这篇笔记围绕一套可复现的中文情感分析系统源码展开把 CNN 提取局部 n-gram 特征、Bi-LSTM 建模长距离依赖这条经典路线拆成能照着敲的步骤。适合正在做毕业设计、需要一份能讲清原理又能跑出结果的学生也适合想快速验证中文短文本分类方案的工程师。读完你能自己搭出数据清洗、词表构建、模型训练到推理可视化的完整链路并且知道每个参数为什么这么设。2. CNN-Bi-LSTM 做中文情感分析为什么比单模型稳2.1 卷积层和双向 LSTM 各自解决什么问题中文情感分析的核心难点在于情感信号既藏在局部短语里也藏在跨句的语义转折里。比如“虽然包装一般但是用起来真香”前半句负面词“一般”和后半句正面词“真香”之间隔了转折单靠词袋模型会直接判错。CNN 在这里的作用是滑动卷积核一次性捕捉相邻几个词的组合特征。卷积核大小为 3 时它能同时看到“用起来真香”这种三元组相当于自动学习 n-gram而且比手工构造 n-gram 更省事。池化层再做一次最大池化把整句里最强的情感信号保留下来位置不敏感这对评论这种语序灵活的中文文本很关键。Bi-LSTM 补的是另一块。它按时间步读入词向量前向 LSTM 记住“虽然”开头的让步语气后向 LSTM 从“真香”往回看两个方向的隐状态拼接后转折关系就被编码进去了。常见做法是把 CNN 的输出序列再喂给 Bi-LSTM让局部特征在时序上再走一遍最后取 Bi-LSTM 最后一个时间步的隐状态接全连接层做二分类。选型理由很直接TextCNN 快但抓不住长依赖Bi-LSTM 能抓长依赖但局部特征靠词向量本身两者串起来在中文短文本上通常比单用其中一个高 2 到 4 个百分点。代价是训练慢一些显存占用高一些但对毕业设计这种数据量几万条完全扛得住。2.2 数据预处理分词、去噪和标签对齐中文和英文最大的区别是词与词之间没有空格必须先分词。源码里一般用 jieba但要注意情感分析场景下不能无脑分词得保留否定词和程度副词。比如“不好”如果被切成“不”和“好”情感极性就反了。常见做法是加载自定义词典把“不好”“不错”“一般般”这类词加进去或者直接用 jieba 的cut配合停用词表但停用词表里千万别删“不”“没”“很”这些。数据清洗步骤我一般按这个顺序走先去 HTML 标签和 URL再统一全角半角然后去掉纯数字和表情符号表情可以单独做特征但入门版先去掉最后按标点切句只保留长度在 5 到 200 字之间的样本。标签对齐指的是确保每条文本对应的 label 是 0 或 1不能有缺失或字符串类型的标签混进去。import re import jieba # 加载自定义词典把情感短语加进去避免否定词被切散 jieba.load_userdict(sentiment_dict.txt) def clean_text(text): # 去掉 HTML 标签和 URL text re.sub(r[^], , text) text re.sub(rhttp[s]?://\S, , text) # 全角转半角 text .join([chr(ord(ch) - 65248) if 65281 ord(ch) 65374 else ch for ch in text]) # 只保留中文、英文和基本标点 text re.sub(r[^\u4e00-\u9fa5a-zA-Z。、], , text) return text.strip() def tokenize(text): # 精确模式分词过滤空白 return [w for w in jieba.lcut(text) if w.strip()] # 示例 raw 虽然包装一般但是用起来真香http://example.com cleaned clean_text(raw) tokens tokenize(cleaned) print(tokens) # [虽然, 包装, 一般, 但是, 用起来, 真香]这段代码里clean_text负责去噪和归一化tokenize负责切词。参数上sentiment_dict.txt每行一个词可以手动加“真香”“一般般”“不推荐”这类。注意re.sub里的字符集范围\u4e00-\u9fa5覆盖常用汉字如果数据里有生僻字或繁体需要额外处理。分词后建议统计一下词频把出现次数少于 2 的词替换成UNK否则词表太大嵌入层参数爆炸。2.3 词表构建和序列截断的工程细节词表构建不是简单地把所有词塞进去。我一般按词频从高到低排序取前 20000 个词保留PAD和UNK两个特殊 token。PAD用于补齐短句UNK用于替换低频词。词表大小直接影响嵌入层参数量20000 乘 128 维大约是 256 万参数对毕业设计的显卡比如 6G 显存刚好。序列截断长度取 128 或 256。中文评论大部分在 50 字以内128 能覆盖 95% 以上的样本。超过的直接截断不足的用PAD补到 128。这里有个坑如果 batch 里所有样本都补到 128但实际有效长度只有 20Bi-LSTM 会在大量 padding 上浪费计算还会引入噪声。解决办法是用pack_padded_sequence但入门版可以先不做等模型跑通再优化。from collections import Counter import torch def build_vocab(token_lists, max_size20000, min_freq2): counter Counter() for tokens in token_lists: counter.update(tokens) # 按频率排序保留特殊 token vocab {PAD: 0, UNK: 1} for word, freq in counter.most_common(max_size - 2): if freq min_freq: vocab[word] len(vocab) return vocab def encode(tokens, vocab, max_len128): ids [vocab.get(w, vocab[UNK]) for w in tokens] if len(ids) max_len: ids [vocab[PAD]] * (max_len - len(ids)) else: ids ids[:max_len] return ids # 假设 token_lists 是分词后的列表 vocab build_vocab(token_lists) input_ids encode(tokens, vocab) input_tensor torch.tensor(input_ids).unsqueeze(0) # 增加 batch 维度build_vocab里max_size和min_freq是两个必调参数。max_size太大会导致嵌入层过拟合太小会丢信息20000 是中文情感分析的常用值。min_freq2能过滤掉拼写错误和噪声词。encode函数里先截断再补齐顺序不能反否则短句会被截掉有效部分。unsqueeze(0)是为了模拟 batch 维度实际训练时用DataLoader自动拼 batch。3. 模型搭建与训练从嵌入层到全连接层的参数怎么定3.1 CNN 卷积核数量和 Bi-LSTM 隐藏层维度的搭配模型结构按“嵌入层 → CNN → Bi-LSTM → 全连接”串。嵌入层维度一般取 128 或 256和词表大小配合。CNN 部分我习惯用三个不同尺寸的卷积核比如 2、3、4每个尺寸 64 个核这样能同时捕捉二元、三元、四元短语。卷积后接 ReLU 和最大池化池化窗口覆盖整个序列长度输出三个 64 维向量拼接成 192 维。Bi-LSTM 的隐藏层维度取 128双向拼接后是 256 维。层数一般 1 层就够2 层容易过拟合除非数据量超过 10 万条。Dropout 加在嵌入层后和 Bi-LSTM 输出后概率 0.3 到 0.5。全连接层从 256 维降到 2 维二分类中间可以加一个 64 维的隐藏层但毕业设计为了简单可以直接 256 到 2。import torch.nn as nn class CNN_BiLSTM(nn.Module): def __init__(self, vocab_size, embed_dim128, cnn_filters64, lstm_hidden128, num_classes2): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) # 三个不同尺寸的卷积核 self.convs nn.ModuleList([ nn.Conv1d(embed_dim, cnn_filters, kernel_sizek) for k in [2, 3, 4] ]) self.lstm nn.LSTM(cnn_filters * 3, lstm_hidden, batch_firstTrue, bidirectionalTrue) self.dropout nn.Dropout(0.4) self.fc nn.Linear(lstm_hidden * 2, num_classes) def forward(self, x): # x: [batch, seq_len] emb self.embedding(x) # [batch, seq_len, embed_dim] emb emb.permute(0, 2, 1) # [batch, embed_dim, seq_len] conv_outs [] for conv in self.convs: c torch.relu(conv(emb)) # [batch, cnn_filters, seq_len-k1] c torch.max(c, dim2)[0] # [batch, cnn_filters] conv_outs.append(c) cnn_out torch.cat(conv_outs, dim1) # [batch, cnn_filters*3] # 扩展成序列喂给 LSTM这里简单重复 seq_len 次 cnn_out cnn_out.unsqueeze(1).repeat(1, x.size(1), 1) # [batch, seq_len, cnn_filters*3] lstm_out, _ self.lstm(cnn_out) # [batch, seq_len, lstm_hidden*2] # 取最后一个时间步 last lstm_out[:, -1, :] last self.dropout(last) return self.fc(last)这段代码里cnn_filters64和lstm_hidden128是核心参数。卷积核尺寸[2,3,4]覆盖常见中文短语长度。padding_idx0让嵌入层对PAD不更新梯度。注意permute是因为Conv1d要求通道在第二维。LSTM 输入这里做了简化把 CNN 输出重复成序列实际也可以把 CNN 输出作为每个时间步的额外特征拼到词向量上但那样改动较大入门版先跑通这个结构。3.2 训练循环、学习率与早停策略训练用 Adam 优化器学习率 1e-3batch size 64。损失函数用交叉熵二分类也可以用 BCEWithLogitsLoss但交叉熵更通用。每个 epoch 后在验证集上算准确率和 F1如果验证 loss 连续 3 个 epoch 不下降就早停保存验证集上最好的模型。import torch.optim as optim from sklearn.metrics import f1_score device torch.device(cuda if torch.cuda.is_available() else cpu) model CNN_BiLSTM(vocab_sizelen(vocab)).to(device) optimizer optim.Adam(model.parameters(), lr1e-3) criterion nn.CrossEntropyLoss() best_f1 0 patience 3 wait 0 for epoch in range(20): model.train() 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() optimizer.step() # 验证 model.eval() preds, labels [], [] with torch.no_grad(): for batch_x, batch_y in val_loader: batch_x batch_x.to(device) logits model(batch_x) pred torch.argmax(logits, dim1).cpu().numpy() preds.extend(pred) labels.extend(batch_y.numpy()) f1 f1_score(labels, preds, averagebinary) print(fEpoch {epoch}, F1: {f1:.4f}) if f1 best_f1: best_f1 f1 torch.save(model.state_dict(), best_model.pt) wait 0 else: wait 1 if wait patience: print(Early stop) break学习率 1e-3 是 Adam 的默认值如果 loss 震荡可以降到 5e-4。patience3是早停耐心值验证集小的时候可以设 5。保存模型用state_dict而不是整个模型方便后续加载。注意验证时model.eval()和torch.no_grad()要成对出现否则 dropout 和 batch norm 会干扰结果。3.3 类别不平衡时用加权损失和阈值调整中文情感分析数据集经常正负样本比例悬殊比如好评远多于差评。这时候准确率会虚高模型全预测成多数类也能到 80%。解决办法有两个一是损失函数加weight参数给少数类更高权重二是调整分类阈值默认 0.5 改成 0.3 或 0.4让少数类更容易被预测出来。# 假设正类样本 8000负类 2000 weight torch.tensor([1.0, 4.0]).to(device) # 负类权重是正类的 4 倍 criterion nn.CrossEntropyLoss(weightweight) # 推理时调整阈值 def predict_with_threshold(logits, threshold0.4): probs torch.softmax(logits, dim1) # 取正类概率和阈值比较 return (probs[:, 1] threshold).long()weight的计算方式是多数类样本数除以少数类样本数。阈值调整要在验证集上试一般从 0.3 到 0.5 扫一遍选 F1 最高的。注意阈值调整只影响推理不影响训练。4. 避坑与排查源码跑不通时先看这 5 条4.1 现象loss 不降准确率一直在 50% 附近原因通常是标签没对齐或者嵌入层没更新。先检查batch_y是不是全是 0 或全是 1再看padding_idx0有没有设如果没设PAD也会参与梯度更新把嵌入层带偏。解决方法是打印一个 batch 的标签分布确认正负样本都有然后检查嵌入层定义。4.2 现象分词后“不好”变成“不”“好”情感极性反了这是 jieba 默认词典的问题。解决方法是加载自定义词典把“不好”“不错”“不推荐”“一般般”这类词加进去。如果不想维护词典可以在分词后做后处理把“不”和后面的形容词合并成一个 token。血泪经验是停用词表里千万别删否定词和程度副词。4.3 现象训练集准确率 99%验证集只有 60%典型过拟合。先看数据量如果训练集不到 5000 条模型参数又大肯定过拟合。解决办法是加 Dropout、减小嵌入维度、早停或者做数据增强比如同义词替换、随机删除非关键词。注意验证集不能参与训练也不能用来调超参数太多次否则验证集也会过拟合。4.4 现象GPU 显存不够batch size 降到 16 还是 OOM检查序列长度是不是设成了 512 或更大。中文评论 128 足够改成 128 能省一半显存。另外 CNN 的卷积核数量 64 乘 3 个尺寸如果改成 32 也能省。还可以用梯度累积batch size 设 16累积 4 次再更新等效 batch size 64。4.5 现象推理时单条预测结果和批量预测不一致原因是model.eval()没加dropout 还在起作用。另外单条预测时unsqueeze(0)加的 batch 维度要和训练时一致。如果训练时用了pack_padded_sequence推理时也要用否则 LSTM 在 padding 上的输出会不一样。解决方法是封装一个predict函数内部统一做eval和no_grad。5. 把模型用起来推理封装、可视化与一个提点技巧模型训练完只是半成品毕业设计答辩时老师更想看你怎么用。我一般封装一个SentimentAnalyzer类对外只暴露predict(text)方法内部做清洗、分词、编码、推理、阈值判断返回标签和置信度。这样演示时输入一句话就能出结果比贴训练日志直观得多。class SentimentAnalyzer: def __init__(self, model_path, vocab, max_len128, threshold0.4): self.model CNN_BiLSTM(vocab_sizelen(vocab)).to(device) self.model.load_state_dict(torch.load(model_path, map_locationdevice)) self.model.eval() self.vocab vocab self.max_len max_len self.threshold threshold def predict(self, text): cleaned clean_text(text) tokens tokenize(cleaned) ids encode(tokens, self.vocab, self.max_len) tensor torch.tensor(ids).unsqueeze(0).to(device) with torch.no_grad(): logits self.model(tensor) probs torch.softmax(logits, dim1) score probs[0, 1].item() label 正面 if score self.threshold else 负面 return label, score analyzer SentimentAnalyzer(best_model.pt, vocab) print(analyzer.predict(虽然包装一般但是用起来真香)) # (正面, 0.87)可视化部分毕业设计常要求画混淆矩阵和 ROC 曲线。混淆矩阵用 sklearn 的confusion_matrixROC 用roc_curve注意正类概率要传probs[:, 1]而不是标签。如果想让答辩更出彩可以加一个注意力可视化把 Bi-LSTM 每个时间步的隐状态权重画成热力图标出哪些词对情感判断贡献大。这个不需要改模型结构推理时返回lstm_out和attention_weights即可。最后一个提点技巧中文情感分析里否定词和转折词是两大信号源。可以在输入里额外拼接一个手工特征向量比如“是否包含转折词”“否定词数量”和 CNN-Bi-LSTM 的输出拼接后一起进全连接层。这个改动很小但在小数据集上通常能提 1 到 2 个点。我自己的习惯是先把基线跑通再逐个加特征每加一个就在验证集上看 F1 变化涨了才保留。希望帮到你。本文还有配套的精品资源点击获取
返回列表