ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python电影数据可视化分析:从爬虫到交互看板的完整实战

Python电影数据可视化分析:从爬虫到交互看板的完整实战

简介:基于Python的电影数据可视化分析系统是一份毕业设计级完整源码包,面向计算机及数据分析专业学生,覆盖豆瓣电影数据采集、SQLite存储、Flask后端接口与ECharts/BootStrap/WordCloud可视化展示的完整链路。包体共2000个文件,以1684个Python脚本为核心,涵盖爬虫、数据处理与业务逻辑;另有124个HTML模板、93个JavaScript文件及18个CSS样式,用于搭建交互式可视化页面,11个PDF文档及若干配置与标记文件可辅助理解项目结构。压缩包约76.51MB,目前已有1018人学习下载,项目经过严格调试与高分评审,可直接运行。下载后能获得一套完整可借鉴的毕业设计源码,既可拆分学习爬虫模块与Flask路由写法,也能复用其中可视化组件,为相关电影数据分析项目提供快速改造基础。

1. 电影数据可视化分析系统:毕业设计最值得先把这三件事想清楚

豆瓣Top250这类榜单,几乎是做Python数据可视化分析最容易入手的真实数据集,可很多同学把代码跑通了,答辩却答不上来“为什么这么画”。这套基于Python的电影数据可视化分析系统,本质是一条从数据采集、清洗、存储到展示的完整流水线:抓取电影评分与票房,用Pandas做聚合统计,再用Matplotlib或Plotly把结果画成趋势图、分布图和可交互看板。它适合正在选题的应届生,想要一个工作量适中、技术栈完整、能落到答辩PPT上的项目;也适合刚学完Python基础、想拿真实数据练手的数据分析入门者。下面我按自己做过的一版方案,把每一步的参数、验证方法和踩过坑的地方讲清楚。

2. 数据从哪来:电影数据采集的三条路线与清洗流程

2.1 动手之前,先把你到底要分析什么定成字段清单

很多毕业设计做到一半推倒重来,不是因为代码写不出来,而是数据字段没想清楚就开爬。你要先确定分析目标:想看评分随年份的变化,还是看不同类型电影的评分差异,或者看票房与评分之间有没有相关性。目标不同,需要的字段完全不同。我一般会先列一张字段清单,比如片名、上映年份、类型、导演、评分、评分人数、票房、片长、地区,然后对着数据源一项项勾选。这样后面用Pandas做聚合时,不会发现自己想分析的维度根本不在数据里。

类型和票房这类字段,Top250列表页并不直接提供,常见做法是抓详情页补充,或者合并一份公开的电影元数据集,这也是为什么清洗阶段要把多来源数据拼在一起处理。如果你只想把毕业设计控制在一个月内做完,建议第一版就锁定“评分 + 年份 + 类型 + 片名”四个字段,其余等主线跑通再加。

2.2 用 Requests + BeautifulSoup 爬取豆瓣 Top250:最小可跑代码

数据源方面,拿豆瓣Top250练手最合适,因为列表固定25页、每页25条,总量确定,适合做技术验证。票房这类动态数据则可以从聚合网站的接口抓取,但第一版不用碰接口签名,直接用Requests加BeautifulSoup拿静态页面是最容易控制的方式。下面这段是我验证过的最小爬虫:

import time import requests from bs4 import BeautifulSoup headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36" } def fetch_douban_top250(): movies = [] for start in range(0, 250, 25): # 分页参数,start=0是第一页,每页25条 url = f"https://movie.douban.com/top250?start={start}" resp = requests.get(url, headers=headers, timeout=10) resp.raise_for_status() # 非200状态直接抛异常,不静默失败 soup = BeautifulSoup(resp.text, "html.parser") for item in soup.select("div.item"): title = item.select_one("span.title").get_text(strip=True) rating = item.select_one("span.rating_num").get_text(strip=True) movies.append({"title": title, "rating": float(rating)}) time.sleep(1) # 每页间隔1秒,避免请求过快 return movies

代码的核心逻辑是构造分页URL,用requests带请求头发GET请求,再用BeautifulSoup按CSS选择器定位“div.item”节点,从中提取片名和评分。参数上,headers里的User-Agent必须带,很多站点会拦裸requests;timeout设成10秒防止网络卡死;range(0, 250, 25)对应豆瓣的常规分页规律,start=0是第一页,start=225是最后一页。只想验证脚本能不能跑通,把range改成range(0, 25, 25)只抓第一页即可。time.sleep(1)是每页间隔一秒的限速策略,别人能抓不代表你能一口气抓完,有些反爬卡的是请求频率而不是请求头。

爬下来之后不要急着分析,先存成中间文件,方便反复调试。我习惯用Pandas直接转成DataFrame再to_csv落盘:

import pandas as pd data = fetch_douban_top250() df = pd.DataFrame(data) df.to_csv("douban_top250_raw.csv", index=False, encoding="utf-8-sig")

这里encoding用utf-8-sig而不是utf-8,是因为后面用Excel打开CSV时,utf-8不带BOM会导致中文列名乱码,这是踩过坑之后的习惯写法。index=False则避免把行号写进文件里,读取时不会多出一列无用数据。

2.3 数据清洗:缺失值、重复项与字符串格式统一

Top250的数据相对规整,但真实场景里,无论是自己爬的电影网站还是公开数据集下载的CSV,往往乱得一塌糊涂。清洗阶段的三个固定动作是去重、补缺失、统一类型。

import pandas as pd df = pd.read_csv("douban_top250_raw.csv") # 1. 去重:按片名和评分双字段判断,避免同名不同片被误删 dup_mask = df.duplicated(subset=["title", "rating"], keep=False) print("重复记录数:", dup_mask.sum()) # 2. 缺失值:先看每列缺多少,再决定填充还是删除 print(df.isna().sum()) df = df.dropna(subset=["title", "rating"]) # 关键字段缺失直接删 # 3. 类型统一:评分转float,上映年份从字符串中提取数字 df["rating"] = df["rating"].astype(float) df["year"] = df["title"].str.extract(r"\((\d{4})\)").astype("Int64") df.head()

duplicated加keep=False会标出所有重复行,打印出来确认是不是同一部电影的不同条目;dropna只针对关键字段,如果只是片长缺失,直接删整行反而丢失信息;year字段用正则从片名里提取四位年份,比如“肖申克的救赎 (1994)”会提取出1994。这里用Int64而不是int,是为了让缺失值保留为NA而不是变成0,后边聚合时能直接发现异常。

读取原始CSV时,编码也可能不统一。你用utf-8-sig写出来的文件,Pandas读的时候同样写encoding="utf-8-sig"即可;但如果是老网站导出的数据,可能是gbk编码,读取要改成encoding="gbk",或者用errors="ignore"把非法字符过滤掉。报错时只会告诉你UnicodeDecodeError,不会告诉你是哪个编码,所以拿到文件先看前几行是什么字符,这是我早期最常翻车的地方。每做完一步清洗,打印一遍df.info()确认字段类型,再往下一步。

3. 用 Pandas 做聚合统计:DataFrame 操作是分析的地基

3.1 从 CSV 载入数据,先看字段类型和分布

数据清洗完存成CSV,分析阶段第一步永远是“体检”。我每次都用df.info()和df.describe()两个方法开头,前者看有没有空值和字段类型,后者看数值列的分布是否正常。

import pandas as pd df = pd.read_csv("douban_top250_clean.csv", encoding="utf-8-sig") df.info() print(df.describe()) print(df["year"].value_counts().sort_index().tail(10))

这段代码的输出能帮你快速发现三类问题:一是rating列有没有超过10分的脏数据,二是year列有没有提取失败的NaN,三是样本在时间轴上是否均匀。value_counts().sort_index()按年份统计电影数量,如果某年份出现0部,后面的趋势图就会断档,分析时要特别说明是数据源覆盖问题还是真实缺失。

我特别推荐把“清洗后的中间表”和“原始数据”分开存,比如douban_top250_raw.csv和douban_top250_clean.csv。因为清洗逻辑如果写错了,可以直接对比两个文件定位是哪一步把数据改坏,而不是重新爬一遍。这是血泪经验,我大学时修改清洗规则时直接覆盖了原始文件,想回溯根本没有后悔药。

3.2 分组聚合:按年份、类型、评分区间构造分析表

接下来是聚合统计。整个可视化分析系统的核心分析表通常有三张:按年份的评分均值与数量、按类型的评分均值与数量、按评分区间的分布。年份和类型这样写:

# 按年份聚合 year_stats = ( df.groupby("year") .agg( 影片数=("title", "count"), 平均评分=("rating", "mean"), 最高评分=("rating", "max"), ) .round(2) ) # 按类型聚合,genres字段来自详情页抓取或公开数据集 type_stats = ( df.explode("genres") .groupby("genres") .agg(影片数=("title", "count"), 平均评分=("rating", "mean")) .sort_values("影片数", ascending=False) )

groupby后接agg是Pandas管道操作的核心写法,agg里用元组语法(列名, 聚合函数)构造命名列,比直接mean()再rename要清爽。explode会把“剧情, 爱情”这种列表字符串拆成一列多行,让一部电影归属到每个类型下统计。注意explode之前要确认genres字段已经是列表,如果是字符串,先df["genres"] = df["genres"].str.split(",")转成列表。

聚合字段与用途对应关系如下:

聚合维度使用的列聚合函数输出用途
年份yearcount/mean/max趋势图
类型genrescount/mean横向对比条形图
评分区间ratingcut/count直方图数据

聚合之后默认的index是年份或类型,绘图前要reset_index(),否则Pandas绘图时会把index当作x轴,列名丢失后图例容易错乱。如果你拿到的数据源里类型字段是管道符“|”分隔而不是逗号,split的时候要跟着改;更麻烦的情况是类型字段本身是NULL,explode会直接报错,稳妥做法是先df["genres"] = df["genres"].fillna("未知")再切分。

3.3 把清洗后的数据存进 SQLite,方便复查和二次分析

数据全部处理规整后,不要只留在内存里。毕业设计答辩时经常被问“你的系统是怎么组织的”,这时候能掏出一个数据库文件,说明你有数据建模意识。SQLite是Python标准库自带的选择,零配置,一个文件搞定。

import sqlite3 conn = sqlite3.connect("movies.db") df.to_sql("movies", conn, if_exists="replace", index=False) year_stats.to_sql("year_stats", conn, if_exists="replace") type_stats.to_sql("type_stats", conn, if_exists="replace") conn.close()

to_sql的第一个参数是表名,if_exists="replace"表示表已存在就删掉重建,适合反复跑分析脚本的场景;index=False针对原始数据表,避免把行号存进去。聚合表自带年份或类型作为index,所以这里保留index。之后复查数据时,用pd.read_sql就能拿到子集,比重新读CSV再过滤高效得多:

conn = sqlite3.connect("movies.db") high_rated = pd.read_sql( """ SELECT title, year, rating FROM movies WHERE rating >= 9.0 ORDER BY rating DESC """, conn, ) conn.close() high_rated.head()

这段SQL查出了评分9.0以上的影片,用来快速验证前面清洗和聚合逻辑是否正确。把数据库文件、CSV和分析脚本分目录存放,答辩演示时打开脚本就能一步跑通,而不是靠临时操作,这在答辩现场能省掉很多尴尬。

4. 用 Matplotlib 和 Plotly 画图:把分析结果画成能答辩的图

4.1 中文字体配置:出图前先解决“豆腐块”

Matplotlib默认字体不含中文,plt.title("年份趋势")显示出来全是方框,这是电影数据可视化项目里最高频的问题。解法固定,先配置rcParams:

import matplotlib.pyplot as plt import matplotlib matplotlib.rcParams["font.sans-serif"] = ["Microsoft YaHei", "SimHei", "PingFang SC"] matplotlib.rcParams["axes.unicode_minus"] = False # 负号显示为方块的问题

上面按Windows和macOS环境列了候选字体,Matplotlib会按顺序选第一个存在的。axes.unicode_minus必须设成False,否则坐标轴负号“-”也会变成方块,这个问题即使字体设对了也照样出现,很多人查半天都想不到。如果你用的是Linux服务器,需要先fc-list :lang=zh确认系统中文字体存在,没有就手动装一个,然后删掉~/.cache/matplotlib缓存再重跑。

4.2 画评分分布直方图与年份趋势图:两张图讲清数据全貌

基础图有两张必画。第一张是评分分布直方图,用于验证整体数据形态:

import matplotlib.pyplot as plt fig, ax = plt.subplots(figsize=(10, 5)) ax.hist( df["rating"], bins=16, range=(0, 10), color="#4C72B0", edgecolor="white", alpha=0.85 ) ax.set_title("Top250电影评分分布") ax.set_xlabel("评分") ax.set_ylabel("影片数量") ax.grid(axis="y", linestyle="--", alpha=0.5) plt.tight_layout() plt.savefig("rating_dist.png", dpi=200) plt.show()

bins设为16是把0到10分成16个区间,每个区间宽度0.625,适合观察集中在7到9分的分布形态;range=(0,10)把横轴固定,避免Matplotlib自动缩放把集中在高分的区域拉伸得过于“平坦”,这一条很容易忽略。alpha用于半透明,防止柱状图之间互相遮挡。图存成PNG时dpi给到200,答辩投屏或者插到论文里都够清晰。

第二张是年份趋势折线图,展示各年评分均值的变化。由于前面聚合好的year_stats已经包含平均评分和影片数,直接画就行,但影片数太少的年份会造成噪音。我一般先过滤出影片数不少于3的年份:

trend = year_stats[year_stats["影片数"] >= 3] fig, ax = plt.subplots(figsize=(12, 5)) ax.plot(trend.index, trend["平均评分"], marker="o", markersize=4, linewidth=1.5) ax.set_title("各年份平均评分趋势(样本数≥3)") ax.set_ylabel("平均评分") ax.set_xlabel("年份") plt.tight_layout() plt.savefig("year_trend.png", dpi=200)

过滤条件“影片数大于等于3”是为了避免个别年度只有一部高分片导致趋势线剧烈抖动,看起来像数据失真。答题的时候可以主动解释这一点,老师会觉得你对数据质量有意识。用df.plot(kind="hist")自动画图确实快,但答辩被问到“怎么调整坐标轴范围”就不好接,所以上面统一用面向对象接口,每个可调对象都显式暴露出来,改起来清楚。

4.3 用 Plotly 做可交互图表,嵌入 HTML 在答辩时演示

如果只交静态PNG,毕业设计只能算“做了图表”,离“可视化分析系统”还有距离。系统的卖点是交互:能点击图例、有悬浮提示、能框选缩放。Plotly是Python里改造成本最低的交互方案,参数和Matplotlib大致对应。

import plotly.express as px fig = px.scatter( df, x="year", y="rating", color="rating", hover_data=["title", "评分人数"], title="电影年份与评分分布", labels={"year": "上映年份", "rating": "豆瓣评分"}, ) fig.write_html("movies_scatter.html") fig.show()

px.scatter一行代码就有交互能力,hover_data会在鼠标悬浮时显示片名和评分人数;color参数绑定rating,颜色从蓝到红渐变,用来快速定位高分区域。write_html生成单个自包含的HTML文件,答辩现场不需要装Python环境,浏览器打开就能演示,这是交互可视化最省事的交付形态。

如果不满足于散点图,plotly.graph_objects的写法可以做更精细的控制,比如加范围滑块:

import plotly.graph_objects as go fig = go.Figure( go.Scatter( x=trend.index, y=trend["平均评分"], mode="lines+markers", name="平均评分" ) ) fig.update_layout( xaxis=dict(rangeslider=dict(visible=True), type="date"), yaxis=dict(range=[7, 9.5]), title="年份趋势交互图(可拖动缩放)" ) fig.write_html("year_trend_interactive.html")

go.Scatter构造折线图,mode参数里lines+markers表示线点一起画;update_layout里xaxis加了rangeslider,页面下方会出现一个可拖动的迷你条,用来缩放年份范围,这是论文里展示交互性的经典功能。type="date"告诉Plotly横轴是日期类型,否则年份会按普通字符串排列。

5. 避坑指南:电影数据可视化项目最常见的 5 个踩坑现场

5.1 爬虫返回空列表:不一定是反爬,更可能是页面结构换了

现象:代码在别人那里能跑,到自己这里输出空列表,而且resp.status_code仍然是200。

原因:页面结构改版,原来定位的div.item节点已经不存在,或者请求返回的是一个验证页面。豆瓣这类动态页面偶尔会调整class命名,教程里的选择器过几个月就失效。

解决:先把resp.text[:500]打印出来,确认拿到的是页面HTML还是验证跳转页;再用浏览器开发者工具重新复制CSS选择器,核对div.item这类路径是否还存在于页面里。不要反复改header参数,先确认自己能拿到正确HTML,这是排查顺序的问题。

5.2 中文乱码:一个编码问题,出现在三个位置

现象:CSV用Excel打开乱码,Pandas读取时报UnicodeDecodeError,图表标题显示成方框。

原因:写入时用utf-8,读取时用gbk,或者读取时忘了传encoding参数。Windows环境下Pandas默认编码跟系统区域相关,容易踩中gbk与utf-8不一致的问题。

解决:写入统一用utf-8-sig,读取时显式传encoding="utf-8-sig";如果数据源来自第三方CSV,先用文本编辑器打开看编码再决定。SQLite内部存的就是UTF-8,所以乱码大多发生在读写CSV这一层,不要怀疑数据库。

5.3 Matplotlib 中文字体显示为方框:字体缓存是黑匣子

现象:按照教程设置了font.sans-serif,重新运行脚本,中文还是方框,怎么改都不生效。

原因:字体名称写错了,或者系统中没有对应字体文件,又或者是Matplotlib字体缓存损坏。这个坑最玄学,有时候配置完全正确,但因为缓存了旧字体列表,新配置不生效。

解决:用fc-list :lang=zh查看系统实际可用的中文字体名,把rcParams里的名字改成系统里真实存在的;确认无误后删除~/.cache/matplotlib目录下的缓存文件,或者用matplotlib.font_manager._rebuild()强制重建缓存。按这个顺序排查,基本一分钟内解决。

5.4 年份聚合出现NaN组:正则提取后类型没转对

现象:df.groupby("year")之后,结果里多出一个NaN分组,画趋势图时横坐标出现一个空白断点。

原因:用str.extract提取的年份是字符串类型,缺失值也没有正确处理。groupby会把NaN当成一个独立分组,聚合结果里就混入了年份缺失的样本。

解决:提取后先用astype("Int64")转成可空整数类型,再dropna(subset=["year"])删掉缺失行。转换要放在groupby之前,顺序错了就会复现这个坑。用Int64而不是int,既能保留NA又不会把缺失值变成0,避免0年这种脏数据混进图表。

5.5 评分分布全挤在8到9分:不是数据出错,是数据源偏了

现象:直方图画出来所有柱子都挤在8到9分之间,看起来没有区分度。

原因:Top250本身是高分榜单,样本区间窄,属于数据源的天然特征,不是清洗逻辑写错了。如果直接按0到10等宽画图,视觉上会显得信息量很小。

解决:在图表标题里注明“数据来源为Top250高分榜”,同时把bins调小到10个区间,或者改用累计分布曲线展示相对排名。答辩时主动说明样本偏高端,反而能体现你对数据分布的判断。

6. 进阶技巧:用 Streamlit 把静态图升级成可筛选的电影分析看板

到这里,静态图表已经能支撑起一份答辩材料,但如果想让系统观感更完整,我会推荐最后一步:把前面所有分析包进一个Streamlit应用。Streamlit不需要写前端,用纯Python脚本就能生成带筛选器的交互页面。

import streamlit as st import pandas as pd df = pd.read_csv("douban_top250_clean.csv", encoding="utf-8-sig") st.title("电影数据可视化分析") min_rating = st.slider("最低评分", 0.0, 10.0, 7.0) recent = st.checkbox("只看最近十年") display_df = df[df["rating"] >= min_rating] if recent: display_df = display_df[display_df["year"] >= 2015] st.dataframe(display_df) st.bar_chart(display_df.groupby("year")["rating"].mean())

运行命令是streamlit run app.py,浏览器会自动打开本机端口。第一次跑通后,可以继续加一个selectbox选类型、一个multiselect筛年份、一个download_button导出当前筛选结果。到这里,你的系统不再是一个死板的图表文件夹,而是一个能当场回答导师问题的分析工具。

关于验证,不要只靠眼睛看图。我交系统前会做一次数据核对:对同一份数据,用Excel的数据透视表重算年份均值,与Pandas聚合结果对比,误差在0.01以内才算通过。这个习惯能帮你把隐藏的groupby错误、重复统计问题全揪出来。

以前我自己做过一版只重视“画图好看”的系统,答辩时被老师问“怎么验证聚合结果”,当场答不上来。后来我养成一个习惯:每个图表都保存对应的中间数据表,谁想看数据都能倒回去。这套流程看起来简单,但每一步确实能落地、能被追问。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进