R数据框操作:索引、筛选与分组统计完全指南
数据框(data frame)是 R 里使用频率最高的结构。一份问卷、一次实验的测量记录、一个表达量矩阵,读进来都是数据框。它长得像 Excel 表格:有行有列,列有名字,每列类型可以不同,但同一列内部必须一致——因为每一列都是一个向量,这是上一篇验证过的结论。
手工创建,或者从文件读入
Section titled “手工创建,或者从文件读入”少量数据直接敲:
df <- data.frame(id = 1:3, name = c("a", "b", "c"), score = c(88.5, 92, 79))str(df)'data.frame': 3 obs. of 3 variables: $ id : int 1 2 3 $ name : chr "a" "b" "c" $ score: num 88.5 92 79str() 的输出值得逐行读:第一行告诉你 3 行 3 列,下面每行对应一列,冒号后面是存储类型和开头几个值。int、chr、num 就是上一篇讲的向量类型。
R 4.0 之前,data.frame() 会把字符列自动转成因子(factor),现在默认不转了(所以 name 显示为 chr)。接手别人的老代码时如果发现字符列莫名其妙变成因子,先确认一下当年跑代码的 R 版本。
真实数据基本都从文件进来,用 read.csv()。下面先造一个 CSV 再读回来,不依赖任何外部文件:
tmp <- tempfile(fileext = ".csv")writeLines(c("id,Sepal Length,score", "1,5.1,88.5", "2,4.9,NA", "3,.,79"), tmp)
d <- read.csv(tmp, na.strings = c("", "NA", "."))str(d)'data.frame': 3 obs. of 3 variables: $ id : int 1 2 3 $ Sepal.Length: num 5.1 4.9 NA $ score : num 88.5 NA 79几个参数值得记住。na.strings 指定哪些字符串按缺失值处理,上面把 . 也算了进去:. 是 SPSS、SAS 导出数据里的缺失标记,很多公开数据集沿用这个约定,不写进去就会被读成一列字符型。fileEncoding 在读 GBK 编码的中文 CSV 时必须显式指定,否则乱码。colClasses 手动指定每列类型,读大文件时更快,也不容易猜错类型。至于 stringsAsFactors,R 4.0 之后默认已经是 FALSE,不用再写了。
read.csv() 还会把列名里的空格和特殊字符换成点,Sepal Length 变成了 Sepal.Length。加 check.names = FALSE 能保留原样:
names(d)names(read.csv(tmp, check.names = FALSE))[1] "id" "Sepal.Length" "score"[1] "id" "Sepal Length" "score"保留下来的空格会让取列变成 d[["Sepal Length"]] 这种写法,可读性不如 d$Sepal.Length。更好的做法是读进来之后统一重命名,而不是关掉检查。
动手之前先看清楚
Section titled “动手之前先看清楚”拿到一个陌生的数据框,先用 str() 摸结构和类型:
str(airquality)'data.frame': 153 obs. of 6 variables: $ Ozone : int 41 36 12 18 NA 28 23 19 8 NA ... $ Solar.R: int 190 118 149 313 NA NA 299 99 19 194 ... $ Wind : num 7.4 8 12.6 11.5 14.3 14.9 8.6 13.8 20.1 8.6 ... $ Temp : int 67 72 74 62 56 66 65 59 61 69 ... $ Month : int 5 5 5 5 5 5 5 5 5 5 ... $ Day : int 1 2 3 4 5 6 7 8 9 10 ...再看几行实际值,顺便确认行列数:
head(airquality, 3)nrow(airquality)ncol(airquality) Ozone Solar.R Wind Temp Month Day1 41 190 7.4 67 5 12 36 118 8.0 72 5 23 12 149 12.6 74 5 3[1] 153[1] 6然后是 summary(),重点看缺失值:
summary(airquality) Ozone Solar.R Wind Temp Min. : 1.00 Min. : 7.0 Min. : 1.700 Min. :56.00 1st Qu.: 18.00 1st Qu.:115.8 1st Qu.: 7.400 1st Qu.:72.00 Median : 31.50 Median :205.0 Median : 9.700 Median :79.00 Mean : 42.13 Mean :185.9 Mean : 9.958 Mean :77.88 3rd Qu.: 63.25 3rd Qu.:258.8 3rd Qu.:11.500 3rd Qu.:85.00 Max. :168.00 Max. :334.0 Max. :20.700 Max. :97.00 NA's :37 NA's :7NA's 那两行是最有价值的信息:Ozone 缺 37 个,Solar.R 缺 7 个。这个数据框是 1973 年纽约的每日空气质量记录,缺失来自当年的仪器故障。做任何统计之前先看这一行,能省掉后面「为什么均值是 NA」的排查时间。
取列:$、[ ]、[[ ]] 不是一回事
Section titled “取列:$、[ ]、[[ ]] 不是一回事”假设数据框叫 dd,下面三种写法取出来的值相同,容器不同:
airquality[["Temp"]][1:3]airquality[, "Temp"][1:3]class(airquality["Temp"])class(airquality[, "Temp"])class(airquality[["Temp"]])[1] 67 72 74[1] 67 72 74[1] "data.frame"[1] "integer"[1] "integer"规则还是上一篇列表那套:[ 取子集,返回同类容器;[[ 取元素本身,返回原类型。数据框是列表,所以 airquality["Temp"] 是只含一列的数据框,airquality[["Temp"]] 是整数向量。$ 是 [[ 的语法糖,效果一样,只是不能传变量:airquality$Temp 可以,airquality$col_name 不会去找 col_name 这个变量,而是去找名字就叫 col_name 的那一列。想在函数里按参数传列名,只能写 airquality[[col_name]]。
用 $ 还有个小陷阱:R 允许部分匹配(partial matching)。airquality$Tem 不报错,静默返回 Temp 列。拼错列名不一定有人提醒你,写脚本时老老实实打全,或者在 RStudio 里用 Tab 补全。
取行、筛选,以及 drop 的坑
Section titled “取行、筛选,以及 drop 的坑”按行号取,返回的仍然是数据框:
airquality[1, ] Ozone Solar.R Wind Temp Month Day1 41 190 7.4 67 5 1日常用得最多的是按条件筛行、按名字选列:
hot <- airquality[airquality$Temp > 90, c("Month", "Day", "Temp")]nrow(hot)head(hot, 3)[1] 14 Month Day Temp42 6 11 9343 6 12 9269 7 8 92方括号里逗号左边是行条件,右边是要保留的列。注意输出左侧的行号 42、43、69——它们是原始行号,不是「筛选后的第几行」。核对数据时这个信息很有用,能直接定位回原文件;但把它当序号读就会得出错误结论。需要新序号的话,取 rownames() 或者干脆加一列 seq_len(nrow(hot))。
接下来是 drop 这个必须知道的行为:取单列时 R 会默认降维,筛选之后只剩一列也一样。
len <- iris[, "Sepal.Length"]class(len)len_df <- iris[, "Sepal.Length", drop = FALSE]class(len_df)dim(len_df)[1] "numeric"[1] "data.frame"[1] 150 1什么时候必须加 drop = FALSE?写函数的时候。如果函数接收一个数据框、按列名取一列、后面还指望它是数据框(比如还要按行取子集),那么只要调用者传进来的列名恰好命中一列,内部就悄悄变成了向量,x[1, ] 立刻报错。经验规则:写给别人用的代码一律加 drop = FALSE,自己交互式探索时可以省。
给数据框加一列,直接赋值就行:
d <- mtcars[, c("mpg", "wt", "cyl")]d$kpl <- d$mpg * 0.425144head(d, 3) mpg wt cyl kplMazda RX4 21.0 2.620 6 8.928024Mazda RX4 Wag 21.0 2.875 6 8.928024Datsun 710 22.8 2.320 4 9.6932830.425144 是 mpg 换算成 km/L 的系数(1 英里 = 1.609344 公里,1 美制加仑 = 3.785411784 升)。赋值给 NULL 就是删除这一列:
d$cyl <- NULLnames(d)[1] "mpg" "wt" "kpl"注意 d$kpl 这种写法只在数据框上有效,换成矩阵会报 $ operator is invalid for atomic vectors,因为矩阵是原子向量加了维度属性,不是列表。
排序与分组统计
Section titled “排序与分组统计”order() 返回的是排序后的位置索引,不是排好序的向量,这一点跟 sort() 完全不同:
d[order(d$kpl), ][1:3, ] mpg wt cyl kplCadillac Fleetwood 10.4 5.250 8 4.421498Lincoln Continental 10.4 5.424 8 4.421498Camaro Z28 13.3 3.840 8 5.654415降序在排序键前面加负号,多个键就多写几个:
head(mtcars[order(mtcars$cyl, -mtcars$mpg), c("cyl", "mpg")], 3) cyl mpgToyota Corolla 4 33.9Fiat 128 4 32.4Honda Civic 4 30.4先按 cyl 升序,同一组内按 mpg 降序,读起来就是「每个缸数里最省油的车排在前面」。字符列排序默认按当前 locale 的字典序,中文字符串在不同系统上的顺序可能不一样,跨机器复现结果时要留意。
分组汇总用 aggregate(),公式读作「按右边分组,对左边计算」:
aggregate(mpg ~ cyl, data = mtcars, FUN = mean) cyl mpg1 4 26.663642 6 19.742863 8 15.10000要同时汇总多列,用 cbind() 把左边包起来:
aggregate(cbind(mpg, wt) ~ cyl, data = mtcars, FUN = mean) cyl mpg wt1 4 26.66364 2.2857272 6 19.74286 3.1171433 8 15.10000 3.999214多个分组变量用 + 连接,aggregate(mpg ~ cyl + am, data = mtcars, FUN = mean)。需要给统计函数传额外参数时,... 会转交给 FUN,比如按月汇总臭氧浓度并跳过缺失值:
aggregate(Ozone ~ Month, data = airquality, FUN = mean, na.rm = TRUE) Month Ozone1 5 23.615382 6 29.444443 7 59.115384 8 59.961545 9 31.44828五个月的均值都出来了,没有一个 NA。如果漏掉 na.rm = TRUE,7 月和 8 月会因为缺失值直接变成 NA——mean() 遇到 NA 就返回 NA,除非明确告诉它跳过。
行名不是数据
Section titled “行名不是数据”mtcars 的行名是车型,看着方便,但会带来两个麻烦。
第一个,筛完行之后行名跟着走,打印出来的左侧不再是 1、2、3:
heavy <- mtcars[mtcars$wt > 5, c("wt", "mpg")]heavy wt mpgCadillac Fleetwood 5.250 10.4Lincoln Continental 5.424 10.4Chrysler Imperial 5.345 14.7想知道这几辆车在原始数据里的位置,得用 which(mtcars$wt > 5) 取索引。第二个,行名必须是唯一字符串,重复会直接报错 duplicate row.names。所以样本编号这类信息,老老实实存成一列,别塞进行名。
把上面几件事串起来:取列、算派生变量、筛行、排序、分组汇总。
d <- iris[, c("Species", "Sepal.Length", "Petal.Length")]d$ratio <- d$Sepal.Length / d$Petal.Lengthd <- d[d$ratio > 1.2, ]d <- d[order(d$ratio, decreasing = TRUE), ]nrow(d)head(d, 3)aggregate(cbind(Sepal.Length, Petal.Length, ratio) ~ Species, data = d, FUN = mean)[1] 119 Species Sepal.Length Petal.Length ratio15 setosa 5.8 1.2 4.83333323 setosa 4.6 1.0 4.60000037 setosa 5.5 1.3 4.230769 Species Sepal.Length Petal.Length ratio1 setosa 5.006 1.462000 3.4649062 versicolor 5.934694 4.242857 1.4054763 virginica 6.725000 5.380000 1.251390150 条观测筛掉 31 条,剩下 119 条。按花萼长宽比排序,排在最前面的全是 setosa——这个品种的花瓣特别短,比值自然大。分组均值也印证了这点:setosa 的比值 3.46,是 virginica 的 2.8 倍。这类派生变量在判别分析里很常见,花瓣和花萼的长度比本身就是区分鸢尾花品种的强特征。
最后提一个容易踩的坑:把混合类型的数据框转成矩阵,所有列会一起变成字符,因为矩阵只能存一种类型。
m <- as.matrix(iris)m[1, 1:5]Sepal.Length Sepal.Width Petal.Length Petal.Width Species "5.1" "3.5" "1.4" "0.2" "setosa"数字全部加上了引号。真要做数值矩阵运算,先取数值列:as.matrix(iris[1:4])。
如果你也使用 Python,pandas 的 DataFrame 和这里的 $、[ ]、[[ ]] 各有对应写法(loc、iloc、[]),可以对照 /python/basics/pandas-basics 看同一种操作在两边怎么写。数据框的下一步是控制流:/r/basics/control-flow。