简介: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_dim | 128 | 词表超过 5 万时升到 200 或 300 |
| hidden_dim | 128 | 欠拟合时升到 256;显存紧张先降这个 |
| num_layers | 2 | 数据量过万才建议上 3 层,否则容易过拟合 |
| dropout | 0.5 | 验证集波动大时降到 0.3,过拟合时升到 0.6 |
| batch_size | 64 | 文本平均长度超过 128 时降到 32 |
| max_len | 64 | 长评论场景升到 128,同时确认内存 |
| learning_rate | 1e-3 | Adam 下 loss 不降换 5e-4,不要直接上 1e-2 |
| patience | 5 | 数据噪声大时放宽到 8 |
参数调整每次只动一个维度。同时改学习率和 dropout,实验记录里就分不清是哪个起了作用。把每次的实验结果记成表格,这也是文档说明里最有分量的部分。
4.4 损失不下降时的对照检查顺序
先确认数据管道:打印一个 batch 的x_pad和lengths,检查输入是否全为 0,label 是否都在 0/1/2 范围内。CrossEntropyLoss接收的是类别索引而不是 one-hot,传错形状会直接报维度错误。再确认清洗的一致性:训练用了clean_text,验证集如果直接喂原始文本,词表匹配率会很低。然后看学习率:1e-3 对 Adam 是安全起点,loss 反复横跳就降到 5e-4,下降太慢则升到 3e-3。最后看lengths:pack_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_text、vocab.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_text、Vocab、predict封装进同一个类,避免漏掉任何一步清洗。源码项目的文档建议按"数据集统计 → 预处理规则 → 模型结构 → 参数记录 → 评估结果"五个小节写,参数记录里写明最终值和试过的失败值。文档里附一张混淆矩阵图,再写上"为什么在最终隐状态和 masked mean 之间选了其中一个",这份记录比任何调参代码都更能说明项目完成度。
本文还有配套的精品资源,点击获取