简介:基于LSTM的文本情感分析实战项目,配套完整Python源代码与说明文档,主要面向深度学习入门者及NLP爱好者,帮助理解循环神经网络在文本分类中的应用。压缩包共4个文件,含Python脚本(.py)、Markdown说明(.md)和结果示意图片(.png),整体仅83KB,虽小但结构清晰,便于快速部署。已有212人学习,适合结合GPU环境运行情感分类任务。代码基于PyTorch实现LSTM模型,包含数据预处理、模型训练与预测流程,README中提供运行说明,另附百度网盘数据集下载链接,读者可完整复现实验并在此基础上扩展调参或迁移到其他文本分类场景。
1. 用LSTM做文本情感分析,为什么至今仍值得一练
一提到文本情感分析,很多人第一反应是“直接上BERT不就行了”。但当你想搭一条能快速迭代、能看透每个权重的基线模型,LSTM仍是性价比最高的选择——它没有Transformer那种动辄几千万参数的黑匣子,也没有预训练模型的下载和微调成本。这个Pytorch实战项目要做的事情很具体:把一段文本输入LSTM,输出它属于正面还是负面,全程用GPU加速训练。适合刚学完PyTorch基础、想走一遍“数据处理→建模→训练→预测”完整闭环的人,也适合给后续上注意力机制、上BERT做效果对照的基线版本。
我实际做完这个项目最大的体会是:LSTM的情感分析不难,难的是把数据切分、长度对齐、GPU显存分配这些“边上”的活干干净。这篇文章从代码层面拆开讲,参数怎么定、坑在哪、训练时看什么指标,都按我踩过的路走一遍。
2. 搭建LSTM情感分析模型:从词表到输出的三层结构
在写模型之前,必须先搞清楚输入和输出长什么样。情感分析通常是一个二分类问题,但也有三分类(正面/中性/负面)。本文按二分类讲,代码改一行就能扩成多分类。模型的骨架分三层:Embedding层把词索引映射成稠密向量,LSTM层负责捕捉时序依赖,最后的全连接层把最后一个时间步的隐状态压成类别得分。
2.1 数据预处理:把一段文本变成固定长度的张量
PyTorch的LSTM接收的是形状为(seq_len, batch_size, embedding_size)的输入,新手最容易在这里迷路。所以预处理的核心就两件事:建立词表、把句子变成等长的索引序列。
import torch from torch.utils.data import Dataset, DataLoader from collections import Counter import re def build_vocab(sentences, min_freq=2): """统计词频,构建词表。min_freq过滤掉只出现一次的词,避免过拟合噪声。""" counter = Counter() for sent in sentences: # 简单按空格分词。中文可先做jieba分词,英文直接split即可 tokens = re.findall(r'\b\w+\b', sent.lower()) counter.update(tokens) vocab = {'<pad>': 0, '<unk>': 1} for word, freq in counter.items(): if freq >= min_freq: vocab[word] = len(vocab) return vocab class SentimentDataset(Dataset): def __init__(self, sentences, labels, vocab, max_len=64): self.data = [] for sent, label in zip(sentences, labels): tokens = re.findall(r'\b\w+\b', sent.lower()) ids = [vocab.get(t, vocab['<unk>']) for t in tokens] # 截断或padding到max_len if len(ids) > max_len: ids = ids[:max_len] else: ids = ids + [vocab['<pad>']] * (max_len - len(ids)) self.data.append((torch.tensor(ids, dtype=torch.long), label)) def __len__(self): return len(self.data) def __getitem__(self, idx): return self.data[idx]这里有三个参数直接影响训练效果。min_freq=2:只出现一次的词既学不到语义,又会让词表膨胀,通常设2或3。max_len=64:LSTM按时间步展开,句子太长计算量大且梯度容易消失;情感分析里64个词通常够用,如果你发现很多样本被截断,再调到128。<pad>用0,是因为后续如果使用预训练词向量,padding位置通常不参与更新。
DataLoader那边要注意一个细节:LSTM需要按batch打包,同一个batch里的样本长度必须一致。上面代码已经在Dataset里统一pad到max_len了,所以DataLoader里不需要再写collate_fn。如果你的数据长短差异大,想用动态padding,再自定义collate_fn做按batch内最长长度对齐,能省显存,但代码会复杂一截。
2.2 LSTM模型定义:embedding_size、hidden_size、num_layers怎么设
模型本身在PyTorch里是标准三层。我见过不少新手把batch_first=True这个参数漏掉,导致维度一直对不上。建议统一加这个参数,让输入变成(batch, seq_len, embedding_size),更符合人类直觉。
import torch.nn as nn class LSTMSentiment(nn.Module): def __init__(self, vocab_size, embedding_size=128, hidden_size=128, num_layers=2, num_classes=2, dropout=0.3): super().__init__() self.embedding = nn.Embedding(vocab_size, embedding_size, padding_idx=0) self.lstm = nn.LSTM(embedding_size, hidden_size, num_layers=num_layers, batch_first=True, dropout=dropout if num_layers > 1 else 0) self.dropout = nn.Dropout(dropout) self.fc = nn.Linear(hidden_size, num_classes) def forward(self, x): # x: (batch, max_len) emb = self.embedding(x) # (batch, max_len, embedding_size) lstm_out, (h_n, c_n) = self.lstm(emb) # lstm_out: (batch, max_len, hidden_size) # 取最后一层的最后一个时间步的隐状态 last_hidden = h_n[-1] # (batch, hidden_size) out = self.dropout(last_hidden) return self.fc(out) # (batch, num_classes)embedding_size:我一般128起步。太小语义表达力不够,太大在小型数据集上容易过拟合,而且拖慢训练。hidden_size:128够用,如果数据量大、任务复杂,提到256。num_layers=2:两层LSTM能捕捉更高层的抽象特征,但到了3层以上训练难度明显增加,对情感分析这种短文本任务收益很小。dropout:只在多层LSTM层间和最后的全连接前加,注意PyTorch官方文档写明,如果num_layers=1,LSTM内部的dropout参数会被忽略。这里有个细节:h_n形状是(num_layers, batch, hidden_size),取h_n[-1]就是最后一层的隐状态,这也是为什么这里不取lstm_out[:,-1,:]的原因——两者结果一样,但前者语义更明确。
2.3 训练循环:交叉熵损失与Adam配置
训练部分比模型定义更容易被忽视。如果数据集只有几千条,训练轮数、学习率、梯度裁剪这三样直接决定最终准确率是70%还是85%。
import torch.optim as optim from torch.nn.utils import clip_grad_norm_ def train_model(model, train_loader, val_loader, epochs=10, lr=1e-3, device='cuda'): model.to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=lr) for epoch in range(epochs): model.train() total_loss = 0 for batch_x, batch_y in train_loader: batch_x = batch_x.to(device) batch_y = batch_y.to(device) optimizer.zero_grad() logits = model(batch_x) loss = criterion(logits, batch_y) loss.backward() # 梯度裁剪,防止LSTM长期依赖下的梯度爆炸 clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() total_loss += loss.item() # 验证 model.eval() correct = 0 total = 0 with torch.no_grad(): for batch_x, batch_y in val_loader: batch_x = batch_x.to(device) batch_y = batch_y.to(device) logits = model(batch_x) preds = torch.argmax(logits, dim=1) correct += (preds == batch_y).sum().item() total += batch_y.size(0) acc = correct / total print(f"Epoch {epoch+1}, Loss={total_loss/len(train_loader):.4f}, Val Acc={acc:.4f}")clip_grad_norm_不是可选项。LSTM在序列长度超过50时,梯度范数经常冲到几十甚至几百,一次参数更新就把loss打飞,也就是大家说的“炸了”。max_norm=5.0是常见取值,你可以从5开始,调小到1会训练变慢,调大到10则防不住爆炸。lr=1e-3对Adam是安全起点,但如果你发现loss下降很慢,可以先看是不是数据或padding问题,不要急着调lr。
这里的损失函数用的是CrossEntropyLoss,它内部已经包含了Softmax,所以模型的最后一层不需要再手动接Softmax——推理时想要概率再torch.softmax。
3. GPU加速训练:CUDA环境检查与单卡/多卡切换
训练慢不是GPU的锅,多半是数据还在CPU上打转。很多人以为把模型to(device)就完事了,结果每个batch又在CPU上生成,再拷贝到GPU,来回倒腾反而比纯CPU还慢。这一章讲清楚GPU加速的完整链路。
3.1 先确认你的GPU能被PyTorch用起来
很多环境装好了PyTorch,但装成了CPU版本,torch.cuda.is_available()返回False。这一步骤能省掉后面所有玄学问题。
import torch print("PyTorch版本:", torch.__version__) print("CUDA可用:", torch.cuda.is_available()) if torch.cuda.is_available(): print("GPU名称:", torch.cuda.get_device_name(0)) print("显存:", torch.cuda.get_device_properties(0).total_memory / 1024**3, "GB")如果is_available()是False,不要急着改代码,先检查三件事:显卡驱动是否装了(NVIDIA-smi能跑);PyTorch是不是带CUDA的版本;如果是在conda环境里,nvidia-smi显示的CUDA版本和torch.version.cuda不一定一致,前者是驱动支持的版本,只要驱动版本大于PyTorch要求的就行。一个常见问题是装了pytorch-cpu包,重装为pytorch即可。
3.2 把模型和数据搬上GPU的三种写法
确定GPU可用后,设备切换要覆盖模型、每个batch的输入张量、标签张量。我一般会在项目开头定义一个全局device,然后所有.to(device)都指向它,避免后面改设备要到处找。
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') # 写法1:模型一路带过去 model = LSTMSentiment(vocab_size=len(vocab)).to(device) # 写法2:数据进batch时转移(在训练循环里用) for batch_x, batch_y in train_loader: batch_x = batch_x.to(device) batch_y = batch_y.to(device) # 写法3:直接构造张量时指定device(适合验证阶段) # dummy_input = torch.randint(0, 100, (1, 64), device=device)第二种写法最常用,但要注意batch_x.to(device)会返回一个新的张量,原张量还在CPU上,所以必须重新赋值。另外,如果数据加载用了num_workers>0,子进程里的数据拷贝不会占用GPU显存,但主进程的GPU张量不能直接传给DataLoader,必须在训练循环里转移。
3.3 多卡训练时一个容易忽略的坑
当你有多张GPU时,用DataParallel可以让模型在单机多卡上并行。但这里有个新手常踩的坑:DataParallel包装后的模型,model.module才是原来的模型结构,保存权重时如果不注意,加载时会维度对不上。
if torch.cuda.device_count() > 1: model = nn.DataParallel(model) # 保存时,如果用了DataParallel,想保存原始模型权重: torch.save(model.module.state_dict(), 'model_state_dict.pth') # 否则直接torch.save(model.state_dict()),权重key带"module."前缀,单卡加载会报错我建议工程项目里先不用多卡。情感分析这种任务,单卡2080或3060在几万样本上跑十几轮也就十几分钟,多卡并行带来的通信开销可能抵消加速收益。只有单个batch的显存放不下时才考虑DataParallel或DistributedDataParallel。真要用,建议从始至终用DistributedDataParallel,它比DataParallel更稳定,也不会留下module.前缀这种坑。
还有一个显存优化的常用技巧:在验证阶段用with torch.no_grad():包裹,并且不调用loss的计算,这样显存占用会大幅下降。训练时如果CUDA out of memory,优先把batch_size减半,同时把max_len调小,而不是换模型。
4. 训练过程调参与效果验证:别让loss曲线骗了你
模型跑起来只是开始。情感分析项目翻车最多的地方不在模型结构,而在训练策略——loss在下降不代表模型学会了语义,也可能是在记住训练集里的噪声。这一章讲三个关键判断点。
4.1 学习率与batch_size的搭配经验
业界有个不成文的经验:batch size翻倍,学习率也翻倍。因为batch越大,梯度越平滑,可以用更大步长。但这是对固定数据集的粗略经验值,不能套用到所有任务。
# 常见搭配参考表 # batch_size: 32 -> lr=1e-3, 适合小数据集 # batch_size: 64 -> lr=2e-3, 适合中等规模 # batch_size: 128 -> lr=4e-3, 适合大数据集且需配合warmup # 实际做法:先用小batch跑通,再调batch,lr跟着缩放 batch_size = 32 lr = 1e-3 # 如果你改batch_size=64,lr改为2e-3还有一个反直觉的点:学习率太低时,loss会在一个较高的平台值原地踏步,看起来每一轮都在降,但降得极慢。这时候正确做法是先把学习率调大一个数量级,观察一两轮,如果不炸再微调。学习率太高则表现为loss在几个epoch后突然飙升,这种事我遇到不下十次,解决方案不是降学习率,而是加载之前保存的最优权重,从那个点重新用小学习率训练。
4.2 用验证集和混淆矩阵判断模型是真会还是背题
训练集的loss趋近于0,验证集准确率却不到60%,这是典型的过拟合。LSTM的参数量远超小样本量时很容易发生。怎么判断?看验证集loss是否在某个epoch后开始回升,训练loss还在下降——这就是“曲线骗了你”的名场面。
from sklearn.metrics import confusion_matrix, classification_report # 收集预测结果 preds_list = [] labels_list = [] model.eval() with torch.no_grad(): for batch_x, batch_y in val_loader: batch_x, batch_y = batch_x.to(device), batch_y.to(device) logits = model(batch_x) preds = torch.argmax(logits, dim=1).cpu().numpy() preds_list.extend(preds) labels_list.extend(batch_y.cpu().numpy()) print(confusion_matrix(labels_list, preds_list)) print(classification_report(labels_list, preds_list, target_names=['negative', 'positive']))混淆矩阵能告诉你模型是在“均匀犯错”还是“只偏向某一类”。情感分析数据如果正负样本不均匀,模型可能把所有样本都预测成多数的那个类,此时准确率看着不错,其实毫无泛化能力。解决方式是加class_weight到损失函数,或者过采样少类样本。classification_report输出的F1-score比准确率更诚实,尤其当样本不平衡时。
4.3 常见loss不下降的原因排查
loss不下降时,第一先排除代码bug,而不是调参。做过几次之后总结出这几条高频原因,按优先级排查:
- 标签和损失函数不匹配:如果用CrossEntropyLoss,标签必须是
0到num_classes-1的整型,不能是[0,1]的one-hot编码,更不能是浮点。 - padding位置的梯度污染:虽然padding idx=0的embedding不更新(因为
padding_idx的作用),但LSTM仍然会把这个填充位当成真实输入,导致隐状态被无意义的padding影响。解决办法是真正使用pack_padded_sequence,但对于定长截断到64的小数据集,影响不算大。 - 学习率太大直接梯度爆炸:loss变成
NaN大概率是学习率问题或数据里出现异常值。先设lr=1e-4跑通,再逐步加大。 - 验证集和训练集的数据预处理不一致:例如训练时做了小写转换,验证时没做,导致OOV词全部变成
<unk>,性能断崖下跌。
遇到loss不降,先打印一个batch里的logits和labels,确认形状和值域是否合理。logits全是一个数,说明模型没有学进去,常是embedding层初始化或LSTM维度设置的问题。
5. LSTM情感分析避坑指南:5条真实踩坑记录
这一章的每一条都是实操中遇到过的,不是理论推导。按“现象→原因→解决”写,方便你遇到对应问题时直接对照。
5.1 加载模型参数时提示size mismatch
现象:保存训练好的模型后,换机器或换环境重新加载,报size mismatch for embedding.weight: expected [19999, 128], got [20001, 128]。
原因:重新构建词表时,单词过滤阈值min_freq变了,或者数据预处理时没过滤干净,导致词表数量变化。
解决:把词表保存下来,和权重一起打包。不要每次运行脚本都重新build_vocab。正确做法是把vocab对象用json或pickle存下来,加载模型前先恢复词表,再初始化模型。
5.2 batch_first=False导致的维度错乱
现象:RuntimeError: input must have 3 dimensions, got 2,或者训练中loss正常但预测阶段结果完全不对。
原因:LSTM默认batch_first=False,输入是(seq_len, batch, embedding_size)。如果数据在Dataset里是(batch, seq_len),网络会混淆时间步和batch维度。预测阶段batch_size=1时尤其隐蔽,因为(1, seq_len)和(seq_len, 1)在某些情况下不报错,但语义完全错了。
解决:在LSTM定义里显式加batch_first=True,并在模型forward里加注释说明输入形状。不要依赖默认值。
5.3 训练时GPU显存一直在涨,最后OOM
现象:每个epoch刚开始显存占用正常,跑到后来越占越多,直到CUDA out of memory。
原因:验证阶段忘了写torch.no_grad(),或者训练循环里把每步的loss都追加到一个list,而这个list保留了整个计算图。另一个常见原因是DataLoader的num_workers开太多,每个进程复制了模型副本。
解决:验证阶段必须用no_grad;loss累加时用loss.item()提取Python数值,不要直接total_loss += loss,否则计算图会一直保留。num_workers一般设4~8即可,超过物理核心数反而因进程切换增加开销。
5.4 中文数据你没分词就直接按空格split
现象:中文文本按空格split后,一句话变成一长串,词表巨大而且几乎每个词只出现一次,模型准确率只在55%左右。
原因:中文没有天然的空格分词。re.findall(r'\b\w+\b', text)在中文上会匹配一整段连续汉字,相当于把整个句子当一个词。
解决:中文数据统一用jieba.cut做分词。预处理流程改为“分词→去停用词→索引化”。这一条影响极大,不处理的话,词表数量和OOV率都会失控。
5.5 LSTM输出层用sigmoid还是softmax
现象:二分类训练完,预测时用sigmoid输出概率,阈值怎么调都不对,准确率始终在50%附近徘徊。
原因:模型输出层是Linear(num_classes),没加激活函数。二分类如果用Sigmoid,对应的损失函数是BCEWithLogitsLoss,而CrossEntropyLoss期望的是每个类一个得分。两者混用时,模型输出的logits含义完全不同。
解决:要么保持CrossEntropyLoss+ 输出层两个节点,推理用softmax;要么改成单节点输出 +Sigmoid+BCEWithLogitsLoss。不要混搭。这个坑在二分类任务里特别隐蔽,因为代码不报错,但结果非常奇怪。
6. 把模型用到实际文本:单条预测与分析结果的细节
训练完了,最终的落脚点是给一条没见过的文本做预测。这时最容易踩的坑是:单条数据没有batch维度,模型直接forward会报错。另外,推理时的预处理必须严格复刻训练时的流程——不是“差不多”,是“完全相同”。
def predict_single(text, model, vocab, device, max_len=64): # 和训练时一样的分词、转索引、padding tokens = re.findall(r'\b\w+\b', text.lower()) ids = [vocab.get(t, vocab['<unk>']) for t in tokens] if len(ids) > max_len: ids = ids[:max_len] else: ids = ids + [vocab['<pad>']] * (max_len - len(ids)) tensor = torch.tensor([ids], dtype=torch.long, device=device) # 注意[ids]变成了(1, max_len) model.eval() with torch.no_grad(): logits = model(tensor) probs = torch.softmax(logits, dim=1) # (1, 2) positive_prob = probs[0, 1].item() return positive_prob这段代码里有三个细节值得记一下:第一,torch.tensor([ids])比torch.tensor(ids)多一对方括号,才符合模型要求的(batch, seq_len)形状;第二,推理时一定要切到model.eval(),否则模型里的dropout和BatchNorm行为会变化,导致预测不稳定;第三,positive_prob是一个0到1的浮点数,你可以根据业务需求设阈值,比如大于0.6才算正面,而不是死认0.5。
更进阶的用法是把中间隐状态抽出来。LSTM最后一个时间步的隐向量可以当作文本的稠密表示,你可以把它喂给聚类或者相似度检索。这在情感分析之外是个惊喜,相当于免费拿到一个“句向量”功能。做法是在forward里把last_hidden返回出来,或者单独写一个特征提取方法。
最后说一个我的习惯:保存模型时,连带着保存max_len、vocab、device这些配置,打包成一个字典文件。以前我偷懒只保存state_dict,后来换数据重新跑了一次,词表全变了,换个模型等于重训。现在我会这样保存:
torch.save({ 'model_state_dict': model.state_dict(), 'vocab': vocab, 'max_len': max_len, 'embedding_size': embedding_size, 'hidden_size': hidden_size, 'num_layers': num_layers, 'num_classes': num_classes, }, 'sentiment_lstm.pth')加载时,用保存的配置重建模型,恢复词表,再loadstate_dict,全程不需要手动记录任何超参数。这一套做完,这个项目就能真正“收盘”了。希望这些经验帮你在自己数据集上少绕几个弯,一次跑通。
本文还有配套的精品资源,点击获取