tidyr 数据重塑:pivot_longer 与 pivot_wider 完全指南
同一批数据可以摆成两种形状,而几乎每个分析任务只接受其中一种。ggplot2 画分组折线图要求数据是长表,lm() 拟合多元回归通常希望自变量各占一列,相关矩阵 cor() 更是只吃数值矩阵。reshape(重塑)不是在整理数据,而是在同一份信息的两种排布之间切换——这也是为什么它常出现在分析流程的中段,而不是开头。
用 airquality(纽约 1973 年 5–9 月的日均气象数据)举例。它现在是宽表(wide format):每行一天,四个测量指标各占一列。
| 形状 | 一行代表什么 | 变量在哪 | 典型消费者 |
|---|---|---|---|
| 宽表 | 一个观测单位(一天、一个人) | 每一列 | lm()、cor()、prcomp() |
| 长表 | 一个「观测单位 × 变量」组合 | 名字进一列,值进一列 | ggplot2、分组汇总、t.test() |
长表(long format)也叫「整洁数据」(tidy data):每一列是一个变量,每一行是一个观测。听上去长表更「正确」,但两种形状没有优劣,只有合不合适。
pivot_longer:宽变长
Section titled “pivot_longer:宽变长”library(tidyverse)
airquality |> pivot_longer( cols = c(Ozone, Solar.R, Wind, Temp), names_to = "variable", values_to = "value" ) |> head(6)# A tibble: 6 × 4 Month Day variable value <int> <int> <chr> <dbl>1 5 1 Ozone 412 5 1 Solar.R 1903 5 1 Wind 7.44 5 1 Temp 675 5 2 Ozone 366 5 2 Solar.R 118三个参数就够日常使用:cols 指定要堆叠的列(支持 select() 的全部辅助函数),names_to 是「原来的列名」存到哪一列,values_to 是「原来的值」存到哪一列。行数从 153 变成 153 × 4 = 612:
long <- airquality |> pivot_longer( cols = c(Ozone, Solar.R, Wind, Temp), names_to = "variable", values_to = "value" )
nrow(long)nrow(pivot_longer(long, cols = value, values_drop_na = TRUE))[1] 612[1] 568第二个 nrow() 里 values_drop_na = TRUE 表示丢掉值为 NA 的行。612 减到 568,差的 44 行正是 Ozone 的 37 个缺失和 Solar.R 的 7 个缺失。这个参数几乎每次都要用:宽表里的缺失值散落在各列,堆成长表后会变成一堆占位的行,忘了清理就会让后续的 mean()、sum() 全变 NA。相对的,values_drop_na = FALSE(默认)保留它们,好处是能明确区分「这天没测臭氧」和「这天臭氧是 0」。
列名里带着信息
Section titled “列名里带着信息”列名常常不是干净的变量名,而是「前缀 + 取值 + 后缀」的拼装结果。tidyr 提供三个参数处理这类列名,按需要挑一个:
sales <- tribble( ~district, ~q1_2024, ~q2_2024, "chaoyang", 120, 135, "haidian", 98, 105)
sales |> pivot_longer( cols = starts_with("q"), names_to = c("quarter", "year"), names_sep = "_", values_to = "revenue" )# A tibble: 4 × 4 district quarter year revenue <chr> <chr> <chr> <dbl>1 chaoyang q1 2024 1202 chaoyang q2 2024 1353 haidian q1 2024 984 haidian q2 2024 105names_prefix:剥掉一个固定前缀。把上面的names_sep换成names_prefix = "q"、names_to = "quarter",得到的取值就是"1_2024"、"2_2024"。names_sep:按分隔符把列名切成多段,段数要和names_to的长度一致,切多了会报错。names_pattern:用正则的捕获组来切,比分隔符灵活得多。iris的列名是Sepal.Length这种「部位.测量项」结构,用names_pattern = "(\\w+)\\.(\\w+)"一次拆成part和measure两列:
iris |> pivot_longer( cols = -Species, names_to = c("part", "measure"), names_pattern = "(\\w+)\\.(\\w+)", values_to = "cm" ) |> head(4)# A tibble: 4 × 4 Species part measure cm <fct> <chr> <chr> <dbl>1 setosa Sepal Length 5.12 setosa Sepal Width 3.53 setosa Petal Length 1.44 setosa Petal Width 0.2切完之后 part 和 measure 都是字符串(<chr>)。如果接下来要按这两个变量分组或画图,可以先 mutate(across(c(part, measure), factor)) 转成因子,让图例和汇总表的顺序变得可控。另一个容易忽略的点:cols 里选中的列如果是不同类型(比如一列数值、一列字符),堆叠后整列会统一升格为字符型。真遇到这种情况,用 values_ptypes 显式声明目标类型。
pivot_wider:长变宽
Section titled “pivot_wider:长变宽”反向操作是把「变量名」那一列摊开成多列。回到 ToothGrowth,先按补给方式和剂量算平均牙长,再摊平:
ToothGrowth |> group_by(supp, dose) |> summarise(mean_len = mean(len), .groups = "drop") |> pivot_wider(names_from = supp, values_from = mean_len)# A tibble: 3 × 3 dose OJ VC <dbl> <dbl> <dbl>1 0.5 13.2 7.982 1 22.7 16.83 2 26.1 26.1names_from 指定哪一列的名字变成新列名,values_from 指定哪一列的值填进去。这个宽表能直接做减法:mutate(diff = OJ - VC) 就是每个剂量下两种补给方式的差值,这也是宽表在建模场景里受欢迎的原因——每一列就是一个可以直接参与运算的变量。
重复标识是 pivot_wider 最容易翻车的地方。 如果每个 (dose, supp) 组合有多行(长表里没先做汇总),pivot_wider() 会输出 list-column 并给出一条警告,提示值没有被唯一标识。它不会猜测你想怎么聚合——这是有意的设计,因为「取均值还是取第一个」只有你知道。两个修法:先 summarise() 把每组压成一行(上面的例子就是这么做的),或者在 pivot_wider() 里加 values_fn = mean 当场聚合。
组合不存在时,新增的格子默认填 NA。想让它们填 0,加 values_fill = 0:
ToothGrowth |> filter(dose == 0.5 | supp == "OJ") |> group_by(supp, dose) |> summarise(mean_len = mean(len), .groups = "drop") |> pivot_wider(names_from = supp, values_from = mean_len, values_fill = 0)# A tibble: 3 × 3 dose OJ VC <dbl> <dbl> <dbl>1 0.5 13.2 7.982 1 22.7 03 2 26.1 0这里 VC 组只有 0.5 剂量的数据,另外两格被填成了 0。填 0 要谨慎:0 是「没有样本」还是「效应为零」,在后续求均值、画图时含义完全不同。如果只是想让表格好看,宁可留着 NA。
重塑之后要核对的三件事
Section titled “重塑之后要核对的三件事”- 行数。
pivot_longer()之后的行数应当是「原行数 × 选中的列数」,pivot_wider()之后应当是「唯一标识组合数」。对不上说明选列选错了,或者长表里存在重复标识。 - 列类型。
pivot_longer()会把不同类型混合的列升格为字符,pivot_wider()新增的列类型取决于values_from。重塑后如果类型不对,用readr::type_convert(),或者mutate(across(where(is.character), as.numeric)),改完再核对一遍。 - NA 的来源。重塑后的
NA可能来自原始缺失,也可能来自不存在的组合,两者不能混为一谈。
两种形状各用在哪儿
Section titled “两种形状各用在哪儿”- 绘图用长表。
ggplot2的aes(color = variable)、facet_wrap(~ variable)都要求分组信息在一列里,多个指标画在一张图上必须先变长,见 /r/visualization/ggplot2-basics。 - 建模用宽表。
lm(mpg ~ hp + wt, data = mtcars)要求自变量各占一列;cor()直接要求数值矩阵;主成分分析、聚类同理。 - 报告表格看场合。给读者看的描述统计表通常是宽表(变量在行、组别在列)。长表里要是有
mean、sd两列,可以一次摊开:pivot_wider(names_from = supp, values_from = c(mean, sd))默认给出mean_OJ、sd_VC这类列名,想换成别的格式就用names_glue定制。
最后提醒一点:pivot_longer() 会把数据行数成倍放大(本例中 4 个指标就是 4 倍),几百万行的数据重塑后可能直接吃满内存。遇到这种量级,先在宽表上算好需要的汇总量再变长,而不是先变长再汇总。
如果你也用 Python,pandas 里的 melt() 与 pivot_table() 是同一组操作,列名拆分要靠字符串方法手动做,可以对照 /python/pandas/cleaning 看差异。