走进一家繁忙的餐厅厨房:点菜单从传菜口一张张飞进来,掌勺大厨左手持漏勺(filter)筛掉标注“暂停供应”的菜品,右手捏笔在单子上补一列“小计”(mutate),收银台按桌号把菜单夹在一起算总账(group_by + summarise),后厨的储物柜阿姨把散乱堆放的食材标签按“品名-数量”重新归档(tidyr 的长宽转换)。这套流水线之所以高效,靠的是一条在工位之间不停转动的传送带——它就是 R 里的管道操作符 %>%。
dplyr 和 tidyr 正是 R 生态中这套厨房流水线的两大核心:dplyr 提供一套“动词”来加工数据表,tidyr 负责把数据表整理成整洁的长/宽形态。两者配合使用,能让数据清洗、聚合、连接一气呵成,把 messy data(脏乱数据)变成规规矩矩的分析底稿。本文就用这个厨房故事贯穿全程,带你认识这对“黄金搭档”。
掌握 dplyr/tidyr,等于给你的数据分析流水线装上标准化传送带:无论数据多脏多乱,你都能用一套统一的动词快速清洗、聚合、连接——这是 R 数据岗位最基本也最值钱的硬技能。
目录
可视化演示
展示正态分布N(μ,σ²)的概率密度函数曲线,支持调节均值μ和标准差σ,实时观察分布形状变化及累积概率。
本节配有交互动效(进入视口后自动加载,约 3KB)。
概率密度函数 钟形曲线描述连续随机变量的概率密度分布 μ决定中心位置,σ决定曲线宽窄 f(x) = (1/σ√2π) · e^(-(x-μ)²/2σ²)
dplyr 动词核心:四件趁手的厨房工具
走进厨房,你会看到掌勺师傅的案板上只摆四件工具:漏勺、拣菜盘、刻刀和摆盘夹。漏勺从汤锅里捞出想要的食材——对应 dplyr 的 filter,按条件筛行;拣菜盘把需要的配菜从一堆原料中挑出来——对应 select,按列名选列;刻刀在食材上划出花刀,让菜更入味——对应 mutate,根据已有列算出新列;摆盘夹把菜品码放整齐——对应 arrange,按某列排序。这四件工具构成了 dplyr 最核心的“动词体系”。
在 R 中,一张数据表叫 data.frame(数据框),你可以把它想象成一本网格账本:每一行是一条记录(比如一道菜的订单),每一列是一个字段(比如菜品名、价格、数量)。dplyr 的哲学是:把数据处理拆成一个个“动词”,每个动词接受一个数据框,返回一个新的数据框,而且绝不修改原始数据——就像漏勺捞食材不会破坏汤底一样。
filter() 按行过滤数据,比如 filter(df, price > 30) 只留下单价超过 30 元的菜品;select() 按列挑选,比如 select(df, dish, price) 只抽出菜品和价格两栏;mutate() 生成新列,比如 mutate(df, subtotal = price * qty) 给每行加一列“小计”;arrange() 对行排序,arrange(df, desc(price)) 按价格降序排列。
这四件工具单独使用已经很方便,但它们的真正威力在于“组合”——而组合它们的关键,就是下一节要讲的厨房传送带:管道操作符。
- verb(动词)dplyr 内置的操作函数,如 filter、select、mutate、arrange,每个动词只负责一类操作。
- df(数据框)操作对象,就是一张像账本一样的表,行是记录,列是字段。
- args(参数)告诉动词“怎么做”,比如筛选条件、列名、新列的算式。
- df\_new(新数据框)动词返回的新表,原表原封不动,每一步操作都可复查、可回退。
管道操作符 %>%:厨房里的流水线传送带
假设你是一名传菜员,每道菜要经过切配、烹炒、装盘三道工序,你需要在三个灶台之间来回跑,把半成品端来端去——很快你就会累垮。厨房真正高效的方案是一条传送带:把点菜单往上一放,它自己流经每个工位,每个师傅只管加工自己负责的那一环。R 里的管道操作符 %>% 就是这条传送带。
管道做的事极其简单:x %>% f(y) 完全等价于 f(x, y)——它把左边的值“喂”给右边的函数,作为第一个参数。当多个管道连在一起时,代码就从“层层嵌套、从里往外读”变成“从左到右、一行一步”的自然语言流。
来看一个对比。不使用管道时,你想“先筛选出单价超过 30 的菜,再按价格降序排序”,R 代码要写成 arrange(filter(df, price > 30), desc(price))——你得从最里层的 filter 开始读到最外层。使用管道后:
一眼就能看出数据流:df → filter → arrange,代码就是一个流程图的文字版。
传送带本身并不能让厨师变快,它只是免去了来回奔波的工夫,让注意力集中在每一道工序上。R 社区里,管道让 dplyr 动词的组合成为日常——接下来我们看看最常用的组合:分组汇总。
- df(数据)流水线的起点,一摞待处理的点菜单放进传送带。
- %>%(管道)传送带本身,把左侧结果传到右侧函数的第一个参数位置。
- f()(第一个工位)第一道工序,比如 filter 筛行。
- g()(第二个工位)第二道工序,比如 arrange 排序。
- = 等价符号管道链与嵌套调用的结果完全相同,它只是换了一种更易读的写法。
df %>% filter(price > 30) %>% arrange(desc(price))分组汇总:按桌结算的收银台
餐厅里最高效的结算方式,不是一盘菜一盘菜地算,而是把同一个桌号的所有点菜单用夹子夹在一起,然后飞快地算出这张桌子的总消费。group_by 就是那个夹子——它按某一列(比如桌号)把数据框划分成若干小组;summarise 则是收银台的算盘——针对每个小组计算均值、总和、计数等统计量。
group_by(df, table) 这一行代码单独运行,看起来什么都没发生:数据框的样子没有变。这是因为分组只是“打标签”——R 默默记住了这张表按 table 分成了几个组,等到后续的 summarise 或 mutate 到来时,再按照分组逐个计算。
summarise() 配合分组使用,输出的是“每组一行”的汇总表。最常用的三个助手是:mean() 求均值、sum() 求总和、n() 统计本组行数。n() 是 dplyr 专属的计数函数,不需要任何参数,只要在 summarise 内调用即可。
分组汇总让“一桌一结”变得清爽。但现实中的数据往往不是恰好长成你需要的形状——一张表可能列太多(宽),也可能行太多(长)。这时候就需要 tidyr 出马了。
- g(分组标识)表示某个小组,比如桌号为 1 的所有订单集合。
- x\_g,i(组内观测值)某个特定订单的金额——第 g 组里的第 i 条记录。
- Σ(求和)把组内所有金额加起来,相当于收银员把一桌订单的金额逐行相加。
- n\_g(组内样本量)第 g 组的记录条数;n() 函数在 summarise 里就返回这个数字。
- x̄\_g(组均值)第 g 组的平均消费,summarise 输出的核心统计量。
长宽转换:储物柜的两个收纳方向
每个厨房都有一个储物柜。一种收纳方式是“宽柜格”:每个柜格标明一种食材(鸡肉、牛肉、蔬菜),格子里放着不同的数量;另一种是“长清单”:一张流水账,每行一条记录——食材、数量、时间,一行一个条目。宽柜格适合人去找某一种食材,长清单适合机器核对总量。tidyr 的 pivot_longer 和 pivot_wider 就是在这两种形态之间互相转换的工具。
一张“宽表”通常把多个相关变量并列成多列,比如三列分别是 2023 年、2024 年、2025 年的销售额。pivot_longer() 把这三列“堆叠”成两列:一列叫“年份”(原来的列名变成值),一列叫“销售额”(原来的数值搬进去)。转换后,三列变两列,行数变为原来的三倍。
反过来,pivot_wider() 把长表中的“名称列”重新拆成多个新列,长表变宽表。两者互为逆操作。为什么要转来转去?因为画图(比如 ggplot2)和数据建模更喜欢长表,报表展示更喜欢宽表。数据本身没变,变的只是摆放的姿势。
储物柜的两种收纳方向,让你的数据能按需切换姿势。但还有些时候,你不想挨个处理每一列,而是想对一批列同时做同样的事——这就是 across 的用武之地。
- df\_wide(宽表)多列并列的表,人眼阅读方便。
- pivot\_longer(变长)把指定的多列合起来,列名变成一列“名称”,列值变成一列“数值”。
- df\_long(长表)只有“名称-值”两个关键列,行数增加,便于机器处理。
- ⇔(可逆符号)两种转换互为逆运算,信息不丢失。
- pivot\_wider(变宽)把“名称”列的值拆回多个新列,恢复宽表。
跨列操作 across:一勺盐撒一排菜
后厨出菜高峰时,一排五道菜同时等着调味。有经验的师傅不会一盘一盘撒盐,而是左手端盐碗、右手捏盐勺,从第一盘一路撒到第五盘——一次操作,五盘到位。across() 就是这把“一勺盐撒一排菜”的手:在 dplyr 里,它对选中的多列批量应用同一个函数,省去逐个重复书写。
across() 通常跟 mutate() 或 summarise() 配合使用。最经典的写法:
这行代码把数据框里所有数值型列都做标准化(scale)。同理,也可以用 across(c(price, qty), log) 对指定两列取对数。where(is.numeric) 是一个“列选择器”——它像一个自动拣选的手,把满足条件的列挑出来批量处理。
在 summarise() 中使用 across 的典型场景是批量求均值:df %>% summarise(across(where(is.numeric), mean)),一下子得到所有数值列的平均值,不必为每一列写一次 mean(col)。
有了 across,批量加工变成一行代码。但现实中的数据往往散落在多张表里,分析之前要先把它们拼成一张大表——这就要用到 join 系列了。
- cols(列集合)被批量处理的列,可以用 c() 列举,也可以用 where(is.numeric) 按类型挑。
- f(函数)要批量执行的加工,比如 mean、log、scale。
- col\_j(某列)集合中的一列,比如 price 列或 qty 列。
- 集合推导式对每一列执行 f,输出结果列表;这是“撒盐”动作的数学化描述。
df %>% mutate(across(where(is.numeric), scale))多表连接 join:把两本账册拼成一桌宴席
会计要对账时,手上有两本册子:一本记录每张订单的桌号和菜品,另一本记录每张订单的结算金额。要得到“哪个桌号消费了多少”,必须把两本册子按相同的“单号”对齐——这就像把两张分开的点菜单用订书机钉在一起。R 里的 join 系列函数就是这个订书机。
dplyr 提供了一整套连接函数:left_join() 保留左表的所有行,把右表的匹配信息补到右边,右表没匹配上的就填 NA;inner_join() 只保留两表都能匹配上的行;right_join() 是 left_join 的镜像,保留右表全部行;full_join() 保留两表的全部行。四个函数,核心都是“按共同的列(键 key)把两行对齐”。
最常用的是 left_join(df1, df2, by = c("order_id" = "订单号"))。by 参数指定对齐的键:当两个表的键列名一样时写 by = "id",不一样时可以写成 by = c("a" = "b")。连接后,原本散落两张表的信息终于回到了同一张表里,后续的 filter、mutate 就能统一处理了。
把多张表拼在一起之后,数据总算齐全了。不过,翻开任何一本真实的账册,总有几行字迹模糊、被汤汁沾污——这就是缺失值问题。
- df1(左表/主表)连接结果以它为基础,它的每一行都会保留。
- df2(右表/附表)按 key 匹配 df1,提供要补充的字段。
- by = key(连接键)两个表共同的列名,就像两本账册里相同的“订单号”。
- 结果(拼接)df1 每行找一个匹配的 df2 行,并排拼接;匹配不到就补缺失值。
缺失值处理:涂改不清的菜单怎么办
点菜单在厨房里传递久了,总有几行被油渍覆盖、字迹模糊——可能看不清辣度,可能忘了填数量。面对这样的单子,厨师有两个选择:要么把整张看不清的单子扔掉(drop_na),要么根据经验把缺的信息补上(replace_na)。数据清洗里的缺失值(NA)处理,正是这两种思路。
先要“发现”缺失值。is.na(df) 会返回一个跟 df 同形状的 TRUE/FALSE 矩阵,TRUE 表示那个位置是缺失值。更直观的检查是 df %>% summarise(across(everything(), ~ sum(is.na(.)))),它能数出每一列有多少个 NA。
处理缺失值的两个主力函数:drop_na() 默认删除任何含 NA 的行,也可以指定列 drop_na(df, price) 只针对 price 列为 NA 的行做删除;replace_na() 则把 NA 替换成指定值,比如 replace_na(df, list(price = 0)) 把 price 列的缺失改成 0。此外,很多统计函数都带 na.rm 参数——mean(df$price, na.rm = TRUE) 会在计算前剔除缺失值,等价于“跳过字迹不清的单子再算账”。
至此,从筛选、选列、排序,到分组汇总,再到长宽转换、跨列批量操作、多表连接、缺失值清洗——这一套流水线就是数据分析中必做的“数据准备”(data wrangling)。有了 dplyr 和 tidyr 这对黄金搭档,再 messy 的数据也能收拾得干干净净,像从传送带末端端出的一盘盘规整好菜。
- df(原始表)含有 NA 的数据框,就像沾了油污的菜单。
- drop\_na(删行)把含缺失值的整行丢弃,简单粗暴但有效。
- replace\_na(填充)把指定列里的 NA 换成给定值,保留整行数据。
- 值(填充值)填 0 还是填均值,取决于业务含义——就像有些菜填“微辣”还是“不辣”要问客人。
- clean / filled(清洗结果)两种做法的目标都是让数据不再有 NA,后续计算才安稳。
AI 应用场景
电商订单数据清洗流水线
阶段:数据清洗阶段
解决的问题:传统方式是用 Excel 手工打开导出文件、逐列删除、逐行筛选,每次报表都要重来一遍,效率低且容易出错。AI 数据管道要求每天自动处理新增数据,dplyr/tidyr 的动词流水线可以一次性写成可复用的清洗脚本。
具体案例:某电商公司每天导出的订单表有 80 列,分析师只用到其中的 10 列,且每月有约 2% 的记录存在缺失或重复。分析师用 select() 挑出关键列、filter() 剔除无效订单、drop_na() 清掉缺失信息、mutate() 计算金额小计,最后把这套操作写成一个 R 脚本,每天定时跑一遍,数据到底时已是干净整齐的分析底稿。
金融风控特征工程中的多表拼接与批量标准化
阶段:特征工程阶段
解决的问题:建模前需要把用户基础信息、交易流水、历史逾期记录三张表合并成一张宽表。传统做法是用 SQL 手工写多个 JOIN,字段一多就难以维护。dplyr 的 join 系列配合 across 可以在一段管道里完成拼表、选列、标准化。
具体案例:某风控团队用 left_join() 按用户 ID 把三张表拼在一起,用 mutate(across(where(is.numeric), scale)) 对所有数值型特征做标准化,再用 group_by(user_id) %>% summarise(...) 生成用户级聚合特征,整个特征制作过程从两天缩短到两小时。
医学临床试验数据的长宽转换
阶段:数据预处理阶段
解决的问题:临床试验数据常以宽表存储(每位患者一行,每个访视时间点一列),但统计建模和绘图需要长表(每个患者-时间点一行)。手工转置极易出错,pivot_longer 可以精确、可逆地完成转换。
具体案例:某肿瘤临床试验记录了患者在第 1、2、3 个月的肿瘤尺寸,原始数据是每个时间点一列的宽表。统计分析师用 pivot_longer(cols = c(m1, m2, m3), names_to = "month", values_to = "size") 转成长表,再用 ggplot2 绘制每个患者的生长曲线,全程不到十行代码。
Python 演示
# 用 pandas 模拟 dplyr 核心流水线import pandas as pd def make_orders(): # 原始订单表: 订单号, 桌号, 菜品, 单价, 数量, 是否缺货 orders = [ {"order_id": "A01", "table": 1, "dish": "kung_bao", "price": 38, "qty": 2, "out_of_stock": False}, {"order_id": "A02", "table": 1, "dish": "rice", "price": 3, "qty": 4, "out_of_stock": False}, {"order_id": "A03", "table": 2, "dish": "pork", "price": 58, "qty": 1, "out_of_stock": True}, {"order_id": "A04", "table": 2, "dish": "veg", "price": 22, "qty": 2, "out_of_stock": False}, ] return pd.DataFrame(orders) def main(): df = make_orders() # 管道式数据处理: 筛选 -> 新列 -> 分组汇总 result = ( df # filter: 剔除缺货菜品 .query("out_of_stock == False") # mutate: 为每道菜计算小计 .assign(subtotal=lambda d: d["price"] * d["qty"]) # group_by 桌号 + summarise 每桌小计之和 .groupby("table", as_index=False)["subtotal"].sum() ) print(result) if __name__ == "__main__": main()
make_orders()构造一份示例订单表,对应厨房里的原始点菜单。df数据框对象,等价于 R 的 data.frame,是流水线起点。.query("out_of_stock == False")等价于filter(),按行条件筛掉缺货菜品。.assign(subtotal=lambda d: d["price"] * d["qty"])等价于mutate(),用现有列算出新列“小计”。.groupby("table", as_index=False)["subtotal"].sum()等价于group_by()+summarise(),按桌号汇总小计之和。print(result)输出最终结果,每一桌的消费总额一目了然。
常见误区
误区“我想把数据里某一列留下来,于是写了 filter(df, 列名),结果输出是空的。filter 不是过滤吗?怎么把整列过滤掉了?”
正解filter 是“按行筛”,它检查每一行的值是否满足条件;select 才是“按列挑”。想留列请写 select(df, 列名)。两者的分工就像漏勺捞食材和拣菜盘挑配料,一个管“哪些行留下”,一个管“哪些列留下”。
为何常见:SQL 里 select 同时管行和列,很多新手把数据库经验直接搬进 R,忽略了 dplyr 把动词拆分得更细。
误区“我写了 `df %>% filter(df$price > 30) %>% select(df$name)`,结果报错找不到列。管道不是已经传了 df 吗?”
正解管道已把 df 作为第一个参数传给下一步,在函数内部应该直接用列名 price 和 name,不要再写 df$price。管道传送带已经把菜单送过来了,你只需要在菜单上写字,不必再把整本菜单从传送带上拿下来。
为何常见:习惯了用 $ 取列,总觉得“不写全名不放心”,其实管道里的数据就是当前那一步的输入,引用列名足够。
误区“我运行了 `group_by(df, table)`,数据一点变化都没有,是不是函数根本不起作用?”
正解group_by 只做“打分组标签”,真正起效要等后续的 summarise 或 mutate。这是 R 把“分组状态”和“聚合计算”拆成两步的优雅设计——夹子夹好单子后,算盘还没拨,自然看不出变化。
为何常见:SQL 的 GROUP BY 一写就立即聚合,而 R 的 group_by 是惰性的,等待后续操作触发,新手看到“没变化”就容易误判。
误区“我用 pivot_longer 把三列并成一列之后,原来的列名不见了,是不是数据被破坏了?”
正解列名变成了“名称”列里的值,信息完整无损,只是换了个存放位置。用 pivot_wider 可以完全还原。就像把宽柜格的标签摘下来贴到长清单上,食材没有少,只是整理方式变了。
为何常见:人眼习惯了表头,看到列名消失就慌,其实表格的价值在信息本身,不在表头的位置。
误区“我辛辛苦苦清洗完数据,`mean(df$price)` 却返回 NA,以为是数据全坏了。”
正解R 的均值默认不舍弃缺失值——只要数据里有 NA,结果就是 NA,这是刻意为之,为了提醒你数据里还有“字迹不清的单子”。需要 na.rm = TRUE 才会跳过 NA 计算。drop_na 和 replace_na 是在源头处理,na.rm 是在计算时绕过。
为何常见:Excel 的 AVERAGE 会自动忽略空白单元格,R 更严谨,默认行为不同,习惯 Excel 的人最容易踩这个坑。
面试题
Q1. 在 dplyr 中,filter() 函数的作用是什么?
- A.筛选符合条件的行← 正确答案
- B.挑选指定的列
- C.对数据表按列排序
- D.生成一个新的计算列
答案:A。filter 按行筛选,就像漏勺捞食材;select 才是选列,arrange 是排序,mutate 是生成新列。四者各司其职,不能混淆。
Q2. df %>% filter(price > 10) %>% select(name) 等价于以下哪个?
- A.
select(filter(df, price > 10), name)← 正确答案 - B.
filter(select(df, name), price > 10) - C.
select(df, name) %>% filter(price > 10) - D.
select(filter(df, price > 10), price)
答案:A。管道从左到右执行,先 filter 再 select,等价于先内层后外层的嵌套写法 A。其他选项要么调换了顺序,要么选错了列。
Q3. 关于管道操作符 %>% 的说法,哪些是正确的?
- A.把左值作为右函数第一个参数← 正确答案
- B.让代码从左到右自然阅读← 正确答案
- C.会直接修改原始数据框
- D.可以减少中间变量的使用← 正确答案
答案:ABD。管道是传送带,把结果传给下一个函数当第一个参数,也让代码可读性更好、少写中间变量。但它不会修改原数据框,C 错误。
Q4. group_by(df, city) 单独运行后会发生什么?
- A.数据框按 city 排序
- B.数据框按 city 去重
- C.看起来没有明显变化← 正确答案
- D.每行被合并成一组
答案:C。group_by 只打分组标签,类似用夹子把同一桌的单子夹好,但还没算账。必须等后续 summarise 或 mutate 才会看到分组效果。
Q5. summarise() 中哪个函数可以统计组内行数?
- A.count()
- B.n()← 正确答案
- C.length()
- D.size()
答案:B。n() 是 dplyr 专属的无参计数函数,在 summarise 里调用即可返回组内行数。count() 是另一套快捷 API,length() 需要传参数,size() 不存在于 dplyr。
Q6. 关于 pivot_longer() 的效果,正确的描述是?
- A.把多列压缩成“名称-值”两列← 正确答案
- B.把长表还原成宽表
- C.列数减少、行数增加← 正确答案
- D.自动删除缺失值
答案:AC。pivot_longer 把宽表变长表,多列堆叠成“名称-值”两列,列数减少、行数增加。还原宽表要用 pivot_wider;删除缺失值是 drop_na 的职责。
Q7. 在 mutate() 内使用 across() 的效果是什么?
- A.对选中的多列执行同一个函数← 正确答案
- B.对所有行逐行执行函数
- C.只对第一列执行函数
- D.生成交叉验证的结果
答案:A。across(cols, fun) 对 cols 中的每一列应用 fun,像一勺盐撒一排菜。它作用于列,不是行;也不是只处理第一列,更不是交叉验证。
Q8. left_join(df1, df2, by = "key") 返回什么结果?
- A.只返回两表都匹配的行
- B.返回 df1 全部行加 df2 匹配列← 正确答案
- C.返回 df2 全部行加 df1 匹配列
- D.返回两表的笛卡尔积
答案:B。left_join 以左表为主,df1 每一行都保留,df2 只提供匹配行的字段;匹配不到的补 NA。A 是 inner_join,C 是 right_join,D 是 cross_join。
Q9. 下列哪个函数能把缺失值填充为指定值?
- A.drop_na()
- B.replace_na()← 正确答案
- C.na.omit()
- D.is.na()
答案:B。replace_na() 用指定值替换 NA,像给涂改不清的菜单补上信息。drop_na 是删除含 NA 的行,na.omit 是 base R 里的删行函数,is.na 只是做缺失检查。
Q10. 关于 dplyr 动词的共同特点,正确的是?
- A.不修改原始数据框← 正确答案
- B.返回新的数据框← 正确答案
- C.必须配合管道才有效
- D.第一个参数总是数据框← 正确答案
答案:ABD。dplyr 动词的第一个参数都是数据框,输出新数据框且不改原数据。管道只是提升可读性的语法糖,不是必需的——单独调用动词同样有效,C 错误。