☰
机器学习票房预测实战:回归任务、特征工程与模型选型
2026/10/3 10:37:19 网站建设 项目流程

简介:这份毕业设计项目以TMDB电影数据集为基础,借助机器学习算法构建电影票房预测模型,完整覆盖数据清洗、特征提取、模型选择、参数调优、结果评估与可视化等环节,既适合计算机相关专业学生用于毕业设计、课程设计或期末大作业,也适合需要项目实战练习的开发者参考学习。项目曾获导师指导认可,评审分高达98.5分,代码按照基础推荐、集成推荐、个性化推荐等模块清晰组织,同时附带报告PDF与电影数据分析文档,能够帮助读者掌握算法选型、特征工程和模型对比的关键思路,也可作为同类项目的可复用模板。压缩包内共有59个文件,主要包括16个Python源码文件、16个CSV数据文件、23张PNG可视化图片,以及Markdown笔记、说明文档和报告PDF,全部打包后大小约30.95MB,数据文件可直接用于训练与验证,目录结构清晰,便于按需查阅、复现代码或扩展二次开发。目前已有267人学习或下载,适合需要参考高分毕业设计、快速搭建票房预测系统并完善论文配图的读者。

1. 机器学习算法做票房预测:把毕业论文做成一份可复现的数据工程

拿 Python 做毕业设计,最不缺的就是算法 demo,缺的是一个能讲清楚“为什么这样预测”的完整闭环。基于机器学习算法实现电影票房预测,恰好是一个标准到可以直接当模板的回归任务:数据网上能拿到、特征维度和领域知识有逻辑又不过度复杂、结果好坏一眼看得见。它解决的真实需求是:在电影上映前(或首日后)对总票房做一个提前估计,让制片、发行甚至平台排片都有一个量化参考。适合谁?正在选题的本科生、想用 sk-learn 和 python 数据分析做个像样项目的入门者,以及打算投算法岗、需要一个能完整复现的项目放进作品集的求职者。这篇文章从上手数据讲到踩坑,帮你把这个题目做成一份能跑、能讲、能交源码和报告 PDF 的作品。

2. 任务定义与建模选型:先分清预测目标,再谈算法

2.1 回归任务的三个先验决策:预测什么票房、站在什么时间点、用什么指标评价

很多同学拿到题目第一件事就是跑模型,结果跑完之后发现导师问的第一句“你预测的是首周还是总票房”都答不上来。票房预测在业务上至少有两个口径:上映首日/首周票房,和最终累计总票房。前者数据时效性强、受宣发影响大,后者是暑期档结束后隔很久才统计完成,更接近“终局评价”。毕设里我建议把口径定成“上映后首周已有数据时,预测最终总票房”,理由很简单:公开数据集里首日与首周字段往往最全,而“上映前只靠静态信息预测”的数据很多会缺失营销数据,做起来一是特征太少,二是不好解释。

第二个先验决策是站在哪个时间点做预测。时间点直接决定特征集合:上映前只能用导演、演员、类型、档期、宣发预算这类静态信息;上映后可以再加入首日票房、首日排片占比、首日口碑评分。常见做法是分成两个模型:基础预测模型(纯静态特征)和修正预测模型(静态加首日动态特征)。报告里同时出现这两个模型时,对比本身就是一份很好的实验设计,答辩时不用担心没内容可讲。

第三个决策是评估指标。票房是一个典型的长尾分布:头部爆款拿走大部分票房,大量小成本电影只有几百万。如果你只看 R²,会被极端值拉得虚高或虚低;只看 MAE,又容易被几部几亿票房的片子主导。我的习惯是主指标用 MAE 和 RMSE(还原到原票房单位后按“万元”看),辅指标用 log 空间下的 R²。误差在 30% 以内的模型,在报告里已经可以说“具备业务参考价值”。

2.2 特征体系:票房不能只看“主演有没有名气”

票房预测的特征工程,核心思路是把“人的判断”翻译成“数字历史表现”。最常用、也最容易出效果的特征是以下五类:导演历史票房均值、主演历史票房均值、类型组合、档期、制作体量(时长、地区、是否续集)。前三个解决“谁来做”,后两个解决“什么时候上、卖给谁”。

导演和演员的处理是这题的灵魂。常见错误是把演员名、导演名直接做 OneHot 编码——导演上千个、演员几万人,OneHot 后特征矩阵爆炸,随机森林还会被高基数的离散特征带偏,importance 排名几乎全被演员列占据。更稳的做法是均值编码(target encoding):把导演名和主演名替换成“该导演/该演员过去作品票房的平均值”。这个特征本身就是行业里“票房号召力”的量化版,模型理解起来毫不费力。

档期也要处理成特征而不是丢给模型自己猜。春节档、暑期档、国庆档这三个时段平均票房明显高于普通周末档,用时间规则生成一个四档分类变量比直接丢“几月上映”更贴合业务逻辑。再加上是否续集、是否 IP 改编这类标记位,特征总数控制在 15 个以内就够了。特征不是越多越好,对几千条训练数据来说,十几个干净特征足以把随机森林和 XGBoost 喂饱。

2.3 算法选型:从线性回归到随机森林与 XGBoost,按数据规模决定

网上常说的十大机器学习算法里,真正适合表格型回归任务的其实就那几个。对票房预测这种“特征有强业务先验”的问题,我的选型顺序是:先岭回归或 Lasso 做基线,再上随机森林,最后用 XGBoost 提分。线性模型的价值不是拿第一名,而是用最少的调参成本验证特征是否有效——如果线性基线 R² 在 log 空间只有 0.3,那多半是特征工程的问题而不是模型的问题,先回去修特征。

随机森林适合这种“特征量少、缺失不多、量级差异大的表格数据”,不需要标准化,对非线性关系也扛得住。它最大的短板是外推能力差:当一部电影的票房远超训练集历史极值时,随机森林预测会偏向历史最高值,很难给出更高的数字。XGBoost 在同样的特征下通常能把 R² 再拉高几个点,对缺失值容忍度高、正则项内置防过拟合,是这个题目里最值得作为最终模型的那一个。

选型上有一个实操原则:训练样本少于 3000 条时,不要一上来就堆复杂集成,先跑通简单模型,把管线打通再升级算法。报告里放三张模型的对比表(基线、随机森林、XGBoost),比单放一个最优模型更能体现你做过选型思考。

3. 数据清洗与特征工程:用 pandas 把乱数据变成训练集

3.1 数据体检:先看缺失率与字段类型,再谈建模

拿到数据先别急着写模型。常见的公开数据集字段大致是:release_date、director、actors、genre、runtime、region、rate、opening_box、total_box。第一件事是体检:哪些列缺失率超过 60%,哪些列是 object 类型但其实应该是数值,哪些日期字段格式不统一。这些信息决定了后面 80% 的清洗工作。

import pandas as pd import numpy as np # 数据源可能是爬虫抓的,也可能来自教材附带 CSV,编码常是 utf-8 带 BOM df = pd.read_csv('movies.csv', encoding='utf-8-sig') print('shape:', df.shape) print('columns:', df.columns.tolist()) print(df.info()) # 缺失率降序排列,超过 60% 的列基本只能删掉 print(df.isna().mean().sort_values(ascending=False).head(10))

编码用utf-8-sig是为了去掉 BOM 头,避免第一列列名变成\ufeffrelease_date这种隐蔽问题。df.info()能快速看到非空计数和 dtype,如果director或rate被读成了 object,说明里面有脏值。缺失率排行的用途是区分“可填充列”和“整列删掉”:比如budget缺失 70%,直接用均值填充会造成伪造数据,删掉更诚实;而runtime缺失 5%,可以用同类型电影的中位数补。

3.2 特征工程代码:档期、人物均值编码与首日修正

日期清洗是这个项目里最常见的翻车点。爬下来的日期可能是“2019-07-08”,也可能写成“2019/7/8”甚至“2019年7月8日”。先统一用pd.to_datetime强制转换,并把演员列按“/”或“,”拆开取前三位。

# 日期统一:errors='coerce' 会把解析失败的置为 NaT,后面统一处理 df['release_date'] = pd.to_datetime(df['release_date'], errors='coerce') df['year'] = df['release_date'].dt.year # 主演列一般是 “张译/刘浩存/范伟” 这种斜杠格式 df['actor1'] = df['actors'].str.split('/').str[0].str.strip() df['actor2'] = df['actors'].str.split('/').str[1].str.strip() if df['actors'].str.contains('/').any() else np.nan df['actor3'] = df['actors'].str.split('/').str[2].str.strip() if df['actors'].str.contains('/').any() else np.nan # 档期四分类:春节前后和暑期档、国庆档单独划出 def season_of(dt): if pd.isna(dt): return 'other' m = dt.month if m in (12, 1, 2): return 'spring' if m in (6, 7, 8): return 'summer' if m in (9, 10): return 'national' return 'other' df['season'] = df['release_date'].map(season_of)

errors='coerce'是这里最关键的一个参数,裸的pd.to_datetime遇到非法字符串会直接抛异常导致脚本中断。拆主演时先检查“actors”列里有没有分隔符,避免对单一演员的数据执行字符串索引而报错。档期规则我用的月份范围,如果数据里有农历档期需求,可以再做映射扩展,但对一个毕业设计来说月份划分已经足够。

人物均值编码是整个特征工程里最值钱的一段代码。注意:这个函数必须接收已经切分好的训练集,再映射到验证集和测试集,绝不能在整个数据集上直接groupby + transform,否则就是数据泄漏,后面的模型分数全是幻觉。

def fit_target_mean(df_train, df_val, df_test, group_col, target): """均值编码:在训练集上统计历史票房均值,map 到 val/test""" global_mean = df_train[target].mean() grp = df_train.groupby(group_col)[target].mean() for dfx in (df_train, df_val, df_test): dfx[group_col + '_hist_mean'] = dfx[group_col].map(grp) # 训练集里没出现过的导演/演员,用全局均值兜底,而不是填 0 dfx[group_col + '_hist_mean'].fillna(global_mean, inplace=True) return df_train, df_val, df_test

fillna(global_mean)这个兜底策略是为了让“新导演”“新人演员”也获得一个中性的先验值,而不是 0。填 0 的后果是模型学到“没见过的演员 = 零票房”,完全违背常理。调用时对三个对象分别覆盖原始数据:df_train[col] = ...。这个函数避免了在验证集和测试集上重新计算历史均值,从源头堵死泄漏。

3.3 数据划分与特征对齐:泄漏的问题要在这一刻根治

数据划分策略直接决定答辩时能不能站稳。随机train_test_split的问题在于:同一个导演或演员的多部作品可能同时出现在训练集和验证集,模型等于开卷考试,验证分数虚高。更贴近真实业务场景的做法是按年份切分:用 2018 年之前的样本训练,用 2019 年之后的样本验证,模拟“站在过去预测未来”。

from sklearn.model_selection import train_test_split # 推荐按时间切分:仿照真实场景,用旧数据预测新片 df_train = df[df['year'] < 2019].copy() df_val = df[df['year'] >= 2019].copy() # 特征清单固定下来,训练和验证严格同一批列 FEATURES = ['runtime', 'rate', 'director_hist_mean', 'actor1_hist_mean', 'season', 'opening_box', 'is_sequel', 'genre_count'] # 标签取 log1p:票房右偏严重,不取 log 模型会被头部爆款主导 y_train = np.log1p(df_train['total_box']) y_val = np.log1p(df_val['total_box']) X_train = df_train[FEATURES] X_val = df_val[FEATURES] # 特征对齐断言:跑之前先确认列顺序和列名一致 assert list(X_train.columns) == list(X_val.columns), '训练集与验证集特征列不对齐'

这段代码里最值得说明的是np.log1p和最后的assert。log1p是对数变换,把从几百万到几十亿跨越极大的票房压缩到可回归的区间,模型训练时不会因为个别大片的残差压过所有小成本电影。assert是防止你前面做了多次特征选择后,训练集多了列而验证集忘了同步——这种低级错误在报告里只会体现为一句“模型报错”,但在答辩现场非常尴尬。

4. 模型训练与参数调优:三套代码跑通票房预测全流程

4.1 岭回归做基线:先把特征是否有效的底线试出来

第一个模型一定是最简单的线性回归。票房预测和量化交易策略代码里的套路很相似:先用一条简单的规则跑出底线分数,再谈复杂模型。普通最小二乘在特征有共线性时方差很大,所以这里用岭回归Ridge,它对导演均值、演员均值这类本身就相互关联的特征更稳。

from sklearn.pipeline import make_pipeline from sklearn.preprocessing import StandardScaler from sklearn.linear_model import Ridge from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score base = make_pipeline(StandardScaler(), Ridge(alpha=1.0)) base.fit(X_train, y_train) pred_log = base.predict(X_val) # 还原到原票房单位,评估才符合业务直觉 pred_origin = np.expm1(pred_log) val_origin = np.expm1(y_val) print('R2(log):', r2_score(y_val, pred_log)) print('MAE(万):', mean_absolute_error(val_origin, pred_origin) / 1e4) print('RMSE(万):', mean_squared_error(val_origin, pred_origin, squared=False) / 1e4)

这段代码里有三个细节要解释清楚。第一,StandardScaler一定要放在管道里并且只在训练集上拟合,不能自己另写一个scaler.fit(X)然后把验证集也标准化——管道会确保 fit 和 transform 的分界正确。第二,R²我用 log 空间的值,而 MAE/RMSE 用还原后的原始票房值,说的是两件事:log 空间 R² 看模型对对数票房的解释力,原始票房误差才是业务上关心的偏差。第三,alpha=1.0只是起点,不要在这里花太多时间调参,基线模型的意义是检验特征管线通没通。

4.2 集成模型:随机森林和 XGBoost 的参数设定与调参顺序

线性基线跑通后,升级到集成模型。随机森林的优势是几乎不需要特征缩放,对离散、连续混合特征友好;XGBoost 的优势是支持缺失值、正则项内置、训练效率高。两个模型用同一份特征和验证集,才有对比意义。

from sklearn.ensemble import RandomForestRegressor from xgboost import XGBRegressor # 随机森林:n_jobs=-1 用满所有核,random_state 固定保证可复现 rf = RandomForestRegressor( n_estimators=300, max_depth=8, min_samples_leaf=5, n_jobs=-1, random_state=42 ) rf.fit(X_train, y_train) # XGBoost:学习率调小,树多一点,防止一步跨过最优区间 xgb = XGBRegressor( n_estimators=300, learning_rate=0.05, max_depth=5, subsample=0.8, colsample_bytree=0.7, random_state=42 ) xgb.fit(X_train, y_train, eval_set=[(X_val, y_val)], verbose=False)

这两个模型的参数表格值得写进报告里,参数含义要能当场讲出来,这是答辩必问项。以下是我常用的起点参数,不是定死的最优值:

超参数我常用的起点作用与调整方向
n_estimators300树的数量,越大越稳但训练越慢,观察 val 误差收敛后再定
max_depth随机森林 8,XGBoost 5控制树的深度,深度过大容易记住训练集的噪声
min_samples_leaf5叶子节点最小样本数,调大能平滑预测、防过拟合
learning_rate0.05XGBoost 步长,调小通常要配更大的 n_estimators
subsample0.8每棵树随机采样 80% 的行,缓解过拟合
colsample_bytree0.7每棵树随机采样 70% 的特征,效果类似特征子空间

调参顺序有讲究。先固定 learning_rate 和 n_estimators,观察训练曲线是否收敛;然后调 max_depth,从 3 到 9 逐个试;最后再动 subsample 和 colsample_bytree。不要一上来就做全网格搜索,几千条数据加上三百棵树,全参数网格跑一晚上不出结果,毕业论文可等不起。

4.3 三模型评估:R²、MAE 与 RMSE 怎么一起看

三个模型跑完后,写一个统一的评估函数,输出格式对齐到同一张表里。这里最容易犯的错是只报一个 R²,然后宣称模型效果多好。R² 高可能是泄漏,R² 低也不代表无用,必须结合原始票房误差来看。

def evaluate(name, model, X_valid, y_valid): pred_l = model.predict(X_valid) pred_o = np.expm1(pred_l) y_o = np.expm1(y_valid) print(f'{name:12s} R2={r2_score(y_valid, pred_l):.3f} ' f'MAE={mean_absolute_error(y_o, pred_o) / 1e4:8.1f}万 ' f'RMSE={mean_squared_error(y_o, pred_o, squared=False) / 1e4:8.1f}万') evaluate('Ridge', base, X_val, y_val) evaluate('RandomForest', rf, X_val, y_val) evaluate('XGBoost', xgb, X_val, y_val)

对几千条训练数据的项目来说,log 空间 R² 落在 0.75 到 0.85 之间属于正常发挥,MAE 在 3000 万上下已经算可用。如果 R² 超过 0.95,先别高兴,回头检查是不是均值编码泄漏了。三个模型放同一张表里,报告里再用一句话解释“为什么 XGBoost 优于随机森林”——通常是 XGBoost 对缺失值和特征交互的处理更好,以及树模型在 log 标签空间的外推能力比随机森林强。

5. 避坑排查:票房预测最容易被数据泄漏和金指标带偏的六个细节

5.1 长尾标签与虚高 R²:为什么预测值全在平均值附近

现象:模型输出的预测票房全挤在一个狭窄区间,几部爆款电影的预测值离真实值差了 5 倍,但打印的 R² 却有 0.8 以上。原因:票房标签右偏严重,头部大片残差平方占主导,模型为了最小化整体误差宁愿把预测都压向均值附近,也不去冒险预测高票房。解决:对标签做np.log1p变换后再训练,评估时把预测还原到原票房单位计算 MAE 和 RMSE。同时在报告里补一个误差分布图,按真实票房分成低、中、高三档,分别看这三档的误差率。如果低档误差率 20%、高档误差率 80%,说明模型对小成本片有用、对爆款无能为力,这个结论写进报告是很诚实的亮点。

5.2 均值编码写错位置:验证分数全是幻觉

现象:验证集 R² 高达 0.95,模型换了新数据表现一塌糊涂,导师问一句“你的导演均值特征是在哪一步算的”就露馅。原因:对全量数据groupby('director')['gross'].transform('mean'),导致验证集的导演历史均值里包含了自己这部片子的票房,训练时等于答案已经写在特征里。解决:严格遵循“先切分,后编码”,用前面写的fit_target_mean函数在训练集上统计,验证集和测试集只做map。补充一点:如果按时间切分,训练集里没有出现过的新导演,均值用全局票房均值兜底,不要用验证集信息回填。

5.3 日期和演员列解析翻车:缺行比缺列更隐蔽

现象:跑完season特征后发现暑期档电影占比只有 20%,明显比真实分布少。原因:日期列里有“2019年7月8日”这种中文格式,pd.to_datetime默认解析失败后整行变成 NaT,档期全落到 other。演员列同理,有些源数据用中文逗号“,”分隔,有些用“/”,拆列后 actor2、actor3 大量缺失,均值编码变成全局均值兜底,人物票房号召力信息全丢。解决:在to_datetime里加errors='coerce'并显式打印解析失败行数,比如df['release_date'].isna().sum();演员列先print(df['actors'].head(20).tolist())看真实分隔符再决定用/还是,。研究报告里把“清洗后有效样本数”从原始行数中单独标出来,导师会觉得你对数据有掌控感。

5.4 高基数类别直接 OneHot:特征重要性全是“演员名”的形状

现象:随机森林feature_importances_排名前 10 里 7 个是主演 OneHot 列,模型效果却一般。原因:OneHot 后每个演员变成一列 0/1 特征,树模型天然偏好高基数离散特征,会在每个演员上尝试切分,重要性虚高,真实有用的导演均值、档期特征反而被淹没。解决:演员和导演一律用均值编码替代 OneHot,只在genre这种基数十来个的类型字段上保留 OneHot 或者直接做标签编码。如果要佐证,可以在报告里放两张 importance 图:OneHot 版的混乱排名和均值编码版的清晰排名,这本身就是一段好内容。

5.5 随机切分让验证集开卷:同一导演横跨训练和验证

现象:按 8:2 随机切分后模型分数好看,但把切分方式换成按年份后 MAE 突然涨了 30%。原因:随机切分把同一导演、同一系列的电影拆进了训练和验证两个集合,模型见过“该导演风格”,验证等于开卷考试,分数虚高。解决:用按年份切分或者按导演做分组划分。毕设项目规模一般不大,直接按年份切分最稳妥,报告里写明“训练集为 2019 年前上映电影,验证集为 2019 年后上映电影”,这句话答辩时就是你的护城河。

5.6 特征对齐错误:训练 20 列、预测 19 列的经典事故

现象:模型在训练集上正常,predict(X_val)突然报feature_names mismatch,或者不报错但结果全部离谱。原因:训练前特征选择做过几次调整,验证集特征列表没同步;或者手工把 DataFrame 转 numpy 时列顺序发生了漂移。解决:把特征清单定义成全局列表FEATURES,训练和预测统一用它取列,并在训练前执行assert list(X_train.columns) == list(X_val.columns)。XGBoost 训练后输出model.feature_names看一眼,预测时用X_val[FEATURES],不要图省事传X_val.values。

6. 源码整理与报告 PDF:答辩前把“能跑”变成“能讲”

6.1 报告 PDF 里最值钱的两张表

报告不需要流水账堆字数。导师和评委最想看的是:模型对比表、以及“你说你的特征有效,证据呢”。模型对比表我从来只保留三行:Ridge、RandomForest、XGBoost,每行放 R²(log)、MAE(万)、RMSE(万) 三列,下面跟一段一句话解释差异原因。特征有效性用均值编码前三名来证明:director_hist_mean、actor1_hist_mean、opening_box。如果 opening_box 排第一,说明“首日修正模型”比“纯静态预测”信息量大,这个结论能直接回答“你这个项目到底预测了什么”。

6.2 让源码可复现:固定种子、日志、环境说明

源码包交出去之前,先想一下评审老师会不会真的去跑。我见过太多源码包跑起来缺库、乱码、路径写死。固定三样东西:所有模型统一random_state=42;训练过程打印时间、数据量、每个模型的评估结果;requirements.txt 里列清楚 pandas、scikit-learn、xgboost 的版本号。项目根目录放一个run.py,从上到下依次执行“读数据 → 清洗 → 特征工程 → 切分 → 训练 → 评估”,保证一个命令出全部结果。

if __name__ == '__main__': df = load_data() # 数据读取 df = clean(df) # 日期、演员、缺失值处理 df = build_features(df) # 档期、均值编码、首日特征 cutoff = pd.Timestamp('2019-01-01') train, val = split_by_time(df, cutoff) result = train_and_evaluate(train, val) result.to_csv('result.csv', index=False)

6.3 用“反向验证”自证模型置信度

答辩时最容易被挑战的问题是:“你凭什么说模型能用在未来的电影上?”我的做法是加一个反向验证:把已知数据切成两个时间窗口,拿前一个窗口训练,去预测后一个窗口的票房,然后计算每条样本的误差率,最终给出平均误差。这个思路和量化交易里策略回测是同一个套路。

def backtest(df, last_train_date, features): tr = df[df['release_date'] < last_train_date].copy() va = df[df['release_date'] >= last_train_date].copy() # 重新在 tr 上做均值编码,验证集只做 map,禁止复用全量编码 tr, va, _ = fit_target_mean(tr, va, va.copy(), 'director', 'total_box') # 训练与评估,仿真“过去不知道未来”的真实决策 model = XGBRegressor(n_estimators=300, learning_rate=0.05, random_state=42) model.fit(tr[features], np.log1p(tr['total_box'])) va['pred'] = np.expm1(model.predict(va[features])) va['err'] = (va['pred'] - va['total_box']).abs() / va['total_box'] return va[['title', 'total_box', 'pred', 'err']].sort_values('err')

这段代码输出的表里,挑一两部典型电影在答辩时展开讲:为什么高估、为什么低估、有哪些特征没捕获到。这种“我知道模型在哪失效”的态度,比一个写着 95% 准确率的模型更能压住场。

我做这套设计收尾前最后悔的一件事,就是把均值编码写在了数据切分之前,验证 R² 漂亮到连自己都膨胀,结果换年份一验证直接垮掉。后来改成“先切分再编码”,把反向验证表放进报告附录,才真正觉得这个题目闭环了。希望帮到你。

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

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

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

立即咨询