R语言实战:dplyr进阶与ggplot2绘制120年奥运数据图表

发布时间:2026/10/15 3:21:17
R语言实战:dplyr进阶与ggplot2绘制120年奥运数据图表 这段时间我一直在拿Kaggle的奥运会历史数据集练手前一篇已经带着大家把数据读进R做了基础的清洗和字段检查。这一篇算系列的第二篇核心很明确从“能跑通”到“能说明白问题”把dplyr的进阶数据处理和ggplot2的可视化一起串起来。说白了就是同一份120多年的参赛记录上一版只回答“数据长什么样”这版要回答“数据能告诉我们什么”。这套数据适合三类读者一类是刚学完R基础语法、想找个真实数据集练手的朋友一类是已经会用dplyr做简单筛选但面对group_by、case_when、多层管道时有点发怵的人还有一类是看过很多图表教程但自己想画时总在图例、字体、颜色这些细节上卡壳的。这篇没有一句废话全部对着真实可跑的代码来讲。1. 先把数据底子摸清120年参赛记录里能挖出什么1.1 这几张表到底记录了哪些信息Kaggle上的这套奥运会数据集行数是几十万条级别覆盖从1896年到最近几届的几乎所有参赛记录。拿到手第一件事不是急着算奖牌而是把每一列的含义在脑子里过一遍。常用的关键字段大概是这样的字段含义分析中容易踩的点ID运动员唯一编号同一个运动员同一年可能出现在多行Name运动员姓名同一人可能有不同拼写或大小写Sex性别早年数据确实只有M/F两值Age / Height / Weight年龄、身高、体重早年大量缺失不能直接不管Team / NOC队伍名称 / 国家奥委会代码Team有历史改名问题Year / Season / City届次 / 季节 / 举办城市Season分Summer和WinterSport / Event大项 / 小项例Sport是SwimmingEvent是Mens 100m FreestyleMedal金/银/铜或缺失缺失值表示没拿牌不是数据缺失这里最需要花心思理解的是Sport和Event的区别。Sport是大的运动分类Event是具体的比赛项目。同样是田径Athletics这个Sport下面可以拆出几十个Event。数据行数的真实含义是“一名运动员在某届某小项中的参赛记录”所以一个人同一届里参加两三个项目就会占两三行。这个认知贯穿整篇后面所有统计口径都建立在它上面。1.2 第一篇做完之后数据应该处于什么状态上一篇我们一般会完成四件事读入CSV、把列名统一成小写便于书写、检查重复值、把Medal列转成因子或字符。如果你的工作区里已经有类似结构的对象这节直接跳过也可以。如果没有现在就花两分钟把数据准备好。注意保持原始表不动我们后续所有分析都从一份清洗后的副本开始library(dplyr) library(ggplot2) library(forcats) library(tidyr) athlete_events - read_csv(athlete_events.csv) # 只保留我们这篇会用到的列减少干扰 events_work - athlete_events %% select(ID, Name, Sex, Age, Height, Weight, Team, NOC, Year, Season, City, Sport, Event, Medal) %% mutate( medal_flag if_else(is.na(Medal), No Medal, Medal), medal_type case_when( Medal Gold ~ 金牌, Medal Silver ~ 银牌, Medal Bronze ~ 铜牌, TRUE ~ NA_character_ ) )代码里我故意加了medal_flag这一列它和medal_type看起来高度重复但分析中两者的用处不一样算“有没有拿牌”用medal_flag算“金/银/铜分布”用medal_type。后面你会看到这种一眼冗余的字段在管道操作里能省很多事。1.3 这一篇要回答的具体问题在继续写代码之前我把这一篇的目标收敛成三个问题免得后面东一锤子西一棒子历史上哪些NOC拿到的奖牌总数最多金/银/铜的结构是否明显偏向某一种夏季和冬季奥运会的奖牌总量趋势分别怎么走中间有没有断档参赛运动员的年龄分布长什么样男女之间有没有肉眼可见的差异这三个问题正好对应三组ggplot2图表而它们共同需要的所有数据准备正好把dplyr里最常用的进阶操作都覆盖一遍。这是我把第二篇设计成“dplyr进阶可视化实操”连在一起的原因处理的目的不是处理本身而是让图表有东西可画。2. dplyr进阶操作从能筛选到会分组、会重编码2.1 case_when的完整用法把奖牌列变成可统计的等级变量处理奖牌列是这套数据里最典型的“条件重编码”场景。一个Medal列里面的值是Gold、Silver、Bronze和NA我们要把它变成能参与分组汇总的类别变量。我习惯用case_when而不是一连串ifelse因为它在多条件时读起来清晰而且天然支持“其他情况”兜底events_clean - events_work %% mutate( medal_score case_when( medal_type 金牌 ~ 3, medal_type 银牌 ~ 2, medal_type 铜牌 ~ 1, TRUE ~ 0 ), is_medalist if_else(medal_score 0, TRUE, FALSE) )这里有三个值得注意的点。第一case_when是多条件从上到下依次匹配的排在前面的条件先生效所以如果你写了一个既满足金牌又满足银牌的重叠条件结果是金牌。第二TRUE ~ 0这个兜底分支必须存在它保证了不会有意外的NA进入后续计算。第三在case_when里面用medal_type而不是直接用Medal是因为我们已经把NA的语义处理清楚了——没拿牌的人medal_type是NA而medal_score是0这种“缺失”和“零分”的语义转换很重要。2.2 group_by summarise的统计口径先想清楚统计单位再动手进入分组统计之前必须先解决一个核心问题这一行数据代表什么前面说过行代表“参赛记录”一个运动员同一届参加多个项目就会有多行。所以统计人数和统计奖牌数是两个完全不同的统计口径。如果算的是奖牌总数那就直接对过滤掉无牌记录的数据逐行计数因为一枚奖牌就是一行country_medal_count - events_clean %% filter(is_medalist) %% count(NOC, medal_type, name medal_n) %% arrange(desc(medal_n)) country_total - country_medal_count %% group_by(NOC) %% summarise(gold sum(medal_type 金牌), silver sum(medal_type 银牌), bronze sum(medal_type 铜牌), total sum(medal_n), .groups drop)这里最容易被新手忽略的是.groups drop。summarise执行完之后结果表还保留着分组属性如果你紧接着arrange(desc(total))这段代码默认只在每个组内排序很容易出现“号乱了”的错觉。我建议但凡在summarise后面还要继续管道的统一加上.groups drop养成习惯就不会在复杂管道里翻车。如果统计的是运动员人数就要用n_distinct(ID)。这句话值得读三遍。假设某游泳运动员在一届奥运会上参加了三个小项如果你直接count()他会给你算出三个人头但n_distinct(ID)会正确识别为一个人。不管是画年龄分布图还是算参赛总人数这个细节都会直接影响结论。2.3 多级分组与长表转换让数据既能聚合也能展开奖牌榜分析完成后我们还需要一张“年份-赛季”维度的奖牌数时间序列。这里会用到两阶段的管道先group_by(Year, Season)再统计奖牌行数最后用pivot_wider把长表变成宽表方便后续Excel式查看。medal_ts - events_clean %% filter(is_medalist) %% group_by(Year, Season) %% summarise(medal_n n(), .groups drop) %% pivot_wider(names_from Season, values_from medal_n, values_fill 0)我建议跑完这段之后用glimpse(medal_ts)看一眼结果。你会看到Summer和Winter各成一列。这样做的意义在于如果直接把两个赛季混在一起画折线冬季奥运会的几百枚奖牌会被夏季奥运会的几万枚压成一条贴地的线什么趋势都看不出来。2.4 分组后再处理用slice_max和rank找出每组的榜首分组统计做多了之后你会发现一个高频需求每个组里取前几名。比如每个大项里历史上奖牌数最多的三个NOC是谁。用slice_max比“先arrange再head”更严谨因为它先按组切分再在每个组内取头部top_by_sport - events_clean %% filter(is_medalist) %% group_by(Sport, NOC) %% summarise(total n(), .groups drop_last) %% group_by(Sport) %% slice_max(total, n 3, with_ties FALSE)with_ties FALSE的意思是不要并列、只取前三行。如果保持默认的TRUE遇到两支队并列第三名时可能会挤进来4行甚至更多后面做图表时顺序就乱了。当然如果分析目标本身需要保留并列这个参数设置另说。3. 从数字到图表三张核心图表的完整绘制链路3.1 奖牌总数Top10条形图排序、长标签和坐标翻转拿到country_total之后第一张图直接从它出发。要画奖牌总数排名前十的NOC最核心的坑在于ggplot2默认会按字母顺序排列x轴这和“按数量排序”的需求正好相反。解决办法是先用forcats::fct_reorder把NOC按total重新排成因子。library(forcats) top10_noc - country_total %% arrange(desc(total)) %% slice_head(n 10) p1 - ggplot(top10_noc, aes(x fct_reorder(NOC, total), y total)) geom_col(fill #4472C4) coord_flip() labs( title 历史奖牌总数排名前10的NOC, x NULL, y 奖牌总数 ) theme_minimal(base_size 14) ggplot2::ggsave(top10_medal.png, p1, width 8, height 6, dpi 300)解释一下为什么非要用coord_flip。当x轴标签是文本且数量超过6个时横排标签互相拥挤翻转成纵排是最省事的排版方案。单色填充我选了#4472C4是因为默认的灰色面板在黑白打印稿上会很淡颜色稍重的纯色更适合文档传播。数据集里NOC的字母缩写历史上有过调整所以如果发现某两个代码对应的中文名看起来“不太对”不必惊讶这是数据本身的年代跨度导致的。3.2 历年奖牌趋势折线图季节分开看才有说服力第二张图用前面生成的medal_ts。注意到我们还没有把它转成长表但ggplot2天生喜欢长表所以这里要用pivot_longer把宽表再变回长表medal_ts_long - medal_ts %% pivot_longer(cols c(Summer, Winter), names_to Season, values_to medal_n) p2 - ggplot(medal_ts_long, aes(x Year, y medal_n, color Season)) geom_line(linewidth 0.9) geom_point(size 1.5) scale_color_manual(values c(Summer #C00000, Winter #2E75B6)) labs( title 历届奥运会奖牌数变化趋势, x 年份, y 奖牌数, color 赛季 ) theme_minimal(base_size 14) ggsave(medal_trend_by_season.png, p2, width 9, height 5, dpi 300)这张图绘制完成后你会看到夏季和冬季两条线几乎不重叠夏季的整体量级大得多冬季曲线在中间段有几次明显波动。这里的解释逻辑是这样的——奥运会早期冬季项目还没有固定的赛制流程举办届次不规律所以冬季线会出现跳跃式上升。如果直接把两条线合并成一条这种结构性差异就看不出来了。3.3 年龄分布直方图先distinct再去重否则图表会骗你第三张图的目标是看清楚“参赛运动员年龄的分布形状”。这里就回到前面反复强调的统计单位问题如果我们直接用原始数据画年龄分布一个参加多个项目的运动员会被画多次整体分布会偏向年龄较大的区间因为老将往往兼项更多。正确做法是先按IDYearSex去重一个人在某届只保留一条记录然后再画athlete_age - athlete_events %% select(ID, Year, Age, Sex) %% distinct() p3 - athlete_age %% drop_na(Age) %% ggplot(aes(x Age, fill Sex)) geom_histogram(binwidth 2, alpha 0.65, position identity) facet_wrap(~ Sex, ncol 1) labs( title 各届参赛运动员年龄分布, x 年龄, y 人数 ) scale_fill_manual(values c(M #4472C4, F #ED7D31)) theme_minimal(base_size 14) ggsave(age_distribution.png, p3, width 7, height 7, dpi 300)drop_na(Age)这一步不能省早年的参赛记录里年龄缺失非常严重如果不过滤ggplot2会直接报错或者画出奇怪的空白区间。alpha0.65是给两个叠加直方图用半透明效果如果设为1后画的性别会完全盖住前面的。看这张图的时候你可能会注意到分布上有个“小鼓包”集中在运动员十几岁左右的区间那是部分技巧类项目比如体操的参赛者。这不是数据异常是项目特征的自然反映。4. 实操中的高频坑这些错误几乎每个人都会踩一次4.1 奖牌列的缺失值不是“缺失”是“没拿牌”这是整套数据分析里最经典的语义陷阱。很多人拿到Medal列后顺手na.omit()把缺失行删掉然后画奖牌图怎么画怎么不对劲——因为na.omit把几十万条“没拿牌的参赛记录”全删了四舍五入等于只保留奖牌获得者。正确语义是Medal NA代表“该选手在这个项目上没拿到前三名”它是一条有效记录只是结果值为空。所以做统计时应该在管道中先filter(is_medalist)而不是在外面对整个数据框执行删除缺失行。我的做法始终是显式写出条件筛选绝不全局na.omit。4.2 分组统计后的arrange方向和你以为的不一样另一个高频坑藏在group_by的“残留状态”里。很多人写完这段代码后发现排名永远是乱的country_total %% group_by(NOC) %% summarise(total sum(medal_n)) %% arrange(desc(total))如果直接跑这串代码很可能没有得到全局排序的结果因为summarise之后数据还带着“按NOC分组”的状态arrange会“在组内排序”。解决办法是像我前面写的在summarise里加上.groups drop。这是个一眼看上去无关紧要、实际影响巨大的参数强烈建议写进自己的习惯清单。4.3 画图前忘了处理长标签图例挤成一团当NOC、Sport这些列里有特别长的类别名称时ggplot2默认不会自动换行标签会互相重叠或者被裁掉一半。你可能会去拖图片边框但那治标不治本。我常用的写法是scale_x_discrete(labels function(x) str_wrap(x, width 15))str_wrap会把超过15个字符的标签拆成两行。这个技巧在画Event级别、Sport级别的分类图时几乎必用因为Event名称经常一长串。4.4 中文字体乱码和“豆腐块”如果你用的是Windowstheme_minimal()里的中文字符经常显示成方框。这不是你的代码错了是R找不到中文字体。我一般直接用showtext包统一解决library(showtext) showtext_auto() font_add(heiti, regular simhei.ttf) theme_set(theme_minimal(base_size 14) theme(text element_text(family heiti)))macOS用户对应把字体名改成PingFang SC之类的系统字体。三条命令执行完所有ggplot图的中文就正常了。这个坑在R社区问答里出现频率极高提前解决能省一大半烦躁。4.5 保存图片时只存默认尺寸拉到论文里全是马赛克ggsave的默认dpi是300但宽高如果不指定默认值可能只有7×7英寸。如果这张图要放进PDF报告或者公号文章里字号会显得偏小。我一般习惯保存图片时统一写成ggsave(filename.png, plot p, width 10, height 6, dpi 300)另外一个小建议尽量保存PNG格式而不是JPG。ggplot2生成的图表以线条、色块、文字为主PNG无损压缩更清晰JPG压缩会在边缘产生杂色。5. 下一步还能怎么挖这三个方向我把代码都试过了5.1 把奖牌数放到人口、GDP背景下做归一化单纯看NOC奖牌总数永远是大国强国霸榜看不出太多结构信息。我试过把奥运数据join了一份世界银行的人口数据算“每百万人奖牌数”排名会洗牌小国优势立刻浮现。这一步最大的练习价值是掌握left_join的语义以NOC为连接键时左边的奥运数据一行都不能丢人口匹配不上的给它补NA。5.2 用运动员身高体重做关联尝试选手的身体数据适合做散点图矩阵男女分开看。我之前用Age、Height、Weight三个变量分别与是否夺牌做分组对比发现不同项目里身体条件的影响方向完全不同——篮球、排球项目里身高是硬门槛马拉松项目里身高与成绩的相关方向又不一样。想做定量分析的朋友可以按Sport拆分后用简单的逻辑回归或者t检验逐个验证每组样本量都不小跑起来非常顺。5.3 批量产出各项目的趋势图如果想把每个Sport的奖牌趋势都画出来手写几十个ggsave会疯掉。用purrr::walk可以一行遍历所有Sport自动生成并保存图片。这个方向练的是“把数据分析和文件输出结合”的能力对后续做周期性报告的人特别有用。sport_list - events_clean %% distinct(Sport) %% pull(Sport) purrr::walk(sport_list, function(s) { p - events_clean %% filter(Sport s, is_medalist) %% count(Year, name total) %% ggplot(aes(x Year, y total)) geom_line(color #4472C4, linewidth 1) labs(title s, x NULL, y 奖牌数) theme_minimal(base_size 12) ggsave(paste0(sport_trend_, s, .png), p, width 6, height 4, dpi 300) })我最初做这套数据时因为没先distinct导致年龄分布图的双峰形状偏得离谱排查半天才意识到是统计单位的问题。从那以后我养成了两个习惯凡是统计人数先问自己“这一行代表什么”凡是画图先用count()验证一遍中间结果再交给ggplot2。这套奥运会数据本身不难读真正会翻车的都是这些不起眼的地方希望这篇能帮你跳过我已经踩过的坑。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询