ARTICLE DETAIL

资讯详情

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

手写LSTM门控机制:中文电影评论情感分析实战拆解

手写LSTM门控机制:中文电影评论情感分析实战拆解 简介面向机器学习初学者的中文电影评论情感分析项目基于LSTM网络实现文本正向/负向二分类适合研究RNN模型原理及作为毕业设计参考。数据集中包含已分词并标注的train/test/validation文本可直接用于训练验证。资源共11个文件以6个Python脚本为核心覆盖数据处理、模型构建、训练、预测完整流程另有词汇表json、数据文本及说明文档压缩包整体3.43MB。已有2315人学习该资源。代码按功能清晰拆分data_manager.py负责任务加载与词表生成network/mylstm.py实现词向量映射、双层RNN与全连接分类train.py展示迭代批次训练及权重更新过程predict_demo.py演示加载模型对新语句进行情感判断。对初学者理解LSTM在NLP中的应用提供了可运行的完整参考较适合课程设计与论文实验复现。1. 中文电影评论情感分析一份能把 LSTM 门控机制看穿的代码包做情感分析的开源项目不少但多数直接把nn.LSTM(...)一行封装掉调参的人根本看不到遗忘门和输入门在里面怎么算的。这份基于 LSTM 的电影评论感情倾向分析代码恰恰把最核心的mylstm.py手写了出来从词向量到两层循环网络再到全连接分类每一步的张量变化都能在源码里找到。适合两类人一是想研究 RNN 系模型原理、不想只当调包侠的初学者二是做毕业设计需要「模型结构 完整训练 预测 Demo」三段式参考的在校生。数据已经分好词、打好标签训练脚本和预测脚本拆得干净跑通后再去改结构比对着教程空想要直观得多。2. 数据管道先搞清 train.txt / test.txt / validation.txt 是怎么进模型的2.1 数据集长什么样标签前置、文本已分词打开data目录三个 txt 文件分别是训练集、测试集和验证集。每行的格式是「标签 空格 分词后的评论」标签 1 代表正向0 代表负向。因为已经做好分词词与词之间用空格隔开所以不需要再额外接 jieba 或 hanlp。1 这 部 电影 的 节奏 很 好 0 剧情 拖沓 演员 演技 也 不行这里要提醒一个细节原始评论如果是英文或中英混合分词结果里会出现中英文符号混排。这个数据集既然已经预处理过就直接信任它但你要理解——所有后续的word2id.json词表都是基于这份分词结果生成的预测新句子时也必须用同一套分词方式否则词表对不上。2.2 data_manager.py 的职责构建词表、生成训练批次data_manager.py的核心工作是两件事扫描全部训练文本统计词频、按词频排序生成「词 → 下标」的映射然后把这个映射存成word2id.json。这个文件是训练和预测的中间桥梁import json from collections import Counter # data_manager.py 核心逻辑示意 def build_vocab(file_path, min_count1): counter Counter() with open(file_path, encodingutf-8) as f: for line in f: # 每行格式: 标签 空格 分词文本 parts line.strip().split( ) if len(parts) 2: continue counter.update(parts[1:]) # 跳过首位标签统计评论词 # 按词频降序低频词直接丢弃控制词表规模 vocab {word for word, cnt in counter.items() if cnt min_count} word2id {word: idx for idx, word in enumerate(vocab, start1)} # 一般会把 word2id 落盘后续 predict 直接加载 with open(word2id.json, w, encodingutf-8) as f: json.dump(word2id, f, ensure_asciiFalse) return word2id这段代码有两点值得注意。第一start1是从 1 开始编号下标 0 一般预留给未知词或 padding 位这样遇到词表外的新词时还能兜底不至于直接 KeyError。第二min_count参数直接控制词表大小——设 1 时词表可能几万起步设 3 以上能砍掉大量只出现一次的人名和专有名词模型更稳但覆盖率下降。数据加载阶段的另一个关键点是定长化。LSTM 要求一个 batch 内的序列长度一致常见做法是设定max_len比如 30超过的截断、不足的补 0def pad_sequence(seq_ids, max_len30): if len(seq_ids) max_len: return seq_ids[:max_len] # 0 是 padding 位 return seq_ids [0] * (max_len - len(seq_ids))参数选择上有几条经验电影评论平均长度在 20 到 40 个词之间max_len设 30 是个比较稳的起点设太短会截掉后半段的转折信息设太长会引入大量 padding 噪声训练时间和显存都划不来。同时批量读取时用yield生成器按批次吐数据而不是一次性把所有数据读进内存不然 5 万条评论能吃掉几个 G 内存容易在 Notebook 里直接卡死。3. 模型结构mylstm.py 里手写的门控机制才是这份代码的精华3.1 为什么是 LSTM而不是普通 RNNnetwork目录下同时给了simplernn.py和mylstm.py两份循环单元实现。这一点在同类项目里不多见直接把对比素材摆出来了普通 RNN 只有一个tanh激活把当前输入和上一时刻的隐状态压到[-1, 1]链式求导时梯度在时间步上一直连乘序列一长就梯度消失前面几帧的信息基本传不过来。LSTM 增加细胞状态c这条传送带靠遗忘门、输入门、输出门三个门控来决定「记住多少旧信息、写入多少新信息、输出多少给下一层」。电影评论恰恰是需要长距离依赖的场景——「虽然前半段拖沓但结尾的反转很精彩」这种句子否定和转折相隔很远。普通 RNN 读到「精彩」时前半段的「虽然」已经被稀释得差不多了LSTM 可以用遗忘门把转折前的铺垫保留下来。3.2 手写 LSTM 单元三个门的前向计算mylstm.py把 LSTM 拆成了你能逐行读懂的形式。每个时间步的输入是当前词向量x_t和上一时刻的隐状态h_{t-1}输出是新的隐状态h_t和细胞状态c_timport numpy as np def lstm_cell_forward(x_t, h_prev, c_prev, W_f, U_f, b_f, W_i, U_i, b_i, W_o, U_o, b_o, W_c, U_c, b_c): # 遗忘门决定上一时刻细胞状态保留多少 f_t sigmoid(np.dot(W_f, x_t) np.dot(U_f, h_prev) b_f) # 输入门候选值由当前输入生成 i_t sigmoid(np.dot(W_i, x_t) np.dot(U_i, h_prev) b_i) c_tilde np.tanh(np.dot(W_c, x_t) np.dot(U_c, h_prev) b_c) # 输出门决定从当前细胞状态输出多少 o_t sigmoid(np.dot(W_o, x_t) np.dot(U_o, h_prev) b_o) # 细胞状态更新遗忘旧信息 写入新信息 c_t f_t * c_prev i_t * c_tilde # 隐状态输出 h_t o_t * np.tanh(c_t) return h_t, c_t, (f_t, i_t, o_t, c_tilde)逐行说明这四组公式的物理含义。遗忘门f_t对c_prev逐元素放行值接近 1 表示「这段信息很重要继续保留」输入门i_t控制当前候选值c_tilde能写入多少输出门o_t控制最终隐状态。三个门都用sigmoid压缩到(0,1)候选记忆用tanh压缩到(-1,1)这是 LSTM 的标准配置不要随便换成别的激活函数。代码里的W_*是输入到门的权重矩阵U_*是隐状态到门的权重矩阵b_*是偏置。手写实现里最容易出错的是维度如果hidden_dim 128、词向量embed_dim 100那么W_*的形状都是(128, 100)U_*都是(128, 128)b_*都是(128,)。任何一处维度写错矩阵乘法立刻报错这是新手接触这份代码时第一个会撞上的坑。3.3 三层装配词向量、双层循环网络、全连接分类mylstm.py的完整前向流程分成三段。第一段是词嵌入层把每个词的整数下标映射成稠密向量——这里不是用预训练向量而是随机初始化一个(vocab_size, embed_dim)的矩阵训练过程中跟着反向传播一起更新属于最朴素的端到端方案# 词嵌入层从词表大小映射到稠密向量 embedding np.random.randn(vocab_size, embed_dim) * 0.01 def lookup(word_idx): return embedding[word_idx] # 返回 shape (embed_dim,)第二段是把嵌入后的词向量序列依次送进两层 LSTM。层数设为 2 不是随手定的第一层捕捉局部词序关系「不好」「太差」这类相邻词组合第二层在更高抽象层级上组合第一层的输出能建模跨从句的语义关系。很多初学项目只用单层遇到「虽然……但是……」这类转折句时准确率上不去就是这个原因。第三段是全连接分类层。两层 LSTM 跑完后取最后一步的隐状态h_T而不是每一步的平均值送进全连接层映射到 2 个神经元再接 softmax 转成概率分布# 取最后一个时间步的隐状态做分类 final_h h_seq[-1] # shape (hidden_dim,) logits np.dot(W_cls, final_h) b_cls # shape (2,) probs softmax(logits)这里为什么取最后一步而不是平均值因为最后一步的h_T已经通过细胞状态聚合了整句话的信息门控机制决定哪些信息该留到最后。平均值反而会把开头和结尾的信息一视同仁削弱转折句里后半段的权重实测通常掉 2 到 3 个点。这个取舍在predict_demo.py里也同样生效。4. 训练闭环train.py 与 model.py 的分工和关键超参数4.1 训练脚本的骨架外循环迭代、内循环批次model.py和train.py的分工很典型前者负责把数据加载、模型构建组装到一起对外暴露训练和预测接口后者是主入口触发整个训练流程。训练循环的结构是经典的两层循环# train.py 训练主循环骨架 for epoch in range(epochs): total_loss 0.0 correct 0 total 0 # 内循环按 batch 遍历训练数据 for batch_x, batch_y in data_iter(): # 1. 前向计算 probs model.forward(batch_x) # (batch_size, 2) # 2. 计算交叉熵损失 loss cross_entropy(probs, batch_y) # 3. 反向传播计算梯度 grads model.backward(loss_grad) # 4. 更新权重 model.update(grads, lr) total_loss loss # 统计准确率 pred np.argmax(probs, axis1) correct np.sum(pred batch_y) total len(batch_y) # 每个 epoch 结束输出一次方便观察收敛 print(fepoch {epoch1}/{epochs}, loss{total_loss:.4f}, acc{correct/total:.4f})这批代码的顺序不能乱先前向算出概率再由损失函数倒推梯度最后更新权重。很多初学项目翻车就是把model.update()写在了backward()之前梯度还没算出来就更新了旧权重结果训练曲线完全不动。每当遇到训练 loss 不下降第一步就检查顺序对不对。4.2 超参数怎么设一份可以直接抄作业的配置表结合这个项目「数据集不大、手写实现」的特点我实验下来觉得下面这组参数比较稳妥参数推荐值说明embed_dim64 ~ 100词向量维度电影评论词典规模不大128 以上收益甚微hidden_dim128隐状态维度双向或双层时建议保持 128 起步num_layers2单层欠拟合三层以上在中文情感分类上提升有限batch_size32 ~ 64手写实现建议 32占用内存小且收敛稳定lr0.001 ~ 0.01手写 LSTM 用 SGD 时 0.005 起步偏大容易梯度震荡epochs10 ~ 30配合早停策略看验证集曲线选择中断时机max_len30超过 30 个词的评论直接截断保留主体语义学习和训练节奏有一个常见误区手写 LSTM 比 PyTorch 封装版慢不少一个 epoch 可能要几分钟到十几分钟所以很多人把epochs压到 3 以下。这其实是没必要的因为数据量不大时完全可以从max_len30、hidden_dim64开始试跑一个 epoch 验证代码通顺再调回完整配置。头一个问题先解决「能不能跑」再解决「效果好不好」。4.3 损失函数和优化器手写项目里通常怎么选情感分类是二分类问题输出层用 softmax损失函数几乎毫无悬念地选交叉熵。交叉熵的梯度形式比均方误差更容易收敛因为在概率接近 0 或 1 时MSE 的梯度趋近于 0会让训练卡死。优化器方面这个项目用的是最朴素的全量梯度下降或其变体。验证集准确率如果长期卡住不动可以试试给梯度更新加一个动量项v momentum * v - lr * grad; w v。在手写代码里加动量只需要多维护一个缓存数组是性价比很高的改动通常能提升一到两个百分点的收敛速度。学习率调整方面个人经验是每 5 个 epoch 把学习率乘以 0.5验证集 loss 在最后几个 epoch 基本不再抖动。# 每 5 个 epoch 衰减一次学习率 if epoch % 5 0 and epoch 0: lr * 0.5 print(flr decay to {lr:.4f})学习率衰减的度要掌握好衰减到原来的四分之一以下就没必要再继续了对这类小规模文本分类任务过小的学习率只是白白浪费时间。另外训练结束前一定把最终的loss和验证集准确率打印出来留档后面换数据、改结构才有对照基准。5. 避坑手写 LSTM 训练时最常见的五个翻车现场5.1 现象loss 突然变成nan训练到某个 epoch 后打印出来的损失值直接变成nan准确率也跟着崩盘重启进程也一样。原因手写 LSTM 的反向传播里梯度经过多个时间步连乘数值很容易爆炸。尤其当学习率偏大大于 0.01时权重更新过大下一轮前向计算里tanh和sigmoid的输出逐渐推向极值最终溢出。解决在backward()返回梯度后、update()之前加梯度裁剪把梯度的全局范数限制在 5.0 以内。# 梯度裁剪防止梯度爆炸 total_norm np.sqrt(sum(np.sum(g**2) for g in grads.values())) clip_scale 5.0 / (total_norm 1e-6) if clip_scale 1.0: for g in grads.values(): g * clip_scale顺带说一句裁剪阈值 5.0 是个通用起点如果裁剪后 loss 还是不稳定就降到 1.0 再试。这条经验对以后用 PyTorch 训练 RNN 类模型同样适用。5.2 现象训练准确率 95%验证集却一直卡在 70% 左右训练集上几乎能背答案验证集上表现平平典型的过拟合信号。原因模型容量超出数据所需加上没有正则化手段。hidden_dim设到 256、embed_dim设到 200对这种小规模电影评论数据集来说明显过参数化了。另一个推手是训练轮数太多模型把训练样本里的偶然共现也学进去了。解决先降hidden_dim到 64~96观察验证集准确率是否上升同时引入早停验证集 loss 连续 3 个 epoch 不下降就停止训练保留历史最优模型。手写 LSTM 里加 dropout 比较麻烦但如果在全连接层之前只对final_h做一次 dropout实现成本低且不会干扰循环内部的状态传递。5.3 现象预测新评论时报KeyError训练和验证都正常一旦把训练语料之外的新句子送进predict_demo.py直接抛KeyError。原因词表和模型是配套训练的预测时遇到词表里没有的字直接按字典下标查找就崩溃了。中文评论里人名、网络新词、英文品牌名都很容易落在词表之外。解决在build_vocab时预留UNK下标把词频过低的词或未知词统一映射到它预测时对每个词先查词表查不到就用UNK的下标替代def word_to_id(word, word2id, unk_id0): return word2id.get(word, unk_id) # 0 作为未知词兜底这算是文本分类任务里一个常见的工程细节项目代码里如果没有自己补上也很简单。我一般会在data_manager.py构建词表时就顺手把UNK写进word2id.json而不是最后在预测脚本里临时处理。5.4 现象训练效果好predict_demo.py输出结果却明显离谱同一个模型验证集准确率 85%但 demo 对「这部电影真难看」输出了正向。原因训练语料是分词且按规范预处理的但预测脚本很可能没有做同样的预处理流程。比如训练时标点符号被过滤预测时句尾句号还留在原文里或者训练集里的英文短语转成了小写预测时原样大写传入词表匹配不上。解决把预测阶段的文本清洗、分词流程和训练时保持完全一致。最稳妥的办法是抽一个公共预处理函数放在data_manager.py里训练和预测都调用它而不是各写各的。# 预测前强制走同一套预处理 def preprocess_sentence(text): text text.strip().lower() # 统一小写 text re.sub(r[^\w\u4e00-\u9fa5], , text) # 去掉标点 return text.split( ) # 按空格分词这个坑的关键在于「后悔药要早吃」——项目一上来就把预处理统一成公共函数后面就不会出现两边不一致的诡异问题。一旦发现预测乱来先别调模型去检查预处理。5.5 现象训练时 loss 在下降但准确率振荡剧烈每 10 个 batch 的准确率从 50% 跳到 80% 再掉回 60%波动幅度很大。原因batch_size太小每个 batch 的类别分布严重不均衡比如某个 batch 全是正向样本模型更新方向被带偏一次或者数据没有提前随机打乱负样本在文件里集中扎堆了。解决加载数据时先整体 shuffle 一遍再按顺序切 batchbatch_size不要低于 16不然梯度估计的噪声太大。这里有个容易被忽视的点shuffle 必须在划分训练集之前做如果先切出 80% 训练集再 shuffle数据分布已经被原文件顺序绑定了类别分布可能天然不均。6. 预测脚本与两个进阶方向从能跑到跑得更准6.1 predict_demo.py 的完整流程拆解预测脚本和训练脚本结构是镜像的但少了反向传播和权重更新。完整流程是加载模型参数和word2id.json把新评论经预处理转成词汇表下标序列送进模型前向计算输出正向概率和负向概率# predict_demo.py 核心逻辑 import json import numpy as np # 1. 加载词表和已经训练好的模型参数 with open(word2id.json, encodingutf-8) as f: word2id json.load(f) model build_model(vocab_sizelen(word2id), hidden_dim128) model.load(saved_model.npy) # 2. 预处理清洗、分词、转下标、定长截断 tokens preprocess_sentence(这部电影的节奏太慢了看得想睡觉) ids [word2id.get(w, 0) for w in tokens] ids pad_sequence(ids, max_len30) # 3. 前向计算得到正负概率 probs model.predict(np.array([ids]))[0] # shape (2,) positive_prob, negative_prob probs # 4. 按概率大小判断情感倾向 label 正向 if positive_prob negative_prob else 负向 print(f正向概率: {positive_prob:.4f}, 负向概率: {negative_prob:.4f}, 判定: {label})这段代码里第 2 步和第 4 步是最容易让人忽略但对结果影响最大的。第 2 步如果不做长度对齐模型会直接报维度错误第 4 步如果用「概率是否大于 0.5」来判断在二分类 softmax 下等价于比较两个概率的大小但写成比较概率的写法更直观后面如果要改成三分类也能直接复用。6.2 进阶一把 simplernn.py 和 mylstm.py 的结果并排跑一次network目录下那两个文件本身就是一个天然的对比实验。把训练的model.py里循环单元从mylstm换成simplernn保持其他参数完全一致分别在测试集上跑一遍准确率。通常你会看到普通 RNN 在超长评论上会比 LSTM 低 3 到 8 个百分点这是能写进毕业设计「实验对比」章节的一手数据——比引用任何论文都更有说服力。这个对比实验本身也是拆这份代码最值得做的事。6.3 进阶二从手写 LSTM 平移到 PyTorch 的边界在哪把这份手写实现和 PyTorch 的nn.LSTM对照着看你会发现mylstm.py里自己维护的h_t和c_t就是 PyTorch 里一个LSTMCell做的事。如果后续要换框架、要上 GPU 加速把lstm_cell_forward里的矩阵乘法替换成torch.nn.Linear再在时间步上循环就行。手写实现的每一行公式都能在官方文档里找到对应物这也是它适合毕业设计答辩的原因——被问到底层原理时可以和公式一一对应而不是背一句「LSTM 能解决梯度消失」。作为一个反复拆过多个教学项目的一线工程师我拿到这类代码包有个习惯先不急着跑train.py而是花十分钟把data_manager.py、mylstm.py、train.py三个文件里的关键函数从头看一遍确认词表构建、前向顺序、梯度更新这三条链路没有断。从那以后我每次拆 RNN 类项目都强制自己走一遍这个流程——它至少替我挡掉了一半以上的对坑时间。这份代码包的设计恰好就是按这个链路组织的顺着读下来你对手写 LSTM 的认知会是完整的。希望帮到你。本文还有配套的精品资源点击获取
返回列表