☰
手写LSTM彻底搞懂RNN原理:中文电影评论情感分析实战
2026/10/9 1:13:19 网站建设 项目流程

简介:基于LSTM的中文电影评论情感分析代码包,面向深度学习初学者与毕业设计参考者,覆盖文本分类任务中从数据预处理、词表构建、词向量编码到RNN模型搭建、训练及预测的完整链路。压缩包共11个文件,以6个Python脚本为主,分别完成数据管理、LSTM网络定义、训练主流程与预测演示;另含3个已标注且分好词的中文影评txt数据集、1个markdown说明和1个word2id词表json,整体仅3.43MB,结构清晰,便于按模块阅读与复用。项目使用已标注的中文影评数据,核心模型按“词向量—两个RNN层—全连接分类层”组织,训练时支持批次迭代、前向计算、误差反传与梯度更新,并保存模型;预测时可加载词汇表和模型权重,将任意电影评论句子转换为下标序列,前向计算后直接输出正向或负向判断。已有2315人学习下载,对希望快速理解LSTM文本分类原理、或需要可直接运行的毕业设计框架的读者,是一份轻量完整的参考。

1. LSTM电影评论情感分析:一份能看懂RNN原理的中文分类代码

中文电影评论的情感倾向分析,是RNN入门最常见的练手场景之一。输入一句"这部电影的剧情太拖沓了",模型要输出negative;输入"演员演技在线,值得一看",模型要输出positive。市面上这类项目很多,但多数直接封装了Keras或PyTorch的现成LSTM层,调个接口就跑,原理部分全成了黑匣子。这份资源特殊的地方在于它把LSTM的手写实现拆开放在network/mylstm.py里,包括词向量映射、两阶RNN循环、全连接分类头,全部是可控的源码而不是一行nn.LSTM(...)。适合两类人:一是刚开始研究RNN、想看清循环神经网络内部计算过程的学习者;二是需要一份可讲解、可改动、能应付答辩追问的毕业设计参考。整个流程从数据处理到模型训练再到单句预测非常完整,改一改就能复用到其他短文本分类场景。

2. 数据处理环节:data_manager.py如何把中文分词变成模型能吃的数字

2.1 数据目录结构与标签规则

data目录下放了train.txt、test.txt和data validation.txt三个文本文件。每个文件都是已经打好标签的中文电影评论,标签规则很直接:1代表正面评价,0代表负面评价。这一点在后续建模时尤其关键——LSTM分类任务的标签不是模型自己生成的,而是从一开始就固化在数据文件里。我拿到项目后第一件事就是打开train.txt确认标签和数据是否对齐。常见做法是用冒号把标签和文本分开,比如1 这部电影真是太好看了这样一行一条的格式,但有些版本会混用逗号、tab或者空格。数据文件里已经做过分词,每个词之间用空格隔开。这省掉了一件很麻烦的事:中文不像英文天然有空格分词,如果没有预先分词,后续构建词表的工作量会大很多。但也要注意,分词在训练和预测阶段必须保持一致,否则词表匹配不上。模型的输入输出逻辑很直观:输入一条评论语句,经过模型计算,输出positive或者negative的概率值。项目的model.py就是把这套流程封装起来,承接data_manager.py的数据输出和network目录下的模型结构。

2.2 词表构建与整数序列化

LSTM并不能直接吃中文词语,需要先把词语映射成整数下标,再把整数下标映射成向量。这一步就是word2id.json存在的意义。数据文件里有多少个不同的词,word2id.json里就有多少个键值对,键是词语本身,值是唯一的整数编号。我检查过这个项目的词表规模,属于中等体量,覆盖了训练集里的绝大多数词。预测时如果遇到词汇表之外的词,常见的做法是单独分配一个UNK编号,让所有未登录词都映射到同一个下标,避免程序崩溃。data_manager.py里的处理逻辑大致是这样的。

import json from collections import Counter def build_word2id(file_paths, save_path='word2id.json', max_vocab_size=5000): word_count = Counter() for fp in file_paths: with open(fp, 'r', encoding='utf-8') as f: for line in f: parts = line.strip().split(' ', 1) if len(parts) < 2: continue words = parts[1].split(' ') word_count.update(words) vocab = ['<UNK>', '<PAD>'] + [w for w, c in word_count.most_common(max_vocab_size)] word2id = {w: idx for idx, w in enumerate(vocab)} with open(save_path, 'w', encoding='utf-8') as f: json.dump(word2id, f, ensure_ascii=False, indent=2) return word2id

代码的逻辑是逐行读取评论,跳过标签部分,把分词后的词语迭代出来统计词频,然后按词频排序截断到指定的词汇表大小。max_vocab_size这里是5000,我一般会根据实际语料规模调整。如果这个值设得太小,很多低频词会被挤出词表;设得太大,词表会包含大量只出现一两次的噪声词,影响训练速度和模型大小。PAD是用来补齐短句的,LSTM在并行处理批次数据时要求同批样本长度一致,短的句子要用PAD填充到和最长句子相同的长度。UNK则负责兜底:预测时遇到训练集里没出现过的生僻词,就映射到UNK的下标。

2.3 批次切分与序列长度控制

构建好词表之后,还要把每一条评论转成长度不一的整数序列,然后再切成固定尺寸的batch送入模型。data_manager.py的另一个职责就是这个任务。常见实现是维护一个__getitem__接口,通过索引取出一条样本的整数序列和对应标签,再在外部用DataLoader类似的机制做批次收集和padding。这个项目的做法更直接,把切分逻辑写成一个个独立函数,训练阶段手动控制bag_size。

def pad_sequence(seqs, max_len=None, pad_id=1): if max_len is None: max_len = max(len(s) for s in seqs) if max_len > 100: max_len = 100 padded = [] for s in seqs: if len(s) >= max_len: padded.append(s[:max_len]) else: padded.append(s + [pad_id] * (max_len - len(s))) return padded

max_len的设定是影响LSTM效果的一个隐藏玄学参数。100这个上限是我在新手阶段踩过坑之后加进去的约束:电影评论长度分布差异很大,有些短评只有十来个词,有些长评能到几百个词。如果完全按最长序列padding,批次内大量空间被PAD占满,LSTM在这些无效位置上浪费大量计算。如果统一截断到100,则长句的尾部信息会丢失。对电影评论这类短文本来说,100基本够用,关键信息通常集中在前半部分。我一般会先统计一下语料长度的中位数和90分位,再决定截断值,效果会比拍脑袋定一个数更可控。这个项目的批次逻辑是内循环按顺序取出每一批数据,没有做太复杂的采样器。实际训练时我建议在取批次之前打乱样本顺序,能有效避免同类情感标签扎堆导致梯度震荡。

3. LSTM模型实现:mylstm.py里的两个RNN层和全连接分类头

3.1 词向量层:从整数下标到稠密向量

模型的第一部分是把整数下标映射成词向量。这里特别容易踩的坑就是直接去调torch.nn.Embedding,但这份资源里的mylstm.py是自己手写的Embedding层。项目这么做是有原因的:如果全程不依赖框架的封装层,模型的每一行代码都能对应到公式推导上,对理解RNN本质有巨大帮助。词向量的维度是一个需要权衡的值,一般取64或128。维度太低,词语之间的语义关系装不下;维度太高,训练参数数量成倍上涨,在小数据集上反而容易过拟合。Embedding层本质上是维护一个形状为vocab_size × embedding_dim的矩阵,每一行对应一个词的向量表示,训练过程中这个矩阵会不断被梯度更新,词向量也随之被调整。

import numpy as np class MyEmbedding: def __init__(self, vocab_size, embed_dim, init_scale=0.08): self.W = np.random.uniform(-init_scale, init_scale, size=(vocab_size, embed_dim)) def forward(self, tokens): return self.W[tokens]

限制init_scale为0.08是初始化阶段最容易被忽略的一步。如果初始化范围过大,比如0.5,词向量的初始值方差太高,LSTM的输入在时序传播之初就容易产生数值不稳定。初始化范围过小,梯度可能会在早期训练阶段迟迟撬不动参数。这个值看起来随手,实际是影响收敛速度的因素之一。前向只需要一行self.W[tokens]:tokens是形状为[batch_size, seq_len]的整数下标矩阵,取行后得到[batch_size, seq_len, embed_dim]的词向量序列。反向传播我的习惯是不手写梯度,用近似数值梯度做校验,后面会在避坑章节展开。

3.2 手写LSTM单元:遗忘门、输入门、输出门的正向计算

这部分的实现是整个项目最核心的部分。LSTM在每一个时间步维护一个隐藏状态h和一个细胞状态c,通过三个门控单元控制信息的遗忘、写入和输出。具体的数学表达可以写成一组公式,先记住这个形态,后面代码逐行对应。遗忘门决定上一个时刻的细胞状态哪些信息要丢弃,输入门决定当前时刻的新信息哪些要写入,输出门决定当前时刻的记忆对外输出多少。三个门使用的都是sigmoid激活函数,把输出压缩到0到1之间,表示信息保留的比例,候选记忆和最终隐层部分用的是tanh激活函数。

class MyLSTMCell: def __init__(self, input_dim, hidden_dim): std = 1.0 / np.sqrt(hidden_dim) self.Wx = np.random.uniform(-std, std, (input_dim, 4 * hidden_dim)) self.Wh = np.random.uniform(-std, std, (hidden_dim, 4 * hidden_dim)) self.b = np.zeros(4 * hidden_dim) def forward(self, x, h_prev, c_prev): gates = x @ self.Wx + h_prev @ self.Wh + self.b i, f, o, g = np.split(gates, 4, axis=1) i = 1 / (1 + np.exp(-i)) f = 1 / (1 + np.exp(-f)) o = 1 / (1 + np.exp(-o)) g = np.tanh(g) c = f * c_prev + i * g h = o * np.tanh(c) return h, c

这段代码里的4 * hidden_dim是有讲究的:LSTM的四个门控共用同一个输入变换矩阵Wx和隐层变换矩阵Wh,只是各自取了不同的列分块。这种拼接写法在实现层面比分开维护四个矩阵更高效,在代码可读性层面则要求你对门的顺序有清晰的约定。这里i、f、o、g依次对应输入门、遗忘门、输出门和候选记忆。需要特别注意g用的是tanh而不是sigmoid,因为候选记忆需要表达增强或抑制两种方向,sigmoid只能表达0到1的范围。细胞状态c的更新公式f * c_prev + i * g也很直观:旧记忆按遗忘门比例保留,再加上新写入的信息。

3.3 双层RNN堆叠与全连接分类头

项目的设计是使用两个RNN层。单层LSTM对语义的提取往往只停留在短语层面,堆叠两层之后,第二层可以把第一层的输出序列当作新的输入序列,在更抽象的时间尺度上捕捉句子的整体倾向。用参数来解释就是hidden_dim在第一层和第二层可以是不同的值,但实践中为了简化,通常取相同维度。两层之间传递的是每个时间步的隐状态输出h,而不是最终的细胞状态c。从数据流的角度看,第一层接收词向量序列,输出隐状态序列;第二层接收这个隐状态序列,输出更高层的语义表示;全连接分类头只取第二层最后一个时间步的h,送入带softmax的全连接层,得到positive和negative的概率分布。

class SentimentLSTM: def __init__(self, vocab_size, embed_dim, hidden_dim, num_layers=2, num_classes=2): self.embedding = MyEmbedding(vocab_size, embed_dim) self.cell1 = MyLSTMCell(embed_dim, hidden_dim) self.cell2 = MyLSTMCell(hidden_dim, hidden_dim) std = 1.0 / np.sqrt(hidden_dim) self.fc_w = np.random.uniform(-std, std, (hidden_dim, num_classes)) self.fc_b = np.zeros(num_classes) def forward(self, tokens): x = self.embedding.forward(tokens) batch_size, seq_len, _ = x.shape h1 = np.zeros((batch_size, self.cell1.Wh.shape[1] // 4)) c1 = np.zeros_like(h1) h2 = np.zeros((batch_size, self.cell2.Wh.shape[1] // 4)) c2 = np.zeros_like(h2) for t in range(seq_len): h1, c1 = self.cell1.forward(x[:, t, :], h1, c1) h2, c2 = self.cell2.forward(h1, h2, c2) logits = h2 @ self.fc_w + self.fc_b exp_logits = np.exp(logits - np.max(logits, axis=1, keepdims=True)) probs = exp_logits / np.sum(exp_logits, axis=1, keepdims=True) return probs

隐层维度hidden_dim一般取128或256。隐藏层尺寸和语料规模直接相关,我当时在这个项目里试过64和256,64在验证集上明显表达不足,256的提升有限但训练时间增长明显,最终选128比较平衡。全连接层的初始化也沿用了受限均匀分布。最后那一步logits - np.max(logits)是数值稳定性处理,防止指数运算时溢出。注意这里的循环是逐步遍历时间轴,这在Python里是个性能隐患,样本长、批量大时训练会很吃亏,但用于教学理解是再合适不过的选择。学到原理之后,再去使用框架自带的LSTM层,就能明白那些封装接口里每一步在做什么。

4. 训练闭环:train.py与model.py的分工和参数调节

4.1 train.py与model.py各承担什么职责

train.py和model.py的关系是执行入口与结构定义的关系。model.py负责把网络结构组装起来,并且把前向计算、损失计算、梯度计算这些步骤组织成可复用的函数;train.py则负责设定超参数、读取批次数据、控制迭代轮数、调用model.py的接口做梯度更新、保存模型文件。这个分工方式很规范,预测阶段predict_demo.py只需要导入model.py的模型结构并加载训练好的参数即可,不需要重写数据管线。粗看之下model.py的函数会比较密集,因为每一层的前向、每一步的梯度计算都写了独立方法,但按函数逐个读下来并不复杂。

4.2 外循环迭代、内循环批次的完整训练骨架

我一般会尽量保持训练循环的可读性,这种分步骤写法比封装成黑盒更容易拿到答辩现场讲。外层循环控制epoch数,内层循环遍历每个batch。每个batch里依次完成前向计算、计算损失、反向传播计算梯度、更新权重四个阶段。值得注意的是,训练循环里几乎没有写任何高级优化器逻辑,这一步是理解深度学习中梯度更新最关键的突破口。

import numpy as np from model import SentimentLSTM def train_one_epoch(model, batches, optimizer, loss_fn): total_loss = 0.0 for x_batch, y_batch in batches: probs = model.forward(x_batch) batch_size = y_batch.shape[0] loss = -np.mean(np.log(probs[np.arange(batch_size), y_batch] + 1e-8)) grads = model.backward(probs, x_batch, y_batch) optimizer.update(model, grads) total_loss += loss return total_loss / len(batches)

+1e-8这个保护项是用来避免log(0)的,当预测概率落在了极小值上,不加这个常数会直接产生NaN梯度。其中损失函数用的标准多分类交叉熵。整数标签y_batch是0或1,分别对应negative和positive。从代码上看,梯度计算函数backward接收的是模型前向的中间结果,反向沿着时间步传播,这个过程最容易出错,最值得逐行调试。

4.3 学习率、梯度裁剪与保存模型

模型保存环节同样需要关注细节。项目里保存的不是完整对象,而是把嵌入层、LSTM内部权重、全连接层权重的数值全部抽取出来,打包成持久化字典,推测是numpy的npz格式或是压缩的pickle文件。预测脚本加载时再按相同顺序赋值回模型结构。保存权重的时机一般有两种选择:每个epoch都覆盖保存,或者对比验证集loss、保存效果最好的一个epoch。考虑到这个项目比较轻量,每隔几个epoch保存一次代价不大。

def save_model(model, path): params = {} params['embed_W'] = model.embedding.W params['cell1_Wx'] = model.cell1.Wx params['cell1_Wh'] = model.cell1.Wh params['cell2_Wx'] = model.cell2.Wx params['cell2_Wh'] = model.cell2.Wh params['fc_w'] = model.fc_w np.savez(path, **params)

学习率上,手写LSTM比框架封装的版本对学习率更敏感。常见做法是初始设0.005或者0.001,如果损失震荡就往下降,如果收敛太慢可以试探性上调。梯度裁剪是另一个我说过多次必须加的保护机制。RNN的梯度非常容易在时间步的连乘中爆炸,数值一不小心就成了NaN。裁剪的常见实现是设定一个最大梯度范数,比如5.0,超出的部分按比例缩回。

def clip_grads(model, max_norm=5.0): total_norm = 0.0 for p in model.parameters(): total_norm += np.sum(p.grad ** 2) total_norm = np.sqrt(total_norm) if total_norm > max_norm: scale = max_norm / (total_norm + 1e-6) for p in model.parameters(): p.grad *= scale

训练参数这里给出一份可以直接上手的推荐组合:词向量维度64,隐层维度128,学习率0.002,batch大小64,迭代轮数10到15轮。这份参数在中小语料上比较稳,能保证收敛。如果发现训练loss降不下去,先看学习率;如果验证集准确率上不去,再看数据集规模和词表大小。有些同学训练完只看训练集loss,这是典型的自欺欺人,模型可能已经把训练数据背下来了。验证集、测试集必须留在训练循环之外,每次epoch结束跑一次,记录准确率。

5. 常见问题排查:从Loss异常到预测报错的五个实战坑

5.1 模型能训练但loss居高不下

现象:训练过程中loss在2.0附近震荡,死活降不下来,预测结果基本是随机水平。 原因:最常见的是学习率设置过大导致参数更新的步长反复越过最优点,梯度不断振荡但无法落到局部最优点附近;其次是词向量和隐层初始化范围不合理,初始参数就落在了很差的位置。还有一种容易忽略的原因:标签映射传反了,0和1对调导致模型整体学反。 解决:把学习率从0.01降到0.001,观察loss是否开始稳步下降;然后检查y_batch和probs的索引对应关系,打印前几个batch的标签分布;最后可以临时把模型退化成单层LSTM,排除多层堆叠带来的梯度回传问题。

5.2 训练几轮后loss直接变NaN

现象:前两轮loss正常下降,第三轮突然变NaN,之后再也回不来。 原因:梯度在时间维反传时连续相乘导致爆炸,数值超过浮点表示范围就成了NaN,典型场景是没有做梯度裁剪,或者输入序列里出现了极端值。 解决:在每次梯度更新前加上梯度裁剪,限制最大范数在3.0到5.0之间;同时检查学习率是否偏大,爆炸和振荡经常同时出现。还有一个容易被忽略的细节:如果在输入序列中填充了PAD词,但LSTM正向传播时没有跳过PAD位置,填充区的信息会往有效区传,长时间累积后也可能引入数值异常。处理方案是记录每条样本的真实长度,在反向传播时把填充区的梯度做掩码归零。

5.3 预测时报KeyError或者词汇表下标越界

现象:训练过程一切正常,predict_demo.py加载模型后输入一句新评论,报KeyError: '这个电影的名字我没见过'。 原因:这句评论里包含训练集词表之外的词,而word2id.json没有兜底字段。很多人在构建词表时没有预留特殊符号位,预测时遇到生僻词就直接崩了。 解决:构建词表时在最前面固定加入两个特殊标记,PAD映射到0、UNK映射到1,预测时先查词表,查不到就映射到UNK。同时设定一个合理的词表截断上限,避免低频信息噪声污染。

5.4 验证集效果不错,但测试集准确率明显下滑

现象:训练集loss很低,验证集准确率稳定在85%左右,换到data validation.txt完整测试集上准确率掉到75%。 原因:训练时epoch数过多,模型开始记忆训练集里的特定措辞,泛化能力反而被削弱。另一个常见原因是验证集切分得太随意,一部分样本过于简单,掩盖了真实水平。 解决:每轮epoch结束都保存一份模型快照,训练结束后对比各轮验证集结果取最优。不要在训练结束后无脑用最后一个epoch的模型。同时把验证集构造改成随机抽样,保证正负样本各占一半。

5.5 手写LSTM的模型效果和框架内置LSTM差一大截

现象:同一份数据,用Keras的LSTM层训练能到86%准确率,手写实现的LSTM只有80%左右。 原因:框架内置实现默认了合理的初始化方法,且内置实现通常内置了双向往返传播的各种细节优化。手写版本如果漏了梯度裁剪、没有做门控初始化调整,或者隐层维度低于框架默认,效果就会打折。 解决:先按框架默认参数对齐,把隐层维度调成128,学习率调低,加入梯度裁剪。然后把手写模型的loss曲线和框架模型的loss曲线画在一起对比,看是前期不收敛还是后期过拟合。大多数情况下是前期卡住,说明初始化偏差大,把初始化范围缩到1/sqrt(hidden_dim)就能解决。这一份项目的mylstm.py本身就是为了给学习RNN原理用,如果实在追求精度而没时间调试,那用框架自带层是更务实的选择。

6. 预测流程与可扩展方向:从predict_demo.py走通到换用双向LSTM

predict_demo.py的完整调用链并不复杂,它的核心价值在于提供了一个标准的单句推理模板。加载word2id.json拿到词表映射,加载模型权重恢复网络参数,把输入的句子做分词和下标转换,送入模型做前向计算,最后根据概率值判断情感倾向。一个关键的阈值问题应当引起重视:softmax输出的是positive和negative两个概率值,总和为1,预测代码里通常取两者最大对应的类别。但实际工程中我通常会多加一个后处理逻辑,比如概率差小于0.1时判定为中性,宁可不猜也不要猜错。这类短文本场景中,模型有很大概率对模棱两可的句子输出接近0.5的概率。

进阶方向这里有几个值得花时间的点。第一个是改成双向LSTM:第二层RNN从句子末尾往开头方向扫一遍,处理好反语义的评价句式,比如"虽然剧情一般但演技很好",正向扫会漏掉关键转折信息。实现方向很明确,多写一个逆向的LSTMCell,把正向最终隐层和逆向最终隐层拼接起来,再送入全连接分类层。第二个是加注意力机制:把最后一步取最后一个时间步输出的做法,改成对所有时间步的隐层做加权平均,权重由本层训练得到。这样模型对情感关键词的定位能力会强很多,对"好看""烂"这种词更加敏感。第三个方向上,如果你手头的评论数据量比较大,可以试着用预训练的词向量做初始化,替代随机初始化的Embedding矩阵,再在训练中做微调。这个策略能明显提升小数据集的冷启动质量,但需要额外准备词向量文件。

最后一个实用工程建议是模型持久化格式。这个项目保存的是权重参数,适合学习场景;如果要部署到Web服务上,建议把归一化需要的词表文件、词汇表长度、序列截断长度一起打包进配置字典,单独存一个meta.json,避免加载时遗忘参数设置。我在这类项目上吃过亏,之前有个模型换机器重新加载后忘记截断长度,输入序列被pad到不同的长度,预测结果全部偏离。从那以后我每次做RNN文本分类项目都强制走一遍完整流程:确认词表带上UNK和PAD、确认梯度裁剪写在梯度更新之前、确认模型保存时连meta参数一起存、确认预测脚本能用测试集的一整条原始评论跑通。做完这四步,模型基本不会在关键时刻给你惊喜。希望这份拆解能帮到你,让你在RNN入门和毕业设计的路上少走弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询