电影票房虽然受非常多因素的影响,但整体也算有迹可循,比如电影的投资额、演员、电影的类型等,都可能会最终影响电影的票房。所以理论上来说也可以从电影制作期间的一些基本信息来建立模型,预测出电影的票房。为院线和资方等提供参考。
本节的案例,就尝试建立一些电影票房的预测模型。
准备数据
本节的实战,使用 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 numpy as np
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 格式,需要对其进行展开。比如 genres, belongs_to_collection, production_companies 等。
- 纯数字的字段较少,只有 budget、popularity、runtime 和 revenue,所以要建立回归模型,还需要处理其他的字段。
- 部分字段是存在缺失值的,比如 homepage。
什么是 JSON?
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 字段,得出以下结论:
- json 字段可能包含多个字典,比如 Keywords 字段,多个字典代表有多个关键词;
- 每个字典都有 name 这个 key,代表字典的名称。 基于上面的分析,首先提取字典中的 name 属性,并将其转换为 python 的列表。另外,从前文中已经发现,往往最后对 test 数据集,做和 train 数据集一样的数据清洗和特征工程的操作。所以本文所有对 train 数据集的操作都对 test 同步进行操作。
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 数据要低很多。
处理缺失值
接下来,来看一下数据集中缺失值的情况。
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_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")
输出如下:
可以发现两个信息:
- 绝大多数电影的时长都在 90 ~ 150 分钟左右;
- 电影太短或者太长,收入都不会太高。 这只能说时长和收入存在一定的相关性。
流行度与收入的关系
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 = df_train[df_train["year"] <= 2015]
df_train["year"] = pd.to_datetime(df_train["release_date"]).dt.year
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)
输出如下:
小结
本节电影预测的实战项目就基本完成了,接触数据集比以往的都要复杂一些。回顾一下,本节主要涉及了以下新的知识点:
- 对于列很多的数据,可以使用 df.head(1).transpose 来完成查看一条记录的内容;
- JSON 数据,一种 python 字典的字符串表示,也可以表示字典列表;
- 使用 ast.literal_eval 方法可以将 json 字符串转换为 Python 的字典或者列表;
- 可以使用 df.isna().apply(lambda x: 0 if x else 1) 来将有缺失值的列改为 0 或者 1 取值的数据列,用于模型使用;
- 对于内容很复杂的字段,可以考虑使用简单的数值属性来建立模型,比如今天的列表字段,就使用了它的元素个数作为特征来建立模型。 本节的例子比之前的都会更复杂一些。
版本差异(数据科学栈 → 当前版本)
| 库 | 本文编写时 | 当前稳定版 | 升级要点 |
|---|---|---|---|
| Python | 3.8-3.12 | 3.14 | 3.12+ 起性能显著提升;3.14 PEP 649/750 |
| NumPy | 1.x/2.0 | 2.3.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.7.x | API 稳定,新算法持续加入 |
本文讲解的数据分析流程(读取→清洗→分析→可视化)与核心 API 在最新版本中成立;升级时重点关注 Pandas 3.0 的 Copy-on-Write 与 NumPy 2.x 的类型变化。