RNN实现《流浪地球》猫眼影评三分类情感识别教程

发布时间:2026/10/7 2:57:27
RNN实现《流浪地球》猫眼影评三分类情感识别教程 简介一份以《流浪地球》猫眼平台真实用户评论为实例的循环神经网络情感分析项目面向自然语言处理、深度学习方向的初中级学习者及影视舆情分析人员旨在解决影评数据量大、人工标注困难时如何利用RNN自动完成情感极性三分类正面、中立、负面识别与结果分析的问题。压缩包共25个文件、约8.99MB文件类型涵盖4个Python源码、2个Jupyter Notebook、2个CSV数据文件以及图片、说明文档等源码目录中内置d2l深度学习组件支持PyTorch、MXNet、TensorFlow等后端切换便于在不同环境中运行。另有Pyc编译文件与多张可视化图片可帮助快速查看模型训练过程中的特征与结果。目前资源已有72人学习下载适合希望从数据采集到建模评估完整走通文本情感分析流程的读者。通过该资源可复现爬虫采集、评论清洗、分词、词向量嵌入及RNN训练评估的完整链路附带的说明文件和数据也便于理解实验设定、核心代码逻辑以及三分类结果对后续拓展到其他影视作品的评论情感挖掘有直接参考价值。1. 为什么用《流浪地球》猫眼影评和 RNN 做三分类情感识别一句话说清这个标题在干什么从猫眼平台抓取《流浪地球》的用户短评依次做数据清洗、预处理、中文分词、词向量嵌入再交给 RNN 循环神经网络做训练评估最终输出正面、负面、中性三分类情感识别结果。它本质上是一条完整的文本挖掘流水线覆盖了网络爬虫、文本表示和深度学习三块内容。选《流浪地球》当语料有实际好处短评量大正负情感表达都很鲜明中性评论也足够多用来调三分类比那些冷门或一边倒的影片舒服得多。适合课程设计、毕业设计也适合想在内容平台里快速验证评论挖掘价值的从业者。2. 网络爬虫抓评论、清洗预处理与中文分词语料先收拾干净后面才能少返工2.1 猫眼评论接口定位、请求参数与重试策略常见做法是从浏览器开发者工具里抓 XHR 接口。打开《流浪地球》的猫眼电影详情页按 F12 切到 Network 面板过滤 comment 或 review 关键字下拉评论区就能看到真正的评论接口。接口地址通常是一串带 movieId、offset、limit 参数的 HTTP 请求返回 JSON 结构。不要拿页面 HTML 去解析页面改版你的代码就废了直接拿 JSON 字段最稳定。请求参数里基本绕不开 movieId这个电影的全局 ID、offset偏移量、limit单页条数和 type排序方式通常在 1 和 3 两个值里选。我一般把 limit 设成 50 到 100太大接口容易吞请求。headers 至少要伪装成一个看起来正常的浏览器会话User-Agent 直接复制你自己浏览器的即可Referer 填详情页地址Cookie 在未登录状态通常也能拿到一部分数据但如果要求高就用已登录会话的 Cookie 兜底。import requests import time import json def fetch_reviews(movie_id: int, offset: int, limit: int, headers: dict) - list: # 实际接口地址从开发者工具里复制别手打 url https://example.com/api/comment params {movieId: movie_id, offset: offset, limit: limit} for attempt in range(3): try: resp requests.get(url, paramsparams, headersheaders, timeout5) resp.raise_for_status() data resp.json() return data.get(comments, []) except Exception as e: print(f第 {attempt 1} 次请求失败: {e}) time.sleep(2 ** attempt) # 退避策略连续失败时指数休息 return []这段代码的逻辑很简单请求失败不立刻放弃而是按照 2 的指数次方秒数退避后重试最多三轮。为什么要这个退避策略因为猫眼这类站点对高频请求非常敏感连续失败时你不休息继续硬刚大概率触发风控。请求成功后我建议先落盘存 JSON不要立刻转 CSV。原始 JSON 保留了评论 ID、时间、点赞数等元数据万一后面想做深度分析重新取字段时你会感谢这个习惯。爬取循环里每请求完一页至少 sleep 0.5 到 1 秒数据量小没必要追求极限速度。2.2 评论清洗预处理去重、去噪、保留情感标点采集完的原始评论不会直接拿去分词先过几步清洗。第一是去重同一个用户多次短评或者营销号复制粘贴都会造成重复样本。轻量做法是对文本做归一化后算 MD5重了就丢弃全角转半角去掉空白字符再哈希比较。第二是去噪具体操作是去掉 URL、用户、HTML 实体和系统插入的话题占位符。这里有个关键原则宁少勿多。数据清洗里最容易翻车的是把带情感信息的符号一起删了。比如中文影评里“太好了”三个感叹号本身是强烈正向信号你把感叹号全清掉模型就少了一个特征。再比如“特效强到没话说”这种表达光靠字面很难识别但标点和上下文还能提供一部分线索。我一般会保留感叹号和问号这两种标点替换成特殊标记让模型学到“情绪强烈”的隐含含义。常用做法是构造一个统一的清理函数。import re import hashlib def normalize_text(text: str) - str: # 全角转半角去掉首尾空白再做归一化 text text.replace(\u3000, ).strip().lower() return text def clean_review(raw: str) - str: text raw text re.sub(rhttps?://\S, , text) # 去 URL text re.sub(r\S, , text) # 去 用户 text re.sub(r[^], , text) # 去 HTML 标签 text text.replace(amp;, ).replace(nbsp;, ) # 解码实体 # 感叹号和问号是情感强度特征替换成标记而不是删掉 text text.replace(, !!! ).replace(, ! ) text text.replace(, ??? ).replace(, ? ) text re.sub(r[ \t], , text).strip() return text def is_duplicate(normed: str, seen: set) - bool: digest hashlib.md5(normed.encode(utf-8)).hexdigest() if digest in seen: return True seen.add(digest) return False这里的核心点在两个替换语句把感叹号和问号转成带空格的形式后续 jieba 分词会把它当作独立 token 处理。后面构建词表时可以检查这些标记是否保留如果明显对预测有帮助就留着。我个人经验是这类特殊标点在短评数据集里往往比某些高频词更有区分度。去重之后要处理评论长度。短评大多在 10 到 50 个中文字符之间但也会混入“哈哈”“不错”这种极短评论。不要直接扔因为“哈哈”可能是正面情绪。反而是那些单字评论比如“烂”要特别处理建议加进词典而不是简单过滤。2.3 中文分词jieba 用户词典、停用词表与词性过滤清洗完的文本下一步是分词。英文自然语言用空格切开就行中文必须过一个分词器jieba 是绕不开的开源选择生态成熟自定义词典也方便。两个地方最容易踩坑一是默认词典不覆盖《流浪地球》里的电影专名“流浪地球”可能被切错“MOSS”变成英文单词甚至“刘启”和“刘培强”这两个角色名都可能被切得七零八落二是停用词表如果过度删除把含否定意义的“不”“没”“太”去掉情感极性直接反转。分词的稳定实践是每次跑新语料之前先加载一个用户词典把片名、导演、主演、关键道具和网络用语全部写进去。然后停用词表只删那些确实无语义功能的词比如“的”“了”“啊”“呀”“就是”“一个”等否定词、程度副词一个都不能删它们是情感分类的主心骨。最后可以做词性过滤保留动词、形容词、名词副词看情况保留。import jieba import jieba.posseg as pseg jieba.load_userdict(movie_userdict.txt) # 每行一个词可带词频和词性 # 停用词表里永远不要删以下这类词不、没、太、很、反正、居然 stop_words set() with open(stopwords_cn.txt, encodingutf-8) as f: for line in f: w line.strip() if w in {不, 没, 太, 很, 反正, 挺}: continue stop_words.add(w) NEG_WORDS {不, 没, 太, 很, 挺, 反} def tokenize_with_pos(text: str) - list: result [] for word, flag in pseg.cut(text): w word.strip() if not w: continue if w in stop_words and w not in NEG_WORDS: continue # 标点只保留感叹号和问号标记 if w in {!, ?}: result.append(w) continue if flag in {x, w, t, f} and w not in {!, ?}: continue result.append(w) return result这段代码做三件事主体过滤靠停用词表其次把感叹号和问号标记成情感强度 token第三排除部分词性。跑完必须检查分词结果不能盲目扔给模型。常见做法是随机抽 200 条人工看一下确认“流浪地球”有没有被整体保留、“不”有没有被误删。我还额外关注含“但”的句子“特效不错但剧情拉胯”这种转折结构分完词必须保留“但”它是情感极性的转折点。3. 词向量嵌入与序列构造把评论变成 RNN 能接收的定长输入3.1 自训 Word2Vec 还是加载预训练词向量分词之后你得到的是字符串序列模型不能直接吃字符串必须转成词向量。这里有一个选择自己用 gensim 训练 Word2Vec还是直接加载中文预训练词向量。两者各有取舍我来说实际场景下的判断逻辑。预训练词向量覆盖词面广一般有十几万个词像“吴京”“刘慈欣”这种专名通常也有对应向量。但它有两个问题一是词表来自新闻、百科类语料和电影短评的口语表达有一定分布差异二是模型训练时你很难靠预训练向量的空间把“烂片”“吹爆”这类网络用语真正刻进语义关系里。所以在这个项目里我的推荐做法是用语料自训练 Word2Vec能保证电影的领域词汇和网络用语都进入词表。常见做法是拿预训练向量做随机初始化再用自己语料微调但这个任务自训往往已经够用。实际操作时先把分好词的列表全部汇总成句子列表然后交给 Word2Vec。gensim 的接口很稳定但参数要刻意设置下一小节展开。3.2 三个必调参数embedding_dim、window 与 min_count挑三个参数按影评短文本场景展开。embedding_dim 就是词向量的维度短评这种轻量任务 128 已经够用上到 256 反而容易过拟合尤其是当你的数据集只有几千条时。window 指上下文窗口大小短评平均句长短我一般取 5窗口再大会把同一句话里隔得太远的词关联起来引入噪声。min_count 是词频阈值在语料里出现次数少于这个值的词不进入词表统一当作 unknown 处理。对于这个任务min_count3 是安全的能压掉拼写噪声又不至于把低频但关键的网络词弄丢。通用训练代码示例from gensim.models import Word2Vec # all_seg_lists 是所有评论分词后的二维列表 model_w2v Word2Vec( sentencesall_seg_lists, vector_size128, window5, min_count3, sg1, # skip-gram小语料下比 CBOW 更容易学出区分度 epochs10, # 短文本语料 10 轮足够别贪 workers4 ) # 训练完顺手抽几个词检查语义质量 for w in [特效, 剧情, 好看, 烂片]: if w in model_w2v.wv: print(w, [x[0] for x in model_w2v.wv.most_similar(w, topn3)])代码解释sg1 选用 skip-gram 是因为语料规模小它对低频词的向量质量更友好如果语料大到几十万条CBOW 训练更快。epochs 设置在 10 轮左右短文本重复出现次数高练太久反而让模型过拟合到具体搭配。窗口 5 保留前后 5 个词的共现关系对影评这种短文本足够了。词向量训练完别急着送进模型先跑一个相似词冒烟测试。这是在打开黑匣子前做检查看“特效”的最近邻是不是“画面”“视觉”“音效”。如果最近邻全是无关词说明分词质量或训练参数有问题先回头调不要继续往下走。这一步在我做过的项目里能拦下将近三成的“翻车”。3.3 定长填充与截断max_len 怎么选什么时候用 packRNN 的训练单位是 batchbatch 内每条评论长度必须对齐。我一般先统计长度分布用 99 分位数当 max_len。短评场景常见 max_len 在 120 左右高于 200 就过头了。为什么要选 99 分位而不是最大值因为个别刷屏长评的条数极少却会把序列撑到几千全体跟着它对齐计算量浪费损失更大的是大量样本被 padding 稀释掉。PyTorch 里构造定长序列from torch.nn.utils.rnn import pad_sequence def make_sequence(word_ids: list, max_len: int): if len(word_ids) max_len: word_ids word_ids[:max_len] return word_ids sequences [make_sequence(wids, max_len) for wids in all_word_ids] padded pad_sequence( [torch.tensor(seq, dtypetorch.long) for seq in sequences], batch_firstTrue, padding_value0 ) # padded 形状: [batch_size, max_len]关键点padding_value0词表里 0 号 token 永远留给 不参与梯度更新。训练时可以用 pack_padded_sequence 把真实长度计算量压下来避免 RNN 在 padding 部分做无用功from torch.nn.utils.rnn import pack_padded_sequence, pad_packed_sequence packed_seq pack_padded_sequence( emb, lengths, batch_firstTrue, enforce_sortedFalse ) output, hidden rnn(packed_seq) output, _ pad_packed_sequence(output, batch_firstTrue)这里有个实现差异。Keras 里 GRU/LSTM 层自带 mask 机制自动跳过 paddingPyTorch 里可以用 pack 处理跳过。我从一开始就推荐用 pack_padded_sequence习惯成自然后面做长序列预测时不用回头改结构。4. RNN 模型训练与评估从模型结构到三分类收敛4.1 模型结构Embedding BiGRU 全连接的三分类设计很多教程一上来就堆 LSTM而我会从 BiGRU 开始。GRU 比 LSTM 少一个门参数更少短文本任务上和 LSTM 精度基本持平训练速度更快过拟合风险也更低。输入层是 nn.Embedding中间是双向 GRU最后接一个全连接层做三分类。为什么用双向而不是单向因为影评里一句“前半段一般后半段炸裂”的情感极性强依赖后半段内容单向 RNN 读到结尾时前半段信息已经被长程遗忘压得很弱双向结构可以让模型在任意时间步同时看到前后的上下文。import torch import torch.nn as nn class BiGRUClassifier(nn.Module): def __init__(self, vocab_size, embed_dim, hidden_dim, num_classes3, dropout0.4): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.rnn nn.GRU(embed_dim, hidden_dim // 2, bidirectionalTrue, batch_firstTrue, num_layers1) self.dropout nn.Dropout(dropout) self.fc nn.Linear(hidden_dim, num_classes) def forward(self, x): emb self.embedding(x) # [B, T, E] out, hidden self.rnn(emb) # out: [B, T, hidden] # 取最后一个时间步的隐藏状态拼接。注意双向的顺序 last_forward out[:, -1, :hidden_dim // 2] last_backward out[:, 0, hidden_dim // 2:] h torch.cat((last_forward, last_backward), dim-1) h self.dropout(h) logits self.fc(h) # [B, 3] return logits这段代码里 forward 取的是 out 而不是 hidden原因在于双向 GRU 返回的 hidden 是两层双向状态的拼接直接用 hidden 拼接容易把方向搞反out 里则明确按时间步保存了方向信息。取最后一个前向状态和第一个反向状态拼起来就是一个既能看见前文又能看见后文的顶点向量。实现这里有个坑双向 GRU 取最后一个时间步时反向状态并不是最后一个时间步的输出而是第一个时间步的输出。很多人在这里直接把 out[:, -1, :] 全拿过来用等于同时取了正向最后一个和反向最后一个方向信息全乱了。只要把 forward 里那两行记熟用双向 RNN 基本不会再踩这个坑。4.2 训练配置learning_rate、batch_size、dropout 与早停模型落到训练阶段有四个参数需要刻意设随便取默认值往往不收敛或过拟合。第一个是 learning_rate。Adam 时代大家都默认给 1e-3但我会看数据集规模。几千条短评我一般从 2e-3 起步跑 3 到 5 个 epoch 后观察损失曲线。稳定下降就继续震荡就减半降到 5e-4。第二个是 batch_size短文本场景不要贪大64 或 128 都可以。batch 太大模型更快见过整个数据集容易过早收敛到次优解。第三个是 dropout。这个任务 0.4 起步模型小可以降到 0.3。千万不能开 0否则模型在学习阶段过拟合到句子级搭配验证损失很快就抬起来。第四个是早停 patience。我固定用 3 个 epoch 不下降就停同时把验证集上表现最好的模型权重重新恢复。训练管线代码大致如下import torch.optim as optim criterion nn.CrossEntropyLoss() optimizer optim.AdamW(model.parameters(), lr2e-3, weight_decay1e-4) scheduler optim.lr_scheduler.ReduceLROnPlateau(optimizer, modemin, patience2) best_f1 0 patience 0 for epoch in range(20): model.train() for batch_x, batch_y in train_loader: optimizer.zero_grad() logits model(batch_x) loss criterion(logits, batch_y) loss.backward() optimizer.step() model.eval() val_loss, val_pred, val_true evaluate(model, val_loader) scheduler.step(val_loss) macro_f1 f1_score(val_true, val_pred, averagemacro) if macro_f1 best_f1: best_f1 macro_f1 torch.save(model.state_dict(), best_model.pt) patience 0 else: patience 1 if patience 3: print(fEarly stop at epoch {epoch}) break要点说明CrossEntropyLoss 在多分类里同时包含 softmax 和损失计算所以你网络最后一层别再手动加 softmax否则等于做了两次。额外加权衰减 1e-4对影评短文本有实测帮助能阻止部分高活跃词向量把自己学到极端。评分用宏平均 F1具体原因下一小节展开。4.3 评估指标准确率只是入场券F1 和混淆矩阵才见真章三分类情感识别项目里我最警惕的指标就是准确率。一个极端例子如果语料里正面占 50%中性占 30%负面占 20%你全部猜正面准确率就有 50%。模型看起来没白练但这种结果没有任何使用价值。尤其是情感分类天然不均衡准确率虚高几乎是常态。正确评估方式看三样东西混淆矩阵、宏平均 F1、置信度分布。混淆矩阵能直观暴露模型把负面误判成中性的系统性行为这是方向性线索宏平均 F1 把三个类别的 F1 做算术平均不会让小类别被大类别淹没。置信度分布则能看到模型的判断拿不准的区域这部分是后续阈值调优的原材料。from sklearn.metrics import confusion_matrix, classification_report # val_pred 是 N 个样本的标签val_true 是真实标签 cm confusion_matrix(val_true, val_pred) print(cm) print(classification_report(val_true, val_pred, target_names[负面, 中性, 正面]))看输出时第一眼看主对角线上的数字再盯一下负面-中性和中性-负面两个交叉项。电影短评里中性往往会和负面混淆因为很多人用“平淡”“一般”“普通”表达没有明显夸也没有贬模型如果都归到中性说明它对负面强度的感知还没学到。5. 避坑与排查RNN 影评三分类最容易翻车的 4 个问题5.1 数据不平衡模型学成“全猜中性”现象训练完成后测试集预测结果里中性占了大半正面和负面几乎没有输出分类报告里中性 F1 很高另外两个类别惨不忍睹。原因短评数据正面一贯偏多中性也不少负面相对少。CrossEntropyLoss 对每个样本一视同仁模型只要学会把多数类输出成中性就能得到很低的平均损失根本不需要去学负面特征。解决先按标签分布做 class weight给少数类更高权重是最直接的改法。PyTorch 里把 weights 传进 nn.CrossEntropyLoss(weightweights)。如果加权后 F1 还是上不去再对训练集做分层采样确保每个 batch 内部三类数量都差不多。我用分层采样比较多它比权重法更容易稳定收敛代价是每个 epoch 要多做一步重排。5.2 分词拆碎情感词导致极性判断错误现象某条评论原文是“不烂但也没吹得那么神”分词结果变成“不 / 烂 / 但 / 也 / 没 / 吹得 / 那么 / 神”模型预测成负面而“烂”单独出现时也是负面模型没有真正抓住否定结构。原因词典对“吹得”“神作”这类组合覆盖不足把网络用语当普通词切开。更关键的是停用词表把“不”“没”当作停止词删掉让原本的否定结构失效了。解决停用词表永远不要包含否定词和程度副词我在 2.3 已经强调过。然后对领域专名和组合词写用户词典“吹得”“神作”“烂片”“还行”这类评论高频词全部在 userdict 里明确指定成整体。分词这一步做完后必须抽样看结果这是唯一可靠的检查手段。5.3 长影评梯度消失模型只记住结尾情绪现象一条 400 字的影评前文全是夸结尾补了一句“但整体可以一看”模型最后判成正面。人工看正文发现整段其实是负面评价模型完全没抓住。原因RNN 的长期依赖能力有限越长的序列前面信息被遗忘得越干净。单取最后时间步的隐藏状态作为全连接输入时丢掉的远不只是细节而是整段中段信息。解决第一选择是把模型改成双向 GRU前面 3.3 提到的 pack 处理能减少无效计算。第二是把 max_len 限制在 200 以内超长评论截断到主干部分让模型聚焦在核心情绪区间。第三种方案是加一层轻量注意力对所有时间步输出做加权求和弱化开头信息对尾部的不公平影响。对于这个任务前两种足够注意力会额外加参数短文本里性价比未必高。5.4 训练损失下降、验证损失上涨过拟合的抢救顺序现象训练集 loss 从 0.9 稳步跌到 0.2验证集却从 0.8 先降后涨到 1.2F1 也随之回落。这时候有人开始调各种参数各种玄学操作都上了。原因模型容量对几千条短评数据来说太大了。embedding 层本身就有十几万参数训练过程中把每个词的向量都朝训练样本里的语境逼近一旦遇到和训练样本分布不一致的词推理时就全乱。解决我遵循固定抢救顺序前面 4.2 里有提到。先开 dropout给 embedding 出口的向量加 Dropout再做 weight_decay用 1e-4如果验证损失继续涨就降低 hidden_dim。以上都试过还是不行就回到数据层面准备更多标注数据。6. 进阶技巧用置信度阈值细化三分类判断模型训练完得到的是每个样本在三个类别上的 softmax 概率。很多人直接取 argmax 当最终标签我在实际项目里通常把这一步换成两阶段判断第一阶段用 argmax 拿到标签第二阶段再看这个样本的置信度低于 0.6 的就归入不确定区拿回人工复核或默认归中性。这背后的逻辑是三分类里置信度低本身是有业务价值的信号。比如平台要做差评预警模型把 0.6 概率的中性和 0.95 概率的中性一样看待最终会漏掉那些看起来最像差评但还没完全踩线的用户。我在拿猫眼影评数据分析时会把负面类概率大于 0.7 的评论拿出来人工复核效果比只看 argmax 精确得多。如果想让阈值调整更系统化可以对 logits 做一次温度缩放。温度 T 是一个标量在 softmax 之前把 logits 除以 TT 大于 1 时概率分布被拉平模型更保守T 小于 1 时更尖锐模型更自信。训练完在验证集上扫几个 T 值看 F1 曲线的峰值点。这个手段像开盲盒但意外有效尤其是对中性类过度自信的问题。def temperature_scale(logits: torch.Tensor, temperature: float) - torch.Tensor: # T1 就是原始模型T1 更尖锐T1 更平缓 return (logits / temperature).softmax(dim-1)我做过的项目里T 在 0.8 到 1.2 之间扫一遍宏观 F1 一般能有 1 到 3 个百分点的提升代价几乎为零。唯一要注意的是温度缩放要在验证集上做不能用训练集否则就是把过拟合搬到了后处理层。还有一个小技巧比较冷门但好用把接近边界让模型拿不准的样本单独拉出来检查特征词。如果模型在“特效不错剧情不行”上怎么都拿不准把这类样本输入和特征权重打印出来人工看一遍它为什么模糊。RNN 隐藏状态说到底是一个黑匣子但让黑匣子输出一条可视化样例比盯着 loss 曲线反复调参更实际。我自己的经验教训是别后半段一路追着 F1 跑最终给业务看结果时最关心的不是 0.02 的 F1 提升而是阈值调整后误判到底减了多少。调阈值比调模型结构划算先把这个便宜占到手再考虑更复杂的模型。如果中途哪一步卡住了回到清洗和分词环节复查一遍比重新调参更有效。希望帮到你。本文还有配套的精品资源点击获取