news 2026/9/11 13:14:41

基于LSTM的三分类中文情感分析完整实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于LSTM的三分类中文情感分析完整实现

简介:Python基于LSTM三分类的文本情感分析项目,面向计算机相关专业学生和需要实战练习的开发者,适用于课程设计、期末大作业或毕业设计参考。这是一份大三学生的期末项目,经导师指导并认可,评审分99分,代码完整、保证可运行,附有文档说明,新手也能快速上手。压缩包共13个文件,包含3个CSV数据集、3个Python程序、2个Jupyter Notebook交互式分析文件,以及训练好的LSTM模型、模型配置、词向量和依赖库清单,覆盖数据预处理、模型训练到测试评估的完整流程;训练脚本与测试脚本分离,便于按模块学习和二次开发。包体约11.63MB,轻量易用。目前已有306人学习,源码可直接复现三分类情感分析实验,帮助理解文本向量化、LSTM建模与情感极性判别等关键步骤;项目内附的README文档还能引导读者快速掌握目录结构与运行方法,是高质量的课程设计与毕业设计参考。

1. 三分类文本情感分析:为什么 LSTM 仍是值得手写的高分方案

电商评论、客服工单、舆情监控这三类场景里,最常要求的情感分析不是二分类,而是"正向 / 中性 / 负向"三分类。中性样本的边界模糊是它比二分类难的主要原因:一句"价格还行"到底算中性还是正向,不同标注员会给出不同答案,模型也很容易把所有弱情绪句子都吸进中性类。有不少人觉得 Transformer 出来后 LSTM 已经过时,但数据量只有几千到几万条、单条文本几十到几百字时,LSTM 收敛稳定、显存占用小、每一步都好解释。下面的完整方案覆盖一个可复现的源码链路:jieba 预处理、词表构建、PyTorch 的 nn.LSTM 模型、训练调参与评估。环境只要完成 python 安装后执行pip install torch jieba scikit-learn就能跑通,适合做课程设计、毕业设计,也适合需要离线部署的轻量业务。

2. 数据清洗与词表构建:三分类任务的第一步决定上限

2.1 先统计标签分布,再决定中性样本的处理策略

拿到标注数据后第一件事不是写模型,而是统计三类样本的数量。三分类标注里中性样本通常占 40% 以上,且一致性最差。用 pandas 统计一眼就能看清:

import pandas as pd df = pd.read_csv('sentiment.csv', encoding='utf-8') print(df['label'].value_counts()) # 示例输出: # 1(中性) 6112 # 2(正向) 5230 # 0(负向) 3871

看到分布后再决定要不要做类别加权,而不是直接把数据丢进模型。另一个高频坑是标签映射:中文标签转数字时要写死映射关系,比如label_map = {'负向': 0, '中性': 1, '正向': 2},不要用 pandas 的factorize()factorize按出现顺序分配编号,换一次数据顺序编号就变,训练好的模型加载后预测结果会整体错位。这个映射表在后面对比预测结果时会反复用到,建议单独存成 JSON。

2.2 jieba 分词与停用词过滤:最小可用清洗管道

中文文本没有天然空格分隔,必须分词。jieba 是默认方案,下面的管道处理了标点、停用词和单字噪音:

import re import jieba stopwords = set() with open('stopwords.txt', encoding='utf-8') as f: for line in f: if line.strip(): stopwords.add(line.strip()) STRONG_WORDS = {'好', '差', '烂', '快', '慢', '贵'} def clean_text(text: str) -> str: text = re.sub(r'[^\u4e00-\u9fa5a-zA-Z0-9]', ' ', str(text)) text = re.sub(r'\s+', ' ', text).strip() tokens = [] for w in jieba.cut(text): w = w.strip() if not w: continue if w in stopwords and w not in STRONG_WORDS: continue if len(w) == 1 and w not in STRONG_WORDS: continue tokens.append(w) return ' '.join(tokens) df['clean'] = df['text'].apply(clean_text)

这个管道做了三件事:把非中英数字符替换成空格,避免表情符号和标点干扰;过滤停用词;过滤单字。注意STRONG_WORDS白名单,像"好、差、贵"这类单字在短文本里是强情感词,如果被停用词表或单字规则过滤掉,会直接丢掉关键信号。停用词表可以从网上下载常见中文停用词表,通常几百行就够了,不需要追求大而全。

提示:清洗函数必须同时在训练和预测时调用。线上预测时忘了做clean_text,词表匹配率会断崖式下降,这是三分类项目里最常见的"训练分高、实测分低"原因。

2.3 词表、编码与 Padding:让 Embedding 层拿到固定形状

模型要接收固定形状的张量,需要把分词后的句子映射成索引序列,再 padding 到同一长度。词表要设置min_count过滤低频词,把出现次数太少的词替换成<unk>,否则 Embedding 层会有大量几乎没有梯度更新的词向量:

from collections import Counter import torch from torch.nn.utils.rnn import pad_sequence class Vocab: def __init__(self, min_count=2): self.word2idx = {'<pad>': 0, '<unk>': 1} self.idx2word = {0: '<pad>', 1: '<unk>'} self.min_count = min_count def build(self, tokenized_sentences): counter = Counter() for sent in tokenized_sentences: counter.update(sent.split()) for word, cnt in counter.items(): if cnt >= self.min_count: idx = len(self.word2idx) self.word2idx[word] = idx self.idx2word[idx] = word def encode(self, sent, max_len=64): ids = [self.word2idx.get(w, 1) for w in sent.split()][:max_len] return torch.tensor(ids, dtype=torch.long) vocab = Vocab(min_count=2) vocab.build(df['clean']) print('词表大小:', len(vocab.word2idx)) seqs = [vocab.encode(s) for s in df['clean']] x_pad = pad_sequence(seqs, batch_first=True, padding_value=0) lengths = torch.tensor([len(seq) for seq in seqs])

pad_sequence会把序列按 batch 内最长长度对齐,padding_value=0对应词表的<pad>lengths张量保存每条样本的真实长度,训练时传给pack_padded_sequence,让 LSTM 跳过 padding 位置的计算,既省显存,又避免全零向量参与状态更新。max_len的选择可以先统计所有句子分词后的长度分布,覆盖 95% 的样本即可,一般取 64 到 128。如果句子长度差异极大,先截断再 padding,不要让极端长句撑大整个矩阵。

3. LSTM 模型结构:从 nn.LSTM 到三分类输出层的完整实现

3.1 门控机制为什么适合情感分析

LSTM 由 Hochreiter 和 Schmidhuber 提出,核心是用遗忘门、输入门和输出门控制记忆单元的写入与丢弃。在情感分析里,情感极性往往取决于相隔较远的几个词:例如"虽然上菜慢,但味道确实好","但"之后的转折决定了整体是正向。遗忘门会学习保留转折后的关键信息、丢弃前面的负面铺垫,这是普通的 RNN 和词袋模型难以做到的。

这套模型设计里 LSTM 只充当编码器,不负责最终分类。分类由最后的全连接层完成,LSTM 负责把长度不等的句子压缩成一个固定维度的语义向量。对文本分类这类任务,不需要像序列生成那样逐时刻输出,通常取每个方向最后一个时间步的隐状态或全部时间步的池化结果。下面的实现采用双向 LSTM,因为"没有想象中那么差"这类否定结构里,后向信息能帮助模型确认"差"被"没有"消解了。

3.2 完整模型代码:Embedding + 双向 LSTM + 全连接

import torch import torch.nn as nn from torch.nn.utils.rnn import pack_padded_sequence class LSTMSentimentModel(nn.Module): def __init__(self, vocab_size, embed_dim=128, hidden_dim=128, num_layers=2, num_classes=3, dropout=0.5): super().__init__() self.embedding = nn.Embedding( vocab_size, embed_dim, padding_idx=0) self.lstm = nn.LSTM( input_size=embed_dim, hidden_size=hidden_dim, num_layers=num_layers, batch_first=True, dropout=dropout if num_layers > 1 else 0.0, bidirectional=True, ) self.dropout = nn.Dropout(dropout) self.fc = nn.Linear(hidden_dim * 2, num_classes) def forward(self, x, lengths): emb = self.embedding(x) # (batch, max_len, embed_dim) packed = pack_padded_sequence( emb, lengths.cpu(), batch_first=True, enforce_sorted=False) _, (h_n, _) = self.lstm(packed) # h_n 形状: (num_layers * 2, batch, hidden_dim) last_fwd = h_n[-2] # 最后一层前向 last_bwd = h_n[-1] # 最后一层后向 h = torch.cat([last_fwd, last_bwd], dim=1) logits = self.fc(self.dropout(h)) return logits

关键参数说明:

  • padding_idx=0:让<pad>位置的词向量恒为零且不参与梯度更新,这是最容易漏掉的参数。漏掉它,padding 位置会学到随机向量,干扰 LSTM 状态。
  • bidirectional=True:输出维度翻倍为hidden_dim * 2,所以全连接输入是hidden_dim * 2而不是hidden_dim,两个方向拼接后语义更完整。
  • dropout=0.5:作用于 LSTM 层之间和全连接前。数据量只有几千条时建议提到 0.6 防过拟合;验证集 loss 震荡时先降回 0.3。
  • enforce_sorted=False:允许 batch 内序列不按长度排序,省去每次迭代前手动排序,代价是 pack 时内部多做一次排序,数据量大时可改回 True 并预排序。
  • lengths.cpu()pack_padded_sequence要求长度张量在 CPU 上,模型搬 GPU 后这行不能省。

3.3 最后的输出层:logits、softmax 与池化策略选择

fc输出的是未归一化的 logits,形状(batch, 3)。训练时配合CrossEntropyLoss,损失函数内部会做 softmax,不要在模型里提前加,否则数值稳定性和梯度都会受影响。预测时才用torch.softmax(logits, dim=1)取概率,或直接argmax取类别。

取隐状态有两种常见策略。上面用的是拼接最后一层两个方向的最终隐状态,适合短文本。另一种策略是对 LSTM 的输出做时间维度的平均池化:

output, _ = self.lstm(packed) # 需要先 pad_packed_sequence 还原 output, _ = nn.utils.rnn.pad_packed_sequence( output, batch_first=True) # 按真实长度做 masked mean,而不是直接 mean mask = (x != 0).unsqueeze(-1).float() h = (output * mask).sum(dim=1) / mask.sum(dim=1)

平均池化对"情感词分散在句子各处"的样本更稳,最终隐状态则更强调句尾信息。三分类任务里我一般先试拼接最终隐状态,如果验证集准确率上不去,再换成 masked mean,两者改动都很小。

3.4 可选增强:加载预训练词向量

数据量小于一万条时,随机初始化的 Embedding 也能收敛,但加载 word2vec 预训练向量通常能带来 2 到 5 个百分点的提升。加载时只取词表里有的词,未命中的词保留随机初始化:

def load_pretrained_embedding(vocab, w2v_path, embed_dim): embed = torch.zeros(len(vocab.word2idx), embed_dim) found = 0 with open(w2v_path, encoding='utf-8') as f: for line in f: parts = line.rstrip().split(' ') if len(parts) != embed_dim + 1: continue word = parts[0] if word in vocab.word2idx: embed[vocab.word2idx[word]] = torch.tensor( [float(v) for v in parts[1:]]) found += 1 print(f'命中 {found}/{len(vocab.word2idx)}') return nn.Embedding.from_pretrained(embed, padding_idx=0, freeze=False)

注意freeze=False,让预训练向量继续参与微调,一般微调比冻结多 1 到 2 个点。这一步属于锦上添花,如果文档里写明"预训练向量的命中率、是否微调、对准确率的影响",对判断项目完成度很有帮助。

4. 训练循环与调参:交叉熵、类权重与早停

4.1 类别不均衡用权重交叉熵,而不是过采样

三分类数据里中性样本多、正负样本少是常态。常见做法是用compute_class_weight计算各类权重,传给CrossEntropyLoss。权重大的类别梯度被放大,相当于让模型更关注样本少的类别:

from sklearn.utils.class_weight import compute_class_weight import numpy as np y = df['label'].to_numpy() class_weight = compute_class_weight( 'balanced', classes=np.array([0, 1, 2]), y=y) class_weight = torch.tensor(class_weight, dtype=torch.float) print('类别权重:', class_weight) criterion = nn.CrossEntropyLoss(weight=class_weight)

如果不加权重,模型很容易学成"全都预测中性",整体准确率看起来不低,但正向和负向的召回率接近 0。训练时要打印每个类别的召回率,不能只看 loss 和总准确率。权重交叉熵的问题是中性类被压缩后,边界样本容易被推给正负类,需要观察混淆矩阵来确认偏移方向。

4.2 训练主循环:梯度裁剪、学习率调度与早停

下面是完整的训练循环,每个 epoch 结束在验证集上计算准确率,连续多个 epoch 不提升就提前停止:

from torch.utils.data import DataLoader, TensorDataset, random_split import torch.optim as optim dataset = TensorDataset(x_pad, torch.tensor(y, dtype=torch.long), lengths) n_val = int(len(dataset) * 0.2) train_ds, val_ds = random_split( dataset, [len(dataset) - n_val, n_val], generator=torch.Generator().manual_seed(42)) train_loader = DataLoader(train_ds, batch_size=64, shuffle=True) val_loader = DataLoader(val_ds, batch_size=128) model = LSTMSentimentModel(vocab_size=len(vocab.word2idx)) optimizer = optim.Adam(model.parameters(), lr=1e-3) scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=5, gamma=0.5) best_acc = 0.0 patience = 5 wait = 0 for epoch in range(30): model.train() total_loss = 0.0 for xb, yb, lb in train_loader: optimizer.zero_grad() logits = model(xb, lb) loss = criterion(logits, yb) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step() total_loss += loss.item() model.eval() correct = 0 total = 0 with torch.no_grad(): for xb, yb, lb in val_loader: logits = model(xb, lb) preds = logits.argmax(dim=1) correct += (preds == yb).sum().item() total += yb.size(0) val_acc = correct / total if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), 'best_model.pt') wait = 0 else: wait += 1 if wait >= patience: print(f'early stop at epoch {epoch}') break scheduler.step() print(f'epoch {epoch} loss={total_loss / len(train_loader):.4f} ' f'val_acc={val_acc:.4f}')

这个循环有三个容易出问题的位置。clip_grad_norm_的 max_norm 设为 5.0,防止 LSTM 梯度爆炸,loss 出现 NaN 时优先检查这里。scheduler.step()放在每个 epoch 之后,StepLR每 5 个 epoch 把学习率减半,让后期收敛更稳。torch.save保存的是state_dict而不是整个模型,加载时必须先实例化同样的模型再load_state_dict。如果想把训练搬到 GPU,还需要在循环前加model = model.to(device),并把xb, yb.to(device)

4.3 参数起点值与调整方向

参数起点值调整方向
embed_dim128词表超过 5 万时升到 200 或 300
hidden_dim128欠拟合时升到 256;显存紧张先降这个
num_layers2数据量过万才建议上 3 层,否则容易过拟合
dropout0.5验证集波动大时降到 0.3,过拟合时升到 0.6
batch_size64文本平均长度超过 128 时降到 32
max_len64长评论场景升到 128,同时确认内存
learning_rate1e-3Adam 下 loss 不降换 5e-4,不要直接上 1e-2
patience5数据噪声大时放宽到 8

参数调整每次只动一个维度。同时改学习率和 dropout,实验记录里就分不清是哪个起了作用。把每次的实验结果记成表格,这也是文档说明里最有分量的部分。

4.4 损失不下降时的对照检查顺序

先确认数据管道:打印一个 batch 的x_padlengths,检查输入是否全为 0,label 是否都在 0/1/2 范围内。CrossEntropyLoss接收的是类别索引而不是 one-hot,传错形状会直接报维度错误。再确认清洗的一致性:训练用了clean_text,验证集如果直接喂原始文本,词表匹配率会很低。然后看学习率:1e-3 对 Adam 是安全起点,loss 反复横跳就降到 5e-4,下降太慢则升到 3e-3。最后看lengthspack_padded_sequence报错时检查长度张量是否在 CPU、类型是否为整型,以及长度是否与x_pad第二维匹配。

5. 混淆矩阵、模型持久化与单条预测闭环

训练结束后,验证集准确率说明不了全部问题。三分类必须把每个类别的 Precision、Recall 分开看:

from sklearn.metrics import classification_report, confusion_matrix y_true, y_pred = [], [] with torch.no_grad(): for xb, yb, lb in val_loader: logits = model(xb, lb) y_pred.extend(logits.argmax(dim=1).tolist()) y_true.extend(yb.tolist()) print(classification_report( y_true, y_pred, target_names=['负向', '中性', '正向'])) print(confusion_matrix(y_true, y_pred))

如果混淆矩阵显示"中性"列把另外两类大量吸收,说明决策边界偏向中性,可以把中性类在类别权重里下调 20%,或者预测时对非中性类的 logits 加一个小的偏移量再取argmax。这两种做法本质是移动决策边界,都要在文档里记录调整量和效果,这正是"源码+文档说明"里文档部分的价值。

预测阶段的闭环要注意预处理完全一致:加载词表、再次调用clean_textvocab.encode,长度要包成 batch 传入模型:

def predict(model, vocab, text): model.eval() cleaned = clean_text(text) ids = vocab.encode(cleaned) ids = ids.unsqueeze(0) # (1, seq_len) length = torch.tensor([ids.size(1)]) with torch.no_grad(): logits = model(ids, length) prob = torch.softmax(logits, dim=1).squeeze(0) return prob.numpy() # [负向概率, 中性概率, 正向概率]

squeeze(0)去掉 batch 维度后,返回的三元组就是每个类别的概率。实际部署时建议把clean_textVocabpredict封装进同一个类,避免漏掉任何一步清洗。源码项目的文档建议按"数据集统计 → 预处理规则 → 模型结构 → 参数记录 → 评估结果"五个小节写,参数记录里写明最终值和试过的失败值。文档里附一张混淆矩阵图,再写上"为什么在最终隐状态和 masked mean 之间选了其中一个",这份记录比任何调参代码都更能说明项目完成度。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/11 13:13:05

PCSX2模拟器上手:BIOS怎么配、渲染器怎么选、掉帧怎么排查

PCSX2模拟器上手&#xff1a;BIOS怎么配、渲染器怎么选、掉帧怎么排查 【免费下载链接】pcsx2 PCSX2 - The Playstation 2 Emulator 项目地址: https://gitcode.com/GitHub_Trending/pc/pcsx2 PCSX2是一款开源的PS2模拟器&#xff0c;在Windows、Linux和macOS上用软件模…

作者头像 李华
网站建设 2026/9/11 13:11:40

WPF数据可视化实战:高性能动态图表与仪表盘开发

1. WPF数据可视化项目概述在工业控制、物联网监控和业务分析系统中&#xff0c;数据可视化始终是核心需求。最近我完成了一个基于WPF的实时数据监控项目&#xff0c;主要实现了动态折线图和仪表盘两大核心组件。这个方案完美替代了传统WinForm图表控件&#xff0c;在医疗监护设…

作者头像 李华
网站建设 2026/9/11 13:09:31

如何用 Vosk 三步搞定离线语音识别:完整指南

如何用 Vosk 三步搞定离线语音识别&#xff1a;完整指南 【免费下载链接】vosk-api Offline speech recognition API for Android, iOS, Raspberry Pi and servers with Python, Java, C# and Node 项目地址: https://gitcode.com/GitHub_Trending/vo/vosk-api Vosk 是一…

作者头像 李华
网站建设 2026/9/11 13:08:40

车载蓝牙六大协议协同开发实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/11 13:04:11

Locust压测实战指南:从脚本编写到分布式压测的完整攻略

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华