简介:一套基于猫眼电影数据和SVR回归器实现的电影票房预测系统,覆盖数据爬取、特征分析与票房预测的完整流程。项目源自个人毕业设计,代码已通过运行测试并获答辩均分96分,适合计算机相关专业学生用于毕设、课程设计或项目立项演示,也可帮助具备Python基础的学习者理解回归建模与爬虫实践。压缩包共18个文件,整体仅186KB,包含7个Python源码脚本(对应数据抓取、预处理、特征分析、SVR预测等模块)、6个pyc编译缓存、4个woff字体文件(用于猫眼反爬字体解析)及1个xls电影特征数据表,结构清晰,便于对照源码快速理顺项目流程。目前已有157人学习,作者可提供远程教学支持,遇到运行问题可私聊解决。包内附有README说明文件,便于快速掌握项目整体结构;作为真实业务型机器学习回归样例,也可在其上扩展新特征或调整预测目标,适合练习完整项目链路。
1. 做电影票房预测,为什么绕不开这套技术栈
接到「预测电影首周票房」这类需求时,我第一反应就是把猫眼电影数据爬下来,再做特征分析,最后扔进 SVR 回归器里跑。这不是拍脑袋,而是目前中短期票房预估里最稳妥、复现成本最低的组合:爬虫解决数据来源,特征分析把「想看人数、评分、档期、主演」这些杂信息变成数值特征,SVR 回归器用径向基核去拟合票房和特征之间那条明显非线性的曲线。很多人用线性回归做票房预测,结果在爆款和高冷文艺片上同时翻车,就是因为线性模型扛不住这种两头极端的分布。这套基于猫眼电影数据和 SVR 回归器的电影票房预测系统,适合手里有 Python 基础、想做数据采集到建模全流程的工程师,也适合想用一份可复现代码快速验证票房预测可行性的分析师。下面我从数据采集开始,把每一步的代码和边界都讲清楚。
2. 用 requests 爬取猫眼票房数据:接口字段与反爬应对
2.1 猫眼票房榜的公开接口长什么样
常见做法是直接请求猫眼的票房榜接口,它返回的是结构化 JSON,不需要走浏览器渲染,比解析 HTML 省事很多。猫眼票房榜的接口路径通常是带有 offset 和 limit 参数的分页结构,返回内容里包含电影名、上映日期、实时票房、累计票房、场次、人次、平均票价、上座率等字段。需要注意,这个接口给的是「实时票房」口径,不是猫眼专业版里的最终票房,所以写爬虫时字段名要对齐接口返回的 key,不能凭页面显示猜。
我一般会先把接口返回的 JSON 结构打印出来,确认字段后再写解析逻辑。票房榜接口常见的字段包括:
| 字段名 | 类型 | 说明 |
|---|---|---|
| movieName | string | 电影名称 |
| releaseInfo | string | 上映日期与地区信息 |
| boxInfo | string | 实时票房(带格式) |
| sumBoxInfo | string | 累计票房(带格式) |
| boxRate | string | 票房占比 |
| showInfo | string | 场均人次与上座率 |
| splitInfo | string | 场次信息 |
注意这些字段大多是字符串而不是数字,落地时需要统一清洗成 float。除了票房榜接口,想看人数、评分这类字段通常要去电影详情接口拿,常见做法是按电影 ID 逐个请求详情页,再把两张表按电影名或 ID 合并。写爬虫前先想清楚最后要建什么模型,特征决定字段,不要一股脑全爬。
2.2 最小爬虫代码:Session 复用、请求头和限速
爬猫眼这类站点,最忌讳上来就无脑循环请求。我常用的模板是复用 requests.Session,带上 User-Agent 和 Referer,然后控制请求频率。下面是能直接跑通的最小实现,以票房榜接口为例:
import time import requests import pandas as pd session = requests.Session() session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://piaofang.maoyan.com/", }) rows = [] for offset in range(0, 100, 10): url = f"https://piaofang.maoyan.com/api/ajax/box?offset={offset}&limit=10" resp = session.get(url, timeout=10) resp.raise_for_status() data = resp.json() # 常见接口结构:data.list 里是当前页的电影数据 rows.extend(data.get("list", [])) time.sleep(1.2) df = pd.DataFrame(rows) print(df.shape) print(df.head(3))这段代码里有两个关键点。第一,Session 复用连接,避免每次请求都重新握手,也能统一携带头部信息;Referer 字段对猫眼这类站点尤其重要,少了它容易被判定为异常请求。第二,time.sleep(1.2)是给自己留的缓冲,猫眼对请求频率敏感,爬太快会直接返回空列表。offset按 10 递增是分页逻辑,具体步长以接口实际返回的 pageSize 为准,有的是 10,有的是 30。跑完先看df.shape,如果行数远小于预期,优先怀疑被反爬拦截了。
2.3 清洗落盘:字符串转数值和缺失值处理
拿到 DataFrame 后,直接建模是不行的,原因在于票房榜接口大量字段是「1.2亿」「23.4万」这种带格式的字符串,还有可能缺字段。常见做法是写一个清洗函数,把中文字符统一转成数值单位,把空字符串替换成 NaN,再决定填充策略。
import numpy as np def parse_amount(s): if isinstance(s, str): s = s.strip() if s.endswith("亿"): return float(s[:-1]) * 1e8 if s.endswith("万"): return float(s[:-1]) * 1e4 try: return float(s) except ValueError: return np.nan return s # 只保留建模可能用到的列 keep_cols = ["movieName", "releaseInfo", "boxInfo", "sumBoxInfo", "boxRate"] df_clean = df[keep_cols].copy() for col in ["boxInfo", "sumBoxInfo", "boxRate"]: df_clean[col] = df_clean[col].map(parse_amount) # 缺失处理:票房字段缺失直接丢弃,这部电影没有预测价值 df_clean = df_clean.dropna(subset=["sumBoxInfo"]) df_clean.to_csv("maoyan_boxes.csv", index=False, encoding="utf-8-sig") print(f"清洗后剩余 {len(df_clean)} 条记录")parse_amount里有个容易被忽略的细节:float(s[:-1])之前要先做strip(),因为接口返回的字符串偶尔带空格。编码用utf-8-sig是为了让 Excel 打开 CSV 不乱码,后续如果用 pandas 读回,encoding="utf-8-sig"同样适用。这里的数据是建模的原料,宁可去掉缺失行也不要让脏数据混进特征。
3. 特征分析:把原始字段变成 SVR 回归器能消化的数值特征
3.1 特征分类与目标变量的选择
票房预测的特征一般分成三类:影片属性、上映前热度、上映后表现。影片属性包括类型、时长、制片地区、是否系列片;上映前热度包括想看人数、预售票房、物料播放量;上映后表现包括首日票房、首周末口碑评分、排片占比。SVR 回归器本身不吃文本,类型、档期、主演这些必须编码成数值。目标变量我一般选首周票房而不是总票房,理由是首周票房受上映前特征和首日表现影响最大,预测边界清晰;总票房要覆盖更长的时间窗口,受口碑扩散和后续排片影响,误差会显著变大。
对猫眼抓下来的数据,能直接当数值特征的是评分、想看人数、场次、人次。类型是典型的类别特征,动作、喜剧、科幻这些标签需要 one-hot。档期也要拆,暑期档、国庆档、春节档对票房的影响差别巨大,一个小成本片子放在春节档和放在冷门档期,首周票房可能差一个数量级。
3.2 类别编码和时间特征的构建
类型和档期的编码,我一般直接用 pandas 的get_dummies。这里有个关键点:get_dummies默认会把所有类别列展开成多列,如果类型字段是「动作,冒险」这种逗号分隔的多标签,要先str.split再展开,否则整串会变成一个独立类别,等于没拆分。主创信息不能简单做 one-hot,当红花旦和小透明演员出现在同一列里,one-hot 会让模型学到「这个演员出现过」,但学不到热度差异。
常见做法是把主演名字映射成近三年的平均票房热度,这个需要离线统计,代码里演示一下思路:
df_clean["genre_list"] = df_clean["genres"].str.split(",") genre_dummies = df_clean["genre_list"].apply(lambda x: pd.Series([1] * len(x), index=x)).fillna(0) df_feat = pd.concat([df_clean, genre_dummies], axis=1) # 上映日期拆成星期几和月份 df_feat["release_date"] = pd.to_datetime(df_feat["releaseInfo"].str[:10]) df_feat["release_weekday"] = df_feat["release_date"].dt.dayofweek df_feat["release_month"] = df_feat["release_date"].dt.month # 周末上映标记:周四周五上映有利于首周票房 df_feat["is_weekend_release"] = df_feat["release_weekday"].isin([3, 4]).astype(int) # 主演热度:合并外部统计表,若缺失用中位数填充 actor_heat = pd.read_csv("actor_heat.csv") df_feat = df_feat.merge(actor_heat, on="lead_actor", how="left") df_feat["actor_heat"] = df_feat["actor_heat"].fillna(df_feat["actor_heat"].median())这段代码有几个地方值得展开。apply(lambda x: pd.Series(...))这种展开方式会把多标签类型拆成多个一行向量,缺点是当某个标签出现次数极少时,fillna(0)后整列几乎全零,SVR 会把它们当噪声处理,后续可以过滤掉出现次数小于 5 的标签。上映日期从字符串切片取前 10 位,是因为releaseInfo常常是「2024-06-08 中国大陆」这种格式,直接pd.to_datetime会报错。主演热度缺失用中位数填充,而不是用 0,理由是中位数不会拉偏分布。
3.3 相关性分析与偏态处理
特征做完,先看相关性再进模型。这里有两个硬性任务:一是看特征和目标变量之间的相关性,排除掉那种「看着有关实际无关」的字段;二是检查票房标签的分布。票房数据天然偏态,头部爆款几亿,尾部片子几百万,如果不处理,SVR 回归器会被少数高票房样本牵着走。
import seaborn as sns import matplotlib.pyplot as plt from sklearn.preprocessing import StandardScaler numeric_cols = ["boxInfo", "sumBoxInfo", "boxRate", "actor_heat", "is_weekend_release"] sns.heatmap(df_feat[numeric_cols].corr(), annot=True, cmap="coolwarm") plt.savefig("feature_corr.png", dpi=150, bbox_inches="tight") # 标签取 log,压缩偏态 df_feat["target_week1"] = np.log1p(df_feat["boxInfo"]) # 标准化数值特征,SVR 对量纲敏感 num_feats = ["boxRate", "actor_heat", "release_weekday"] scaler = StandardScaler() df_feat[num_feats] = scaler.fit_transform(df_feat[num_feats]) print(df_feat[["target_week1", "boxRate", "actor_heat"]].describe())np.log1p而不是np.log,是因为boxInfo可能为 0,log1p等价于log(1+x),避免负无穷。标准化放在相关性分析之后的理由,是corr()本身对量纲不敏感,但 SVR 的径向基核函数计算距离时量纲差异会直接压垮小数值特征。如果画完热力图发现两个特征相关系数超过 0.85,我一般会去掉其中一个,因为 SVR 对冗余特征没有天然的惩罚机制,共线性会让支持向量的选择变得不稳定。这一节做完,特征应该是一张纯数值的宽表,SVR 回归器才能顺利消化。
4. SVR 回归器建模:核函数、参数调节与时间序列划分
4.1 为什么选 SVR 而不是线性回归
票房和特征之间的关系是典型的非线性:想看人数到了一定规模,票房增速放缓;评分很高但排片少,票房依旧上不去。线性回归在这种场景下表现很差,因为它假设特征是独立线性累加。SVR 回归器的思路不是拟合所有样本点,而是拟合一个「间隔带」,只有落在间隔带外面的样本才会成为支持向量,损失函数也只惩罚这些样本。
一旦选 SVR,就要面对四个参数:kernel、C、epsilon、gamma。kernel我默认选rbf,它能把特征映射到高维空间去拟合非线性关系;poly在特征维度高时容易过拟合,线性核在票房预测这种小样本场景下拟合能力不够。C是正则化系数,越大越不愿意容忍误差,但也越容易过拟合;epsilon是间隔带半宽,越大支持向量越少,模型越平滑;gamma控制径向基核的作用半径,越大单个样本的影响范围越小。这四个参数没有固定值,必须靠网格搜索。
| 参数 | 默认值 | 调节方向 | 常见区间 |
|---|---|---|---|
| kernel | rbf | 首选 rbf,线性核做对比 | rbf / linear |
| C | 1.0 | 过拟合就调小,欠拟合就调大 | 1 ~ 100 |
| epsilon | 0.1 | 噪声大就调大,想拟合更细就调小 | 0.01 ~ 1 |
| gamma | scale | 特征多时用 scale,特征少时试固定值 | scale / 0.01 ~ 0.1 |
gamma的scale选项会根据特征数量自动计算,是 sklearn 给的安全默认值。特征少于 20 个时我通常手动试小数值区间,特征多时用scale起步。
4.2 建模管线与网格搜索:一份可以抄作业的代码
SVR 的建模流程我会写成 Pipeline,把标准化和模型放在一起,避免网格搜索交叉验证时数据泄漏。这里给出完整示例:
from sklearn.svm import SVR from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.model_selection import GridSearchCV from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score import numpy as np feature_cols = [c for c in df_feat.columns if c not in ["movieName", "releaseInfo", "genres", "release_date", "target_week1"]] X = df_feat[feature_cols].values y = df_feat["target_week1"].values # 时间序列划分:按上映日期排序后切分,不打乱 split = int(len(df_feat) * 0.8) X_train, X_test = X[:split], X[split:] y_train, y_test = y[:split], y[split:] pipe = Pipeline([ ("scale", StandardScaler()), ("svr", SVR(kernel="rbf", epsilon=0.1)), ]) param_grid = { "svr__C": [1, 10, 100], "svr__epsilon": [0.01, 0.1, 1], "svr__gamma": ["scale", 0.01, 0.1], } grid = GridSearchCV(pipe, param_grid, cv=5, scoring="neg_mean_absolute_error", n_jobs=-1) grid.fit(X_train, y_train) best = grid.best_estimator_ y_pred_log = best.predict(X_test) y_pred = np.expm1(y_pred_log) y_true = np.expm1(y_test) print("best params:", grid.best_params_) print("MAE:", mean_absolute_error(y_true, y_pred)) print("RMSE:", np.sqrt(mean_squared_error(y_true, y_pred))) print("R2:", r2_score(y_true, y_pred))三个地方需要重点说明。第一,Pipeline里的StandardScaler会在交叉验证的每一折内重新 fit,不会用全量数据的均值和方差去污染训练集,这是网上很多 SVR 示例代码踩坑的地方。第二,GridSearchCV的scoring用的是neg_mean_absolute_error,因为 MAE 在票房场景下更直观,「平均差多少万票房」比「误差平方」好解释。第三,预测结果做了一次np.expm1还原,因为训练时对标签做了log1p,还原后的y_pred才是真实的票房量级。
C和epsilon的搜索区间要根据数据量调整。几百条样本时,C超过 100 极容易过拟合,训练集分数很好,测试集一塌糊涂;epsilon调太大模型会变得过于平滑,直接丢失对爆款电影的预测能力。网格搜索跑完后,不要只看 best params,把grid.cv_results_里每个参数组合的得分打印出来看看趋势,比单点最优值更有参考价值。
4.3 时间序列划分:为什么不能随机 split
票房预测天然是时间序列问题:用 2023 年之前的数据训练,去预测 2023 年之后的电影。很多人建模时直接train_test_split(test_size=0.2, random_state=42),这个操作在票房场景下是错的。随机划分会让相近时间上映的电影一部分进训练集、一部分进测试集,而同期电影在类型、档期、大盘热度上高度相似,测试集分数会被虚高。更隐蔽的是,如果样本里包含「上映后」才产生的特征,比如累计票房、上映第二周的场次,随机划分等于把未来信息泄进了训练集。
我一般按上映日期排序后取前 80% 做训练、后 20% 做测试,代码就是上面split的写法。更严谨一点的做法是留出法按时间窗口滚动:训练集用第 1 到 12 个月,测试集用第 13 个月,然后滑到第 2 到 13 个月、测试第 14 个月。对猫眼这种数据量不大的场景,固定切分足够评估模型了,滚动窗口留给特征工程稳定后的精调。注意排序要用原始上映日期,而不是 DataFrame 的索引,df_feat在前面的特征处理中可能已经被concat改变了行序。
5. 避坑清单:从爬虫到 SVR 回归器的 6 个翻车点
5.1 爬虫返回空列表,接口被反爬拦截
现象:请求接口后data.get("list")一直是空数组,但浏览器里打开接口地址却能看到数据。原因:缺少必要的请求头,或者请求频率太高被服务端熔断。解决:先加User-Agent和Referer,再用time.sleep把请求间隔拉大到 1 秒以上。血泪经验是,Referer 字段比 User-Agent 更容易被忽略,但很多站点恰恰优先校验它。如果加了头还不行,检查一下请求里是否带了Cookie,猫眼有些接口对未登录会话会返回空数据,此时可以用session.get先访问一次榜单页面,再请求接口,让 Session 持有合法的会话状态。
5.2 票房标签偏态导致预测值全部堆在均值附近
现象:模型在训练集上 MAE 很低,测试集上预测出来的票房全都在几千万上下,高分爆款和低分冷门都没预测出来。原因:票房标签严重右偏,SVR 用对称的epsilon间隔带拟合,头部大票房的样本太少,模型为了最小化整体损失,倾向于把所有样本预测到分布中心。解决:对标签做log1p变换后再训练,预测完用expm1还原。这一步是票房预测 SVR 建模里性价比最高的一处调整,没有之一。做了 log 变换后,误差评估也要同步在还原后的空间里算,不能拿y_pred_log和y_test_log直接报告 MAE,那个数字在 log 空间里没有业务含义。
5.3 随机划分训练集导致测试集分数虚高
现象:用train_test_split建模,R2 到 0.9 以上,换成时间排序切分后 R2 掉到 0.5。原因:同期电影特征相似,随机划分把「好友」分到了测试集,模型相当于开卷考试。解决:严格按上映日期排序切分。更稳妥的做法是把「月份」特征显式加进建模,让模型学到季节性。踩过这个坑之后我再也不信任何没有时间切分的票房回归结果,这个领域的评估指标如果不按时间划分,就没有参考价值。
5.4 One-Hot 展开后特征维度过大,训练慢到怀疑人生
现象:类型字段做get_dummies后多出 50 多列,加上档期、地区,特征维度破百,SVR 训练时间从几秒涨到几分钟。原因:低频类别全部被展开成独立列,大部分列非零值极少,径向基核在稀疏高维特征上计算开销成倍增长。解决:先统计各标签出现次数,只保留出现次数大于 5 的标签,其余归为「其他」。类别编码不是越全越好,SVR 在小样本场景下特征维度尽量控制在 30 以内,超过这个阈值优先考虑主成分分析降维,或者把低频标签手工合并。
5.5 特征里混入未来信息,模型「作弊」而不自知
现象:模型 MAE 低得离谱,直到用真实上线数据预测才发现完全不准。原因:特征表里混进了上映后才产生的字段,比如「累计票房」出现在训练特征里,但真实预测时这个值根本拿不到。解决:建模前把特征分成「上线前可得」和「上线后可得」两组。上线前特征包括想看人数、预售、档期、主演热度;上线后特征包括首日票房、首日排片、开画评分。如果目标是预测首周票房,特征只能用到首日及之前的数据,这是猫眼票房预测系统最容易掉进去的坑。我一般会在特征表里加一列available_from标记数据可用时间点,调模型时按时间点过滤,能省掉很多后期排查的麻烦。
5.6 网格搜索全用默认评分,结果被极端值绑架
现象:GridSearchCV默认用 R2 或 MSE 评分,选出来的参数预测普通片子很准,但一到爆款电影就崩。原因:MSE 对离群点极度敏感,票房分布里头部爆款数量少但量级大,模型为了压低 MSE,把大量参数预算花在拟合爆款上。解决:评估指标改用neg_mean_absolute_error或neg_median_absolute_error。中位数绝对误差对离群点更稳健,在票房这种长尾分布下比 MAE 更值得参考。调参前先想清楚业务上你最在意哪类片子,如果在意的是「普通片子预测准」,MAE 优先;如果在意「爆款不跑偏」,建议把 MAE 和 P90 误差一起打印出来看。
6. 验证模型和时间切片回测:把 SVR 预测误差变成一张可用图表
SVR 回归器通过网格搜索拿到最优参数后,先别急着写报告。我最后一步固定做时间切片回测,验证模型在不同时间窗口的稳定性。方法很简单:把数据按上映月份分成 12 个窗口,每次用前 11 个月做训练、后 1 个月做测试,循环得到 12 组误差,再画一张月度 MAE 的折线图。这张图能直接看出模型在暑期档和冷门档期的表现差异,如果某个月的 MAE 突然飙高,基本可以定位到特征里缺少那个月的关键变量,比如春节档的「票补力度」。
from sklearn.svm import SVR from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.metrics import mean_absolute_error df_feat["year_month"] = df_feat["release_date"].dt.to_period("M") months = sorted(df_feat["year_month"].unique()) results = [] for i in range(6, len(months)): train_mask = df_feat["year_month"].isin(months[:i]) test_mask = df_feat["year_month"] == months[i] X_train = df_feat.loc[train_mask, feature_cols] y_train = df_feat.loc[train_mask, "target_week1"] X_test = df_feat.loc[test_mask, feature_cols] y_true = df_feat.loc[test_mask, "sumBoxInfo"] pipe = Pipeline([ ("scale", StandardScaler()), ("svr", SVR(kernel="rbf", C=10, epsilon=0.1, gamma="scale")), ]) pipe.fit(X_train, y_train) y_pred = np.expm1(pipe.predict(X_test)) results.append({ "month": str(months[i]), "mae": mean_absolute_error(y_true, y_pred), }) # 月度误差可视化,定位模型失效的时间窗口 result_df = pd.DataFrame(results) print(result_df.sort_values("mae", ascending=False).head(5))时间切片回测的代码逻辑和普通训练几乎一样,区别只在数据划分。results里记录的是每个月的 MAE,排完序后看一眼最大的几个月,就能知道模型在哪些时段不可靠。我习惯把这张图直接放进项目文档里,比单纯贴一个 R2 数字有说服力得多。
如果还想往深走,可以给 SVR 模型加一层解释性分析。用permutation_importance或 SHAP 计算每个特征对预测结果的贡献,把「想看人数贡献最大」这类结论写成给业务方看的报告。我自己常用的方式是排列重要性,它对 SVR 这种黑匣子模型友好,逻辑也很好讲:随机打乱某个特征列,观察预测误差上升多少,上升越多说明特征越重要。这比 SHAP 的核估计快,数据量大时更实用。
这套「爬数据 → 做特征 → SVR 建模 → 时间切片验证」的流程,我已经用在不同数据源的多个项目上。这里分享一个教训:如果你在网格搜索时发现最优参数总落在搜索区间的边界,比如gamma的最优值恰好是0.01的最小值,就该考虑扩大搜索范围而不是直接取这个值,边界最优通常意味着真正的极值不在你的候选集里。先做到这一步,再谈优化也不迟。希望帮到你。
本文还有配套的精品资源,点击获取