尧图精选

中文情感分析实战:基于PyTorch LSTM的评论分类全流程

🕒 发布时间:2026/9/12 2:15:59 📁 来源:尧图网络
简介面向深度学习入门者与 NLP 学习者的 LSTM 情感分析实战项目基于 PyTorch 框架实现使用 GPU 加速训练帮助理解文本分类与情感极性识别任务适用于教学演示、课程作业或个人实践尤其适合刚接触序列模型的学习者。资源包仅 83KB共 4 个文件包含 1 个 Python 源代码、1 个 Markdown 说明文档和 2 张 PNG 示意图源代码承担核心流程文档补充运行说明图片辅助展示模型结构或训练结果整体目录简洁便于定位与复用。已有 212 人学习下载。通过这份源码可快速搭建一条可运行的文本情感分析流程从文本编码、Embedding 层到 LSTM 单元与分类层都有清晰实现适合学习 RNN/LSTM 的核心机制配套 README 对运行环境和关键参数给出说明两张示意图有助于理解整体框架。动手运行后可掌握序列建模基本思路并可将模型迁移到评论分类、舆论分析等类似场景。1. 为什么中文情感分析选 PyTorch LSTM 而不是规则词典做评论情感分析最直接的反应可能是先上情感词典把“好”“差”“垃圾”拉一张词表看句子命中几个正向词、几个负向词得分高就判积极。这个方案在短评上能跑到七八成准确率但遇到“不是不好”这种双层否定或者“屏幕大但电池不耐用”这种转折句词典就束手无策了。LSTM 的价值在于它把整句话按顺序读进去遗忘门自己决定哪部分信息要留下、哪部分放弃语义不再依赖人工规则。这个项目就是一个能直接跑通的 PyTorch LSTM 情感分析工程数据预处理、词表构建、模型定义、GPU 训练、预测脚本都齐了。适合两类人一是刚学完 PyTorch 基础框架、想看看 RNN 家族怎么落地到真实文本任务的人二是产品侧要快速做一个评论正负向分类基线、后面打算换 Bert 或微调的同学。LSTM 跑一个 epoch 比 Transformer 快一个量级在 GPU 加速下调参成本很低拿它当 baselines 再合适不过。2. 文本清洗与词表构建从评论数据集到 batch 张量2.1 原始数据格式与清洗规则解压 emotional-analysis-master 之后数据通常是 CSV 格式核心列就是文本和标签。项目里标签一般用 0 和 1 表示负向和正向也有按 1 到 5 星标成二分类的版本。先用 pandas 读进来看一眼形状和分布顺便处理缺失值和空白行。import pandas as pd df pd.read_csv(data/comment.csv, encodingutf-8) df.columns [text, label] # 统一列名避免索引地狱 df df.dropna(subset[text]) # 丢掉文本为空的记录 df df[df[text].str.strip() ! ] # 全空格文本也去掉 df[label] df[label].astype(int) # 强制转 int print(df[label].value_counts()) # 看一眼两类样本量是否均衡 print(df[text].str.len().describe()) # 文本长度分布决定后面 max_len这里有个容易被忽略的点标签列要尽早做 dtype 转换。CSV 里如果某个单元格是空值pandas 会把整列读成 float不转 int 的话后面CrossEntropyLoss直接报类型错误。value_counts()检查类别分布也不是走过场正负样本如果一边倒后面训练出来的模型会倾向于把一切预测成多数类就算 loss 降低实际效果也很假。2.2 jieba 分词与停用词过滤中文不像英文天然按空格切词所以分词是第一步。项目里用的是 jieba配合一份停用词表把“的、了、还、也”这类高频但无情感倾向的词滤掉。停用词表网上有公开版本也可以用jieba.analyse从语料里自己挖一批高频无意义词。import jieba def load_stopwords(pathdata/stopwords.txt): with open(path, encodingutf-8) as f: return set(line.strip() for line in f if line.strip()) def tokenize(text, stopwords): # jieba.lcut 返回 list比 cut 生成器更适合后面做长度统计 words jieba.lcut(text) words [w for w in words if w.strip() and w not in stopwords] return words这段话里有必要解释下jieba.lcut的返回值它直接给 list方便立刻做过滤和长度检查如果你用jieba.cut拿到的生成器只能遍历一次调试的时候很容易踩“第二次用就空了”的坑。停用词表注意编码要跟数据集一致很多项目报 UnicodeDecodeError 就是因为 CSV 是 utf-8停用词表却是 gbk。分词粒度上不用过度调 jieba 自定义词典情感分析吃的是整体语义词被切碎一点关系不大。2.3 词表构建与定长序列填充分词后的句子长短不一而 LSTM 要求一个 batch 内的张量形状对齐。常见做法是先统计词频按频率从高到低给每个词编一个整数 id再统一截断或填充到固定长度。这里不是随便设个 max_len要结合 2.1 里str.len().describe()的结果取 90 分位长度既保留更多信息又不至于把 padding 占比抬得太高。from collections import Counter def build_vocab(tokenized_texts, min_count1): counter Counter() for words in tokenized_texts: counter.update(words) # 留 0 给 padding留 1 给未登录词 vocab {pad: 0, unk: 1} for word, freq in counter.items(): if freq min_count: vocab[word] len(vocab) return vocabpad和unk这两个特殊符号一定要在最前面占住 0 和 1 号位。0 号位给 padding 是约定俗成因为后面nn.Embedding默认padding_idx参数能直接忽略 0 号位的梯度更新unk负责兜底那些在测试集新出现、但词表里没有的词。min_count 是另一个重要超参设成 2 或 3 可以过滤掉只出现一次的噪声词但这个项目里语料不大保持 1 也不会有太大问题。2.4 DataLoader 批处理与序列排序有了词表之后把每条评论映射成 id 序列再交给pad_sequence做定长。PyTorch 自带的pad_sequence比手写 for 循环补 0 快得多而且能按 batch 内最长序列自动填充配合 pack_padded_sequence 做变长输入时非常顺手。from torch.nn.utils.rnn import pad_sequence from torch.utils.data import TensorDataset, DataLoader import torch def encode(texts, vocab, max_len): ids [] for words in texts: line [vocab.get(w, 1) for w in words[:max_len]] # 截断到 max_len ids.append(torch.tensor(line, dtypetorch.long)) return ids # tokenized_texts 是分词后的完整语料 ids encode(tokenized_texts, vocab, max_len64) labels torch.tensor(df[label].values, dtypetorch.long) # pad_sequence 默认按 batch 内最长序列补 0 padded pad_sequence(ids, batch_firstTrue, padding_value0) dataset TensorDataset(padded, labels) loader DataLoader(dataset, batch_size64, shuffleTrue, num_workers0)这里batch_firstTrue一定要留意设成 True 之后模型拿到的张量形状是[batch, seq_len]这跟大多数人直觉一致不设的话默认是[seq_len, batch]跟后面nn.LSTM的默认参数保持一致反而容易绕晕。num_workers在 Windows 上建议保持 0否则多进程加载会在 Jupyter 里反复报BrokenPipeError这不是模型问题是 PyTorch 在 Windows 上 DataLoader 子进程机制导致的很多新手在这里白折腾半天。下表是预处理阶段最核心的几个参数与推荐范围参数典型值影响max_len32128过短丢信息过长引入大量 padding 噪声min_count13过滤低频词控制词表大小batch_size32128影响训练速度和 GPU 利用率padding_value0必须与 Embedding 的 padding_idx 一致3. LSTM 模型搭建与超参数对训练趋势的影响3.1 Embedding 层词向量初始化与 padding 处理模型的第一层是nn.Embedding它的作用就是把词表里的整数 id 映射成稠密向量。这个项目没有用预训练词向量直接随机初始化让 Embedding 跟着任务一起训练。这样做的理由是语料领域比较垂直通用词向量未必比随机初始化带来更大提升反而多了加载和内存开销。import torch.nn as nn class SentimentLSTM(nn.Module): def __init__(self, vocab_size, embed_dim100, hidden_size128, num_layers2, num_classes2, dropout0.3): super().__init__() # padding_idx0 表示 id 为 0 的位置不参与梯度更新 self.embedding nn.Embedding( vocab_size, embed_dim, padding_idx0 ) self.lstm nn.LSTM( embed_dim, hidden_size, num_layers, batch_firstTrue, dropoutdropout ) self.classifier nn.Sequential( nn.Dropout(dropout), nn.Linear(hidden_size, num_classes) )padding_idx0这件事要展开讲如果不指定Embedding 层照样会给 0 号位置算梯度等于把 padding 向量也拉进反向传播里白白增加计算量极端情况下还会让模型误以为 padding 也有语义。指定之后这些位置的 embedding 永远是零向量且不更新既省算力又不会污染语义。embed_dim 一般取 100 或 200再往上对这个小数据集帮助不大反而让 LSTM 参数量膨胀训练变慢。3.2 nn.LSTM 核心参数与隐藏状态维度nn.LSTM是整棵树的树干它的参数直接影响模型容量。input_size 就是 Embedding 的输出维度也就是 embed_dimhidden_size 是每层隐状态维度也是全连接层的输入维度num_layers 在纵向堆叠多个 LSTM 层层数多能捕捉更抽象的特征但在这个任务里 2 层就够3 层以上在小数据集上基本过拟合。# 前向传播的两种常见处理方式下一种更推荐 # out: [batch, seq_len, hidden_size * num_directions] # h_n: [num_layers * num_directions, batch, hidden_size] out, (h_n, c_n) self.lstm(x) # 方式一取最后一层最后一刻的隐状态 last_hidden h_n[-1] # [batch, hidden_size] # 方式二对最后一个时间步的 out 做池化 # 适合句子里关键情感词不在末尾的情况 # last_output out[:, -1, :] return self.classifier(last_hidden)这里经常会有人纠结取h_n[-1]还是out[:, -1, :]。两者在单向 LSTM、层数为 1 的时候结果一样层数大于 1 时h_n[-1]取的是最后一层的隐藏状态out[:, -1, :]取的也是最后一层最后一个时间步的输出理论上依然相等。真正要区分的是“最后一个词的隐状态”和“整句话的语义”之间的差异如果评论的情感关键词出现在句首或句中只取末尾状态会损失信息。一个更稳的做法是out在序列维度上做平均池化把整句信息揉在一起再进分类器代价是代码多两行但对情感这种全句分布式的信号更友好。3.3 超参数组合与过拟合信号这类小规模情感分析项目超参设置比想象中敏感。hidden_size 从 64 涨到 256模型参数量是呈平方级上升的因为 LSTM 的权重矩阵是4 * hidden_size * (input_size hidden_size)的结构hidden_size 翻倍参数量不是翻倍而是接近四倍。在几千条样本的语料上hidden_size 超过 256 之后验证集准确率基本不再上升训练 loss 却降得飞快这是典型的过拟合信号。超参数推荐值过小过大embed_dim100语义表达不足训练变慢收益极小hidden_size128欠拟合准确率上不去参数量剧烈膨胀num_layers21 层表达力弱3 层以上梯度消失风险dropout0.3过拟合模型难以收敛batch_size64训练震荡显存压力大收敛慢这些参数不是拍脑袋定的项目 README 里的 baseline 通常就是这么配的因为在这个数据量级下128 维隐状态配上 2 层 LSTM 已经能把训练集准确率做到 95% 以上剩下的问题全在验证集上怎么泛化。dropout 加在 LSTM 层间和最后的全连接前注意nn.LSTM的 dropout 参数只在 num_layers 大于 1 时生效单层 LSTM 传 dropout 是无效的这个文档里写得很清楚但特别容易被忽略。4. GPU 加速训练循环与显存排错4.1 设备检测与模型迁移这个项目的卖点之一是 GPU 加速所以代码里一开始就要做设备检测。训练之前把模型和数据都搬到 CUDA 设备上PyTorch 本身不支持“模型在 CPU、数据在 GPU”的自动搬运漏一步就会出现Expected all tensors to be on the same device的错误。import torch device torch.device(cuda if torch.cuda.is_available() else cpu) print(当前设备:, device) print(GPU 型号:, torch.cuda.get_device_name(0)) print(显存容量: {:.1f} GB.format(torch.cuda.get_device_capacity(0) / 1024**3))上面的get_device_capacity在部分版本里不存在更稳的写法是用torch.cuda.mem_get_info(0)返回 (空闲, 总量)单位是字节再换算成 GB。把 GPU 型号打出来这一步很值得做如果你用的显卡 compute capability 低于 3.5新版 PyTorch 编译的 CUDA 算子根本装不上报错信息会让你误以为是安装出了问题。设备检测这一步看似简单实际上决定了后面所有to(device)的走向也是新手在pytorch 安装和运行阶段最容易卡住的地方。4.2 训练循环中的梯度裁剪与 loss 监控训练循环是整个项目的骨架。PyTorch 的torch.no_grad()包住验证阶段是省显存的关键否则验证时也会建计算图显存占用直接翻一倍。另一个值得养成的习惯是梯度裁剪LSTM 对梯度范数非常敏感特别是长句子反向传播经过多个时间步后梯度很容易爆炸表现为 loss 突然变成 nan。def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss 0 for inputs, labels in loader: inputs, labels inputs.to(device), labels.to(device) optimizer.zero_grad() outputs model(inputs) # [batch, num_classes] loss criterion(outputs, labels) loss.backward() # 梯度裁剪范数超过 5 就缩回去防止梯度爆炸 nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() total_loss loss.item() * inputs.size(0) return total_loss / len(loader.dataset)loss.item()在这里比直接 print(loss) 高效得多因为 item() 会把张量从计算图里拆出来变成普通 Python 数字不会在日志阶段继续持有整个计算图的引用也就不会造成显存堆积。grad_clip_norm_的 max_norm 设成 5.0 是常见起点设太小会让模型训练得很慢设太大等于没设。如果训练到一半 loss 变成 nan第一件事不是怀疑学习率而是把梯度裁剪去掉再跑如果没 nan 了说明梯度爆炸被确认。4.3 验证循环与 checkpoint 保存验证的写法跟训练几乎一样区别在于不更新权重、不计算梯度。验证集准确率才是判断模型好坏的唯一标准训练集 accuracy 没有任何参考价值因为模型完全可能背下训练样本。def evaluate(model, loader, criterion, device): model.eval() correct 0 total_loss 0 total 0 with torch.no_grad(): for inputs, labels in loader: inputs, labels inputs.to(device), labels.to(device) outputs model(inputs) loss criterion(outputs, labels) total_loss loss.item() * inputs.size(0) preds outputs.argmax(dim1) # 取概率最大的类 correct (preds labels).sum().item() total labels.size(0) return total_loss / total, correct / total模型保存这里推荐保存整个 checkpoint 而不是只存 state_dict。虽然 state_dict 更省空间但加载时需要手动重建模型结构一旦中间改过参数旧权重就对不上了。直接把 optimizer 的 state 一起存下来后面做断点续训就不用重头调整学习率。torch.save({ model_state: model.state_dict(), optimizer_state: optimizer.state_dict(), epoch: epoch, val_acc: val_acc, }, fcheckpoints/epoch_{epoch}_acc_{val_acc:.4f}.pth)4.4 常见报错与显存优化对策GPU 训练遇到最多的就是CUDA out of memory。这个报错信息会顺带告诉你当前分配了多少显存、CUDACachingAllocator 缓存在里面多少。很多时候不是你模型太大而是 PyTorch 的显存缓存策略导致碎片化。报错信息常见原因处理方式CUDA out of memorybatch_size 过大或序列过长batch_size 减半或减少 max_lenExpected all tensors on same device模型和数据的 device 不一致检查每个 tensor 的 .deviceFound GPU0 CUDA Capability x.x显卡架构太老换 CPU 跑或装旧版 PyTorchcuDNN assertion error显存不稳定或驱动问题设置 torch.backends.cudnn.deterministicTrue显存优化上除了降 batch_size还有一个实用技巧在每轮 epoch 之间调用torch.cuda.empty_cache()。这个函数会释放 CUDACachingAllocator 里残留的空闲块虽然会有一点点性能开销但对长期跑实验的场景来说换来了显存的稳定。另外torch.backends.cudnn.benchmark True在输入序列长度固定时能自动选择最快的卷积算法LSTM 虽然不走卷积但整个 GPU 加速管线也能吃到这个红利。5. checkpoint 加载后的单条预测与 BiLSTM 升级训练结束后真正要落地的是对单条文本做预测。这个流程要重新走一遍分词、词表映射、padding而且必须用训练阶段完全相同的词表和停用词表否则 id 编号错位预测结果再好也是假的。加载模型权重时先实例化一个结构完全相同的模型再load_state_dict注意要在 eval 模式下做推理否则 Dropout 层还在随机丢弃同一句话两次预测结果会不一样。def predict_single(text, model, vocab, stopwords, device, max_len64): model.eval() words tokenize(text, stopwords) ids [vocab.get(w, 1) for w in words[:max_len]] # 未登录词映射到 unk tensor torch.tensor([ids], dtypetorch.long, devicedevice) with torch.no_grad(): logits model(tensor) # [1, num_classes] prob torch.softmax(logits, dim1) # 转成概率 label logits.argmax(dim1).item() return label, prob[0][label].item() # 用法示例 # model.load_state_dict(torch.load(best.pth, map_locationcuda)[model_state]) # print(predict_single(物流很快客服态度也不错, model, vocab, stopwords, device))上面代码里有个细节torch.load一定要加map_location参数。模型在 GPU 上训练的checkpoint 里存的是 CUDA 张量如果你这次想在纯 CPU 机器上推理不加这个参数就会报RuntimeError: Attempting to deserialize object on a CUDA device。同理从 CPU 训练的模型加载到 GPU 也要手动指定 map_location。把单向 LSTM 升级成 BiLSTM 是这类情感分析项目最常规的提点方式改动集中在三个地方nn.LSTM里加bidirectionalTrue前向传播时把h_n[-1]换成torch.cat((h_n[-2], h_n[-1]), dim1)最后全连接层的输入维度改成hidden_size * 2。双向结构的逻辑是正向 LSTM 从“物流很快客服态度也不错”的开头读到结尾反向 LSTM 从结尾读到开头两者的隐藏状态拼接后同时包含前文和后文的上下文。对情感分析这种“整句整体定调”的任务双向结构通常能带来 2 到 4 个百分点的准确率提升。代价是计算量翻倍如果显存吃紧把 hidden_size 从 128 降到 96正好能抵消双向带来的参数量增长效果往往还比单纯加宽单向网络更好。最后给一个实用参数参考计算资源充足时BiLSTM hidden_size128 embed_dim100 dropout0.3 lr0.001配上 Adam 优化器和ReduceLROnPlateau学习率调度通常在第 5 到 8 个 epoch 之间验证准确率触顶如果你的验证 loss 连续两轮不降先把学习率降到 1e-4 再跑两轮多数情况下比从零重训省时间。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →