ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

电影票房预测用机器学习回归模型:从特征工程到XGBoost调参实战

电影票房预测用机器学习回归模型:从特征工程到XGBoost调参实战

简介:基于机器学习算法构建的电影票房预测系统毕业设计,面向计算机相关专业学生,可用于毕业设计、期末大作业或课程设计参考。项目以TMDB电影数据集为输入,完整覆盖数据清洗、特征工程、模型选择与训练、评估优化及票房预测流程,并额外提供基于协同过滤与矩阵分解的推荐算法模块,如KNN、SVD、内容推荐等,便于从多个角度理解机器学习在真实业务中的应用。源码包含详细注释与清晰的项目结构,配合可视化图表和文档,方便初学者快速部署和二次开发。压缩包包含59个文件,包括16个Python脚本、16个CSV数据集、23个PNG可视化图、2个Markdown说明及1个PDF文档,体积约30.95MB,目录划分明确,适合按模块逐步研读。目前已有524人下载学习,是一份兼具完整性与实战性的高分项目参考资料。

1. 电影票房预测这条线,值得用机器学习而不是神经网络

电影票房预测这个毕设方向,看起来年年都有人做,但真正能拿高分的难点不在“跑出一个数字”,而在你能不能把问题定义成回归任务并用对工具。票房是一个连续数值,不是分类标签;Python 里现成的机器学习算法,比如随机森林和 XGBoost,往往比堆一个深度学习网络更稳、更好解释,也更容易在答辩时讲清楚。这篇笔记按照“先定义问题、再做特征、再调参、再避坑”的顺序,把源码和文档数据背后的完整落地路径拆开讲,适合正在选毕设题目或做课程项目的同学。前置要求不高,把 Python 按常规方式装好,能跑pip install pandas scikit-learn xgboost就可以跟着走。

2. 先把问题定义清楚:回归任务、算法选型与特征设计

2.1 票房预测是回归问题,不是分类问题

很多同学拿到题目第一反应是“票房高/中/低三档”,然后套分类模型,这是最典型的翻车起点。分类模型输出的是离散标签,而票房预测的标签是“某部电影最终卖了多少亿”,它天然是连续值。哪怕你只是做一个最简单的线性回归,也是在解决同一个核心问题;而一旦你把它强行改成分类,评估指标就会变成准确率,票房 5.1 亿和 5.2 亿会被分到两个档,这种误差在业务上完全可以忽略,但在模型上却被当成一次错误。

回归任务要看的指标是 RMSE、MAE、R² 这一组,不是准确率。先跑一段最简单的评估代码,把评价口径定下来:

import numpy as np from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score # 真实票房和预测票房,单位:亿元 y_true = np.array([3.2, 0.8, 12.5, 6.1]) y_pred = np.array([2.7, 1.1, 11.9, 6.4]) rmse = mean_squared_error(y_true, y_pred, squared=False) mae = mean_absolute_error(y_true, y_pred) r2 = r2_score(y_true, y_pred) print(f"RMSE: {rmse:.3f} 亿元") print(f"MAE: {mae:.3f} 亿元") print(f"R2: {r2:.3f}")

RMSE 是均方根误差,它把误差平方后平均再开方,所以大误差会被放大;MAE 是平均绝对误差,更符合人对“平均差多少亿”的直觉。R² 表示模型解释了目标变量多少方差,越接近 1 越好,但 R² 高不代表误差小,如果票房整体数值都很大,R² 0.9 也可能对应上亿元的偏差。所以后面评估模型时,这三个指标要一起看。

2.2 三种算法怎么选:线性回归、随机森林、XGBoost

毕业设计里最稳的组合不是“最新最强”,而是“能解释 + 能对比 + 能调参”。我一般会并行跑三个模型,分别承担不同任务:

算法选它的理由课设里容易翻车的点
线性回归解释性好,适合做基线票房和特征之间大多不是线性关系,预测容易整体偏低
随机森林自带特征重要性,能画图答辩类别特征基数大时容易过拟合,训练慢
XGBoost精度通常最高,能处理缺失值默认参数直接跑容易过拟合,必须配合 early stopping

先跑线性回归,花十分钟确认“数据能读、特征能用、管道能通”,这叫基线;再用随机森林看特征重要性,答辩时可以展示“模型认为预算最重要、档期次之”;最后用 XGBoost 调参争取更好的精度。三个模型放在一起做对比,本身就是高分毕设里很讨喜的结构。

如果答辩评委问“为什么不用深度学习”,回答思路是:票房数据集通常只有几百到几千条样本,深度学习在小样本上不稳定,而且可解释性差;机器学习模型训练快、可复现、能看到每个特征的贡献,更适合作为毕设主体。

2.3 特征设计的几个常规动作

特征工程才是这个题目的上限所在。一个电影样本里,常见的字段包括预算、类型、上映日期、导演、主演、时长、是否续集、发行公司。特征设计决定模型能学到什么,模型只是在逼近这个上限。

特征类型处理方式
budget(预算)数值直接使用,通常是最强特征
runtime(时长)数值缺失值用中位数填充
genre(类型)类别类别编码,不 one-hot
director(导演)高基数类别类别编码或出现次数编码
lead_actor(主演)高基数类别类别编码或出现次数编码
release_date(上映日期)时间拆成年份、月份、是否热门档期
is_sequel(是否续集)二值0/1 编码

日期不能直接丢给模型当连续值,每年的市场环境差异很大,原始日期字符串更是毫无意义。常见的做法是把它拆成如下特征:

def create_date_features(df): df["release_date"] = pd.to_datetime(df["release_date"]) df["year"] = df["release_date"].dt.year df["month"] = df["release_date"].dt.month # 暑期档、贺岁档、国庆档是典型的票房集中时间段 df["is_hot_season"] = df["month"].isin([1, 2, 7, 8, 10, 12]).astype(int) return df

is_hot_season把月份变成一个有业务含义的 0/1 特征,比直接丢一个month=7给树模型更直观。这里的核心思想是:让模型少学“日期字符串”,多学“档期规律”,预测结果会稳定很多。

3. 用数据集跑通预处理:切分、编码与目标变量变换

3.1 读数据、清洗缺失值和异常值

拿到原始数据第一步不是建模,而是先看有多少行、多少列、哪些字段缺失。下面是一段常见的清洗逻辑:

import pandas as pd import numpy as np df = pd.read_csv("movies.csv") # 去除同一部电影的重复行 df = df.drop_duplicates(subset=["title"]) # 票房为负或为零的样本通常是有问题的,直接过滤 df = df[df["box_office"] > 0].copy() # 数值型缺失用中位数填充,不要用均值 for col in ["budget", "runtime"]: df[col] = df[col].fillna(df[col].median())

这里有两个细节值得注意。第一,box_office <= 0的样本要直接删除,因为票房不可能是负数,这类记录多半是数据录入错误;第二,数值缺失用中位数而不是均值,因为预算和时长通常是右偏分布,少数大片预算极高,均值会被拉高,中位数对异常值更稳健。

在动手之前,还要检查字段里有没有“泄漏特征”。比如首周票房这种字段,它和总票房的相关性极高,如果把它作为特征,模型分数会很好看,但答辩时会被问“首周票房从哪里来?如果电影还没上映你怎么预测?”所以这类特征要么不用,要么在文档里明确说明预测时点,保持逻辑自洽。

3.2 类别特征编码与目标编码

电影数据里导演、主演这类字段取值非常多,如果直接 one-hot,特征矩阵可能膨胀到几千列,训练慢且容易过拟合。更稳的做法是把高基数类别编码成整数序号:

# 把高基数类别转成 category codes for col in ["genre", "director", "lead_actor"]: df[col + "_code"] = df[col].astype("category").cat.codes # 对票房做 log1p 变换,这是回归任务的关键动作 df["box_office_log"] = np.log1p(df["box_office"])

astype("category").cat.codes会把每个类别映射成一个整数,比如导演 A 是 0,导演 B 是 1。它不会引入高维稀疏问题,训练速度也快。缺点是整数序号本身没有大小含义,但对树模型来说,它只需要找到分裂点,所以影响可控。

真正关键的是log1p变换。票房分布极其右偏,少数大片票房几十亿,多数小片只有几千万,直接回归会让模型拼命拟合那几个极端值。取对数之后,分布变得更接近正态,模型更容易学习规律。预测时再用np.expm1还原成真实票房。

3.3 按时间切分数据,避免数据泄漏

票房数据和普通表格数据不同,它带有时间属性。如果直接train_test_split随机打乱,训练集里会出现 2019 年的电影,验证集里出现 2015 年的电影,模型等于提前看到了“未来”,评估分数会虚高。正确做法是按年份切分:

# 按时间切分,而不是随机切分 train = df[df["year"] <= 2017].copy() val = df[(df["year"] >= 2018) & (df["year"] <= 2019)].copy() test = df[df["year"] >= 2020].copy() print(f"train: {len(train)} rows") print(f"val: {len(val)} rows") print(f"test: {len(test)} rows")

切分比例不用固定,关键是保证训练集的年份全部早于验证集。这样做评估出的模型表现才是真实的“用过去预测未来”。如果年份不均衡,比如 2020 年样本太少,可以把切分点往前移,保证每个集合都有足够样本。

4. 训练与调参:从 baseline 到 R² 稳定在可用范围

4.1 先跑随机森林基线,判断欠拟合还是过拟合

先不急着调参,用随机森林跑一个基线,同时观察训练集和验证集的分数差距:

from sklearn.ensemble import RandomForestRegressor from sklearn.model_selection import GroupKFold from sklearn.metrics import r2_score feature_cols = ["budget", "runtime", "genre_code", "director_code", "lead_actor_code", "is_hot_season"] X = df[feature_cols].values y = df["box_office_log"].values groups = df["year"].values gkf = GroupKFold(n_splits=5) for train_idx, val_idx in gkf.split(X, y, groups): model = RandomForestRegressor(n_estimators=300, max_depth=12, random_state=42) model.fit(X[train_idx], y[train_idx]) train_r2 = r2_score(y[train_idx], model.predict(X[train_idx])) val_r2 = r2_score(y[val_idx], model.predict(X[val_idx])) print(f"train R2: {train_r2:.3f}, val R2: {val_r2:.3f}")

用GroupKFold而不是普通的KFold,目的是让同一年份的电影始终待在同一个折里,不会发生跨越年份的泄漏。n_splits=5表示分成 5 折,训练 5 次取平均。

如果训练集 R² 接近 0.95 而验证集只有 0.5,说明模型过拟合,需要减小max_depth或者增加min_samples_leaf;如果两边都低,说明特征不够强,应该回到特征工程而不是继续堆模型。

4.2 XGBoost 调参:核心参数与 early stopping

随机森林稳定之后,换 XGBoost 提精度。XGBoost 默认参数在小数据集上非常容易过拟合,所以核心思路是降低学习率、限制树深度、加上早停:

import xgboost as xgb model_xgb = xgb.XGBRegressor( n_estimators=500, max_depth=5, learning_rate=0.05, subsample=0.8, colsample_bytree=0.8, min_child_weight=3, tree_method="hist", random_state=42 ) model_xgb.fit( X[train_idx], y[train_idx], eval_set=[(X[val_idx], y[val_idx])], verbose=False )

这几个参数是有联动关系的:

参数作用常见调法
n_estimators树的数量配合 early stopping,越多不一定越好
learning_rate学习率调小到 0.01-0.1 之间,精度更稳
max_depth树深度5-8 之间,太深必过拟合
subsample样本采样比例0.7-0.9,减少方差
colsample_bytree特征采样比例0.7-0.9,减少方差
min_child_weight叶子节点最小样本权重3-5,防止学到噪声

调参的顺序不要乱来。先固定learning_rate和n_estimators,然后把max_depth从 3 往上加,观察验证集分数什么时候开始下降;再用subsample和colsample_bytree把方差压下来。如果数据量很少,max_depth超过 8 基本必翻车。

4.3 评估指标取舍:RMSE、MAE、MAPE 怎么配合

回归模型不能只看 R²。我见过很多毕设只报一个 R² 0.9,但评委追问“平均误差多少亿”时答不上来。R² 高不代表误差小,要看 MAE:

def mape(y_true, y_pred): y_true = np.maximum(y_true, 1e-8) # 防止除零 return np.mean(np.abs((y_true - y_pred) / y_true)) * 100 pred_log = model_xgb.predict(X[val_idx]) pred = np.expm1(pred_log) actual = np.expm1(y[val_idx]) print(f"MAE: {mean_absolute_error(actual, pred):.3f} 亿元") print(f"MAPE: {mape(actual, pred):.2f}%")

MAPE 是平均百分比误差,它直接把误差量纲变成百分比,答辩时最好解释:“平均下来预测票房与真实值大约偏差 15%”。经验上,MAPE 能控制在 20% 以内,这个项目就可以算成功了;如果一直高于 30%,大概率是特征没做好,不是模型不够强。

5. 避坑:电影票房毕设最容易翻车的五个问题

5.1 票房分布右偏,MSE 让模型输出“平均值”

现象:训练完成后,验证集预测值全部集中在 3-8 亿元之间,几部 20 亿以上的大片完全预测不出来。

原因:票房是标准的长尾分布,大片是少数极端值。直接对原始票房算 MSE,模型为了让总损失最小,会把预测结果往均值附近拉,因为预测成 5 亿比预测成 20 亿的损失小得多。

解决:训练前对票房做np.log1p变换,在 log 空间里计算损失,预测后再用np.expm1还原。变换之后,5 亿和 20 亿的差距在 log 空间里被压缩,模型不会再一味讨好极端值。

5.2 不同年份票房不可比,随机切分会高估模型

现象:随机打乱数据后 R² 高达 0.9,改成按年份切分后直接掉到 0.5。

原因:电影市场整体规模逐年变化,2012 年的票房冠军可能还不如 2019 年的第十名。随机切分相当于训练集里混进了未来年份的高票房样本,模型提前“见过答案”,评估分数虚高。

解决:坚持按时间切分数据,交叉验证时用GroupKFold按年份分组。更进一步,可以把“上映年份”本身作为特征加入模型,让模型学习年度市场基数,但预测新电影时,要手动给一个合理的预测年份值。

5.3 导演/主演等类别特征基数大,one-hot 直接炸内存

现象:导演和主演各做 one-hot 后,特征矩阵多出上千列,8GB 内存直接跑不动,训练时间翻了几倍。

原因:一个数据集里可能有几百位导演、上千位演员,one-hot 会把每个取值变成一列,高基数类别膨胀得非常快。

解决:优先用category编码。如果某个导演或演员出现次数很少,可以先把出现次数低于阈值的取值合并成“other”,再做编码。这在工程上叫 frequency encoding,用出现次数当数值特征,既保留了信息又不会让维度爆炸。

5.4 缺失值直接删除导致样本量骤减

现象:原始数据有 800 条电影样本,清洗时对所有缺失值dropna(),最后只剩 400 条,模型怎么训练都不够。

原因:缺失值不是随机分布的,小成本电影的预算、导演信息往往更容易缺失,直接删行等于把最有代表性的小片样本全部丢掉了。

解决:数值型字段用中位数填充,类别型字段先填充一个固定的“unknown”占位字符,再做编码。还可以加一个is_budget_missing的 0/1 特征,让模型自己决定缺失到底意味着什么。这样做的前提是,缺失本身可能包含业务信息。

5.5 模型只用了预算一个特征,答辩被追问

现象:特征重要性画出来,budget几乎占 80%,其他特征接近 0。评委问“那这部电影口碑好怎么解释”,答不上来。

原因:预算和票房相关性本来就强,树模型会优先用它在根节点做分裂,导致其他特征在树里出现得很晚,重要性被覆盖。更重要的是,原始特征里本来就没有“口碑”相关的数据字段。

解决:在特征层面做交互,比如构造“热门档期 × 类型”“预算段 × 是否续集”这类交叉特征,让模型有机会在局部组合里找到规律;同时把“单位预算的票房”作为一个派生目标来辅助分析。答辩时主动说明“当前模型用的是上映前可获取的特征,口碑不在预测范围内”,这句话比硬解释要专业得多。

6. 调试与验证:给毕设源码加一个可复现的预测入口

6.1 把模型封装成可命令调用

训练模型只是第一步,毕设源码最后一定要有一个“输入一部电影的信息,输出预测票房”的入口。用 joblib 保存模型和特征名,再用一个独立脚本加载模型:

# predict.py import pandas as pd import numpy as np import joblib model = joblib.load("models/xgb_model.pkl") feature_names = joblib.load("models/feature_names.pkl") def predict(budget, runtime, genre_code, director_code, is_hot_season): x = pd.DataFrame( [[budget, runtime, genre_code, director_code, int(is_hot_season)]], columns=feature_names ) pred_log = model.predict(x)[0] return np.expm1(pred_log) if __name__ == "__main__": result = predict(2.5, 120, 3, 12, 1) print(f"预测票房: {result:.2f} 亿元")

xgb_model.pkl里保存的是训练好的 XGBoost 模型,feature_names.pkl保存的是训练时使用的特征列顺序。这个顺序很关键,预测时传入的 DataFrame 列顺序必须和训练时完全一致,否则模型会静默出错。用 joblib 把特征名和模型一起保存,是避免这类问题的通用做法。

6.2 把整个流程串成固定脚本并固定随机种子

最后再往前走一步:把清洗、特征、训练、预测串成一个可重复执行的流程。我一般会这样组织源码结构:

data/ 原始数据与清洗脚本 features.py 特征构造函数 train.py 训练与交叉验证 predict.py 单条预测入口 requirements.txt 依赖清单

predict.py可以单独被评委运行,这比让评委员去翻 Jupyter Notebook 里几十个单元格要友好得多。另外,所有模型在训练前必须固定随机种子:

import random import numpy as np random.seed(42) np.random.seed(42)

固定种子是对确定性最基本的尊重。随机森林和 XGBoost 都引入了随机采样,不固定种子的话,同一次训练跑两次结果都不同,文档里写的 R² 数值也没法复现。这一点是我做这类项目时最后才补上的,差点在验收时翻车。把这条习惯养成之后,后续换数据集、换特征方向都会省很多力气。希望这篇笔记能帮你把电影票房预测这条线走得比预期更稳。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进