资讯详情 R语言数据清洗实战:tidyverse管道符与dplyr核心操作指南
📅 2026/10/24 1:27:54
数据清洗这件事干过数据分析的都懂看着简单做起来烦。尤其是拿到一份乱七八糟的原始数据——列名不统一、重复行一大堆、缺值散落各处、该合并的表还分了好几个文件——光靠基础函数一个个处理代码能写到怀疑人生。我自己用R语言做数据清洗大概五年了从最早的base函数硬扛到后来转投tidyverse生态最直观的感受就是dplyr这套语法加上%%管道符真的把数据操作从“写代码”变成了“说人话”。这篇博文就围绕我在实际项目里最常用的几个操作——合并、添加、删除、筛选、去重——完整记录一下tidyverse包的实战用法尤其是管道符怎么用才能让代码既短又不容易错。这篇文章适合刚接触R语言、被各种apply函数和嵌套括号搞到头大的新手也适合已经会用dplyr但还没完全体会到管道符威力的进阶用户。我会从最基础的管道符号讲起然后按数据清洗的常见动作逐一拆解每个操作都配上可以直接复制的代码和实际场景最后整理一份我在真实项目中踩过的坑帮你少走一些弯路。1. 先说清楚管道符 %% 到底解决了什么问题1.1 从嵌套函数到管道流的演变很多R语言初学者第一个崩溃的瞬间就是看到一串这样的代码result - summarize(group_by(filter(df, status active), department), avg_salary mean(salary, na.rm TRUE))读这段代码得从里往外读先filter再group_by最外面才是summarize。当你需要调整中间某一步的时候括号改起来更是灾难。这种写法叫做“嵌套函数”逻辑上没错但可读性太差而且一旦某层括号配错报错信息能让人排查半天。magrittr包提供的管道符%%解决的就是这个问题。上面那段代码用管道符改写之后长这样result - df %% filter(status active) %% group_by(department) %% summarize(avg_salary mean(salary, na.rm TRUE))现在读代码就像顺着一条流水线往下走先把df扔进去过滤再把过滤后的结果分组最后汇总。每一行的动作都是独立的想在哪一步加操作就直接加一行不用管括号嵌套的问题。tidyverse在设计上有一个核心理念就是“每一步操作都作用于一个数据框并且返回一个数据框”。这个理念和管道符是天作之合——上一个函数输出的数据框直接成为下一个函数的输入数据像水一样在管道里流动。这也是为什么tidyverse官方的教程总是强调用%%来组织代码因为它把数据处理变成了一种可读、可维护的流程。1.2 管道符的底层逻辑与使用禁忌很多人最开始对%%的理解是“把左边的结果传给右边函数的第一个参数”。这个理解方向是对的但不够精确。实际上%%是把左边的值插入到右边函数调用的第一个参数位置。比如x %% f(a, b)等价于f(x, a, b)。有一些特殊情况值得注意。比如你要传给函数的不是第一个参数可以用.来占位# 不指定位置时默认传给第一个参数 df %% head(5) # 用 . 显式指定位置 df %% lm(y ~ x, data .)上面第二段代码中lm函数的第一个参数是公式第二个才是data所以要用.告诉管道符把df放到data这个位置上。还有一个容易踩的坑%%和tidyverse函数配合使用时某些函数会改变数据框的结构比如group_by()后数据框带有分组属性如果不注意ungroup()后续操作会在不知不觉中按分组逻辑执行。我见过不少人在group_by()之后直接mutate()结果发现计算逻辑和预期完全不一样这就是没搞懂管道里数据状态的变化。另外管道符也不是越多越好。如果你的管道链超过十行中间又没有保存中间结果一旦某一步报错排查起来就得从头到尾读一遍。我的习惯是当一个清洗流程包含多个明显阶段时比如“读取合并”和“筛选加工”可以在中间用-保存一次中间结果保证代码可读性和可调试性而不是一个管道通到底。1.3 什么时候不建议用管道管道符虽好但不是万能药。我总结了几种不适合用管道的情况第一函数需要返回的结果不只是数据框。比如summary()、plot()这类函数它们要么返回列表要么是画图的副作用管道链路变得不纯读起来反而别扭。第二当你需要在管道中间插入调试代码的时候。%%调试不像普通代码那样可以随意在中间加print()虽然可以用%T%这种特殊的管道符号解决但对新手来说增加了理解负担。第三处理非常大的数据时。管道符本身几乎没有性能损耗但如果你在管道里多次使用filter()或mutate()每一步都会复制一份数据大数据集下内存消耗会比想象中高。这种情况可以适当用data.table的做法或者在管道前先把数据用as.data.table()转成data.table格式。我现在日常的写法是数据处理流程用tidyverse管道性能敏感或者数据量超大的环节单独优化。两种工具并不互斥配合使用反而最顺手。2. 合并数据横向纵向到底怎么选2.1 行合并把多个结构相同的表堆起来行合并说白了就是把两个结构一样的表上下拼接。实际场景中最典型的就是按月分文件存放的订单数据月底了要把12张表合成一张年度总表。用dplyr实现行合并有两个常用函数bind_rows()和rbind()。后者是base包的老牌函数但用起来有一些限制最烦人的就是列名必须完全一致否则直接报错。bind_rows()则宽容得多它会自动匹配列名如果某个表多了或少了列它会用NA补全不会甩脸色给你看。library(tidyverse) # 模拟两个月的订单数据 order_jan - tibble( order_id 1:3, customer c(张三, 李四, 王五), amount c(120, 85, 230) ) order_feb - tibble( order_id 4:6, customer c(赵六, 钱七, 孙八), amount c(310, 95, 150) ) # 行合并 all_orders - bind_rows(order_jan, order_feb)如果列名对不上bind_rows()会在结果里为缺失的列补上NA。这看起来友好但要注意如果你事后没有处理这些NA某些聚合函数的结果会受影响。所以行合并之后我一般会顺手做一次缺失值检查。bind_rows()还有一个好用的场景就是配合循环批量读取文件。比如你有一堆CSV文件文件名有规律可以用list.files()列出所有路径然后循环读取最后一次性bind_rows()file_list - list.files(data/orders, pattern *.csv, full.names TRUE) all_data - file_list %% map(read_csv) %% bind_rows()这里用到了purrr::map()它会把每个CSV文件读进来生成一个列表bind_rows()再把这个列表里所有数据框堆成一个大表。这种写法在真实项目里非常常见省去了手动写几十行rbind()的痛苦。2.2 列合并横向拼接的两种姿势列合并是把两个表左右拼在一起类似于Excel里的VLOOKUP之后把匹配到的列贴到旁边。在dplyr里最基础的列合并函数是bind_cols()它直接把两个数据框按行位置拼起来不做任何匹配。这个函数的适用场景很窄——只有当两个表的行顺序完全一致、行数完全一致时才建议使用。举个例子你有一份学员基础信息表一份学员期末成绩表两张表的行都是按学号排序的顺序完全一样这时候才能放心用bind_cols()。但现实中这种完美对齐的情况很少大部分时候你需要的其实是按某列匹配的join操作。# bind_cols 示例仅当顺序完全一致时可用 info - tibble( id 101:105, name c(A, B, C, D, E) ) scores - tibble( score c(88, 72, 95, 60, 81) ) combined - bind_cols(info, scores)注意bind_cols()不会检查两个表是否有共同的列名如果两个表里都有id列合并之后会出现id...1和id...2这种自动改名的列使用时要小心。2.3 关联合并join系才是真正的核心实际项目中绝大多数合并需求都是“按某个键匹配”。dplyr提供了完整的join家族left_join()、right_join()、inner_join()、full_join()、semi_join()、anti_join()。不同join的区别可以用很简单的话说清楚left_join(a, b)以a表为主b表能匹配上的就拼过来匹配不上的填NA。right_join(a, b)以b表为主a表能匹配上的就拼过来匹配不上的填NA。inner_join(a, b)只保留两边都匹配得上的行。full_join(a, b)保留两边所有行匹配不上的填NA。semi_join(a, b)保留a中能在b里匹配到的行但不拼接b的列。anti_join(a, b)保留a中在b里匹配不到的行。我用一个电商场景来演示。假设你有两份数据订单表orders和用户表usersorders - tibble( order_id 1:5, user_id c(101, 102, 103, 104, 105), amount c(80, 120, 45, 200, 60) ) users - tibble( user_id c(101, 102, 103, 106), name c(张三, 李四, 王五, 赵六) ) # 左连接保留所有订单用户信息能匹配就匹配上 orders_with_user - orders %% left_join(users, by user_id)结果里用户104、105的name会是NA因为users表里没有这两个人的信息。如果想知道哪些订单的用户不在users表里用anti_joinorphan_orders - orders %% anti_join(users, by user_id)这个操作在数据质量检查里非常有用。比如怀疑数据里存在大量无效用户IDanti_join一眼就能查出来。关于join的一个高频坑是by参数不写对。如果两个表的连接键列名不一致比如一个是user_id一个是id就需要写成by c(user_id id)。这个语法特别容易记混我的记忆方式是把它理解成“左表的user_id等于右表的id”。orders %% left_join(users, by c(user_id id))还有一点join时如果连接键在两个表里是一对多或多对一结果会发生行数膨胀。比如订单表一个用户有多笔订单用户表每个用户只有一条信息left_join后订单表的行数不变但如果用户表里有重复的用户IDleft_join后订单表行数会直接翻倍。这种重复键问题常常是隐藏的数据陷阱。3. 添加数据新增列与新增行的正确姿势3.1 mutate新增列的万能工具mutate()应该是dplyr里使用频率最高的函数之一。它的作用是在数据框里创建新列而且新列可以基于已有列进行计算。实际操作中我经常用它做金额标准化、日期提取、数值分箱等操作。df - tibble( product c(A, B, C), price c(10, 20, 30), quantity c(2, 1, 4) ) # 新增一列计算销售额 df - df %% mutate(total price * quantity) # 新增多列用逗号隔开 df - df %% mutate( total price * quantity, discount if_else(quantity 2, 0.9, 1), final total * discount )mutate()的变量可以引用管道里已经创建好的新列比如上面最后一行final引用了total和discount。这意味着你可以让管道里的每一步计算都建立在前面步骤的基础上逻辑极其清晰。还有一个我很常用的变体是transmute()它只保留你新建的列丢弃原有的所有列。适用场景是你新建了大量派生特征原始列已经不需要了直接用transmute()一步到位不用先mutate()再select()。mutate()配合case_when()可以实现复杂的条件赋值逻辑替代嵌套ifelse()df - df %% mutate( price_level case_when( price 15 ~ 低价, price 25 ~ 中等, TRUE ~ 高价 ) )case_when()比ifelse()清晰在两点一是支持任意数量的条件分支二是条件是从上到下匹配的写起来像读规则表不太容易出现逻辑覆盖错误。3.2 add_row手动往数据框里塞一行add_row()在建模项目里使用频率不算高但在某些场景——比如需要往汇总表里加一个“总计”行或者修正数据中缺失的行——是真的好用。summary_table - tibble( region c(华东, 华南, 华北), revenue c(1200, 980, 750) ) summary_table - summary_table %% add_row(region 总计, revenue sum(revenue))这个操作如果用base函数写得用rbind()再加上类型匹配检查代码啰嗦不少。add_row()直接处理数据框即可且新增行后面的参数就是列名和值可读性很高。有个细节要注意add_row()默认加在数据框的最后一行。如果想把行加到特定位置官方并没有直接参数一般做法是先把原数据按需要分割再用bind_rows()重新拼起来。实际上我很少遇到这个需求所以这个限制对我影响不大。3.3 基于已有数据做条件更新很多人以为“添加数据”就只是新增列和新增行其实还有一种常见操作本质上也是添加——根据条件给已有数据重新赋值。比如把某个异常值替换成NA或者按区间给数值打标签。df - df %% mutate( price if_else(price 0, NA_real_, price) )mutate()加上条件判断既能新增列也能覆盖原有列。要特别注意if_else()这个函数它是dplyr提供的和base::ifelse()有两点明显区别一是要求true和false两个分支的类型严格一致所以上面代码里填的是NA_real_而不是NA因为price是数值型二是它会保留NA不参与判断。NA_real_这种写法是R语言中数值型缺失值的显式表达。如果你用if_else(price 0, NA, price)会报错说类型不一致这个问题新手尤其常见务必留意。4. 删除数据列删多了后悔行删错了难受4.1 select删列还是留列先想清楚select()这个函数名听起来是“选择”但它在数据清洗中承担的一个重要职责就是删除列。删除的逻辑有两种不同的思路一种是明确指定要删除哪些列另一种是明确指定保留哪些列。我强烈推荐后者——理由很简单删列的时候你脑子里想的往往是“这些列没用”但现实中判断“哪些列有用”通常更确定也更安全。df - tibble( id 1:4, name c(张三, 李四, 王五, 赵六), temp_1 runif(4), temp_2 runif(4), useless rep(1, 4) ) # 明确保留需要的列 df_clean - df %% select(id, name) # 明确删除不要的列 df_clean2 - df %% select(-temp_1, -temp_2)当你需要保留的列很多、删除的列只有一两列时select(-col)更简洁。当你面对一个几十列的表只想留下几个核心字段时直接select(col1, col2, col3)更稳妥。这两种思路灵活切换才是select()的正确使用姿势。select()还支持一些便捷的列选择辅助函数starts_with(date)选择所有以date开头的列ends_with(_id)选择以_id结尾的列contains(name)选择列名里包含name的列。处理宽表时这些功能能帮你少打很多字也能避免手写列名时拼错。# 批量选择列 df %% select(starts_with(sales_), ends_with(_date))4.2 filter删除行其实是对条件的保留严格来说filter()做的是“保留符合条件的行”但从数据清洗的角度看它的效果就是“删除不符合条件的行”。这个函数有两个最常见的坑。第一个坑filter()里多个条件用逗号分隔时是“且”的关系不是“或”。需要“或”时得用|符号# 且同时满足 df %% filter(status active, amount 100) # 或满足任一条件 df %% filter(status active | amount 100)第二个坑filter()处理NA的方式很反直觉。如果你写filter(amount 100)amount是NA的行会被默认剔除掉因为NA 100的结果也是NA而filter()不会保留结果为NA的行除非你显式用is.na()判断。胆小的做法是分开处理# 保留金额大于100的或者金额本来就缺失的行 df %% filter(amount 100 | is.na(amount))4.3 行删除的其他武器slice、distinct、drop_na除了filter()还有几个专门用于行删除的函数。slice()按行号选择比如删除前几行或保留中间某几行# 删除第一行 df %% slice(-1) # 保留第2到第5行 df %% slice(2:5)drop_na()专门用来删除含有缺失值的行可以指定列# 删除所有列含有缺失值的行 df %% drop_na() # 只在特定列上检查缺失删除这些列上有缺失的行 df %% drop_na(amount)我特别推荐在数据清洗流程的末尾统一执行一次drop_na()把前面各种合并、拼接产生的意外缺失值一次性清理掉。但要小心如果一行数据里大部分列都有值只有最后一列某一行少了个值这一行被drop_na()删掉可能损失太多信息。这种时候用filter(!is.na(关键列))会更精准。5. 筛选数据条件判断的每个细节都是魔鬼5.1 比较运算符和逻辑组合filter()的条件判断依赖R语言的比较运算符和逻辑运算符。数值筛选是最常见的场景总结下来有几种典型用法# 等于、不等于 df %% filter(region 华东) df %% filter(region ! 华南) # 大于、小于、范围 df %% filter(price 50) df %% filter(price 50 price 100) # 属于某个集合 df %% filter(category %in% c(A, B, C))%in%是R语言里的一个便捷操作符用于判断左侧值是否出现在右侧集合中。它比一连串的加|简洁得多也不容易出现括号配对错误。实际使用中%in%的另一个好处是会自动把NA处理成FALSE——这意味着filter(category %in% c(A))不会保留category为NA的行。如果需要保留缺失行要显式加| is.na(category)。5.2 字符串模糊筛选str_detect帮你搞定很多情况下筛选条件不是精确值而是模糊匹配。比如你想筛选所有产品名为“智能手表”系列的数据但实际字段可能是“智能手表Pro”“智能手表Mini”“智能手表2代”用肯定不行这时候可以用stringr::str_detect()library(stringr) df %% filter(str_detect(product, 智能手表))str_detect()是stringr包提供的函数它的第一个参数是字符向量第二个参数是正则表达式模式返回的是TRUE/FALSE逻辑值。管道符配合使用时写法非常自然# 筛选产品名以智能开头或者包含手表的记录 df %% filter(str_detect(product, ^智能) | str_detect(product, 手表))关于正则表达式不需要一开始就学得很深掌握几个基础元字符就够了^表示开头$表示结尾.表示任意字符*表示前面字符出现零次或多次。用str_detect(col, ^2024-)可以快速筛选出col列以2024-开头的所有记录这个操作在筛选日期字符串时特别常用。5.3 日期和时间的筛选技巧如果数据里包含日期列筛选逻辑就不仅是比较大小那么简单了经常需要按月、按季、按年聚合筛选。我一般在清洗阶段就把日期字符串统一转成Date类型再用lubridate包处理。library(lubridate) df - df %% mutate(order_date ymd(order_date)) # 筛选2024年3月之后的订单 df %% filter(order_date ymd(2024-03-01)) # 筛选2024年的数据 df %% filter(year(order_date) 2024) # 筛选本月数据 df %% filter(month(order_date) month(today()), year(order_date) year(today()))lubridate包的ymd()函数能把2024-03-15这种字符串解析成Date对象它比base::as.Date()更智能的一点是能自动识别多种格式比如2024/03/15、20240315都能处理。如果数据里混了好几种日期格式ymd()是最省心的选择。日期筛选最容易翻车的情况是日期列在原始数据里是字符型直接拿2024-03-15 2024-03-01比较结果可能没错但一旦格式变成2024-3-5字符串比较的结果就完全不可靠了。所以我的习惯是数据读进来之后第一件事就是把日期列解析成真正的日期类型后面所有的筛选逻辑才稳。6. 去重distinct和重复数据的爱恨情仇6.1 distinct的基本用法dplyr::distinct()是数据清洗里去重的首选函数。它最简单的用法是保留数据框里所有列组合完全不重复的行等价于base::unique()的效果df - tibble( id c(1, 1, 2, 3, 3), value c(10, 10, 20, 30, 30) ) df %% distinct()结果会保留id 1, value 10一行id 3, value 30一行其余重复行被删除。更常见的使用方式是只根据某几列去重比如同一用户ID出现了多次你只想去重到用户维度# 根据 id 列去重保留每一行第一个出现的值 df %% distinct(id, .keep_all TRUE).keep_all TRUE的意思是除了用于判断的列这里是id其他列保留首次出现的值。如果不加这个参数distinct()只会返回被判断的那几列其他列全部丢弃。这个参数极其重要我见过不少同学就是因为忘了加.keep_all TRUE结果去重之后其他字段全没了数据表直接变成只有几列。6.2 多列组合去重与条件去重去重不一定只按单列经常需要按多列组合判断。比如一张表里有user_id和product_id你想以“同一个用户买同一个产品”为粒度去重df %% distinct(user_id, product_id, .keep_all TRUE)这段代码的意思是保留每个user_id product_id组合的第一条记录。这种多维去重在清洗订单明细数据时非常常见比如一个用户可能因为系统异常产生了重复下单记录按用户和产品两个维度去重就能把异常重复消掉。还有一类需求是条件去重。比如你希望某个分组内只保留满足特定条件的行同时去掉同组内的其他重复行。这种场景单独用distinct()处理不了一般先筛选再distinct()组合使用df %% arrange(desc(score)) %% distinct(student_id, .keep_all TRUE)先按score降序排列再按student_id去重这样保留的就是每个学生分数最高的那条记录。这个操作是我做学习数据分析时最常用的套路之一——给每个学生保留最高成绩或者给每个产品保留最新价格。6.3 去重之前必做的检查去重是非常危险的“破坏性”操作一旦重复行里包含的信息和保留的那行不一样删掉之后数据就丢了。所以去重之前我强烈建议先做一遍重复情况摸底。用dplyr统计重复行数# 查看每个id出现次数 df %% count(id) %% filter(n 1)count()是dplyr的计数函数按id分组计数后n列表示每组出现次数。查到重复记录后再手动检查这些重复行的其他列是不是真的有差异# 检查重复id对应的其他字段 df %% filter(id %in% dup_ids) %% arrange(id)我记得有一次在清洗一份商品数据时表面上按sku_id去重没问题但仔细检查后发现同一sku_id下有的行有颜色信息有的行颜色是NA。如果简单去重留下的是带NA的那一行后面做颜色分析时这部分数据就无故缺失了。所以去重前检查重复行的信息完整度比去重本身更重要。遇到这种重复行信息互补的情况处理方式不是直接distinct()而是先汇总合并。比如颜色缺失的要用有值的行补齐可以按sku_id分组后用fill()或summarize()把非NA值合并出来。7. 常见问题与排查技巧实录7.1 管道符报错Error in pipe管道符报错最常见的原因就是左边的结果类型和右边函数期望的输入类型不匹配。比如你用read_csv()读进来的是tibble但用了base::plot()这类函数画图它期望的是向量或特定结构这时候管道链上就会报错。排查思路很简单把管道链拆开一步步确认每一步的返回类型。或者用glimpse()、str()函数查看当前数据结构df %% filter(condition) %% glimpse() # 暂停管道流查看当前数据结构glimpse()很好地嵌入了管道流它既不改变数据又能让你看到当前数据框的列名、类型和前几个值是排查管道报错最得力的工具。另一个经常遇到的报错是could not find function %%。这种情况几乎都是因为没加载tidyverse或magrittr包。我习惯在脚本开头统一写library(tidyverse)tidyverse是一揽子包加载一次就能同时使用dplyr、tidyr、stringr、lubridate、purrr等常用包的功能省心。7.2 合并时列名不一致或类型不一致我在数据清洗中最常碰到的问题就是合并两张表时“列名对不上”或者“类型对不上”。列名对不上的问题在2.3节已经提过用by c(左表列 右表列)解决。类型对不上则更隐蔽。比如一张表的user_id是数值型另一张表的user_id是字符型join的时候R会报错或者匹配不上。解决方法是先统一类型df1 - df1 %% mutate(user_id as.character(user_id)) df2 - df2 %% mutate(user_id as.character(user_id)) df1 %% left_join(df2, by user_id)数据清洗中“类型统一”这一步最好在读取数据之后立刻完成。我的习惯是每张表只要涉及到后续要合并的键列就第一时间确认该列在所有表中类型一致。7.3 filter和distinct时NA带来的迷惑行为filter()和distinct()与NA的交互都容易产生反直觉的结果这个坑值得单独讲。filter()的判断结果是NA的行默认被丢弃前面说过。distinct()的行为则相反所有参与去重的列只要有NA默认会把这些NA视为同一个值也就是说多个带NA的行可能被当成重复行只保留一条。举个例子你按id去重结果表里有两行id都是NA但其他列内容完全不同distinct(id)会把这两行合并成一行导致信息丢失。这种情况要么先处理缺失值要么用fill()把id补齐别让NA参与去重判断。7.4 性能优化大数据集上的清洗技巧tidyverse在处理百万行以下的数据时体验流畅但数据量上到千万行甚至更多管道链的每一步都复制数据性能会明显下降。如果你遇到这种场景我的经验是第一全程使用data.table包的fread()读取数据而不是read_csv()读取速度会快很多。第二如果清洗逻辑不复杂可以先用dplyr写出逻辑再把关键步骤用data.table重写。比如data.table的链式操作是DT[条件, 新列 : 计算, by 分组]虽然写法和tidyverse完全不同但性能优势巨大。第三尽可能减少管道链中mutate()的数量把能合并的计算合并到一条mutate()里。每一步mutate()都在创建新列数据框会不断扩容对内存消耗很大。这些经验都是在实际项目中一点点磨出来的。最开始我不管三七二十一全用tidyverse数据量一大就卡死后来学会按数据规模选择工具多大规模用多大规模的工具做数据分析的过程就顺畅多了。7.5 清洗流程的日常习惯最后分享几个我长期坚持的小习惯算是这套清洗流程之外的“隐藏心得”写清洗脚本时我会把代码分成“读取”“清洗”“导出”三块每块之间用注释明确分隔。清洗块里又按“合并处理”“缺失值处理”“去重处理”“筛选处理”四部分逐步推进逻辑层次感很强。这样即使过了很久再回头看代码也能快速定位某一步操作。清洗过程中每完成一个关键步骤就用glimpse()或者summary()看一眼数据概况。不要等到最后一起检查那时候出了问题就难以定位是在哪一步引入了错误。临时性的清洗脚本和项目正式的分析脚本分开存放。清洗脚本本来就是一次性的但谁也说不好明天会不会再跑一遍所以还是要把参数尽量写清楚比如读取路径、筛选阈值、去重字段都用变量定义而不是散落在代码各处。清洗好的数据一定要单独导出为一份新文件不要覆盖原始数据。我吃过亏——早期做清洗时直接在原数据上改等到需要回溯原始数据才发现早就被覆盖了那种欲哭无泪的感觉至今记得。这套tidyverse管道清洗的组合拳说白了就是一个不断打磨的过程。我刚上手时也觉得管道符别扭到处是%%不知道在干嘛现在用顺手了反过来再看那些没有管道符的清洗代码确实提不起修改的欲望。如果你也正在经历从base函数到tidyverse的过渡不必着急先把filter、select、mutate这几个最常用的函数玩熟再慢慢体会管道符的妙处你会发现数据清洗这件事真的可以像讲故事一样流畅自然。