ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PyTorch实现LSTM电影评论情感分析:从词表构建到模型训练全攻略

PyTorch实现LSTM电影评论情感分析:从词表构建到模型训练全攻略 简介基于LSTM的中文电影评论情感倾向分析项目面向深度学习初学者、RNN模型研究者以及需要毕业设计参考的高校学生。该项目以评论语句为输入经模型判断正向或负向完整覆盖数据集加载与预处理、分词结果读取、词向量映射、两层RNN编码、全连接分类、批量训练、模型保存与预测等关键环节能帮助读者在复现过程中理解词嵌入、LSTM状态传递、梯度计算与参数更新等核心概念。压缩包共11个文件以Python源码为主6个py另含3个txt数据文件、1个md说明和1个json词表文件整体仅3.43MB轻量紧凑、目录清晰按数据处理、模型构建、训练、预测四个模块展开。目前已有2315人学习使用通过训练与预测脚本可完整体验从词表构建、批处理训练到情感判定的流水线并利用自备数据做小规模实验对毕业设计或RNN入门项目具有直接的参考价值。1. 电影评论情感分析入门为什么第一个RNN项目选LSTM拿到一批IMDB电影评论要判断每句话是好评还是差评这是NLP里最经典的文本二分类任务也是初学者接触RNN模型最常见的切入点。LSTM在里面当主力它能记住序列里靠前的信息比普通RNN更耐得住长句子代码量不大但课堂上学到的反向传播、词嵌入、梯度问题都能在跑通后对上号。用这个标题做毕业设计参考的通常能拿到一条完整链路影评文本清洗、词表构建、序列填充、LSTM模型训练、准确率评估再跟CNN或双向LSTM做对比实验。本文就按这条链路用PyTorch写一版能直接跑的情感分析代码把参数、边界和踩坑交代清楚。适合想研究RNN又不知道从哪下手的人也适合需要一个可扩展demo的毕业生。2. 数据预处理与词表构建把IMDB影评变成模型能读的整数序列2.1 下载与读取IMDB数据集IMDB影评集是情感分析最常用的公开数据集里面每条评论带着pos或neg标签分别对应正面和负面评价。训练集和测试集各两万条左右数据量不大CPU也能跑得动。常见做法是直接下载官方的tar.gz压缩包解压后按目录结构读取不依赖torchtext的版本接口后面少踩很多坑。import os import re import tarfile import urllib.request from collections import Counter import torch from torch.utils.data import Dataset, DataLoader import torch.nn as nn DATA_DIR ./data def download_imdb(data_dirDATA_DIR): os.makedirs(data_dir, exist_okTrue) url https://ai.stanford.edu/~amaas/data/sentiment/aclImdb_v1.tar.gz tar_path os.path.join(data_dir, aclImdb_v1.tar.gz) if not os.path.exists(tar_path): print(正在下载IMDB数据集第一次需要一点时间...) urllib.request.urlretrieve(url, tar_path) extract_dir os.path.join(data_dir, aclImdb) if not os.path.exists(extract_dir): with tarfile.open(tar_path, r:gz) as tar: tar.extractall(data_dir) return extract_dir这段代码先把压缩包下载到本地解压后得到aclImdb目录里面按train/pos、train/neg、test/pos、test/neg分成四个文件夹每个文件是一条独立的评论。栅栏在于如果压缩包已经存在urllib不会断点续传中途断网要删掉重下解压判断用目录是否存在目录不完整时也要手动删掉重来。第一次见这个数据集的同学可以先打开一条文本看看原始格式你会发现里面有HTML标签、大小写混排这些东西必须清洗掉。2.2 文本清洗、词表构建与unk兜底原始评论不能直接喂给LSTM模型只能吃数字。这里要做的有几步去掉HTML标签和标点、统一小写、按空格分词然后统计词频构建词表。词表一定要预留两个特殊位置pad用0表示补长unk用1表示词典外单词否则测试时碰到没见过的词会直接索引越界。def clean_text(text): # 去掉HTML标签保留字母 text re.sub(r[^], , text) text re.sub(r[^a-zA-Z], , text) return text.lower().split() def build_vocab(texts, max_vocab20000): counter Counter() for text in texts: counter.update(clean_text(text)) # 预留2个位置给pad和unk vocab [w for w, _ in counter.most_common(max_vocab - 2)] word2idx {w: i 2 for i, w in enumerate(vocab)} word2idx[pad] 0 word2idx[unk] 1 return word2idx这里max_vocab控制词表大小取20000是让模型体积可控的常见值如果你用的词表太大Embedding层参数会暴涨训练速度明显变慢。大多数英语评论文本的核心词汇量就在一两万级别超过这个数的都是低频生僻词跟着unk走了也不影响效果。要注意的是词表必须完全基于训练集构建不能把测试集数据算进来否则就属于数据泄漏论文答辩时被问到会很麻烦。2.3 定长序列与Dataset封装LSTM虽然能处理变长序列但在批次训练时必须把同batch的句子拼成矩形tensor所以要做截断和填充。评论短的几十词长的上千词这里设max_len256超过的截掉尾部不足的用0补齐。注意截断策略有讲究IMDB评论的重要情感词往往分布在句首和句尾新手常犯的错误是直接截断尾部导致结论丢失常见做法是保留开头部分把尾部超长内容截断。def encode(text, word2idx, max_len256): ids [word2idx.get(w, word2idx[unk]) for w in clean_text(text)] if len(ids) max_len: ids ids[:max_len] else: ids [word2idx[pad]] * (max_len - len(ids)) return ids class IMDBDataset(Dataset): def __init__(self, data_dir, splittrain, word2idxNone, max_len256, max_vocab20000): self.max_len max_len texts, labels [], [] for label, sub in [(1, pos), (0, neg)]: folder os.path.join(data_dir, split, sub) for fname in sorted(os.listdir(folder)): with open(os.path.join(folder, fname), r, encodingutf-8) as f: texts.append(f.read()) labels.append(float(label)) if word2idx is None: self.word2idx build_vocab(texts, max_vocab) else: self.word2idx word2idx self.texts texts self.labels labels def __len__(self): return len(self.texts) def __getitem__(self, idx): ids encode(self.texts[idx], self.word2idx, self.max_len) return torch.tensor(ids, dtypetorch.long), torch.tensor(self.labels[idx], dtypetorch.float)标签用float是因为后面用BCEWithLogitsLoss做二分类。word2idx如果传了训练集的进来测试集就能保持同一个词表这是工程里容易漏的细节。建议在main里这样组织先建训练集得到词表再用同一个词表建验证集和测试集保证PAD、UNK、词频排序完全一致。DataLoader里设shuffleTrue打乱训练顺序batch取64或128都行初学者从64起步比较稳。3. 用PyTorch实现LSTM分类器从embedding到输出层的完整结构3.1 LSTM为什么能缓解RNN的梯度消失普通RNN在长序列上有个根问题误差往回传的时候每一步都要乘一个相同的转移矩阵连乘次数等于序列长度梯度要么爆炸要么消失导致网络记不住几十步之前的信息。LSTM在隐藏单元里加了门控机制输入门、遗忘门、输出门各自负责决定“记住什么、丢掉什么、输出什么”遗忘门可以让梯度在记忆细胞里近似恒等地流过这一步设计让长距离依赖变得可行。跑情感分析这种对“前面铺垫后面反转”有要求的任务LSTM比普通RNN靠谱得多。3.2 模型代码与关键参数说明下面的类就是完整的LSTM分类器结构是Embedding层、单层LSTM、全连接输出层。输入是(batch, seq_len)的整数序列经过Embedding变成(batch, seq_len, embedding_dim)LSTM按时间步逐个处理最后取最后一层的隐藏状态过全连接层做二分类。class LSTMClassifier(nn.Module): def __init__(self, vocab_size, embedding_dim100, hidden_dim128, num_layers2, 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.0) self.dropout nn.Dropout(dropout) self.fc nn.Linear(hidden_dim, 1) def forward(self, x): emb self.embedding(x) # (batch, seq_len, embedding_dim) out, (h_n, c_n) self.lstm(emb) # h_n: (num_layers, batch, hidden_dim) last_hidden h_n[-1] # 取最后一层隐藏状态 logits self.fc(self.dropout(last_hidden)) return logits.squeeze(1) # (batch,)注意几点。第一batch_firstTrue让输入形状变成(batch, seq_len)省去转置的烦恼这是PyTorch里LSTM最常用的写法。第二h_n的形状是(num_layers, batch, hidden_dim)切片h_n[-1]取的是最后一层的隐藏状态如果你用双向LSTM这里要改成拼接最后一层的前向和后向向量。第三Embedding的padding_idx0告诉模型第0号词向量不参与训练它在反向传播时梯度永远是0避免pad噪声影响。第四embedding_dim和hidden_dim决定参数量100/128是CPU能快速跑通的搭配显存富余时可以往上调但要注意下面的欠拟合和训练时长问题。3.3 训练循环损失函数、优化器与梯度裁剪模型搭好之后训练代码是这个项目真正的核心。二分类用BCEWithLogitsLoss它内部把sigmoid和交叉熵合并在一起数值上比“先sigmoid再算loss”更稳。优化器选Adam学习率从1e-3开始。梯度裁剪是LSTM的重要保底手段RNN系列模型在长序列上出现梯度爆炸时loss会突然跳成NaNclip_grad_norm_能直接限制梯度的最大范数。import torch.nn.utils as nn_utils def train_epoch(model, loader, optimizer, criterion, device, clip1.0): model.train() total_loss 0.0 for batch_x, batch_y in loader: batch_x batch_x.to(device) batch_y batch_y.to(device) optimizer.zero_grad() logits model(batch_x) loss criterion(logits, batch_y) loss.backward() nn_utils.clip_grad_norm_(model.parameters(), clip) optimizer.step() total_loss loss.item() * batch_x.size(0) return total_loss / len(loader.dataset)每个batch的逻辑都在注释里前向传播拿logits算loss反向传播得梯度裁剪更新参数。clip1.0表示梯度范数超过1就按比例缩回去这个数值不用抠太细0.5到2之间都常见。训练时我一般会把模型放到GPU或CPU上再用DataLoader的num_workers并行读数据。epoch数建议先跑5轮看趋势如果验证loss在降就继续如果验证loss翻了说明学习率偏大或dropout不够。4. 评估与可视化不只报准确率要看坏例4.1 训练过程中的损失与准确率曲线验证方式的第一步是记录每个epoch的训练loss和验证准确率把它们画成曲线。这是判断模型过拟合最直接的手段训练loss继续下降、验证loss开始反弹就是过拟合信号。不要只盯着训练集准确率那在深度学习里是典型的“自我感动”。torch.no_grad() def evaluate(model, loader, criterion, device): model.eval() total_loss, correct 0.0, 0 all_preds, all_labels [], [] for batch_x, batch_y in loader: batch_x batch_x.to(device) batch_y batch_y.to(device) logits model(batch_x) loss criterion(logits, batch_y) total_loss loss.item() * batch_x.size(0) preds (torch.sigmoid(logits) 0.5).long() correct (preds batch_y.long()).sum().item() all_preds.extend(preds.cpu().numpy()) all_labels.extend(batch_y.long().cpu().numpy()) acc correct / len(loader.dataset) return total_loss / len(loader.dataset), acc, all_preds, all_labelsevaluate里加了两件事第一把所有预测和真实标签收集起来供后面的混淆矩阵和坏例分析用第二模型切到eval模式dropout被关闭这是评估和训练最大的区别。如果忘了model.eval()dropout会随机丢掉节点导致同一个样本每次预测结果不一样验证准确率波动很大这在初学阶段是个很容易忽略的翻车点。4.2 混淆矩阵与预测样例准确率只能说明整体对错混淆矩阵能告诉你错误长什么样。用sklearn一行就能算出来横轴是真实标签纵轴是预测标签。负样本被判成正样本的FN、正样本被判成负样本的FP它们的比例直接影响你后续怎么调阈值。接着从测试集里挑几个样本打印原文、真实标签、预测概率和最终判定这一步能让你直观看到模型在“读”什么。from sklearn.metrics import confusion_matrix, classification_report import matplotlib.pyplot as plt def plot_confusion(matrix, classes): plt.imshow(matrix, cmapBlues) plt.colorbar() plt.xticks([0, 1], classes) plt.yticks([0, 1], classes) plt.xlabel(预测标签) plt.ylabel(真实标签) for i in range(2): for j in range(2): plt.text(j, i, matrix[i][j], hacenter, vacenter) plt.show()打印样例时我习惯同时输出sigmoid后的概率值而不是只看0/1阈值。概率靠近0.5的样本是模型自己都拿不准的这类样本往往包含比较隐晦的反讽或双重否定靠单一固定阈值判断本来就容易出错。把概率值输出到控制台你就能看见模型的不确定性分布后面如果想调阈值也知道往哪个方向调。4.3 从坏例里找到模型真正的短板评估的最后一步是把预测错的样本单独抽出来看。常见做法是打印前20个false positive和false negative人类扫一遍就能发现规律。我做这个项目时印象最深的坏例有两种一是带有讽刺语气的影评字面全是夸赞实际在骂这类很难靠LSTM解决二是评论里出现“not bad”这种双重否定结构模型容易只看尾部词。这些坏例不是模型的失败而是你论文里“分析与讨论”那一节的最好素材写到模型局限性时直接用比空泛说“未来将引入预训练模型”有说服力得多。5. 实践避坑LSTM情感分析最常见的6个坑5.1 坑torchtext版本迁移导致老代码跑不通现象跟着网上的老教程用torchtext的Field、LabelField、BucketIterator装完最新版torchtext后直接报AttributeError或者提示dataset已经废弃。原因torchtext 0.9之后的API大改老的Field接口被移除网上大量早期教程是基于0.6写的换版本后全部失效。解决绕开torchtext直接用torch.utils.data.Dataset手写数据读取。这部分代码就是第2章展示的方式代码量多不了多少但完全不依赖torchtext的版本装哪个版本都能跑。数据集加载这种底层逻辑越少依赖越省心。5.2 坑训练集准确率很高验证集却一直上不去现象训练到第3轮训练准确率到了95%验证准确率卡在82%左右继续训练验证loss还在涨。原因典型的过拟合。LSTM参数量不小IMDB原始词表如果没用max_vocab限制Embedding层会很大模型把训练集里的专有名词记住了但没有泛化能力。解决三步一起做。第一把max_vocab降下来去掉生僻词第二把LSTM的dropout从0.3调到0.5全连接前面也加Dropout层第三用早停法保存验证准确率最高的模型权重而不是最后一轮后面每个epoch结束都做一次验证。5.3 坑loss不降或直接变NaN现象训练到某个batchloss突然变成nan后面的loss全是nan准确率也跟着崩掉。原因最常见的是梯度爆炸LSTM在反向传播时梯度范数失控其次是学习率过大Adam的默认1e-3在部分初始化下也会震到爆炸。解决梯度裁剪是最有效的后悔药clip_grad_norm_设为1.0几乎能让训练稳定下来。另一个做法是把学习率降到3e-4并配合warmup前几个epoch用很小的学习率预热再进入正常训练这两招一起就很少再看见NaN了。5.4 坑预测阶段输入长度不一致导致报错现象训练时一切正常单独预测一条新评论时直接报“mat1 and mat2 shapes cannot be multiplied”。原因训练时每个batch用max_len填充成了矩形但你自己写预测函数时没有对单条文本做同样的encode逻辑长度不固定导致Embedding输出形状对不上全连接层。解决预测函数里必须复用train时那套encode逻辑把输入统一截断/填充到max_len建议把encode、模型加载、预处理封装成一个predict函数任何外部文本都先走同一套流程不要复制粘贴代码里的预处理部分。5.5 坑类别不均衡导致模型倾向性预测现象结果全预测成某一类比如90%以上的影评都被判断为正面准确率看起来还行但召回率极低。原因虽然IMDB是均衡数据但自己裁数据时如果不注意某个batch或某个局部数据集里正负比例失衡模型会往多数类偏移。另外阈值0.5不是永恒真理模型输出概率整体偏高时0.5会误伤不少负样本。解决训练时用随机打乱确保每轮batch分布均匀评估时不光看准确率还要看precision、recall和F1直接用classification_report能看得很清楚。如果概率分布整体偏移在验证集上搜一个最优阈值比如0.55或0.45能救回一部分坏例。5.6 坑训练时间过长与显存不足现象batch_size设了128跑第三个epoch时CUDA out of memory或者CPU上跑一个epoch要二十多分钟。原因max_len和vocab_size分别控制了序列长度和词表宽度。IMDB很多评论近千词256的上限已经截掉了大部分尾部内容batch_size128在单卡上配hidden_dim128不算大但Embedding前向矩阵和LSTM的中间变量很占显存。解决显存不够优先降batch_size到32或64再兼顾max_len降成200CPU训练就把vocab_size降到10000epoch跑3轮验证一下趋势是否正常。另外可以把模型输出了的复杂度做一个预算——embedding_dim/hidden_dim都取128时模型大小大概在百万参数量级这个量级跑IMDB完全够用没必要堆大模型。6. 从毕业设计到进阶BiLSTM、注意力机制与对比实验6.1 把单向LSTM改成双向单向LSTM每个时间步只能看到过去的词影评里转折往往在后面。改成双向后每个位置同时看到前后上下文信息量翻倍也只是把hidden_dim*2拼到全连接层。改法非常小nn.LSTM加bidirectionalTrue取h_n时把最后一层的前向和后向向量拼接self.lstm nn.LSTM(embedding_dim, hidden_dim, num_layersnum_layers, batch_firstTrue, bidirectionalTrue) # forward里 h_forward h_n[-2] # (batch, hidden_dim) h_backward h_n[-1] # (batch, hidden_dim) last_hidden torch.cat((h_forward, h_backward), dim1) self.fc nn.Linear(hidden_dim * 2, 1)这是毕业设计里性价比最高的升级几行代码换来几个点的准确率提升论文里还能画一个前向和反向的结构图值得做。6.2 让模型自己挑重点一个简单的注意力层LSTM最后只用一个隐藏向量代表全句信息这等于逼着模型把整句话压缩成一个点信息损失是必然的。注意力机制的做法是每个时间步的输出不直接扔掉而是按权重加权求和权重由模型自己学习。代码量很少但对影评这种“关键信息可能落在某个从句里”的任务很有效。class Attention(nn.Module): def __init__(self, hidden_dim): super().__init__() self.score nn.Linear(hidden_dim, 1, biasFalse) def forward(self, lstm_outputs): # lstm_outputs: (batch, seq_len, hidden_dim) attn torch.softmax(self.score(lstm_outputs), dim1) context torch.sum(lstm_outputs * attn, dim1) return context用的时候把LSTM的outputs不是h_n交给Attention输出加权后的context向量再过全连接层。注意力还有一个额外好处你可以把每个位置的权重可视化画成热力图放在论文里非常直观地回答“模型到底看了哪些词”。6.3 三个值得做的对比实验毕业设计答辩时老师最常问的问题就是“你为什么不选CNN/传统RNN/BERT”。与其到时候挨个解释不如提前跑三组对照普通RNN、LSTM、BiLSTMAttention在同一份数据、同参数下记录准确率和训练时间。按我经验普通RNN在长影评上准确率会低24个百分点LSTM与BiLSTM差距在12个百分点注意力对长句子的增益会更明显。这张对照表就是你论文实验章节的骨架评审看见你有控制变量意识比单纯堆一个高准确率更有说服力。我自己当年做这个项目时最大的教训是把训练准确率当成了模型能力的全部。后来学着把预测错的样本打印出来逐条看才发现模型真正的问题不是参数而是对讽刺和双重否定的理解不足。这也是为什么我到现在都保留着“先看坏例、再看准确率”的习惯指标能告诉你模型够不够好坏例能告诉你它为什么还不够好。模型不是黑匣子只要肯花时间看输出它就能告诉你下一步该改哪里。希望帮到你。本文还有配套的精品资源点击获取
返回列表