简介:一套基于TMDB 5000电影数据集的数据分析项目完整资料,主要面向数据可视化课程设计、毕业设计或Python数据分析初学者,完整演示从数据读取、缺失值清洗到多维度可视化洞察的实战流程。压缩包共18个文件,涵盖Jupyter Notebook源码、CSV原始数据集、HTML交互式图表、课程设计说明书(docx)、项目报告(pdf)、运行结果图(jpg)以及先看说明文档(txt)等,整体约27.43MB,目录结构清晰,便于按模块参考与复现。项目分析覆盖以下维度:电影类型随时间演变趋势、类型数量与利润分布、Universal与Paramount发行策略对比、原创与改编电影在预算收入利润上的差异、时长对票房和评分的影响,以及电影关键词特征。已有3369人浏览学习,适合用作课程设计参考、毕业设计辅助或Python数据分析实战练习素材,拿到后可直接运行源码生成可视化结果,并结合课程设计说明书快速理解每个分析步骤的设计意图与实现方法。
1. 为什么 TMDB 数据集是练手数据可视化的最佳起点
刚拿到一份电影数据时,多数人第一反应是拉一张票房 Top 10 的条形图就收工。但 TMDB(The Movie Database)的公开数据集远不止提供title和revenue这两个字段,它的genres、keywords、cast、crew等多值列里嵌套着 JSON 结构,预算与票房的关系里藏着规模效应,评分与投票数的分布则会告诉你“豆瓣高分”在统计学上意味着什么。这本博文将基于 TMDB 数据集走一遍完整的电影数据分析流程:从环境准备、数据清洗,到票房与评分的相关性分析、类型与关键词的文本可视化,最后用 Plotly 和 Dash 构建一个可交互探索的仪表盘。整个过程不涉及爬虫抓取,只用官方导出的 CSV 文件,代码全部在本地 Jupyter Notebook 或 VS Code 里可跑通。
文章面向三类读者:刚接触数据可视化、想找一份真实数据集练手的人;需要在课程设计或技术博客中展示完整分析链路的学生;以及想快速了解电影行业数据形态、为后续推荐系统或票房预测做铺垫的分析师。
2. 数据准备:TMDB 数据集结构梳理与清洗策略
2.1 数据集文件与字段构成
TMDB 在 Kaggle 上开放了tmdb_5000_movies.csv和tmdb_5000_credits.csv两个文件,前者每行是一部电影的元数据,后者存放对应的演职人员信息。两个文件都包含一个数字字符串形式的id字段,是关联的主键。
tmdb_5000_movies.csv的关键列如下:
| 列名 | 类型 | 含义 |
|---|---|---|
budget | int | 制作预算(美元) |
revenue | int | 全球票房收入(美元) |
original_language | str | 原始语言代码 |
genres | str | JSON 数组,如[{"id": 18, "name": "Drama"}] |
keywords | str | JSON 数组,存标签关键词 |
popularity | float | TMDB 平台热度指数 |
vote_average | float | 平均评分(0-10) |
vote_count | int | 投票人数 |
release_date | str | 上映日期 |
runtime | int | 片长(分钟) |
credits文件里则包含cast和crew两个大 JSON 字段,crew里每项有job字段,可以用它筛选导演信息。日常分析中,导演对票房的影响经常与“明星效应”混淆,如果想做因果类分析,需要小心处理这个混淆因素。
提示:Kaggle 上的版本是 2017 年的快照,不代表 TMDB 当前全量数据。做时间序列分析时要注意它只覆盖到 2017 年附近。
2.2 环境安装与数据加载
先建立虚拟环境并安装依赖,我一般用 conda 或 venv,避免污染全局 Python:
mkdir tmdb-analysis && cd tmdb-analysis python -m venv venv source venv/bin/activate # Windows 下执行 venv\Scripts\activate pip install pandas numpy matplotlib seaborn plotly dash jupyter如果网络环境访问默认 PyPI 慢,可以临时换用清华镜像源:
pip install -i https://pypi.tuna.tsinghua.edu.cn/simple pandas numpy matplotlib seaborn plotly dash jupyter关于TMDB国内镜像,官方不提供API国内节点,但 Kaggle 的 CSV 文件本身不依赖 API,下载后离线即可分析,不受网络限制影响。
加载两个 CSV:
import pandas as pd import json movies_df = pd.read_csv("tmdb_5000_movies.csv") credits_df = pd.read_csv("tmdb_5000_credits.csv") print(movies_df.shape, credits_df.shape) print(movies_df.columns.tolist())read_csv默认把genres、keywords这些列当作普通字符串读入,后续需要json.loads解析。这里不设置parse_dates,因为release_date并非标准 ISO 格式且含空值,留到清洗阶段统一处理。
2.3 缺失值与异常值处理
先看每个字段的缺失情况:
missing = movies_df.isnull().sum() missing = missing[missing > 0].sort_values(ascending=False) print(missing)homepage缺失最多,它对电影分析没有直接价值,直接删除。runtime和release_date的缺失行数不多,但如果做时间趋势或片长维度分析,不能简单填均值,建议对缺失行做删除处理:
movies_df.dropna(subset=["runtime", "release_date"], inplace=True) movies_df.drop(columns=["homepage"], inplace=True)删除后要注意索引断裂,可以reset_index(drop=True)。另外,budget或revenue为 0 的行不一定是缺失,也可能是真实的小成本作品,但大部分是数据填充为 0。在分析“预算-票房”关系时,我会将两者之一为 0 的行单独拆出来,不参与回归分析,但保留在数据集中观察比例。
JSON 字段解析写一个通用函数:
def parse_json_column(df, col_name): parsed = [] for value in df[col_name]: try: parsed.append(json.loads(value)) except (json.JSONDecodeError, TypeError): parsed.append([]) return parsed movies_df["genres_list"] = parse_json_column(movies_df, "genres") movies_df["keywords_list"] = parse_json_column(movies_df, "keywords")这样每个单元格变成 Python 列表,后续做类型统计或关键词词频时可以直接遍历。如果只想保留类型名称,可以提取出 name 字段拼成新列:
movies_df["genres_names"] = movies_df["genres_list"].apply( lambda x: [item["name"] for item in x] if x else [] )这个处理逻辑同样适用于credits中的cast和crew。注意crew里一个成员可能对应多个 job,筛选导演时要遍历全部元素。
3. 票房与评分的多维度数据可视化分析
3.1 预算、票房与评分的相关性矩阵
清洗完成后,第一件事不是画图,而是先算相关性矩阵,明确哪些数值变量之间有值得视觉化的关系:
import matplotlib.pyplot as plt import seaborn as sns numeric_cols = ["budget", "revenue", "popularity", "vote_average", "vote_count", "runtime"] corr = movies_df[numeric_cols].corr() plt.figure(figsize=(10, 8)) sns.heatmap(corr, annot=True, fmt=".2f", cmap="RdYlBu_r", xticklabels=numeric_cols, yticklabels=numeric_cols, linewidths=0.5, linecolor="white") plt.title("TMDB 数值特征相关性热力图") plt.tight_layout() plt.savefig("correlation_heatmap.png", dpi=150) plt.show()corr()默认使用皮尔逊相关系数,对线性关系敏感,且对异常值很不稳定。预算和票房存在少数极端值(比如《复仇者联盟》级别的制作),这会显著拉高相关系数。实际分析中我通常同时计算 Spearman 秩相关系数做交叉验证:
corr_spearman = movies_df[numeric_cols].corr(method="spearman")从热力图能看到两个核心信息:一是vote_average与revenue相关系数往往偏低(一般不超 0.15),说明“叫好”和“叫座”在 TMDB 数据里基本是两个维度;二是vote_count与revenue的高相关更多反映影片的曝光规模,不代表口碑推动了票房。
3.2 预算与票房散点图:对数变换的必要性
直接画预算-票房散点图会发现所有点挤在左下角,少数大片把坐标轴拉开。因为预算和票房都呈长尾分布,单点差异极大。更好的方式是取对数:
import numpy as np movies_df["log_budget"] = np.log10(movies_df["budget"].replace(0, np.nan)) movies_df["log_revenue"] = np.log10(movies_df["revenue"].replace(0, np.nan)) valid = movies_df.dropna(subset=["log_budget", "log_revenue"]) plt.figure(figsize=(10, 7)) sns.regplot(data=valid, x="log_budget", y="log_revenue", scatter_kws={"alpha": 0.3, "s": 8}, line_kws={"color": "red"}) plt.xlabel("Log10(预算)") plt.ylabel("Log10(票房)") plt.title("预算与票房的双对数散点图") plt.tight_layout() plt.savefig("budget_revenue_scatter.png", dpi=150) plt.show()取log10后数据分布更接近正态,regplot的回归线才有意义。注意replace(0, np.nan)这一步:预算为 0 的电影可能是真实低预算,也可能数据缺失,直接取对数会得到负无穷。
从这张图能直观看出两个现象:斜率小于 1,意味着预算增长 10 倍时票房平均增长不足 10 倍,存在边际递减;高预算电影的残差方差更大,说明大制作中“扑街”和“爆款”并存,预算不是票房的稳定预测变量。
3.3 按类型聚合的评分票房对比分析
genres_names列中一部电影可能有多个类型。比较各类型的基准值时,如果用简单分组会重复计数,正确做法是把长表拆开:
genre_rows = [] for idx, row in movies_df.iterrows(): for genre in row["genres_names"]: genre_rows.append({ "title": row["title"], "genre": genre, "revenue": row["revenue"], "vote_average": row["vote_average"] }) genre_df = pd.DataFrame(genre_rows)拆成长表后,按类型聚合就有多种选择了。我一般用groupby加agg:
genre_stats = genre_df.groupby("genre").agg( 电影数量=("title", "count"), 平均票房=("revenue", "mean"), 中位票房=("revenue", "median"), 平均评分=("vote_average", "mean"), ).sort_values("电影数量", ascending=False)mean容易被少数超级大片拉偏,所以同时查看中位数。绘制类型平均评分箱线图时,常会看到“纪录片”平均分很高,但样本量很小,这时要以电影数量列做筛选,只保留数量超过 50 的类型,避免小样本噪音干扰分析结论。用 seaborn 绘制分组箱线图:
top_genres = genre_stats[genre_stats["电影数量"] > 50].index.tolist() plot_df = genre_df[genre_df["genre"].isin(top_genres)] plt.figure(figsize=(12, 6)) sns.boxplot(data=plot_df, x="genre", y="vote_average") plt.xticks(rotation=45) plt.tight_layout() plt.savefig("genre_vote_boxplot.png", dpi=150) plt.show()箱线图比柱状图更有信息量:中位数反映典型水平,箱体高度反映稳定性。这一步结论通常很明显——动画、纪录片的评分中位数和箱体位置都高于恐怖片,但恐怖片存在长尾高分片,这种分布差异是均值聚合无法体现的。
4. 基于类型与关键词的文本数据可视化
4.1 类型词频统计与词云可视化
用collections.Counter对全部电影的类型做词频统计,这是文本可视化最直接的切入点:
from collections import Counter genre_counter = Counter() for genres in movies_df["genres_names"]: genre_counter.update(genres) print(genre_counter.most_common(15))词云库wordcloud默认从空格分隔的字符串生成,需要把 Counter 转成字符串:
from wordcloud import WordCloud genre_text = " ".join( [f"{genre} " * count for genre, count in genre_counter.items()] ) wordcloud = WordCloud(width=1200, height=600, background_color="white", colormap="viridis", max_words=50).generate(genre_text)colormap控制配色,max_words控制显示数量。生成后立即存图并展示:
plt.figure(figsize=(14, 7)) plt.imshow(wordcloud, interpolation="bilinear") plt.axis("off") plt.tight_layout() plt.savefig("genre_wordcloud.png", dpi=150) plt.show()提示:如果
wordcloud没安装,执行pip install wordcloud。在 Jupyter 里首次调用生成函数可能较慢,属正常现象。
词云适合快速获取全局印象——能看到 Drama、Comedy、Thriller 占据大面积。但词云的局限是尺寸与频率不成精确比例,排版受随机布局影响。如果要展示给甲方或写正式报告,建议用横向条形图替代:
top_genres = genre_counter.most_common(12) genres, counts = zip(*top_genres) plt.figure(figsize=(10, 6)) plt.barh(genres[::-1], counts[::-1], color="#4C72B0") plt.xlabel("出现次数") plt.title("TMDB 电影类型出现频次 Top 12") plt.tight_layout() plt.savefig("genre_barh.png", dpi=150) plt.show()[::-1]用于反转列表让最大的类型出现在顶部,条形图在这种场景下比柱状图更适合横向阅读类型名称。
4.2 关键词抽取与去停用词处理
keywords_list是另一类文本数据,和类型不同,它是 TMDB 编辑人工标注的主题标签,数量远超类型集合。词云直接生成会得到一堆无意义的通用词,比如woman、independent film、death这些在大量电影中反复出现。做关键词分析前需要两步清洗:过滤高频停用词、合并同义表达。
stopwords = {"woman", "man", "life", "death", "love", "one", "new", "independent film", "world", "time", "day"} keyword_counter = Counter() for kw_list in movies_df["keywords_list"]: for kw in kw_list: name = kw.get("name", "") if name.lower() not in stopwords and len(name) > 2: keyword_counter[name] += 1 print(keyword_counter.most_common(20))len(name) > 2过滤掉两个字符以下的缩写标签,停用词集合根据实际统计结果手动增删。这一步处理完后,剩下的关键词才带有业务语义,如dystopia、anti hero、space opera等,能反映电影的内容倾向。
4.3 类型与关键词组合的条形图展示
关键词清洗后,绘制 Top 20 横向条形图并自定义配色:
top_keywords = keyword_counter.most_common(20) keywords, counts = zip(*top_keywords) fig, ax = plt.subplots(figsize=(12, 8)) bars = ax.barh(keywords[::-1], counts[::-1], color="#55A868") ax.set_xlabel("出现次数") ax.set_title("TMDB 电影关键词频次 Top 20(已清洗)") plt.tight_layout() plt.savefig("keywords_top20.png", dpi=150) plt.show()对比类型词云和关键词条形图能得到一个深入洞察:类型决定的是影片的“品类”,关键词决定的是影片的“母题”。一部电影可能属于 Comedy,但关键词是dark comedy和satire。如果后续要做电影相似度计算,关键词的语义覆盖度远高于类型字段。
更进阶的做法是把高频关键词之间的关系用共现矩阵表示。对同一部电影的关键词两两组合计数,再取 Top 20 组合绘制热力图或网络图。共现分析的代码逻辑不复杂:
from itertools import combinations cooccur = Counter() for kw_list in movies_df["keywords_list"]: names = sorted({kw["name"] for kw in kw_list if kw["name"] not in stopwords}) cooccur.update(combinations(names, 2)) print(cooccur.most_common(10))这一步能挖出“亲情与死亡”“复仇与警察”这类稳定的主题搭配,是电影研究中比较有说服力的文本证据。
5. 用 Plotly 构建交互式电影数据仪表盘
5.1 散点图矩阵与悬浮信息设计
前面的图表用的是 Matplotlib 和 Seaborn,适合静态报告。如果要探索数据,交互式图表远比静态图高效。Plotly Express 一行函数即可生成可缩放、可悬浮查看的散点图:
import plotly.express as px valid = movies_df.dropna(subset=["budget", "revenue", "vote_average", "runtime"]) fig = px.scatter( valid.sample(min(3000, len(valid)), random_state=42), x="budget", y="revenue", color="vote_average", size="vote_count", hover_name="title", hover_data={"genres": True, "runtime": True}, log_x=True, log_y=True, color_continuous_scale="viridis", title="TMDB 电影预算-票房交互散点图" ) fig.show()sample抽样到 3000 行是因为 Plotly 渲染超过 5000 个点会有明显卡顿,保留随机种子保证每次运行结果一致。hover_name指定悬停时高亮的标题字段,color映射评分数值,size映射投票数,散点大小代表观众规模。log_x和log_y直接内置于 Plotly 的轴配置,省去了手动取对数的步骤。
这个图的交互价值在缩放逻辑:静态图切分对数值后,无法追溯某个点的具体电影名,而交互散点图悬停后能直接看到“高预算低票房”的点到底是哪些片子,便于排查异常值。
5.2 评分分布直方图与范围联动过滤
交互式图表另一个强项是联动过滤。Plotly 直方图可以同时展示评分分布和累计占比:
fig = px.histogram( movies_df.dropna(subset=["vote_average"]), x="vote_average", nbins=40, marginal="box", title="TMDB 电影评分分布", labels={"vote_average": "平均评分"} ) fig.show()marginal="box"在直方图顶部附加箱线图,能同时展示分布形状与四分位数。从分布能看出 TMDB 评分集中在 5.5 到 7.5 之间,两端的极端高分与极端低分都相对少见,且峰值并不像正态分布那样对称,存在轻微负偏。
评分分布常见的一个误用是直接看原始评分数,忽略vote_count的置信度。投票人数只有几十人的电影评分波动极大,想过滤低投票数的片子,运行时动态调整:
fig = px.histogram( movies_df[movies_df["vote_count"] > 100], x="vote_average", nbins=40 )vote_count > 100这个阈值没有标准答案,需要结合样本量来调整,5000 部电影里投票数超过 100 的占比高,适合做整体分布;投票数超过 1000 的更适合作榜单对比。
5.3 Dash 仪表盘最小可运行实现
Plotly 图表单独展示已经够用,但如果你想给同事或评审一个“在浏览器里选条件看图表”的界面,需要用 Dash 把这几个图整合起来。下面是一个最小可运行示例:
import dash from dash import dcc, html, Input, Output import plotly.express as px app = dash.Dash(__name__) app.layout = html.Div([ html.H1("TMDB 电影数据分析仪表盘"), dcc.Dropdown( id="genre-filter", options=[{"label": g, "value": g} for g in ["全部"] + top_genres], value="全部" ), dcc.Graph(id="budget-revenue-scatter"), dcc.Graph(id="score-histogram"), ]) @app.callback( Output("budget-revenue-scatter", "figure"), Output("score-histogram", "figure"), Input("genre-filter", "value") ) def update_charts(selected_genre): if selected_genre == "全部": filtered = movies_df else: filtered = movies_df[movies_df["genres_names"].apply( lambda x: selected_genre in x )] scatter_fig = px.scatter( filtered, x="budget", y="revenue", log_x=True, log_y=True, hover_name="title", color="vote_average", title=f"{selected_genre} 预算-票房分布" ) hist_fig = px.histogram( filtered, x="vote_average", nbins=40, title=f"{selected_genre} 评分分布" ) return scatter_fig, hist_fig if __name__ == "__main__": app.run(debug=True)运行后访问http://127.0.0.1:8050即可看到仪表盘。dcc.Dropdown的选项用top_genres动态生成,保证下拉列表里只有出现频次足够的类型。回调用Input("genre-filter", "value")监听下拉框变化,每次选择变更时重算两个图表。注意对genres_names做过滤时用apply(lambda x: selected_genre in x),这是一个列表包含判断,比字符串匹配更快也更准确。
提示:
debug=True开启热重载,改代码后浏览器自动刷新,但生产环境要改成False并换用waitress或gunicorn部署。
6. 最后的优化技巧:投票数加权与时间维度扩展
前面的评分分析一直没区分高信度与低信度的样本,一个投票只有 5 次、评分 10 分的短片并不代表口碑最佳。处理这个问题常见做法是采用 IMDB 的加权评分公式,也需要根据TMDB数据结构用 vote_count 与全局均值计算置信下限:
C = movies_df["vote_average"].mean() m = movies_df["vote_count"].quantile(0.8) def weighted_score(row): v = row["vote_count"] if v == 0: return 0 return (v / (v + m)) * row["vote_average"] + (m / (v + m)) * C movies_df["weighted_score"] = movies_df.apply(weighted_score, axis=1)quantile(0.8)作为基准投票数 m,表示超过 80% 的电影才有资格让原始评分占更大权重。这个调整后的排序和原始评分排序会明显不同,那些几千票高分电影会上升到榜单前列,而个位数投票的偶然高分则回落到均值附近。
经过这个处理,Top 20 的电影类型会明显向主流商业片倾斜,因为高投票数代表高曝光度,而这正是热门电影的属性。把weighted_score替换原来的vote_average重新运行第 3 章的散点图和箱线图,会发现评分与票房的关系略有提升,但仍远达不到强相关。如果你想把分析扩展到时间维度,要注意 TMDB 原始 CSV 缺少每年公布的电影数量,因此直接按年份做总量趋势分析意义有限,应聚焦在年度平均评分或类型占比变化上:
movies_df["release_year"] = pd.to_datetime(movies_df["release_date"]).dt.year yearly = movies_df.groupby("release_year").agg( 电影数=("title", "count"), 平均评分=("vote_average", "mean"), 平均预算=("budget", "mean") ).reset_index()用折线图观察平均评分随年份的波动,通常能看到 2000 年前后的低谷和 2010 年后的回升,这种趋势分析与单一横截面分析相比,能额外挖掘出行业周期信息。
至此,这篇文章覆盖了从环境准备、数据清洗、静态可视化、交互式图到仪表盘落地的完整链路。最后的两个技巧属于“做完初级版本后再优化”的进阶步骤,实际分析场景中先完成基础分析再逐步引入加权和时间维度,整个分析过程的可靠性和说服力都会上一个台阶。
本文还有配套的精品资源,点击获取