简介:这份资源是Python基于LSTM三分类的文本情感分析完整项目,面向计算机相关专业正在做课程设计、期末大作业的学生,以及需要NLP项目实战练习的学习者。项目将文本情感划分为负面、中性、正面三类,采用LSTM网络建模,配套Word2vec词向量训练与模型保存文件,可直接运行并复现结果。压缩包共13个文件,约11.63MB,包含3个csv情感语料、3个py训练与测试脚本、2个ipynb交互式笔记,以及yml模型配置、h5权重文件、pkl词向量模型、txt依赖清单和md说明文档,覆盖数据处理、模型搭建、训练评估到预测的完整链路。目前已有307人学习下载,作者为chengxuyuanlaow。项目经导师指导并获99分评审认可,代码结构清晰、注释完整,读者可据此掌握LSTM三分类情感分析的实现思路,快速完成大作业或作为NLP入门实战参考。
1. 从一条差评说起:Python 基于 LSTM 三分类的文本情感分析到底在做什么
电商后台每天涌进几千条评论,运营最怕的不是差评本身,而是差评被淹没在中评里没人发现。二分类(好评/差评)在这里几乎没用——大量「还行」「凑合」「一般般」被强行归到某一类,误判率高得离谱。这就是三分类(正面/中性/负面)存在的理由:把模糊地带单独拎出来,让后续处理有据可依。这个项目要干的事,就是用 Python 搭一套 LSTM 神经网络,把中文文本自动分到这三类里,配套源码和文档说明,属于能直接跑通、能改、能写进简历的那种完整工程。适合谁?会一点 Python、听说过 LSTM 但没完整做过 NLP 项目的人;也适合已经跑过二分类、想搞清楚三分类标签怎么设计、数据怎么清洗、模型怎么调的人。下面按「数据怎么进 → 模型怎么搭 → 训练怎么稳 → 坑在哪」的顺序拆开讲。
2. 数据从哪来、标签怎么定:三分类的第一道分水岭
2.1 三分类标签体系的设计逻辑
二分类的标签是天然的:好评、差评。三分类多出来的「中性」不是凑数,它承担的是兜底和缓冲两个职责。兜底是指那些情感倾向不明显的句子——「东西收到了」「物流三天到」——强行分正负都会污染训练集;缓冲是指模型在边界样本上的置信度天然偏低,中性类给了它一个合理的出口,避免 softmax 被逼着在正负之间二选一。
标签定义我一般这么写:
| 标签 | 编码 | 判定标准 | 典型例句 |
|---|---|---|---|
| 正面 | 2 | 明确表达满意、推荐、超出预期 | 质量很好,下次还来 |
| 中性 | 1 | 陈述事实、无明显褒贬、褒贬抵消 | 包装一般,东西还行 |
| 负面 | 0 | 明确表达不满、失望、劝退 | 用了两天就坏了,别买 |
编码用 0/1/2 而不是 -1/0/1,是因为后面接CrossEntropyLoss时要求标签是 0 到 N-1 的整数,用 -1 会直接报 index 越界。这个细节文档里不写清楚,新手第一次跑必翻车。
标注环节有个血泪经验:中性类的标注一致性最难保证。同一个句子,A 标中性、B 标负面是常事。实操中我会让两个人独立标 200 条,算一下 Kappa 系数,低于 0.6 就说明标准没对齐,得回去重新定义边界,别急着标全量。
2.2 中文文本清洗与分词的可复现流程
中文和英文不一样,没有空格分词,清洗和切词是两件必须分开做的事。清洗负责去掉噪声,分词负责把句子切成模型能吃的 token 序列。
import re import jieba def clean_text(text): # 去掉 URL、@用户、话题标签、多余空白 text = re.sub(r'http[s]?://\S+', '', text) text = re.sub(r'@[\w\u4e00-\u9fa5]+', '', text) text = re.sub(r'#.*?#', '', text) text = re.sub(r'\s+', ' ', text) # 只保留中文、英文、数字和基本标点 text = re.sub(r'[^\u4e00-\u9fa5a-zA-Z0-9,。!?、;:]', '', text) return text.strip() def tokenize(text): # 精确模式分词,过滤单字和停用词 stopwords = set(['的', '了', '是', '在', '我', '有', '和', '就']) words = jieba.lcut(text) return [w for w in words if len(w) > 1 and w not in stopwords]clean_text里那几行正则的顺序不能乱:先去 URL 再去 @,否则 URL 里的特殊字符会干扰后面的匹配。tokenize里过滤单字是个取舍——中文单字携带的信息量低,但「不」「没」这种否定词恰恰是单字,全过滤掉会把「不好」变成「好」,情感直接反转。我的做法是维护一个否定词白名单,白名单里的单字保留。
分词之后要建词表。词表大小直接决定嵌入层参数量,中文情感分析任务里,词表控制在 8000 到 15000 之间比较稳。太小会丢词,太大会让嵌入矩阵稀疏、训练变慢。统计词频后按频次排序,取 top-N,剩下的统一映射到<UNK>。
from collections import Counter def build_vocab(tokenized_corpus, max_size=10000, min_freq=2): counter = Counter() for tokens in tokenized_corpus: counter.update(tokens) # 特殊 token 占 0/1/2 三个位置 vocab = {'<PAD>': 0, '<UNK>': 1, '<CLS>': 2} for word, freq in counter.most_common(max_size): if freq >= min_freq: vocab[word] = len(vocab) return vocabmin_freq=2的意思是只出现一次的词不进词表,直接归到<UNK>。这一步能砍掉大量噪声词,尤其是评论里的错别字和乱码。<PAD>必须占 0 号位,因为后面Embedding层的padding_idx=0要靠它来屏蔽填充位置的梯度。
3. LSTM 模型怎么搭:从嵌入层到三分类输出
3.1 嵌入层与 LSTM 层的参数含义
模型结构不复杂,但每个参数都有讲究。先看完整定义:
import torch import torch.nn as nn class LSTMClassifier(nn.Module): def __init__(self, vocab_size, embed_dim=128, hidden_dim=256, 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, bidirectional=True, dropout=dropout if num_layers > 1 else 0 ) self.dropout = nn.Dropout(dropout) self.fc = nn.Linear(hidden_dim * 2, num_classes) def forward(self, x): # x: (batch, seq_len) emb = self.embedding(x) # (batch, seq_len, embed_dim) out, (h, c) = self.lstm(emb) # out: (batch, seq_len, hidden*2) # 取最后一个时间步的正反向拼接 last = out[:, -1, :] last = self.dropout(last) logits = self.fc(last) # (batch, num_classes) return logitsembed_dim=128是中文情感分析的常用起点,词表一万左右时,128 维能表达足够的语义区分度,再大容易过拟合。hidden_dim=256配合双向 LSTM,实际每个方向的隐藏状态是 256 维,拼接后 512 维进全连接。num_layers=2是深度和训练难度的平衡点,单层欠拟合、三层以上梯度问题明显。bidirectional=True对情感分析几乎是必选项——情感词经常出现在句尾(「……但是很差」),单向 LSTM 读到句尾时前面的信息已经衰减,双向能同时利用前后文。
dropout=0.5加在 LSTM 层间和全连接前,是防过拟合的主力。注意num_layers=1时 LSTM 的 dropout 参数无效,代码里用条件表达式处理了,不然 PyTorch 会警告。
3.2 变长序列的 padding 与 pack_padded_sequence
评论长度从几个字到几百字不等,一个 batch 里必须对齐。最粗暴的做法是全部 pad 到固定长度,但这样会让 LSTM 在大量填充位上白算,还会污染最后时间步的隐藏状态。
from torch.nn.utils.rnn import pad_sequence, pack_padded_sequence, pad_packed_sequence def collate_fn(batch): # batch: list of (token_ids, label) texts, labels = zip(*batch) lengths = torch.tensor([len(t) for t in texts]) # pad 到本 batch 最大长度,而不是全局最大 padded = pad_sequence(texts, batch_first=True, padding_value=0) return padded, lengths, torch.tensor(labels) # 在 forward 里使用 def forward(self, x, lengths): emb = self.embedding(x) packed = pack_padded_sequence(emb, lengths.cpu(), batch_first=True, enforce_sorted=False) out, (h, c) = self.lstm(packed) out, _ = pad_packed_sequence(out, batch_first=True) # 用 lengths 索引真实最后一步,而不是 out[:, -1, :] idx = (lengths - 1).view(-1, 1).expand(out.size(0), out.size(2)) last = out.gather(1, idx.unsqueeze(1)).squeeze(1) return self.fc(self.dropout(last))这里有个高频翻车点:out[:, -1, :]取的是 padding 后的最后一位,对短句子来说那是填充位,隐藏状态没意义。必须用lengths去 gather 真实最后一步。enforce_sorted=False让pack_padded_sequence自己处理排序,省得手动排。lengths要放到 CPU 上,这是 PyTorch 的老规矩。
4. 训练、验证与调参:让三分类真正跑起来
4.1 训练循环与类别不平衡处理
三分类数据集里中性类往往偏少,直接训练模型会倾向于把边界样本判成正负。两个手段:加权损失和分层采样。
from torch.utils.data import DataLoader, WeightedRandomSampler from sklearn.utils.class_weight import compute_class_weight import numpy as np # 假设 labels 是全部训练标签的 numpy 数组 class_weights = compute_class_weight('balanced', classes=np.array([0,1,2]), y=labels) class_weights = torch.tensor(class_weights, dtype=torch.float) criterion = nn.CrossEntropyLoss(weight=class_weights) # 分层采样:让每个 batch 里三类比例接近 sample_weights = class_weights[labels] sampler = WeightedRandomSampler(sample_weights, num_samples=len(labels), replacement=True) loader = DataLoader(dataset, batch_size=64, sampler=sampler, collate_fn=collate_fn)compute_class_weight('balanced')按类别频率的倒数给权重,中性类少就权重高。WeightedRandomSampler在采样阶段就做平衡,比只在损失里加权更彻底。两者可以同时用,但要注意别矫枉过正——中性类被过度采样会导致模型把太多句子判成中性,精确率掉得厉害。我一般先用损失加权,看混淆矩阵再决定要不要上采样。
训练循环本身没什么玄学,关键是早停和梯度裁剪:
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) best_f1 = 0 patience, wait = 3, 0 for epoch in range(30): model.train() for padded, lengths, labels in train_loader: optimizer.zero_grad() logits = model(padded, lengths) loss = criterion(logits, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() # 验证集评估,按 macro-F1 早停 f1 = evaluate(model, val_loader) if f1 > best_f1: best_f1, wait = f1, 0 torch.save(model.state_dict(), 'best.pt') else: wait += 1 if wait >= patience: breakclip_grad_norm_的max_norm=5.0是 LSTM 的保命参数,梯度爆炸时它能把范数压回来。早停指标用 macro-F1 而不是准确率,因为三分类里中性类样本少,准确率会被多数类主导,macro-F1 对每个类一视同仁。
4.2 学习率与 batch size 的搭配经验
lr=1e-3配batch_size=64是 Adam 的常规组合,但 LSTM 对学习率比 CNN 敏感。如果 loss 在前几个 epoch 就震荡不降,先降到 5e-4 试试。batch size 不建议超过 128,中文评论序列长,显存吃紧,而且大 batch 会让梯度更新次数变少,LSTM 这种序列模型收敛更慢。
序列最大长度设 128 还是 256?统计一下训练集长度的 95 分位数,取那个值向上取整到 2 的幂。超过的部分截断,截断时保留头部——情感词在评论开头和结尾出现频率最高,中间往往是描述性内容。
5. 避坑与排查:三分类项目里最容易翻车的五件事
现象一:模型在验证集上准确率 90%,实际用起来一塌糊涂。原因:训练集和验证集来自同一批数据随机划分,同一条评论的不同片段可能同时出现在两边,造成数据泄漏。 解决:按用户 ID 或商品 ID 划分数据集,确保同一来源的评论只出现在一个集合里。如果数据没有 ID,至少按时间划分,用早期数据训练、后期数据验证。
现象二:loss 一直是 nan。原因:常见于学习率过大、序列里有超长样本、或者标签编码越界(比如出现了 3 但 num_classes=3)。 解决:先把学习率降到 1e-4 跑一遍;检查标签的唯一值集合是不是 {0,1,2};打印每个 batch 的最大序列长度,超过 512 的截断。
现象三:中性类召回率极低,几乎全被判成正或负。原因:中性类样本少,且边界模糊,模型学不到判别特征。 解决:先确认中性类样本量是否低于总体的 15%,如果是,用WeightedRandomSampler过采样;同时检查标注标准,把「褒贬抵消」和「纯陈述」分开,前者其实更接近中性,后者可以考虑单独处理。
现象四:训练时 GPU 利用率很低,一个 epoch 跑很久。原因:collate_fn里用了 Python 循环逐条处理,或者num_workers=0导致数据加载阻塞。 解决:DataLoader设num_workers=4、pin_memory=True;pad_sequence是向量化操作,别自己写循环 pad。
现象五:换了一批新数据,模型效果断崖式下跌。原因:新数据的用词分布和训练集差异大,词表覆盖不了,大量词变成<UNK>。 解决:统计新数据里<UNK>的比例,超过 10% 就要考虑增量训练或重新建词表。增量训练时冻结嵌入层,只调 LSTM 和全连接,避免小数据把预训练的词向量带偏。
6. 把模型用起来:推理封装与效果验证的实操技巧
训练完的模型不能只躺在best.pt里,得封装成能直接调用的推理接口。我习惯写一个类,把词表、模型结构、预处理串起来,外部只需要传原始文本。
class SentimentPredictor: def __init__(self, model_path, vocab, max_len=128): self.vocab = vocab self.max_len = max_len self.model = LSTMClassifier(vocab_size=len(vocab)) self.model.load_state_dict(torch.load(model_path, map_location='cpu')) self.model.eval() self.label_map = {0: '负面', 1: '中性', 2: '正面'} def _encode(self, text): tokens = tokenize(clean_text(text)) ids = [self.vocab.get(w, self.vocab['<UNK>']) for w in tokens] ids = ids[:self.max_len] length = len(ids) ids = ids + [0] * (self.max_len - length) return torch.tensor([ids]), torch.tensor([length]) def predict(self, text): x, length = self._encode(text) with torch.no_grad(): logits = self.model(x, length) probs = torch.softmax(logits, dim=-1)[0] pred = torch.argmax(probs).item() return { 'label': self.label_map[pred], 'confidence': round(probs[pred].item(), 4), 'probs': {self.label_map[i]: round(p, 4) for i, p in enumerate(probs.tolist())} }_encode里做了截断和补齐,保证推理时的输入格式和训练时一致。torch.no_grad()关掉梯度计算,单条推理能快好几倍。返回结果里带上三个类的概率,比只给一个标签有用得多——置信度低于 0.6 的样本可以打上「待人工复核」的标记,这是实际业务里最实用的一个设计。
验证模型有没有真的学到东西,别只看准确率。拿几十条自己构造的对抗样本测一下:
| 测试句 | 预期 | 说明 |
|---|---|---|
| 质量很好,但是物流太慢了 | 中性 | 褒贬抵消 |
| 不是不好用 | 正面 | 双重否定 |
| 还行吧,没什么特别的 | 中性 | 弱情感 |
| 用了三天就坏了,客服还不理人 | 负面 | 多重负面 |
如果「不是不好用」被判成负面,说明模型没学到否定词的组合语义,这时候要考虑在分词阶段保留否定词,或者换用预训练词向量。如果「质量很好,但是物流太慢了」被判成正面,说明模型被前半句主导,双向 LSTM 的注意力分配有问题,可以试试在 LSTM 输出上加一层注意力池化,而不是只取最后一步。
最后说个我自己的习惯:每次改完模型结构或超参,先跑一个 200 条的小数据集,确认 loss 能正常下降、推理接口能跑通,再上全量。全量训练一次动辄半小时,用小数据做冒烟测试能省下大量等待时间。这套三分类方案不算复杂,但每个环节都有细节能决定成败,把数据清洗、padding 处理、类别平衡这三件事做扎实,效果就不会差。希望帮到你。
本文还有配套的精品资源,点击获取