ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python基于LSTM三分类情感分析:从数据到部署完整实战

Python基于LSTM三分类情感分析:从数据到部署完整实战

简介:这份资源是Python基于LSTM三分类的文本情感分析完整项目,面向计算机相关专业正在做课程设计、期末大作业的学生,以及需要项目实战练习的学习者。项目将文本情感划分为三类,采用LSTM网络建模,配套文档说明,代码完整可运行,适合作为高分课程设计参考。压缩包共13个文件,约11.63MB,包含3个csv情感语料、3个py训练与测试脚本、2个ipynb实验笔记、1个pkl词向量模型、1个h5训练权重、1个yml配置、1个txt依赖清单及md说明文档,覆盖数据预处理、词向量加载、模型训练与评估全流程。目前已有307人学习下载。读者可据此掌握从语料读取、Word2vec特征构建到LSTM三分类建模的完整方案,理解训练脚本与测试脚本的分工,并借助配置文件和权重文件快速复现实验结果,为后续文本分类任务提供可迁移的工程模板。

1. 三分类情感分析到底在分什么:从二分类翻车说起

做过情感分析的人大多从二分类起步:好评、差评,一条阈值切下去就完事。真到业务里你会发现,用户评论里大量是「还行吧」「凑合能用」「不算差但也不推荐」这类中间态,硬塞进正负两极,模型准确率看着挺高,上线后运营却天天吐槽分错。三分类要解决的就是这个:把情感切成负面、中性、正面三档,让「中立」有地方待。这个 Python 基于 LSTM 三分类的文本情感分析项目,核心就是用 LSTM 神经网络对中文文本做三分类,配套源码和文档说明,适合想跑通一条完整 NLP 流水线的人——从数据清洗、分词、词向量到模型训练和推理。它不追求 SOTA,追求的是你能在自己机器上复现、能改、能看懂每一层在干什么。下面按「数据怎么备、模型怎么搭、坑在哪」一路讲透。

2. 数据准备与标签体系:三分类的样本从哪来

2.1 三分类标签怎么定义才不自相矛盾

二分类的标签是天然的,三分类的「中性」是人为划出来的,这一步没定义清楚,后面模型再强也白搭。常见做法是给每条样本打一个情感极性分,比如 -1、0、1,或者用 1~5 星映射:1~2 星归负面,3 星归中性,4~5 星归正面。我一般会先写死一套标注规则再动手,避免边标边改。

标签编码典型文本特征常见来源
负面0差、垃圾、失望、退货1~2 星评论、投诉工单
中性1还行、一般、凑合、无感3 星评论、客观描述
正面2好、推荐、满意、回购4~5 星评论、好评

注意中性类最难标,也最容易和另外两类混淆。实操里我会把「没有明显情感词、以陈述事实为主」的句子优先归中性,而不是靠感觉。如果数据源本身只有正负两档,可以拿「无情感倾向的客观句」补充中性样本,但别用机器硬造,否则中性类会学成一锅粥。

2.2 中文分词与停用词处理的最小流程

中文不像英文有空格,LSTM 吃的是词序列,分词质量直接决定上限。项目里一般用 jieba 做分词,配合一份停用词表过滤掉「的、了、是」这类高频无信息词。下面是一段可直接抄的预处理代码:

import jieba import re # 加载停用词表,一行一个词 def load_stopwords(path): with open(path, 'r', encoding='utf-8') as f: return set(line.strip() for line in f if line.strip()) stopwords = load_stopwords('stopwords.txt') def clean_text(text): # 去掉 URL、@、# 话题标签等噪声 text = re.sub(r'http\S+|@\w+|#\S+#', '', text) # 只保留中文、英文、数字 text = re.sub(r'[^\u4e00-\u9fa5a-zA-Z0-9]', ' ', text) return text.strip() def tokenize(text): text = clean_text(text) # 精确模式分词,过滤停用词和单字 words = [w for w in jieba.lcut(text) if w not in stopwords and len(w) > 1] return words

逻辑说明:clean_text先把 URL、@、话题标签这些在社交文本里高频但对情感判断无用的东西清掉,再用正则只留中英文数字,避免标点符号污染词表。tokenize用 jieba 精确模式切词,过滤停用词和长度为 1 的字——单字在中文里歧义太大,留着反而增加噪声。

参数说明:len(w) > 1这个阈值不是死的,如果你的领域里有「差」「赞」这种单字强情感词,可以放宽到>= 1,但要观察词表膨胀情况。停用词表建议用哈工大或百度那份通用表打底,再按自己数据补领域词。

2.3 构建词表与序列填充:padding 长度怎么定

LSTM 要求输入是定长序列,所以得先统计句子长度分布,再定max_len。我一般会画一下长度分布,取覆盖 95% 样本的那个长度,而不是拍脑袋定 100 或 200。

from collections import Counter import numpy as np # 统计所有句子分词后的长度 lengths = [len(tokenize(t)) for t in raw_texts] print('长度分布:', np.percentile(lengths, [50, 90, 95, 99])) # 取 95 分位作为 max_len max_len = int(np.percentile(lengths, 95)) # 构建词表:按词频排序,保留 top 10000 word_counter = Counter(w for t in raw_texts for w in tokenize(t)) vocab = {w: i + 2 for i, (w, _) in enumerate(word_counter.most_common(10000))} vocab['<PAD>'] = 0 # 填充符 vocab['<UNK>'] = 1 # 未登录词 def encode(text): words = tokenize(text) ids = [vocab.get(w, vocab['<UNK>']) for w in words] # 截断或填充到 max_len if len(ids) < max_len: ids += [vocab['<PAD>']] * (max_len - len(ids)) else: ids = ids[:max_len] return ids

逻辑说明:先统计长度分位数,用 95 分位定max_len,既覆盖绝大多数句子又不至于让 padding 占太多。词表用<PAD>占 0、<UNK>占 1,真实词从 2 开始编号,这是 PyTorch 里Embedding层的常见约定。encode负责把词转成 id 并统一长度。

参数说明:most_common(10000)里的 10000 是词表上限,中文情感任务一般 1 万到 3 万够用,太小会大量<UNK>,太大则 embedding 参数膨胀。max_len如果取 95 分位后还是很大(比如超过 300),要考虑是不是有超长噪声文本,先清洗再统计。

3. LSTM 三分类模型搭建:从 Embedding 到 Softmax

3.1 为什么选 LSTM 而不是 TextCNN 或 BERT

先说选型理由,免得你盲目跟。TextCNN 快、参数少,但它的卷积核感受野固定,对「虽然…但是…」这种长距离转折不敏感;BERT 效果好,但显存和推理成本高,小数据集上还容易过拟合。LSTM 的门控机制天然适合处理变长序列和长距离依赖,参数量适中,在几千到几万条的中文情感数据上是性价比很高的选择。这个项目用 LSTM 三分类,定位就是「单卡能跑、代码能看懂、效果够用」。

常见做法是单向 LSTM 取最后一个时间步的隐状态,接全连接层输出 3 类。如果数据量够,可以换双向 LSTM(BiLSTM),把正向和反向的最后隐状态拼接,效果通常有提升,代价是训练慢一倍。

3.2 PyTorch 版 LSTM 分类网络完整代码

import torch import torch.nn as nn class LSTMClassifier(nn.Module): def __init__(self, vocab_size, embed_dim=128, hidden_dim=128, num_layers=1, num_classes=3, dropout=0.3): super().__init__() # 词嵌入层,padding_idx=0 保证 PAD 不参与梯度 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, # 输入形状 (batch, seq, feature) bidirectional=False, dropout=dropout if num_layers > 1 else 0 ) self.dropout = nn.Dropout(dropout) self.fc = nn.Linear(hidden_dim, num_classes) def forward(self, x): # x: (batch, seq_len) emb = self.embedding(x) # (batch, seq, embed_dim) out, (h_n, c_n) = self.lstm(emb) # out: (batch, seq, hidden) # 取最后一个时间步的隐状态 last = out[:, -1, :] # (batch, hidden) last = self.dropout(last) logits = self.fc(last) # (batch, num_classes) return logits

逻辑说明:Embedding把词 id 映射成稠密向量,padding_idx=0让填充位不产生梯度,避免 PAD 影响训练。LSTM 设batch_first=True后输入输出都是(batch, seq, feature),取out[:, -1, :]就是最后一个时间步的输出,代表整句的语义摘要。最后接 Dropout 和全连接层输出 3 个 logits,交给 CrossEntropyLoss。

参数说明:embed_dim=128和hidden_dim=128是中小数据集的常用起点,数据量大可以加到 256。num_layers=1时 dropout 不生效(PyTorch 会警告),要堆两层以上才需要。dropout=0.3是防过拟合的常规值,如果训练集很小可以提到 0.5。

3.3 训练循环与关键超参设置

from torch.utils.data import DataLoader, TensorDataset from torch.optim import Adam import torch.nn as nn # 假设 X_train, y_train 已经 encode 好 X_train = torch.tensor([encode(t) for t in train_texts], dtype=torch.long) y_train = torch.tensor(train_labels, dtype=torch.long) dataset = TensorDataset(X_train, y_train) loader = DataLoader(dataset, batch_size=64, shuffle=True) device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = LSTMClassifier(vocab_size=len(vocab)).to(device) criterion = nn.CrossEntropyLoss() optimizer = Adam(model.parameters(), lr=1e-3) for epoch in range(10): model.train() total_loss = 0 for xb, yb in loader: xb, yb = xb.to(device), yb.to(device) optimizer.zero_grad() logits = model(xb) loss = criterion(logits, yb) loss.backward() # 梯度裁剪,防止 LSTM 梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() total_loss += loss.item() print(f'epoch {epoch+1}, loss {total_loss/len(loader):.4f}')

逻辑说明:TensorDataset把编码后的输入和标签打包,DataLoader负责分批和打乱。训练循环里标准五步:清零梯度、前向、算损失、反向、更新。clip_grad_norm_是 LSTM 训练的必备操作,梯度爆炸是 RNN 系模型的经典翻车点。

参数说明:batch_size=64是显存和收敛稳定性的折中,显存小就降到 32。lr=1e-3是 Adam 的常用学习率,如果 loss 震荡明显可以降到 5e-4。max_norm=5.0是梯度裁剪阈值,太小会拖慢收敛,太大等于没裁,5 是经验值。训练轮数别死守 10,要看验证集 loss,早停比硬跑更靠谱。

4. 避坑与排查:三分类项目最容易翻车的五个地方

4.1 类别不平衡导致模型只预测多数类

现象:训练准确率很高,但混淆矩阵里中性类几乎全被预测成正面或负面,中性类的召回率低得离谱。

原因:三分类数据里中性样本往往最少,CrossEntropyLoss 默认每类权重相同,模型为了降低总损失会偏向多数类。

解决:给损失函数加类别权重,权重和类别频率成反比。criterion = nn.CrossEntropyLoss(weight=torch.tensor([1.0, 2.5, 1.0]).to(device)),中性类权重调高。或者用重采样让三类样本数量接近。改完记得重新看混淆矩阵,别只看准确率。

4.2 分词把情感词切碎了

现象:模型对「不推荐」和「推荐」分不出来,负面样本被大量判成正面。

原因:jieba 默认会把「不推荐」切成「不」和「推荐」,而「不」如果进了停用词表就被过滤掉,情感直接反转。

解决:把否定词从停用词表里拿掉,或者用 jieba 的add_word把「不推荐」「不好用」这类组合词加进词典。更稳的做法是保留否定词,让 LSTM 自己学「不」和后面词的依赖关系。

4.3 padding 位置参与了注意力或池化

现象:换成 BiLSTM 加平均池化后,短句效果明显变差。

原因:平均池化把 PAD 位置的输出也算进去了,短句 padding 多,被稀释得厉害。

解决:池化前用 mask 把 PAD 位置屏蔽掉,或者干脆用最后时间步而不是平均池化。如果一定要池化,按序列真实长度做 masked mean。

4.4 验证集和测试集分布不一致

现象:验证集准确率 0.9,上线后掉到 0.6。

原因:划分数据时没打乱,或者验证集来自不同时间段、不同来源,分布和训练集差太多。

解决:划分前先 shuffle,再按 8:1:1 切。如果数据有时间属性,按时间切比随机切更接近真实场景。切完对比一下三类的比例,差异大就重新切。

4.5 推理时忘了切 eval 模式

现象:同一句话多次预测结果不一样,或者推理结果比验证时差。

原因:模型还在 train 模式,Dropout 还在随机丢弃神经元。

解决:推理前调model.eval(),并用with torch.no_grad():包住前向,既关掉 Dropout 又省显存。这是新手最常踩的坑,代码里加一行就能避免。

5. 推理部署与效果验证:把模型用起来

5.1 单条文本推理的封装

训练完的模型要能对外提供服务,封装一个预测函数是最小落地单元。

def predict(text, model, vocab, max_len, device): model.eval() ids = encode(text) # 复用训练时的 encode x = torch.tensor([ids], dtype=torch.long).to(device) with torch.no_grad(): logits = model(x) prob = torch.softmax(logits, dim=1) pred = torch.argmax(prob, dim=1).item() label_map = {0: '负面', 1: '中性', 2: '正面'} return label_map[pred], prob[0][pred].item()

逻辑说明:model.eval()关掉 Dropout,torch.no_grad()关闭梯度计算。softmax把 logits 转成概率,argmax取最大概率的类别。返回标签和置信度,置信度低于阈值时可以转人工复核。

参数说明:encode必须和训练时完全一致,包括分词、词表映射、max_len,任何一处不一致都会导致推理结果漂移。置信度阈值一般设 0.6~0.7,低于这个值说明模型没把握。

5.2 用混淆矩阵和分类报告验证三分类效果

准确率在三分类里会骗人,必须看每一类的表现。

from sklearn.metrics import classification_report, confusion_matrix model.eval() all_preds, all_labels = [], [] with torch.no_grad(): for xb, yb in test_loader: xb = xb.to(device) preds = torch.argmax(model(xb), dim=1).cpu().numpy() all_preds.extend(preds) all_labels.extend(yb.numpy()) print(confusion_matrix(all_labels, all_preds)) print(classification_report(all_labels, all_preds, target_names=['负面', '中性', '正面']))

逻辑说明:遍历测试集收集所有预测和真实标签,用 sklearn 输出混淆矩阵和分类报告。混淆矩阵看错分方向,分类报告看每类的 precision、recall、f1。

参数说明:重点盯中性类的 f1,它通常最低。如果中性类 f1 低于 0.5,回去查标签定义和类别权重。三分类的 macro f1 比 accuracy 更能反映真实水平。

5.3 一个我常用来压测的小技巧

模型训完别急着高兴,拿一批「对抗样本」试试:把正面句子里的情感词换成同义但更弱的词,看模型会不会掉到中性;把中性句子加个「太」字,看会不会跳到正面。这种边界测试能快速暴露模型是真学到了语义还是只记住了关键词。我一般会手工构造 20 条边界样本,每次改完模型都跑一遍,比看验证集 loss 直观得多。

说到底,三分类情感分析这个方向值不值得做,取决于你的业务里「中性」是不是真的需要单独处理。如果中间态占比超过两成,硬做二分类就是自欺欺人,那这套 LSTM 三分类的源码和流程就值得你花一个周末跑通。我自己的习惯是先把数据标签定义写死在文档里,再动模型代码,否则后面调参调到怀疑人生都不知道问题出在哪。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进