简介:一份面向华中科技大学Python大数据与人工智能实践课程的大作业完整方案,以烂番茄电影评论为对象,使用Python完成情感分类建模,包含可运行的源码、实验报告与原始数据。资源面向高校计算机、人工智能及相关专业学生,尤其是需要完成课程设计、毕业设计或入门NLP情感分析任务的读者。\n\n压缩包共含555个文件,整体约41.89MB。核心内容包括6个Python源码文件、104个PDF文档、多份CSV/TSV格式影评数据、14个NPY向量文件,以及TensorFlow训练产生的events.out.tfevents日志与大量epoch训练文件,便于查看模型训练过程中的loss与acc变化轨迹。此外还附带md说明与tex/bib文件,便于整理报告与引用。\n\n目前已有158人学习下载。代码已经过完整测试并运行成功,内部包含数据处理、模型搭建、训练评估等完整流程,附实验报告可帮助理解每个环节的设计思路,适合在现有代码基础上二次开发或直接参考完成同类影评情感分析任务。
1. 用 Python 拿下烂番茄影评情感分类:这个大作业到底在做什么
每年到了大作业季,最头疼的不是“写代码”,而是“做什么题目”。烂番茄电影评论情感分类这个方向,我前后带过不少学弟学妹做,也帮人改过几版代码,属于典型的“数据干净、标签现成、效果可量化”的练手项目——数据能爬下来,标签能从星级换算出来,情感分类的准确率能直接写在实验报告里,导师一眼就能看懂做了什么事。整个链路覆盖 Python 爬虫、pandas 数据清洗、sklearn 与深度学习建模、实验对比,正好对得上“Python 大数据与人工智能实践”这门课的能力要求。这篇笔记就按我自己做这类项目时会走的路径,把数据采集、情感分类建模、参数设置、实验报告怎么写以及容易翻车的坑逐一讲清楚。新手照着做能跑通,熟手可以跳过入门部分直接看参数和踩坑记录。
2. 把烂番茄评论整理成可用数据集:爬取、清洗与标签标注
2.1 为什么选烂番茄影评:标签结构天生适合做情感分类
烂番茄(Rotten Tomatoes)和其他影评平台相比,有一个非常省事的特性:观众评论分“新鲜”和“腐烂”两档,也就是正面和负面。它不要求你像做一般文本分类那样先人工标注几千条数据,星级或者 fresh/spoiled 标签是现成的。实践类大作业里,数据标注往往是最大的时间黑洞,选这种自带标签的语料,能把有限的时间花在模型和实验上,而不是花三四个晚上标数据。
另一个好处是评论本身是英文,不用做中文分词。中文情感分类要先解决分词、停用词、词性等一系列前置问题,英文只需要 nltk 或者 spaCy 做一次 tokenize、去掉停用词即可,Pipeline 短一截。对 Python 基础语法还不够熟的同学来说,这是更友好的切入方式。而且烂番茄评论的语境相对规范,没有太多网络黑话和错别字,模型效果容易做得好看。
数据规模上,一个电影页面的观众评论从几十条到上千条不等,实际采集时建议选近几年的热门大片,一页 20 条左右,翻 20~30 页就能拿到一个几百条到上千条的数据集。对课程大作业来说,这个量级已经完全够用。如果你不想爬,也可以在实验报告里注明使用了公开的烂番茄影评数据集,但自己动手爬一遍能多写一章“数据采集与预处理”的内容,工作量更饱满。
2.2 用 requests + BeautifulSoup 把评论和标签落成 CSV
常见做法是先用 requests 请求评论列表页,再用 BeautifulSoup 解析 HTML 结构,把评论正文和对应的“新鲜/腐烂”标签同时抓下来。烂番茄的观众评论页是分页加载的,URL 里的?page=N参数控制页码。每个评论块通常在带有review-row等 class 的节点里,需要先定位评论容器,再在容器内提取文本和标签,比全页面硬搜字符串要稳得多。
import time import random import requests from bs4 import BeautifulSoup import pandas as pd headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36" } def crawl_reviews(base_url, pages=20, delay_range=(1.5, 3.5)): records = [] for page in range(1, pages + 1): url = f"{base_url}?page={page}" resp = requests.get(url, headers=headers, timeout=15) if resp.status_code != 200: continue soup = BeautifulSoup(resp.text, "html.parser") # 每条观众评论所在的容器节点,具体 class 以实际页面为准 for row in soup.select(".review-row"): text_node = row.select_one(".review-text") label_node = row.select_one(".review-freshness") if text_node is None or label_node is None: continue review_text = text_node.get_text(strip=True) freshness = label_node.get_text(strip=True).lower() if "fresh" in freshness: label = 1 elif "rotten" in freshness: label = 0 else: continue records.append({"text": review_text, "label": label}) time.sleep(random.uniform(*delay_range)) return records # base_url 指向烂番茄某部电影的观众评论页 records = crawl_reviews("https://www.rottentomatoes.com/m/xxx/reviews") df = pd.DataFrame(records) df = df.drop_duplicates(subset="text").dropna() df.to_csv("rotten_tomatoes_reviews.csv", index=False, encoding="utf-8") print(df.shape) print(df["label"].value_counts())这套代码的核心逻辑是按页请求、定位评论节点、把新鲜度映射成 0/1 标签。time.sleep(random.uniform(...))是为了控制请求频率,避免短时间内打太多请求导致 IP 被临时限制。drop_duplicates(subset="text")清理同一部电影在不同分页里可能出现的重复评论。注意.review-row这类选择器,不同时期的烂番茄页面结构会调整,脚本跑不出数据时先打印 soup 内容确认当前页面的实际 class 名,这是爬虫环节最常见的调试步骤。
2.3 标签平衡与最小数据集要求
爬完数据后,第一件事不是急着训练,而是查看正负样本分布。烂番茄的观众评分总体偏向正面,很多电影 70% 以上是 fresh 评论。如果正向样本占 85%,模型全预测正向就有 85% 准确率,这种模型写了跟没写一样。我的习惯是控制正向样本占比在 60%~75% 之间,如果偏差太大就多爬几部口碑偏差的电影,或者对多数类做下采样。
df = pd.read_csv("rotten_tomatoes_reviews.csv") counts = df["label"].value_counts() print(counts) # 下采样到多数类与少数类接近 1.2:1 左右 majority = df[df["label"] == df["label"].mode()[0]] minority = df[df["label"] != df["label"].mode()[0]] if len(majority) > len(minority) * 1.2: majority = majority.sample(n=int(len(minority) * 1.2), random_state=42) df_balanced = pd.concat([majority, minority]).sample(frac=1, random_state=42) df_balanced.to_csv("reviews_balanced.csv", index=False)这里random_state=42固定下来,保证每次运行下采样结果一致,实验报告里的数字才能自洽。数据集总量不需要很大,训练集 800 条、测试集 200 条,这种体量已经能训练出像样的传统机器学习模型。不过如果实验报告里写了“数据量对深度学习模型的影响”这类对照实验,就要准备 2000 条以上的数据,否则 LSTM 学不到什么东西,loss 曲线会抖得没法看。
3. 用 TF-IDF + 朴素贝叶斯先跑通一个可复现的基线:参数与评估口径
3.1 为什么先做机器学习基线而不是一上来就上深度学习
很多同学一听到“人工智能大作业”就直接要上深度学习,仿佛不用神经网络就不够高级。但实验报告里真正有价值的是“基线模型”和“改进模型”的对比。先做一个 TF-IDF + 朴素贝叶斯或者逻辑回归的基线,跑通整个评估流程,再上 LSTM 或者 BERT,这样既能说明你理解了问题难度在哪里,也能用数据证明深度学习带来了多少提升——这才是一份能站得住脚的实验报告。
另外,TF-IDF + 朴素贝叶斯这种组合在 1000 条量级的影评数据上准确率通常能做到 75%~85%,而且训练时间只要几秒。先跑通基线,等于先把数据、标签、评估脚本的整条链路验证了一遍。后面深度学习模型出问题时,可以快速对比是数据问题还是模型问题。
3.2 英文影评的预处理链条:tokenize、去停用词、词形还原
英文文本预处理比中文简单,但也不是直接按空格切就算完。评论里有大小写差异、时态变化、标点和 HTML 残留。我的标准流程是:统一转小写,用 nltk 的 word_tokenize 分词,去掉长度小于 2 的 token,去掉 nltk 停用词,再做一次词形还原(lemmatization)。词形还原比词干提取更温和,把 “movies” 和 “movie” 归到一起,但不会把 “boring” 砍成 “bore” 丢失情感信息。
import nltk import re from nltk.corpus import stopwords from nltk.stem import WordNetLemmatizer nltk.download("punkt") nltk.download("stopwords") nltk.download("wordnet") nltk.download("omw-1.4") stop_words = set(stopwords.words("english")) lemmatizer = WordNetLemmatizer() def clean_text(text): text = text.lower() text = re.sub(r"<[^>]+>", "", text) # 去掉 HTML 标签 text = re.sub(r"[^a-z\s]", " ", text) # 只保留字母 tokens = nltk.word_tokenize(text) tokens = [t for t in tokens if len(t) > 2 and t not in stop_words] tokens = [lemmatizer.lemmatize(t) for t in tokens] return " ".join(tokens) df["clean_text"] = df["text"].apply(clean_text)这段代码里有几个容易忽略的细节:re.sub(r"[^a-z\s]", " ", text)会把所有数字和标点变成空格,避免film.和film被算成两个特征;len(t) > 2过滤掉a、it这类短词;先除标签再分词,顺序不能反。nltk 的word_tokenize在第一次运行时需要下载 punkt 资源,如果运行环境没外网,会直接报错,这也是后面避坑章节要单独讲的问题。
3.3 训练集/测试集划分、评估指标与混淆矩阵
数据划分上,我不用简单随机划分,而是加stratify做分层采样,保证训练集和测试集里的正负样本比例一致。这样跑出来的准确率、F1 值在不同次运行之间波动更小,报告里的数据更可信。评估指标除了 accuracy,必须看 F1 和混淆矩阵——正面评论和负面评论在实际分布上天然不平衡,只看准确率会被多数类蒙蔽。
from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.naive_bayes import MultinomialNB from sklearn.model_selection import train_test_split from sklearn.metrics import classification_report, confusion_matrix X_train, X_test, y_train, y_test = train_test_split( df["clean_text"], df["label"], test_size=0.2, random_state=42, stratify=df["label"] ) vectorizer = TfidfVectorizer(max_features=5000, ngram_range=(1, 2)) X_train_vec = vectorizer.fit_transform(X_train) X_test_vec = vectorizer.transform(X_test) clf = MultinomialNB(alpha=1.0) clf.fit(X_train_vec, y_train) y_pred = clf.predict(X_test_vec) print(classification_report(y_test, y_pred, target_names=["negative", "positive"])) print(confusion_matrix(y_test, y_pred))max_features=5000限制特征维度,防止评论语料里出现太多生僻词把向量矩阵撑得过于稀疏。ngram_range=(1, 2)同时保留单词和双词组合,像 “not good” 这种二元结构本身带有情感转折信息,单看 “good” 会被误判为正向。alpha=1.0是朴素贝叶斯的拉普拉斯平滑参数,如果验证集效果不好,可以试着调大一点,这个参数的作用是给未在训练集出现的单词一个非零概率,避免极端情况下的零概率连乘导致误判。这一整套流程跑下来,基线模型的结果就有了:准确率 78%、F1 值 0.76,这类数字写在实验报告里就是后续所有改进实验的参照系。
4. 升级到深度学习方案:用 LSTM 做序列建模与实验报告所需的正交实验
4.1 为什么用 LSTM 而不是直接套 BERT
烂番茄影评属于短文本,平均长度二三十个词,LSTM 这类序列模型能捕捉“前半句铺垫、后半句转折”的局部语义结构,比 TF-IDF 的词袋模型多一个词序维度的信息。BERT 当然效果更好,但课程大作业的实验报告里,LSTM 和基线的对比就能说明问题,而且训练一个像样的 BERT 需要 GPU 环境和更长的训练时间,很多同学的笔记本跑不动。我一般会建议先做 LSTM,学有余力再在进阶章节提一嘴预训练模型的替换方案。
LSTM 的套路很固定:Embedding 层把词索引映射成稠密向量,双向 LSTM 层提取序列特征,GlobalMaxPooling 把变长序列压成一个定长向量,最后接 Dense 层输出二分类概率。相比于 CNN 文本分类,LSTM 对短文本的“先后顺序”更敏感,比如 “unexpectedly good” 这种词序,LSTM 能学到 unexpected 对 good 的修饰关系。
4.2 词向量矩阵构建、序列填充与模型训练配置
深度学习模型不能直接吃字符串,需要先构建词表,把每个词映射成整数索引,再把每一条评论填充成等长序列。max_len的设置我在实验里通常会取 60,因为烂番茄观众评论的中位长度在 15~25 个词之间,60 已经能覆盖绝大多数评论,过长只会拖慢训练速度。
import numpy as np from tensorflow.keras.preprocessing.text import Tokenizer from tensorflow.keras.preprocessing.sequence import pad_sequences from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Embedding, LSTM, Dense, Dropout, Bidirectional, GlobalMaxPooling1D from tensorflow.keras.optimizers import Adam MAX_VOCAB_SIZE = 10000 MAX_LEN = 60 EMBEDDING_DIM = 100 tokenizer = Tokenizer(num_words=MAX_VOCAB_SIZE, oov_token="<OOV>") tokenizer.fit_on_texts(df["clean_text"]) sequences = tokenizer.texts_to_sequences(df["clean_text"]) X = pad_sequences(sequences, maxlen=MAX_LEN, padding="post", truncating="post") y = df["label"].values X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) model = Sequential([ Embedding(input_dim=MAX_VOCAB_SIZE, output_dim=EMBEDDING_DIM, input_length=MAX_LEN), Bidirectional(LSTM(64, return_sequences=True, dropout=0.3)), GlobalMaxPooling1D(), Dense(32, activation="relu"), Dropout(0.3), Dense(1, activation="sigmoid") ]) model.compile(optimizer=Adam(learning_rate=1e-3), loss="binary_crossentropy", metrics=["accuracy"])关键参数都在代码里标出来了:oov_token="<OOV>"让测试集里没见过的词统一映射到一个特殊索引,否则推理时会炸;padding="post"在句子后面补 0,truncating="post"从后面截断,这样前面开头的词能保留,保留的信息对情感判断权重更高;dropout=0.3是防止训练集只有几百条时模型直接过拟合。训练时 batch size 建议 32,epoch 设 20~30 并配合早停,在验证集 loss 连续三轮不降时停掉,避免白跑后面的轮次。这里我用GlobalMaxPooling1D而不是直接取 LSTM 最后时刻的输出,是为了保留整个序列里最强烈的情感信号,短文本情感分类的实践里这个操作很常用。
4.3 实验报告里必须有的对照实验与可视化
课程大作业的实验报告,光有一张准确率表是不够的,导师想看到的是你“思考过”。我的做法是固定三组对照:第一组,TF-IDF + 朴素贝叶斯基线;第二组,LSTM 不加预训练词向量;第三组,LSTM 加 GloVe 预训练词向量。三组用完全相同的训练集和测试集,只替换特征表示方式,表格里列出准确率、精确率、召回率、F1 四项指标。
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score def evaluate_report(name, y_true, y_pred): print(f"{name}:") print(f" accuracy = {accuracy_score(y_true, y_pred):.4f}") print(f" precision = {precision_score(y_true, y_pred):.4f}") print(f" recall = {recall_score(y_true, y_pred):.4f}") print(f" f1 = {f1_score(y_true, y_pred):.4f}") # y_pred_lr / y_pred_lstm 是不同模型对同一测试集的预测结果 evaluate_report("Logistic Regression", y_test, y_pred_lr) evaluate_report("LSTM", y_test, y_pred_lstm)可视化方面,训练过程的 loss 曲线和 accuracy 曲线是必须放进报告的两张图。用 matplotlib 把每个 epoch 的训练/验证 loss 画出来,如果训练 loss 持续下降但验证 loss 在第 10 轮开始上升,这就是过拟合的实锤,报告里直接写“加入 Dropout 后验证 loss 在第 15 轮才开始反弹,说明正则化有效”。这就是实验报告里最能体现“你亲手调过模型”的证据。
import matplotlib.pyplot as plt history_dict = history.history epochs = range(1, len(history_dict["loss"]) + 1) plt.plot(epochs, history_dict["loss"], label="train_loss") plt.plot(epochs, history_dict["val_loss"], label="val_loss") plt.legend() plt.xlabel("epoch") plt.ylabel("loss") plt.savefig("loss_curve.png")5. 避坑:烂番茄影评情感分类最容易翻车的 5 个细节
5.1 评论爬下来是空的,或者只有一两条
现象:运行爬虫脚本后,records 列表几乎为空,打印出来只有一两行数据。
原因:烂番茄的页面结构不是一成不变的,.review-row这个 class 可能已经改版,或者页面对未登录访客渲染的是简化版页面,评论内容藏在style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />