R语言学习笔记又更新了。上一篇我们把R的基本数据结构过了一遍,从向量、列表到数据框,终于把“装数据的容器”搞明白了。这一篇笔记的主题,是数据分析里每天都要反复做的基础动作:数据处理中的变量和观测的增改选排。说白了,就是对数据框这个表格做四类操作:新增或修改列,新增或删除行,选择你关心的列或行,再按某种顺序给数据排排队。这些动作看着简单,但如果不搞清楚底层逻辑,后面一碰到分组汇总、画图、建模就会处处踩坑。所以这篇笔记适合刚学完R基础语法、想拿真实数据动手做清洗和整理的初学者;也适合对tidyverse不太熟、想系统梳理一遍操作套路的老手。
我尽量把每个函数的“为什么”也讲清楚,而不是只丢一段能跑的代码。毕竟笔记这东西,过两周回看,真正有用的不是命令本身,而是当时记录下来的判断逻辑和踩过的坑。
1. 数据框到底是什么:变量和观测要先分清
1.1 变量是列,观测是行,搞反了后面全乱
很多初学者第一次接触R语言的数据框时,会觉得它和Excel表格差不多:上面一行是列名,下面每一行是一条记录。这个直觉是对的,但R里有两个词需要刻意去记:变量是列,观测是行。变量描述的是“维度”,比如销售额、客户类型、订单日期;观测描述的是“样本”,比如某一个客户在某一天下的某一单。
这两个维度在后续操作里扮演的角色完全不同。比如你要计算“不同客户类型的平均销售额”,本质上是把观测按客户类型分组,然后对每一组的销售额变量做汇总。如果脑子里没有“列是变量、行是观测”这个坐标,很容易在用select时想留下几行,用filter时又想留下几列,最后代码写成一锅粥。
另一个需要留意的差异是:Excel的单元格可以随便填不同类型的数据,但R的数据框每一列只能是同一种类型。一列要么全是数值,要么全是字符,要么全是逻辑值。这个约束看起来死板,实际上保证了后面计算时不会出现“这一行是文字、下一行是数字”的尴尬局面。刚开始写R代码时多用str()看看每一列的类型,比盯着数据本身更管用。
下面构造一个最小的模拟订单数据,后面所有示例都基于它:
library(tidyverse) orders <- tibble( order_id = 1:10, customer_type = rep(c("个人", "企业"), each = 5), sales_amount = c(120, 80, NA, 95, 210, 350, 150, 60, 400, 280), quantity = c(2, 1, 3, 2, 4, 5, 2, 1, 6, 3), region = rep(c("华东", "华南", "华北"), length.out = 10) )这里tibble是tidyverse提供的数据框版本。它和基础R的data.frame很接近,但在打印的时候更克制,不会一次性刷出几千行;而且列类型保留得更严格,不太容易悄悄变成字符型。做数据处理练习时,我建议直接用tibble。
1.2 为什么我建议优先使用tidyverse而不是基础R
基础R当然也能完成增改选排,比如df[df$a > 100, c("a", "b")]这种写法。问题在于,一旦有多个步骤,基础R的代码会越套越深,阅读顺序从里往外跳,很容易看错。
tidyverse的核心思路是把“对数据的每一步操作”变成动词,再用管道符串起来。R 4.1之后原生支持|>,tidyverse里的%>%也完全兼容。举个例子,同样是“筛选销售额大于100的记录,再按地区分组计算平均销售额”,基础R可能写成:
aggregate(sales_amount ~ region, data = orders[orders$sales_amount > 100, ], FUN = mean)而tidyverse是这样:
orders %>% filter(sales_amount > 100) %>% group_by(region) %>% summarise(avg_sales = mean(sales_amount, na.rm = TRUE))后者的阅读顺序和操作顺序完全一致,像读一句流水账:先筛,再分组,再汇总。写起来不容易漏,回看时也舒服。更重要的是,dplyr的函数名都是语义化的动词,filter是筛选、mutate是修改、arrange是排序,看到函数名就知道这一步在干什么。
当然不是说你必须彻底抛弃基础R。某些时候基础R的代码更短,尤其是在处理单个向量时。但在处理“数据框”这个级别的数据结构时,tidyverse的稳定性、可读性和报错信息都更友好。学R的新手与其两套都记,不如先把tidyverse这一套练熟,基础R作为辅助阅读能力去掌握。
2. 变量的增改操作:mutate、rename与基础R的取舍
2.1 mutate新增变量:先搞清楚“行数一致”规则
新增变量最常用的函数是mutate()。它的核心逻辑是:对已有的列做某种计算,把结果作为一个新列追加到数据框里。比如计算每笔订单的客单价:
orders %>% mutate(avg_price = sales_amount / quantity)新列avg_price会出现在最右边。这里有个重要规则:mutate里生成的新列,长度必须等于数据框的行数,或者长度为一。因为R在计算时会做“向量循环”,如果新向量的长度是1,R会自动把它重复到每一行;如果长度是5而数据框有10行,R就会报错。你可以把它理解为:给全班同学发成绩单,你提供的成绩数量要么等于人数,要么干脆只给一个统一说明,不能只发一半人。
一个容易被忽略的用法是:在同一个mutate里,新列可以被后面的表达式引用。比如:
orders %>% mutate( total_amount = sales_amount * quantity, total_amount_plus_tax = total_amount * 1.13 )这种“先建中间列,再基于中间列继续算”的方式非常实用,不用为了一个中间结果单独写一行代码。
如果你只想保留新列,可以用transmute(),它和mutate的语法完全一样,但结果只保留你指定的那些列。调试时特别方便,可以快速看看某个计算结果对不对。基础R里相似的函数是transform(),但它的表达能力弱一些,不太适合在列之间做复杂引用。
2.2 批量改列与跨列计算:across()的正确用法
处理真实数据时,很少只改一列。你经常会遇到“所有数值列都要把缺失值替换成0”“所有字符列都要去掉空格”这类批量需求。如果一个个列去写mutate,代码会又臭又长。dplyr的across()函数就是为这种场景设计的。
基本写法是:
orders %>% mutate(across(where(is.numeric), ~ ifelse(is.na(.), 0, .)))这里where(is.numeric)表示“选择所有数值列”,后面的匿名函数对每一列分别执行:如果这一行是NA,就填0,否则保留原值。across()的特点是:选择列的方式可以非常灵活,支持直接写列名、写c()组合、写where条件,甚至写starts_with("sales")这类前缀匹配。
再比如你想把字符列统一修剪空格:
orders %>% mutate(across(where(is.character), ~ str_trim(.)))这里str_trim()来自stringr包,也是tidyverse的一部分。批量修改时,最怕的是选了不该选的列。所以across()搭配where()使用时要冷静确认:究竟是“所有数值列”还是“某些数值列”。如果只想选指定的几列,可以直接写across(c(sales_amount, quantity), ...),比where更直观。
修改变量名有专门的rename()函数,写法是rename(新名字 = 旧名字)。需要注意这里等号左边是新名,右边是旧名,方向容易搞反。批量改名用rename_with(),配合一个函数作用在列名上:
orders %>% rename_with(~ toupper(.), starts_with("sales"))这条会把所有以sales开头的列名改成大写。实际项目里最常见的用法是把列名统一改成小写,或者把空格替换成下划线。
2.3 删除变量、调整位置和基础R对照
删除变量本质上也是一种选择,用select()就够了。select(-sales_amount)表示去掉sales_amount这一列;select(-c(quantity, region))去掉多列;select(where(is.numeric))只留数值列。它和mutate、filter听起来不同,但在“变量维度”上的操作都归select管,这一点要记牢。
有时候你想把某个新列放到第一列或第二列,可以用.before和.after参数:
orders %>% mutate(avg_price = sales_amount / quantity, .before = region)这样avg_price会插到region前面。默认情况下新列永远在最后面,但在你打印数据框、或者把结果导出给同事看时,列的位置会直接影响阅读体验。调位置这种小事,虽然不影响计算结果,但在交付时很加分。
基础R里新增和修改列最原始的方式是直接$赋值:
orders$avg_price <- orders$sales_amount / orders$quantity好处是直观,坏处是一旦写错,原数据框可能已经被污染。尤其你在交互式环境里跑,修改是直接作用于原对象的。tidyverse的约定是“不修改原数据,而是返回新数据框”,这让你可以放心地反复试验,错了就重新跑一步,原数据始终保底。
这里必须补充一个我踩过的坑:用mutate新增列时,如果原来的列是字符型数字,直接做除法一定会报错。比如一列写着“120”但类型是chr,sales_amount / quantity会提示“非数值参数”。所以在增改之前,先用str()或者glimpse()看一眼类型,该转numeric就用mutate(across(c(sales_amount), as.numeric)),不要等报错才回去查。
3. 观测的筛选与新增:filter、slice、bind_rows一条龙
3.1 用filter按条件筛选:等号、NA与多个条件的坑
筛选观测的核心函数是filter()。它按行操作,保留满足条件的行。语法上最让新手崩溃的是:判断相等必须用双等号==,单等号=是赋值。这个错误R不会报错,而是会在某些上下文里产生匪夷所思的结果,非常隐蔽。
比如筛选出个人客户的订单:
orders %>% filter(customer_type == "个人")多个条件可以直接用逗号分隔,等价于AND:
orders %>% filter(region == "华东", sales_amount > 100)这里有个很容易被忽略的细节:如果sales_amount列里有NA,那么sales_amount > 100这个条件的结果也是NA,而filter遇到NA会默认把这一行删掉。也就是说,NA不会报错,但会“悄悄消失”。如果你希望保留NA的数据,必须显式写出:
orders %>% filter(sales_amount > 100 | is.na(sales_amount))这种对缺失值的处理逻辑,是R语言里非常核心的思维。NA不只是“没有值”,它甚至会影响判断结果。用Excel的人习惯了空单元格自动被忽略,但R里必须自己决定“要不要、怎么处理缺失值”。
相比基础R的subset(),filter()还有一个优势:subset()在按列条件筛选时,如果列里有NA,会直接剔除那些行,不管你愿不愿意。dplyr的filter则把判断逻辑交给你,更透明。刚入门时不必纠结“哪个更好”,但至少要知道两者的行为差异,否则换个函数结果不同,你都不知道问题出在哪。
3.2 按位置选行:slice与抽样
筛选除了按条件,还可以按位置。slice()就是“给我第几行”的函数:
orders %>% slice(1:3)这个操作在查看前几行、或者随机抽部分数据做测试时特别常用。类似的功能还有:
- slice_head(n = 5):前5行,相当于head()
- slice_tail(n = 5):后5行,相当于tail()
- slice_sample(n = 3):随机抽3行
- slice_min(orders, sales_amount, n = 2):销售额最小的2行
- slice_max(orders, sales_amount, n = 2):销售额最大的2行
删除某些行,既可以用slice(-1:-3),也可以用filter(!(order_id %in% c(1, 2, 3)))。前者适合明确知道行号的情况,后者适合按业务逻辑排除。
实际处理脏数据时,slice_sample的用处很大。当数据有几百万行,你想先抽一小部分调试代码,它比一行行数行号高效太多。还有个容易被忽略的函数是distinct(),它可以按某些列去除重复观测。比如同一个客户在同一日期可能有多个订单,但你只关心唯一的组合,就可以:
orders %>% distinct(customer_type, region)这里distinct()返回的仍然是数据框,而不是单纯的两列向量,这跟base R里的unique()行为不一样。
3.3 新增观测:bind_rows比rbind稳在哪
增加观测的第一反应可能是rbind(),但如果你用tidyverse,我更推荐bind_rows()。原因是rbind()要求两个数据框的列完全一致,而且按位置对齐;一旦列顺序不一样、列名不一样,很容易出错或者生成一个列名错位的表。bind_rows()则按列名进行匹配,哪怕两个数据框的列顺序不同也能正确拼接。
比如有一个新订单要追加:
new_order <- tibble( order_id = 11, customer_type = "个人", sales_amount = 320, quantity = 4, region = "西南" ) orders %>% bind_rows(new_order)如果orders里还有一列“是否发货”,而new_order里没有这一列,bind_rows()会为缺失的那列自动填上NA。这个行为在多数场景下是合理的,但你要知道它存在,别以为合并后所有列都完整。
手动构造小数据集时,更省事的写法是用tribble():
new_orders <- tribble( ~order_id, ~customer_type, ~sales_amount, ~quantity, ~region, 11, "个人", 320, 4, "西南", 12, "企业", 150, 2, "华北" )tribble()是“按行填写”的构造方式,特别适合在代码里临时录入几条记录。再配合bind_rows()和原数据合并,整个流程非常顺。
这里有一个因子变量的坑必须单独说:如果两个数据框的同一列是factor类型,但因子水平不一致,rbind()很可能把数据强制转成整数编码,结果你看到的是1、2、3而不是原来的分类名称。dplyr的bind_rows()在处理因子时也会遇到合并问题,比较稳妥的做法是:合并前先把这类列统一转成字符型,或者用forcats::fct_unify()统一因子水平。真实项目里,我一般倾向把分类列在清洗阶段就转成字符型,因为很多后续建模步骤对字符型更宽容,反而不会误把分类变量当成数值。
4. 数据排序:arrange进阶与order的取舍
4.1 arrange多列排序与desc()
排序是数据处理里最不起眼、但最容易翻车的一环。tidyverse里用arrange()进行排序,默认升序,降序需要用desc()把列包起来:
orders %>% arrange(region, desc(sales_amount))这条命令的含义是:先按region字段升序排列,在同一区域内部,再按sales_amount从大到小排列。arrange()的多列条件是从左到右依次生效的,这个顺序决定了排序的层级结构。你可以手动对比:
arrange(orders, desc(sales_amount), region)和上面那条结果完全不同,因为第一优先列变了。
关于缺失值,arrange()的默认行为是把NA放到最后,无论是升序还是降序。这省去了很多手动处理。基础R里的order()则更像一个“索引生成器”,它返回的是排序后的行号向量,你需要再用df[order(df$col), ]去取行。功能上等价,但读起来不够直观,嵌套一多很容易出错。
排序还有一个大坑:在基础R里,arrange或者order并不会改变数据框原有的行名。如果你是从外部文件读入的数据,行号就是1、2、3,排序后行名还留在原地,某些后续操作(尤其是按行名取子集)会莫名其妙地错位。tidyverse的tibble没有行名概念,所以不会遇到这个问题。如果你用的还是老式的data.frame,建议在排序后用rownames(df) <- NULL把行名重置一遍。
4.2 排序的高阶场景:自定义顺序、分组内排序与去重
arrange()虽然好用,但默认的字母序或数字序并不总是符合业务逻辑。比如地区排序,你可能希望按“华东、华南、华北”而不是“华北、华东、华南”。这时候需要把列转成有序因子,再排序:
orders %>% mutate(region = factor(region, levels = c("华东", "华南", "华北"))) %>% arrange(region)因子水平就是排序优先级。这个技巧在处理星期几、月份、优先级这类带有固定顺序的分类变量时特别有用。
分组内排序是另一个高频需求。比如你想看“每个区域内部销售额最高的订单”,自然想到先按区域分组,再在组内排序,然后取每组第一行:
orders %>% group_by(region) %>% arrange(desc(sales_amount), .by_group = TRUE) %>% slice_head(n = 1)arrange()本身并不会改变数据的分组结构,但如果你希望排序的顺序尊重分组,需要加上.by_group = TRUE。不加的话,arrange()会先忽略分组,只按你指定的全局条件排序,然后再slice_head,取到的就可能是全局前几行而不是每个组的前几行。这个细节很隐蔽,我见过不止一个人在这里栽跟头。
排序还可以配合mutate()给数据打序号。比如:
orders %>% arrange(desc(sales_amount)) %>% mutate(rank = row_number())row_number()会按当前行的顺序生成1、2、3……,所以先排序再生成序号,就是一个标准的排名。如果你需要更复杂的排名逻辑,比如并列排名,可以看看dplyr的min_rank()和dense_rank(),这里不展开,但知道有这些函数总归是好事。
5. 实战案例:从模拟订单数据打一套组合拳
5.1 构造模拟数据与需求说明
到了这里,增改选排的单个函数基本都过了一遍。但真实的数据处理不可能只用一个函数,往往是把它们串成一条流水线。下面我用一个模拟场景把这些组合起来。
假设你拿到了某电商平台的一批订单数据,字段包括:订单号、客户类型、销售额、数量、地区、是否退款。需求是这样的:
- 计算每笔订单的总金额(销售额乘以数量);
- 把缺失的销售额当作0处理;
- 删除“是否退款”这一列,因为本期分析不需要;
- 只保留企业客户的订单;
- 按地区升序、总金额降序排列,输出最终结果。
模拟数据构造如下:
orders_raw <- tibble( order_id = 1:12, customer_type = rep(c("个人", "企业"), each = 6), sales_amount = c(120, 80, NA, 95, 210, 350, 150, 60, 400, 280, NA, 90), quantity = c(2, 1, 3, 2, 4, 5, 2, 1, 6, 3, 2, 1), region = rep(c("华东", "华南", "华北"), each = 4), is_refund = c(0, 1, 0, 0, 1, 0, 1, 1, 0, 0, 0, 1) )5.2 组合操作与逐步验证
先把前两步组合起来:
orders_step1 <- orders_raw %>% mutate( sales_amount = ifelse(is.na(sales_amount), 0, sales_amount), total_amount = sales_amount * quantity )这里的ifelse()把缺失销售额统一填0,再计算总金额。注意:因为处理的是变量维度,所以用mutate;因为涉及“删除列”,用select;因为要筛选企业客户,用filter;因为要调整顺序,用arrange。每一步的动词和维度要对应上。
继续完成剩下步骤:
orders_result <- orders_step1 %>% select(-is_refund) %>% filter(customer_type == "企业") %>% arrange(region, desc(total_amount))最终结果会保留所有企业客户的订单,按地区升序、总金额降序排列,且不再有退款标记列。如果你在RStudio里运行,数据的右上角会显示“Rows: 6,Columns: 5”,一眼就能看到筛选和删列后的规模变化。这个过程中间任何一步出错,就回到orders_step1去看结果,用glimpse()检查列名和类型,没必要整条管道从头重跑。
这里也顺便展示一下链条式写法:
orders_raw %>% mutate( sales_amount = ifelse(is.na(sales_amount), 0, sales_amount), total_amount = sales_amount * quantity ) %>% select(-is_refund) %>% filter(customer_type == "企业") %>% arrange(region, desc(total_amount))整个过程像一条流水线:进料、清洗、切掉不要的部件、筛选合格品、按规格排好。每一步都只依赖上一步的结果,每一步都返回一个新的数据框,原数据orders_raw始终没变。这就是tidyverse处理数据时最舒服的地方。
5.3 新手的执行顺序建议
实际操作时,我一般建议按这个顺序来:
- 先用glimpse()或str()看结构,确认每列类型;
- 处理缺失值和类型转换,这是整个流程的地基;
- 再做变量维度的增改,比如新增列、改名、删列;
- 再做观测维度的筛选和排序,因为筛选条件往往依赖于新算出来的字段;
- 最后才导出或进入下一步分析。
这个顺序不是绝对的,但遵循它可以让每一步的“输入数据”更干净。反过来,如果一上来就filter,后面才发现需要用到被删掉的列,就得回头重跑,浪费时间。
6. 常见问题速查与避坑心得
6.1 常见报错与排查速查表
以下这些是我在教R和日常使用中遇到频率最高的几类问题,整理成表格方便你快速对照。
| 报错或现象 | 可能原因 | 解决方法 |
|---|---|---|
| 找不到函数filter() | 没加载dplyr或tidyverse | 运行library(tidyverse) |
| filter(customer_type = "个人")报错 | 把等号写成单等号 | 用==判断相等 |
| 提示“must be length 12 or one, not 6” | mutate生成的新列长度不对 | 检查计算逻辑,或用ifelse/rep补齐 |
| 字符列无法做除法 | 列类型实际是chr | 用mutate(across(col, as.numeric))转换 |
| 排序后行号错乱 | 用的是基础R data.frame | 用rownames(df) <- NULL重置 |
| 合并数据后发现列名错位 | 用了rbind,列顺序不一致 | 改用dplyr::bind_rows() |
| NA被悄悄删掉 | filter条件里的NA判断为NA | 显式加is.na(col)条件 |
| across没生效 | 忘了加匿名函数写法~ | 写成across(cols, ~ 表达式) |
表格里的第4条要特别说一下:as.numeric()用在字符型数字上能正常转换,但如果列里混着“N/A”这类文本,会被转成NA并产生警告。遇到这种情况先看表里到底有哪些奇怪值,别盲目转类型。
6.2 三个独家避坑技巧
第一个技巧是:每写完一步操作,打印出来看一眼。RStudio里直接运行到某一步,用View()看数据,比想象中更省时间。不要觉得多这一步麻烦,数据清洗时“每一步都验证”能省下后面几小时的排查时间。
第二个技巧是:始终保留一份原始数据对象。不管你是用orders_raw <- read_csv(...)读入的,还是手动构造的,永远不要在原对象上直接修改。如果你习惯用df$new_col <- ...这种基础R写法,一旦改了原数据,后续想重跑脚本就得从头再读一遍。更推荐的做法是:每步都生成新对象,或者用管道一直往后传,原数据始终保留。
第三个技巧是:调试管道时,把%>%或|>停掉,直接复制前几步去跑。管道写太长的时候,报错信息只会告诉你最后一步出了问题,但真正的原因可能在前三步。我的习惯是先把管道拆成几个独立步骤,分别检查列名和类型,再拼回去。
我在实际操作中最深的体会是:变量和观测的增改选排,表面上是几个函数的问题,实际上是思维方式的问题。你是在对列做操作,还是在对行做操作?这一步是改结构还是改内容?想清楚这两个问题,dplyr的函数名称会自动浮现在你脑子里:要改内容用mutate,要筛行用filter,要排序用arrange,要改结构用select。这套组合拳练熟了以后,你再去看任何复杂的数据处理脚本,都会觉得它们不过是一个又一个基础动作的连接而已。最后再分享一个小习惯:每次拿到新数据集,我会先写一行glimpse(),把列名和类型打印出来,贴在笔记最上面。后续所有增改选排的代码,都以这份“列清单”为准,这样既不会改错列名,也不会忘记处理缺失值。