
简介一套基于TMDB 5000电影数据集的数据分析项目完整资料主要面向数据可视化课程设计、毕业设计或Python数据分析初学者完整演示从数据读取、缺失值清洗到多维度可视化洞察的实战流程。压缩包共18个文件涵盖Jupyter Notebook源码、CSV原始数据集、HTML交互式图表、课程设计说明书docx、项目报告pdf、运行结果图jpg以及先看说明文档txt等整体约27.43MB目录结构清晰便于按模块参考与复现。项目分析覆盖以下维度电影类型随时间演变趋势、类型数量与利润分布、Universal与Paramount发行策略对比、原创与改编电影在预算收入利润上的差异、时长对票房和评分的影响以及电影关键词特征。已有3369人浏览学习适合用作课程设计参考、毕业设计辅助或Python数据分析实战练习素材拿到后可直接运行源码生成可视化结果并结合课程设计说明书快速理解每个分析步骤的设计意图与实现方法。1. 为什么 TMDB 数据集是练手数据可视化的最佳起点刚拿到一份电影数据时多数人第一反应是拉一张票房 Top 10 的条形图就收工。但 TMDBThe 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的关键列如下列名类型含义budgetint制作预算美元revenueint全球票房收入美元original_languagestr原始语言代码genresstrJSON 数组如[{id: 18, name: Drama}]keywordsstrJSON 数组存标签关键词popularityfloatTMDB 平台热度指数vote_averagefloat平均评分0-10vote_countint投票人数release_datestr上映日期runtimeint片长分钟credits文件里则包含cast和crew两个大 JSON 字段crew里每项有job字段可以用它筛选导演信息。日常分析中导演对票房的影响经常与“明星效应”混淆如果想做因果类分析需要小心处理这个混淆因素。提示Kaggle 上的版本是 2017 年的快照不代表 TMDB 当前全量数据。做时间序列分析时要注意它只覆盖到 2017 年附近。2.2 环境安装与数据加载先建立虚拟环境并安装依赖我一般用 conda 或 venv避免污染全局 Pythonmkdir 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下载后离线即可分析不受网络限制影响。加载两个 CSVimport 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(ascendingFalse) print(missing)homepage缺失最多它对电影分析没有直接价值直接删除。runtime和release_date的缺失行数不多但如果做时间趋势或片长维度分析不能简单填均值建议对缺失行做删除处理movies_df.dropna(subset[runtime, release_date], inplaceTrue) movies_df.drop(columns[homepage], inplaceTrue)删除后要注意索引断裂可以reset_index(dropTrue)。另外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, annotTrue, fmt.2f, cmapRdYlBu_r, xticklabelsnumeric_cols, yticklabelsnumeric_cols, linewidths0.5, linecolorwhite) plt.title(TMDB 数值特征相关性热力图) plt.tight_layout() plt.savefig(correlation_heatmap.png, dpi150) plt.show()corr()默认使用皮尔逊相关系数对线性关系敏感且对异常值很不稳定。预算和票房存在少数极端值比如《复仇者联盟》级别的制作这会显著拉高相关系数。实际分析中我通常同时计算 Spearman 秩相关系数做交叉验证corr_spearman movies_df[numeric_cols].corr(methodspearman)从热力图能看到两个核心信息一是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(datavalid, xlog_budget, ylog_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, dpi150) 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加agggenre_stats genre_df.groupby(genre).agg( 电影数量(title, count), 平均票房(revenue, mean), 中位票房(revenue, median), 平均评分(vote_average, mean), ).sort_values(电影数量, ascendingFalse)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(dataplot_df, xgenre, yvote_average) plt.xticks(rotation45) plt.tight_layout() plt.savefig(genre_vote_boxplot.png, dpi150) 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(width1200, height600, background_colorwhite, colormapviridis, max_words50).generate(genre_text)colormap控制配色max_words控制显示数量。生成后立即存图并展示plt.figure(figsize(14, 7)) plt.imshow(wordcloud, interpolationbilinear) plt.axis(off) plt.tight_layout() plt.savefig(genre_wordcloud.png, dpi150) 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, dpi150) 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, dpi150) 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_state42), xbudget, yrevenue, colorvote_average, sizevote_count, hover_nametitle, hover_data{genres: True, runtime: True}, log_xTrue, log_yTrue, color_continuous_scaleviridis, titleTMDB 电影预算-票房交互散点图 ) 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]), xvote_average, nbins40, marginalbox, titleTMDB 电影评分分布, labels{vote_average: 平均评分} ) fig.show()marginalbox在直方图顶部附加箱线图能同时展示分布形状与四分位数。从分布能看出 TMDB 评分集中在 5.5 到 7.5 之间两端的极端高分与极端低分都相对少见且峰值并不像正态分布那样对称存在轻微负偏。评分分布常见的一个误用是直接看原始评分数忽略vote_count的置信度。投票人数只有几十人的电影评分波动极大想过滤低投票数的片子运行时动态调整fig px.histogram( movies_df[movies_df[vote_count] 100], xvote_average, nbins40 )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( idgenre-filter, options[{label: g, value: g} for g in [全部] top_genres], value全部 ), dcc.Graph(idbudget-revenue-scatter), dcc.Graph(idscore-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, xbudget, yrevenue, log_xTrue, log_yTrue, hover_nametitle, colorvote_average, titlef{selected_genre} 预算-票房分布 ) hist_fig px.histogram( filtered, xvote_average, nbins40, titlef{selected_genre} 评分分布 ) return scatter_fig, hist_fig if __name__ __main__: app.run(debugTrue)运行后访问http://127.0.0.1:8050即可看到仪表盘。dcc.Dropdown的选项用top_genres动态生成保证下拉列表里只有出现频次足够的类型。回调用Input(genre-filter, value)监听下拉框变化每次选择变更时重算两个图表。注意对genres_names做过滤时用apply(lambda x: selected_genre in x)这是一个列表包含判断比字符串匹配更快也更准确。提示debugTrue开启热重载改代码后浏览器自动刷新但生产环境要改成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, axis1)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 年后的回升这种趋势分析与单一横截面分析相比能额外挖掘出行业周期信息。至此这篇文章覆盖了从环境准备、数据清洗、静态可视化、交互式图到仪表盘落地的完整链路。最后的两个技巧属于“做完初级版本后再优化”的进阶步骤实际分析场景中先完成基础分析再逐步引入加权和时间维度整个分析过程的可靠性和说服力都会上一个台阶。本文还有配套的精品资源点击获取