资讯详情

资讯详情

基于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, encodingutf-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, encodingutf-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_count2): 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_len64): ids [self.word2idx.get(w, 1) for w in sent.split()][:max_len] return torch.tensor(ids, dtypetorch.long) vocab Vocab(min_count2) vocab.build(df[clean]) print(词表大小:, len(vocab.word2idx)) seqs [vocab.encode(s) for s in df[clean]] x_pad pad_sequence(seqs, batch_firstTrue, padding_value0) lengths torch.tensor([len(seq) for seq in seqs])pad_sequence会把序列按 batch 内最长长度对齐padding_value0对应词表的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_dim128, hidden_dim128, num_layers2, num_classes3, dropout0.5): super().__init__() self.embedding nn.Embedding( vocab_size, embed_dim, padding_idx0) self.lstm nn.LSTM( input_sizeembed_dim, hidden_sizehidden_dim, num_layersnum_layers, batch_firstTrue, dropoutdropout if num_layers 1 else 0.0, bidirectionalTrue, ) 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_firstTrue, enforce_sortedFalse) _, (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], dim1) logits self.fc(self.dropout(h)) return logits关键参数说明padding_idx0让pad位置的词向量恒为零且不参与梯度更新这是最容易漏掉的参数。漏掉它padding 位置会学到随机向量干扰 LSTM 状态。bidirectionalTrue输出维度翻倍为hidden_dim * 2所以全连接输入是hidden_dim * 2而不是hidden_dim两个方向拼接后语义更完整。dropout0.5作用于 LSTM 层之间和全连接前。数据量只有几千条时建议提到 0.6 防过拟合验证集 loss 震荡时先降回 0.3。enforce_sortedFalse允许 batch 内序列不按长度排序省去每次迭代前手动排序代价是 pack 时内部多做一次排序数据量大时可改回 True 并预排序。lengths.cpu()pack_padded_sequence要求长度张量在 CPU 上模型搬 GPU 后这行不能省。3.3 最后的输出层logits、softmax 与池化策略选择fc输出的是未归一化的 logits形状(batch, 3)。训练时配合CrossEntropyLoss损失函数内部会做 softmax不要在模型里提前加否则数值稳定性和梯度都会受影响。预测时才用torch.softmax(logits, dim1)取概率或直接argmax取类别。取隐状态有两种常见策略。上面用的是拼接最后一层两个方向的最终隐状态适合短文本。另一种策略是对 LSTM 的输出做时间维度的平均池化output, _ self.lstm(packed) # 需要先 pad_packed_sequence 还原 output, _ nn.utils.rnn.pad_packed_sequence( output, batch_firstTrue) # 按真实长度做 masked mean而不是直接 mean mask (x ! 0).unsqueeze(-1).float() h (output * mask).sum(dim1) / mask.sum(dim1)平均池化对情感词分散在句子各处的样本更稳最终隐状态则更强调句尾信息。三分类任务里我一般先试拼接最终隐状态如果验证集准确率上不去再换成 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, encodingutf-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_idx0, freezeFalse)注意freezeFalse让预训练向量继续参与微调一般微调比冻结多 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, classesnp.array([0, 1, 2]), yy) class_weight torch.tensor(class_weight, dtypetorch.float) print(类别权重:, class_weight) criterion nn.CrossEntropyLoss(weightclass_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, dtypetorch.long), lengths) n_val int(len(dataset) * 0.2) train_ds, val_ds random_split( dataset, [len(dataset) - n_val, n_val], generatortorch.Generator().manual_seed(42)) train_loader DataLoader(train_ds, batch_size64, shuffleTrue) val_loader DataLoader(val_ds, batch_size128) model LSTMSentimentModel(vocab_sizelen(vocab.word2idx)) optimizer optim.Adam(model.parameters(), lr1e-3) scheduler optim.lr_scheduler.StepLR(optimizer, step_size5, gamma0.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(dim1) 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(fearly stop at epoch {epoch}) break scheduler.step() print(fepoch {epoch} loss{total_loss / len(train_loader):.4f} fval_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 或 300hidden_dim128欠拟合时升到 256显存紧张先降这个num_layers2数据量过万才建议上 3 层否则容易过拟合dropout0.5验证集波动大时降到 0.3过拟合时升到 0.6batch_size64文本平均长度超过 128 时降到 32max_len64长评论场景升到 128同时确认内存learning_rate1e-3Adam 下 loss 不降换 5e-4不要直接上 1e-2patience5数据噪声大时放宽到 8参数调整每次只动一个维度。同时改学习率和 dropout实验记录里就分不清是哪个起了作用。把每次的实验结果记成表格这也是文档说明里最有分量的部分。4.4 损失不下降时的对照检查顺序先确认数据管道打印一个 batch 的x_pad和lengths检查输入是否全为 0label 是否都在 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(dim1).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, dim1).squeeze(0) return prob.numpy() # [负向概率, 中性概率, 正向概率]squeeze(0)去掉 batch 维度后返回的三元组就是每个类别的概率。实际部署时建议把clean_text、Vocab、predict封装进同一个类避免漏掉任何一步清洗。源码项目的文档建议按数据集统计 → 预处理规则 → 模型结构 → 参数记录 → 评估结果五个小节写参数记录里写明最终值和试过的失败值。文档里附一张混淆矩阵图再写上为什么在最终隐状态和 masked mean 之间选了其中一个这份记录比任何调参代码都更能说明项目完成度。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →