
简介基于Python实现对烂番茄电影评论情感分类的完整实践项目来源于华中科技大学大数据与人工智能课程大作业。项目包含源码、实验报告与数据集覆盖评论获取、文本预处理、情感特征提取、模型训练与评估等环节适合计算机、人工智能等专业学生用于课程设计、毕业设计或项目立项演示也能帮助对自然语言处理感兴趣的开发者快速上手深度学习情感分类流程。资源包共555个文件包括104份PDF格式报告文档、Python脚本、CSV/TSV数据文件、NPY向量文件以及TensorFlow训练事件日志等整体约41.89MB目录结构清晰便于按模块查阅。代码经测试运行成功答辩平均分达96分整体完成度高具有较好的参考性与扩展性。目前已有158人学习下载。借助该项目可系统理解基于深度学习的文本情感分类技术路线掌握数据加载、模型搭建、训练调参与结果分析的全流程同时提供远程教学服务遇到运行问题可私聊沟通适合在此源码基础上修改扩展实现其他分类或对比实验。1. 烂番茄影评情感分类为什么这门大作业值得完整复现一遍打开烂番茄Rotten Tomatoes的影评区每条短评右上角都有一个“Fresh”或“Rotten”标签这个看似人工标注的结果背后是一套自动情感分类系统。在华中科技大学的Python大数据与人工智能实践大作业里用Python对烂番茄电影评论做情感分类被很多学弟学妹当成“交了就能过”的常规项目。但真正动手跑一遍你会发现从拿到数据到写出一份能自圆其说的实验报告中间隔着清洗、特征工程、模型对比、复现性管理好几道坎。这篇笔记按交付顺序把整个方案拆开讲数据集怎么洗干净、传统机器学习的基线为什么必做、LSTM分类器怎么落地、复现时最常见的几个翻车点是什么以及实验报告里哪些细节真正加分。正在做大作业的学生可以照着复现想拿这个方向练手的工程师也能看到参数边界和调试路径。2. 拿到数据先别急于训练字段认知、标签编码与清洗细节2.1 数据集里有什么那些容易看漏的字段和真正能用的列手头导出的烂番茄影评CSV常见字段包括影评人名称、影评文本、新鲜度标签、电影标题、出版方、原文链接等。很多第一次做这个题目的人习惯性把整张表读进来然后一股脑把所有列拼进特征结果模型准确率低得离谱还找不到原因。其实真正用于情感分类的只有两列评论文本和标签列。我一般在拿到文件后先不改名、不预处理先把结构打出来看一遍。这样能避免在后续清洗步骤中因为列名拼写不一致而报错。import pandas as pd # 读取题目附带的烂番茄评论数据建议先把文件命名为 rotten_tomatoes.csv df pd.read_csv(rotten_tomatoes.csv, encodingutf-8) # 确认行数和列名防止读进来的表格和想象中不一样 print(df.shape) print(df.columns.tolist()) # 检查标签列的取值分布fresh 是“新鲜/正面”rotten 是“腐烂/负面” print(df[fresh].value_counts())读完先看df.shape如果行数和你预期的差很多大概率是编码或分隔符问题CSV里偶尔会出现某个字段值里自带逗号导致列解析错位。df.columns.tolist()能快速暴露这类问题。标签分布决定后续要不要做类别平衡处理而不是等模型训练完才发现指标怎么调都上不去。2.2 清洗时的两个必做步骤去空值与去HTML实体烂番茄影评是用户和影评人产生的文本天然带很多噪音。最典型的有两类一是review_text和fresh同时缺失的行二是文本里残留的HTML实体。公开渠道抓下来的评论文本里常常被编码成amp;引号变成quot;。如果不清洗模型会把“”当成一个词它既和情感无关还会在特征矩阵里占掉一个维度。清洗时不要只看几条样例就下结论用代码统计一遍再动手。# 评论文本是核心特征标签是监督信号缺失这两个字段的行直接丢弃 df df.dropna(subset[review_text, fresh]) # 同一段文本只保留第一次出现的行避免重复样本拉偏训练 df df.drop_duplicates(subset[review_text], keepfirst) # 把常见HTML实体还原成正常字符 df[review_text] ( df[review_text] .astype(str) .str.replace(amp;, , regexFalse) .str.replace(quot;, , regexFalse) .str.replace(#39;, , regexFalse) )dropna按列删除只对review_text和fresh两个字段做判断不用把整行所有列都要求非空。drop_duplicates注意keepfirst会保留先出现的那条如果你的文件里靠后的评论质量更高可以改成keeplast。.astype(str)这一步容易被忽略因为NaN虽然被删了但某些单元格可能是浮点类型直接str.replace会报AttributeError。2.3 标签映射与分层切分先给字符串贴上数值模型的损失函数只认数值不认字符串。“Fresh”与“Rotten”这两个词本身如果被当作字符串直接喂进模型会被当成类别特征。在PyTorch或sklearn里这都会导致后续训练报类型错误或者准确率出现随机水平。所以标签映射要专门做一个步骤并且要检查映射后的分布是否发生变化。label_map {fresh: 1, rotten: 0} df[label] df[fresh].map(label_map) # 检查标签是否出现 NaNnull 意味着 fresh 字段里存在其他取值 print(df[label].isna().sum()) # 再确认候选类型的类别是干净的 print(df[label].value_counts())做完映射后用isna()检查一次能发现数据里是否混入了类似“Fresher”或空字符串的脏标签。如果出现这种情况不要急着drop先df[fresh].unique()把取值打印出来看清楚脏数据的规模再决定是修正还是删除。这一步写在实验报告里能直接体现你对数据质量的判断能力。切分训练集和测试集时直接用train_test_split而不做分层参数指定是常见误用。烂番茄数据集的positive和negative比例并不是严格的50:50如果不固定stratify偶发情况下切出来的测试集里某一类占比会被放大导致准确率波动超过两个百分点。我习惯在切分时显式传入标签列。from sklearn.model_selection import train_test_split train_df, test_df train_test_split( df, test_size0.2, random_state42, stratifydf[label] )stratifydf[label]保证训练集和测试集里“正面/负面”比例与原数据集一致。random_state42是固定随机种子这一步为后面实验报告里的可复现性打下基础。这里多说一句整个项目被称作“大数据”实践但烂番茄影评这个规模充其量是单机可处理的中小型数据课程里用pandas完全没问题不用为了赶时髦去上Spark或Hive。3. 用传统机器学习跑通全流程TF-IDF 加逻辑回归的基线3.1 为什么要先做基线模型效果有参照报告才有说服力很多同学一上来就搭BiLSTM或直接上预训练模型最后实验报告里只写“准确率0.85”导师问一个问题就露馅这个0.85比谁好比随机猜测好多少和传统方法比有没有提升基线的意义就是给所有复杂模型提供一个参照系。TF-IDF加逻辑回归是文本情感分类里最经典、最稳定的组合几十万规模的样本在单机CPU上几分钟就能训练完且结果可解释能直接查看哪些词对“正面/负面”贡献最大。我用它作为全流程的数据管线验证器只有当基线跑通并且指标合理才放心把同一份数据交给深度学习模型。from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.pipeline import Pipeline pipeline Pipeline([ (tfidf, TfidfVectorizer( max_features5000, ngram_range(1, 2), min_df5, sublinear_tfTrue )), (clf, LogisticRegression(C1.0, class_weightbalanced, max_iter1000)) ]) pipeline.fit(train_df[review_text], train_df[label]) acc pipeline.score(test_df[review_text], test_df[label]) print(fBaseline Accuracy: {acc:.4f})这段代码用Pipeline把特征提取和分类器串起来pipeline.fit会自动先做TF-IDF再训练逻辑回归测试时也会用同一套流程不会出现训练集和测试集特征不一致的尴尬。max_features5000不是拍脑袋定的影评文本的词表通常有几万词但只保留出现频次最高的5000个词能有效抑制长尾噪音特征。ngram_range(1,2)同时保留单词和相邻双词组合这个设置非常关键因为“not good”这类双词组合如果被拆成两个单词否定语义就丢了。3.2 TF-IDF 参数怎么调从词频到权重的三重取舍TF-IDF本身解决一个核心问题一个词在单条文本里出现多了不一定是正面词要把文档频率也考虑进去。sublinear_tf是微软和很多竞赛方案里的常见选择它把词频取对数避免“good”在一篇长影评里出现30次导致权重爆炸。min_df5的含义是出现在少于5篇文档中的词直接丢弃。逻辑回归这边的C1.0是正则化强度的倒数C越小正则越强。我一般不会一开始就细调C先用默认值跑通再看验证集误差方向决定。class_weightbalanced在类不平衡时很有效它会根据标签占比自动给少数类更高的损失权重比手动复制样本数据安全得多复制回采样在之后第5章还会重点讲。用这份参数烂番茄影评的二分类准确率通常在0.76到0.82之间。如果你跑出来的结果远低于这个区间不用急着换模型先回头看清洗步骤是否把引号、缩写、大小写弄得一团糟。3.3 停用词在这个任务里要谨慎处理普通文本分类任务往往会调用sklearn内置的英文停用词表把“the, a, is”这类词全部去掉。但情感分类里not,no,never这些词携带强烈的情感极性绝对不能丢。内置停用词表一般不会包含否定词但有些同学觉得“多删点词更干净”手动往停用词表里塞了“not”结果“not good”变成“good”特征和标签直接冲突模型怎么训都乱套。我一般用一份保守的自定义停用词集合只删除那些纯结构性的词stop_words {the, a, an, and, or, but, of, to, is, are}but这类转折词在情感分析里其实也有作用但保留它并不会带来太大提升。稳妥做法是先把停用词设为空集跑一版再对比加停用词的版本哪个测试集指标高就留哪个。实验报告里写清楚你比较过这两种设置比随便复制别人的参数更容易拿分。3.4 基线结果写进实验报告一张表胜过三段话不少实验报告的模型对比环节只有一段话“实验表明LSTM效果更好。”这种写法在答辩时是送命题。建议用表格把基线指标和后续模型指标放在一起。模型AccuracyF1训练耗时CPUTF-IDF Logistic Regression0.7890.786约15秒TF-IDF LSTM0.8230.821约8分钟预训练模型0.8710.868约30分钟表中“训练耗时”这列很多人忽略但对证明工程价值很关键深度学习模型比传统模型高3个百分点但训练时间多了30倍是否值得实验报告里主动回答这个问题体现的是工程判断力。4. 把深度学习模型跑起来PyTorch实现一个能训练到85%的 BiLSTM 分类器4.1 为什么选 BiLSTM 而不是起步就上预训练大模型烂番茄影评是单领域短文本单条评论平均也就是两三百词LSTM的结构完全能够捕捉上下文信息而且它在CPU上也能跑出能看的结果。预训练大模型的准确率上限更高但要看硬件和时间预算。课程设计通常不允许长期占用实验室GPUCPU上跑一个几百MB的预训练模型一次训练要几小时很多学弟学妹因此放弃了实验的深入调参。BiLSTM的优势在于双向编码当前词的情感既能利用上文也能利用下文。比如“这部电影并不烂但也不惊艳”这种句子前半段和后半段各自引导出不同极性双向结构能捕捉到更多对比关系。4.2 构建词表与数据集类先把训练文本清洗后的内容拆成单词统计词频只保留出现次数不低于2的词。低频词在有限数据下学不到可靠向量统一映射到unk序列长度统一截断到200个词超过部分直接丢弃。这个长度对烂番茄影评足够用97%以上的评论都在这个长度以内。from collections import Counter import torch from torch.utils.data import Dataset def build_vocab(texts, min_freq2): counter Counter() for text in texts: counter.update(text.split()) vocab {w: i 2 for w, c in counter.items() if c min_freq} vocab[pad] 0 vocab[unk] 1 return vocab class ReviewDataset(Dataset): def __init__(self, texts, labels, vocab, max_len200): self.labels labels self.data [] for text in texts: tokens text.split()[:max_len] ids [vocab.get(w, vocab[unk]) for w in tokens] ids [vocab[pad]] * (max_len - len(ids)) # 后向填充到统一长度 self.data.append(torch.tensor(ids)) self.labels torch.tensor(labels, dtypetorch.float) def __len__(self): return len(self.labels) def __getitem__(self, idx): return self.data[idx], self.labels[idx]pad索引固定为0后续在nn.Embedding里设置padding_idx0pad位置的向量就不会参与梯度更新。填充放在序列尾部而不是头部保持原文的时间顺序。vocab[unk]设为1表示所有低频词共享同一个“未知词”向量这比随机初始化一个无效向量更稳定。4.3 BiLSTM 分类器的完整结构网络结构分四层Embedding嵌入层、双向LSTM层、Dropout层、全连接输出层。Embedding把每个词映射到128维实数向量LSTM在两个方向上对各词编码最终把最后一个时间步的前向隐状态和第一个时间步的反向隐状态拼接成固定长度再送入全连接层。import torch.nn as nn class BiLSTMClassifier(nn.Module): def __init__(self, vocab_size, embed_dim128, hidden_dim128, num_layers2): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.lstm nn.LSTM( embed_dim, hidden_dim, num_layers, batch_firstTrue, bidirectionalTrue, dropout0.3 ) self.dropout nn.Dropout(0.3) self.fc nn.Linear(hidden_dim * 2, 1) # 双向隐状态翻倍 def forward(self, x): emb self.embedding(x) out, _ self.lstm(emb) # 取前向最后一个时间步和反向第一个时间步拼接 last_fwd out[:, -1, :self.lstm.hidden_size] last_bwd out[:, 0, self.lstm.hidden_size:] feat torch.cat([last_fwd, last_bwd], dim1) return self.fc(self.dropout(feat)).squeeze(1)bidirectionalTrue是双向开关只改这一个参数参数量会翻倍效果提升大约1到2个百分点。dropout0.3加在LSTM层之间和全连接层之前主要防止小训练集上的过拟合。注意力机制理论上能提升模型但在这个数据规模下收益有限我一般不会在首次实验时就加进去原因很简单注意力可视化的分析适合报告加分不适合做主要性能来源。4.4 训练循环与评估脚本训练时用BCEWithLogitsLoss它把sigmoid和交叉熵合成一个操作数值上更稳定。优化器用Adam初始学习率1e-3批量大小选择64。LSTM训练很快会过拟合因此不要盲目加大轮数我见过有人用10个epoch把训练损失降到0.1测试集准确率反而掉到0.75。from torch.utils.data import DataLoader train_dataset ReviewDataset( train_df[review_text].tolist(), train_df[label].tolist(), vocab ) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue) model BiLSTMClassifier(len(vocab)).to(device) loss_fn nn.BCEWithLogitsLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3) for epoch in range(5): total_loss 0.0 model.train() for xb, yb in train_loader: xb, yb xb.to(device), yb.to(device) logits model(xb) loss loss_fn(logits, yb) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() print(fepoch {epoch 1}, loss: {total_loss / len(train_loader):.4f})model.train()这行要记得切回训练模式它会影响Dropout和BatchNorm的状态。训练完成后预测代码要对模型调用model.eval()并用torch.no_grad()关闭梯度计算否则推理时显存占用会翻倍。model.eval() with torch.no_grad(): test_dataset ReviewDataset( test_df[review_text].tolist(), test_df[label].tolist(), vocab ) test_loader DataLoader(test_dataset, batch_size128, shuffleFalse) preds [] for xb, _ in test_loader: logits model(xb.to(device)) preds (torch.sigmoid(logits) 0.5).float().cpu().tolist()注意shuffleFalse在评估时必须关闭否则预测标签顺序与测试集顺序对不上后续算混淆矩阵时会得到完全错误的结果。4.5 如果你想试试预训练模型代价是什么用Hugging Face的transformers库加载预训练模型做迁移学习在绝对准确率上通常会高于BiLSTM。但先别急着换你得接受几个代价预训练模型的词表和tokenizer与手写数据管线完全不同训练耗时从分钟级变成小时级显存不够时只能调小batch size又进一步拖长训练时间最重要的是预训练模型的训练语料很可能已经包含烂番茄影评这意味着实验结果存在数据泄露嫌疑答辩时遇到较真的提问非常难解释。我的建议是把它作为报告里的“探索性实验”一笔带过核心交付还是落在你能完整解释的传统模型与BiLSTM方案上。5. 避坑5个让模型翻车但实验报告里不写的细节5.1 把not good洗成good停用词列表里的内鬼现象用TF-IDF加逻辑回归时效果正常换成LSTM后准确率反而下降检查预测结果发现大量“not good”被分成正面。原因有人在预处理环节用了完整英文停用词表其中包含“not”。清洗后“not good”变成了“good”模型看到的正面信号与负面标签自相矛盾学到一个混乱的类别边界。解决保留否定词。我使用的停用词集合只包含冠词、介词和be动词并把not,no,never,nor排除在外。如果特征维度过高宁可调低max_features也别偷懒删否定词。更稳妥的方案是引入否定词翻转策略把“not good”改写为“not_good”作为一个整体特征这个技巧在传统机器学习里能带来一到两个百分点的提升。5.2 标签没转成数值损失函数直接崩溃现象训练第一个batch时报错提示Expected scalar type Float but found Byte或者loss直接变成nan。原因df[fresh]列还是字符串传给BCEWithLogitsLoss时无法计算梯度。LSTM的Embedding层倒是能接受torch.long类型的索引标签必须和损失函数类型匹配。解决在第2章标签映射步骤里先做df[label] df[fresh].map({fresh: 1, rotten: 0})再检查label.dtype。用一个多此一举但有效的方法兜底训练循环里初始化loss None每轮结束后检查torch.isnan(loss)写进代码里能少浪费半小时排查时间。5.3 过采样拉高训练集指标测试集上却现原形现象把“rotten”类评论文本复制三倍后训练集损失大幅下降F1在验证集上却比复制前低了四个百分点。原因复制操作产生了大量完全相同的样本模型把模板句子背了下来没有学到泛化特征。一旦遇到措辞不同的负面评论就把它误判成正面。解决优先用class_weightbalanced做损失层面平衡而不是动数据分布。如果一定要加数据去做同义词替换式的增广而不是复制。报告里如果提“数据不均衡”一定要写清楚你用的是权重平衡还是过采样并同时给出两个方案的测试集指标对比。5.4 显存不足不只有换硬件一条路序列长度和batch的取舍现象训练约半程时进程崩溃报CUDA out of memory。原因把序列长度设为512、batch设为256同时开双向LSTM三层embedding缓存把显存撑满了。解决先调小batch_size到32或16观察GPU使用率是否明显下降如果仍然爆显存检查max_len是否可以被截断到100。烂番茄影评的词汇密度不高截断到100对最终效果影响很小。还有一种隐蔽的可能训练过程中PyTorch缓存未释放跑完一个epoch后调torch.cuda.empty_cache()并把不需要的中间变量用del删除。# 训练主循环里每个epoch结束后释放缓存 torch.cuda.empty_cache()这行代码不改变任何数学结果但能避免连续训练两个模型时显存碎片化。5.5 随机种子没固定复现实验发现结果对不上现象同一份数据和同一套训练代码先后运行两次准确率差了两个百分点。原因Python的random、NumPy、PyTorch各自维护一套随机状态只要有一个没固定数据加载顺序、权重初始化、Dropout掩码都会变。解决在训练脚本最前面集中设置所有随机种子并且确保固定后不会被其他模块覆盖。import random import numpy as np def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed)DataLoader里的shuffleTrue默认使用全局随机源如果设完种子还出现不可复现问题可以为它单独创建generator对象g torch.Generator().manual_seed(42) loader DataLoader(dataset, batch_size64, shuffleTrue, generatorg)训练日志里把你使用的随机种子写清楚这是科学实验的基本素养。有些竞赛代码里还会固定cv2的种子这里不涉及图像不用管。6. 交付一份能加分的实验报告混淆矩阵、注意力可视化和可复现验证6.1 报告不只是贴准确率放一张有参照系的效果对比表前面第3章给出的表是最低要求在第6章我做的是升级版本不只在表格里放Accuracy和F1还额外加一行“90%置信区间”。计算置信区间的方法是用sklearn.metrics里的accuracy_score配合bootstrap手动抽样。很多答辩老师不看具体数字只看你有没有考虑过指标的随机波动区间。import numpy as np def bootstrap_ci(y_true, y_pred, n_boot1000): rng np.random.default_rng(42) accs [] idx np.arange(len(y_true)) for _ in range(n_boot): sample rng.choice(idx, sizelen(idx), replaceTrue) acc (np.array(y_true)[sample] np.array(y_pred)[sample]).mean() accs.append(acc) return np.percentile(accs, [2.5, 97.5])这段代码会在报告中生成类似“Accuracy: 0.823 (95% CI: 0.817–0.829)”的表达。这个细节直接拉开该作业和其他模板报告的距离。6.2 用混淆矩阵找出模型的“事故高发区”准确率掩盖了太多信息。一个模型可能把98%的正面评论都预测对了但负面评论只猜对一半总体准确率看起来是0.75实际商业价值很低。课程作业不需要做商业权衡但报告里分析混淆矩阵能证明你懂模型行为。from sklearn.metrics import confusion_matrix import seaborn as sns import matplotlib.pyplot as plt cm confusion_matrix(test_df[label], y_pred, labels[0, 1]) sns.heatmap(cm, annotTrue, fmtd, cmapBlues, xticklabels[rotten, fresh], yticklabels[rotten, fresh]) plt.xlabel(Predicted) plt.ylabel(Actual) plt.savefig(confusion_matrix.png, dpi150, bbox_inchestight)注意labels[0,1]参数指定了矩阵的行列顺序否则confusion_matrix默认按标签数值升序排列也就是先rotten后fresh。手动指定顺序是为了保证热力图的横轴纵轴理解成本最低。我常年做NLP实验的教训是报告里的每一个结论必须能从输出日志或图表追溯到具体模型和数据切片。我会在交付前把整条脚本从头到尾重跑一遍哪怕只是改过一个清洗函数。很多“高分模型”其实在重新跑的时候因为一个随机种子的疏忽就崩了。写实验报告不丢人丢人的是复现不了自己写的数字。这份笔记里提到的方案每一步我都踩过类似的坑希望帮到你少走这些弯路。本文还有配套的精品资源点击获取