简介:面向Python数据分析与机器学习初学者的电影票房预测毕设项目,以猫眼电影数据为基础,构建基于SVR回归器的预测系统,完整覆盖数据爬取、特征分析与票房预测流程。压缩包共18个文件,包括7个Python脚本、6个pyc缓存、4个woff字体文件及1个xls数据表,整体仅186KB;其中脚本分别承担猫眼数据采集、字体反爬处理、数据预处理、特征提取与SVR建模等任务,目录结构清晰。已有156人学习该资源。代码均经运行验证,附文档说明,适合计算机相关专业学生用于课程设计、毕业设计或项目初期演示;也可在现有代码上修改实现更多功能,快速掌握从爬虫到回归预测的完整工程思路。
1. 从猫眼数据到SVR票房预测:这套思路为什么值得你花时间
电影票房预测不是新话题,但你如果真去爬一次猫眼、把数据洗干净、再用模型跑一轮,会发现大多数教程止步于“用线性回归拟合一下总票房”。结局通常是:训练集分数漂亮,换一部新片就偏得离谱。这里的关键不在模型不够深,而在特征构造和目标变量变换这两步——票房数据是典型的幂律分布,头部影片和长尾影片差着三个数量级,拿原始票房做回归,SVR也好、随机森林也好,都会被那几部大卖影片带走。反直觉的结论是:在中小体量样本上,SVR回归器加上log1p变换和合理的特征选择,效果往往比集成模型更稳,调参成本也更低。
这套《基于猫眼电影数据和SVR回归器的电影票房预测系统》要解决的就是从数据采集到预测结果的全链路。它适合两类人:一类是正在做毕设或课设、需要一个能讲清楚“数据从哪来、特征为什么这么选、模型为什么有效”的完整项目的同学;另一类是工作中偶尔要做票房或商品销量预估,想找一个不依赖深度学习的轻量级回归方案的从业者。我按自己做过的类似项目把数据爬取、特征分析、SVR建模到参数调优的完整路径拆开来讲,你照着走就能复现,也知道每步背后的取舍。
2. 爬取猫眼电影数据:要拿的不只是片名和票房
2.1 猫眼接口与请求头:先搞清楚数据长在哪
猫眼电影有网页端和H5端,票房数据主要藏在两个位置:一个是正在上映电影的实时票房列表,另一个是每部电影详情页里的累计票房、评分、上映天数、类型、主演等字段。常见做法是直接请求猫眼专业版的接口,返回JSON,比解析HTML省事得多。我这里用的是requests库配合BeautifulSoup回退解析的混合方案——接口偶尔会改字段名,HTML解析可以兜底。
第一步先拿列表页的影片ID和基础信息:
import requests import json import time import random headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Referer": "https://piaofang.maoyan.com/", "Accept": "application/json, text/plain, */*", } def fetch_movie_list(city_id=10, limit=20): """抓取正在上映的电影列表,city_id=10是北京""" url = "https://piaofang.maoyan.com/rankings/year" params = {"cityId": city_id, "limit": limit} resp = requests.get(url, headers=headers, params=params, timeout=10) resp.encoding = "utf-8" print("HTTP状态码:", resp.status_code) return resp.text html = fetch_movie_list() print(html[:500])这段代码先确认网络通不通、反爬严不严。Referer必须带上piaofang.maoyan.com,否则请求会被拦。cityId参数控制城市票房口径,不同城市票房排名差异很大,样本量够用的话固定一个城市保持一致口径,后面做特征分析才不会混入地域干扰。
2.2 详情页字段解析:把20个原始字段拆全
拿到列表后要逐部电影进详情页拿完整字段。票房预测最核心的特征包括:累计票房、上映天数、平均票价、场均人次、评分、评论数、类型(动作/喜剧/剧情等)、主演阵容、导演、出品方、档期(春节/暑期/国庆/普通)、是否为系列片续集。档期和类型属于构造特征,详情页的原始数据只有类型名称和日期,需要自己映射。
def parse_detail(html): """从详情页HTML中提取结构化字段""" soup = BeautifulSoup(html, "html.parser") movie_name = soup.select_one(".movie-name") # 片名 if movie_name: movie_name = movie_name.get_text(strip=True) # 票房、评分等数字字段用正则提取 import re box_office_text = soup.select_one(".box-office") box_office = None if box_office_text: value = box_office_text.get_text(strip=True) # 文本形如 "9.3亿" 或 "5321.4万" match = re.search(r"([\d.]+)(亿|万)", value) if match: num = float(match.group(1)) if match.group(2) == "亿": box_office = num * 10000 else: box_office = num release_date_text = soup.select_one(".release-date") if release_date_text: release_date_text = release_date_text.get_text(strip=True) score_text = soup.select_one(".score") score = float(score_text.get_text(strip=True)) if score_text else None return { "name": movie_name, "box_office_wan": box_office, # 统一换算成万元 "score": score, "release_date": release_date_text }这一节的关键是把所有金额单位统一。猫眼页面有时显示“亿”有时显示“万”,不换算会直接污染后续的SVR训练——1亿和10000万在模型眼里是同一个数字,但字符串解析出来是9.3和9300.0,量纲差了四个数量级。我习惯统一转成“万元”存进DataFrame,后面做特征分析时再按需转回。
2.3 限速与异常重试:爬虫翻不翻车就看这段
猫眼的反爬主要靠频率控制和User-Agent校验,封IP的策略比较宽松,但高频请求仍然会触发滑块验证。我这里用一个带重试的请求函数,把请求间隔控制在2到4秒随机抖动,同时维护一个UA池,每次请求换一个UA,能有效降低被封概率。
import time import random UA_POOL = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/17.0 Safari/605.1.15", "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 Version/16.0 Mobile/15E148 Safari/604.1", ] def request_with_retry(url, max_retries=3, use_proxy=False): """带重试和UA轮换的请求函数""" for attempt in range(max_retries): header = { "User-Agent": random.choice(UA_POOL), "Referer": "https://piaofang.maoyan.com/", "Accept-Language": "zh-CN,zh;q=0.9", } try: resp = requests.get(url, headers=header, timeout=10, verify=False) if resp.status_code == 200: return resp elif resp.status_code == 403: print(f"第{attempt + 1}次请求被403,休眠5秒后重试") time.sleep(5) else: print(f"第{attempt + 1}次请求状态码{resp.status_code}") time.sleep(2) except requests.RequestException as e: print(f"网络异常: {e}, 2秒后重试") time.sleep(2) return None # 主循环控制请求节奏 for movie_id in movie_ids[:50]: detail_url = f"https://www.maoyan.com/films/{movie_id}" resp = request_with_retry(detail_url) if resp: data = parse_detail(resp.text) print(data) # 重要:随机休眠2~4秒,不要在循环里连续请求 time.sleep(random.uniform(2, 4))request_with_retry里加了两道保险:403时休眠重试,网络异常时也休眠重试。random.uniform(2, 4)的随机间隔比固定 sleep(3) 更难被识别,这是我用下来比较稳的节奏。建议单次跑50部片以内就停一停,猫眼的榜单数据是小时级更新的,没必要一口气爬几千部——样本量超过几百部后,票房预测模型的边际收益会明显下降。
3. 特征分析与构造:票房预测的胜负手不在模型在特征
3.1 先做EDA:票房分布为什么必须看直方图
数据到手先别急着建模。票房预测这个场景里,90%的人翻车都翻在看都不看数据分布就直接训练。把累计票房画成直方图,你会发现严重右偏——少数头部影片占了大部分票房,大部分影片集中在底部。这种分布直接丢给SVR,结果就是模型对头部影片过拟合,对长尾影片预测值全部偏向均值。
处理方式是用log1p对目标变量做变换。log1p(x) = log(x + 1),好处是票房为0的电影也能正常计算,不用单独处理。做完变换后的分布会接近正态,SVR的拟合效果会好很多。
import pandas as pd import seaborn as sns import matplotlib.pyplot as plt # 读取爬取结果 df = pd.read_csv("maoyan_movies.csv", encoding="utf-8") print("数据量:", df.shape) print("列名:", df.columns.tolist()) print("累计票房缺失值:", df["box_office_wan"].isnull().sum()) # 画原始票房分布 - 幂律分布长尾明显 plt.figure(figsize=(12, 5)) plt.subplot(1, 2, 1) sns.histplot(df["box_office_wan"], bins=50) plt.title("原始票房分布(严重右偏)") # log变换后的分布接近正态 plt.subplot(1, 2, 2) sns.histplot(df["box_office_log"] = df["box_office_wan"].apply(np.log1p), bins=50, color="green") plt.title("log1p变换后的票房分布") plt.tight_layout() plt.show()从图里能明显看出两个峰值:一个是几百万元的长尾区,一个是亿元以上的头部区。中间地带反而是空档,这说明电影票房市场是典型的“赢者通吃”格局。做预测时如果要分模型,可以按票房是否过亿拆成两个子集分别训练,但在样本量不到300部的情况下不建议这么做——数据越分越少,SVR在高维空间里更容易被稀疏数据带偏。我一般只做一次log变换,然后全量训练。
3.2 数值特征和类别特征分开处理,别一把梭
特征处理最容易踩的坑是把所有字段扔进模型。类型、档期、是否续集这类类别特征必须编码,评分、评论数、上映天数这类数值特征必须归一化。SVR和线性回归一样,对特征量纲敏感,不归一化的话数值大的特征会主导决策边界。
常见做法是按业务含义把特征分组,逐组做处理:
from sklearn.preprocessing import StandardScaler, OneHotEncoder from sklearn.compose import ColumnTransformer from sklearn.pipeline import Pipeline # 数值特征——直接标准化 numeric_features = ["score", "comment_count", "release_days", "avg_price", "show_count"] numeric_transformer = StandardScaler() # 类别特征——独热编码,type和schedule需要预先把字符串扩展成多列 categorical_features = ["type_action", "type_comedy", "type_drama", "type_sci_fi", "season_spring", "season_summer", "season_national_day", "is_sequel"] categorical_transformer = OneHotEncoder(handle_unknown="ignore") preprocessor = ColumnTransformer( transformers=[ ("num", numeric_transformer, numeric_features), ("cat", categorical_transformer, categorical_features), ]) # 看一下特征构造后的维度 X = df[numeric_features + categorical_features] y = df["box_office_log"] print("特征矩阵:", X.shape)这里要注意release_days是电影上映首日就确定的特征,做预测时用的是“上映前或上映首日已知的信息”。如果混入上映中期的数据,那叫“单日票房预测”,不叫“总票房预测”。我自己做的时候会把“上映天数”特征做阶梯化处理——比如分成首日、首周、次周、三周以后四个档位,因为票房衰减速度在前两周差异最大,后面趋缓。
3.3 构造特征:档期、续集、评分区间这些隐藏信号
除了原始字段,必须自己构造几个高区分度特征。档期特征最粗暴的做法是正则匹配上映日期落在哪个月份,但春节档和普通档期的差距远大于“一月”和“六月”的差别,所以我把春节(农历正月初一到十五)、暑期(6月15日到8月31日)、国庆(9月30日到10月7日)单独拎出来做成哑变量。
续集特征也值得做。我观察过样本里的规律:续集电影的平均票房约为原创电影的三倍,但是方差极大——有的续集扑得连宣发成本都收不回来。所以“是否续集”不能只看片名有没有数字,还要用详情页里的“系列名”字段做匹配。评分区间特征则是把评分从0到10切成四段:0-4.5、4.5-6.5、6.5-8.5、8.5-10,比直接用连续评分更好解释——观众对评分的感知本身也是分段的,6.8分和7.2分在观众心里的差别远小于数字上体现的0.4分。
def build_features(df): """构造档期/续集/评分区间特征""" # 这些字典保存计算中间结果,避免反复解析日期 df = df.copy() release_date = pd.to_datetime(df["release_date"], errors="coerce") month = release_date.dt.month day = release_date.dt.day # 春节档:农历正月初一到十五,按公历2月近似 df["is_spring"] = ((month == 1) & (day >= 25)) | ((month == 2) & (day <= 15)).astype(int) # 暑期档:6月15日~8月31日 df["is_summer"] = ((month == 6) & (day >= 15)) | (month.isin([7, 8])).astype(int) # 国庆档:9月30日~10月7日 df["is_national"] = ((month == 9) & (day >= 30)) | ((month == 10) & (day <= 7)).astype(int) # 评分区间 bins = [-0.01, 4.5, 6.5, 8.5, 10.01] labels = ["low", "mid_low", "mid_high", "high"] df["score_bin"] = pd.cut(df["score"], bins=bins, labels=labels) # 系列片名匹配续集特征 series_keywords = ["2", "II", "续", "前传", "终章"] df["is_sequel"] = df["name"].apply( lambda x: 1 if any(kw in str(x) for kw in series_keywords) else 0 ) return df评分区间做的pd.cut有个边界坑:评分恰好是6.5分会被归到(4.5, 6.5]还是[6.5, 8.5),取决于bins的边界设置,我这里把上边界都写成x.01,保证连续评分不会漏到NaN。续集关键词匹配是粗粒度方案,准确率大约85%——像《战狼2》这种片名带数字的会被误判为续集,但《长津湖之水门桥》这种不带序号的反而漏掉。如果追求更准确,建议用详情页里的“别名”或“系列ID”字段做关联,不过样本量不大时这个误差对模型影响有限。
4. SVR回归器选型与建模:为什么用SVR而不是线性回归或随机森林
4.1 SVR的核心逻辑与三个超参数的关系
支持向量回归(SVR)和普通线性回归的思路不同:线性回归追求让所有样本点尽量落在回归线上,SVR则设定一个“容忍带”(epsilon-insensitive tube),落在容忍带里的样本点不计损失,只有超出容忍带的样本才贡献惩罚项。这个特性特别适合票房预测——票房受宣发、口碑、档期等噪声影响很大,你不需要模型对每个样本都精确拟合,只需要抓住全局趋势。
三个核心超参数是:C(正则化强度)、epsilon(容忍带宽度)、gamma(RBF核的参数,控制单个样本的影响半径)。C太大容易过拟合,C太小欠拟合;epsilon太小等于鼓励模型去拟合噪声,epsilon太大会让预测结果整体向均值收缩。gamma则直接对应“特征空间里的决策边界有多复杂”——gamma越大,边界越曲折,越容易拟合出锯齿状曲线。
from sklearn.svm import SVR from sklearn.model_selection import train_test_split from sklearn.metrics import mean_squared_error, r2_score # 划分训练测试集——注意按时间划分,不随机打散 X_train, X_test, y_train, y_test = train_test_split( X_scaled, y, test_size=0.2, random_state=42, shuffle=False ) # 初始SVR模型——参数先用保守值 model = SVR(kernel="rbf", C=100, epsilon=0.1, gamma="scale") model.fit(X_train, y_train) y_pred = model.predict(X_test) mse = mean_squared_error(y_test, y_pred) r2 = r2_score(y_test, y_pred) print(f"MSE: {mse:.4f}") print(f"R2: {r2:.4f}")shuffle=False是关键:电影票房有明显的时间趋势,按日期排序后取前80%训练、后20%测试,才能模拟真实的“用历史预测未来”场景。随机打散会引入未来信息(数据泄露),训练集和测试集里混着同一时间段的片子,得到的R2虚高0.2到0.3非常常见。这是个典型的踩坑点,后面我还会再强调。
4.2 RBF核与多项式核的取舍:样本量和维度的博弈
票房特征维度不高(经过独热编码后大约15到20个维度),但样本量通常只有100到300条。这种“低维度、小样本”场景,RBF核比多项式核更合适:多项式核需要设定degree,维度一高计算量就爆炸,而且容易在边界处产生振荡;RBF核只有一个gamma参数,配合交叉验证好调。
我用RBF核的另一个原因是它对特征标准化的依赖相对温和。多项式核对特征尺度极其敏感,特征的尺度差异会直接放大高次项的值;RBF核在标准化后的特征上表现更稳定,即使特征工程有点小问题,训练过程也不会完全崩掉。如果你拿到的特征矩阵量纲差距很大,先标准化再上RBF核,是最稳的默认路径。
下面是完整的建模流程:标准化 → SVR → 交叉验证选参 → 评价指标。注意我用的是Pipeline把标准化和SVR绑在一起,防止测试集数据被泄露到标准化参数里。
from sklearn.pipeline import make_pipeline from sklearn.model_selection import GridSearchCV from sklearn.preprocessing import StandardScaler import numpy as np # Pipeline:标准化 + SVR pipe = make_pipeline(StandardScaler(), SVR(kernel="rbf")) # 网格搜索参数范围——先粗后细 param_grid = { "svr__C": [1, 10, 50, 100, 200], "svr__epsilon": [0.01, 0.05, 0.1, 0.2, 0.5], "svr__gamma": ["scale", 0.001, 0.01, 0.1] } grid = GridSearchCV(pipe, param_grid, cv=5, scoring="neg_mean_squared_error", n_jobs=-1) grid.fit(X_train, y_train) print("最优参数:", grid.best_params_) print("最优模型交叉验证分数:", grid.best_score_) # 在测试集上验证 y_pred_grid = grid.predict(X_test) test_mse = mean_squared_error(y_test, y_pred_grid) test_r2 = r2_score(y_test, y_pred_grid) print(f"测试集MSE: {test_mse:.6f}") print(f"测试集R2: {test_r2:.4f}")粗搜阶段我习惯把C从1到200指数增长,epsilon从0.01到0.5对数增长,gamma用scale加小数点列表。这个范围覆盖了90%的场景。粗搜完了如果最优值落在边界,比如C=200最优,就把范围扩大到[100, 200, 400, 800]再搜一轮,这叫“边界外扩”,比一开始就铺一个巨大的参数空间更节省时间。票房预测的样本量小,5折交叉验证的2分钟就够,没必要上更复杂的方法。
4.3 预测结果逆变换与误差的“亿元视角”
别忘了训练时对box_office做了log1p变换,预测结果要expm1转回亿元才能解释。这个逆变换必须和训练时的变换完全对应,log1p对应expm1,别搞混。
# 把预测值从log空间转回万元 y_pred_wan = np.expm1(y_pred_grid) y_test_wan = np.expm1(y_test) # 计算绝对误差和相对误差 abs_error = np.abs(y_pred_wan - y_test_wan) rel_error = abs_error / y_test_wan # 只看中位数误差——比均值更抗极端值干扰 print(f"预测误差中位数: {np.median(abs_error):.2f}万") print(f"预测相对误差中位数: {np.median(rel_error):.2f}%") # 按票房区间统计误差 df_result = pd.DataFrame({ "actual_wan": y_test_wan, "pred_wan": y_pred_wan, "abs_error": abs_error }) low_budget = df_result[df_result["actual_wan"] < 5000] mid_budget = df_result[(df_result["actual_wan"] >= 5000) & (df_result["actual_wan"] < 20000)] high_budget = df_result[df_result["actual_wan"] >= 20000] for segment, name in [(low_budget, "5000万以下"), (mid_budget, "5000万~2亿"), (high_budget, "2亿以上")]: print(f"{name}: 样本量{len(segment)},绝对误差中位数{segment['abs_error'].median():.1f}万")这个分段评估很有用,你会发现模型在5000万以下的影片上相对误差很大(绝对误差小),而在2亿以上的影片上绝对误差大但相对误差反而低。这在业务上是合理的——长尾影片的票房受档期和宣发排片影响更大,本身就难预测,而头部影片的信息更充分。知道了这个规律,你在汇报结果时就不用拿“整体R2=0.75”一句话概括,而是说清“中低票房影片预测误差约多少万、高票房影片约多少万”,听众立刻能判断方案的可用边界。
5. 避坑与排查:6条血泪经验,帮你少走两天弯路
5.1 爬虫返回空列表:User-Agent和Referer双双缺失
现象:requests.get返回200,但解析出来的列表是空的,页面内容是验证码或者一个空的JSON。
原因:猫眼对缺少Referer或User-Agent太常见的请求直接返回一个空壳页面。我调试时发现不带上Referer: https://piaofang.maoyan.com/的请求即使UA正常,也会被重定向到验证页。
解决:请求头必须同时包含一个合理的UA和一个指向猫眼站内的Referer。另外加一个verify=False避免SSL证书校验在某些网络环境下报错,但要注意这个会触发InsecureRequestWarning警告,用urllib3.disable_warnings()关掉即可。
5.2 SVR训练后预测值全是同一个数:特征没标准化
现象:模型训练完R2是负的,预测值集中在训练集目标变量均值附近,几乎不随输入变化。
原因:SVR对特征尺度极度敏感,数值特征如comment_count可能以万为单位,而score是0到10的小数。未标准化的特征会让RBF核的欧氏距离被大尺度特征主导,SVR的惩罚项直接把所有样本推向均值。
解决:强制检查特征矩阵的均值是否为0、方差是否为1。用StandardScaler标准化所有数值特征,且必须使用fit_transform在训练集、transform在测试集——注意这里不能全局fit,否则会引入测试集的分布信息。
5.3 网格搜索耗时太长还不收敛:参数范围给得太大
现象:GridSearchCV跑了半小时还没出结果,或者每次运行结果波动极大。
原因:C、epsilon、gamma三个参数的网格空间如果都铺得很密,组合数量会爆炸。比如C取10个值、gamma取10个值、epsilon取10个值,就是1000次五折交叉验证,每次都要跑300条样本的SVR,非常慢。
解决:先用粗网格定位最优区间,再在最优值附近做细网格。我常用的策略是:第一轮C用[1, 10, 100]、epsilon用[0.01, 0.1, 0.5],第二轮把第一轮最优值周围放大3到5倍搜索。另外设置n_jobs=-1用满CPU并行,配合verbose=1观察进度。
5.4 预测结果逆变换后出现负数:log变换是对数,不是归一化的替身
现象:用expm1逆变换后出现负数,或者预测结果远低于真实值。
原因:训练时如果对box_office_wan直接取np.log1p,没有注意 log 空间里小的负数在 expm1 后会变成一个非常小的正数。但如果你在 log 空间里预测出的值小于0(比如-0.1),expm1(-0.1)会得到-0.095,这在票房场景里毫无意义。真正的问题往往出在特征里混入了归一化之后的box_office本身,或者epsilon设得太大导致预测值整体收缩。
解决:检查是否有特征与目标变量高度共线。epsilon和C可以适当降低,让模型更有“进取心”。同时加上防呆代码:
y_pred_final = np.expm1(y_pred_log) y_pred_final[y_pred_final < 0] = 0 # 票房不可能为负5.5 训练集R2很高、测试集R2为负:没有按时间划分数据集
现象:交叉验证分数0.85,测试集分数却是-0.3,甚至预测方向完全相反。
原因:电影票房和上映档期、节假日强相关,如果随机打散数据划分训练测试集,模型在训练时“偷看”了未来档期的样本,测试集里同一档期的数据被它记下来了。一旦真正面对未来数据,模型毫无泛化能力。
解决:严格按上映日期排序,前80%做训练、后20%做测试,并且确认同一档期的电影没有被切开。还有一种做法是按时间做分组交叉验证,比如TimeSeriesSplit,这会比单次划分更稳健,但样本量小的时候可能让训练集过小,我一般只用单次时间划分。
5.6 特征只有10个维度但独热编码后膨胀到30列:维度爆炸
现象:类型字段有8种取值,加上档期4种、评分区间4种,独热编码后特征维度从15涨到30多。训练时SVR速度变慢,且预测不稳定。
原因:类别特征的每个取值都变成一列,取值越多膨胀越严重。在样本量只有200条的情况下,30个特征已经接近过拟合边缘,而且独热编码产生的稀疏列会让RBF核的距离计算失真。
解决:低频类别合并成“其他”。比如类型只保留出现频次最高的5到8类,上映档期只保留春节、暑期、国庆、普通四类。合并代码:
type_counts = df["movie_type"].value_counts() keep_types = type_counts[type_counts >= 5].index.tolist() # 出现少于5次的合并 df["movie_type_grouped"] = df["movie_type"].apply( lambda x: x if x in keep_types else "其他" )经验法则是:特征维度不要超过样本量的五分之一。样本量200条,特征控制在40个以内比较安全,超过这个阈值就优先做特征合并而不是加更多特征。
6. 模型验证与进阶技巧:用一个残差直方图判断你的SVR能不能上线
训练完模型先别急着看R2,画一张残差直方图是快速判断模型健康度的方法。残差为y_test - y_pred,如果残差集中在0附近且大致呈正态分布,说明模型没有系统性偏差;如果残差整体偏向正数,说明模型系统性低估票房——这在票房预测里非常常见,因为训练集里混入了大量受宣发费用影响的“黑马”影片,模型对这些样本的误差全部体现在正残差上。
import matplotlib.pyplot as plt import seaborn as sns # 计算残差(在log空间里计算更合理) residuals = y_test.values - y_pred_grid plt.figure(figsize=(10, 4)) plt.subplot(1, 2, 1) sns.histplot(residuals, bins=20, kde=True) plt.axvline(x=0, color="red", linestyle="--") plt.title("残差分布 (log空间)") # 残差 vs 预测值的散点图 plt.subplot(1, 2, 2) plt.scatter(y_pred_grid, residuals, alpha=0.6) plt.axhline(y=0, color="red", linestyle="--") plt.xlabel("预测值 (log)") plt.ylabel("残差 (log)") plt.title("残差-预测值散点图") plt.tight_layout() plt.show()如果散点图呈现喇叭形——预测值越大误差越大,说明log变换没有完全消除异方差性。这时候不要急着换模型,先检查是否有一些影片的release_days特征在训练时是0(上映首日),但测试时点变了导致预测不稳定。另一个常见问题是特征里包含的show_count(排片场次)本身就和票房高度共线,这在预测未来总票房时是个伪特征——排片场次本身就是票房结果的一部分,而不是前置原因。
说一个我自己的习惯:每轮调参都在同一个测试集上评估,测试集绝不参与任何特征筛选或参数搜索。听起来是常识,但实际操作里很容易犯“用测试集调参”的错——网格搜索跑完看到测试集R2不理想,于是手动改参数再跑一次测试,等于把测试集变成了训练集。我现在的做法是:把所有数据切成训练集、验证集、测试集三份,网格搜索和特征筛选只在训练+验证上进行,测试集留到最终验证一次。样本量小的时候可以不做独立验证集,但必须给自己定个规矩:测试集最多跑三次,超过就说明特征或参数根本不对,不是调参的问题。
最后提一个可以短期上手的进阶方向:把时间相关的特征(比如映前想看人数、宣发物料曝光量)纳入模型,SVR的表现会有明显提升,特征分析时也能看出“想看人数”和票房的幂律关系。值得投入,但前提是把前面这套数据管道跑通,别一上来就贪多。希望帮到你。
本文还有配套的精品资源,点击获取