第10讲:数据合并与重塑——join与pivot_longer/pivot_wider

数据分析与R语言

2026年09月29日

本讲导览

Joining & Reshaping Data

六站路线

  • 一、复习导入——第6课的 rbind/cbind/merge 还够用吗?
  • 二、bind_rows()——按列名对齐的行合并,rbind() 的安全替代品
  • 三、join 系列——四种连接 + 反连接 + "键"的完整性与多字段键
  • 四、pivot_longer() 与 pivot_wider()——宽格式与长格式互转
  • 五、数据库读写(选学)+ 综合实战——项目二工具的集中检验
  • 六、项目二总结收官——完整流程与 Exit Ticket

重要

今天是项目二"数据准备"的收官课。前三次课学了读写、筛选排序、变换汇总,今天补上最后两块拼图:多表合并与数据重塑。

课前小测

复习一下第9课的内容,热热身

小测(第 1、2 题)

1. 单选:mutate() 和 summarise() 最主要的区别是?
A. mutate() 改列名,summarise() 不改
B. mutate() 加/改列、行数不变;summarise() 压成汇总行
C. mutate() 只能用于数值列,summarise() 不限类型
D. mutate() 必须配 group_by(),summarise() 不用

2. 单选:按"评级"分组,算每组的平均营收和公司数,写法是?
A. group_by() 在前、summarise() 在后
B. summarise() 在前、group_by() 在后
C. 用 mutate() 代替 summarise()
D. 用 filter() + arrange()

小测(第 3、4 题)

3. 填空:group_by() 单独使用不会立刻显示分组效果,必须配合 ____ 或 ____ 才能看到实际结果。

4. 填空:summarise() 汇总完后,若不想保留分组状态、避免 R 输出多余提示信息,应加上参数 ____。

一、复习导入与新课导论

第6课的rbind/cbind/merge还够用吗?

1.1 复习提问

第9课的 mutate() 和 summarise() 最大的区别是什么?group_by() 为什么一定要配合它们使用?

答案:mutate() 新增列但行数不变;summarise() 把数据压缩成汇总行。group_by() 本身"看不见分组状态",必须配合 summarise()(或 mutate())才能看到分组的实际作用。

1.2 情境导入——两个引导问题

问题一:第6课的 rbind() / cbind() / merge()——如果要合并两张以上的表、或者两张表的列凑不齐,base R 的写法会不会有点吃力?

有——这就是 bind_rows() 与 join 系列 要解决的问题。

问题二:如果数据是"一行一家公司、四个季度分四列",但你想按季度分组统计、或画一张"季度趋势图",需要"一行一个季度"的格式——怎么转?

这是今天全新的知识点——数据重塑。把合并和重塑都补齐,项目二"数据准备"的工具链就到齐了。

二、bind_rows()——按列名对齐的行合并

替代 rbind() 的更安全写法

2.1 造两张"列顺序不同"的小表

▶️ 查看代码
h1 <- data.frame(company = c("锦城科技", "蜀汉制造"), revenue = c(600, 480))
new_company <- data.frame(revenue = 450, company = "浣花实业")   # 列的顺序反过来了!

h1; new_company      # 两张表"长得不像",其实是同一件事
   company revenue
1 锦城科技     600
2 蜀汉制造     480
  revenue  company
1     450 浣花实业

提示

h1 是 company 在前,new_company 是 revenue 在前。对 data.frame 来说,rbind() 会按列名对齐,所以列顺序不同它照样能摞。真正让 rbind() 掉链子的是缺列和类型不一致——见 2.3。

2.2 bind_rows():按列名对齐,缺列自动补 NA

▶️ 查看代码
bind_rows(h1, new_company)                        # 列顺序不同,照样对齐
   company revenue
1 锦城科技     600
2 蜀汉制造     480
3 浣花实业     450
▶️ 查看代码
bind_rows(h1, data.frame(company = "岷山科技"))   # 只给一列:revenue 补 NA
   company revenue
1 锦城科技     600
2 蜀汉制造     480
3 岷山科技      NA

第 2 行:岷山科技 的 revenue 是 NA——「信息缺失」≠「营收为零」。

2.3 bind_rows() vs rbind():差在哪里

对比项 rbind() bind_rows()
列顺序不一致 同样按列名对齐(data.frame 不受影响) 按列名对齐
两边列不齐 直接报错(列数不同就停) 缺的列自动补 NA
类型不一致 静默强转(按 base R 规则把整列悄悄换成另一种类型) 兼容类型自动统一(整数+小数→小数);不兼容就直接报错
一次合并多张表 只能逐个写 rbind(a, b, c) 还能直接吃一个列表

警告

rbind() 有两个坑:列不齐直接报错(逼你手动改表),类型不一致时静默强转(整列被悄悄换成另一种类型,出了错也看不出来)。bind_rows() 刚好相反——列不齐自动补 NA,类型不兼容就直接报错让你先改数据,绝不静默改动你的数据。

"左右并排"(cbind() 那一类)本课不再单独讲:并表一律靠"一个共同字段"对齐,就是下一节的 join。

2.4 课堂练习:合并两份客户名单

任务:h1 是上半年业绩,new_company 是新增公司。

  1. 用 bind_rows() 把三家公司的业绩合并成一张表;
  2. 再新建一个只有 revenue 一列的数据框,用 bind_rows() 合并进去,观察 company 列会出现什么。

思考:第 2 步里补出来的 NA,在业务上意味着"营收为零"还是"这家公司不知名"?

三、join系列——按共同字段合并的四种方式

本课最大难点:四种join的语义区别

3.1 造表一:本季度营收

▶️ 查看代码
# 表一:本季度营收(5 家公司)
df1 <- data.frame(
  company = c("锦城科技", "蜀汉制造", "天府物流", "青羊金融", "锦江生物"),
  revenue = c(1200.5, 980.0, 1560.3, 760.2, 2100.0)
)

df1
   company revenue
1 锦城科技    1200
2 蜀汉制造     980
3 天府物流    1560
4 青羊金融     760
5 锦江生物    2100

提示

表一有 5 家公司,company 与 revenue 一一对应。这张表本课会反复用到,建议现在就跟着敲一遍。

3.2 造表二:信用评级

▶️ 查看代码
# 表二:信用评级(4 家公司,与表一部分重叠)
df2 <- data.frame(
  company = c("锦城科技", "蜀汉制造", "天府物流", "浣花实业"),
  rating  = c("A", "B", "A", "B")
)

df2
   company rating
1 锦城科技      A
2 蜀汉制造      B
3 天府物流      A
4 浣花实业      B

重要

表二的公司名单和表一不一样——锦江生物只在表一,浣花实业只在表二。第 6 课学过的 merge() 默认只保留两边都有的记录,其实就是 inner join;dplyr 把它拆成了后面四种写法。

3.3 四种join的语义:到底保留谁的数据

表一表二inner_join()只留两边都有的表一表二left_join()左表全保留表一表二right_join()右表全保留表一表二full_join()两边都保留

提示

口诀:inner=交集(只留共同的);left=以左为准;right=以右为准;full=全部都要。缺的部分统一补 NA。

3.4 最常用的两种:inner 与 left

▶️ 查看代码
# 导入软件包
library(dplyr)
library(tidyr)

inner_join(df1, df2, by = "company")   # 交集:锦江生物、浣花实业都被丢掉
   company revenue rating
1 锦城科技    1200      A
2 蜀汉制造     980      B
3 天府物流    1560      A
▶️ 查看代码
left_join(df1, df2, by = "company")    # 以左表 df1 为准,5 家公司全保留
   company revenue rating
1 锦城科技    1200      A
2 蜀汉制造     980      B
3 天府物流    1560      A
4 青羊金融     760   <NA>
5 锦江生物    2100   <NA>

重要

看行数:inner 3 行、left 5 行;多出的 2 行(青羊金融、锦江生物)rating 是 NA。

3.5 另两种:right 与 full

▶️ 查看代码
right_join(df1, df2, by = "company")   # 以右表 df2 为准,4 家公司
   company revenue rating
1 锦城科技    1200      A
2 蜀汉制造     980      B
3 天府物流    1560      A
4 浣花实业      NA      B
▶️ 查看代码
full_join(df1, df2, by = "company")    # 两边都要,6 家公司
   company revenue rating
1 锦城科技    1200      A
2 蜀汉制造     980      B
3 天府物流    1560      A
4 青羊金融     760   <NA>
5 锦江生物    2100   <NA>
6 浣花实业      NA      B

提示

right_join(a, b) 等价于 left_join(b, a)——调换表的顺序即可;full_join() 行数最多(6 = 5 + 4 − 3)。

3.6 反连接 anti_join():找出"没匹配上"的记录

场景:df1 是"本月有交易的客户",df2 是"已完成实名认证的客户"。找出"有交易但未认证"的客户——合规排查的常规动作。

表一 df1表二 df2anti_join(df1, df2)只留"左表有、右表没有"的记录注意方向:a、b 互换结果不同;表二独有的不管
▶️ 查看代码
anti_join(df1, df2, by = "company")   # 有交易、但认证表里查无此人
   company revenue
1 青羊金融     760
2 锦江生物    2100

提示

anti_join(a, b) 只留"在 a 里、但 b 里找不到"的记录,而且不会多出 b 的列。它相当于 left_join(a, b) %>% filter(is.na(rating)),但语义更直白。

3.7 join的坑:连接键不唯一,行数会"膨胀"

如果 df2 里同一家公司出现了两条记录(比如两条评级历史),left_join() 会怎样?

▶️ 查看代码
df2_dup <- data.frame(company = c("锦城科技", "锦城科技", "蜀汉制造"),
                      rating  = c("A", "A+", "B"))

left_join(df1, df2_dup, by = "company")   # 5 行 → 6 行?
   company revenue rating
1 锦城科技    1200      A
2 锦城科技    1200     A+
3 蜀汉制造     980      B
4 天府物流    1560   <NA>
5 青羊金融     760   <NA>
6 锦江生物    2100   <NA>

警告

锦城科技被"复制"成两行——行数从 5 变成 6,如果这是营收表,这笔营收就被重复计算了一次。

3.8 合并前先检查:一家公司是不是只有一行

▶️ 查看代码
df2_dup %>% count(company)          # 查出 n > 1 的就是"不唯一的键"
   company n
1 蜀汉制造 1
2 锦城科技 2

重要

count() 是合并前的体检动作:只要有一家公司出现两次以上,join 出来的行数就不可信。解决办法有两条:换一个本来就唯一的标识(如统一社会信用代码),或者——把几个字段一起当键(3.11 起展开)。

3.9 造表一:两年营收

▶️ 查看代码
# 同一家公司有 2024、2025 两条记录 —— 单靠 company 不唯一
rev_year <- data.frame(
  company = c("锦城科技", "蜀汉制造", "锦城科技", "蜀汉制造"),
  year    = c(2024, 2024, 2025, 2025),
  revenue = c(1150, 940, 1200.5, 980)
)

rev_year
   company year revenue
1 锦城科技 2024    1150
2 蜀汉制造 2024     940
3 锦城科技 2025    1200
4 蜀汉制造 2025     980

提示

rev_year 只有 4 行、2 家公司——锦城科技、蜀汉制造各出现两次。单看 company 这一列,已经不能唯一确定一条记录。

3.10 造表二:两年评级

▶️ 查看代码
# 评级表同样是"公司 × 年份"才唯一;两年的评级故意不同
rat_year <- data.frame(
  company = c("锦城科技", "蜀汉制造", "锦城科技", "蜀汉制造"),
  year    = c(2024, 2024, 2025, 2025),
  rating  = c("A", "B", "A+", "B+")
)

rat_year
   company year rating
1 锦城科技 2024      A
2 蜀汉制造 2024      B
3 锦城科技 2025     A+
4 蜀汉制造 2025     B+

提示

两张表都用「公司 + 年份」才能唯一确定一条记录,而且 rating 只在表二有。下一页看"只按公司合并"会出什么事。

3.11 只按公司合并:4 行变成了 8 行

▶️ 查看代码
left_join(rev_year, rat_year, by = "company")   # ✗ 只按公司
   company year.x revenue year.y rating
1 锦城科技   2024    1150   2024      A
2 锦城科技   2024    1150   2025     A+
3 蜀汉制造   2024     940   2024      B
4 蜀汉制造   2024     940   2025     B+
5 锦城科技   2025    1200   2024      A
6 锦城科技   2025    1200   2025     A+
7 蜀汉制造   2025     980   2024      B
8 蜀汉制造   2025     980   2025     B+

警告

两个年份被交叉配对了:有一行是「2024 年的营收」配上「2025 年的评级」。注意 year 被自动改名成 year.x / year.y——R 在替我们报警。

3.12 字段不够,就把字段加够

▶️ 查看代码
left_join(rev_year, rat_year, by = c("company", "year"))   # ✓ 公司 + 年份
   company year revenue rating
1 锦城科技 2024    1150      A
2 蜀汉制造 2024     940      B
3 锦城科技 2025    1200     A+
4 蜀汉制造 2025     980     B+

重要

加上 year 之后回到 4 行,年份与评级一一对应。现实的 GDP 数据同样是「国家 + 年份」才唯一——一个字段不够,就多给几个。

3.13 课堂练习:用内置数据练习join

  1. 先打印 dplyr 自带的 band_members、band_instruments 看结构,再用 inner_join()、left_join()、full_join() 按 name 合并,比较行数与 NA 分布;
  2. 数一数 left_join(rev_year, rat_year, by = "company") 有几行,再换成 by = c("company", "year") 数一遍——说出两个数字差在哪、为什么会差。

四、pivot_longer()与pivot_wider()——宽格式与长格式互转

同一份信息,不同的"摆放形状"

4.1 宽格式与长格式:同一份数据,两种摆法

宽格式:一行一家公司,四个季度各占一列长格式:一行一个「公司 × 季度」公司Q1Q2Q3Q4锦城科技280310295315蜀汉制造230240250260天府物流380400390410青羊金融190185195190pivot_longer()宽 → 长pivot_wider()长 → 宽公司季度营收锦城科技Q1280锦城科技Q2310锦城科技Q3295锦城科技Q4315蜀汉制造Q1230蜀汉制造Q2240蜀汉制造Q3250⋮⋮⋮4 行 × 5 列,一眼能看明白4 家公司 × 4 个季度 = 16 行

重要

两种格式装的是同一份信息,只是"摆放的形状"不同——这就是"重塑"。宽格式像 Excel 报表;长格式不好直读,但更适合分组统计和画图。哪个更"正确"没有标准答案,取决于后面要做什么分析。

4.2 造一张宽格式季度表

▶️ 查看代码
# 季度营收宽格式:一行一家公司,四个季度分四列
wide_data <- data.frame(
  company = c("锦城科技", "蜀汉制造", "天府物流", "青羊金融"),
  Q1 = c(280, 230, 380, 190),
  Q2 = c(310, 240, 400, 185),
  Q3 = c(295, 250, 390, 195),
  Q4 = c(315, 260, 410, 190)
)

wide_data   # 4 家公司 × 4 个季度(4 行 5 列)
   company  Q1  Q2  Q3  Q4
1 锦城科技 280 310 295 315
2 蜀汉制造 230 240 250 260
3 天府物流 380 400 390 410
4 青羊金融 190 185 195 190

提示

Q1~Q4 这四列,每一格都装着一个数值。想按季度分组统计、或画一张季度趋势图,得先把这四列"收"起来——翻到下一页。

4.3 pivot_longer():把列名"收"进新列

▶️ 查看代码
wide_data %>%
  pivot_longer(cols = Q1:Q4,          # 要"拆开"的列
               names_to = "quarter",  # 新列:装原来的列名
               values_to = "revenue") # 新列:装格子里的数值

提示

三个参数各管一件事:cols 指定拆哪些列;names_to 是装原来列名的新列;values_to 是装格子数值的新列。先别运行——猜猜结果有多少行,翻到下一页看。

4.4 宽变长的结果:4 行变成了 16 行

▶️ 查看代码
long_data <- wide_data %>%
  pivot_longer(cols = Q1:Q4, names_to = "quarter", values_to = "revenue")

print(long_data, n = 8)   # 共 16 行,这里只看前 8 行
# A tibble: 16 × 3
  company  quarter revenue
  <chr>    <chr>     <dbl>
1 锦城科技 Q1          280
2 锦城科技 Q2          310
3 锦城科技 Q3          295
4 锦城科技 Q4          315
5 蜀汉制造 Q1          230
6 蜀汉制造 Q2          240
7 蜀汉制造 Q3          250
8 蜀汉制造 Q4          260
# ℹ 8 more rows

重要

4 家公司 × 4 个季度 = 16 行。长格式里"一行"的含义变小了:不再是"一家公司",而是"一家公司的一个季度"。

4.5 pivot_wider():把一列的取值"铺"回列名

▶️ 查看代码
long_data %>%
  pivot_wider(
    names_from = quarter,    # 谁的取值要变成新的列名(Q1/Q2/Q3/Q4)
    values_from = revenue    # 填进这些新列的数值来自哪一列
  )
# A tibble: 4 × 5
  company     Q1    Q2    Q3    Q4
  <chr>    <dbl> <dbl> <dbl> <dbl>
1 锦城科技   280   310   295   315
2 蜀汉制造   230   240   250   260
3 天府物流   380   400   390   410
4 青羊金融   190   185   195   190

提示

pivot_wider() 是 pivot_longer() 的反向操作。四个参数的分工:宽变长看 names_to / values_to(列名和数值各自进哪一列),长变宽看 names_from / values_from(哪一列的取值去当新列名、哪一列去填数)。记混了就回头看 4.1 那张图。

4.6 课堂练习:用内置数据练习 pivot_longer()

tidyr 自带 relig_income(宗教信仰与家庭收入调查,宽格式:每行一个宗教,各收入区间分列,格子里是人数)。用 pivot_longer() 把除 religion 外的所有列转成长格式,新增 income 列(收入区间名)和 count 列(人数),再用 group_by() + summarise() 计算每个收入区间的信众总人数。

提示

提示:cols = -religion 表示"除 religion 外的所有列都要拆开",不用逐个列出收入区间的列名——列特别多的时候这个写法省事得多。

五、数据库读写(选学)+ 综合实战

项目二四课工具的集中检验

5.1 数据库读写简介(选学,可跳过)

实际工作中,财务数据很多时候不是存在 CSV/Excel 里,而是存在企业的数据库系统中(如 MySQL、SQL Server):

▶️ 查看代码
library(DBI)
con <- dbConnect(RMySQL::MySQL(),
  host = "...", user = "...", password = "...", dbname = "...")  # 建立连接(示意)

data <- dbGetQuery(con, "SELECT * FROM company_financial")   # SQL查询,结果直接变数据框

警告

这部分不作为考核要求,仅作了解。课堂时间紧张时这一页可以直接跳过,不影响后续内容。

5.2 综合实战:四个动作串成一条流水线

任务:分析"不同评级的公司,季度营收走势如何"。

思路——四步,缺一不可:

  1. 合并:把宽格式季度数据和评级表按 company 接上——left_join()
  1. 重塑:把四个季度列"收"成两列(quarter + revenue)——pivot_longer()
  1. 分组:按"评级 + 季度"两个变量分组——group_by(rating, quarter)
  1. 汇总:算每个组的平均营收——summarise(mean(revenue))

5.3 综合实战:代码与结果

▶️ 查看代码
rating_tbl <- data.frame(company = c("锦城科技", "蜀汉制造", "天府物流", "青羊金融"),
                         rating  = c("A", "B", "A", "C"))   # 综合实战用的评级表
res <- wide_data %>%
  left_join(rating_tbl, by = "company") %>%                 # 1 合并评级
  pivot_longer(cols = Q1:Q4, names_to = "quarter",          # 2 宽变长
               values_to = "revenue") %>%
  group_by(rating, quarter) %>%                             # 3 分组
  summarise(avg_revenue = mean(revenue), .groups = "drop")  # 4 汇总
res %>% pivot_wider(names_from = quarter, values_from = avg_revenue)  # 再转宽:一张报告表
# A tibble: 3 × 5
  rating    Q1    Q2    Q3    Q4
  <chr>  <dbl> <dbl> <dbl> <dbl>
1 A        330   355  342.  362.
2 B        230   240  250   260 
3 C        190   185  195   190 

长表算、宽表看:四个动作串起来得到 12 行长表,一转就是 3 行报表。

5.4 课堂练习:用内置数据练习综合流水线

billboard(2000 年公告牌单曲逐周排名,宽格式:每行一首歌,wk1~wk76 是上榜以来每周的名次)——探究"哪些歌曲长期霸榜":

  1. 用 pivot_longer() 把 wk1~wk76 转成长格式,新增 week 和 rank 两列,并用 values_drop_na = TRUE 剔除已下榜、没有名次的记录;
  2. 用 group_by() 按 track 分组,summarise() 算出每首歌的最高名次(min(rank))和上榜周数(n());
  3. 用 arrange() 按上榜周数从多到少排序。

思考:上榜周数最多的那首歌,最高名次是不是第一名?"持续热度"和"巅峰热度"是一回事吗?

六、项目二总结收官、Exit Ticket与预告项目三

"数据准备"完整流程在此闭环

6.1 项目二全景回顾

flowchart LR
    A["第7课<br/>数据读写<br/>类型转换"] --> B["第8课<br/>dplyr筛选<br/>排序"]
    B --> C["第9课<br/>变换与<br/>汇总"]
    C --> D["第10课(今天)<br/>合并与<br/>重塑"]

    style A fill:#2e5984,color:#fff
    style B fill:#7a4419,color:#fff
    style C fill:#375623,color:#fff
    style D fill:#6b3d8f,color:#fff

重要

四次课合起来,就是一条完整的"数据准备"标准流程:读入数据 → 规范类型 → 筛选排序 → 变换汇总 → 合并重塑——这是几乎所有数据分析任务的"标配前奏"。

6.2 学习成果自查

对照一下,今天这几件事你都做到了吗?

  • ✓ 能用 bind_rows() 按列名合并两张表,并说出它与 rbind() 的差别
  • ✓ 能用四种 join 合并数据,并预测结果的行数与 NA 分布
  • ✓ 能发现"连接键不唯一"导致的行数膨胀,并用多字段键 by = c("company", "year") 修正
  • ✓ 能用 anti_join() 找出"没匹配上"的记录
  • ✓ 能用 pivot_longer() / pivot_wider() 在宽、长格式之间互相转换

提示

有没打勾的,下课后找同学或老师补上——项目三"数据清洗"会直接在这些"准备好的数据"上继续加工。

6.3 Exit Ticket —— 写给自己的三个问题

请在纸条或在线问卷上简要回答:

  1. left_join() 和 right_join() 最大的区别是什么?
  2. 什么情况下你会需要把宽格式数据转换成长格式?
  3. 回顾项目二四次课,你觉得哪个工具在未来实习/工作中最可能用到?

课后作业

实验报告4:项目二「数据准备」综合实验

dplyr 综合应用(变换汇总 + 表连接 + 长宽转换),共 6 题。第 3、4、5 题是今天讲的新工具:

  1. 选列 + 筛选 + 新增列 + 排序
  2. 分组汇总 vs 组内计算
  3. inner_join() 单字段合并
  1. left_join() 双字段合并
  2. pivot_wider() 长转宽
  3. 完整流水线:筛选 + 汇总 + 排序

提交要求与本讲加练

重要

提交要求:在实验报告4 的模板里,把每题"代码 + 运行结果"的截图粘贴到相应位置,并在"结果与分析"里写出结论;文件命名为"学号_姓名_实验报告4"。本次作业计入平时成绩的"实验报告"部分。

提示

本讲加练(选做,2 题)——实验报告4 已覆盖本讲大部分新工具,下面两处做个补充:

  1. bind_rows() vs rbind():造两张列数不同的表,分别用两个函数合并,比较结果差异;
  2. pivot_longer():把一张宽格式季度表转成长格式,再按季度分组算平均营收——注意实验报告4 第 5 题只练了反向的 pivot_wider(),两个方向的参数别记混。

下讲预告

项目三:数据清洗——把"准备好的数据"变成"可以直接分析的数据"

  • 缺失值处理(NA 的识别、删除与填补)
  • 异常值检测与处理
  • 重复值处理
  • 字符串规范化(stringr 包)

提示

课前准备:保留好项目二四课的代码与数据文件。项目三要解决的是数据中"脏数据"的问题——这是从"准备好的数据"往"可以直接分析的数据"迈出的关键一步。

谢谢!

第10讲:数据合并与重塑

"Join is not just a technical operation; it is a business decision." 关联字段必须唯一且准确(如按统一社会信用代码而非公司简称合并),"一一对应、不重不漏"是财务数据整合的基本职业操守;选择哪种 join,本身就是对业务规则的理解——技术选择的背后,是业务判断。

朱 奇 | 锦城大学 · 财会学院