简介面向自然语言处理入门者的PyTorch LSTM情感分析实战项目重点演示如何借助GPU加速完成文本情感分类任务适合正在学习深度学习、循环神经网络或准备相关课程设计的开发者和高校学生。压缩包共4个文件包含1个LSTM情感分析Python脚本、1个Markdown说明文档以及2张运行效果截图整体仅83KB结构精简下载后即可结合网盘数据集部署运行。目前已有212人学习使用属于热度尚可的参考实现。代码覆盖数据加载、词嵌入、LSTM建模、训练评估与GPU设备调用等关键环节并配有运行截图和文档说明便于对照理解输出结果与参数调整思路数据集通过百度网盘链接单独提供按README指引即可完整复现可用于入门实验也可作为课程作业或毕业设计中情感分析模块的基础模板。1. 别急着调参先跑通一个能用的 LSTM 情感分析做情感分析很多人上来就怼 BERT显存爆了不说训练一轮够吃顿午饭。其实绝大多数场景——电商评论、舆情监控、客服工单——用 LSTM 加 GloVe 或者自训练的 Embedding完全够用而且训练快、部署轻、坑少。这份 Pytorch 实战源码给我的感觉就是「把路铺好了」模型结构、训练循环、数据集、GPU 加速全都有不是那种只有半个骨架的 demo。你要做的事情很简单把环境配好把数据路径改对然后跑起来看 loss 曲线。我不太喜欢一上来就抄代码先搞清楚它解决什么问题输入是一段文本输出是一个二分类正向/负向概率。LSTM 在这里的作用是把不定长的词序列压成一个有语义的向量最后接全连接层出分类。整个项目代码不长但该有的都有——数据清洗、 vocab 构建、embedding、LSTM 层、dropout、训练循环、评估。适合两类人刚学完 Pytorch 基础想做一个完整项目的学生和需要用深度学习做文本分类但不想碰 BERT 的工程师。接下来我从环境准备开始一步步拆。2. 环境与数据GPU 加速的前提是 CUDA 版本和 Pytorch 必须匹配2.1 先看硬件和驱动再装 Pytorch这个项目标注了 GPU 加速所以第一步不是 pip install而是确认你的显卡能干活。在终端跑nvidia-smi看右上角 CUDA Version这是驱动支持的最高版本不是说你必须装那么高而是告诉你上限。比如显示 CUDA 12.1那装 cu121 的 Pytorch 没问题如果显示 11.8就老实装 cu118。nvidia-smi # 输出示例Driver Version: 545.84 CUDA Version: 12.1我一般用 conda 建独立环境避免把系统 Python 搞乱。Pytorch 的安装命令去官网 PyTorch Get Started 页面生成别自己猜版本。这里给一个 CUDA 12.1 的示例conda create -n sentiment python3.9 conda activate sentiment pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121装完验证一下 GPU 是否真的可用import torch print(torch.__version__) print(torch.cuda.is_available()) print(torch.cuda.get_device_name(0))如果torch.cuda.is_available()返回 False后面代码里所有.to(device)都不会报错但会全部跑在 CPU 上训练速度慢到怀疑人生。别急着往下走先把这个验证过了这一步花不了五分钟能省后面两小时。2.2 数据集结构文本和标签怎么对应这个项目自带了一份数据集注意它用的不是网盘里那个大文件而是代码目录下直接能读的小样本数据。网盘里的是完整版路径在 README 里有写我自己测试时先把完整版拷到项目根目录然后改代码里的路径就行。数据格式很朴素每一行是一段文本标签要么在文件名里要么在行尾用 tab 分隔。我用的是train.txt和test.txt分别存放训练和测试集每行格式为「文本\t标签」标签 0 表示负向、1 表示正向。加载方式用 Pytorch 的Dataset封装from torch.utils.data import Dataset class SentimentDataset(Dataset): def __init__(self, filepath): self.samples [] with open(filepath, r, encodingutf-8) as f: for line in f: text, label line.strip().split(\t) self.samples.append((text, int(label))) def __len__(self): return len(self.samples) def __getitem__(self, idx): text, label self.samples[idx] return text, label这里有个关键点__getitem__返回的是原始字符串不是 tensor。为什么因为文本要经过 vocab 转成索引序列这个转换放到collate_fn里做更合适——可以在一个 batch 内做 padding效率更高。如果你在__getitem__里提前转padding 的长度就得全局统计麻烦不说还可能把短文本 pad 得很长浪费显存。2.3 词表构建最低频次过滤是个好习惯数据加载只是第一步真正影响效果的是 vocab 怎么建。最粗暴的做法是把所有词都收进词表但这样词表动辄几万甚至几十万embedding 层参数爆炸。这个项目里做了一个 min_count 过滤比如出现次数小于 2 的词直接丢进 unk。这一步很有必要生僻词学不到有意义的向量还白占显存。from collections import Counter def build_vocab(texts, min_count2): counter Counter() for text in texts: for word in text.split(): counter[word] 1 vocab {pad: 0, unk: 1} for word, count in counter.items(): if count min_count: vocab[word] len(vocab) return vocab注意pad和unk固定占 0 和 1 两个位置这是惯例。pad用于 padding 补齐 batch 内序列长度unk用于替换词表外的词。训练时unk词会被映射到同一个 embedding 向量这个向量会慢慢学到「未知词」的语义相当于一个兜底。min_count 设得越高词表越小训练越快但信息损失也越大。这份数据集我试下来min_count2 或 3 效果都不错再高收益就明显下降了。3. 数据管道Tokenizer、Padding 和 DataLoader 的配合3.1 文本转索引序列embedding 层只能吃整数有了词表接下来把文本变成整数序列。这个步骤几乎每个 Pytorch 项目都一样但你封装得好不好直接影响后面调试效率。我习惯把 tokenize 和 lookup 合成一个函数输入一段文本输出一个索引列表长度不定padding 留到 batch 内处理。def text_to_sequence(text, vocab): words text.split() seq [] for word in words: if word in vocab: seq.append(vocab[word]) else: seq.append(vocab[unk]) return seq这里用.split()做分词是因为这份数据集本身就是英文空格分词足够如果是中文就得换 jieba。不要在一个英文项目里引入中文分词会破坏 pipeline还慢。分词方式要和训练语料保持一致训练时怎么切预测时就怎么切这个一致性决定了上线后的效果。对中文来说常见做法是先加载一个停用词表在.split()之前过滤掉「的、了、吗」这类高频但无意义的词。停用词表网上随手能下但要看领域——电商评论和新闻文本的停用词集并不完全重叠最好根据你的样本人工补几个词。3.2 collate_fn 里的动态 paddingPytorch 的DataLoader默认会把样本拼成 tensor但文本序列长度不一直接拼会报错。解决办法是自定义collate_fn在一个 batch 内按最长序列 pad同时生成 attention mask 或者 length 列表方便后面 pack_padded_sequence 用。def collate_fn(batch): texts, labels zip(*batch) sequences [text_to_sequence(text, vocab) for text in texts] lengths [len(seq) for seq in sequences] max_len max(lengths) padded_seqs [] for seq in sequences: padded seq [vocab[pad]] * (max_len - len(seq)) padded_seqs.append(padded) return torch.tensor(padded_seqs), torch.tensor(labels), torch.tensor(lengths)注意到返回了三个东西padded 序列、标签、还有每个样本的真实长度。第三个很关键后面如果要用 LSTM 的pack_padded_sequence这个 lengths 就是必需品而且它必须是降序排列的。很多人在这一步翻车pack_padded_sequence要求输入序列按长度降序DataLoader 默认不打乱 batch 内部顺序所以必须在collate_fn里按 lengths 降序重排否则模型不报错但结果完全不对。3.3 DataLoader 参数怎么设from torch.utils.data import DataLoader train_loader DataLoader( datasettrain_dataset, batch_size64, shuffleTrue, collate_fncollate_fn, num_workers2, drop_lastTrue )batch_size 的选择要看显存。这个项目的数据集不大我实测 64 在 6G 显存上完全跑得动。如果显存紧张可以把 batch_size 降到 32代价是训练步数翻倍但模型效果差别不大。shuffleTrue只在训练集上开测试集不要 shuffle否则评估指标会抖动。num_workers根据 CPU 核数设Windows 上经常因为多进程启动慢报错可以设成 0 先跑通再说。drop_lastTrue是防止最后一个 batch 不足 64 条导致 BatchNorm 层报错虽然 LSTM 没这个问题但养成好习惯总没错。4. 核心模型Embedding LSTM 全连接别小看 dropout 的位置4.1 模型结构的定义和每层的形状变化看这份源码的模型定义结构不复杂但每一层都有讲究。先是nn.Embedding把词索引映射成稠密向量然后 LSTM 层提取序列特征最后全连接输出二分类 logits。我一开始以为 LSTM 的输出要取最后一个时间步的 hidden state后来发现代码用的是最后一步的 output 也能跑。这两种做法在单向 LSTM 下等价双向就不一样了一会儿说。import torch.nn as nn class LSTMClassifier(nn.Module): def __init__(self, vocab_size, embedding_dim, hidden_dim, num_layers, num_classes, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idx0) self.lstm nn.LSTM( embedding_dim, hidden_dim, num_layersnum_layers, batch_firstTrue, dropoutdropout if num_layers 1 else 0, bidirectionalFalse ) self.dropout nn.Dropout(dropout) self.fc nn.Linear(hidden_dim, num_classes) def forward(self, x, lengthsNone): emb self.embedding(x) # (batch, seq_len, embedding_dim) if lengths is not None: packed nn.utils.rnn.pack_padded_sequence(emb, lengths, batch_firstTrue, enforce_sortedFalse) packed_output, (hn, cn) self.lstm(packed) output, _ nn.utils.rnn.pad_packed_sequence(packed_output, batch_firstTrue) else: output, (hn, cn) self.lstm(emb) last_hidden hn[-1] # 取最后一层的 hidden state logits self.fc(self.dropout(last_hidden)) return logits每个参数都值得说两句padding_idx0告诉 embedding 层索引 0也就是pad对应的向量永远不参与梯度更新始终是 0。这样 padding 位置不会干扰模型学习否则模型要额外花精力去忽略 pad 位。batch_firstTrue让输入形状是(batch, seq_len, embedding_dim)而不是 Pytorch 默认的(seq_len, batch, embedding_dim)。前者符合直觉不容易在 reshape 时算错。num_layers这个项目源码里写的是 2 层。加深层数能捕捉更复杂的语义但对情感分类这种任务2 层基本到头再深就过拟合了。dropout 加在了 LSTM 层内多层时和全连接之前。LSTM 层内 dropout 作用于各层之间的输出全连接前那个 dropout 则是防止最后一层过拟合。两个都开用 0.5 的丢弃率。4.2 取哪一个 hidden state单向取 hn[-1]双向要拼接上面代码里last_hidden hn[-1]这个hn的形状是(num_layers, batch, hidden_dim)。取hn[-1]就是取最后一层 LSTM 的最终记忆它综合了整个序列的信息。单向 LSTM 这么取没有任何问题。但如果你把bidirectionalTrue改成双向就要注意了hn的形状变成(num_layers * 2, batch, hidden_dim)前 num_layers 行是正向的隐状态后 num_layers 行是反向的。你如果想要「正向最后一个词」和「反向第一个词」的拼接结果应该取hn[-2]和hn[-1]拼起来维度变成2 * hidden_dim对应的全连接层输入维度也要翻倍。# 双向 LSTM 取 hidden state 的正确姿势 hn_forward hn[-2] # 最后一层正向 hn_backward hn[-1] # 最后一层反向 last_hidden torch.cat((hn_forward, hn_backward), dim1) # (batch, 2*hidden_dim)坑在哪里如果你在双向模型里仍然用hn[-1]你取到的只有反向的最后一层信息少了一半模型精度会莫名其妙掉几个点而且不容易排查——loss 还在降但永远达不到应有的水平。4.3 训练循环的固定套路和 loss 计算训练循环是 Pytorch 的固定套路清零梯度、前向传播、算 loss、反向传播、更新参数。但有几个细节新手总是漏。第一个是optimizer.zero_grad()必须在每次迭代开头调用不然梯度累加loss 会震荡第二个是 loss 的输入必须是 logits 和标签不能先过 softmax 再算nn.CrossEntropyLoss内部已经做了 softmax你多过一道就是错的。import torch.optim as optim model LSTMClassifier( vocab_sizelen(vocab), embedding_dim128, hidden_dim128, num_layers2, num_classes2, dropout0.5 ).to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr1e-3) def train_one_epoch(model, train_loader, criterion, optimizer, device): model.train() total_loss 0 for texts, labels, lengths in train_loader: texts texts.to(device) labels labels.to(device) lengths lengths.to(device) optimizer.zero_grad() logits model(texts, lengths) loss criterion(logits, labels) loss.backward() optimizer.step() total_loss loss.item() return total_loss / len(train_loader).to(device)这一步不能省。这个项目标了 GPU 加速但你得主动把数据和模型搬到 GPU 上Pytorch 不会帮你自动搬。注意 lengths 也要.to(device)——pack_padded_sequence在 GPU 上要求 lengths 也在 GPU。这个不起眼的小地方错过就报CUDA error: device-side assert triggered而且报错信息很不直观。4.4 评估函数准确率不是唯一指标项目的测试脚本里除了准确率其实更值得关注的是混淆矩阵或者召回率。情感分析这种二分类任务类别不平衡很常见——比如 90% 的评论是正向模型全猜正向就有 90% 准确率但这个模型没有实际价值。from sklearn.metrics import classification_report def evaluate(model, test_loader, device): model.eval() all_preds [] all_labels [] with torch.no_grad(): for texts, labels, lengths in test_loader: texts texts.to(device) lengths lengths.to(device) logits model(texts, lengths) preds torch.argmax(logits, dim1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.numpy()) print(classification_report(all_labels, all_preds, target_names[negative, positive]))model.eval()和with torch.no_grad()是两个不同的东西都要用。前者关闭 dropout 和 BatchNorm 的训练行为后者告诉 Pytorch 不需要记录梯度——推理时的中间变量不需要保存省显存也能提速。如果忘了model.eval()dropout 在测试时仍然随机丢弃每次评估结果都不一样看起来像模型不稳定。5. 避坑指南我在这份代码上踩过的五个具体问题5.1 现象loss 为 NaN训练到第十几个 batch 直接崩原因学习率太大或者 embedding 层中有词索引越界。我排查过自己的情况是 vocab 构建时漏了unknown词的处理测试集里有一个词在词表中不存在text_to_sequence返回空列表然后 padding 后全是 0LSTM 输入全零向量梯度爆炸。解决在text_to_sequence里加入unk兜底同时把lr1e-3降为5e-4。如果还 NaN在训练循环里加一个梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0)5.2 现象用了 GPU但训练速度跟 CPU 一样慢原因模型和数据都在 GPU 上但num_workers0导致数据加载成了瓶颈或者文本序列太长大多数时间花在 padding 上真正的计算占比很低。解决用collate_fn输出 lengths并在模型内使用pack_padded_sequence跳过 pad 位的计算。这是最有效的优化手段尤其在长文本场景下速度提升接近 3 倍。另外把num_workers设为 4Linux或 2Windows减轻主进程负担。5.3 现象测试集准确率比训练集高而且是高得离谱原因测试集和训练集分布不一致或者文本预处理时测试集没有走和训练集完全相同的 pipeline。比如训练时做了小写化测试时忘了模型面对「The」和「the」当作两个词泛化能力被高估。解决把文本清洗逻辑提取成同一个函数训练和测试共用。我在这个项目里把所有预处理都收到独立的preprocess.py保证训练、验证、上线预测永远走同一段逻辑杜绝不一致。5.4 现象保存的模型加载后 loss 不降甚至预测结果全是一个类原因保存时只存了model.state_dict()加载的时候初始化了一个新的模型对象但 vocab 不一致。如果加载时的词表和保存时的词表对不上embedding 层的索引错位模型等于在胡说。解决保存时把 vocab 一起存为 json加载时先用它重建模型再用load_state_dict。不要只记权重vocab 和模型参数是同生共死的。import json # 保存 torch.save(model.state_dict(), model.pt) with open(vocab.json, w, encodingutf-8) as f: json.dump(vocab, f) # 加载 with open(vocab.json, r, encodingutf-8) as f: vocab json.load(f) model LSTMClassifier(len(vocab), 128, 128, 2, 2, 0.5).to(device) model.load_state_dict(torch.load(model.pt)) model.eval()5.5 现象推理单条样本时报 shape 错误原因预测函数只传了一个句子没有 batch 维度。Pytorch 的 LSTM 期望输入至少是二维的单个序列要用unsqueeze(0)扩充成(1, seq_len)。解决手动加 batch 维度并构造 lengths 张量def predict(text, model, vocab, device): seq text_to_sequence(text, vocab) seq_tensor torch.tensor([seq]).to(device) # (1, seq_len) lengths torch.tensor([len(seq)]).to(device) logits model(seq_tensor, lengths) pred torch.argmax(logits, dim1).item() return positive if pred 1 else negative6. 进阶把训练好的模型封装成可复用的推理接口6.1 用 torch.save 存完整快照还是只存权重项目对训练好的模型怎么复用没写太多我一般建议存两个文件model.pt存权重vocab.json存词表。torch.save(model.state_dict())只存模型参数体积小加载灵活torch.save(model)把整个模型结构也存了方便是方便但 Pytorch 版本变了加载时会各种兼容问题我不推荐。如果你想把模型接到 Flask 或者 FastAPI 上做服务只存权重反而是最稳的。torch.save(model.state_dict(), lstm_sentiment.pt) torch.save(vocab, vocab.pt)加载的时候先加载 vocab再重建模型结构然后 load 权重。注意一个细节加载后必须调用一次model.eval()。如果你加载完直接 predictdropout 还开着每次预测结果都会不一样这种bug最坑。6.2 推理加速关闭梯度计算 小 batch 攒着推在线推理场景下单个请求一条文本每次都走一遍 Pytorch 的 forward开销其实不小。我通常会把多条请求攒成一个 batch 再推理吞吐量能提升好几倍。下面是一个简单的推理类封装class SentimentService: def __init__(self, model_path, vocab_path, deviceNone): self.device device or (cuda if torch.cuda.is_available() else cpu) self.vocab torch.load(vocab_path) self.model LSTMClassifier( vocab_sizelen(self.vocab), embedding_dim128, hidden_dim128, num_layers2, num_classes2, dropout0 ).to(self.device) self.model.load_state_dict(torch.load(model_path)) self.model.eval() def predict_batch(self, texts): sequences [text_to_sequence(t, self.vocab) for t in texts] lengths torch.tensor([len(s) for s in sequences]).to(self.device) max_len max(lengths.tolist()) padded torch.zeros(len(sequences), max_len, dtypetorch.long).to(self.device) for i, seq in enumerate(sequences): padded[i, :len(seq)] torch.tensor(seq) with torch.no_grad(): logits self.model(padded, lengths) preds torch.argmax(logits, dim1).cpu().numpy() return [positive if p 1 else negative for p in preds]这里有个细节推理时 dropout 设为 0因为不需要随机性。原模型的 dropout 参数只影响训练推理时设为 0 可以省掉一些无效计算。torch.zeros初始化 padding 张量时默认填充的就是 vocab 里pad的索引 0embedding 层会自动把 0 映射成全零向量不需要额外处理。6.3 显存不足时的降级策略这个项目的数据集不大6G 显存的卡基本无压力。但如果你换了更大的数据集或者想在同一个卡上跑别的实验显存不够会直接 OOM。常见做法是把 batch_size 从 64 降到 32 或 16同时把embedding_dim从 128 降到 100精度损失很小但显存占用能少三分之一。另一个不牺牲效果的做法是开 Pytorch 的gradient_accumulation每 2 个 batch 更新一次梯度用时间换显存accumulation_steps 2 for step, (texts, labels, lengths) in enumerate(train_loader): logits model(texts, lengths) loss criterion(logits, labels) loss loss / accumulation_steps loss.backward() if (step 1) % accumulation_steps 0: optimizer.step() optimizer.zero_grad()注意每次要除以 accumulation_steps否则梯度累积后数值偏大等效于学率被放大实验对比会失真。我从做这个项目之后凡是遇到显存不足第一反应不是换小模型而是先检查是不是 batch_size 或序列长度在作怪——大部分时候减个 batch_size 就解决了。从那以后我每次用这份代码第一件事永远是先跑通一个 10 行的迷你数据确认模型、加载、推理全链路是通的再上完整数据集。这个习惯帮我省了至少三次「训练到一半才发现数据处理错」的返工。希望帮到你。本文还有配套的精品资源点击获取 SEO 优化官网定制响应式建站教育培训建站