☰
Python电影数据可视化系统拆解:数据清洗到图表实战
2026/10/10 19:59:15 网站建设 项目流程

简介:一份面向Python课程设计与期末大作业的电影数据可视化分析系统源码包,适合计算机相关专业学生、数据分析入门者,可作为课程实践、答辩参考和二次开发蓝本。资源共8个文件,含4个Python脚本,分别实现豆瓣电影数据爬取、词云生成、Flask Web可视化与辅助功能;另有2个zip打包模板、1个SQLite数据库(douban.db)和1个Markdown说明文档,压缩包总大小3.5MB,短小轻量便于快速部署。包内数据与代码分层明确,crawler.py负责采集,WordCloud.py生成词云,app.py驱动Web展示,形成从数据获取、存储到可视化呈现的完整链路,适合对照学习项目结构设计与数据处理流程。已有2565人浏览学习,内容覆盖爬虫、数据库、Flask与词云等热点技术,能帮助读者快速理解电影数据可视化项目的实现思路,尤其适合期末答辩演示与个人作品积累。

1. 电影数据可视化分析系统:这个 Python 源码包拆开看是什么

「基于Python的电影数据可视化分析系统源码.zip」——这类标题在课设、毕设和作品集里出镜率极高,本质是一条非常标准的数据分析落地链路:把电影数据抓下来,清洗成结构化表格,再用图表把评分、年份、类型分布讲清楚。我见过不少人拿到这类源码包后第一件事就是冲进 README,然后卡在环境上:Python 版本混用、pyecharts 与 matplotlib 的 API 记混、读 CSV 报编码错、爬虫被对方站点拒绝。这篇笔记不按“介绍项目”的套路写,直接按取数、清洗、可视化的顺序,把每个环节的命令、参数和坑拆开讲。适合正在做课设、想练 Python 数据分析、或者想快速搭一个电影数据看板的读者。

2. 先跑通最小链路:数据装载、pandas 清洗与环境依赖

拿到任何源码包,第一步不是看代码逻辑,而是先把一条最小链路跑通:数据能读进来、清洗不报错、能输出一张图。这一步过了,后面所有功能都有地方挂靠。

2.1 数据从哪来:源码包里的 CSV、公开数据集与 API 三条路

先解压压缩包,看看有没有data/、dataset/目录或者.csv文件。常见的课设源码包一般会带一份电影数据,文件名可能是movie_data.csv或films.csv,里面通常已经包含标题、年份、评分、评价人数、类型这些字段。如果你手上的包没带数据,那就只能另找来源,常见做法有三条:

第一条是直接抓豆瓣 Top250 这类公开列表页,简单直接,但反爬压力越来越大,适合演示用。第二条是去公开数据集平台找电影元数据,字段全、数据量大,但列名常常和你源码里的代码对不上,需要花时间做字段映射。第三条是用 TMDB 之类的开放 API,数据结构规整,但要申请密钥,而且有调用频率限制。

对刚入门的人来说,我最推荐先用 CSV 兜底。原因很现实:爬虫和 API 都是不确定因素,接口失败或不稳定时,你没法判断到底是自己的代码问题还是对方站点的问题。而 CSV 是确定性的,你只需要关心读入和清洗。等 CSV 链路跑通了,再回头把爬虫接进来替换数据源,整个系统的鲁棒性会好很多。另外,注意 CSV 的编码,很多下载的数据是 GBK 或 UTF-8,后面避坑章节会专门讲。

2.2 最小可运行脚本:CSV 装载与清洗的 pandas 骨架

先看清楚数据长什么样,再动手清洗。下面这个脚本可以当作整个系统的入口骨架,我会在项目里把它命名为load_clean.py,后续所有图表都从它产出的df开始。

import pandas as pd # 1. 读入原始 CSV。encoding 先按 utf-8 试,失败再换 gbk # engine="python" 能规避部分分隔符/引号导致的解析问题 df = pd.read_csv("movie_data.csv", encoding="utf-8", engine="python") # 2. 打印形状和列名,确认数据真实存在 print(df.shape) print(df.columns.tolist()) # 3. 丢掉整行全为空的数据,这类“空行”最常见 df = df.dropna(how="all") # 4. 数值列从字符串转成数值。errors="coerce" 表示转不了就置为 NaN for col in ["rating", "votes"]: df[col] = pd.to_numeric(df[col], errors="coerce") # 5. 只保留后面要用的列,减少内存占用和列名噪音 keep_cols = ["title", "year", "genre", "rating", "votes"] df = df[keep_cols].dropna(subset=["title", "year"]) # 6. 年份列处理成整数,明显异常的值直接过滤掉 df["year"] = pd.to_numeric(df["year"], errors="coerce") df = df[df["year"].between(1900, 2026)] df["year"] = df["year"].astype(int) print(df.head()) print(df.dtypes)

这段代码看着短,但每个参数都是踩过坑换来的。encoding="utf-8"是默认选择,但很多电影数据是从 Excel 导出的,实际编码可能是 GBK,如果读入报UnicodeDecodeError,就把这里换成encoding="gbk"再试。engine="python"不是必须的,但它能在文件里有不规则引号或分隔符时减少解析错误。

to_numeric配合errors="coerce"是做数据清洗时最常用的组合:它会把“7.5”这种字符串转成浮点数,把“暂无评分”这种脏数据变成NaN,而不是直接让程序崩溃。dropna(subset=["title", "year"])只检查关键列,这样不会把“有标题但没类型”的行误删。between(1900, 2026)是个简单粗暴但有效的年份过滤,能直接把明显异常的脏数据清掉。

提示:如果你在 Windows 上双击运行脚本,建议在文件末尾加一行input("按回车退出"),否则窗口一闪而过,你根本看不清报错信息。

2.3 环境依赖锁定:Python 3.8 与 pandas、pyecharts、matplotlib 的版本匹配

这个系统的依赖不少,常见的有 pandas、matplotlib、pyecharts、requests、flask。网上很多源码包 README 里只写了pip install -r requirements.txt,但 requirements 里的版本号往往已经过期。我一般建议用虚拟环境,把 Python 3.8 作为基线版本,因为 3.8 对 pandas 1.x、pyecharts 2.x 的兼容性最稳,而且不会遇到 3.12 下某些旧版本扩展库装不上的问题。

python -m venv venv # Windows 下激活虚拟环境 venv\Scripts\activate # macOS / Linux 下用下面这行 # source venv/bin/activate pip install pandas==1.5.3 matplotlib==3.7.1 pyecharts==2.0.3 flask==2.3.2 requests==2.31.0

这里锁定版本是有原因的。pandas 2.x 改了一些索引和类型推断行为,老代码在新版本上偶尔会出现诡异警告;pyecharts 1.x 和 2.x 的 API 差异非常大,1.x 用add()方法,2.x 改成add_xaxis()和add_yaxis(),如果你照着网上的老教程写,代码会在 2.x 下直接报错。matplotlib 3.7 和 3.8 的字体管理也有细微差别,锁 3.7.1 能减少中文字体问题。如果你需要 numpy,直接pip install numpy即可,它会自动匹配当前 Python 版本。

等依赖装完,跑一遍 2.2 节的清洗脚本,如果能正常打印出df.head(),说明最小链路已经通了,后面可以放心地往上加爬虫和图表。

3. 接上爬虫取数:JSON 接口抓取、字段映射与存储

CSV 链路跑通后,如果不想永远依赖别人给的数据,就得把爬虫接进来。电影站点的数据获取,我的经验是:优先找 JSON 接口,而不是去解析 HTML 页面,这里面的效率差距和踩坑概率差一个数量级。

3.1 为什么优先找 JSON 接口而不是解析 HTML

很多电影网站的前端页面背后都有对应的 JSON 接口,比如搜索接口、榜单接口、详情接口。用requests直接请求这些接口,返回的是结构化数据,字段清清楚楚,转成 DataFrame 非常快。而解析 HTML 需要用正则或 XPath 去抠元素,页面结构只要一改版,你的解析代码就立刻失效,而且电影页面里的广告、推荐位、动态渲染内容都会干扰解析结果。

判断一个接口是不是 JSON,最简单的方法是在浏览器里打开开发者工具的网络面板,刷新页面,看 XHR 类型的请求,凡是返回application/json的都可以直接请求。找到接口后,先看它的请求参数和返回结构,再动手写爬虫脚本。对电影类站点来说,常见的分页参数是start和count,对应偏移量和每页条数;常见返回结构是subjects数组,里面每一项包含标题、年份、评分等字段。这个特征在多个电影站点的接口里都很一致。

3.2 带重试与限速的抓取脚本:headers、timeout、退避参数

爬虫部分不能只写一个requests.get()就完事,否则跑不了几个请求就会被对方站点识别。我一般会封装一个带重试和退避的请求函数,核心是让程序在失败时“慢下来”,而不是疯狂重试。下面是一个可以直接改来用的脚本骨架:

import time import random import requests import pandas as pd # 用 Session 复用 TCP 连接,避免每次请求都重新握手 session = requests.Session() session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36", "Accept": "application/json, text/plain, */*", "Accept-Language": "zh-CN,zh;q=0.9", }) # API_BASE 是示例地址,换成你实际抓到的 JSON 接口地址 API_BASE = "https://api.example.com/movie/top250" def fetch_json(url, retries=3, timeout=10): for attempt in range(retries): try: resp = session.get(url, timeout=timeout) if resp.status_code == 200: return resp.json() # 418 / 403 说明被识别为爬虫,退避后重试 print(f"HTTP {resp.status_code}, 第 {attempt + 1} 次重试") time.sleep(random.uniform(2, 5) * (attempt + 1)) except requests.RequestException as e: print(f"请求异常: {e}") time.sleep(3) return None rows = [] for page in range(0, 100, 20): url = f"{API_BASE}?start={page}&count=20" data = fetch_json(url) if not data: break for item in data.get("subjects", []): rating = item.get("rating") or {} rows.append({ "title": item.get("title"), "year": item.get("year"), "rating": rating.get("value"), "votes": rating.get("count"), }) # 每页之间随机停顿,别让对方一眼看出是脚本 time.sleep(random.uniform(1, 2)) df = pd.DataFrame(rows) df.to_csv("movie_raw.csv", index=False, encoding="utf-8-sig") print(df.head())

这里几个参数值得解释。timeout=10是必须的,没有超时限制的请求可能一直挂在那,整个脚本就卡死了。retries=3只重试 3 次,并且每次重试的等待时间按random.uniform(2, 5) * (attempt + 1)递增,这叫指数退避,目的是让封禁风险随着重试次数增加而降低。time.sleep(random.uniform(1, 2))是页面级限速,别小看这一秒,很多反爬封禁都是因为请求频率太高。

Session对象的作用是复用底层的 TCP 连接,同时统一维护 headers,不用在每个请求里重复写。最后输出 CSV 时我用encoding="utf-8-sig",这个编码格式会在文件头部加一个 BOM 标记,Windows 上的 Excel 打开后不会乱码。如果你不需要用 Excel 看数据,用普通的utf-8也行。

3.3 嵌套 JSON 转 DataFrame:字段路径映射与类型修正

从接口拿回来的 JSON 通常是嵌套结构,比如评分是一个对象,里面还挂着value和count。直接把整个 JSON 塞进 DataFrame 只会得到一堆字典对象,后续统计根本没法做。我一般会先把嵌套字段拍平,手动建立字段映射关系,再交给 pandas。

接口 JSON 路径DataFrame 列处理后类型说明
subjects[].titletitlestr电影名,保留原样
subjects[].yearyearint用pd.to_numeric转整型
subjects[].rating.valueratingfloat平均评分,可能缺失
subjects[].rating.countvotesint评价人数,排序时会用到

字段映射看起来简单,但有一个高频报错点:item.get("rating")可能返回None,如果你直接.get("value")就会报AttributeError。所以我在代码里先做了rating = item.get("rating") or {}的兜底,把空值替换成空字典,这样后续访问就不会崩。这是一个很小的细节,但能避免爬虫跑到一半突然中断。

字段拍平后,必须做类型修正。接口里year可能是字符串,votes可能是字符串,如果不转,后面df.sort_values("votes")会按字典序排,结果完全错误。处理方式和第 2 章一样,用pd.to_numeric(..., errors="coerce"),把脏数据置为NaN,分析时再用dropna或fillna处理。存储时我建议保留一份原始 JSON 或 CSV,方便后续重新清洗,不用再爬一次。

4. 图表层做出演示效果:pyecharts 与 matplotlib 的选型与必调参数

数据洗好之后,可视化这步直接决定你整个系统的观感。很多人交上去的作品,图表一看就是 “默认样式全家桶”,配色丑、坐标轴不解释、鼠标悬停没有反馈。这章我按实际项目里的分工来讲:什么时候用 matplotlib,什么时候用 pyecharts,以及几个必调的参数。

4.1 pyecharts 和 matplotlib 怎么分工:交互看板 vs 静态论文图

matplotlib 和 pyecharts 不冲突,它们解决的问题不一样。matplotlib 适合出静态图,保存成 PNG 放进论文、实验报告里,样式稳定,不依赖网络;pyecharts 包装的是 ECharts,输出 HTML 文件,图表可以缩放、悬停提示、联动,做演示和看板非常出效果。如果你是交课设,建议两个都用:静态图放文档,交互图放进 Flask Web 页面。

对比点matplotlibpyecharts
输出形式PNG / SVG 静态图片HTML 网页,可交互
适合场景论文插图、报告截图演示、数据看板、大屏
中文支持需手动配置字体浏览器渲染,依赖 echarts.js
学习成本低,自由度大中,配置项多但套路固定

如果你的目标是“企业级数据可视化”,真正该用的是 BI 工具或者直接上 ECharts 大屏,而不是在这套系统里死磕图表特效。这套 Python 系统的价值在数据链路本身,而不是视觉效果。所以我在实际做的时候,只保留三种图表类型:分布直方图、趋势折线图、占比饼图,够用且不容易出错。

4.2 评分分布直方图与年份趋势折线图的落地代码

评分分布用 matplotlib,年份趋势用 pyecharts,这正好能把两种工具的用法都覆盖到。先看评分分布:

import matplotlib import matplotlib.pyplot as plt # 中文字体配置,具体说明见 4.3 matplotlib.rcParams["font.sans-serif"] = [ "Microsoft YaHei", "SimHei", "PingFang SC", "Noto Sans CJK SC" ] matplotlib.rcParams["axes.unicode_minus"] = False fig, ax = plt.subplots(figsize=(10, 5)) ax.hist(df["rating"].dropna(), bins=20, edgecolor="black", alpha=0.75) ax.set_title("电影评分分布直方图") ax.set_xlabel("评分") ax.set_ylabel("影片数量") plt.tight_layout() plt.savefig("rating_dist.png", dpi=150)

bins=20控制直方图的分箱数,数据量为几百条时 20 箱能看出分布形状,数据量更大可以试着加 to 30 或 40。alpha=0.75是透明度,柱状图重叠时能看出层次。dpi=150决定输出图片清晰度,论文插图 150 够用,如果要投印刷级再调到 300。tight_layout()是为了避免标题和坐标轴文字被裁掉。

再看年份趋势折线图,用 pyecharts 输出交互式 HTML:

from pyecharts.charts import Line import pyecharts.options as opts # 按年份聚合:平均评分 + 影片数量 year_stats = df.groupby("year").agg( avg_rating=("rating", "mean"), movie_count=("title", "count"), ).reset_index().sort_values("year") line = ( Line() .add_xaxis(year_stats["year"].astype(str).tolist()) .add_yaxis( "平均评分", year_stats["avg_rating"].round(2).tolist(), is_smooth=True, ) .set_global_opts( title_opts=opts.TitleOpts(title="电影年份趋势:平均评分与产量变化"), xaxis_opts=opts.AxisOpts(type_="category", name="年份"), yaxis_opts=opts.AxisOpts( type_="value", name="平均评分", min_=0, max_=10, splitline_opts=opts.SplitLineOpts(is_show=True), ), tooltip_opts=opts.TooltipOpts(trigger="axis"), ) ) line.render("templates/charts/year_trend_chart.html")

pyecharts 2.x 的写法就是链式调用:先建Line(),然后add_xaxis、add_yaxis、set_global_opts一步步拼。is_smooth=True把折线变成平滑曲线,视觉上更舒服。yaxis_opts里的min_=0, max_=10是强制评分轴范围,否则 pyecharts 默认会根据数据自动缩放,评分范围 7 到 9 就会把细微波动放大,看起来像过山车。tooltip_opts里trigger="axis"表示鼠标移动时按整条轴显示所有数据点,比默认的item触发更好用。

提示:pyecharts 渲染时会生成完整的 HTML 文件,包含引用的 echarts.js 资源。如果你把 HTML 文件发给别人,对方离线打开可能白屏,解决办法见 5.3 节避坑说明。

4.3 中文字体配置:Windows、macOS、Linux 三平台的处理方式

matplotlib 默认字体没有中文字形,不配置的话,所有中文标签都会变成方块。网上常见的方案是只会告诉你rcParams["font.sans-serif"] = ["SimHei"],但换到 macOS 或 Linux 服务器上又失效。我自己的做法是列一个字体回退列表:

import matplotlib from matplotlib import font_manager # 如果项目里有 fonts/ 目录,直接加载本地字体文件 # 这样即使系统没装对应字体也不会乱码 font_manager.fontManager.addfont("fonts/NotoSansCJK-Regular.ttc") matplotlib.rcParams["font.sans-serif"] = [ "Noto Sans CJK SC", "Microsoft YaHei", "SimHei", "PingFang SC" ] matplotlib.rcParams["axes.unicode_minus"] = False

Windows 上通常有SimHei或Microsoft YaHei,macOS 上常见PingFang SC,Linux 服务器上一般需要安装Noto Sans CJK SC。把字体文件名按顺序列在列表里,matplotlib 会从左到右找第一个可用的。axes.unicode_minus = False必须设置,否则坐标轴负号会显示成方框。

如果你的代码要部署到没装字体的服务器上,最稳妥的做法是下载一个开源中文字体文件放进项目目录的fonts/文件夹,用font_manager.addfont()加载。这样你的图在任何机器上生成都是同样的效果,不会因为换机器就变 “方块图”。

5. 跑这套系统的五个高频坑:编码、反爬、类型与渲染排查

这套系统看起来功能不多,但会把 Python 数据处理里最常见的几类问题全踩一遍。我按现象到原因到解决的顺序,整理五个高频翻车点,基本覆盖你跑源码包里前几个小时的报错。

5.1 请求返回 418/403:被反爬识别后的重试与降速

现象:爬虫脚本运行时,requests.get()返回 418 或 403,有时候返回 200,但拿到的内容里有“访问验证”这类字样。

原因:请求头里缺User-Agent,或是请求频率太高被站点识别成脚本。418 是很多站点专门用来拒绝爬虫的状态码。

解决:用requests.Session统一维护 headers,把User-Agent、Accept-Language、Referer都补上。每页请求间隔至少 1 秒,遇到 418 采用指数退避重试。如果换了很多 headers 还是被拒,就别硬爬了,直接改用 CSV 或开放的公开数据集,省下的时间够你多调两张图。

5.2 数值列全是 object:无法做数值统计与排序

现象:df.dtypes显示rating、votes、year全是object,用df.sort_values("votes")排序后发现顺序完全不对,比如 “99” 排在 “1000” 后面。

原因:pandas 读取 CSV 时,如果某一列存在空字符串、“暂无”这类脏值,整列会被识别成字符串类型。

解决:用pd.to_numeric(series, errors="coerce")强制转换,转换失败的值自动变成NaN。转换后检查一下df["rating"].isna().sum(),确认有多少脏数据,如果比例超过 10%,说明数据源本身有问题,回源头清洗更靠谱。

5.3 pyecharts 渲染出的 HTML 是空白页

现象:line.render("output.html")执行完,浏览器打开 output.html 却是一片空白,控制台报错说某个.js文件加载失败。

原因:pyecharts 生成 HTML 时默认引用远程 ECharts 资源。如果电脑没联网、开了广告拦截,或者资源 CDN 被污染,图表就加载不出来,页面自然空白。

解决:先打开浏览器开发者工具看网络请求,确认是不是 JS 资源加载失败。如果是,就把项目所需的 echarts.js 资源下载到本地,把 HTML 里的引用路径改成相对路径;另一个绕过方式是换用 matplotlib 输出静态图,虽然没交互效果,但至少不白屏。如果 HTML 在某些电脑上正常、另一些上白屏,基本就是网络环境问题。

5.4 pandas 读取 CSV 报 UnicodeDecodeError

现象:pd.read_csv("movie_data.csv", encoding="utf-8")直接抛UnicodeDecodeError: 'utf-8' codec can't decode byte...,文件读不进来。

原因:文件的实际编码不是 UTF-8,而是 GBK 或 GB18030。很多国内网站导出的 CSV 都是 GBK 编码,用 UTF-8 解码当然报错。

解决:先改成encoding="gbk"或"gb18030"再读。如果你拿到的数据是无 BOM 的 UTF-8,也可以先尝试"utf-8-sig"。如果不想每次手动试,可以用一个简单兜底逻辑:先按 UTF-8 读,抛异常后自动换 GBK 重试。同时,以后写 CSV 时统一用encoding="utf-8-sig",这样 Excel 能正常打开,其他 Python 程序读起来也方便。

5.5 Flask 启动时端口被占用

现象:启动 Flask 项目时终端报OSError: [Errno 98] Address already in use,或者 Windows 上提示端口被占用。

原因:上一次的 Flask 进程没退出,或者 5000/8000 端口被其他程序占用。调试模式开启时,Flask 会自动重载代码,有时旧的子进程没有完全释放。

解决:先换一个端口跑,比如app.run(port=8001),这是最快的处理方式。如果想要根治,Windows 下用netstat -ano | findstr :8000找到对应 PID,再用taskkill /PID xxxx /F结束进程;macOS/Linux 下用lsof -i :8000定位进程。另外,开发时记得把debug=True关掉再部署,调试模式下监听的机制在部分系统上会放大这个问题。

6. 把静态图表升级成可用系统:Flask 动态渲染与数据更新

图表单独渲染成 HTML 文件,只能算“半成品”。让别人通过浏览器访问一个地址、看到最新数据,才是这套系统真正变成“系统”的那一步。这一步不需要做多复杂,用 Flask 把两张图托管起来,再配一个数据更新脚本就够了。

import os from flask import Flask, send_from_directory app = Flask(__name__) # 约定:所有图表 HTML 都输出到 templates/charts/ 目录 CHART_DIR = os.path.join(os.path.dirname(__file__), "templates", "charts") @app.route("/chart/<chart_name>") def show_chart(chart_name): # 只允许访问 _chart.html 结尾的文件,防止路径穿越 if not chart_name.endswith("_chart.html"): return "not found", 404 return send_from_directory(CHART_DIR, chart_name) @app.route("/") def index(): return "<a href='/chart/year_trend_chart.html'>查看年份趋势</a>" if __name__ == "__main__": app.run(host="0.0.0.0", port=8000, debug=True)

这个路由设计里有两个关键点:一是把图表文件统一放在templates/charts/目录,让数据文件和页面文件分离;二是用endswith("_chart.html")做了文件名校验,避免别人通过路径穿越读取服务器上的任意文件。send_from_directory本身也会做路径安全检查,双保险更稳。

数据更新方面,我习惯把“抓取数据”和“生成图表”拆成两个脚本。每次更新时先跑爬虫生成新的movie_raw.csv,再跑清洗和图表脚本覆盖旧的 HTML 文件,最后刷新浏览器。这样数据更新和页面展示完全解耦,哪天爬虫挂了,你至少还能用旧数据生成图表。验证方法很简单:浏览器打开http://localhost:8000/chart/year_trend_chart.html,能看到图表且鼠标悬停有数据提示,整条链路就是通的。

我做这类系统有个改不掉的习惯:所有输出文件的路径都用相对路径,并且以脚本所在目录为基准来拼,很少直接用 “/root” 这种绝对路径。因为代码今天在自己电脑上跑,明天就可能丢到服务器或别人机器上,路径一写死,换个环境就是一片红色报错。这个习惯帮我省了不知道多少排错时间。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询