电影票房虽然受非常多因素的影响,但整体也算有迹可循,比如电影的投资额、演员、电影的类型等,都可能会最终影响电影的票房。所以理论上来说也可以从电影制作期间的一些基本信息来建立模型,预测出电影的票房。为院线和资方等提供参考。
本节的案例,就尝试建立一些电影票房的预测模型。
本节的实战,使用 kaggle 公开的 tmdb 电影票房数据集。
该数据集包含了 1971 到 2015 年的部分电影数据。主要字段的含义如下:
| 字段名 | 含义 |
|---|---|
| belongs_to_collection | 属于哪个系列 |
| budget | 预算 |
| genres | 种类 |
| homepage | 主页 |
| imdb_id | imdb 的 id |
| original_language | 初始语言 |
| original_title | 初始标题 |
| overview | 电影简介 |
| popularity | 流行指数 |
| poster_path | 封面图地址 |
| production_companies | 出品公司 |
| production_countries | 出品国家 |
| release_date | 上映日期 |
| runtime | 电影时长 |
| spoken_languages | 包含语言 |
| status | 状态 |
| tagline | 标语 |
| title | 标题 |
| keywords | 关键词 |
| cast | 演员表 |
| crew | 职员表 |
| revenue | 票房收入 |
test 数据集的字段和 train 一致,只是不包含 revenue 字段。
本节的任务目标非常清晰,就是基于 train.csv 中的数据,建立电影的票房收入预测模型,这样对于 test.csv 中的电影,基于现有的数据去预测对应的 revenue。
首先第一步,还是导入必要的依赖工具:
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np
import seaborn as sns
import random
from plotly import tools
import plotly.express as px
from plotly.offline import init_notebook_mode, iplot, plot
import plotly.figure_factory as ff
import plotly.graph_objs as go
import ast
然后导入数据:
df_train = pd.read_csv("train.csv")
df_test = pd.read_csv("test.csv")
如果直接输出 df_train 概述的话,会发现其中部分字段太长,会导致整个 DataFrame 概述一个屏幕都装不下。所以曲线救国,采取其他方式来看一下 DataFrame 的情况。
首先看一下 DataFrame 的 shape:
print(df_train.shape)
print(df_test.shape)
输出如下:
(3000, 23)
(4398, 22)
可以看到,记录数并不多,但是每条记录有很多个列。而且另一个有意思的现象就是 test 的记录数居然比 train 的还要多。
接下来,看一下单条数据的数据情况。由于单条数据很长,所以即便只输出一条仍然无法全部显示。这个时候考虑将结果转置为竖版的,即可完整显示。
df_train.head(1).transpose()
输出如下:
从以上的单条记录,可以发现这几个现象:
什么是 JSON?
JSON 可以理解为是字典的字符串表示。把字典转成一个 json 字符串,也可以反向把一个 json 字符串转换为字典。在 json 字符串中, []表示列表,{} 表示字典。
首先,使用以下代码来逐个观察 json 数据的特征,比如 genres 字段:
print(df_train.head(1).Keywords.values)
输出如下:
["[{'id': 4379, 'name': 'time travel'}, {'id': 9663, 'name': 'sequel'}, {'id': 11830, 'name': 'hot tub'}, {'id': 179431, 'name': 'duringcreditsstinger'}]"]
通过逐个观察 json 字段,得出以下结论:
def expand_json(json_str):
d = ast.literal_eval(json_str)
return [item["name"] for item in d]
json_features = ["belongs_to_collection", "genres", "production_companies", "production_countries",\
"Keywords", "spoken_languages", "cast", "crew"]
for feature in json_features:
df_train.loc[df_train[feature].notnull(), feature] = df_train.loc[df_train[feature].notnull(), feature].apply(expand_json)
df_test.loc[df_test[feature].notnull(), feature] = df_test.loc[df_test[feature].notnull(), feature].apply(expand_json)
执行上述代码处理后,看到相关的字段都被转换为名字列表的形式,比如看一下 Keywords 字段。
print(df_train.head(1).Keywords.values)
输出如下:
[list(['time travel', 'sequel', 'hot tub', 'duringcreditsstinger'])]
可以看到,转换后的字段已经是字符串列表了,这样之后整个处理的难度都会比之前的 json 数据要低很多。
接下来,来看一下数据集中缺失值的情况。
df_train.isna().sum()
输出如下:
id 0
belongs_to_collection 2396
budget 0
genres 7
homepage 2054
imdb_id 0
original_language 0
original_title 0
overview 8
popularity 0
poster_path 1
production_companies 156
production_countries 55
release_date 0
runtime 2
spoken_languages 20
status 0
tagline 597
title 0
Keywords 276
cast 13
crew 16
revenue 0
dtype: int64
其中,belongs_to_collection、tagline 和 homepage 这三个字段的缺失值是最多的。所以这三个字段的价值可能会比较小。但另一方面,当一个电影有主页以及属于某个系列的话,会不会票房可能就更好一些呢?
漫威系列的子电影一般票房都不会太差,又比如说有标语的电影可能更吸引更多的受众。这三个字段的内容可能价值有限,但如果有,可能对票房还是有影响的。基于此,将其转换为三个新的字段:has_homepage、has_tagline 和 is_belongs_to_collection,用 0 和 1 来表示之前的三个字段是否有值。
代码如下:
df_train["has_homepage"] = df_train["homepage"].isna().apply(lambda x:0 if x else 1)
df_train["is_belongs_to_collection"] = df_train["belongs_to_collection"].isna().apply(lambda x:0 if x else 1)
df_train["has_tagline"] = df_train["tagline"].isna().apply(lambda x:0 if x else 1)
df_test["has_homepage"] = df_test["homepage"].isna().apply(lambda x:0 if x else 1)
df_test["is_belongs_to_collection"] = df_test["belongs_to_collection"].isna().apply(lambda x:0 if x else 1)
df_test["has_tagline"] = df_test["tagline"].isna().apply(lambda x:0 if x else 1)
执行之后,抽样两条数据看看操作是否符合预期。
df_train[["has_homepage", "is_belongs_to_collection", "homepage", "belongs_to_collection"]].head(5)
输出如下:
可以看到,输出是符合预期的。
在添加了新的字段之后,将原先的几个字段直接删除。
df_train = df_train.drop(columns=["belongs_to_collection", "tagline", "homepage"])
df_test = df_test.drop(columns=["belongs_to_collection", "tagline", "homepage"])
剩余的缺失值分为两种,一种是从 json 格式转换为 list 的字段,也就是 json_features 中的字段。对于这种字段,直接填充为空的 list,和字段其他值保持一致。
# 因为 belongs_to_collection 一列已经被删除,所以这里也要剔除
json_features.remove("belongs_to_collection")
for item in json_features:
df_train[item] = df_train[item].apply(lambda x: x if isinstance(x,list) else [])
df_test[item] = df_test[item].apply(lambda x: x if isinstance(x,list) else [])
第二种情况就是本身是字符串的值,比如 overview 字段。这种直接填充空字符串,然后剩下的零星空值则直接 drop 掉即可。
df_train["overview"] = df_train["overview"].fillna("")
df_train = df_train.dropna()
df_test["overview"] = df_test["overview"].fillna("")
df_test = df_test.dropna()
执行之后,再次看一下 df_train 中的空值情况。
df_train.isna().sum()
输出如下:
id 0
budget 0
genres 0
imdb_id 0
original_language 0
original_title 0
overview 0
popularity 0
poster_path 0
production_companies 0
production_countries 0
release_date 0
runtime 0
spoken_languages 0
status 0
title 0
Keywords 0
cast 0
crew 0
revenue 0
has_homepage 0
is_belongs_to_collection 0
has_tagline 0
dtype: int64
可以看到,目前已经没有缺失值了。
目前初步的数据分析已经完毕,进入可视化分析的环节。
px.scatter(df_train,x = "budget", y = "revenue")
输出如下:
可以看到,大部分电影还是小成本、小票房的电影。随着预算的拉高,相对应的收入也会提高。预算和收入存在一定的正相关性。所以预算应该是核心特征之一。
px.scatter(df_train,x = "runtime", y = "revenue")
输出如下:
可以发现两个信息:
px.scatter(df_train,x = "popularity", y = "revenue")
输出如下:
可以看到,流行度要高还是很难的,大多数电影的流行度非常一般。不过流行度高的电影虽然收入不一定会好,但基本也都不差。流行度应该也可以作为特征之一。
之前从 json 中抽取了它们的 name 属性,并转换成了 list,那如何查看这些列表和收入的关系呢?毕竟可视化都基于数值。
有一种简单的处理方法就是,不去尝试理解列表中的 name 到底是什么值, 而是只看列表的元素个数,看是否和收入能产生联系。
以类别的值为例。首先创建一个新的列,来代表类别字段的列表长度,实现方法也很简单,就是对该列 apply len 函数即可。
df_train["genres_list_length"] = df_train["genres"].apply(len)
之后就能够画出类别数与收入的关系:
px.scatter(df_train,x="genres_list_length", y = 'revenue')
输出如下:
可以看到,类别的数量和收入还真有关系,类别为 3~4 个的电影收入上限都会高一些。当然不排除主流的电影类别都是 3~4 个所以才显得高。但换个角度看,一个电影算不算主流本身也是可以预测收入的原因之一,所以可以认为类别数和收入之间是存在关系的。
所以对于所有列表类型的特征,都是用其个数作为特征。
现在来看一下不同年份的收入数据,近几年来电影收入是逐渐增多的。所以理论上收入和年份应该是正相关的关系。要绘制年份的图,首先将 release_date 字段转换为 datetime,然后提取年份作为新的字段,之后对年份字段做 groupby 即可。
# 根据数据集描述,2015 年之前的数据才比较准确,所以过滤掉 2015 年之后的数据
df_train["year"] = pd.to_datetime(df_train["release_date"]).dt.year
df_train = df_train[df_train["year"] <= 2015]
df_year_group = df_train[["revenue","year"]].groupby("year").sum()
px.line(df_year_group)
输出如下:
可以看到,数据上确实和估计的一致,电影收入和上映的年份呈强烈的正相关关系,说明电影行业的收入近几年有了蓬勃的发展。
到这里,可视化分析就基本结束了。通过对这几类值进行可视化分析,对这些字段对目标值的影响有了一定的认识。
现在进入特征工程环节。毕竟最终训练模型的时候,模型接收的只能是数字。
基于刚才的分析,列表类数据统一使用它们的元素个数来作为特征。所有的列表类的列名字都存在 json_features 数组中,只需要遍历它来批量添加新字段即可。
新字段的名称就为原字段名+count,代码如下:
for item in json_features:
df_train[item + "_count"] = df_train[item].apply(len)
df_test[item + "_count"] = df_test[item].apply(len)
df_train.columns
输出如下:
Index(['id', 'budget', 'genres', 'imdb_id', 'original_language',
'original_title', 'overview', 'popularity', 'poster_path',
'production_companies', 'production_countries', 'release_date',
'runtime', 'spoken_languages', 'status', 'title', 'Keywords', 'cast',
'crew', 'revenue', 'has_homepage', 'is_belongs_to_collection',
'has_tagline', 'genres_count', 'production_companies_count',
'production_countries_count', 'Keywords_count',
'spoken_languages_count', 'cast_count', 'crew_count'],
dtype='object')
可以看到,生成的新字段都被添加到 DataFrame 了。
还有几个特征是字符串的类型,比如 original_title, overview 和 title。对它们,则采用它们字符串的长度来作为特征值。
string_features = ["original_title", "overview", "title"]
for item in string_features:
df_train[item+"_len"] = df_train[item].apply(len)
df_test[item+"_len"] = df_test[item].apply(len)
df_train.columns
输出如下:
Index(['id', 'budget', 'genres', 'imdb_id', 'original_language',
'original_title', 'overview', 'popularity', 'poster_path',
'production_companies', 'production_countries', 'release_date',
'runtime', 'spoken_languages', 'status', 'title', 'Keywords', 'cast',
'crew', 'revenue', 'has_homepage', 'is_belongs_to_collection',
'has_tagline', 'genres_count', 'production_companies_count',
'production_countries_count', 'Keywords_count',
'spoken_languages_count', 'cast_count', 'crew_count',
'original_title_len', 'overview_len', 'title_len'],
dtype='object')
从刚才的分析,都知道年份对于收入很关键,事实上时间一直都和收入是正相关的。比如月份来看,7~9 月的收入是最高的,因为是暑期。所以这里把时间维度都加成新列,作为特征。
date_obj = pd.to_datetime(df_train.release_date)
df_train["year"] = date_obj.dt.year
df_train["month"] = date_obj.dt.month
df_train["day"] = date_obj.dt.day
df_train["dayofweek"] = date_obj.dt.dayofweek
date_obj = pd.to_datetime(df_test.release_date)
df_test["year"] = date_obj.dt.year
df_test["month"] = date_obj.dt.month
df_test["day"] = date_obj.dt.day
df_test["dayofweek"] = date_obj.dt.dayofweek
这里,也新增了年、月、日和星期几作为新的特征列。
特征准备完毕,还需要把所有的特征抽取出来,抛弃掉那些已经不需要的。
feature_list = ["budget", "popularity", "runtime", "has_homepage", "is_belongs_to_collection", "has_tagline", 'genres_count', 'production_companies_count',
'production_countries_count', 'Keywords_count',
'spoken_languages_count', 'cast_count', 'crew_count',
'original_title_len', 'overview_len', 'title_len', "year","month","day", "dayofweek"]
df_train_features = df_train[feature_list]
df_train_features.columns
df_test_features = df_test[feature_list]
然后把模型拟合的目标列也抽取出来:
df_train_target = df_train["revenue"]
df_train_target
目前万事俱备,开始训练模型。
这次的特征非常复杂,并且包含非常多和收入存在非线性关系的列,所以还是使用前文用过的 XGBoost 来建立模型。
from xgboost import XGBRegressor
xgb = XGBRegressor(n_estimators = 2000 , random_state = 0 , max_depth = 27)
xgb.fit(df_train_features, df_train_target)
执行一段时间后输出:
XGBRegressor(base_score=0.5, booster='gbtree', colsample_bylevel=1,
colsample_bynode=1, colsample_bytree=1, gamma=0, gpu_id=-1,
importance_type='gain', interaction_constraints='',
learning_rate=0.300000012, max_delta_step=0, max_depth=27,
min_child_weight=1, missing=nan, monotone_constraints='()',
n_estimators=2000, n_jobs=8, num_parallel_tree=1, random_state=0,
reg_alpha=0, reg_lambda=1, scale_pos_weight=1, subsample=1,
tree_method='exact', validate_parameters=1, verbosity=None)
用训练出来的模型去预测一次训练集,并且和训练集的 revenue 进行比较,来看一下模型的均方误差。
from sklearn.metrics import mean_squared_error
pred_target = xgb.predict(df_train_features)
print("模型均方误差:", mean_squared_error(pred_target, df_train_target))
输出如下:
模型均方误差: 352.18567634130454
电影的 revenue 绝大多数都是千万量级,所以这个误差表示模型还是相对比较准确的。
最后一步,就是使用模型来预测 test 数据集中的电影收入。
df_test["pred_revenue"] = xgb.predict(df_test_features)
查看一下预测的结果:
df_test[["title", "pred_revenue"]].head(5)
运行后即可看到每部电影的预测票房收入。
本节电影预测的实战项目就基本完成了,接触数据集比以往的都要复杂一些。回顾一下,本节主要涉及了以下新的知识点:
| 库 | 本文编写时 | 当前稳定版 | 升级要点 |
|---|---|---|---|
| Python | 3.8-3.12 | 3.14 | 3.12+ 起性能显著提升;3.14 PEP 649/750 |
| NumPy | 1.x/2.0 | 2.5.x | np.float_ 等别名移除;NEP 50 类型提升 |
| Pandas | 1.x/2.x | 3.0.x | Copy-on-Write 默认开启;inplace 移除;字符串 dtype 变化 |
| Matplotlib | 3.x | 3.x 稳定版 | API 兼容,样式更新 |
| Seaborn | 0.12/0.13 | 0.13.x | API 稳定 |
| scikit-learn | 1.x | 1.9.x | API 稳定,新算法持续加入 |
本文讲解的数据分析流程(读取→清洗→分析→可视化)与核心 API 在最新版本中成立;升级时重点关注 Pandas 3.0 的 Copy-on-Write 与 NumPy 2.x 的类型变化。