第一次看到athlete_events.csv这个文件的时候我其实有点发怵——二十多兆的 CSV27 万行记录横跨 1896 年雅典奥运会到 2016 年里约奥运会整整 120 年的奥运历史全在里面。当时我正好在研究 Python 数据分析可视化之前练手的项目大多是爬点电商评论、做做词云总感觉差点意思。直到把这份数据跑通才真正体会到什么叫数据清洗两小时画图五分钟。这篇文章就把我完整做过的这个基于 Python 的历届奥运会数据可视化分析系统从数据集的坑、指标设计、可视化选型到工程化落地和性能优化的全过程原原本本梳理一遍。不管你是正在找练手项目的 Python 学习者还是对体育数据感兴趣的分析师这套思路应该都能直接用上。1. 数据集摸底先搞清楚你手里到底有什么很多人拿到 CSV 第一件事就是pd.read_csv()然后赶紧画图我建议你忍住。先把数据集的脾气摸清楚后面能少踩一半的坑。1.1 字段结构15 列里藏着哪些信息这份经典数据集来自 Kaggle 上的 120 years of Olympic history文件名为athlete_events.csv。它每一行代表一位运动员在一个项目中的参赛记录。核心字段如下字段名含义示例ID运动员唯一编号1Name运动员姓名A DijiangSex性别M / FAge参赛年龄24Height身高厘米180Weight体重公斤80Team代表队名称ChinaNOC国家奥委会三字母代码CHNGames届次名称2016 SummerYear年份2016Season季节Summer / WinterCity举办城市Rio de JaneiroSport运动项目BasketballEvent具体小项Basketball Mens BasketballMedal奖牌类型Gold / Silver / Bronze / NA整份数据共271116 行时间跨度从1896 年到 2016 年包含夏季和冬季奥运会。这个数据规模用 pandas 处理绰绰有余内存占用也就几十 MB普通笔记本跑起来没有任何压力。1.2 先别急着画图三分钟快速体检我拿到数据后做的第一件事不是画图而是快速体检import pandas as pd df pd.read_csv(data/athlete_events.csv) print(df.shape) print(df.info()) print(df.isnull().sum())这一步能让你立刻知道三件事数据量多大、每列类型对不对、哪些字段有缺失。当时输出的结果里Height缺失了 6 万多条Weight缺失了 6 万多条Age缺失了几百条Medal缺失了 23 万多条。看到这个缺失量先别慌缺失不等于数据质量差关键在于区分真缺失还是假缺失。Medal缺失 23 万条其实是正常的——绝大部分参赛运动员没有获奖Medal列当然是空值。这份数据的真正难点在于Height和Weight的大量缺失以及后面要提到的奖牌重复计数问题。2. 清洗环节才是重头戏三个容易翻车的细节数据分析圈有句话叫Garbage in, garbage out。奥运会这份数据看着规整实际处理起来全是细节。我挑三个最容易翻车的点详细说。2.1 身高体重的缺失值直接删行是下策早期奥运会的运动员数据记录很不完整尤其是 1920 年以前身高体重的缺失率非常高。如果你图省事直接dropna()会损失大量早期参赛记录后面分析历史趋势的时候数据就断档了。我采用的策略是分组填充中位数按运动项目分组填充因为不同项目的运动员身高体重差异极大——体操运动员和举重运动员的身高体重根本没有可比性用全体中位数填充会制造出大量四不像数据。# 按项目分组填充中位数 df[Height] df.groupby(Sport)[Height].transform( lambda x: x.fillna(x.median()) ) df[Weight] df.groupby(Sport)[Weight].transform( lambda x: x.fillna(x.median()) )这样处理后既保留了早期参赛记录又不会因为缺失值导致后面画散点图时空一大片。要注意的是填充只是给分析用的如果你要做严格的数据报告最好把填充口径写在说明里避免误导。2.2 奖牌统计的重复计数问题官方奖牌榜不是简单的 count这是整份数据里最隐蔽的坑我在这里折腾了很久。注意数据集的粒度——每一行是一个运动员在一个小项中的参赛记录。一个运动员可能参加多个项目一个团体项目比如篮球、足球、接力会有多名运动员同时获得同一枚奖牌。如果你直接按Medal不为空来统计奖牌数# 错误示范会把团体项目人数当成奖牌数 wrong df[df[Medal] ! NA].groupby(NOC)[Medal].count()结果会严重虚高。比如一支篮球队 12 名球员都拿了金牌你这里一统计就是 12 枚金牌但官方奖牌榜只算 1 枚。正确的做法是先按年份 国家 小项 奖牌类型去重再来计数这样才能接近官方的统计口径# 只保留有奖牌的记录 medal_df df[df[Medal] ! NA].copy() # 去重同一国家同一小项目的同一枚奖牌只算一次 medal_df medal_df.drop_duplicates( subset[Year, NOC, Event, Medal] ) # 按年份和国家统计奖牌数 medal_count medal_df.groupby([Year, NOC]).size().reset_index( namemedal_count )当然这里还有一个细节不同国家对奖牌榜口径的定义不同。中国喜欢按金牌数排序美国习惯按总奖牌数排序。做可视化分析时如果你想展示金牌数 Top 10就得单独把Medal Gold过滤出去再统计方法和上面类似。2.3 国家名称的规范化Team、NOC、代码对不上怎么办数据里同时有Team如 China和NOC如 CHN两个字段。大多数情况下两者能对应上但有几个特殊情况需要处理苏联的遗产1992 年巴塞罗那奥运会苏联已经解体参赛的是独联体EUN而 1994 年后又变成俄罗斯、乌克兰等各自参赛。直接按Team聚合会把这段历史割裂。国家改代码比如土耳其从 TUR 到 TUR 没变但有些国家的 NOC 代码在不同年份有微调。Team 名称不统一比如 Russia 和 Russian Federation 在不同年份可能出现。我的处理方案是优先使用 NOC 代码做跨年份的国家维度分析因为它相对稳定且是标准编码只有在展示给普通读者看的时候才做一张 NOC 代码到中文/英文名称的映射表避免界面上出现一串看不懂的三字母代码。注意NOC 代码和 ISO 3166 国际标准代码并不完全一致比如苏联的 URS、独联体的 EUN这些在标准地理编码体系里是没有的。后面用 plotly 画地图时这个问题会让你很头疼稍后会细说。3. 指标设计不要只做一个会画奖牌榜的看图工具数据集清洗干净之后很多人会自然而然地去做一个历届奖牌榜 Top 10 动态排名——这个图表确实出效果但它太常见了几乎每个做过奥运数据的人都会做一遍。真正让这个分析系统有辨识度的是你能否提出奖牌榜之外的分析维度。3.1 女性参赛比例从 0% 到 45% 的百年变迁1896 年第一届现代奥运会女性运动员参赛人数是 0。到了 2016 年里约奥运会女性参赛比例已经接近 45%。这个指标用数据讲出了一个比奖牌榜更宏大的社会变迁故事。实现起来非常简单summer_df df[df[Season] Summer] female_ratio summer_df.groupby(Year)[Sex].apply( lambda x: (x F).mean() )需要注意的坑是有些Sex字段可能含有异常值或缺失值参与比例计算前先确认一下取值集合。另外冬季奥运会和夏季奥运会的女性参赛比例差异明显分开统计更合理。3.2 东道主优势能不能量化出主场buff的存在每届奥运会东道主国家往往表现超常这是体育圈公认的现象但我一直想用数据验证一下到底有多超常。思路很直接把每个东道主国家在举办当年的奖牌数和它非举办年份的平均奖牌数做对比算出提升比例。# 手动维护一份年份 - 东道主NOC 的映射 host_map { 1896: GRE, 1900: FRA, 1904: USA, 1908: GBR, 1912: SWE, 1920: BEL, # ... 省略中间年份实际操作时补全 2008: CHN, 2012: GBR, 2016: BRA }把 1896 到 2016 年每届夏季奥运会的东道主都手动列出来然后和medal_count做关联分析。我当时跑出来的结果很有意思大多数东道主的奖牌数相比其历史平均水平有明显提升但提升幅度参差不齐2008 年的中国、2012 年的英国提升幅度都在 50% 以上。这说明主场 buff不是玄学而是可以量化的统计规律。3.3 优势项目集中度为什么有的国家奖牌靠广覆盖有的靠单点突破美国的奖牌靠游泳、田径等大项广撒网中国则长期在跳水、乒乓球、举重等项目上高度集中。怎么用数据刻画这种差异一个简单粗暴但有效的指标是优势项目占比——计算金牌数排名前 3 的项目其金牌数占该国总金牌数的比例。比例越高说明该国奖牌来源越集中。# 先统计每个国家在每个项目上的金牌数 gold_by_noc_sport medal_df[medal_df[Medal] Gold].groupby( [NOC, Sport] ).size().reset_index(namegold_count) # 再算每个国家前3项目金牌占比 def top3_ratio(group): top3 group.nlargest(3, gold_count)[gold_count].sum() return top3 / group[gold_count].sum() concentration gold_by_noc_sport.groupby(NOC).apply(top3_ratio)跑完这个指标再去看各国奖牌分布的时候你的视角会发生明显变化——从谁更厉害变成各自用什么路径拿了牌这个分析维度让我自己都觉得打开了新世界。4. 可视化方案选型与核心图表实现数据和分析指标都齐了接下来就是重头戏可视化。很多初学者上来就无脑选一个库结果画到一半发现做不出想要的效果又推倒重来。这里我把自己真实的选型逻辑和核心图表实现都摆出来。4.1 四个库怎么选没有最好只有最合适我在这个项目里实际对比过 matplotlib、seaborn、plotly、pyecharts 四个库它们各有侧重库优势短板我的使用场景matplotlib稳定、可定制粒度细、文档全API 繁琐默认样式朴素论文风格配图、复杂自定义图表seaborn基于 matplotlib统计图表友好大数据量时性能一般身高体重分布、回归关系图plotly交互强、支持地图动画图表对象大离线地图配置麻烦动态榜单、世界地图pyecharts中文社区活跃、配置像写字典版本迭代快API 变动大中文报告、可视化大屏实际项目中我日常探索用 seaborn汇报展示用 plotly特殊定制用 matplotlib。不要在一个项目里把四个库都用一遍那会让代码变得异常臃肿。4.2 环境准备中文乱码和基础依赖不管用哪个库Python 环境里中文乱码几乎是必踩的坑。matplotlib 默认字体不支持中文画出来的图全是方块。解决方案是在画图前统一设置字体import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [SimHei, Microsoft YaHei, PingFang SC] plt.rcParams[axes.unicode_minus] False # 解决负号显示异常如果你是刚接触 Python 的新手环境还没配好建议先装 Python 3.9 以上的版本安装时勾选 Add Python to PATH然后一条命令装齐依赖pip install pandas matplotlib seaborn plotly之前有同学问我 pyecharts 要不要一起装。我的建议是第一版先别装等核心图表都跑通了再按需加入否则 dependencies 一多光是版本冲突就够你折腾半天。4.3 核心图表一百年参赛规模增长趋势这个图能直观展示现代奥运会从小规模精英赛事到全球性盛会的扩张过程。用 seaborn 画最顺手import seaborn as sns athlete_count df.groupby(Year).agg( athletes(ID, nunique) ).reset_index() plt.figure(figsize(12, 6)) sns.lineplot(dataathlete_count, xYear, yathletes, markero) plt.title(历届奥运会参赛运动员人数变化) plt.xlabel(年份) plt.ylabel(参赛运动员人数) plt.tight_layout() plt.savefig(output/figures/athlete_growth.png, dpi150)这里我特意用了nunique()而不是count()因为同一名运动员可能参加多个小项count()会把人数算重。4.4 核心图表二奖牌榜 Top 10 国家的历史演变这个图最能出效果。plotly 的动态排名图bar chart race很多自媒体都发过代码其实不复杂核心是先用前面的medal_count算出每个国家每年的奖牌总数再取累计奖牌数 Top 10 的国家逐年绘制import plotly.express as px # 先算出每个国家各年份的累计奖牌数 medal_count[cumsum] medal_count.groupby(NOC)[medal_count].cumsum() # 取总累计数前10的国家 top_nocs medal_count.groupby(NOC)[cumsum].max().nlargest(10).index top_data medal_count[medal_count[NOC].isin(top_nocs)] fig px.line( top_data, xYear, ycumsum, colorNOC, title历届奥运会累计奖牌数 Top 10 国家变化 ) fig.write_html(output/figures/top10_medals.html)plotly 的图表是交互式的鼠标悬停能看到具体数值非常适合放到网页或汇报里。4.5 核心图表三举办城市和奖牌分布的世界地图地图是数据可视化里最吸睛的图表但也是坑最多的图表。plotly 的scatter_geo和choropleth都能画地图问题在于NOC 三个字母代码不等于 ISO 3166 标准代码。比如苏联的 URS、独联体的 EUN在 plotly 内置的地理编码表里根本找不到。我的解决方案是重新维护一列标准的 ISO 3 位代码用于地图展示把无法映射的国家主要是历史国家过滤掉或归并到后继国家。虽然丢失了一小部分历史数据但地图能画出来从展示效果看是值得的。import plotly.express as px # 假设 medal_count 中新增了 iso_code 列是标准的ISO-3代码 fig px.choropleth( medal_count, locationsiso_code, colormedal_count, hover_nameNOC, animation_frameYear, title各国家历届奥运会奖牌数量分布, projectionnatural earth ) fig.write_html(output/figures/medal_map.html)动画地图一旦跑起来视觉冲击力非常强。但要注意年份太多1896-201630 多帧会让人看不过来建议筛选几个关键年份如 1896、1928、1964、2000、2016来播。4.6 核心图表四运动员身体数据与项目类型的关系用散点图看不同运动项目的运动员身高体重分布是个很有趣的角度。你会发现体操运动员和篮球运动员在两个对角一目了然import seaborn as sns sample_df df.dropna(subset[Height, Weight]).sample(5000, random_state42) plt.figure(figsize(10, 8)) sns.scatterplot( datasample_df, xHeight, yWeight, hueSex, alpha0.6 ) plt.title(运动员身高体重分布抽样5000条) plt.tight_layout() plt.savefig(output/figures/height_weight.png, dpi150)这里抽样 5000 条是因为全量 27 万条画散点图会让 PDF 导出特别卡抽样后趋势依然清晰。random_state42固定随机种子保证每次运行结果可复现。5. 工程化组织让项目一次跑通也方便二次复用分析项目做到后面最怕的就是自己写的代码自己也不认识。把项目整理成清晰的工程结构无论对你自己的迭代还是对别人参考都价值巨大。5.1 目录结构推荐我最终采用了这样的目录组织olympic-analysis/ ├── data/ │ └── athlete_events.csv ├── src/ │ ├── __init__.py │ ├── etl.py # 数据清洗 │ ├── analysis.py # 指标计算 │ └── visualize.py # 可视化输出 ├── output/ │ ├── figures/ # 静态图片 │ └── reports/ # HTML 交互图表 ├── run.py # 主流程 └── requirements.txtdata/放原始数据src/放核心逻辑output/放生成结果。这样项目拿到手读者能快速明白每个文件的职责。run.py作为唯一入口从上到下依次执行读取 → 清洗 → 分析 → 可视化。5.2 参数化配置路径和过滤条件别写死我在第一版代码里把csv路径写死在各个文件中后来换一个目录就到处改。更好的做法是把配置集中管理# run.py import os DATA_PATH data/athlete_events.csv OUTPUT_DIR output/figures os.makedirs(OUTPUT_DIR, exist_okTrue) MIN_YEAR_FOR_MAP 1960 # 地图只展示1960年之后减少动画帧数这样你能很清楚地看到数据从哪来、结果输出到哪、地图分析从哪年开始。后续如果要接入 2020 东京或 2024 巴黎的数据只需要新增一个数据文件然后把DATA_PATH指向新地址再跑一遍python run.py就行。5.3 用函数封装不要写一坨散装代码图省事的同学经常在一个 Notebook 里从上往下写几百万个单元格最后导出代码时根本没法看。我的习惯是每个大的分析动作封装成函数比如# src/etl.py def load_data(path: str) - pd.DataFrame: 读取并做基础类型转换 df pd.read_csv(path) df[Year] df[Year].astype(int) return df def fill_missing_by_sport(df: pd.DataFrame) - pd.DataFrame: 按运动项目填充身高体重缺失值 ...函数返回的是明确的数据结构调用方拿到了就能直接用可读性和可测试性都比散装代码好一个量级。6. 实测踩坑与性能优化记录这部分我记录的是真的在项目里花时间解决的问题。每个坑都卡过我至少半小时写下来帮你避开。6.1 plotly 离线环境地图加载失败用px.choropleth的时候plotly 默认需要联网加载地理 JSON 数据。如果网络不好或者你在内网环境运行图表会一片空白或者直接报错。解决方案是本地化地理数据提前把countries.geojson之类的文件下载到项目目录然后通过geojson参数显式传入# 本地化geojson避免运行时联网 fig px.choropleth( medal_count, geojsondata/countries.geojson, locationsiso_code, colormedal_count )这个坑很隐蔽因为在有网络的环境下完全复现不出来只有部署到离线环境才暴露。6.2 循环画几十张图卡成 PPT早期版本我图省事直接 for 循环每个国家每年画一张图再合成 GIF。结果导出 30 多张图时进程直接卡死GIF 文件几百 MB。后来学乖了用groupby聚合好数据再一次性交给 plotly 的animation_frame参数避免手动循环生成多帧。对大数据量的散点图先抽样再画趋势不变但性能提升好几倍。生成的 HTML 交互图用fig.write_html()文件体积虽然比 PNG 大但浏览器打开流畅度远高于 GIF。6.3 年份的 dtype 问题CSV 读进来后Year列有时会被 pandas 自动识别为int64有时是int32如果你中间做过来自字符串的拼接可能变成object。最稳妥的做法是读入后显式转换df[Year] df[Year].astype(int)否则你在groupby(Year)的时候年份会被当字符串排序出现 1912 排在 1900 前面的诡异现象。6.4 数据量大时用 category 类型减小内存27 万行数据不大但如果你把Sex、Season、Medal这些重复度极高的列转换成 category 类型内存占用能下降一半以上后续聚合计算也会更快for col in [Sex, Season, Medal, NOC]: df[col] df[col].astype(category)这个技巧在处理更大数据集时尤其有用。数据科学里有个常见误解是必须用 Spark 才能处理大数据实际上先把数据类型优化到位很多问题单机 pandas 就能解决。7. 复盘做完这套系统的核心收获与后续想法项目做到最后最值钱的反而不是那一堆图表而是过程中建立起来的分析思维和处理经验。第一个收获是数据清洗要先理解后动手。刚拿到数据时我差点把Medal的 23 万缺失值当成脏数据直接填充成无奖牌这个处理本身没错但如果不理解没有获奖记录就是空值这个背景后续分析奖牌占比时就会出现逻辑混乱。清洗的每一步都应该能用一句业务语言解释清楚而不是机械地套模板。第二个收获是可视化是手段不是目的。一开始我追求炫酷的图表效果后来发现能回答具体问题的图才有价值。比如东道主优势分析就是被一个朋友问奥运会是不是真有主场魔咒启发的数据跑完后我们俩都惊到了——这比单纯做一个奖牌榜动图更有意义。第三个收获是工程化能力往往比算法能力更影响体验。把代码封装成函数、统一管理路径、固定随机种子这些看似不起眼的工作让整个项目的复用成本低了很多。东京奥运会重跑了一遍流程10 分钟就更新完所有图表那种感觉比第一次画出图还爽。如果后面要继续扩展我考虑的路线是加入 2020 东京和 2024 巴黎的增量数据补充一个简单的奖牌数预测模型用历史趋势做线性外推再就是把可视化图表做成一个轻量 Web 应用让非技术朋友也能自己选维度、看数据。这条路够我玩一整年了。最后分享一个实用小技巧pd.set_option(display.max_columns, None)和display.max_rows在探索数据时能避免 pandas 疯狂省略号打码。这个设置在 Notebook 里尤其实用看到全貌再决定下一步处理比瞎猜字段内容高效得多。这套基于 Python 的奥运数据分析系统做下来我对 pandas matplotlib/plotly 打天下 这句话的体会算是彻底到位了。