第一次拿到这套历届奥运会的比赛记录,是在一次课程设计的选题阶段。印象很深的不是数据量有多大,而是这份数据自带的那种“叙事感”——跨越百余年的运动员、代表团、项目设置、奖牌变化,全部挤在一张扁平的表格里。把这张表讲成能让人看懂的故事,就是我当时定下做可视化分析系统的原因。
这个项目的完整标题是“基于Python大数据的历届奥运会数据可视化分析系统”,说白了做的事情就是三件:把几十万条历史比赛记录洗干净,用Pandas拆出背后规律,再用图表把规律讲出来。它适合正在找数据分析练手项目的人、准备课程设计答辩的在校生,也想看看自己在“数据处理+可视化”这条技能链上到底有几斤几两的开发者。今天这篇就完整复盘一遍我的实现过程,包括数据结构、代码细节、图表选型、踩过的坑,你看完可以直接照着自己的节奏复刻一版。
1. 项目立意与技术路线
1.1 为什么拿奥运会数据练手
数据分析入门最怕的不是不会写代码,而是找不到一份“既有意思又不太脏”的数据。电商订单数据虽然真实,但通常涉及保密;爬虫抓到的数据又要处理反爬和各种字段缺失;自己造数据又说服不了别人。奥运会历史数据几乎是天然的练习场:时间跨度长、地域覆盖面广、人的属性和比赛结果混在一起,既能做全局趋势,也能深入单项细节。
从难度上看,这份数据的清洗复杂度适中。缺失值集中在身高、体重、奖牌三列,含义各不相同,处理方式天差地别。分析侧又有天然的话题:参赛规模怎么变、奖牌格局怎么迁移、运动员的身体条件随项目怎么分化、女性参赛比例是怎么涨上来的。这些话题不需要业务背景就能理解,做出来的图表也容易让人“看得进去”。
还有一个很实在的原因:这类数据有公开的教学版本,字段设计规整,原始记录在十万行级别,单机用Pandas处理完全没压力,又能体验到“数据量大到需要优化”的感觉,卡在了一个非常适合学习和展示的粒度上。
1.2 系统目标拆解
动手之前我先把需求翻译成了五个问题,后面所有代码都围绕这些问题展开:
- 参赛规模是怎么演变的——每届的运动员人数、参赛代表团数、大项数量。
- 奖牌分布有什么规律——哪些代表团长期霸榜,东道主有没有“主场加成”。
- 运动员画像有什么特征——年龄、身高、体重在不同项目上的分布差异。
- 性别参与度的变化——一百年前和现在相比,女性参赛比例有多大变化。
- 项目设置的演进——哪些年份一口气加了大项,有没有项目逐渐消失。
不用追求“全维度全可视化”,把这些问题想清楚,系统的主线就出来了。我给自己的定位是做一个“可交互的数据分析看板”,不是堆图表的展示墙。每个分析对应一个结论,每个图表都要能支撑某个问题的回答,这样答辩或汇报时才能讲得清楚。
1.3 技术栈与选型理由
我最终用的技术栈是:Pandas + NumPy 处理数据,pyecharts 做交互图表,Streamlit 搭看板外壳。整套东西用Python一个语言串完,适合个人项目和课程展示。
选型时其实纠结过几轮。首先是图表库,Matplotlib 虽然生态稳,但默认样式偏学术,做出来的图缺少“系统感”,交互也得额外用 mpld3 之类的插件去补。Plotly 功能很全,但配置项比 pyecharts 复杂一点,而且pyecharts 基于 ECharts,内置了地图、滚动缩放、渐变配色等好看又好用的能力,中文社区资料也足够多。
框架层我考虑过两条路:Flask + 前端静态页面,或者 Streamlit。Flask 灵活但需要自己处理前后端交互、页面路由、请求参数,工作量大很多;Streamlit 是“数据脚本直接变网页”,缓存机制和组件系统都够用,写起来非常快。对“可视化分析系统”这种偏展示型项目,Streamlit 能把精力集中在分析逻辑上,而不是浪费在调前端框架上。
如果你正好有 Web 基础,想做得更“企业级”一点,用 FastAPI 提供数据接口 + Vue 写前端也没有问题,只是时长会明显拉长。先接受“用最少代码跑通闭环”,再在闭环基础上加东西,是这类项目最顺的推进方式。
2. 数据清洗与预处理
2.1 先把数据长什么样看清楚
拿到数据第一步永远是df.info()和df.head(),不要急着写聚合。我用的公开教学数据集包含运动员的历届比赛记录,每一行代表“某位运动员在某一届某个小项上的一次参赛记录”。常用字段大体如下:
| 字段 | 含义 | 示例 |
|---|---|---|
| ID | 运动员唯一编号 | 1 |
| Name | 运动员姓名 | A B |
| Sex | 性别 | M / F |
| Age | 参赛时年龄 | 24 |
| Height | 身高(厘米) | 180 |
| Weight | 体重(千克) | 80 |
| Team | 代表团名称 | China |
| NOC | 国家奥委会三字母代码 | CHN |
| Games | 届次名称 | 2016 Summer |
| Year | 年份(整数) | 2016 |
| Season | 季节 | Summer |
| City | 举办城市 | Rio de Janeiro |
| Sport | 大项 | Basketball |
| Event | 小项 | Basketball Men's Basketball |
| Medal | 奖牌 | Gold / Silver / Bronze / NaN |
特别注意 Medal 这一列,它是 NaN 代表“没有获得奖牌”,并不是数据缺失。这一点的理解直接决定后面统计对不对,我在第三节会重点展开。
原始数据大概是 27 万行左右,70 多万条记录的说法我也见过,不同版本字段会有差异,但核心结构一致。第一次运行df.isnull().sum()时,Age、Height、Weight、Medal 四列都有缺失,这四类的处理逻辑完全不同。
2.2 三类缺失值,三种处理思路
很多人一遇到缺失值就“fillna 填均值”,这在这份数据上属于典型的错误处理。
第一类是 Medal。它缺失代表“没拿奖”,不是记录不完整。处理方式是单独筛出非空的行来统计奖牌,而不是补一个“无”。df.dropna(subset=["Medal"])就是做这件事最直接的方式。
第二类是身高和体重。早期赛事的身体测量数据大量缺失,如果全局用一个均值填充,会把短跑运动员和举重运动员的身高体重混成同一个数,后续画像分析直接失真。我的做法是:先按“性别 + 大项”分组,然后用组内身高 / 体重的中位数去填充本组缺失值。中位数比均值更稳健,不容易被极端值拉偏。
第三类是年龄。缺失量相对少,我做的处理是保留缺失行用于其他分析,但在具体画图时先删除缺失值,不额外填补。分析年龄这种强分布特征时,填数反而会制造假分布。
补充一句垃圾进垃圾出原则:数据里还有少量参赛记录中 Age 为个位数,明显属于录入异常,我用df = df[(df['Age'] >= 10) & (df['Age'] <= 80)]做了个基础过滤。这类边界清理不需要复杂规则,关键是意识到脏数据存在。
2.3 类型修复、重复处理和统一存储
数据类型方面,Year 是整数,Medal 转为 category 可以节省内存并加快聚合。字符串列里的前后空格统一 strip,Team 列可能出现相同 team 但大小写不同的情况,用str.title()或统一大写都比手工一个个查靠谱。
重复值主要出现在“同一届同一小项里,同一运动员可能被分配了多条记录”这种情况下。我用subset=["ID", "Games", "Event", "Medal"]做去重,保留第一条。不是所有重复都是错误,所以这一步要结合自己对数据语义的理解来定主键。
清洗完成后,我建议存一份中间结果:
import pandas as pd df = pd.read_csv("athlete_events.csv") # 类型与基础清洗 df["Year"] = df["Year"].astype(int) df["Medal"] = df["Medal"].astype("category") df["Team"] = df["Team"].str.strip() df = df.drop_duplicates(subset=["ID", "Games", "Event", "Medal"]) # 性别+大项分组填充身高体重 df["Height"] = df.groupby(["Sex", "Sport"])["Height"].transform(lambda x: x.fillna(x.median())) df["Weight"] = df.groupby(["Sex", "Sport"])["Weight"].transform(lambda x: x.fillna(x.median())) df.to_parquet("athlete_clean.parquet", index=False)后面所有分析直接读 parquet 而不是原始 CSV,读取速度快,数据类型也保留得更好。这个习惯在数据量再大几倍时收益尤其明显。
3. 核心分析模块实现
3.1 规模趋势:线图里藏着一个世纪的膨胀
先看最直观的规模问题。要回答“参赛总人数怎么变化”,本质是对 Year 分组后对唯一运动员去重计数。注意不能直接对行做 count,因为同一个运动员在当届可能参加多个项目,会产生多条记录。
athlete_count = ( df.groupby(["Year", "Season"]) .apply(lambda g: g["ID"].nunique()) .reset_index(name="运动员数") ) games_count = df.groupby("Year").agg( 参赛代表团数=("NOC", "nunique"), 大项数=("Sport", "nunique"), 小项数=("Event", "nunique"), ).reset_index()画出来的折线图有个非常明显的节奏:奥运会到了上世纪后半叶,曲线基本是陡峭向上的,这说明奥运会的“规模膨胀”不是渐进的,而是战后全球化带动参赛群体扩张的结果。同时可以看到小项数的涨幅比大项数高得多,说明一个运动下拆分出的类别越来越多。
做这类分析时,年份轴建议强制用折线而不用柱状,因为折线能更好地表现连续变化的斜率。如果年份太多,可以用 pyecharts 的 dataZoom 组件拖拽缩放,保留整体趋势的同时也允许查看早期细节。
3.2 奖牌格局:统计前先想清楚“奖牌记录”是什么
奖牌统计是这份数据里最容易算错的地方。直接df[df["Medal"].notna()]得到的是“每一枚奖牌对应的运动员+项目记录”,但“一支队伍拿一块金牌”和“一个个人项目拿一块金牌”在记录里不一样。团体项目(比如足球、篮球、接力)一支队伍有十几名运动员,每人都会各有一条 Gold 记录,如果直接对行计数,团体项目的权重会被无限放大。
我当时处理的方法是分两步:先对 “Year + NOC + Event + Medal” 去重,再统计总数。这样一支队伍一枚团体金牌只算一次。
medal_df = df.dropna(subset=["Medal"]).copy() medal_df = medal_df.drop_duplicates(subset=["Year", "NOC", "Event", "Medal"]) medal_count = ( medal_df.groupby(["Year", "NOC"]) .size() .reset_index(name="奖牌数") )如果要展示“奖牌榜排名”,我建议给金、银、铜设置权重,比如金牌3分、银牌2分、铜牌1分,做加权排序。单纯按总数排会掩盖“金牌含金量”的差别,用加权分会更接近大众认知中的奖牌榜顺序。
另一个容易忽略的角度是东道主效应。把“举办当年的奖牌数”和“该代表团前后几届的平均奖牌数”做对比,能看到明确的主场增益。这个现象放在图表里特别有话题性,很适合答辩时作为分析亮点。实现上只需要先筛出举办城市对应的年份和东道主NOC,再和它前后各一两届的数据合并比较。
3.3 运动员画像:身高体重的项目差异
这个模块用来回答“不同项目的运动员到底哪里不一样”。我分别画了年龄、身高、体重的分布,并且按大项分面展示。
年龄分析要处理好“届次”和“个人”的关系:同一个运动员可能参加多届,但年龄列本身就是参赛当届的年龄,所以直接以每行记录做统计是合理的。实际数据里击剑、马术等项目的老将比例明显偏高,而体操项目年轻选手密集,这和项目对身体素质和经验要求的差异完全对得上。
身高体重的处理要用箱线图而不是折线图。箱线图能同时展示中位数、四分位距和离群点。比如篮球、排球运动员身高中位数远高于其他项目,举重运动员的体重分布偏大,长跑选手则集中在中低体重区间。这一步极大地体现了“数据能够讲述物理差异”这件事。
3.4 性别比例:从极少到接近均等的百年变化
为了统计女性参赛比例,先按年份分组建一个二维表:
gender_by_year = ( df.groupby(["Year", "Sex"])["ID"] .nunique() .unstack(fill_value=0) ) gender_by_year["女性占比"] = ( gender_by_year["F"] / (gender_by_year["M"] + gender_by_year["F"]) * 100 )结果非常显著:早期女子参赛比例是个位数,后来逐届攀升,近年已超过 47%,早期很多年份甚至没有女子项目记录。画成面积图或堆叠柱状图以后,听众会非常直观地感受到“奥运会性别平等进程”的推进速度。
如果你愿意再深入一层,可以挑几个首次出现女子项目的年份做标注,比如在对应年份点上加一条 markLine,说明“这一年首次增设女子大项”。图表里带着历史背景,整个项目的叙事层次立刻就不一样了。
4. 可视化与交互系统落地
4.1 图表选型:不是所有数据都适合同一种图
我对自己定了一条规矩:先确定“这个分析要回答什么问题”,再决定用什么图表。下面这张表是我在实践中反复调整后形成的选型对照,你可以直接用:
| 分析问题 | 推荐图表 | 选型原因 |
|---|---|---|
| 参赛人数随年份变化 | 折线图 | 强调时间趋势和斜率变化 |
| 各国奖牌对比 | 横向柱状图 / 地图 | 排名对比适合条形,地理分布适合地图 |
| 运动员年龄分布 | 直方图 / 箱线图 | 展示集中趋势、离散程度 |
| 各项目身高体重差异 | 箱线图 | 同时看中位数和离群点 |
| 性别比例随时间变化 | 堆叠面积图 | 展示两部分结构变化 |
| 项目设置演变 | 桑基图或热力图 | 展示项目从无到有、从少到多 |
我在早期犯过的错误是看到什么顺眼就画什么,结果一屏堆了五六个饼图,信息量却不增反减。后来统一改成“先有结论,后配图表”,整体观感立刻干净很多。
4.2 交互看板:用 Streamlit 把脚本变成系统
Streamlit 的优势是写起来快。我把它拆成三栏布局:左侧放筛选器和参数,主区域放核心图表组件,底部放数据表格和原始数据预览。典型结构如下:
import streamlit as st import pandas as pd from pyecharts.charts import Bar, Line from pyecharts import options as opts st.set_page_config(layout="wide") st.title("历届奥运会数据分析看板") @st.cache_data def load_data(): return pd.read_parquet("athlete_clean.parquet") df = load_data() year_range = st.sidebar.slider("年份范围", int(df["Year"].min()), int(df["Year"].max()), (1948, 2016)) season = st.sidebar.selectbox("季节", ["Summer", "Winter"]) filtered = df[(df["Year"].between(*year_range)) & (df["Season"] == season)]用@st.cache_data把数据加载缓存起来,页面切换筛选条件时不会反复读取文件。这一点对体验提升非常大,尤其当数据量超过十万行后,每操作一次就重新读CSV的卡顿感会让人怀疑人生。
图表渲染我用st_pyecharts插件把 pyecharts 图实例塞进 Streamlit,代码大概是:
from streamlit_pyecharts import st_pyecharts bar = ( Bar() .add_xaxis(years) .add_yaxis("参赛人数", counts) .set_global_opts(title_opts=opts.TitleOpts(title="各届参赛人数变化")) ) st_pyecharts(bar)这样一个带筛选项的交互看板就成型了。你还可以通过st.radio切换“按届次看”“按代表团看”“按项目看”三种视图,每个视图对应独立函数,代码结构比把所有逻辑写在一个主文件里清晰得多。
4.3 中文显示与主题美化
pyecharts 处理中文本身没有大坑,字体渲染基本都是开箱即用。真正需要留意的是地图组件:pyecharts 的地图需要配合对应的 map 数据,直接用内置世界地图时,国家名称的匹配规则要和数据里的代表团名称做映射,否则会出现“地图上找不到这个国家”或“名字对不上”的问题。
主题统一也很关键。我用了 pyecharts 自带的ThemeType.WALDEN,整体视觉效果沉稳,带一点渐变质感。页面上的辅助说明统一用 Markdown 写在图表下方,讲清楚“这张图说明了什么”,而不是让读者自己猜。对于课程设计和成果展示,这种注释习惯很加分。
5. 踩坑记录与排查思路
5.1 团体项目奖牌被重复计算
这是我犯得最早也最典型的一个错误。初次统计奖牌榜时,我直接用medal_df.groupby("NOC").size(),出来结果里足球、篮球强国的奖牌数高得离谱。排查后才发现团体项目里每个队员都有一条奖牌记录,而不是每个项目一条。用 “Year + NOC + Event + Medal” 四列去重后再计数,数据才对得上。
这个问题特别提醒一点:代码跑出来没报错,不代表结果正确。做数据分析一定要对最终数字有大致的量级预期,比如金牌总数应该和当年小项总数接近,如果统计出的金牌数是小项数的十几倍,就该怀疑是不是把运动员级别记录当成了项目级别记录。
5.2 缺失奖牌参与进了不该进的聚合
另一个隐蔽问题是,奖牌列里的 NaN 在 groupby 聚合时会被默认忽略。如果你把 Medal 直接作为 groupby 的列,再配合 agg 函数,很容易得到“每个组里非空奖牌数”的隐含结果,而不是“这个组的总记录数”。这会导致某些统计图里,部分年份的柱状图突然“缺了一块”。
我的解决方案是区分两条分析链路:一条基于全量记录,用于参赛人数、项目数、性别比例;一条只基于奖牌非空记录,用于奖牌分布。两条链路的数据框在代码里分明注释好,避免混淆。
5.3 CSV 加载慢,越到后期越明显
数据量从几万行涨到几十万行后,CSV 加载速度和内存占用就成了体感瓶颈。Streamlit 每次刷新都加载一遍原始 CSV 的话,页面首屏要好几秒。解决方法是前面提到的read_parquet缓存转换,以及@st.cache_data缓存加载结果。如果以后数据量继续涨到百万行,可以用dtype参数在读取时指定列类型,或者转成 DuckDB 做 SQL 查询,但当前这个体量没必要过度设计。
5.4 地图匹配不上,初始就显示空白
用世界地图展示奖牌分布时,常见的问题不是代码报错,而是“图出来了但全空白”。这里要注意两点:一是 pyecharts 的地图包要单独安装,比如全球地图数据;二是 NOC 三字母代码需要先映射成地图组件认识的国家名称。如果数据里是国家代码而不是国家全名,必须做一张NOC 到全名的映射表,否则地图数据根本对应不上。我当时对照数据里的代表团列表手工整理了一批映射,并把它写成独立的 JSON 文件,方便后续复用。
5.5 图表文字重叠与坐标轴截断
在年份跨度较长、柱状条较多的图里,默认配置下 x 轴标签会挤成一团。最省事的办法就是开启 dataZoom,让用户自己缩放查看区间;另一种是旋转标签或设置interval控制标签密度。当然,更好的做法是给坐标轴设置一个合理的起始范围,避免自动缩放的默认区间把图拉得变形。
6. 可扩展方向与个人心得
6.1 加一点预测,系统会更完整
如果做完了基础可视化还想再更进一步,可以尝试“奖牌数预测”模块。思路是先按“Year + NOC + Sport”聚合历史奖牌、参赛人数、上届奖牌数、是否东道主等特征,再用简单的回归或随机森林去预测下届奖牌数。不少项目用这套思路拿到了不错的演示效果,它把项目从“纯可视化”升级成了“分析和建模结合”,答辩时说服力更强。
6.2 代码组织的几个实用习惯
我这次最大的教训是不要写“一个脚本跑到底”。规范化做法是把代码按功能拆分:data_preprocess.py负责清洗和存储、analysis_modules.py放统计函数、dashboard.py只做展示。函数命名也要直白,比如plot_gender_ratio(filtered_df)比plot1()可读性高得多。再配合 Git 管理不同版本,改坏了随时可以回滚,这在试错型项目中特别有用。
还有一个小技巧:把每次分析产出的关键结果保存成 CSV 或 JSON,比如“每届每 NOC 的奖牌数”是会被反复使用的中间数据。提前缓存中间结果,看板交互速度会有非常直观的提升。
6.3 复盘的一点体会
从拿到这份数据的兴奋,到中间被团体项目奖牌数翻车浇了一盆冷水,再到最终把看板跑起来,整个过程的收获其实不止是“会了 Pands 和 pyecharts”。更难得的体验是完整走了一遍“提出问题 — 找数据 — 清洗 — 分析 — 可视化 — 验证结论”的闭环。这种闭环在真实工作里正是最常用的思考方式:先想清楚要解决什么问题,再考虑用什么手段,而不是先迷恋某个工具。
如果给你一个启发,我希望是这句话:别急着把所有图表堆满,先把数据读懂。读懂之后,故事自然就有了。