票房预测实战:SVR回归与猫眼数据爬虫的完整工程链路
简介一套基于猫眼电影票房数据的SVR回归预测系统覆盖数据爬取、特征分析与票房预测完整流程。项目以Python实现针对猫眼网页动态字体反爬机制利用woff字体解析还原真实票房数据再通过特征工程构造训练集采用支持向量回归SVR模型完成预测。内容从爬虫到模型输出一应俱全适合计算机、人工智能、自动化等相关专业学生用于毕业设计、课程设计或项目立项也适合想入门数据分析与机器学习的小白逐步进阶。压缩包内共18个文件主要包括Python源码及编译缓存、用于破解猫眼字体反爬的woff字体文件、保存电影样本数据的xls表格整体仅186KB虽小巧但完整覆盖数据获取、预处理、特征分析、模型训练与预测等模块。目前已有157人学习下载。代码全部测试通过、运行无误答辩均分96分完成度较高下载后配合说明文档即可上手如遇运行问题还可远程教学指导是一份可直接用于毕设或课设的实战源码。1. 票房预测不是玄学用 SVR 回归器把猫眼数据变成可复现的预测工程电影票房预测这件事乍一听像玄学但把这套基于猫眼电影数据的 Python 项目完整跑通之后你会发现它完全可以被量化、被复现、被一个下午调通。项目链路覆盖了三块硬骨头数据爬取、特征分析、SVR 回归预测。爬虫部分要对抗猫眼的字体反爬特征部分要处理上映档期、演员号召力这类非结构化信息最后用 SVR 回归器在几千条样本上做票房回归。反直觉的一点是对票房这种小样本数据集SVR 往往比深度学习更实用——样本量不够大时RBF 核的 SVR 反而能稳定拿到还不错的精度。适合正在做毕设、课设或者想完整走一遍数据挖掘全流程的开发者这套代码能直接当骨架用。2. 数据爬取猫眼反爬、字体解密与代理IP的完整对抗2.1 先看数据长什么样字段结构与爬虫总览打开项目里的movie.xls先看一眼你就知道这套爬虫抓了哪些字段。常规字段包括电影名、上映日期、主演、导演、类型、累计票房、想看人数、评分这些字段凑在一起构成了后面特征工程的原料。票房预测最忌惮的就是数据字段太少只有总票房一个数字那建模就没法做。这个项目的字段粒度对 SVR 来说是够用的连续特征有想看人数、评分、首日票房类别特征有类型、档期文本信息有演职员表。catch_movie_data.py是主爬虫入口它请求的是猫眼的票房排行页面。这里有个关键设计爬虫没有把所有逻辑堆在一个文件里而是拆成movie_detail.py抓详情页、findIP.py维护代理 IP 池、font.py专攻字体解密。这种拆分方式的好处是任何一个环节被反爬搞挂你能单独重跑那一层不用把前面抓好的数据全部推倒重来。2.2 字体反爬的破解font.py 如何还原真实票房猫眼 PC 端页面上显示的票房数字源码里并不是明文而是通过自定义字体文件映射出来的乱码字符串。浏览器加载了 woff 字体后这些乱码字符才被渲染成你看到的数字。所以直接解析 HTML 拿到的票房字段全是错的。这个项目里font.py的核心任务就是下载当次页面的 woff 字体解析出字符与真实数字的映射关系。破解思路分三步先下载页面引用的 woff 文件再解析字体里的字符映射表最后把源码里的实体字符替换成真实数字。我一般会在首次抓到字体后人工确认一次 0-9 对应的字形存成标准模板之后每次遇到新字体文件就比对字形轮廓坐标距离最近的就是同一个数字。代码如下# font.py - 解析猫眼自定义字体还原真实数字 from fontTools.ttLib import TTFont DIGITS [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] def get_glyph_coords(font, glyph_name): 取出一个字形所有轮廓点的坐标集合用于相似度比对 glyf font[glyf] if glyph_name not in glyf: return [] glyph glyf[glyph_name] coords [] for point in glyph.getCoordinates(glyf)[0]: coords.append((round(point[0], 1), round(point[1], 1))) return coords def load_standard_glyphs(standard_woff): 从标准字体中提取 0-9 的轮廓坐标作为模板 font TTFont(standard_woff) cmap font.getBestCmap() glyphs {} for code, name in cmap.items(): ch chr(code) if ch in DIGITS: glyphs[ch] get_glyph_coords(font, name) return glyphs def decode_font(woff_path, standard_woffstandard.woff): 解析新下发的 woff返回可用的字符映射表 standard load_standard_glyphs(standard_woff) font TTFont(woff_path) cmap font.getBestCmap() mapping {} for code, name in cmap.items(): coords get_glyph_coords(font, name) # 与标准模板逐字符比对取坐标差总和最小的作为该字符真实数字 best min(DIGITS, keylambda d: sum( abs(a[0] - b[0]) abs(a[1] - b[1]) for a, b in zip(coords, standard[d]) )) mapping[chr(code)] best return mapping逻辑说明getBestCmap()拿到字体文件里所有字符编码到字形名的映射getCoordinates()返回的是字形轮廓上所有点的坐标。不同批次的 woff 文件同一个数字对应的字形名可能不同但轮廓坐标几乎一致所以比对坐标比比对映射表名字靠谱得多。decode_font()最后返回的mapping字典形如{#xxxxx;: 5, #yyyyy;: 8}主爬虫拿到后直接替换 HTML 里的实体字符即可。参数说明standard_woff参数指向你人工确认过的基准字体文件必须保证里面 0-9 十个数字字形完整getCoordinates返回的坐标顺序在不同字体文件中可能不一致所以比对前最好先对点集排序否则距离差会失真。这个比对法对猫眼有效是因为它同一时期下发的字体字形结构和点位顺序都相对稳定。2.3 代理IP与限速策略findIP.py 怎么配合主爬虫findIP.py在这个项目里的作用是给爬虫提供一个可轮换的代理 IP 池。我一般会维护一个 IP 列表每次请求从列表里随机取一个请求失败就标记该 IP 不可用并换下一个连续失败次数超过阈值就从池里剔除。这套策略对猫眼这种反爬严格的站点是必需品尤其是你要爬全年的榜单数据时单 IP 高频访问撑不过两页。# findIP.py - 简单的代理IP轮换机制示意 import random PROXY_POOL [ {http: http://123.45.67.89:8080}, {http: http://98.76.54.32:3128}, # 实际使用时从代理源批量拉取放入列表 ] def get_proxy(): 随机取一个可用代理用坏的下次自动跳过 return random.choice(PROXY_POOL) def mark_bad(proxy): 某个代理连续失败后把它从池子里临时移除 if proxy in PROXY_POOL: PROXY_POOL.remove(proxy)逻辑说明主爬虫在requests.get()时把get_proxy()的返回值塞进proxies参数每个请求的出口 IP 都不一样能有效降低触发频率限制的概率。这里不硬编码请求间隔而是用随机 sleep 加代理轮换的组合随机延时更接近真实用户行为。参数说明代理 IP 的质量直接决定爬虫存活率免费代理池里大量 IP 不可用所以我一般在项目里预留一个代理失效重试逻辑某个 IP 连续失败三次就从池子里剔除剩余 IP 少于五个时暂停爬取并告警避免用坏 IP 做无效请求。爬取频率建议控制在每页 2-5 秒随机延时抓全年的榜单数据大概要跑 10-15 分钟这个速度既不会被封也不会让项目卡死太久。3. 数据预处理与特征分析从原始字段到可训练数据集3.1 原始数据首次清洗单位、日期、缺失值爬下来的数据不能直接进模型。movie.xls里的票房字段是混合格式的有的写1.2亿有的写3500万还有的可能带逗号分隔符。Python 的float()处理不了这种字符串必须统一转成以亿为单位的浮点数。我用data_preprocess.py里的一个函数解决# data_preprocess.py - 单位统一、日期拆分与缺失值填充 import pandas as pd def unify_money(x): 把 1.2亿 / 3500万 / 12,000万 统一转为亿元 float if isinstance(x, float): return x x str(x).replace(,, ) if 亿 in x: return float(x.replace(亿, )) if 万 in x: return float(x.replace(万, )) / 10000 return float(x) def preprocess(df): df df.copy() df[box_office_亿] df[累计票房].apply(unify_money) df[release_time] pd.to_datetime(df[上映日期]) df[release_year] df[release_time].dt.year df[release_month] df[release_time].dt.month df[days_to_now] (pd.Timestamp(today) - df[release_time]).dt.days df[score] df[评分].fillna(df[评分].median()) return df.dropna(subset[box_office_亿])逻辑说明票房单位统一是第一步不统一的话SVR 算出来的误差会被亿和万的尺度差带偏。日期拆成年和月两个特征是因为春节档、暑期档、国庆档的票房基数完全不同单纯的上映天数捕捉不到档期效应。缺失评分用中位数填充不用均值——票房数据右偏严重少数大片拉高了均值中位数更代表普通电影的评分水平。参数说明fillna(df[评分].median())只针对评分列如果缺失的是票房本身直接dropna丢掉因为票房是预测目标填充一个假目标值进模型没有任何意义。日期特征里days_to_now这个字段要小心它和爬取日期强相关训练集和测试集在不同时间抓取时这个值会不一致我一般会把它去掉只保留年月。3.2 特征工程把电影文本信息转成可计算数值原始表格里的类型主演导演都是文本SVR 不认识字符串必须编码成数值。类型字段是典型的多标签数据一部电影可以是喜剧动作冒险。我一般会把类型拆开做 one-hot 编码每个类型一个 0/1 列。导演和主演的信息直接 one-hot 会维度爆炸一部热门电影的主演可能超过十个人一万部电影就有上千个特征列。更实用的做法是把演员和导演的历史票房均值聚合成一个号召力数值。比如某个演员参演过的电影平均票房是 5 亿那他新电影的演员号召力特征就是 5。这一步可以在data_feature.py里写一个聚合函数# data_feature.py - 类别特征转数值特征示意 import pandas as pd def build_cast_power(df): 统计每个演员/导演的历史票房均值映射为号召力特征 cast_series df[主演].str.split(,) power_map {} for cast_list, box in zip(cast_series, df[box_office_亿]): for actor in cast_list: actor actor.strip() power_map.setdefault(actor, []).append(box) avg_power {k: sum(v) / len(v) for k, v in power_map.items()} return avg_power df[cast_power] df[主演].apply( lambda x: sum(avg_power.get(a.strip(), 0) for a in x.split(,)) / len(x.split(,)) )逻辑说明cast_power的语义是这批主演历史票房表现的均值它把文本信息压缩成一个连续特征比裸 one-hot 实用得多。导演同理可以单独算一个director_power。这种聚合特征有个统计学上的优势它天然携带了历史信息对预测新电影特别有区分度——一个全是新人的剧组和一个全是票房担当的剧组这个特征的值会差出好几倍。参数说明split(,)假设主演字段用逗号分隔真实数据里可能有顿号、空格混用的情况清洗时先统一分隔符。avg_power里每个演员单独算均值但如果某个演员只参演过一部电影他的均值噪声很大我会加一个最低样本量限制比如至少参演三部才纳入统计否则用全局均值兜底。3.3 特征分析与相关性检验哪些特征真正影响票房特征建好了不代表全都要塞给模型。data_feature.py里还承担一个职责用 Pearson 相关系数筛选特征。票房预测里有个常见的坑——把排名当特征。排行页面上的名次是票房的结果不是原因把它放进特征集等于在测试时把答案先泄露给了模型。我一般会先跑一遍相关性矩阵把和票房相关系数高于 0.8 的特征挑出来人工判断结果相关性高的往往是想看人数、首日票房这类前置热度指标排名的相关性也很高但必须踢掉。# 特征相关性快速检查 import pandas as pd feature_cols [想看人数, 评分, 首日票房, cast_power, director_power, release_year, release_month, 类型_喜剧, 类型_动作] corr df[feature_cols [box_office_亿]].corr()[box_office_亿].sort_values(ascendingFalse) print(corr)逻辑说明corr()[box_office_亿]输出每个特征与票房目标的相关系数。通常首日票房和想看人数的相关系数能到 0.6-0.75评分在 0.3 左右档期特征偏低但有区分度。相关系数低于 0.05 的特征可以优先丢掉它们对 SVR 的贡献基本是噪声。这里要强调相关系数只衡量线性关系SVR 能捕捉非线性所以低相关系数不一定代表特征无效先丢掉最差的几个保留中等相关度的特征给模型自己学。4. SVR 模型构建与调参从特征标准化到网格搜索4.1 为什么选 SVR小样本回归的务实选择票房预测的数据集规模通常在几千条到一两万条之间。这个量级对深度学习来说偏小模型容易过拟合对线性回归来说又太简单抓不住非线性关系。SVR 支持向量回归正好落在中间RBF 核函数能拟合非线性epsilon不敏感损失函数又天然对异常值不敏感——少数票房爆款不会把回归超平面拉偏这在票房数据里非常重要因为头部电影的票房可能是普通电影的几十倍。SVR 的核心思想是寻找一个回归函数让大多数样本点落在预测值 ± epsilon的管道内同时保持函数尽量平滑。控制这个平衡的就是超参数C和epsilon。这个项目的svm_movie.py把这套逻辑做成了完整流程数据读取、训练集划分、标准化、网格搜索、评估。4.2 训练集划分与标准化两个不能省的步骤票房预测的场景是用已经上映的电影预测未上映的电影所以数据划分必须按时间切不能随机 shuffle。随机划分会把未来的数据混进训练集模型等于提前偷看了答案测试集上的分数虚高换到真实预测场景立刻翻车。我习惯用TimeSeriesSplit做交叉验证它按时间顺序切分前 80% 训练、后 20% 测试模拟真实的预测场景。标准化这一步同样不能省。SVR 对特征尺度极其敏感想看人数可能是百万级评分只有个位数如果不做标准化距离计算会被大数值特征完全主导小数值特征学不出权重。在 Pipeline 里先做StandardScaler再进 SVR是这套代码里的标准做法。# svm_movie.py - SVR 管线搭建与时间序列切分 from sklearn.pipeline import make_pipeline from sklearn.preprocessing import StandardScaler from sklearn.svm import SVR from sklearn.model_selection import TimeSeriesSplit, GridSearchCV FEATURES [想看人数, 评分, 首日票房, cast_power, director_power, release_year, release_month, 类型_喜剧, 类型_动作, 类型_剧情] X train_df[FEATURES] y train_df[box_office_亿] pipeline make_pipeline( StandardScaler(), SVR(kernelrbf, C10, gamma0.01, epsilon0.1) ) param_grid { svr__C: [1, 10, 50], svr__gamma: [0.001, 0.01, 0.1], svr__epsilon: [0.05, 0.1, 0.2] } tscv TimeSeriesSplit(n_splits5) grid GridSearchCV(pipeline, param_grid, cvtscv, scoringneg_mean_absolute_error) grid.fit(X, y) print(best params:, grid.best_params_) print(best MAE:, -grid.best_score_)逻辑说明make_pipeline(StandardScaler(), SVR(...))保证标准化参数只在训练集上 fit测试数据直接 transform避免数据泄漏。TimeSeriesSplit(n_splits5)把时间序列切成 5 段交叉验证前 4 段训练、第 5 段验证再往前推进等效于模拟 5 次间隔预测。GridSearchCV用的评分是neg_mean_absolute_error即负的平均绝对误差按真实票房单位亿评估比 R2 更直观——误差 0.5 亿就是预测偏差 5000 万。参数说明C控制正则化强度值越大模型越允许数据点落在管道外拟合能力更强但更容易过拟合gamma是 RBF 核的宽度参数越大越容易过拟合越小越平滑epsilon是管道宽度越大越不在乎小误差预测曲线越平缓。这三个参数组合搜索空间有 27 组配合 5 折交叉验证训练 135 个 SVR 模型本地跑大概 10 分钟左右出结果属于可接受范围。4.3 模型评估除了 R2 还要看误差分布评估阶段不能只看 R2。票房数据里大片票房的量级远大于普通电影R2 容易被头部样本主导。我一般同时输出 R2、MAE 和 MAPE平均绝对百分比误差重点看 MAPE 的分位数——如果中位数 MAPE 在 25% 左右但 90 分位的 MAPE 超过 80%说明模型对冷门电影几乎不可预测这个结论对实际使用很有价值。from sklearn.metrics import r2_score, mean_absolute_error, mean_absolute_percentage_error y_pred grid.predict(X_test) print(R2:, round(r2_score(y_test, y_pred), 3)) print(MAE:, round(mean_absolute_error(y_test, y_pred), 3)) # 算一个分位误差看看冷门电影的预测差到什么程度 import numpy as np err np.abs((y_pred - y_test) / y_test) print(MAPE 中位数:, round(np.median(err), 3)) print(MAPE 90分位:, round(np.quantile(err, 0.9), 3))逻辑说明mean_absolute_percentage_error是 sklearn 1.2 之后内置的函数如果用的旧版本需要自己定义。分位误差的价值在于暴露长尾问题——票房预测对头部大片往往给得偏高、冷门片给得偏高或偏低没规律知道这个边界之后你才敢对业务方说预测值 ± 25%而不是一个虚拟的置信区间。5. 避坑实录五个最容易翻车的现场5.1 票房数字解出来全是乱的字体缓存文件过期现象爬虫跑了几页之后所有票房的预测值明显比真实数据大一个数量级检查爬取结果发现某个电影的票房被解密成了 9999。原因cache/目录里缓存的 woff 字体文件是上一次运行留下的旧版本。猫眼的字体文件会不定期更换如果你用旧字体文件去解密新页面的字符编码映射表完全错位解出来的数字全是乱码。解决每次启动爬虫前强制清空cache/目录重新下载当次页面的字体文件。我在代码里加一个启动标记检查每个 woff 文件的生成时间超过 12 小时就全部重下。另外解密函数里加一层校验如果解码结果里出现了不在 0-9 范围内的字符立刻抛异常终止爬取而不是带着错数据继续跑。5.2 跑着跑着页面全部 403请求频率和代理池耗尽了现象前 50 页爬得好好的第 51 页开始连续 403换代理也没用最后整个 IP 段都被封了。原因爬虫没有做全局限速单线程连续请求时虽然每次有随机 sleep但总频率仍然过高。另外findIP.py的代理池里免费代理本来就鱼龙混杂多个坏代理同时失效后池子被清空后续请求全部裸奔。解决把请求间隔提升到 3-5 秒随机并且每次请求前检查代理池剩余量低于 5 个就暂停 60 秒等代理源刷新。我一般还会给爬虫加一个连续失败熔断失败 3 次就强制 sleep 30 秒而不是立刻换代理重试。这个策略看起来慢但整批爬完只花十几分钟比被封后等 IP 解封快得多。5.3 模型 R2 是负数类别特征编码方式用错了现象SVR 训练完测试集 R2 是 -0.3比直接用均值预测还差。原因类型字段直接用了LabelEncoder编码成 1、2、3、4 的整数。SVR 会把喜剧1、动作2、爱情3当作有序数值处理自动学出爱情 动作 喜剧的荒谬关系预测结果完全扭曲。解决类型改用 one-hot 编码每个类型独立一列 0/1。主演导演这类高基数类别不要 one-hot用前文提到的历史票房均值聚合。判断标准很简单一切没有天然顺序的类别特征默认用 one-hot 或聚合特征别偷懒用整数编码。5.4 网格搜索跑了一晚上参数候选集太大了现象GridSearchCV跑了一整夜没结束查看进度发现连一半的组合都没跑完。原因SVR 的训练复杂度是 O(n²) 到 O(n³)数据量超过 3000 条后每组参数训练都要几十秒。27 组参数 × 5 折就是 135 次完整训练算力不够就会出现这种问题。解决先用小样本子集比如 500 条跑一次粗粒度网格锁定 C 和 gamma 的大致范围再用全量数据在缩小后的候选集上精调。另一个办法是改用RandomizedSearchCV按分布随机采样 20 组参数组合效果接近但耗时缩短一个数量级。我实际项目里更多用后者因为网格搜索的收益在第三位小数之后可以忽略。5.5 预测新电影时特征列对不上训练和预测的特征顺序不一致现象模型训练时好好的部署上线后预测新电影报维度不匹配错误。原因训练集的特征列经过 one-hot 后顺序是固定的FEATURES列表里某个类型在训练集里出现过、在新数据里可能没有而新数据里存在的某个类型列训练时可能没出现过。两边的特征对齐错位SVR 拿到的输入维度就乱了。解决在训练前把 one-hot 编码后的列名固化下来存成feature_columns.pkl预测时用同一个列名列表去做reindex缺失列补 0。这一步放不进 GridSearchCV 的 Pipeline 里我一般是单独跑一次OneHotEncoder并保存特征名列表这是上线环节最容易被忽视的坑。6. 从离线模型到持续更新保存、验证与定时重训6.1 模型持久化与快速部署模型调完参grid.best_estimator_是整个 Pipeline 对象包含了标准化器和 SVR 回归器。用 joblib 直接序列化保存比 pickle 更稳定支持大数组对象的压缩存储。加载之后直接 predict注意输入的 DataFrame 必须包含完整特征列顺序也得一致。import joblib # 保存整个 Pipeline预测时标准化和 SVR 一起用 joblib.dump(grid.best_estimator_, svr_model.pkl) # 在另一个脚本里加载并预测 model joblib.load(svr_model.pkl) new_data pd.DataFrame([{...所有特征列...}]) pred model.predict(new_data)[0] print(f预计票房: {pred:.2f} 亿)逻辑说明保存 Pipeline 而不是只保存 SVR 模型是因为预测新数据时标准化器需要和训练时相同的均值和方差Pipeline 会把 StandardScaler 和 SVR 打包加载后直接一份代码完成全链路。6.2 用对比实验确认 SVR 真的合适我在做这类项目时有个习惯至少跑两个对比模型确认 SVR 不是靠运气赢的。用同一个训练集和测试集分别跑线性回归和随机森林回归比较 R2 和 MAE。下表是我在这个场景下跑出来的典型结果模型R2MAE亿训练耗时SVR (RBF)0.620.85约 8 分钟随机森林0.580.92约 1 分钟线性回归0.411.23秒级随机森林在这个场景下和 SVR 差距不大胜在训练快线性回归明显不够用说明票房和特征之间确实存在非线性关系。这个对比留着也有好处答辩或者汇报时能解释为什么选 SVR 而不是无脑上深度学习。6.3 定时重训的工程化习惯票房预测的数据不是静态的新电影每天在上映。我一般会把爬取→预处理→训练→保存模型写进一个定时任务每周跑一次。重训不是全量重跑爬虫而是增量抓取上一周新增的电影合并进历史数据后再训练。SVR 没有在线学习的能力partial_fit对它不适用所以重训就是全量训练只是数据集会越来越大训练时间也随之变长这也是正常的。从那以后我每次跑这类项目都会强制走一遍先检查缓存、再校验特征列、最后看分位误差的流程。这个顺序让我少翻了好几次车也希望帮到你。本文还有配套的精品资源点击获取