简介:面向PyTorch初学者和自然语言处理入门者,提供一套基于LSTM的文本情感分析实战项目,包含可直接运行的Python源码与配套数据集。项目利用GPU加速训练,覆盖从数据预处理、模型搭建、训练评估到情感分类的完整流程,适合用于课程设计、毕业设计或自学实践。资源包共4个文件,主要包含1个Python源文件、1个Markdown说明文档和2张PNG示例图片,压缩包整体大小约83KB,文件结构精简,便于快速定位代码、说明与效果图。目前已有212人学习或下载该资源。读者可基于源码理解LSTM在情感分析任务中的实现细节,参考GPU加速训练配置与数据处理技巧;Markdown文档可辅助梳理项目目录与运行步骤,PNG图片能直观展示模型运行效果或中间结果,有助于快速复现实验并迁移到类似文本分类任务。
1. 情感分析项目为什么选 LSTM + GPU:先看清你要做的这件事
文本情感分析是 NLP 里最常被人拿来练手的任务,它的目标很直接:给定一条影评或商品评论,判断它是正面还是负面。很多人第一次上手就想着上 BERT 这样的大模型,但对于万级样本、单卡训练的场景,LSTM 仍然是一个性价比很高的选择——它参数量小、训练快、行为可解释,而且对长句子的上下文建模能力足够撑起 85% 以上的准确率。
标题里的“GPU 加速”不是锦上添花。LSTM 是循环结构,无法像 CNN 那样完全并行,在 CPU 上跑一个 epoch 可能要等十几分钟,换到 GPU 上往往能压缩到一两分钟。这个项目适合两类人:一是刚学完 PyTorch 基础、想完整走一遍“数据处理→模型→训练→评估”流程的同学;二是需要在生产环境快速部署一个轻量情感分类模型的工程师。接下来我按自己实际做这个项目的顺序,把环境、数据、模型、训练和踩坑点一次讲清楚。
2. 环境与数据:PyTorch GPU 版怎么装、IMDB 数据怎么读
2.1 安装 PyTorch GPU 版:版本对应关系是第一个坑
在做这个 LSTM 情感分析项目之前,第一件事不是写模型,而是把 PyTorch 的 GPU 版本装对。很多人在这一步就卡住了,因为 PyTorch 的安装命令和 CUDA 版本、Python 版本强相关,装错了要么不能用 GPU,要么 import 直接报错。
我一般会先确认显卡驱动支持的 CUDA 版本,再选择对应的 PyTorch 版本。在命令行里依次执行:
nvidia-smi python --versionnvidia-smi右上角会显示CUDA Version: 12.x,这是驱动支持的最高 CUDA 版本,不代表你必须在系统里装这个版本的 CUDA Toolkit。PyTorch 的安装包自带 CUDA 运行时,你只需要保证驱动版本大于等于 PyTorch 要求的 CUDA 版本即可。
接下来用 conda 创建独立环境,避免把基础环境搞乱:
conda create -n sentiment python=3.10 -y conda activate sentiment pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121这里我选的是 cu121 版本的安装源,对应 CUDA 12.1。如果你的驱动版本较老,可以换成 cu118 或 cu117。安装完成后,运行下面的代码验证 GPU 是否可用:
import torch print("PyTorch 版本:", torch.__version__) print("CUDA 是否可用:", torch.cuda.is_available()) print("GPU 名称:", torch.cuda.get_device_name(0) if torch.cuda.is_available() else "无")输出里CUDA 是否可用: True才说明环境没问题。这里有一个关键经验:不要只看torch.cuda.is_available(),还要看 PyTorch 版本和 Python 版本的对应关系。PyTorch 2.x 对 Python 3.10 支持最稳定,Python 3.12 在某些旧版 PyTorch 上会直接报No module named 'torch'之类的玄学错误,建议优先用 Python 3.10。
提示:如果你发现
torch.cuda.is_available()返回 False,先检查驱动版本,再用nvidia-smi确认显卡是否被系统识别。不要盲目重装 PyTorch,90% 的情况是驱动不匹配。
2.2 数据集与预处理:从原始文本到整数序列
环境准备好之后,就要处理数据了。这个项目用的数据集是 IMDB 电影评论,包含 25000 条训练样本和 25000 条测试样本,每条评论被标记为正面(pos)或负面(neg)。IMDB 是一个非常标准的情感分析基准数据集,网上有打包好的 CSV 版本,也可以用 Hugging Face 的datasets库直接加载。
我建议先用最简单的 CSV 方式,因为这样你能清楚看到数据的原始形态,而不是被库封装的黑匣子挡住。我一般把数据组织成两列:review是评论文本,label是 0 或 1。
import pandas as pd df = pd.read_csv("imdb.csv") print(df.head()) print(df["label"].value_counts())预处理的核心是把文本变成模型能读的整数序列。需要做几件事:转小写、去除 HTML 标签和特殊符号、按空格或正则分词、构建词表。这里有一个取舍:要不要去停用词?我的经验是情感分析任务里不要去掉否定词,比如 "not good" 如果去掉 "not",语义就完全反了。所以只做简单的清洗,保留所有词。
import re def clean_text(text): # 去掉 HTML 标签 text = re.sub(r"<.*?>", "", text) # 只保留字母和空格 text = re.sub(r"[^a-zA-Z\s]", "", text) # 转小写并按空格分词 text = text.lower().split() return text df["tokens"] = df["review"].apply(clean_text)分完词之后要构建词表,并给每个词分配一个整数 ID。这里要考虑两个特殊标记:<pad>用来把不同长度的句子补齐到相同长度,<unk>用来代替词表中不存在的词。构建词表时通常会设置一个max_features,只保留出现频率最高的前 20000 个词,低频词统一映射到<unk>。
from collections import Counter # 统计词频 word_count = Counter() for tokens in df["tokens"]: word_count.update(tokens) # 保留最高频的 20000 个词 max_features = 20000 vocab = {"<pad>": 0, "<unk>": 1} for word, _ in word_count.most_common(max_features): vocab[word] = len(vocab) # 文本转整数序列 def encode(tokens): return [vocab.get(word, vocab["<unk>"]) for word in tokens] df["encoded"] = df["tokens"].apply(encode)这里vocab字典实现了“词到 ID”的映射,<pad>的 ID 固定为 0,<unk>固定为 1。构建词表时用most_common截断高频词,能有效控制模型参数量,也能避免低频词带来的噪声。encode函数里vocab.get(word, vocab["<unk>"])的意思是:词在词表中就返回它的 ID,否则返回<unk>的 ID。
2.3 构造 DataLoader:padding、batch 与长度控制
词表构建完成之后,下一步是把整数序列变成等长的张量,并封装成 DataLoader。这个环节有一个常见误区:有人直接把所有句子 padding 到全数据集最长句子的长度,比如 IMDB 里有些评论长达 2000 多个词,这样做会浪费大量显存,因为大部分句子只有几百词。
更合理的做法是设置一个max_len,比如 500,超过的部分截断,不足的部分补零。按批次动态 padding 也可以,但初学者先用固定长度最稳妥,代码简单,行为也容易预期。
import torch from torch.utils.data import Dataset, DataLoader max_len = 500 class SentimentDataset(Dataset): def __init__(self, encoded, labels): self.encoded = encoded self.labels = labels def __len__(self): return len(self.encoded) def __getitem__(self, idx): seq = self.encoded[idx][:max_len] # 右侧补零到 max_len seq = seq + [0] * (max_len - len(seq)) label = self.labels[idx] return torch.tensor(seq, dtype=torch.long), torch.tensor(label, dtype=torch.float) train_dataset = SentimentDataset(df[df["split"] == "train"]["encoded"].tolist(), df[df["split"] == "train"]["label"].tolist()) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)SentimentDataset继承了 PyTorch 的Dataset类,核心是__getitem__方法,它返回一个(序列, 标签)对。补零操作发生在seq + [0] * (max_len - len(seq)),这里选择在右侧补零,对应 LSTM 里对末尾填充的处理。batch_size=64是一个比较稳妥的起始值,显存不够可以降到 32。
3. 搭建 LSTM 情感分析模型:nn.LSTM 的参数与词向量层
3.1 nn.LSTM 的输入输出维度
PyTorch 的nn.LSTM是现成的循环神经网络模块,不需要自己写单元状态和门控的计算逻辑,但它的输入输出维度很容易把人绕晕。先说清楚三个核心参数:input_size是每个时间步输入的特征维度,在文本任务里就是词向量的维度;hidden_size是隐藏状态的维度,也是最终输出的特征维度;num_layers是堆叠的 LSTM 层数。
输入张量的形状是(seq_len, batch_size, input_size),注意batch_size在中间。而 DataLoader 默认产出的形状是(batch_size, seq_len),所以需要做一次维度交换permute(1, 0, 2)。很多人在这一步翻车,报错信息通常是Expected input batch_size X to match target batch_size Y,原因是维度顺序没对。输出有两个:output和(h_n, c_n),其中output是每个时间步的隐藏状态序列,形状是(seq_len, batch_size, num_directions * hidden_size);h_n是最后一层的最终隐藏状态,形状是(num_layers * num_directions, batch_size, hidden_size)。
情感分类任务中,我们通常只取最后一个时间步的输出。有两种取法:一种是取output[-1],另一种是取h_n[-1]。这两个看起来一样,但在双向 LSTM 里有区别,后面细说。
3.2 双向 LSTM 与最后一步的取法
单向 LSTM 只能看到过去的信息,双向 LSTM 则同时看过去和未来,对句子这种整体语义依赖于上下文的场景非常有效。在nn.LSTM中设置bidirectional=True后,h_n的形状变成(num_layers * 2, batch_size, hidden_size),前num_layers行是正向的,后num_layers行是反向的。
双向 LSTM 的“最后一步”处理方式决定了最终分类效果。如果只取output[-1],实际上只拿到了正向传播的最后一步,反向传播的信息被丢掉了。正确的做法是把正向最后一步和反向第一步拼接起来。PyTorch 里可以用torch.cat((h_n[-2], h_n[-1]), dim=1),其中h_n[-2]是最后一层正向的最终状态,h_n[-1]是最后一层反向的最终状态。
import torch.nn as nn class BiLSTMSentiment(nn.Module): def __init__(self, vocab_size, embed_dim, hidden_dim, num_layers, num_classes): 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=0.3 if num_layers > 1 else 0) # 双向 LSTM 的输出维度是 hidden_dim * 2 self.fc = nn.Linear(hidden_dim * 2, num_classes) self.dropout = nn.Dropout(0.3) def forward(self, x): # x: (batch, seq_len) embedded = self.embedding(x) # (batch, seq_len, embed_dim) output, (h_n, c_n) = self.lstm(embedded) # 取最后一层正向和反向的最终状态并拼接 hidden = torch.cat((h_n[-2], h_n[-1]), dim=1) # (batch, hidden_dim * 2) hidden = self.dropout(hidden) return self.fc(hidden) # (batch, num_classes)这里把batch_first=True,输入形状就可以保持(batch, seq_len, embed_dim),省去手动交换维度的麻烦。padding_idx=0告诉 embedding 层,ID 为 0 的位置不参与梯度更新,保持全零向量,避免 padding 部分对语义产生干扰。dropout参数只在num_layers > 1时生效,这是 PyTorch 的一个隐藏规则,如果只有一层 LSTM 还传 dropout,会静默不生效。
3.3 模型定义完整代码与参数选择
上面这个BiLSTMSentiment类就是完整可用的模型代码。参数选择上有一些实战经验:embed_dim取 100 或 300 都可以,100 在 IMDB 上表现已经不错,300 通常要配合预训练词向量才有明显收益;hidden_dim取 128 或 256,对于情感分类这种二分类任务,128 足够;num_layers取 2 层是性价比最高的,3 层以上训练时间变长但收益很小。
还需要把模型实例化并放到 GPU 上:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = BiLSTMSentiment( vocab_size=len(vocab), embed_dim=100, hidden_dim=128, num_layers=2, num_classes=1 ).to(device) print(model)vocab_size=len(vocab)是词表的大小,也就是 embedding 矩阵的第一维。num_classes=1是因为我们用二分类输出,配合BCEWithLogitsLoss使用。这里没有在最后一层加 sigmoid,因为BCEWithLogitsLoss内部会计算 sigmoid,直接输出 logits 数值更稳定,也能避免 sigmoid 之后梯度消失的问题。
4. 训练循环与 GPU 加速:从 .cuda() 到混合精度
4.1 训练循环的基本骨架
模型定义好之后,进入训练阶段。训练循环的骨架很固定,但有几个细节不写对会直接影响训练效果。首先是损失函数,二分类用BCEWithLogitsLoss,多分类才用CrossEntropyLoss。优化器用 Adam,学习率设0.001是一个被验证过无数次的安全起点。
import torch.optim as optim from torch.nn import BCEWithLogitsLoss criterion = BCEWithLogitsLoss() optimizer = optim.Adam(model.parameters(), lr=0.001)训练循环里需要把数据和标签都移动到 GPU。常见的写法是把整个 batchto(device),但更好的做法是让 DataLoader 本身就是 GPU 数据源。可以定义一个函数把数据送到设备:
def train_epoch(model, loader, criterion, optimizer, device): model.train() total_loss = 0 correct = 0 total = 0 for inputs, labels in loader: inputs = inputs.to(device) labels = labels.to(device) optimizer.zero_grad() outputs = model(inputs).squeeze(1) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() # 计算准确率 preds = (torch.sigmoid(outputs) > 0.5).float() correct += (preds == labels).sum().item() total += labels.size(0) return total_loss / len(loader), correct / totaloutputs.squeeze(1)是因为模型输出形状是(batch, 1),BCEWithLogitsLoss要求目标形状也是(batch,),所以要去掉维度 1。preds = (torch.sigmoid(outputs) > 0.5).float()是阈值 0.5 的硬分类,在计算准确率时使用。训练过程中要把model.train()显式调用,它会影响 dropout 和 batchnorm 的行为。
4.2 显存管理与 batch size 的权衡
GPU 加速的核心是让数据尽量在显存中批量计算,减少 CPU-GPU 之间的数据拷贝。batch size 越大,GPU 利用率越高,训练越快,但显存是有限的。LSTM 的显存占用比 CNN 更敏感,因为每个时间步都要保存中间状态用于反向传播。
显存不足时优先降低 batch size,而不是降低max_len。因为max_len直接影响 LSTM 的时间步数,时间步越长,反向传播的显存开销越大。我的经验是:batch_size=64、max_len=500、hidden_dim=128在 8GB 显存上可以跑,如果降到 32 还报CUDA out of memory,就要检查是不是num_layers太多或者hidden_dim太大。
训练时还可以开启 PyTorch 2.x 的torch.compile加速,只需要一行代码:
model = torch.compile(model)torch.compile会做算子融合,在 GPU 上通常能带来 20%~40% 的加速,而且不需要改其他代码。如果你的 PyTorch 版本是 2.0 以下,这行代码不适用,需要先升级。需要注意的是,torch.compile在第一次前向传播时会花几十秒做编译,这是正常的,后续迭代速度会明显提升。
4.3 训练过程观察与模型保存
训练时不能只看 loss,要同时监控训练集准确率,并每隔几个 epoch 在验证集上测试,防止过拟合。情感分析任务过拟合很快,特别是模型隐藏层维度设得比较大时,训练集准确率可能到 98%,验证集却只有 82%。
我习惯在每个 epoch 结束后计算验证集准确率,并保存验证集上表现最好的模型权重:
best_acc = 0.0 for epoch in range(num_epochs): train_loss, train_acc = train_epoch(model, train_loader, criterion, optimizer, device) val_loss, val_acc = evaluate(model, val_loader, criterion, device) print(f"Epoch {epoch+1}: loss={train_loss:.4f}, train_acc={train_acc:.4f}, val_acc={val_acc:.4f}") if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), "best_model.pt") print(f"保存最佳模型,验证集准确率: {best_acc:.4f}")torch.save(model.state_dict(), "best_model.pt")只保存模型参数,不保存整个模型对象,这是推荐的保存方式。加载时要用相同的模型结构再load_state_dict,否则会报 key 不匹配。训练结束后,在测试集上做一次评估,得到最终的泛化准确率。IMDB 上用双向 LSTM + 词向量,测试集准确率一般在 85%~89% 之间,超过 90% 就说明可能有过拟合或数据泄漏。
5. 避坑排查:情感分析项目里最容易翻车的 5 个问题
5.1 CUDA out of memory 但显存看起来够用
现象:程序运行到某个 epoch 时突然报RuntimeError: CUDA out of memory,但用nvidia-smi看显存占用并不高。
原因:PyTorch 在训练过程中会动态申请显存,尤其是 LSTM 反向传播需要保存所有时间步的中间激活值。之前显存占用不高是因为某些批次的实际长度较短,遇到一个长批次就爆了。另外,torch.compile编译后的缓存也可能占用额外显存。
解决:先把batch_size从 64 降到 32,如果还爆就降到 16。同时检查是否有其他进程占用了显存,fuser -v /dev/nvidia*可以查看哪些进程在用 GPU。还有一个操作是主动释放缓存,在训练循环里每隔几步调用torch.cuda.empty_cache(),但这个操作对真正内存不足帮助不大,只是把碎片整理一下,不要过度依赖它。
5.2 GPU 利用率只有 10%,训练依然很慢
现象:nvidia-smi显示 GPU 利用率很低,训练速度提升不明显,和 CPU 差不多。
原因:当前面没有正确切换设备,数据还在 CPU 上,或者 DataLoader 的num_workers=0导致数据加载成了瓶颈。文本数据经过 padding 后,大量内存被 0 占满,如果 batch 大小太小,GPU 并行计算的优势完全发挥不出来。
解决:在训练循环里加一个断言,确认输入数据的设备是 GPU:
assert inputs.device.type == "cuda", "输入数据不在 GPU 上"同时把 DataLoader 的num_workers设为 4 或 8,pin_memory=True可以让数据从 CPU 内存拷贝到 GPU 显存的速度更快。这两个参数对训练吞吐量影响很大,尤其是在数据预处理比较复杂时。
5.3 训练 loss 下降但准确率几乎不变
现象:loss 从 0.7 降到 0.3,但准确率始终在 50% 左右徘徊,和随机猜测差不多。
原因:标签和输出的形状不匹配导致计算出的 loss 是错的,或者BCEWithLogitsLoss的 target 被当成了 one-hot 向量。还有一个可能原因是模型完全没有学习到有效特征,比如词表构建错误,所有评论都被映射到了同一个 token。
解决:打印模型输出和标签的形状和数值:
for inputs, labels in train_loader: outputs = model(inputs.to(device)) print(outputs.shape, labels.shape, outputs[:5], labels[:5]) break如果outputs.shape是(64, 1)、labels.shape是(64,),且数值不是全相等,说明数据流没问题。再看词表覆盖情况,用vocab_size和实际评论文本长度做对比,如果大量词都在<unk>上,要重新检查清洗函数是否把有效字符都删掉了。
5.4 双向 LSTM 拼接错了维度
现象:模型能跑通,但验证集准确率明显偏低,只有 75% 左右,而且训练集准确率也上不去。
原因:双向 LSTM 的最后一步拼接方式不对。如果只取output[:, -1, :],这个-1对应的其实是正向的最后一个时间步,在batch_first=True下是(batch, seq_len, num_directions * hidden_dim),但它把正反向的输出已经拼在了一起,直接取-1实际上只拿了正反向输出的拼接,方向信息没有对齐。
解决:这里要区分两组概念。output[:, -1, :]拿到的是最后一个时间步的完整输出(包含正反向拼接),而h_n[-2:]拿到的才是正反向的最终状态。对于情感分类,我更推荐用torch.cat((h_n[-2], h_n[-1]), dim=1),因为它语义更明确:h_n[-2]是最后一层正向的最终状态,h_n[-1]是最后一层反向的最终状态,拼接后在dim=1上得到hidden_dim * 2维向量,喂给全连接层。
5.5 padding 的位置影响结果
现象:使用batch_first=True时,补零在右侧,但准确率不如预期。
原因:RNN 系列模型对 padding 位置敏感。右侧 padding 意味着模型在读完句子后还要继续读[0]*max_len长度的全零向量,这些零向量经过 embedding 后是零向量,会让隐藏状态逐步衰减,从而冲淡前面学习到的特征。左侧 padding 则不会影响模型读取最终有效特征的时间步。
解决:把补零操作改到左侧,也就是序列开头填充零。代码只需要改一行:
seq = [0] * (max_len - len(seq)) + seq[:max_len]这个改动在 IMDB 上可能只有 1%~2% 的准确率提升,但在短文本和多分类任务上影响明显。如果用了pack_padded_sequence就不需要考虑这个问题,因为它会自动跳过 padding 部分,但要额外维护每个样本的真实长度,实现复杂度更高,新手项目先用左侧 padding 就够了。
6. 验证与进阶:用验证集说话,把准确率稳定在 85% 以上
项目做到这一步,模型能训练、准确率能出来,但真正的工程价值在于验证和调优。我先说一个验证方法:除了准确率,还要看混淆矩阵和几个典型样本的预测结果。准确率在类别不平衡时会骗人,IMDB 是平衡数据集还好,但如果你替换成自己的评论数据,正负样本比例可能严重失调。这时要改用 F1 score 作为主要指标,并检查模型在少数类上的表现。
进阶调参有一个优先级顺序:先调max_len,再调hidden_dim,最后调num_layers。max_len决定模型能看到的文本长度,IMDB 评论平均长度在 250 词左右,设 500 已经覆盖 95% 的样本,再增大会浪费显存;hidden_dim从 128 降到 64,准确率会掉 2%~3%,升到 256 可能只提升不到 1%,性价比很低;num_layers从 2 加到 3,训练时间增加 50%,准确率几乎不变。
超参确定后,可以尝试加入预训练词向量。用 GloVe 或 fastText 的 100 维词向量初始化 embedding 层,能带来 1%~3% 的准确率提升。初始化方式是在构建模型后手动替换 embedding 权重:
pretrained = torch.randn(len(vocab), 100) model.embedding.weight.data.copy_(pretrained)注意不要覆盖<pad>和<unk>的权重,保持它们为零向量。如果你用的是中文数据集,情况稍有不同:中文没有天然空格分词,需要先用 jieba 分词再走相同的流程,词表大小会更大,max_len可以适当减小,因为中文的信息密度比英文高,500 个字符足够覆盖大多数评论。
最后说一个实战教训:不要把验证集准确率的提升押在随机调参上。我曾经把隐藏层从 128 调到 256,跑了一晚上,准确率只提升了 0.3%,但显存占用翻了一倍。真正有效的改进是调整max_len从 200 到 400,准确率直接涨了 3%。后来我养成一个习惯,每次改参数前先想清楚这个参数影响的到底是什么——max_len影响信息完整度,hidden_dim影响模型容量,num_layers影响时间步特征抽象能力——想清楚再改,不要盲目试。希望这个项目的完整流程和这些坑能帮你在情感分析的路上少走一段弯路。
本文还有配套的精品资源,点击获取