跳到正文

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):每一列是一个变量,每一行是一个观测。听上去长表更「正确」,但两种形状没有优劣,只有合不合适。

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 41
2 5 1 Solar.R 190
3 5 1 Wind 7.4
4 5 1 Temp 67
5 5 2 Ozone 36
6 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」。

列名常常不是干净的变量名,而是「前缀 + 取值 + 后缀」的拼装结果。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 120
2 chaoyang q2 2024 135
3 haidian q1 2024 98
4 haidian q2 2024 105
  • names_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+)" 一次拆成 partmeasure 两列:
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.1
2 setosa Sepal Width 3.5
3 setosa Petal Length 1.4
4 setosa Petal Width 0.2

切完之后 partmeasure 都是字符串(<chr>)。如果接下来要按这两个变量分组或画图,可以先 mutate(across(c(part, measure), factor)) 转成因子,让图例和汇总表的顺序变得可控。另一个容易忽略的点:cols 里选中的列如果是不同类型(比如一列数值、一列字符),堆叠后整列会统一升格为字符型。真遇到这种情况,用 values_ptypes 显式声明目标类型。

反向操作是把「变量名」那一列摊开成多列。回到 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.98
2 1 22.7 16.8
3 2 26.1 26.1

names_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.98
2 1 22.7 0
3 2 26.1 0

这里 VC 组只有 0.5 剂量的数据,另外两格被填成了 0。填 0 要谨慎:0 是「没有样本」还是「效应为零」,在后续求均值、画图时含义完全不同。如果只是想让表格好看,宁可留着 NA

  1. 行数pivot_longer() 之后的行数应当是「原行数 × 选中的列数」,pivot_wider() 之后应当是「唯一标识组合数」。对不上说明选列选错了,或者长表里存在重复标识。
  2. 列类型pivot_longer() 会把不同类型混合的列升格为字符,pivot_wider() 新增的列类型取决于 values_from。重塑后如果类型不对,用 readr::type_convert(),或者 mutate(across(where(is.character), as.numeric)),改完再核对一遍。
  3. NA 的来源。重塑后的 NA 可能来自原始缺失,也可能来自不存在的组合,两者不能混为一谈。
  • 绘图用长表ggplot2aes(color = variable)facet_wrap(~ variable) 都要求分组信息在一列里,多个指标画在一张图上必须先变长,见 /r/visualization/ggplot2-basics
  • 建模用宽表lm(mpg ~ hp + wt, data = mtcars) 要求自变量各占一列;cor() 直接要求数值矩阵;主成分分析、聚类同理。
  • 报告表格看场合。给读者看的描述统计表通常是宽表(变量在行、组别在列)。长表里要是有 meansd 两列,可以一次摊开:pivot_wider(names_from = supp, values_from = c(mean, sd)) 默认给出 mean_OJsd_VC 这类列名,想换成别的格式就用 names_glue 定制。

最后提醒一点:pivot_longer() 会把数据行数成倍放大(本例中 4 个指标就是 4 倍),几百万行的数据重塑后可能直接吃满内存。遇到这种量级,先在宽表上算好需要的汇总量再变长,而不是先变长再汇总。

如果你也用 Python,pandas 里的 melt()pivot_table() 是同一组操作,列名拆分要靠字符串方法手动做,可以对照 /python/pandas/cleaning 看差异。