简介:这份资源面向需要完成文本情感分析课程大作业或入门深度学习的开发者,提供了一套基于LSTM实现正面、中性、负面三分类的完整Python源码与说明文档。压缩包共15个文件,约11.79MB,包含3个py脚本与2个ipynb笔记本用于模型训练和测试,3个csv分别存放正、中、负样本数据,另有h5模型权重、pkl词向量文件、yml环境配置、md说明文档及jpg效果图,覆盖从数据到部署的完整链路。资源围绕数据预处理、词嵌入、LSTM建模、特征提取与softmax分类预测展开,代码结构清晰,便于对照理解长短期记忆网络在情感分析中的实际应用。目前已有154人学习下载,适合希望快速复现三分类情感分析流程、参考高分大作业实现思路的读者,也可作为深度学习文本分类的练手项目。
1. 三分类情感分析:从二分类的“够用”到业务真正需要的“细粒度”
做过情感分析的人大多从二分类起步:正面、负面,一个 sigmoid 输出就收工。但真到了业务里,二分类往往不够用。电商评论里“东西还行,就是物流太慢”,二分类模型会直接判成负面,可这条评论对商品本身其实是认可的;舆情监控里“政策方向没问题,执行层面有待观察”,既不是纯正面也不是纯负面。这时候就需要三分类:正面、中性、负面。Python 实现基于 LSTM 的三分类文本情感分析,核心要解决的就是把情感粒度从“好/坏”细化到“好/中/坏”,让模型能识别出那些态度模糊、情绪混合的样本。这套方案适合做课程大作业的学生,也适合需要快速搭一个可解释、可复现情感分类基线的工程师。LSTM 在短文本上的优势在于它能捕捉词序信息,比如“不”和“好”之间的依赖关系,这是词袋模型做不到的。下面从数据准备一路讲到调参和避坑,每一步都给出可运行的代码和参数说明。
2. 数据准备与标签体系:三分类的标注逻辑和预处理流水线
2.1 三分类标签怎么定:中性类的判定边界
三分类最大的坑不在模型,在标签定义。二分类时“正面 vs 负面”边界清晰,三分类多了一个中性类,问题就来了:什么算中性?我一般用三条规则来界定。第一条,情感词强度低于阈值且无明确褒贬动词的,归中性,比如“今天收到了货”“包装是纸盒”。第二条,正负情感词同时出现且数量接近的,归中性,比如“质量不错但价格偏高”。第三条,纯事实陈述、疑问句、祈使句,归中性,比如“请问怎么退货”“请尽快发货”。这三条规则要写成标注手册,让所有标注人员对齐,否则中性类会变成垃圾桶,模型学到的就是噪声。
实际操作中,我建议先用规则打一版弱标注,再人工抽检修正。弱标注的规则可以用情感词典加否定词窗口来实现。下面这段代码展示了一个基于词典的弱标注函数,用来给无标签数据打上初始标签,后续再人工校验。
import jieba import re # 基础情感词典,实际项目中应替换为更完整的版本 POS_WORDS = set(['好', '不错', '满意', '喜欢', '推荐', '棒', '优秀', '舒服', '划算', '惊喜']) NEG_WORDS = set(['差', '烂', '失望', '垃圾', '慢', '贵', '丑', '退货', '坑', '后悔']) NEGATION = set(['不', '没', '无', '非', '别', '莫']) def weak_label(text): """ 基于词典和否定词窗口的弱标注函数 返回: 0-负面, 1-中性, 2-正面 """ words = list(jieba.cut(text)) pos_score, neg_score = 0, 0 for i, w in enumerate(words): # 检查前两个词是否有否定词 negated = any(words[j] in NEGATION for j in range(max(0, i-2), i)) if w in POS_WORDS: if negated: neg_score += 1 else: pos_score += 1 elif w in NEG_WORDS: if negated: pos_score += 1 else: neg_score += 1 # 正负得分接近且都较低时判为中性 if abs(pos_score - neg_score) <= 1 and pos_score + neg_score <= 2: return 1 return 2 if pos_score > neg_score else 0这段代码的逻辑是:先分词,然后遍历每个词,检查它前面两个词范围内有没有否定词。如果有否定词,情感极性翻转。最后根据正负得分差值和总分判断类别。参数方面,否定词窗口设为2是经验值,中文里“不太满意”的“不”和“满意”之间隔了一个“太”,窗口为2能覆盖大部分情况。得分差值阈值设为1,意味着正负得分差在1以内且总分不超过2时判中性,这个阈值可以根据标注数据分布调整。注意,弱标注只用于冷启动,不能直接当训练标签,必须人工抽检修正至少20%的样本。
2.2 文本清洗与序列截断:LSTM 输入张量的构造细节
LSTM 要求输入是固定长度的序列,所以预处理的核心是把变长文本转成定长索引序列。清洗步骤我一般按这个顺序来:去 HTML 标签、去 URL、去 @用户、去多余空白、全角转半角。中文还需要考虑是否保留标点,我的经验是保留逗号、句号、问号、感叹号,因为它们携带情感停顿信息,但去掉连续重复的标点,比如“!!!”压缩成“!”。
分词用 jieba,然后构建词表。词表大小建议控制在 10000 到 30000 之间,太小会导致大量 OOV,太大会增加嵌入层参数量。低频词统一映射到<UNK>。序列长度取 95 分位数,比如你的数据里 95% 的文本在 80 个词以内,那就设 max_len=80,超过的截断,不足的补零。补零用 pre-padding 还是 post-padding?LSTM 对末尾的零不敏感,但如果你用双向 LSTM,建议用 pre-padding,让有效词靠近输出端。下面是对应的预处理代码。
from collections import Counter import numpy as np def build_vocab(texts, max_vocab=20000, min_freq=2): """构建词表,返回词到索引的映射""" counter = Counter() for text in texts: counter.update(jieba.cut(text)) # 按频率排序,保留高频词 vocab = {'<PAD>': 0, '<UNK>': 1} for word, freq in counter.most_common(max_vocab): if freq >= min_freq: vocab[word] = len(vocab) return vocab def text_to_ids(text, vocab, max_len=80): """将文本转为固定长度的索引序列""" words = jieba.cut(text) ids = [vocab.get(w, vocab['<UNK>']) for w in words] if len(ids) > max_len: ids = ids[:max_len] # 截断 else: ids = ids + [vocab['<PAD>']] * (max_len - len(ids)) # 补零 return idsbuild_vocab里max_vocab=20000和min_freq=2是两个关键参数。min_freq=2表示只出现一次的词不进词表,直接归为 UNK,这能有效降低词表噪声。text_to_ids里max_len=80需要根据你的数据分布来定,可以用np.percentile([len(list(jieba.cut(t))) for t in texts], 95)算一下。补零放在末尾是 post-padding,如果你后面用双向 LSTM,建议改成[vocab['<PAD>']] * (max_len - len(ids)) + ids,把零放在前面。
注意:词表必须只在训练集上构建,验证集和测试集用同一个词表,否则会造成信息泄露,验证指标虚高。
3. LSTM 三分类模型搭建:嵌入层、双向 LSTM 与分类头的参数配置
3.1 模型结构选型:为什么用双向 LSTM 加注意力池化
三分类和 LSTM 结合时,模型结构有几个常见选择。最朴素的是 Embedding + LSTM + 最后一个时间步输出 + 全连接。这个结构在短文本上够用,但有个问题:最后一个时间步未必能代表整个句子的情感,尤其是长句。我一般用双向 LSTM,把前向和后向的最后隐状态拼接,这样每个词都能看到上下文。更进一步,加一个注意力池化层,让模型自己学习哪些词对情感判断更重要。比如“质量不错但价格偏高”,注意力应该更多落在“不错”和“偏高”上,而不是“但”。
PyTorch 实现如下。嵌入层维度设 128 或 256,LSTM 隐层维度设 128,双向则输出 256 维。Dropout 设 0.3 到 0.5 之间,防止过拟合。分类头是两层全连接,中间加 ReLU 和 Dropout,输出 3 维 logits。
import torch import torch.nn as nn class LSTMClassifier(nn.Module): def __init__(self, vocab_size, embed_dim=128, hidden_dim=128, num_classes=3, dropout=0.4): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.lstm = nn.LSTM(embed_dim, hidden_dim, batch_first=True, bidirectional=True) self.attention = nn.Linear(hidden_dim * 2, 1) self.fc = nn.Sequential( nn.Linear(hidden_dim * 2, 64), nn.ReLU(), nn.Dropout(dropout), nn.Linear(64, num_classes) ) self.dropout = nn.Dropout(dropout) def forward(self, x): # x: (batch, seq_len) mask = (x != 0).float() # 忽略 padding 位置 emb = self.dropout(self.embedding(x)) # (batch, seq_len, embed_dim) lstm_out, _ = self.lstm(emb) # (batch, seq_len, hidden_dim*2) # 注意力池化 attn_score = self.attention(lstm_out).squeeze(-1) # (batch, seq_len) attn_score = attn_score.masked_fill(mask == 0, -1e9) attn_weight = torch.softmax(attn_score, dim=1) # (batch, seq_len) context = torch.bmm(attn_weight.unsqueeze(1), lstm_out).squeeze(1) return self.fc(context)padding_idx=0让嵌入层对补零位置不更新梯度。bidirectional=True使 LSTM 输出维度翻倍。注意力部分用masked_fill把 padding 位置的注意力分数设为负无穷,softmax 后权重为 0,避免 padding 影响池化结果。dropout=0.4是经验值,数据量小于 1 万条时可以调到 0.5,大于 5 万条时可以降到 0.3。
3.2 训练循环与类别权重:三分类不均衡的处理
三分类数据往往不均衡,中性类可能只占 10% 到 20%。如果不处理,模型会倾向于预测多数类,中性类的召回率极低。解决办法是在损失函数里加类别权重,权重和类别频率成反比。nn.CrossEntropyLoss的weight参数接受一个张量,长度等于类别数。
from sklearn.utils.class_weight import compute_class_weight import numpy as np # 假设 train_labels 是训练集标签列表 class_weights = compute_class_weight('balanced', classes=np.array([0, 1, 2]), y=train_labels) class_weights = torch.tensor(class_weights, dtype=torch.float) criterion = nn.CrossEntropyLoss(weight=class_weights) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) # 训练循环核心 for epoch in range(10): model.train() for batch_x, batch_y in train_loader: optimizer.zero_grad() logits = model(batch_x) loss = criterion(logits, batch_y) loss.backward() # 梯度裁剪,防止 LSTM 梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step()compute_class_weight('balanced')会自动计算权重为n_samples / (n_classes * class_count)。梯度裁剪max_norm=5.0是 LSTM 训练的标配,因为 LSTM 的反向传播容易梯度爆炸。学习率用 1e-3 配合 Adam 是安全起点,如果 loss 震荡明显,降到 5e-4。训练轮数 10 轮是参考值,实际要看验证集 loss 是否还在下降,通常 5 到 15 轮之间会收敛。
注意:验证集和测试集不能用类别权重,评估指标要用 macro-F1,因为 micro-F1 会被多数类主导,掩盖中性类的表现。
4. 训练过程排查与调参:loss 不降、中性类召回低、过拟合的应对
4.1 loss 不下降或震荡:从学习率到数据泄露逐项排查
训练时 loss 不降,先查三件事。第一,学习率是不是太大。Adam 默认 1e-3,但 LSTM 加注意力后参数量增加,1e-3 可能导致震荡,试试 5e-4 或 1e-4。第二,标签有没有错位。三分类标签必须是 0、1、2,不能是 1、2、3,否则 CrossEntropyLoss 会报错或静默出错。第三,数据有没有泄露。词表如果在全量数据上构建,验证集 loss 会异常低,但测试集崩掉。排查方法是打印训练集和验证集的 loss 曲线,如果训练 loss 降但验证 loss 升,就是过拟合;如果两个都不降,就是欠拟合或学习率问题。
还有一个隐蔽的坑:padding 位置参与了 loss 计算。如果标签是序列级别的,CrossEntropyLoss 只取最后一个时间步或池化后的输出,padding 不影响。但如果你不小心把 padding 位置的输出也拿来算 loss,梯度会被零向量主导,loss 卡住不动。检查方法是打印模型输出的 shape,确认是(batch, num_classes)而不是(batch, seq_len, num_classes)。
4.2 中性类召回率低:阈值调整与后处理策略
中性类召回低是三分类的典型问题。模型倾向于把中性样本判成正面或负面,因为中性类的特征不够鲜明。除了类别权重,还可以用阈值调整。模型输出 logits 后,先 softmax 得到概率,然后对中性类的概率加一个偏置,比如prob[:, 1] += 0.1,再取 argmax。这个偏置值在验证集上调,通常 0.05 到 0.2 之间。
另一个策略是后处理规则。如果模型预测为正面或负面,但概率低于 0.6,且文本中同时出现正负情感词,就改判为中性。这个规则能捞回一部分边界样本。代码实现如下。
def predict_with_threshold(model, dataloader, neutral_bias=0.1): model.eval() all_preds = [] with torch.no_grad(): for batch_x, _ in dataloader: logits = model(batch_x) probs = torch.softmax(logits, dim=1) probs[:, 1] += neutral_bias # 中性类偏置 preds = torch.argmax(probs, dim=1) all_preds.extend(preds.cpu().numpy()) return all_predsneutral_bias=0.1是起始值,在验证集上以 0.05 为步长搜索,选 macro-F1 最高的。注意偏置不能太大,否则中性类召回上去了,但精确率会掉,整体 F1 可能反而下降。
4.3 过拟合与欠拟合:Dropout、早停与数据增强的取舍
过拟合的表现是训练 loss 持续降,验证 loss 先降后升。对策有三个:增大 Dropout、加 L2 正则、早停。Dropout 从 0.4 提到 0.5 或 0.6,L2 正则用optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4)。早停是验证 loss 连续 3 轮不降就停,保存验证 loss 最低的模型。
欠拟合则是训练 loss 和验证 loss 都高。这时候先检查模型容量,隐层维度从 128 提到 256,或者加一层 LSTM。但更常见的原因是数据太少或词表太小。数据少于 5000 条时,LSTM 容易欠拟合,可以考虑用预训练词向量初始化嵌入层,比如用中文 Word2Vec 或 GloVe 的中文版本。加载预训练词向量的代码逻辑是:构建词表后,对每个词查预训练向量,查不到的就随机初始化。
def load_pretrained_embeddings(vocab, pretrained_path, embed_dim=128): """加载预训练词向量,返回嵌入矩阵""" embeddings = np.random.normal(0, 0.1, (len(vocab), embed_dim)) embeddings[0] = 0 # PAD 位置为零向量 with open(pretrained_path, 'r', encoding='utf-8') as f: for line in f: parts = line.strip().split() word = parts[0] if word in vocab and len(parts) == embed_dim + 1: embeddings[vocab[word]] = np.array(parts[1:], dtype=np.float32) return torch.tensor(embeddings, dtype=torch.float)加载后把嵌入层的权重设为这个矩阵,并冻结嵌入层的前几轮训练,让 LSTM 先适应。embed_dim必须和预训练词向量维度一致,常见的是 128 或 300。冻结用self.embedding.weight.requires_grad = False,训练几轮后再解冻。
5. 避坑清单:三分类 LSTM 落地时最容易翻车的五个地方
现象一:验证集准确率 95%,测试集只有 60%。原因:词表在全量数据上构建,或者预处理时用了测试集的统计量。解决:词表、标准化参数、序列长度都只在训练集上算,验证集和测试集用训练集的参数。
现象二:中性类样本几乎全被预测成正面。原因:类别权重没加,或者中性类定义太模糊,标注一致性差。解决:加类别权重,重新检查标注手册,对中性类做人工复核,确保标注边界清晰。
现象三:训练 loss 降到 0.1 以下,但验证 loss 从第 3 轮开始上升。原因:模型参数量远大于数据量,过拟合。解决:增大 Dropout 到 0.5 以上,加 weight_decay=1e-4,早停,或者减少 LSTM 层数。
现象四:GPU 显存溢出,batch_size 只能设 8。原因:序列长度设太大,或者词表太大导致嵌入层参数量爆炸。解决:max_len 从 95 分位数降到 90 分位数,词表从 30000 降到 20000,或者用梯度累积模拟大 batch。
现象五:模型预测结果全是同一个类。原因:学习率太大导致模型坍缩,或者标签全是同一个类。解决:学习率降到 1e-4,检查标签分布,如果某一类占比超过 90%,先做重采样或调整类别权重。
6. 进阶技巧:用混淆矩阵定位问题类别与模型融合提点
训练完模型,别只看准确率。三分类的准确率有欺骗性,中性类少的时候,全预测正面也能有 70% 准确率。我习惯先画混淆矩阵,看每一类的召回和精确率。用 sklearn 的confusion_matrix和classification_report,重点看中性类的 F1。如果中性类 F1 低于 0.5,说明模型根本没学到中性特征,得回去检查标注质量。
from sklearn.metrics import confusion_matrix, classification_report # y_true 和 y_pred 是真实标签和预测标签 print(confusion_matrix(y_true, y_pred)) print(classification_report(y_true, y_pred, target_names=['负面', '中性', '正面']))混淆矩阵能告诉你中性样本被错分成了什么。如果大量中性被分成正面,说明模型把“还行”“一般”这类词学成了正面信号,需要在训练数据里增加这类样本的权重,或者在后处理里加规则。
模型融合是另一个提点手段。LSTM 加注意力的输出可以和 TextCNN 或 BERT 微调的结果做加权平均。权重用验证集上的 macro-F1 来搜。我一般用简单的网格搜索,步长 0.1,找最优组合。融合后通常能提 1 到 3 个点,但代价是推理时间翻倍,看业务能不能接受。
最后说一个我踩过的坑:不要用测试集调参。我见过有人用测试集选中性偏置,结果测试集 F1 虚高,上线后掉得厉害。验证集和测试集必须严格分开,验证集调参,测试集只跑一次。这个习惯能帮你省下很多后悔药。希望帮到你。
本文还有配套的精品资源,点击获取