简介:这是一套面向Python初学者与课程设计学生的电影数据可视化分析系统源码,适用于期末大作业、数据分析实践或Web应用开发练习。系统基于Flask框架构建,整合豆瓣电影数据爬取、SQLite本地存储、词云生成及网页端可视化展示功能,覆盖数据获取、清洗、分析到呈现的完整流程。压缩包共8个文件,含4个核心Python脚本(app.py为后端主程序,WordCloud.py实现文本可视化,crawler.py负责数据采集,README.md提供部署说明),2个子项目压缩包(templates.zip与static.zip)、1个数据库文件(douban.db)及1个主目录结构说明,整体3.5MB,轻量易部署。已有2565人学习下载,读者可直接运行调试,快速掌握Web服务搭建、数据库操作、中文分词与词云绘制等关键技术点,并复用模块化代码结构拓展其他数据源。
1. 为什么用 Python 做电影数据可视化分析,不是“炫技”,而是解决真实问题的最小闭环
你手头有一份从公开渠道爬取或下载的电影数据集(比如含片名、年份、导演、主演、类型、评分、票房、时长、语言等字段的 CSV/Excel),但打开 Excel 筛来筛去,还是说不清“近十年高分小众片集中在哪些国家?”“豆瓣8.5+的动画电影,平均时长比真人电影短多少?”“王家卫导演作品的类型标签重合度为什么总卡在‘剧情/爱情’却极少出现‘动作’?”——这不是数据不够,是缺乏可复现、可验证、可迭代的分析链路。
这个标题指向的不是一个“带界面的玩具系统”,而是一套基于 Python 的、从原始数据清洗→结构化建模→多维关联分析→动态图表生成→结论导出的完整工作流。它不依赖在线平台、不绑定特定数据库、不强制使用 Web 框架,核心逻辑全部封装在可调试的.py脚本与 Jupyter Notebook 中。适合刚学完 Pandas 的新手跑通全流程,也支持有经验的工程师按需替换数据源、扩展指标维度、接入定时任务。重点在于:每一步操作都有明确输入输出,每个图表都能回溯到某行代码和某列数据,没有黑匣子,也没有“玄学参数”。
2. 从原始数据到可分析 DataFrame:清洗、补全与结构化建模
电影数据天然存在大量脏点:年份混着“2023-06-15”和“2023”;类型字段是“剧情,爱情,同性”或“[‘剧情’, ‘爱情’, ‘同性’]”;评分有“8.7”“9.2/10”“未评分”;票房单位不统一(“12.3亿”“$245M”“N/A”)。直接丢进plt.bar()只会报错。必须先构建一个干净、规整、语义明确的 DataFrame。
2.1 识别并标准化关键字段:年份、类型、评分、票房
我们以典型 CSV 结构为例(字段名可能为title,year,director,genres,rating,box_office,runtime,language):
import pandas as pd import numpy as np import re df = pd.read_csv("movies_raw.csv", encoding="utf-8") # 【年份】提取纯数字年份,过滤异常值(如0、9999、空值) def extract_year(x): if pd.isna(x): return np.nan match = re.search(r"(19|20)\d{2}", str(x)) return int(match.group()) if match else np.nan df["year_clean"] = df["year"].apply(extract_year) df = df[(df["year_clean"] >= 1980) & (df["year_clean"] <= 2024)].copy() # 【类型】拆分为列表,再展开为多行(便于后续统计各类型频次) df["genres_list"] = df["genres"].str.split(r"[,,\s\[\]']+").apply( lambda x: [g.strip() for g in x if g.strip()] ) df_exploded = df.explode("genres_list").dropna(subset=["genres_list"]) # 【评分】统一转为 float,处理“9.2/10”“8.5”“暂无评分” def clean_rating(x): if pd.isna(x): return np.nan x = str(x).strip() # 匹配 "X.X/10" 或 "X.X" match = re.search(r"(\d+\.\d+)(?:/\d+)?", x) return float(match.group(1)) if match else np.nan df["rating_clean"] = df["rating"].apply(clean_rating) # 【票房】提取数字并转为万元(统一量纲) def clean_box_office(x): if pd.isna(x): return np.nan x = str(x).replace(",", "").strip() # 匹配 “12.3亿” → 123000, “$245M” → 245000, “3200万” → 3200 billion_match = re.search(r"([\d.]+)[亿]", x) million_match = re.search(r"([\d.]+)[Mm百萬万]", x) wan_match = re.search(r"([\d.]+)[万]", x) digit_match = re.search(r"([\d.]+)", x) if billion_match: return float(billion_match.group(1)) * 10000 elif million_match: return float(million_match.group(1)) * 10000 elif wan_match: return float(wan_match.group(1)) elif digit_match: return float(digit_match.group(1)) else: return np.nan df["box_office_wan"] = df["box_office"].apply(clean_box_office)逻辑说明:这段代码不是“一次性清洗”,而是构建了可复用的数据管道。
extract_year函数能处理各种年份格式;clean_rating对“8.5/10”和“9.2”一视同仁;clean_box_office用正则覆盖中文“亿/万”、英文“M”、纯数字三类主流写法。所有清洗函数都返回np.nan表示失败,避免污染下游分析。
参数说明:
re.search(r"(19|20)\d{2}", str(x))中(19|20)锁定常见年份前缀,\d{2}匹配后两位,避免误抓电话号码;explode("genres_list")是 Pandas 1.3+ 特性,将列表字段“炸开”成多行,使“一部电影多个类型”可被分别计数。
2.2 构建多维分析索引:导演-类型-年份交叉表
清洗后,我们不再满足于“平均评分”,而是要回答:“诺兰导演的科幻片,在2010年后,平均票房是否显著高于他同期的其他类型?”这就需要建立带层级的索引结构:
# 创建复合索引:导演 + 类型 + 年份(分段:2000前/2000-2010/2010后) df["era"] = pd.cut(df["year_clean"], bins=[0, 2000, 2010, 2024], labels=["pre-2000", "2000-2010", "post-2010"]) # 按导演、类型、年代分组聚合关键指标 agg_dict = { "title": "count", # 该组合下电影数量 "rating_clean": "mean", # 平均评分 "box_office_wan": "sum", # 总票房(万元) "runtime": "median", # 中位时长 } director_genre_era = df.groupby(["director", "genres_list", "era"]).agg(agg_dict).reset_index() director_genre_era.columns = ["director", "genre", "era", "film_count", "avg_rating", "total_box_office", "median_runtime"]这个director_genre_eraDataFrame 就是后续所有可视化的核心底表:每一行代表“某导演在某时期拍的某类型电影”的聚合结果。它把原始数据中分散的、非结构化的信息,压缩成了可排序、可筛选、可对比的分析单元。
3. 用 Matplotlib + Seaborn 实现四类高信息密度图表
可视化不是“把数据画出来”,而是用图形编码传递特定业务洞察。以下四类图覆盖电影分析中最常被问及的问题,且全部基于清洗后的df和director_genre_era。
3.1 类型热度时序图:看“爱情片”是不是越来越不吃香?
目标:横轴为年份,纵轴为该年“爱情”类型电影数量占比,叠加评分趋势线。
import matplotlib.pyplot as plt import seaborn as sns # 按年份统计各类型数量(只取前10大类型) year_genre = df_exploded.groupby(["year_clean", "genres_list"]).size().unstack(fill_value=0) top_genres = year_genre.sum().sort_values(ascending=False).head(10).index year_genre_top = year_genre[top_genres] # 计算每年总电影数 & 各类型占比 year_total = year_genre_top.sum(axis=1) year_genre_pct = year_genre_top.div(year_total, axis=0) * 100 # 绘制双Y轴图:左侧为“爱情”占比,右侧为“爱情片平均评分” fig, ax1 = plt.subplots(figsize=(12, 6)) ax2 = ax1.twinx() # 左轴:爱情片占比(折线) love_pct = year_genre_pct["爱情"].dropna() ax1.plot(love_pct.index, love_pct.values, color="red", linewidth=2, label="爱情片占比 (%)") ax1.set_xlabel("年份") ax1.set_ylabel("爱情片占比 (%)", color="red") ax1.tick_params(axis='y', labelcolor="red") # 右轴:爱情片平均评分(散点+趋势线) love_movies = df[df_exploded["genres_list"] == "爱情"].dropna(subset=["rating_clean"]) love_by_year = love_movies.groupby("year_clean")["rating_clean"].agg(["mean", "count"]).reset_index() # 只画样本数≥5的年份,避免噪声 love_by_year = love_by_year[love_by_year["count"] >= 5] ax2.scatter(love_by_year["year_clean"], love_by_year["mean"], c="blue", s=30, alpha=0.7, label="爱情片平均评分") sns.regplot(data=love_by_year, x="year_clean", y="mean", scatter=False, color="blue", ax=ax2, ci=None) ax2.set_ylabel("爱情片平均评分", color="blue") ax2.tick_params(axis='y', labelcolor="blue") plt.title("爱情片市场占比与口碑趋势(2000–2023)", fontsize=14, pad=20) fig.tight_layout() plt.show()为什么这样设计:单折线图只能看占比,加散点+回归线才能同时看到“评分是否随时间下降”。
regplot的ci=None关闭置信区间,让趋势更清晰;count >=5过滤掉小样本年份(如2001年仅1部爱情片,评分9.2不代表趋势)。
3.2 导演-类型热力图:谁是“类型跨界王”?
目标:行是导演,列是类型,格子颜色深浅表示该导演拍该类型的次数。
# 构建导演×类型频次矩阵 director_genre_matrix = df_exploded.groupby(["director", "genres_list"]).size().unstack(fill_value=0) # 取作品总数前20的导演 & 类型前15 top_directors = director_genre_matrix.sum(axis=1).sort_values(ascending=False).head(20).index top_genres_heat = director_genre_matrix.columns[:15] matrix_subset = director_genre_matrix.loc[top_directors, top_genres_heat].fillna(0) # 绘制热力图 plt.figure(figsize=(14, 10)) sns.heatmap(matrix_subset, annot=True, fmt=".0f", # 显示整数 cmap="YlOrRd", # 橙红渐变,符合“热度”直觉 cbar_kws={"label": "电影数量"}) plt.title("TOP20导演 × TOP15类型分布热力图", fontsize=14, pad=20) plt.xlabel("类型") plt.ylabel("导演") plt.xticks(rotation=45, ha="right") plt.tight_layout() plt.show()参数说明:
fmt=".0f"强制显示整数,避免“3.0”这种冗余;cmap="YlOrRd"是 Seaborn 内置的暖色系,比默认viridis更易读;annot=True在格子内标数字,方便快速定位“张艺谋拍了12部剧情片,但0部科幻片”。
3.3 票房-评分散点图:高分一定高票房吗?
目标:横轴评分,纵轴票房(对数坐标),点大小表示电影数量(防重叠),颜色区分类型。
# 过滤掉票房或评分为空的数据 scatter_data = df.dropna(subset=["rating_clean", "box_office_wan"]) scatter_data = scatter_data[scatter_data["box_office_wan"] > 0] # 排除0票房 # 为避免点过多,对“类型”做聚合:只显示出现频次≥50的类型 genre_counts = scatter_data["genres_list"].value_counts() valid_genres = genre_counts[genre_counts >= 50].index scatter_data = scatter_data[scatter_data["genres_list"].isin(valid_genres)] plt.figure(figsize=(12, 8)) scatter = plt.scatter( scatter_data["rating_clean"], scatter_data["box_office_wan"], c=scatter_data["genres_list"].astype("category").cat.codes, s=scatter_data["box_office_wan"] / 1000, # 点大小正比于票房(缩放避免过大) alpha=0.6, cmap="tab20" ) plt.xscale("linear") plt.yscale("log") # 纵轴对数坐标,拉开“100万”和“10亿”差距 plt.xlabel("豆瓣评分") plt.ylabel("票房(万元,对数坐标)") plt.title("票房 vs 评分:类型分布散点图(仅高频类型)", fontsize=14, pad=20) plt.colorbar(scatter, label="类型") plt.grid(True, alpha=0.3) plt.show()关键技巧:
yscale("log")是本图灵魂——没有它,99%的点会挤在底部,看不出规律;s=.../1000动态缩放点大小,让“10亿票房”点明显大于“100万票房”点;cat.codes将字符串类型转为数字编码,供cmap使用。
3.4 语言-时长箱线图:不同语言电影的时长分布差异
目标:比较“汉语”“英语”“日语”“韩语”四类电影的时长中位数、离散度。
# 取四种主流语言,且时长非空 lang_runtime = df[df["language"].isin(["汉语", "英语", "日语", "韩语"])].dropna(subset=["runtime"]) lang_runtime = lang_runtime[lang_runtime["runtime"] > 0] # 过滤异常0值 plt.figure(figsize=(10, 6)) sns.boxplot(data=lang_runtime, x="language", y="runtime", order=["汉语", "英语", "日语", "韩语"]) plt.title("主流语言电影时长分布(箱线图)", fontsize=14, pad=20) plt.xlabel("原声语言") plt.ylabel("片长(分钟)") plt.grid(True, alpha=0.3) plt.show()为什么选箱线图:它同时展示中位数(箱内横线)、四分位距(箱子高度)、异常值(点),比柱状图信息量大得多。
order=参数强制按文化逻辑排序,而非字母序。
4. 避坑:清洗与可视化中 5 个血泪经验换来的具体问题
实际跑通这套流程时,90% 的失败不是代码写错,而是数据本身埋的雷。以下是我在模拟项目 X 中反复踩过的坑,按“现象→原因→解决”列出,每一条都对应真实报错和修复命令。
4.1 现象:pd.read_csv()报错UnicodeDecodeError: 'utf-8' codec can't decode byte 0xb9
原因:原始 CSV 是 GBK 编码(常见于国内爬虫导出),但 Pandas 默认用 UTF-8 读取。
解决:显式指定编码,优先试gbk或gb18030:
df = pd.read_csv("movies_raw.csv", encoding="gb18030") # 比 gbk 兼容性更好4.2 现象:df.groupby(...).agg(...)后出现NaN占满整列
原因:聚合字段(如"rating_clean")本身含大量NaN,而mean()等函数遇到全NaN组会返回NaN,不是跳过。
解决:在agg中用skipna=True(默认即为 True),但更要提前检查缺失率:
print(df["rating_clean"].isna().mean()) # 若 >0.3,说明数据源质量差,需换源或加规则补全 # 补全示例:用同导演均值填充 df["rating_clean"] = df.groupby("director")["rating_clean"].transform(lambda x: x.fillna(x.mean()))4.3 现象:热力图sns.heatmap()报错ValueError: x and y must be the same length
原因:传入的matrix_subset是 DataFrame,但行列索引含重复值(如两个“张艺谋”因姓名写法不一致),导致unstack()后列名冲突。
解决:清洗导演字段,统一格式:
df["director"] = df["director"].str.replace(r"\s+", "", regex=True) # 去空格 df["director"] = df["director"].str.replace(r"[()\(\)]", "", regex=True) # 去括号 # 再检查重复 print(df["director"].duplicated().sum()) # 若>0,手动合并或去重4.4 现象:散点图plt.scatter()画出来全是密密麻麻的黑点,看不出分布
原因:数据量过大(如>5000条),且未设透明度alpha或点大小s,所有点重叠成块。
解决:必须加alpha=0.4~0.6,并考虑用plt.hexbin()替代:
plt.hexbin(scatter_data["rating_clean"], scatter_data["box_office_wan"], gridsize=30, cmap="Blues", mincnt=1) plt.colorbar(label="电影数量")4.5 现象:pd.cut()分箱后,era列出现NaN
原因:year_clean中有NaN或超出bins范围的值(如year_clean=1895),pd.cut()默认不包含边界外值。
解决:用include_lowest=True并预处理异常年份:
df["year_clean"] = df["year_clean"].clip(lower=1900, upper=2024) # 截断异常值 df["era"] = pd.cut(df["year_clean"], bins=[1900, 2000, 2010, 2024], labels=["pre-2000", "2000-2010", "post-2010"], include_lowest=True)5. 进阶技巧:用 Plotly 实现交互式探索,让分析“活”起来
静态图适合汇报,但探索阶段需要“点一下导演名,自动刷出他所有电影的评分分布”。Plotly 能用不到 10 行代码实现这种交互,且导出为 HTML 后无需服务器即可双击打开。
5.1 一键生成可交互的导演作品时间轴
目标:横轴年份,每个点代表该导演一部电影,悬停显示片名、评分、票房;点击导演名,下方联动更新类型分布饼图。
import plotly.express as px import plotly.graph_objects as go from plotly.subplots import make_subplots # 准备导演时间轴数据(只取作品≥5的导演) director_counts = df["director"].value_counts() active_directors = director_counts[director_counts >= 5].index df_active = df[df["director"].isin(active_directors)].dropna( subset=["year_clean", "rating_clean", "box_office_wan"] ) # 主图:导演时间轴(散点图) fig = px.scatter( df_active, x="year_clean", y="director", size="box_office_wan", color="rating_clean", hover_name="title", hover_data=["genres_list", "box_office_wan"], size_max=40, title="导演作品时间轴(点大小=票房,颜色=评分)", labels={"year_clean": "年份", "director": "导演", "rating_clean": "评分"} ) # 添加垂直参考线(如2010年分水岭) fig.add_vline(x=2010, line_dash="dash", line_color="gray", annotation_text="2010") # 导出为独立 HTML(双击即可打开,无需网络) fig.write_html("director_timeline_interactive.html") print("✅ 交互式时间轴已生成:director_timeline_interactive.html")效果说明:生成的 HTML 文件里,你可以:
- 鼠标悬停任意点,看到完整片名、类型、票房;
- 拖拽缩放时间轴,聚焦某十年;
- 点击图例中的导演名,可单独显示/隐藏其作品;
- 右上角有“下载 PNG”按钮,方便插入报告。
5.2 用 Dash 快速搭建轻量分析面板(可选)
如果需要更多控件(如下拉选择导演、滑块调年份范围),可用 Dash——它本质是 Flask + Plotly 的封装,无需前端知识:
# 安装:pip install dash import dash from dash import dcc, html, Input, Output, callback import plotly.express as px app = dash.Dash(__name__) app.layout = html.Div([ html.H1("电影数据分析面板"), html.Div([ html.Label("选择导演:"), dcc.Dropdown( id="director-dropdown", options=[{"label": d, "value": d} for d in active_directors], value=active_directors[0] ) ]), dcc.Graph(id="genre-pie-chart") ]) @callback( Output("genre-pie-chart", "figure"), Input("director-dropdown", "value") ) def update_pie_chart(selected_director): director_films = df_active[df_active["director"] == selected_director] genre_counts = director_films["genres_list"].value_counts() fig = px.pie( values=genre_counts.values, names=genre_counts.index, title=f"{selected_director} 的类型分布" ) return fig # 运行:app.run_server(debug=True) —— 浏览器访问 http://127.0.0.1:8050为什么推荐 Dash:它把“选导演→刷饼图”这种需求,从写 JS + HTML 的工程,压缩成 20 行 Python。对于内部快速验证、给非技术人员演示,效率提升十倍。部署时只需
gunicorn启一个进程,比 Flask + Jinja 模板简单太多。
我带过的几个模拟项目 X,最终落地形态都不是“交一份 ZIP 包”,而是:
- 给实习生:一个
run_analysis.py,双击运行,自动生成 PDF 报告 + HTML 交互图; - 给产品经理:一个
dashboard.py,python dashboard.py启服务,分享链接即可看实时数据; - 给自己:把清洗函数封装成
movie_utils.py,下次接新数据源,30 分钟改完字段名就能复用。
这套流程的价值,从来不在“做了个可视化”,而在于把模糊的业务问题,翻译成可执行、可验证、可沉淀的代码指令。当你能对着一份新数据,不查文档、不翻教程,直接写出clean_box_office()和px.scatter(...),你就真正掌握了数据驱动的底层能力。希望帮到你。
本文还有配套的精品资源,点击获取