☰
PyTorch LSTM实战:IMDB影评情感分类从环境搭建到90%准确率
2026/10/7 4:43:03 网站建设 项目流程

简介:这份PDF文档面向NLP入门者与深度学习开发者,围绕IMDB电影评论数据集,完整讲解基于PyTorch LSTM的情感分类模型开发流程。内容从情感分析的定义、任务与常用方法切入,系统梳理PyTorch与LSTM基础原理,再逐步展开数据获取、探索、预处理与可视化,进而完成模型架构设计、代码实现、前向传播、训练与评估全流程,并延伸至超参数调优、正则化、早停、模型保存加载及混淆矩阵、ROC曲线等性能分析,最后探讨在线部署、电商评论与舆情分析等拓展应用。资源共1个PDF文件,约1.95MB,支持目录跳转与阅读器大纲定位,34页篇幅条理清晰,图表与文字显示正常。已有74人学习,适合希望以实战方式掌握PyTorch文本分类、构建可复用情感分析方案的读者参考。

1. 从一条 IMDB 影评说起:PyTorch LSTM 情感分类到底在做什么

一条 IMDB 影评动辄两三百词,正负情绪往往藏在 “not worth the ticket”“barely held my attention” 这种局部搭配里,而不是某个单词本身。词袋模型把词序丢掉,遇到否定和转折就翻车,这也是很多人做 NLP 情感分析时第一个踩的坑。这个标题讲的就是用 PyTorch 搭一个 LSTM 模型,把 IMDB 评论映射成正面/负面二分类,覆盖从环境搭建、词表构建、Dataset 封装、模型定义到训练评估的完整链路。它适合已经会写 Python、想从 sklearn 传统模型切到深度学习序列建模的从业者,也适合需要一套可复现基线来做评论审核、舆情监控、商品评价打标的人。读完你能自己跑通一条 88% 以上准确率的基线,并知道显存、序列长度、词表大小这些参数怎么调。

2. 环境与数据准备:PyTorch 安装、IMDB 数据集加载和词表构建

2.1 先把 PyTorch 环境搭稳,别在 CUDA 上耗一晚上

做这个任务,环境是第一个门槛。常见做法是用 conda 建独立环境,再按显卡情况装对应 CUDA 版本的 PyTorch。如果你只是先跑通 CPU 版本,直接装默认包即可;有 N 卡且想加速,就去 PyTorch 官网选对应 CUDA 版本,别自己乱配 cuda 和 pytorch 的版本组合,这是血泪经验。

# 创建独立环境,Python 3.10 兼容性较好 conda create -n imdb_lstm python=3.10 -y conda activate imdb_lstm # CPU 版本,先跑通流程用这个 pip install torch torchvision torchaudio # 有 NVIDIA 显卡时,按官网选择对应 CUDA 版本,例如 cu121 # pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121 # 其余依赖 pip install numpy pandas scikit-learn tqdm

装完必须验证,否则后面报错会怀疑到模型代码上:

import torch print(torch.__version__) print(torch.cuda.is_available()) # 有卡且装对才为 True print(torch.cuda.get_device_name(0) if torch.cuda.is_available() else "CPU only")

逻辑说明:torch.cuda.is_available()返回 False 时,要么没卡,要么 CUDA 版本和驱动不匹配。参数上,--index-url指定的是 PyTorch 官方 wheel 源,不同 CUDA 版本对应不同 URL,装之前先nvidia-smi看驱动支持的最高 CUDA 版本,别超过它。

2.2 IMDB 数据集加载与词表构建的四个关键参数

IMDB 数据集在 torchtext 里有现成封装,但 torchtext 版本变动频繁,我一般直接用 torchvision 风格的torchtext.datasets.IMDB,或者下载原始 aclImdb 目录自己读。为了可控,这里用后者思路:读pos/neg目录下的 txt,拼成 (文本, 标签) 列表。

import os, re, glob from collections import Counter def load_imdb(root): data = [] for label, sub in [(1, "pos"), (0, "neg")]: for fp in glob.glob(os.path.join(root, sub, "*.txt")): with open(fp, encoding="utf-8") as f: data.append((f.read(), label)) return data def tokenize(text): # 简单清洗:转小写,保留字母和基本标点 text = re.sub(r"<br\s*/?>", " ", text) return re.findall(r"[a-z0-9']+", text.lower()) train_raw = load_imdb("aclImdb/train") test_raw = load_imdb("aclImdb/test") # 只用训练集建词表,避免测试集信息泄漏 counter = Counter() for text, _ in train_raw: counter.update(tokenize(text)) # 四个关键参数:min_freq / max_vocab / max_len / pad_idx MIN_FREQ = 3 MAX_VOCAB = 25000 MAX_LEN = 256 PAD_IDX = 0 vocab = {"<pad>": PAD_IDX, "<unk>": 1} for word, freq in counter.most_common(MAX_VOCAB): if freq >= MIN_FREQ: vocab[word] = len(vocab) print("vocab size:", len(vocab))

逻辑说明:min_freq=3过滤只出现一两次的噪声词,max_vocab=25000控制 embedding 参数量,max_len=256截断长评论,pad_idx=0给补齐位。参数怎么改:显存小就把max_len降到 128,词表大就降max_vocab;如果发现<unk>比例过高,说明min_freq太严,可降到 2。

2.3 Dataset 与 DataLoader:把变长文本补齐成定长张量

LSTM 一个 batch 内要求序列等长,所以要在 collate 阶段做 padding。这里用pad_sequence按 batch 内最长补齐,比全局补到 256 更省显存。

import torch from torch.utils.data import Dataset, DataLoader from torch.nn.utils.rnn import pad_sequence class IMDBDataset(Dataset): def __init__(self, data, vocab, max_len): self.samples = [] for text, label in data: ids = [vocab.get(w, 1) for w in tokenize(text)][:max_len] if len(ids) == 0: ids = [1] self.samples.append((torch.tensor(ids), label)) def __len__(self): return len(self.samples) def __getitem__(self, idx): return self.samples[idx] def collate_fn(batch): texts, labels = zip(*batch) lengths = torch.tensor([len(t) for t in texts]) padded = pad_sequence(texts, batch_first=True, padding_value=PAD_IDX) return padded, lengths, torch.tensor(labels, dtype=torch.float) train_ds = IMDBDataset(train_raw, vocab, MAX_LEN) test_ds = IMDBDataset(test_raw, vocab, MAX_LEN) train_loader = DataLoader(train_ds, batch_size=64, shuffle=True, collate_fn=collate_fn) test_loader = DataLoader(test_ds, batch_size=128, shuffle=False, collate_fn=collate_fn)

逻辑说明:lengths是每条真实长度,后面传给pack_padded_sequence用,避免 LSTM 在 pad 位上浪费计算。参数上batch_size=64是 8G 显存下的稳妥值,显存够可上 128;shuffle=True只在训练集开,测试集必须 False,否则评估结果不可复现。

3. LSTM 模型定义与训练:从 Embedding 到二分类输出的完整代码

3.1 模型结构:Embedding + LSTM + 全连接的三段式

情感分类不需要太深,两层 LSTM 加 dropout 就够。关键点是取 LSTM 最后一个有效时间步的输出,而不是最后一个 pad 位的输出,否则短句会被 pad 稀释。

import torch.nn as nn from torch.nn.utils.rnn import pack_padded_sequence class LSTMClassifier(nn.Module): def __init__(self, vocab_size, embed_dim=128, hidden_dim=256, num_layers=2, dropout=0.3, pad_idx=0): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=pad_idx) self.lstm = nn.LSTM(embed_dim, 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, 1) # 双向所以乘 2 def forward(self, x, lengths): emb = self.dropout(self.embedding(x)) packed = pack_padded_sequence(emb, lengths.cpu(), batch_first=True, enforce_sorted=False) out, (h, c) = self.lstm(packed) # 双向:取正向最后隐状态和反向最后隐状态拼接 h_cat = torch.cat([h[-2], h[-1]], dim=1) return self.fc(self.dropout(h_cat)).squeeze(1)

逻辑说明:bidirectional=True让模型同时看前后文,对 “not good” 这类搭配更敏感;h[-2]是正向最后一层,h[-1]是反向最后一层。参数上embed_dim=128、hidden_dim=256是 IMDB 上的常用起点,dropout=0.3防过拟合,num_layers=2再深收益递减且更慢。注意pack_padded_sequence要求 lengths 在 CPU 上且降序,enforce_sorted=False会自动处理排序。

3.2 训练循环:损失函数、优化器和梯度裁剪

二分类用BCEWithLogitsLoss,它把 sigmoid 和 BCE 合在一起,数值更稳。LSTM 容易梯度爆炸,梯度裁剪是后悔药。

from torch.optim import Adam from tqdm import tqdm device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = LSTMClassifier(len(vocab)).to(device) criterion = nn.BCEWithLogitsLoss() optimizer = Adam(model.parameters(), lr=1e-3, weight_decay=1e-5) def train_one_epoch(model, loader, optimizer, criterion): model.train() total_loss, correct, total = 0, 0, 0 for x, lengths, y in tqdm(loader): x, y = x.to(device), y.to(device) optimizer.zero_grad() logits = model(x, lengths) loss = criterion(logits, y) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() total_loss += loss.item() * y.size(0) pred = (torch.sigmoid(logits) > 0.5).float() correct += (pred == y).sum().item() total += y.size(0) return total_loss / total, correct / total for epoch in range(5): loss, acc = train_one_epoch(model, train_loader, optimizer, criterion) print(f"epoch {epoch+1} loss={loss:.4f} acc={acc:.4f}")

逻辑说明:lr=1e-3是 Adam 的常规起点,weight_decay=1e-5轻微正则;clip_grad_norm_(max_norm=5.0)把梯度范数压到 5 以内,防止 LSTM 梯度爆炸。参数怎么改:loss 不降就把 lr 降到 5e-4,过拟合就把 dropout 提到 0.5 或加 weight_decay。

3.3 评估与保存:准确率、F1 和推理接口

训练完要在测试集上评估,并保存 state_dict 供推理用。

from sklearn.metrics import accuracy_score, f1_score def evaluate(model, loader): model.eval() preds, labels = [], [] with torch.no_grad(): for x, lengths, y in loader: x = x.to(device) logits = model(x, lengths) pred = (torch.sigmoid(logits) > 0.5).float().cpu().numpy() preds.extend(pred.tolist()) labels.extend(y.numpy().tolist()) return accuracy_score(labels, preds), f1_score(labels, preds) acc, f1 = evaluate(model, test_loader) print(f"test acc={acc:.4f} f1={f1:.4f}") torch.save(model.state_dict(), "lstm_imdb.pt")

逻辑说明:model.eval()关闭 dropout,torch.no_grad()省显存。IMDB 上这套配置通常能到 0.87~0.89 准确率,F1 与准确率接近说明类别均衡。参数上阈值 0.5 可按业务调,比如宁可漏判也不误判时把阈值提到 0.6。

4. 避坑与排查:IMDB LSTM 训练中最容易翻车的五件事

4.1 现象:loss 一直是 0.69 不降 → 原因:标签或 logits 形状不对 → 解决:检查 squeeze 和 dtype

BCEWithLogitsLoss要求 logits 和 target 同形状。如果fc输出是[B,1]而 y 是[B],广播后 loss 会异常。我在forward里加了.squeeze(1),target 用dtype=torch.float,两边都是[B]。排查时打印logits.shape和y.shape,不一致就先对齐。

4.2 现象:验证集准确率远低于训练集 → 原因:词表用了全量数据或 dropout 太小 → 解决:只用训练集建词表,调大 dropout

词表构建时如果混入测试集,等于提前看到了测试分布,评估会虚高,上线就崩。正确做法是只用train_raw建词表,测试集遇到未登录词走<unk>。另外dropout=0.3在 IMDB 上偏小,过拟合明显时可提到 0.5。

4.3 现象:CUDA out of memory → 原因:max_len 或 batch_size 太大 → 解决:降 max_len 到 128,batch_size 到 32

LSTM 显存和batch_size × max_len × hidden_dim成正比。8G 卡上batch_size=64, max_len=256, hidden_dim=256接近上限。先降max_len到 128,再降 batch,比换卡快。也可以用pack_padded_sequence已经省了一部分,但 pad 位仍占 embedding 输出。

4.4 现象:pack_padded_sequence 报 “lengths must be on CPU” → 原因:lengths 被 to(device) 了 → 解决:lengths 保持 CPU

pack_padded_sequence的 lengths 参数必须在 CPU 上,这是 PyTorch 的设计。我在forward里写lengths.cpu(),训练循环里不要把 lengths 搬到 GPU。这个报错信息很明确,但新手容易在x, lengths, y = x.to(device), lengths.to(device), y.to(device)里顺手全搬。

4.5 现象:推理时单条文本结果和批量不一致 → 原因:没切 eval 模式或 padding 方式不同 → 解决:推理固定 eval + no_grad,单条也走 collate

model.train()下 dropout 会随机丢神经元,单条推理结果每次不同。推理前必须model.eval()。另外单条文本如果手动补到 256,而训练时是按 batch 内最长补,分布不一致会掉点。稳妥做法是单条也包成 batch 走同一个collate_fn。

5. 进阶技巧:把 LSTM 情感分类推到 90% 以上的三个可落地手段

第一,换预训练词向量。IMDB 上从随机初始化 embedding 换成 GloVe 100d 或 fastText,准确率通常能涨 1~2 个点。做法是读 GloVe 文件,按词表顺序填进embedding.weight,padding_idx那行保持全零,训练时可以选择冻结或微调。冻结适合数据少,微调适合数据够。

def load_glove(vocab, path="glove.6B.100d.txt", dim=100): vectors = torch.randn(len(vocab), dim) * 0.1 vectors[PAD_IDX] = torch.zeros(dim) with open(path, encoding="utf-8") as f: for line in f: parts = line.rstrip().split(" ") word = parts[0] if word in vocab: vectors[vocab[word]] = torch.tensor([float(v) for v in parts[1:]]) return vectors model.embedding.weight.data.copy_(load_glove(vocab)) # 冻结:model.embedding.weight.requires_grad = False

第二,用学习率调度和早停。ReduceLROnPlateau在验证 loss 不降时把 lr 减半,配合早停能省时间也防过拟合。我一般 patience 设 2,连续两轮不降就停。

scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode="min", patience=2, factor=0.5) # 每个 epoch 后:scheduler.step(val_loss)

第三,把模型导出成 ONNX 做部署。PyTorch 训练完的模型要上生产,转 ONNX 后用 onnxruntime 推理,延迟更低且不依赖 PyTorch 环境。注意 LSTM 的pack_padded_sequence在 ONNX 里支持有限,导出时用固定长度输入或去掉 pack,改成手动取最后有效步。

dummy_x = torch.randint(0, len(vocab), (1, 128)).to(device) dummy_len = torch.tensor([128]) torch.onnx.export(model, (dummy_x, dummy_len), "lstm_imdb.onnx", input_names=["input", "lengths"], output_names=["logits"], dynamic_axes={"input": {0: "batch", 1: "seq"}})

参数上dynamic_axes让 batch 和 seq 可变,但 LSTM 的 hidden state 在 ONNX 里对动态长度支持一般,生产环境我通常固定max_len=128,短句补 pad,用 mask 取最后有效步。这套组合下来,IMDB 测试集上 90% 准确率是可以摸到的。我自己踩过最深的坑是忘了切 eval 就上线,结果同一句评论两次结果不一样,查了半天才定位到 dropout。做序列模型,训练和推理的边界一定要卡死,希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询