拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

机器学习票房预测平台实战:从数据清洗到Flask部署

机器学习票房预测平台实战:从数据清洗到Flask部署

简介:这是一个面向机器学习初学者与研究者的电影票房预测完整项目包,整合历史票房、影片信息、市场趋势与观众评价等多类数据源,经过数据清洗、特征工程与监督学习建模,完成票房回归预测与结果可视化,可服务于课程设计、毕业设计与竞赛参考。压缩包共六十二个文件,以十六个Python脚本、十六个CSV数据集、二十三张PNG图表为主,另含Markdown笔记与PDF说明文档,整体约三十一点二八MB。其中TMDB电影与演职员数据可直接用于训练,代码涵盖基础版、组合版与个性化推荐等多种模型,并配有EDA特征可视化图表与数据分析报告,方便对照复现预测区间与置信度,为预算分配、宣传策略提供数据参考。目前已有三百一十一人学习下载,适合想系统掌握票房回归预测、推荐系统与数据挖掘全流程的读者。

1. 一个数据科学课的作业,凭什么变成能照做的票房预测平台

拿到“基于机器学习的电影票房预测平台源码+数据集+文档说明.zip”这个压缩包,很多人的第一反应是解压、跑通、截图、交作业。但我的建议是把它当做一个完整的工程样本来读,而不是临时抱佛脚的素材。它不是一个工业级票房系统,而是一个把数据清洗、特征工程、模型训练、Web部署串起来的最小闭环。这个zip的价值在于:你不必先去爬两份数据、不必纠结模型选型、不必从零搭Flask,只要按照文档的顺序执行,就能看到一个用户输入电影信息、返回票房预测值的界面。适合正在做机器学习课程设计的学生、想转行数据科学但没有实战项目的朋友,以及想快速验证“预算、演员、档期到底怎样影响票房”的从业者。接下来我按解压后的实际使用顺序,把这个平台的内部逻辑和坑位拆给你看。

2. 拆开zip包:源码、数据集与文档各自承担什么角色

一个以“源码+数据集+文档说明”打包的机器学习项目,看起来复杂,其实就三个角色:数据是原料,源码是加工线,文档是操作手册。如果你连这三者的关系都没理清就急着开跑,后面大概率会在某一环翻车。

2.1 目录结构:一个能跑的机器学习项目该有的三个文件夹

解压后你大概率看到类似下面的结构,这是最常见的模板:

MovieBoxOfficePrediction/ ├── data/ │ ├── raw_movie_data.csv │ └── processed_movie_data.csv ├── model/ │ ├── train_model.py │ └── saved_model.pkl ├── web/ │ ├── app.py │ └── templates/ │ └── index.html ├── docs/ │ └── 项目说明文档.md └── requirements.txt

这个结构的逻辑很清晰:data 目录只放数据,原始的和清洗后的分开;model 目录放训练脚本和已经保存的模型文件;web 目录放Flask服务;docs 目录放说明文档。你不用记住每个文件名,但一定要明白三个角色之间的关系:数据是原料,模型是引擎,Web平台是包装。

我在拿到这类项目时,第一步不是运行,而是打开 requirements.txt 看依赖版本。常见依赖是 pandas、numpy、scikit-learn,如果用了XGBoost,文件里也会写。注意版本号写得太死(比如 pandas==1.3.5)反而容易出问题,我一般会把约束放宽到pandas>=1.3.5,<2.0。如果你的机器没装环境,建议用 Anaconda 建一个独立环境:

conda create -n boxoffice python=3.9 conda activate boxoffice pip install -r requirements.txt

为什么要单独建环境?因为这个zip的依赖可能和你的日常项目冲突,尤其是 scikit-learn 版本,它牵涉到模型持久化文件能否跨版本加载。python=3.9是比较折中的选择,大部分机器学习库的现代版本都支持。如果你用的 Python 是 3.11,遇到旧项目报OpenMP错误,不要慌,降回 3.9 或者更新 scikit-learn 都能解决。

另外,解压 zip 时注意 Windows 的路径长度限制。很多课程压缩包放在桌面深层目录里,解压后可能出现FileNotFoundError或OSError。我一般先把 zip 拷贝到纯英文路径下(比如D:\workspace\)再解压,如果文件名里有空格或中文,尽量改成下划线。这不是玄学,是 Python 在读文件时对非 ASCII 路径的兼容性问题。

2.2 票房数据集长什么样:字段、类型和缺失值陷阱

训练数据通常是CSV格式,每行是一部电影,每列是一个特征。常见的字段有:电影名称(不可用于训练)、预算、演员知名度、导演、类型、制片公司、上映日期、时长、口碑评分、是否续集等。目标列就是该电影的全球或本地票房。

字段名示例值类型说明
budget20000000float制片预算(美元)
popularity8.7float热度指数
cast_score5.6float主演平均票房合成值
director_nameNolanobject导演
genresDrama,Thrillerobject类型,逗号分隔

注意genres这类多值分类字段,千万不要直接做 LabelEncoder,因为Drama,Thriller和Thriller,Drama会被当成两个类别。常见做法是 One-Hot 或 MultiLabelBinarizer。

先别急着训练,先做一次数据体检:

import pandas as pd import numpy as np df = pd.read_csv('data/raw_movie_data.csv', parse_dates=['release_date']) print("shape:", df.shape) print(df.dtypes) print("missing:\n", df.isnull().sum()) print("describe:\n", df.describe(include='all'))

parse_dates参数把日期列解析成 datetime 类型,方便后续取年份、月份。dtypes检查每列的类型,如果 budget 显示 object,说明里面有逗号或货币符号。isnull().sum()看缺失点。接下来处理缺失值:

# 数值列用中位数填充,类别列用众数填充 num_cols = df.select_dtypes(include=np.number).columns.tolist() cat_cols = df.select_dtypes(include='object').columns.tolist() df[num_cols] = df[num_cols].fillna(df[num_cols].median()) df[cat_cols] = df[cat_cols].fillna(df[cat_cols].mode().iloc[0])

这里用中位数而不是均值,是为了抵抗异常值。如果预算有极端值,均值会把缺失值带偏,中位数更稳健。类别列填充众数是最省事的选择。但要注意,如果目标列有缺失,千万别填充——训练样本的标签缺失只能删除,或者用其他模型预测,不能简单填众数,否则模型会学到一个“平均答案”。

2.3 文档说明里的东西:除了读API,还要看什么

文档说明通常有“环境安装”“运行步骤”“项目结构”“实验结果”这几节。我读文档不是从前往后读,而是先看实验结果里有没有给出指标。如果有,记下来,等你自己跑完对比一下。如果文档里给出了 Web 界面的截图,那就按截图里的输入条件去测试 API。比如截图里输入budget=30000000, genres='Action',预测结果是 1.2 亿,你在本地也要拿到同样的数字,这叫“对关键结果做复现签核”。

文档里最容易忽略的是“注意事项”一栏。有的文档没写,但源码注释里有TODO,这些位置往往藏着原作者的坑。我用编辑器搜索TODO、fixme、临时,把这些注释全揪出来看。很多数据集和源码不一致的地方,都在这里留下过痕迹。

还有一个点:文档最后一般会列出项目可能存在的问题,比如“模型对续集和动画片的预测误差较大”。这部分是原作者的自我评估,也就是你最应该花功夫优化的方向。不要认为文档写得越全项目越好,有时候文档写得简略,但源码结构清晰,反而更值得学习。

另外,docs目录里可能还有一个数据字典.xlsx或建模过程.pdf。数据字典是宝,它把每一列的含义、单位、取值来源写明白了。我建议你把它单独放到项目根目录,以便在特征工程时查列名。如果你拿到手的文档只有几十行,也不用失望,你可以自己补一份文档,把你跑通的命令、修改过的参数、结果写进去,这本身就是给简历增加亮点。

3. 把票房预测做成一个回归问题:特征工程与模型选择

票房预测的本质是回归:输入关于电影的可获得特征,输出一个连续票房值。但直接把这个数值丢给模型,你会死得很惨。原因不在模型,而在数据分布。

3.1 为什么不能把票房直接喂给模型:从长尾分布到对数变换

如果直接对票房做回归,你会发现线性模型的误差被几个大作严重扭曲。票房分布是典型的幂律分布:少数电影拿走了大部分收入。你用 RMSE 评估,模型的求解器会把精力花在拟合那几个上亿的样本上,导致中低成本电影的系统性高估。

先看分布,再决定变换:

import matplotlib.pyplot as plt df['revenue'].hist(bins=50) plt.show()

如果直方图明显右偏(尾巴拉得很长),就做 log1p 变换。np.log1p(x)等价于np.log(x+1),好处是对 0 值也有定义。然后把这个变换后的列作为回归目标:

from sklearn.model_selection import train_test_split import numpy as np df['log_revenue'] = np.log1p(df['revenue']) features = ['budget', 'popularity', 'runtime'] X = df[features] y = df['log_revenue'] X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

test_size=0.2是常见切分,random_state=42固定随机种子,确保复现。这里我把runtime也拉了进来,注意初版特征里类别列先别加,先把数值列跑通。

为什么用log1p而不是np.log?因为样本中可能有票房为 0 的未上映或极低票房电影,log(0)是负无穷,模型直接崩溃。log1p让 0 变成 0,负值不会出现。预测完还原票房时,用np.expm1(pred)取指数减 1。

3.2 类别特征编码:one-hot 还是 mean encoding

电影类型、导演、制片公司这些类别特征,如果直接 LabelEncoder 成 0,1,2...,模型会误以为这些数字有大小关系。对有序类别可以这么做,但类型之间是无序的,所以初版我用 OneHotEncoder:

from sklearn.preprocessing import OneHotEncoder ohe = OneHotEncoder(handle_unknown='ignore') genres_encoded = ohe.fit_transform(df[['genres']]) print(ohe.categories_)

handle_unknown='ignore'非常重要:如果测试集里出现了训练集没见过的类型(比如训练集没包含“纪录片”,预测时来了个纪录片),默认的 one-hot 会报错,而 ignore 会把未知类别全映射为全 0 向量。代价是模型会把它当作“其他”处理,但至少不会崩溃。

不过 one-hot 有个麻烦:genres是多值字段,OneHotEncoder 会把Drama和Drama,Thriller当作两个单独类别,而不是拆出两个标签。这时需要MultiLabelBinarizer:

from sklearn.preprocessing import MultiLabelBinarizer df['genres_list'] = df['genres'].str.split(',') mlb = MultiLabelBinarizer() genre_matrix = mlb.fit_transform(df['genres_list']) genre_df = pd.DataFrame(genre_matrix, columns=mlb.classes_)

这段代码先把字符串Drama,Thriller拆成列表['Drama','Thriller'],再用 MultiLabelBinarizer 将其展开为Drama和Thriller两列。这样特征维度会变大,但如果类型数量在上百个以内,完全值得。注意split(',')如果原始数据是中文逗号,,需要先替换成英文逗号,不然拆不开。

3.3 基准模型与调参:从线性回归到树模型

先训练一个线性回归作为基线。基线有两个作用:一是验证特征管线没有漏洞,二是提供一个最低标准,后续模型如果连基线都打不过,就是特征工程出了问题。

from sklearn.linear_model import LinearRegression from sklearn.metrics import mean_squared_error import numpy as np model_lr = LinearRegression() model_lr.fit(X_train, y_train) pred_lr = model_lr.predict(X_test) print("LR RMSE:", np.sqrt(mean_squared_error(y_test, pred_lr)))

线性回归的系数可以直接看每个特征的权重,但前提是特征之间没有多重共线性。如果你的特征里同时有 budget 和 budget_sqrt,模型会不稳定。我这里没做StandardScaler,特征权重不能直接对比大小,只能看正负号。

接下来上随机森林:

from sklearn.ensemble import RandomForestRegressor model_rf = RandomForestRegressor( n_estimators=300, max_depth=10, min_samples_leaf=5, n_jobs=-1, random_state=42 ) model_rf.fit(X_train, y_train) pred_rf = model_rf.predict(X_test) print("RF RMSE:", np.sqrt(mean_squared_error(y_test, pred_rf)))

n_estimators=300是树的棵数,越多越稳定,但超过 500 收益递减;max_depth=10限制单棵树深度,防止过拟合;min_samples_leaf=5让叶子节点至少含 5 个样本,这也是缓解过拟合的常规操作。n_jobs=-1告诉 sklearn 用全部 CPU 核心,在数据量几千条时训练速度提升明显。

如果你有 XGBoost,也可以加上:

from xgboost import XGBRegressor model_xgb = XGBRegressor( n_estimators=500, learning_rate=0.05, max_depth=6, subsample=0.8, colsample_bytree=0.8, random_state=42 ) model_xgb.fit(X_train, y_train)

learning_rate=0.05是每一步的学习步长,调小会更稳但需要更多树;subsample=0.8表示每棵树只用 80% 样本训练,colsample_bytree=0.8是每棵树只用 80% 特征,这两个参数都能增加随机性、防止过拟合。XGBoost 对未缩放特征并不敏感,但如果你用线性模型,StandardScaler 必加。

最后比较三个模型的 RMSE,选择最优的。注意,如果 y 是 log 变换后的,RMSE 也在 log 空间里,不能直接看作“票房误差多少美元”。要先 expm1 还原,再用原始票房计算 RMSE,否则会得到“模型误差 0.3 亿”这种误导性数字。

4. 把模型变成“平台”:用 Flask 包一层 Web 服务

训练只是手段,能给别人用才是平台。一个合格的项目,至少要把模型保存成文件,然后提供 HTTP 接口,让用户可以输入参数、拿到预测值。

4.1 先保存模型:joblib 和 pickle 的选择

训练好模型后,你需要把模型文件、特征转换器(比如 Scaler 和 OneHotEncoder)一起保存。常见错误是只保存模型,忘记保存特征列名和编码器,导致部署时特征顺序不对。

import joblib joblib.dump(model_rf, 'model/saved_model.pkl') joblib.dump(genre_df.columns.tolist(), 'model/feature_columns.pkl')

joblib.dump对 numpy 数组和 sklearn 模型支持更好,文件更小,加载也快。为什么不直接用pickle?pickle 容易出现“类找不到”问题,尤其是 sklearn 版本不一致时;joblib 本质上也是 pickle,但它在序列化 numpy 数据时用的协议更稳定。另外,feature_columns.pkl 保存的是训练时 one-hot 展开后的列名列表,部署时对输入数据做同样的展开,再按这个列表喂给模型。

4.2 Flask 应用的最小骨架:POST 一个 JSON,返回一个预测值

创建web/app.py:

from flask import Flask, request, jsonify, render_template import joblib import numpy as np import pandas as pd app = Flask(__name__) model = joblib.load('../model/saved_model.pkl') feature_columns = joblib.load('../model/feature_columns.pkl') @app.route('/') def index(): return render_template('index.html') @app.route('/predict', methods=['POST']) def predict(): data = request.get_json(force=True) budget = float(data['budget']) popularity = float(data['popularity']) runtime = float(data['runtime']) genres = data['genres'] input_dict = {col: 0 for col in feature_columns} input_dict['budget'] = budget input_dict['popularity'] = popularity input_dict['runtime'] = runtime for genre in genres.split(','): column = 'genres_' + genre if column in input_dict: input_dict[column] = 1 input_df = pd.DataFrame([input_dict]) pred_log = model.predict(input_df)[0] pred = np.expm1(pred_log) return jsonify({'predicted_revenue': round(float(pred), 2)}) if __name__ == '__main__': app.run(host='0.0.0.0', port=5000)

这段代码的逻辑是:前端填写表单,POST 到/predict,后端把字段整理成和训练时一模一样的特征列,然后调用模型得到 log 空间预测值,再用np.expm1还原成票房数字。force=True让 Flask 解析不以application/json为 Content-Type 的请求体,方便测试。

注意input_dict初始化全 0,再把连续特征填进去,再对类型标签置 1。这一步必须和训练时代码里MultiLabelBinarizer的结果对齐,否则特征顺序一乱,预测结果就乱套。我这里用pd.DataFrame([input_dict])构造单行数据,如果后面要批量预测,把列表换成多行即可。

app.run(host='0.0.0.0', port=5000)的0.0.0.0表示监听从外部访问的请求,如果你只在本地调试,可以改成127.0.0.1。但如果你把它部署到开发机上,0.0.0.0允许局域网访问,用于演示更合适。注意 Flask 自带的服务器只适合低并发测试,真正上线要换 gunicorn 或 uwsgi,这不是本项目重点。

4.3 平台的前端:一个最简单 HTML 表单

在web/templates/index.html里写:

<!DOCTYPE html> <html> <head><title>票房预测</title></head> <body> <h2>电影票房预测</h2> <form id="predForm"> <label>预算(美元)</label><input type="text" name="budget"><br> <label>热度指数</label><input type="text" name="popularity"><br> <label>时长(分钟)</label><input type="text" name="runtime"><br> <label>类型(逗号分隔)</label><input type="text" name="genres"><br> <button type="submit">预测</button> </form> <div id="result"></div> <script> document.getElementById('predForm').onsubmit = async function(e) { e.preventDefault(); const f = e.target; const data = { budget: f.budget.value, popularity: f.popularity.value, runtime: f.runtime.value, genres: f.genres.value }; const resp = await fetch('/predict', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(data) }); const result = await resp.json(); document.getElementById('result').textContent = '预测票房(美元): ' + result.predicted_revenue; }; </script> </body> </html>

这个表单就是一个可用的页面,类型字段用逗号分隔,和 Flask 里的genres.split(',')正好对上。如果你打算做得好看一点,可以引入 Bootstrap,但核心功能已经很完整了。这里没有复杂的 JavaScript,只用了 async/await 发一个 POST 请求,对新手来说是能读懂得最多的前端代码了。

5. 票房预测里的5个玄学坑:从数据泄露到路径问题,逐个排查

这一节不是教你调参,而是把我在复现这类项目时最容易踩的坑列出来。这些坑不会让程序报错,但会让结果可笑。每一条都是现象、原因、解决三步走,你可以直接对照你自己的项目排查。

5.1 现象:R2 高到 0.98,但模型一遇到新数据就废

原因:模型用到了“上映后”的特征。比如你把首周票房、口碑分数、甚至电影实际分成进了训练特征。这是典型的数据泄露。解决:只保留电影上映前就能获取的信息作为特征。判断标准是:在电影上映日当天,你知道这个值吗?如果不确定,就删掉。比如release_date里的年份和季节可以用,但weeks_in_theater不能用。

5.2 现象:部署时代码报X has 95 features, but model expects 120 features

原因:训练时 one-hot 产生了 120 个特征列,但部署时你只传了 95 列,或者你传入的顺序不对。这通常出现在用pd.get_dummies而不是保存列名的场景。解决:训练时把model.columns保存下来,部署时先构造全 0 向量再填充。我在上面的 Flask 代码里已经演示了这个做法。

另一个细节:get_dummies在训练集上产生的列名和部署时输入新数据产生的列名可能不一致,所以永远不要对两批数据分别调用get_dummies,一定要用同一个fit好的OneHotEncoder或MultiLabelBinarizer。

# 训练时保存列名 train_columns = pd.get_dummies(X_train).columns.tolist() # 部署时构造全0向量 input_vec = pd.DataFrame(0, index=[0], columns=train_columns)

这段代码把列名存成列表,部署时先建一个全 0 的 DataFrame,再按列名逐个填充。这样即使输入里缺了某个类别,列数也和训练时一致。

5.3 现象:预测结果总是接近训练集的均值,但训练集 RMSE 很低

原因:过拟合,模型把训练集的噪声也记下来了。常见于随机森林不设max_depth,或 XGBoost 学习率太大。我一般在树模型里设置min_samples_leaf=5和max_depth=10起步,先压住过拟合,再用交叉验证微调。

还有一个容易被忽视的点:票房数据年份跨度大,如果电影发行量逐年增长,要看时间趋势,不要把不同年份当作独立同分布样本。你可以按年份切分训练集和测试集,比如 2015 年前训练、2015 年后测试,避免模型偷看未来的趋势。

5.4 现象:在 Windows 下解压后,运行python train.py报No such file or directory

原因:相对路径问题。很多课程源码默认你在项目根目录运行,但你很可能直接双击了model/train_model.py,Python 的当前工作目录就变成model/,代码里的路径data/...找不到东西。解决:统一用基于项目根目录的绝对路径,或者用pathlib.Path(__file__).resolve().parent.parent来拼接路径。我自己的习惯是:

from pathlib import Path ROOT = Path(__file__).resolve().parent.parent DATA_PATH = ROOT / 'data' / 'raw_movie_data.csv'

这个写法在任何地方运行都不会迷路。同理,Flask 里加载模型用相对路径../model/saved_model.pkl,但如果启动命令不同也可能翻车,建议也用ROOT解决。

5.5 现象:joblib.load报ModuleNotFoundError: No module named 'sklearn.ensemble._forest'

原因:你训练模型的 scikit-learn 版本和加载模型的版本不一致。比如训练时是 0.23,加载时是 1.2,底层 C 扩展路径变了。解决:尽量避免跨版本加载。实际项目里,我在部署机上也装和训练机一致的 sklearn 版本;如果无法保证,就重新在新环境下训练模型,不要在旧模型上挣扎。这里没有后悔药。

6. 从预测到解释:用 SHAP 讲出“为什么不买这部电影”

跑通了平台只是第一步,能跟别人讲清楚“这个模型为什么这么预测”才是加分项。用 SHAP 库可以解释每一条预测结果。

import shap explainer = shap.TreeExplainer(model_rf) sv = explainer.shap_values(X_test[:100]) shap.summary_plot(sv, X_test[:100])

summary_plot会显示每个特征对不同影片预测值的贡献方向,比如 budget 越高、预测票房越高,就是一张很直观的可解释性报告。这个图在答辩或给业务方汇报时特别有用,比干巴巴的 RMSE 有说服力得多。

另一个验证技巧是残差分析。把测试集原始票房和预测值放在一张图里,如果残差随预测值增大而扩大,说明模型在两端不稳定,你就能针对性补充样本。我一般还会计算 MAPE(平均绝对百分比误差),它比 RMSE 更符合业务直觉:MAPE=0.35 意味着平均偏差 35%。注意票房低的小成本影片会拉高 MAPE,所以最好按预算区间分组评估。

最后说个我的血泪经验:做这类项目,模型分数永远没有“数据可复现”重要。我之前为了追求高 R2,把特征工程越做越复杂,结果换一版数据就全崩。后来我养成了每个环节都存中间数据的习惯——原始数据、清洗后数据、特征工程后数据各一份,一旦模型异常可以回头排查。这个习惯让我少走了很多弯路,也推荐你保留。

希望这份拆解能帮你在两周内把这个平台从“能跑”做成“能讲”。祝你的票房产出数字,不仅跑得出来,还说得清楚。

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

返回列表