数据清洗这件事,干过数据分析的都懂,看着简单,做起来烦。尤其是拿到一份乱七八糟的原始数据——列名不统一、重复行一大堆、缺值散落各处、该合并的表还分了好几个文件——光靠基础函数一个个处理,代码能写到怀疑人生。我自己用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和用户表users:
orders <- 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_join:
orphan_orders <- orders %>% anti_join(users, by = "user_id")这个操作在数据质量检查里非常有用。比如怀疑数据里存在大量无效用户ID,anti_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后订单表的行数不变;但如果用户表里有重复的用户ID,left_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这几个最常用的函数玩熟,再慢慢体会管道符的妙处,你会发现数据清洗这件事,真的可以像讲故事一样流畅自然。