跳到正文

R数据框操作:索引、筛选与分组统计完全指南

数据框(data frame)是 R 里使用频率最高的结构。一份问卷、一次实验的测量记录、一个表达量矩阵,读进来都是数据框。它长得像 Excel 表格:有行有列,列有名字,每列类型可以不同,但同一列内部必须一致——因为每一列都是一个向量,这是上一篇验证过的结论。

少量数据直接敲:

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 79

str() 的输出值得逐行读:第一行告诉你 3 行 3 列,下面每行对应一列,冒号后面是存储类型和开头几个值。intchrnum 就是上一篇讲的向量类型。

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。更好的做法是读进来之后统一重命名,而不是关掉检查。

拿到一个陌生的数据框,先用 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 Day
1 41 190 7.4 67 5 1
2 36 118 8.0 72 5 2
3 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 :7

NA's 那两行是最有价值的信息:Ozone 缺 37 个,Solar.R 缺 7 个。这个数据框是 1973 年纽约的每日空气质量记录,缺失来自当年的仪器故障。做任何统计之前先看这一行,能省掉后面「为什么均值是 NA」的排查时间。

假设数据框叫 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 补全。

按行号取,返回的仍然是数据框:

airquality[1, ]
Ozone Solar.R Wind Temp Month Day
1 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 Temp
42 6 11 93
43 6 12 92
69 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.425144
head(d, 3)
mpg wt cyl kpl
Mazda RX4 21.0 2.620 6 8.928024
Mazda RX4 Wag 21.0 2.875 6 8.928024
Datsun 710 22.8 2.320 4 9.693283

0.425144 是 mpg 换算成 km/L 的系数(1 英里 = 1.609344 公里,1 美制加仑 = 3.785411784 升)。赋值给 NULL 就是删除这一列:

d$cyl <- NULL
names(d)
[1] "mpg" "wt" "kpl"

注意 d$kpl 这种写法只在数据框上有效,换成矩阵会报 $ operator is invalid for atomic vectors,因为矩阵是原子向量加了维度属性,不是列表。

order() 返回的是排序后的位置索引,不是排好序的向量,这一点跟 sort() 完全不同:

d[order(d$kpl), ][1:3, ]
mpg wt cyl kpl
Cadillac Fleetwood 10.4 5.250 8 4.421498
Lincoln Continental 10.4 5.424 8 4.421498
Camaro Z28 13.3 3.840 8 5.654415

降序在排序键前面加负号,多个键就多写几个:

head(mtcars[order(mtcars$cyl, -mtcars$mpg), c("cyl", "mpg")], 3)
cyl mpg
Toyota Corolla 4 33.9
Fiat 128 4 32.4
Honda Civic 4 30.4

先按 cyl 升序,同一组内按 mpg 降序,读起来就是「每个缸数里最省油的车排在前面」。字符列排序默认按当前 locale 的字典序,中文字符串在不同系统上的顺序可能不一样,跨机器复现结果时要留意。

分组汇总用 aggregate(),公式读作「按右边分组,对左边计算」:

aggregate(mpg ~ cyl, data = mtcars, FUN = mean)
cyl mpg
1 4 26.66364
2 6 19.74286
3 8 15.10000

要同时汇总多列,用 cbind() 把左边包起来:

aggregate(cbind(mpg, wt) ~ cyl, data = mtcars, FUN = mean)
cyl mpg wt
1 4 26.66364 2.285727
2 6 19.74286 3.117143
3 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 Ozone
1 5 23.61538
2 6 29.44444
3 7 59.11538
4 8 59.96154
5 9 31.44828

五个月的均值都出来了,没有一个 NA。如果漏掉 na.rm = TRUE,7 月和 8 月会因为缺失值直接变成 NA——mean() 遇到 NA 就返回 NA,除非明确告诉它跳过。

mtcars 的行名是车型,看着方便,但会带来两个麻烦。

第一个,筛完行之后行名跟着走,打印出来的左侧不再是 1、2、3:

heavy <- mtcars[mtcars$wt > 5, c("wt", "mpg")]
heavy
wt mpg
Cadillac Fleetwood 5.250 10.4
Lincoln Continental 5.424 10.4
Chrysler 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.Length
d <- 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 ratio
15 setosa 5.8 1.2 4.833333
23 setosa 4.6 1.0 4.600000
37 setosa 5.5 1.3 4.230769
Species Sepal.Length Petal.Length ratio
1 setosa 5.006 1.462000 3.464906
2 versicolor 5.934694 4.242857 1.405476
3 virginica 6.725000 5.380000 1.251390

150 条观测筛掉 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 和这里的 $[ ][[ ]] 各有对应写法(lociloc[]),可以对照 /python/basics/pandas-basics 看同一种操作在两边怎么写。数据框的下一步是控制流:/r/basics/control-flow