
简介基于PyTorch与LSTM构建的文本情感分析实战项目面向希望掌握循环神经网络在自然语言处理中应用的开发者尤其适合作为课程设计、毕业设计或深度学习入门练习。压缩包约83KB包含4个文件1个Python训练脚本、1份Markdown说明文档以及2张结果截图。Python脚本集成了GPU加速逻辑可在支持CUDA的本地或云端环境直接执行Markdown文档说明了运行环境、依赖安装与关键参数配置截图可直观反映训练曲线或分类效果。目前已有212人浏览学习可帮助读者快速复现情感分类流程覆盖语料加载、词汇表构建、词向量映射、LSTM模型定义、训练循环与评估环节并可通过网盘链接获取配套数据集。整体结构精简代码注释清晰便于二次修改并迁移到评论分析、舆情监控等相似文本分类任务也便于初学者逐段理解、动手改造。1. 动手前先看这个LSTM情感分析项目为什么值得跑一遍做文本情感分析很多人第一反应是上BERT但BERT那套显存和数据集要求对刚入门的人来说就是一道坎。这个Pytorch实战项目走的是LSTM这条更基础的路线用PyTorch从头实现一个基于LSTM的情感分类模型配合GPU加速训练压缩包里有完整源代码、说明文档和现成数据集。适合两类人一类是学完PyTorch基础、想知道LSTM在真实文本任务里怎么落地的开发者另一类是想跑通一个完整情感分析流程、再改造成自己业务数据的从业者。它不炫技但把预处理、建模、训练、评估这条链路都串起来了踩坑点也很有代表性。2. 拆开项目来看文件结构、数据集格式与词表构建2.1 压缩包里的文件都负责什么把emotional-analysis-master.zip解压之后先别急着双击运行脚本。建议用tree或文件管理器看一遍目录项目结构其实很简单核心就几个文件。我按实际职责整理成下表方便你对号入座。文件作用LSTM文本情感分析.py主脚本包含数据读取、分词、词表构建、模型定义、训练循环和评估代码README.md使用文档里面有数据集百度网盘链接和提取码以及运行步骤和依赖说明1.png大概率是训练过程中的损失曲线截图用来直观展示loss下降趋势2.png大概率是训练完成后的准确率或预测结果截图用来验证效果我拿到这类项目的第一步永远是先打开README.md看两件事第一是数据集下载链接和放置路径第二是依赖版本。很多翻车都是因为数据集没下对位置或者PyTorch版本不对导致一上来就是FileNotFoundError或者ImportError。1.png和2.png不用太当真它们只是作者自己跑的结果换一台机器、换一批数据数字肯定不一样只能当参考不能当期待值。2.2 数据集的真实格式不是所有文本都能直接进LSTM情感分析数据集最常见的格式是CSV或TXT每一行包含两列标签和文本。标签通常用0表示负面、1表示正面有的数据集也会写成neg和pos。这份项目的数据集在百度网盘里下载后大概率是一个压缩包解压出来会有train.csv和test.csv或者只有一份data.csv自己切分。先用pandas读进来看看长什么样import pandas as pd df pd.read_csv(train.csv, sep\t, names[label, text]) print(df.head()) print(df[label].value_counts())逻辑说明这里手动指定了sep\t和names[label, text]是因为很多中文情感分析数据集是TSV格式而且是labelTABtext这样排列的。如果压缩包里的文件是普通CSV逗号分隔把sep改成,即可。value_counts()用来检查类别分布如果两个类别的数量差距过大后面训练时要考虑加权重或过采样否则模型会严重偏向多数类这是情感分析里最常见的“假准确率”来源。接下来做文本清洗和分词。中文文本不能像英文那样直接按空格split必须用分词工具切词否则LSTM看到的输入粒度完全是错的。常用做法是jiebaimport jieba def tokenize(text): # 去掉多余空白避免空词进入词表 text .join(text.split()) return list(jieba.cut(text)) df[tokens] df[text].apply(tokenize) print(df[tokens].head())参数说明jieba.cut返回一个生成器要转成list才能存进DataFrame。text.split()这一步会先去掉换行和重复空格防止切出这种空token。如果你手上的数据是英文就不用jieba直接用text.lower().split()就好。但这份项目面向的是中文场景分词这步不能省。2.3 词表构建与padding模型输入维度的第一步LSTM吃的是整数序列不是字符串。所以要把每个token映射成一个整数ID并限制词表大小防止模型参数爆炸。我一般会保留出现频率最高的前N个词剩下的都映射到unkfrom collections import Counter def build_vocab(token_lists, max_size5000): counter Counter() for tokens in token_lists: counter.update(tokens) vocab {pad: 0, unk: 1} for word, _ in counter.most_common(max_size): vocab[word] len(vocab) return vocab vocab build_vocab(df[tokens], max_size5000) def encode(tokens, vocab, max_len100): ids [vocab.get(t, vocab[unk]) for t in tokens] # 超过max_len的截断不足的后面由pad_sequence补 return ids[:max_len]逻辑说明pad固定为0unk固定为1这是PyTorch里很常见的约定。padding_idx0告诉Embedding层所有ID为0的位置不参与梯度更新这样padding不会引入噪声。max_size5000是词表上限如果数据集不大5000通常够用如果语料很杂可以调到10000但要注意Embedding层会多出很多参数。接下来把数据包装成Dataset并在collate_fn里做padding。关键点是不同句子的长度不一样必须统一成同一个batch内的最长长度from torch.utils.data import Dataset, DataLoader from torch.nn.utils.rnn import pad_sequence class SentimentDataset(Dataset): def __init__(self, texts, labels, vocab, max_len): self.data [encode(t, vocab, max_len) for t in texts] self.labels labels def __len__(self): return len(self.data) def __getitem__(self, idx): return torch.tensor(self.data[idx]), torch.tensor(self.labels[idx]) def collate_batch(batch): texts, labels zip(*batch) texts_pad pad_sequence(texts, batch_firstTrue, padding_value0) return texts_pad, torch.tensor(labels) train_loader DataLoader(SentimentDataset(df[tokens], df[label], vocab, max_len100), batch_size64, shuffleTrue, collate_fncollate_batch)参数说明pad_sequence会把一个batch里的序列按最长那条的长度补齐padding_value0和我们词表里的padID一致。batch_firstTrue之后出来的形状是[batch, seq_len]后面的LSTM层也保持batch_firstTrue就不会乱。max_len100表示单条文本最多保留100个token太长截掉太短padding。这个数值可以按数据情况调整如果句子普遍长设150也行但设太大会让GPU显存压力上升。3. 跑通GPU加速环境配置、模型定义与训练循环3.1 PyTorch GPU环境装错版本等于白装这个项目标题里写明“使用GPU加速”所以第一步不是写代码而是确认你的PyTorch真的能调用GPU。很多人用pip默认安装装出来是CPU版跑起来和预期差好几倍还以为是自己代码写得差。先检查CUDA版本再装对应PyTorch。nvcc -V如果nvcc命令找不到说明系统里没装CUDA Toolkit或者没有加入环境变量。在Windows下可以用nvidia-smi查看驱动支持的CUDA版本。假设你的CUDA是11.8创建环境并安装对应的PyTorchconda create -n lstm python3.8 conda activate lstm pip install torch2.1.2 --index-url https://download.pytorch.org/whl/cu118参数说明cu118对应CUDA 11.8cu121对应12.1cu124对应12.4。安装前建议先用nvidia-smi看驱动支持的最高版本再选一个不超过它的CUDA版本。如果选高了PyTorch可能能装上但运行时驱动不认直接报CUDA driver version is insufficient。装完验证一下python -c import torch; print(torch.__version__); print(torch.cuda.is_available()); print(torch.cuda.get_device_name(0))如果输出True和你的显卡型号说明GPU加速这条路通了。如果输出False不要急去检查是不是装成了CPU版或者CUDA版本不匹配。这步是后面所有工作的地基地基歪了后面全是玄学问题。3.2 模型定义Embedding LSTM 全连接的标准组合情感分析本质上是一个序列分类任务输入一串token ID输出一个类别概率分布。这个项目用的是最经典的LSTM分类结构Embedding把整数ID变成稠密向量LSTM读取整个序列并产出最终隐藏状态最后接一个全连接层分类。模型定义代码大致如下import torch import torch.nn as nn class LSTMClassifier(nn.Module): def __init__(self, vocab_size, embedding_dim128, hidden_size256, num_layers2, num_classes2, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idx0) self.lstm nn.LSTM(embedding_dim, hidden_size, num_layers, batch_firstTrue, dropoutdropout if num_layers 1 else 0) self.fc nn.Linear(hidden_size, num_classes) self.dropout nn.Dropout(dropout) def forward(self, x): emb self.embedding(x) # [B, L, embedding_dim] out, (h_n, c_n) self.lstm(emb) # out: [B, L, hidden_size] last h_n[-1] # 取最后一层最后一个时间步的隐藏状态 logits self.fc(self.dropout(last)) return logits逻辑说明h_n的形状是[num_layers, B, hidden_size]h_n[-1]取的是最后一层最后一个时间步的隐藏状态。这里不取out[:, -1, :]原因是对于LSTM来说h_n[-1]和out[:, -1, :]在非双向时是一致的但用h_n更明确地表达了“把整句话压缩成一个向量”的语义。dropout加在LSTM输出和全连接之间用于缓解过拟合。注意LSTM内部的dropout只在层数大于1时才生效单层时传dropout0。参数说明embedding_dim128是词向量的维度越小训练越快但语义表达能力弱hidden_size256是LSTM隐层节点数越大模型容量越大但也越容易过拟合和吃显存num_layers2表示堆叠两层LSTM适当增加层数能捕捉更抽象的特征但层数超过3通常收益很小。num_classes2对应正负两类。3.3 训练循环从CPU迁移到GPU的完整写法模型定义完之后写训练循环。核心就三件事把模型和数据搬到device、前向传播算loss、反向传播更新参数。代码如下from torch.optim import Adam device torch.device(cuda if torch.cuda.is_available() else cpu) model LSTMClassifier(vocab_sizelen(vocab)) model model.to(device) criterion nn.CrossEntropyLoss() optimizer Adam(model.parameters(), lr1e-3) for epoch in range(10): model.train() total_loss 0.0 for batch_x, batch_y in train_loader: batch_x batch_x.to(device) # [B, L] - GPU batch_y batch_y.to(device) optimizer.zero_grad() logits model(batch_x) # [B, num_classes] loss criterion(logits, batch_y) loss.backward() optimizer.step() total_loss loss.item() avg_loss total_loss / len(train_loader) print(fepoch: {epoch1}, loss: {avg_loss:.4f})逻辑说明batch_x.to(device)把数据从CPU内存复制到GPU显存这是加速的关键。未调用.to(device)时数据在CPU模型在GPU前向传播会报错反过来也同理。optimizer.zero_grad()每次训练前清空梯度否则梯度会累积。CrossEntropyLoss内部自带softmax所以模型输出层不需要额外加softmax直接拿logits算loss就行。参数说明lr1e-3是Adam的常见初始学习率。batch_size在DataLoader里设的64显存不够就调到32或16。epoch10对小数据集可能已经足够但建议每轮打印loss看到loss连续两轮不降就考虑提早停或调低学习率。如果torch.cuda.is_available()返回False代码会回退到CPU跑但速度差距很明显——我在一张老显卡上训练10轮只要几十秒CPU要跑小十分钟这就是GPU加速的意义。4. 避坑指南跑这个项目最常见的五个坑这个项目整体流程不复杂但我在帮别人调试类似代码时几乎每次都会碰到下面几个问题。每个都是真实踩过的坑按“现象 → 原因 → 解决”写给你省得你再走一遍弯路。4.1 CUDA不可用GPU加速完全没生效现象torch.cuda.is_available()返回False代码倒是能跑但速度慢得离谱训练一个epoch要等好几分钟。原因最常见的是用pip install torch默认装了CPU版。PyTorch官方pip包的默认版本在部分平台是CPU版必须指定--index-url带cu118或cu121的源才能装上CUDA版。另一种情况是系统CUDA版本和PyTorch要求的版本不匹配驱动太老。解决先跑nvidia-smi看驱动支持的CUDA版本再重新安装匹配的PyTorch。装完一定执行python -c import torch; print(torch.cuda.is_available())确认输出True。不要把is_available()写死成True否则在别人机器上直接崩。4.2 数据集路径不对FileNotFoundError现象运行pd.read_csv(train.csv)时直接报FileNotFoundError或者程序能启动但训练时加载不到数据导致后面的代码连环报错。原因数据集压缩包没有解压到脚本同级目录或者README.md里写的路径是作者本地的绝对路径比如D:\data\换到你的电脑上自然找不到。还有人是直接双击运行.py文件工作目录可能不在脚本所在目录。解决把数据集解压到和LSTM文本情感分析.py同一个文件夹下并在读取文件时改成相对路径或显式绝对路径。我习惯在脚本开头加一句import os os.chdir(os.path.dirname(os.path.abspath(__file__)))这句能让脚本不管从哪里启动都把工作目录切到脚本自己所在的目录路径问题能一次性解决大半。4.3 词表大小和embedding维度不匹配训练中断现象训练到一半抛IndexError: index out of range in self或者模型能加载但准确率极低像猜的一样。原因词表构建时max_size设得比实际词数小导致某些token的ID超出了模型Embedding层的行数。比如词表有6000个词但传给nn.Embedding的vocab_size写死了5000那些ID大于等于5000的token一进Embedding就崩。还有种情况是训练和预测时用了不同的词表ID映射完全对不上。解决模型实例化时一定写LSTMClassifier(vocab_sizelen(vocab))不要手填数字。如果加载了别人训练好的模型要确保加载模型时传入的vocab_size和训练时一致否则权重维度不匹配会直接报错。词表构建完成后最好打印一下len(vocab)心里有个数。4.4 训练loss不降甚至变成nan现象loss一开始就在1.5左右晃好几轮不降或者某个batch之后loss突然变成nan后面全部无效。原因学习率太大是头号嫌疑。Adam默认lr1e-3对大多数任务可行但如果数据量小、词表稀疏这个学习率会让loss震荡甚至发散。另一个常见原因是标签不是从0开始的连续整数比如标签是1和2而CrossEntropyLoss要求类别从0开始。数据里有脏数据导致loss为nan的情况也见过比如文本列空值导致分词后全是unk整个句子没有任何有效信息。解决先把标签打印出来确认是{0, 1}。然后把学习率降到5e-4或1e-4试试。在loss.backward()之后加一句梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0)梯度裁剪能防止LSTM训练后期梯度爆炸是处理nan最有效的保险手段之一。我一般默认就加上几乎不占计算成本。4.5 CPU跑太慢显存不够现象训练时GPU占用率不高但显存直接CUDA out of memory或者没有GPU的同学在CPU上跑一个epoch要喝杯咖啡才能等完。原因显存不足通常是batch_size太大、max_len太长、hidden_size太大三者叠加。LSTM的显存占用会随序列长度线性增长因为要保存每个时间步的中间状态用于反向传播。有人说这玩意是“黑匣子”其实它的显存消耗规则很清楚batch大小 × 序列长度 × hidden_size × 层数四个数乘起来就是主要占用。解决CUDA out of memory时先把batch_size降到32或16大多数场景都能解决。如果还不行把max_len从100降到50很多短文本50个token足够表达核心语义了。再不行就调低hidden_size到128。CPU跑得慢没有太好办法要么换GPU要么降低数据集规模要么接受等待。有一点很实用设置torch.backends.cudnn.benchmark True在输入长度固定的情况下能让CUDA自动选择最合适的卷积/循环算法能省一点时间。5. 看效果和上线前的工作评估指标、模型保存与单句预测5.1 准确率、召回率还是混淆矩阵训练完不是直接说“完事了”至少要看看模型在验证集上的表现。二分类任务里准确率是最直观但不是唯一的指标。如果你的数据集类别不平衡比如 90% 都是正面评论模型全部预测正面也能有 90% 准确率但这种模型没有意义。所以看准确率的同时也要看混淆矩阵。先写一段评估代码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) pred logits.argmax(dim1) correct (pred batch_y).sum().item() total batch_y.size(0) print(faccuracy: {correct / total:.4f})逻辑说明model.eval()会关闭dropout和batch normalization在训练时的随机行为保证预测结果稳定。torch.no_grad()告诉PyTorch不要记录梯度既省显存也能提升推理速度。argmax(dim1)取每个样本概率最大的类别索引也就是模型的预测标签。如果想看混淆矩阵可以用sklearn.metrics.confusion_matrix一行代码from sklearn.metrics import confusion_matrix, classification_report y_true, y_pred [], [] 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) pred logits.argmax(dim1).cpu().tolist() y_pred.extend(pred) y_true.extend(batch_y.cpu().tolist()) print(confusion_matrix(y_true, y_pred)) print(classification_report(y_true, y_pred, target_names[negative, positive]))注意不要忘了.cpu().tolist()因为batch_y在GPU上不能直接传给sklearn。分类报告里有精准率、召回率、F1比单独看准确率更有说服力。5.2 保存模型权重和词表别只存模型训练好的模型必须保存下来否则下次预测还得重新训练。PyTorch里推荐保存state_dict而不是整个模型对象因为这样更轻量也便于重建模型结构。torch.save(model.state_dict(), lstm_model.pth) import json with open(vocab.json, w, encodingutf-8) as f: json.dump(vocab, f)逻辑说明model.state_dict()保存的是所有可学习参数的权重字典加载时需要先实例化一个结构完全一致的模型再调用load_state_dict。vocab.json必须和模型一起保存因为预测新文本时要把新分词结果通过同一个词表映射成ID。如果词表丢失你等于没有了解码器再好的模型权重也用不起来。这是很多项目的“后悔药”但最好一开始就做好别等丢数据了才想起来。5.3 单条文本预测的完整流程写一个predict函数把分词、编码、模型推理全部串起来def predict(text): tokens list(jieba.cut( .join(text.split()))) ids encode(tokens, vocab, max_len100) x torch.tensor([ids], dtypetorch.long).to(device) model.eval() with torch.no_grad(): logits model(x) pred logits.argmax(dim1).item() return 正面 if pred 1 else 负面 print(predict(这家店的牛肉面味道非常好下次还会再来)) print(predict(等了四十分钟才上菜服务员态度也很差))逻辑说明encode函数要和训练时保持一致max_len也必须是同一个值否则padding长度不同模型看到的时间步数量就不一样。torch.tensor([ids])外面再套一层中括号是为了制造一个batch维度因为模型输入要求形状[B, L]这里B1。model.eval()在每次预测都要加上如果忘了模型处于训练模式dropout还会随机丢弃信息导致同样一句话每次预测结果都不一样这种问题排查起来非常隐蔽。6. 继续改进双向LSTM、注意力机制与优化器调参6.1 从单向到双向hidden_size的翻倍陷阱把单向LSTM改成双向代码上几乎就是加一个参数self.lstm nn.LSTM(embedding_dim, hidden_size, num_layers, batch_firstTrue, bidirectionalTrue)但注意双向LSTM的输出维度会翻倍。前向层和反向层的输出在最后一维拼接所以h_n[-1]的形状变成[B, hidden_size * 2]。全连接层的输入维度必须同步改成hidden_size * 2否则维度对不上直接报错。这也是手写LSTM分类模型最容易漏的地方。改双向后模型能同时看到句子前文和后文的上下文对情感这种整体性判断通常有1到3个百分点的收益。6.2 给模型加一个简单的注意力层LSTM的记忆再强也有遗忘问题。一个轻量级的做法是不只看最后一个时间步的输出而是把整个序列的输出按权重加权求和这个权重由模型自己学出来。实现一个简单的注意力层class Attention(nn.Module): def __init__(self, hidden_size): super().__init__() self.w nn.Linear(hidden_size, hidden_size) self.v nn.Linear(hidden_size, 1) def forward(self, lstm_out): # lstm_out: [B, L, hidden_size] score self.v(torch.tanh(self.w(lstm_out))).squeeze(-1) # [B, L] weight torch.softmax(score, dim1) context torch.bmm(weight.unsqueeze(1), lstm_out).squeeze(1) # [B, hidden_size] return context使用时把模型forward里的last h_n[-1]换成out, (h_n, c_n) self.lstm(emb) context self.attention(out) logits self.fc(self.dropout(context))注意力层的核心是torch.softmax(score, dim1)它学习一个和序列长度同维度的重要性权重越重要的位置权重越大。加了注意力之后模型对“虽然…但是…”这类转折句子的判断会更稳因为能聚焦到转折词后面的关键内容。6.3 换优化器与学习率调度的实际收益Adam很省心但换AdamW并配合学习率衰减往往能再涨一点。AdamW把权重衰减和梯度更新解耦对Transformer和LSTM都更友好。from torch.optim import AdamW from torch.optim.lr_scheduler import ReduceLROnPlateau optimizer AdamW(model.parameters(), lr2e-3, weight_decay1e-4) scheduler ReduceLROnPlateau(optimizer, modemin, factor0.5, patience2)在每个epoch结束后用验证集loss去驱动调度器val_loss compute_val_loss(model, val_loader) scheduler.step(val_loss)factor0.5表示每次减半patience2表示连续两轮验证loss不降才减学习率。我试过同样的数据集固定学习率训练十轮准确率卡在86%左右换AdamW加ReduceLROnPlateau能到88%到89%。这不是玄学而是学习率后期过大导致模型在最优解附近震荡衰减一下反而能更精准地落到局部最优。从那以后我每次跑这种文本分类项目都会先花十分钟把词表、max_len、类别分布打印一遍再开始训练能省下大半节debug时间希望帮到你。本文还有配套的精品资源点击获取