1.2 数据清洗与预处理
在实际的科研工作中,原始数据里往往存在许多缺失值、重复行、错位编码、不一致格式等问题。本节围绕 tidyverse 生态中的 dplyr 与 tidyr 两个核心包,把数据清洗体现为一组动词化的操作,让你能像写句子一样写数据处理流程。我们会沿着真实数据的常见问题出发,逐步建立从单表操作到多表连接的完整工具链,并在关键节点用 Python 的 pandas 包作对照,帮助你跨越语言边界。
1.2.1 数据清洗的本质:从原始到可用
一份刚从医院信息系统导出的患者登记表,通常没有教科书里的 mtcars 那样整洁。年龄列里可能混着空字符串和文本型数字,性别编码可能同时出现 M/F、男/女、1/0 三种格式,同一名患者可能因为重复挂号出现两次。这些问题在工作中会沿着分析链条层层放大:缺失值会让回归模型自动丢样本,重复行会让置信区间变窄,类型错误会让聚合结果全部变成 NA。
数据清洗的目标是把杂乱的原始数据整理成可直接分析的整洁数据:每一行是一个观测,每一列是一个变量,每一个单元格是一个原子值。实现这个目标的工具由 dplyr 和 tidyr 两个包提供。
dplyr 解决的是单表操作问题:筛选行、选择列、派生新变量、分组汇总、排序。它把每类操作抽象成一个动词,动词之间通过管道连接,形成一条从输入到输出的清晰流水线。
tidyr 解决的是表结构问题:长格式与宽格式的互转、列的拆分与合并、嵌套数据的展开。两者共同构成了整洁数据理念的具体实现。
本节的示例数据通过 data.frame() 直接构造,便于复现。在实际工作中,数据通常用 readr::read_csv() 或 readxl::read_excel() 读入,读入后第一步永远是 glimpse(df) 或 str(df) 检查类型与维度。
请注意,在数据处理中尽量使用csv格式的列表进行数据的导入导出,excel格式会导致各种各样的数据丢失和错误。
下面构造一份带有典型问题的小数据集,后续小节会反复回到它身上。这份 6 行数据里同时出现了重复行(id=3)、缺失值(age、sys_bp)、类型错误(age 被读成字符)、编码不一致(gender 混用两种编码):
library(dplyr)
library(tidyr)
# 模拟一份患者登记数据,刻意制造缺失、重复与类型混乱
patients <- data.frame(
id = c(1, 2, 3, 3, 4, 5),
name = c("张三", "李四", "王五", "王五", "赵六", "钱七"),
age = c("45", "52", NA, "52", "38", "61"),
gender = c("M", "F", "男", "男", "F", "F"),
sys_bp = c(125, 142, 130, 130, NA, 150),
dia_bp = c(80, 92, 85, 85, 75, 98)
)
glimpse(patients)
# Rows: 6
# Columns: 6
# $ id <dbl> 1, 2, 3, 3, 4, 5
# $ name <chr> "张三", "李四", ...
# $ age <chr> "45", "52", NA, ... # 应为数值,却是字符
# $ gender <chr> "M", "F", "男", ... # 编码不一致
# $ sys_bp <dbl> 125, 142, 130, ... # 存在缺失
# $ dia_bp <dbl> 80, 92, 85, ...glimpse() 比 str() 更紧凑,把每一列横向展开,适合宽表的第一眼检视。注意 age 列显示为 <chr> 而不是 <dbl>,这是数据进入清洗流程时最该警惕的信号之一。如果忽略它直接做 mean(patients$age),结果会是 NA,因为对字符向量求均值在 R 中没有定义。
1.2.2 dplyr包简介
dplyr 的设计哲学是用一组语义明确的动词覆盖 90% 的日常数据操作。掌握五个核心动词和一个分组机制就足以应对绝大多数单表清洗任务。这套设计的好处在于学习曲线平缓
| 动词 | 作用 | 类比 SQL |
|---|---|---|
filter() | 按条件筛选行 | WHERE |
select() | 按列名选择列 | SELECT col |
mutate() | 派生新列或修改列 | col AS new |
summarise() | 汇总为单行结果 | COUNT/SUM/AVG |
arrange() | 排序 | ORDER BY |
filter:按条件筛选行
filter() 保留满足条件的行,条件用逻辑表达式给出。多个条件用逗号分隔表示 AND,用 | 表示 OR。! 表示取反。逻辑表达式中可用的运算符包括 >, <, >=, <=, ==, !=, %in%, between(), is.na()。
# 筛选收缩压大于 130 且不缺失的行
patients %>%
filter(sys_bp > 130, !is.na(sys_bp))
# 多条件 OR:高血压或低血压
patients %>%
filter(sys_bp > 140 | sys_bp < 90)
# 用 %in% 做集合匹配
patients %>%
filter(gender %in% c("M", "F"))filter() 会自动丢弃 NA 行,因为 NA > 130 的结果是 NA 而非 TRUE。若想保留 NA,需显式写 is.na(sys_bp) | sys_bp > 130。这是初学者最常踩的坑。
Python pandas 中的等价写法使用布尔索引,思路一致但语法更显式:
import pandas as pd
patients_df = pd.DataFrame(patients) # 假设已转换为 DataFrame
# 多条件用 & | 连接,每个条件必须用括号包住
patients_df[
(patients_df["sys_bp"] > 130) & (patients_df["sys_bp"].notna())
]select:选择列
select() 用于按列名选列,支持 starts_with()、ends_with()、contains()、matches()、num_range() 等辅助函数,也可以用 : 表示列范围、用 ! 表示取反、用 & 和 | 组合条件。
# 只保留患者 ID 和血压两列
patients %>%
select(id, sys_bp, dia_bp)
# 选择所有血压相关列(按命名模式)
patients %>%
select(id, ends_with("bp"))
# 排除某列
patients %>%
select(-id, -name)
# 把某列移到最前,其余保持顺序
patients %>%
select(name, everything())
# 用 : 选连续列
patients %>%
select(id:gender)mutate:派生新列
mutate() 是数据清洗常用的函数:它根据已有列计算新列,新列可以立即在同一个 mutate() 调用中被后续公式引用。case_when() 处理多分支条件,相当于 SQL 的 CASE WHEN;if_else() 处理二分支;recode() 重编码离散值。
# 把 age 转为数值;计算平均动脉压;按血压分级
patients_clean <- patients %>%
mutate(
age = as.numeric(age),
map = (sys_bp + 2 * dia_bp) / 3,
gender = recode(gender, "M" = "男", "F" = "女"),
bp_level = case_when(
sys_bp < 120 ~ "正常",
sys_bp < 140 ~ "高血压前期",
sys_bp >= 140 ~ "高血压",
TRUE ~ NA_character_
)
)as.numeric(age) 中如果 age 列包含无法解析为数字的字符串(如 "不详"),会被静默转换为 NA。建议转换后用 sum(is.na(age)) 检查是否产生了预期外的缺失。if_else() 与基础包的 ifelse() 行为类似但更严格:它会校验 TRUE 和 FALSE 分支的类型一致。
summarise:汇总为单行
summarise() 把整张表压缩成一行,每一列是一个汇总统计量。它与 group_by() 联用时会有更多神奇功能,单独使用时相当于对整张表算几个数。
# 对整张表汇总:样本量、平均年龄、平均动脉压
patients_clean %>%
summarise(
n = n(),
mean_age = mean(age, na.rm = TRUE),
sd_age = sd(age, na.rm = TRUE),
mean_map = mean(map, na.rm = TRUE),
min_bp = min(sys_bp, na.rm = TRUE),
max_bp = max(sys_bp, na.rm = TRUE)
)注意 na.rm = TRUE 这个参数。R 的统计函数默认遇到 NA 就返回 NA,这是统计学上的保守策略,但在数据清洗中通常需要显式忽略。养成加 na.rm = TRUE 的习惯能省去很多调试时间。
arrange:排序
arrange() 默认升序,desc() 改为降序。缺失值默认排在最末尾。多个排序键按先后顺序生效。
# 按收缩压降序排列,平局时按 id 升序
patients_clean %>%
arrange(desc(sys_bp), id)group_by 与 summarise 的配合:分组汇总
group_by() 本身不改变数据,只是给数据框打上分组标签。后续的 summarise()、mutate()、filter() 会按组执行,这就是 dplyr 常用的分组计算模式。summarise() 会拆解一层分组,ungroup() 则一次性移除所有分组。
# 按性别统计样本量与平均年龄
patients_clean %>%
group_by(gender) %>%
summarise(
n = n(),
mean_age = mean(age, na.rm = TRUE),
mean_bp = mean(sys_bp, na.rm = TRUE)
) %>%
ungroup() # 养成分组后解组的习惯group_by() %>% mutate() 会在组内派生新列,常用于计算组内排名、组内占比、组内标准化。group_by() %>% filter() 会按组筛选,例如每组保留前 3 行。group_by() %>% slice() 可以取出每组的前 N 行、最大值行、随机抽样行。
分组汇总的一个常见需求是多列同时汇总。across() 让你在 summarise() 或 mutate() 中批量应用函数:
# 对所有数值列计算均值与标准差
patients_clean %>%
group_by(gender) %>%
summarise(
across(
where(is.numeric),
list(mean = ~mean(.x, na.rm = TRUE), sd = ~sd(.x, na.rm = TRUE)),
.names = "{.col}_{.fn}"
)
)where(is.numeric) 选择所有数值列,list(mean = ..., sd = ...) 定义要应用的函数列表,.names 控制输出列名格式。这是 dplyr 1.0 之后推荐的批量操作语法,替代了早期的 summarise_if()、summarise_at() 系列。
窗口函数:lag / lead / cumsum
除了聚合函数,dplyr 还提供窗口函数,用于计算相对位置或累计值。lag() 取前一行的值,lead() 取后一行,cumsum()、cummean()、cummax()、cummin() 做累计计算。窗口函数在时间序列和组内排序场景中非常重要。
# 创建一份月度销售数据,演示窗口函数
sales <- data.frame(
month = 1:6,
revenue = c(100, 120, 110, 150, 140, 180)
)
sales %>%
mutate(
prev_rev = lag(revenue), # 上月收入
next_rev = lead(revenue), # 下月收入
growth = (revenue - prev_rev) / prev_rev, # 环比增长率
cum_total = cumsum(revenue), # 累计收入
cum_avg = cummean(revenue) # 累计均值
)lag() 和 lead() 默认偏移一行,可以通过第二参数调整:lag(revenue, 2) 取前两行。结合 group_by() 可以在分组内做窗口计算,例如按患者分组计算两次就诊之间的指标变化。
slice:按位置取行
slice() 系列函数按位置或排名取行,是 filter() 的补充。slice_head()、slice_tail() 取首尾 N 行;slice_max()、slice_min() 取某列最大最小的 N 行;slice_sample() 随机抽样。
# 取每位患者中血压最高的 1 次记录(按组)
patients_clean %>%
group_by(gender) %>%
slice_max(sys_bp, n = 1) %>%
ungroup()
# 随机抽取 50% 的样本做训练集
patients_clean %>%
slice_sample(prop = 0.5)slice_sample() 在数据划分时常用,比基础包的 sample() 更直观。配合 rsample 包可以做更严谨的交叉验证划分。
Python pandas 的等价写法是 groupby() + agg(),以及窗口方法 .shift()、.cumsum():
# 分组汇总
patients_df.groupby("gender").agg(
n=("age", "size"),
mean_age=("age", "mean"),
mean_bp=("sys_bp", "mean"),
).reset_index()
# 窗口函数
sales_df["prev_rev"] = sales_df["revenue"].shift(1)
sales_df["cum_total"] = sales_df["revenue"].cumsum()1.2.3 tidyr:长宽格式转换
同一段数据可以以两种物理形态存在。宽格式把每个变量放在一列,适合人类阅读和 Excel 录入;长格式把变量名与变量值分别堆叠到两列,适合机器分析和 ggplot 作图。tidyr 提供了 pivot_longer() 与 pivot_wider() 两个函数完成两者之间的互转。
理解这两种格式的差异是数据清洗的分水岭。许多初学者在 ggplot 中卡住,原因往往出在数据没有转成长格式。ggplot2 的 aes() 默认把一列映射成一个图形属性,所以数据必须是长格式才能同时展示多个变量。
宽格式转长格式
下面这份宽格式表记录了三个学生在三门课上的成绩。每一行是一个学生,每一列是一门课。这种结构对人来说清晰直观,但如果你想在 ggplot 里同时按科目分面作图,就需要先转成长格式。
# 宽格式成绩表
grades_wide <- data.frame(
student = c("张三", "李四", "王五"),
math = c(85, 78, 92),
english = c(80, 88, 75),
physics = c(90, 85, 88)
)
# pivot_longer 把 math/english/physics 三列堆叠成 subject + score 两列
grades_long <- grades_wide %>%
pivot_longer(
cols = -student, # 除了 student 之外的所有列
names_to = "subject",
values_to = "score"
)cols = -student 表示除了 student 列以外的所有列都要被转换。names_to 给存放原列名的新列命名,values_to 给存放原数值的新列命名。结果是 9 行 3 列的长格式表。
更复杂的场景:列名本身包含信息,例如 math_2023、math_2024、english_2023。此时用 names_sep 或 names_pattern 把列名拆开:
# 列名含科目与年份,用 names_sep 拆开
grades_complex <- data.frame(
student = c("张三", "李四"),
math_2023 = c(85, 78),
math_2024 = c(88, 80),
english_2023 = c(80, 88),
english_2024 = c(82, 90)
)
grades_complex %>%
pivot_longer(
cols = -student,
names_to = c("subject", "year"),
names_sep = "_",
values_to = "score"
)长格式转宽格式
反向操作用 pivot_wider()。它把一对 key-value 列展开为多列。在汇报结果、生成透视表时常用。
# 把长格式转回宽格式
grades_long %>%
pivot_wider(
names_from = subject,
values_from = score
)若长格式中存在重复的 key 组合,pivot_wider() 会报错或生成列表列。可先用 group_by() %>% summarise() 聚合后再转宽,或在 pivot_wider() 里指定 values_fn = list(score = mean) 让函数自动聚合。
Python pandas 的对应函数是 melt()(宽转长)和 pivot()(长转宽),名字不同但思路一致:
# 宽转长
long_df = grades_wide.melt(
id_vars="student",
var_name="subject",
value_name="score",
)
# 长转宽
wide_df = long_df.pivot(
index="student", columns="subject", values="score"
).reset_index()列的拆分与合并
tidyr 还提供 separate() 与 unite(),处理一列中混合多信息的场景。例如把 "45/170" 这样的年龄与身高合并字符串拆成两列:
# 把 "age_height" 列按 "/" 拆成两列
df <- data.frame(id = 1, age_height = "45/170")
df %>%
separate(age_height, into = c("age", "height"), sep = "/", convert = TRUE)convert = TRUE 让拆分后的列自动转类型,避免得到字符型的 age 和 height。unite() 是 separate() 的逆操作,把多列合并成一列,常用于构造复合 ID:
# 把 year 和 quarter 合成 "2023-Q1" 这样的列
sales_long <- data.frame(
year = c(2023, 2023, 2024),
quarter = c("Q1", "Q2", "Q1"),
revenue = c(100, 150, 120)
)
sales_long %>%
unite("period", year, quarter, sep = "-", remove = FALSE)1.2.4 缺失值处理策略
缺失值是数据清洗中最难决策的部分。难不在函数,而在判断:这个 NA 是真的没测,还是录入时漏填,还是计算中产生的副作用。不同来源的缺失需要不同策略。统计学上把缺失分为三类:完全随机缺失(MCAR)、随机缺失(MAR)、非随机缺失(MNAR)。不同类型对应不同的处理方法,处理前先想清楚你面对的是哪一类。
识别缺失
R 用 NA 表示缺失值,字符串缺失在 readr 读入后是 NA_character_,无穷值用 Inf/-Inf,空字符串在原始文件里可能既不是 NA 也不是 "",需要用 na_if() 显式转换。识别工具用 is.na() 和 complete.cases()。
# 整体缺失情况
sum(is.na(patients_clean)) # 总缺失数
colSums(is.na(patients_clean)) # 每列缺失数
mean(is.na(patients_clean$sys_bp)) # 某列缺失比例
# 完整行(无任何缺失)的索引
complete.cases(patients_clean)
# 找出含 NA 的行
patients_clean %>%
filter(!complete.cases(.))可视化缺失模式用 naniar 包的 vis_miss(df) 函数,它会生成一张热力图,行是观测、列是变量、缺失值显示为深色。一眼能看出缺失是集中在某些列还是均匀分布。
删除 vs 填充
删除缺失适合缺失比例低且随机的场景。如果缺失比例高(如某列 50% 是 NA),删除会让样本量骤减,此时应考虑填充或直接弃用该列。
# 删除任意含 NA 的行
patients_clean %>% drop_na()
# 只删除 sys_bp 缺失的行
patients_clean %>% drop_na(sys_bp)
# 用基础包的 complete.cases
patients_clean[complete.cases(patients_clean), ]填充策略取决于变量类型与业务语义。分类变量常用众数或新类别 unknown 填充;连续变量常用均值、中位数或前后值填充。时间序列数据用前后值填充(前向填充或后向填充)能保留趋势信息。
# 用列均值填充 sys_bp
patients_clean %>%
mutate(sys_bp = replace_na(sys_bp, mean(sys_bp, na.rm = TRUE)))
# 用中位数填充(对异常值更稳健)
patients_clean %>%
mutate(sys_bp = replace_na(sys_bp, median(sys_bp, na.rm = TRUE)))
# 用前后值填充(适合时间序列)
patients_clean %>%
fill(sys_bp, .direction = "downup")
# 用固定值填充分类变量
patients_clean %>%
mutate(gender = replace_na(gender, "unknown"))用均值填充会人为压低方差,让置信区间变窄。在回归分析中,更稳妥的做法是用 mice 包做多重插补,或在建模时让模型自己处理 NA。本节只覆盖单值填充。
医疗数据中的缺失往往不是随机的:危重患者可能因为无法测量而缺失生命体征。这种非随机缺失不能用均值简单填充,否则会引入偏倚。处理前先与领域专家确认缺失机制,必要时做敏感性分析比较不同填充策略对结论的影响。
1.2.5 管道操作符
回头看前面任何一段代码,你会发现 %>% 是贯穿始终的符号。它的作用是把左边的对象作为右边函数的第一个参数传入,让数据流向从上到下、从左到右清晰可见。管道改变的不只是代码外观,而是 R 语言的思维模式:从函数嵌套转向数据流水线。
%>% 与 |> 的区别
R 现在有两套管道:magrittr 包提供的 %>%(dplyr 默认),R 4.1+ 内置的 |>。两者在大多数场景下等价,但有几个细节差异:
# magrittr 管道,支持点号占位
mtcars %>% lm(mpg ~ disp, data = .)
# 原生管道,不支持点号占位,只能作为第一个参数
mtcars |> lm(mpg ~ disp) # 等价写法
mtcars |> lm(formula = mpg ~ disp, data = _) # R 4.2+ 用 _ 占位%>% 的优势是支持点号占位(.),可以把左侧对象传到右侧函数的任意位置。|> 是 R 基础包的一部分,无需依赖,性能略好,但只能在第一个参数位置或通过 _ 占位(R 4.2+)。
新项目建议直接用 |>,老项目继续用 %>% 也没问题。两者在 dplyr 工作流中可互换。注意 |> 在 R 4.1 之前不存在,给老版本 R 写代码时只能用 %>%。
管道如何提升可读性
对比下面两种写法,做的是同一件事:选三列、过滤、计算均值、排序。嵌套写法要从里往外读,反人类;管道写法从上往下读,像一条流水线。
# 嵌套写法:从里往外读,反人类
arrange(
summarise(
filter(select(patients, id, gender, sys_bp), sys_bp > 120),
mean_bp = mean(sys_bp, na.rm = TRUE),
.by = gender
),
desc(mean_bp)
)
# 管道写法:从上往下读,像一条流水线
patients %>%
select(id, gender, sys_bp) %>%
filter(sys_bp > 120) %>%
summarise(mean_bp = mean(sys_bp, na.rm = TRUE), .by = gender) %>%
arrange(desc(mean_bp))管道不宜过长。如果一条链超过 10 步,建议拆成中间变量分段命名,方便调试和单元测试。可以用 %>% 链路末尾的 assign() 或简单的 <- 接住中间结果。命名清晰的小段比一条长链更易维护。
Python 的对照:方法链
Python pandas 没有独立的管道操作符,但 DataFrame 的方法大多返回 DataFrame,因此可以串成方法链。思路一致,只是语法糖不同。R 用函数加管道,Python 用方法加点号。
result = (
patients_df[["id", "gender", "sys_bp"]]
.query("sys_bp > 120")
.groupby("gender", as_index=False)
.agg(mean_bp=("sys_bp", "mean"))
.sort_values("mean_bp", ascending=False)
)pandas 还提供了 .pipe() 方法,用于把任意函数插入方法链,相当于 R 的 %>%:
# 用 .pipe() 把自定义函数嵌入链路
def normalize(df, col):
df = df.copy()
df[col] = (df[col] - df[col].mean()) / df[col].std()
return df
result = (
patients_df
.pipe(normalize, "sys_bp")
.groupby("gender", as_index=False)
.agg(mean_bp=("sys_bp", "mean"))
)1.2.6 多表连接
真实数据很少集中在一张表里。临床数据通常分散在患者基本信息表、检验结果表、用药记录表,需要按某个键连接起来才能分析。dplyr 提供了与 SQL 高度一致的连接函数族,掌握语义后从 SQL 迁移几乎零成本。理解连接类型的关键是弄清楚保留哪些行和保留哪些列。
准备演示数据
下面用两个小表演示。patients_tbl 是主表,visits_tbl 是就诊记录表,两者通过 id 关联。注意 id=4 在 patients 中有但 visits 中没有,id=5 在 visits 中有但 patients 中没有,这两个不匹配的 id 是观察连接行为的关键。
patients_tbl <- data.frame(
id = c(1, 2, 3, 4),
name = c("张三", "李四", "王五", "赵六")
)
visits_tbl <- data.frame(
id = c(1, 2, 2, 5),
date = c("2024-01-01", "2024-01-02", "2024-01-05", "2024-01-03"),
diag = c("感冒", "高血压", "复查", "初诊")
)四种主要连接的语义
inner_join 只保留两表都有匹配的行;left_join 保留左表所有行,右表无匹配则填 NA;right_join 保留右表所有行;full_join 保留两表所有行。这是 SQL 的标准语义,dplyr 完全继承。
# 内连接:只保留两表都有匹配的 id
inner_join(patients_tbl, visits_tbl, by = "id")
# id name date diag
# 1 张三 2024-01-01 感冒
# 2 李四 2024-01-02 高血压
# 2 李四 2024-01-05 复查
# 左连接:保留左表所有行,右表无匹配则 NA
left_join(patients_tbl, visits_tbl, by = "id")
# id name date diag
# 1 张三 2024-01-01 感冒
# 2 李四 2024-01-02 高血压
# 2 李四 2024-01-05 复查
# 3 王五 <NA> <NA>
# 4 赵六 <NA> <NA>
# 全连接:保留两表所有行
full_join(patients_tbl, visits_tbl, by = "id")
# 包含 id=4 (只在 patients) 和 id=5 (只在 visits)left_join() 是临床数据分析中常用的连接方式:主表是患者清单,副表是检验或用药,宁可副表为 NA 也不能丢患者。inner_join() 会悄悄删除未就诊患者,可能导致偏倚。养成习惯:每次 join 后用 nrow() 检查行数变化是否符合预期。
连接键的指定方式
当两表的键列名相同时,by = "id" 即可。列名不同时用命名向量:by = c("left_key" = "right_key")。多键连接用字符向量。如果省略 by 参数,dplyr 会按所有同名列做自然连接,并打印一条消息提示用了哪些列。
# 两表中列名不同:patients 用 patient_id,visits 用 pid
left_join(patients_tbl, visits_tbl, by = c("patient_id" = "pid"))
# 多键连接:按 year + quarter 关联
sales_2023 <- data.frame(
year = c(2023, 2023, 2024, 2024),
quarter = c("Q1", "Q2", "Q1", "Q2"),
sales = c(100, 150, 120, 180)
)
sales_2024 <- data.frame(
year = c(2023, 2023, 2024, 2024),
quarter = c("Q1", "Q2", "Q1", "Q2"),
profit = c(20, 30, 25, 40)
)
left_join(sales_2023, sales_2024, by = c("year", "quarter"))半连接与反连接
semi_join() 与 anti_join() 是两个常被忽略的辅助函数,它们用于筛选而非合并:返回左表的行,但不附加右表任何列。可以理解为 IN 子查询的实现。
# 半连接:保留在右表中有匹配的左表行
semi_join(patients_tbl, visits_tbl, by = "id")
# 返回 id=1, 2 两行(不含右表的列)
# 反连接:保留在右表中没有匹配的左表行
anti_join(patients_tbl, visits_tbl, by = "id")
# 返回 id=3, 4 两行(未就诊的患者)反连接是排查数据缺失的利器。例如用 anti_join(lab_results, patients, by = "id") 可以找出检验结果里有但患者主表里没有的 ID,往往是录入错误或数据同步问题。这类异常在 inner_join 后会无声消失,必须主动用 anti_join 才能发现。
行合并与列合并
除了键值连接,dplyr 还提供 bind_rows() 和 bind_cols() 做简单的纵向与横向拼接。bind_rows() 比 base R 的 rbind() 更灵活:列名不完全相同时会自动填充 NA,而不是报错。
# 纵向合并:两个结构相似的表追加行
df1 <- data.frame(id = 1:3, value = c(10, 20, 30))
df2 <- data.frame(id = 4:6, value = c(40, 50, 60))
bind_rows(df1, df2)
# 列名不同时自动填充 NA
df3 <- data.frame(id = 7:8, value = c(70, 80), extra = c("A", "B"))
bind_rows(df1, df3) # df1 的 extra 列填充 NA
# 横向合并:按位置拼接列
bind_cols(df1, df2)bind_cols() 按行号匹配,不检查键。如果两表的行顺序不一致,会得到错误结果。这种场景必须用 left_join() 等键值连接,而不是 bind_cols()。
与 pandas.merge / SQL JOIN 的对照
pandas 的 merge() 函数语义几乎完全对应:
import pandas as pd
# 内连接
pd.merge(patients_tbl, visits_tbl, on="id", how="inner")
# 左连接
pd.merge(patients_tbl, visits_tbl, on="id", how="left")
# 列名不同
pd.merge(patients_tbl, visits_tbl,
left_on="patient_id", right_on="pid", how="left")
# 多键
pd.merge(sales_2023, sales_2024, on=["year", "quarter"], how="left")
# 纵向合并(对应 bind_rows)
pd.concat([df1, df2], ignore_index=True)SQL 的 INNER JOIN、LEFT JOIN、FULL OUTER JOIN 与 dplyr 的同名函数在语义上一一对应,只是语法从函数式换成了声明式。如果你已经熟悉 SQL,迁移成本几乎为零。差异主要在细节:dplyr 默认保留所有列(包括重复的键列会去重),SQL 需要显式 SELECT;dplyr 的 semi_join 和 anti_join 在 SQL 中需要用 EXISTS 或 IN 子查询实现。
连接的常见陷阱
实际工作中连接操作最容易出错的地方有几个:键列有重复值导致结果膨胀、键列类型不一致(一边是数值一边是字符)连接静默失败、连接后列名冲突被自动加后缀 .x/.y。下面是排查清单:
# 连接前检查键的唯一性
visits_tbl %>% count(id) %>% filter(n > 1) # 看哪些 id 重复
# 连接前检查键的类型
class(patients_tbl$id)
class(visits_tbl$id)
# 类型不一致时先转换
patients_tbl %>%
mutate(id = as.character(id)) %>%
left_join(visits_tbl, by = "id")
# 列名冲突时指定后缀
left_join(df_a, df_b, by = "id", suffix = c("_main", "_visit"))left_join 后行数大于左表,几乎一定是右表的键有重复。这种情况要么先用 group_by() %>% summarise() 把右表聚合到键唯一,要么明确接受一对多关系。无声的行数膨胀是数据清洗中最常见的逻辑错误之一。
综合应用:一条完整的清洗流水线
把本节学到的工具串起来,演示从原始混乱数据到分析就绪数据的完整流程。这条流水线把重复去除、类型转换、缺失填充、派生变量、分组汇总整合在一起,是真实项目里典型的清洗脚本骨架:
library(dplyr)
library(tidyr)
# 从原始 patients 出发,一条管道完成全部清洗
patients_summary <- patients %>%
# 1. 去重(按 id 保留第一条)
distinct(id, .keep_all = TRUE) %>%
# 2. 类型转换与重编码
mutate(
age = as.numeric(age),
gender = recode(gender, "M" = "男", "F" = "女", .default = "unknown"),
gender = replace_na(gender, "unknown")
) %>%
# 3. 缺失值填充
mutate(
sys_bp = replace_na(sys_bp, median(sys_bp, na.rm = TRUE)),
dia_bp = replace_na(dia_bp, median(dia_bp, na.rm = TRUE))
) %>%
# 4. 派生变量
mutate(
map = (sys_bp + 2 * dia_bp) / 3,
bp_level = case_when(
sys_bp < 120 ~ "正常",
sys_bp < 140 ~ "高血压前期",
TRUE ~ "高血压"
)
) %>%
# 5. 分组汇总
group_by(gender, bp_level) %>%
summarise(
n = n(),
mean_age = mean(age, na.rm = TRUE),
mean_map = mean(map, na.rm = TRUE),
.groups = "drop"
)
patients_summary真实项目中这条链路通常要拆成 3-4 个命名的中间步骤,例如 patients_dedup、patients_typed、patients_filled,每步都用 stopifnot(nrow(.) >= 1) 等断言检查数据完整性。一条长链在调试时很难定位问题,分段命名能让你在出错时迅速缩小范围。
本节小结
数据清洗的本质是把杂乱的原始数据整理成每一行一个观测、每一列一个变量的整洁形态。dplyr 的五个核心动词 filter、select、mutate、summarise、arrange 加上 group_by 分组机制,覆盖了绝大多数单表操作;tidyr 的 pivot_longer/pivot_wider 处理长宽格式互转,separate/unite 处理列的拆分合并。缺失值处理要先判断机制再选策略,删除与填充各有适用场景,对医疗数据尤其要警惕非随机缺失。管道操作符让数据流向清晰,多表连接则把分散的数据源整合成一张可分析的主表。这些工具组合起来,就是从原始 CSV 到分析就绪数据的完整桥梁。
练习题
第1题 管道与分组汇总
给定数据框 sales <- data.frame(region = c("东", "南", "东", "北", "南"), product = c("A", "A", "B", "B", "A"), amount = c(100, 200, 150, 80, 120)),用一条管道写出按 region 分组计算总销售额与平均销售额,并按总销售额降序排列的代码。
参考答案
sales <- data.frame(
region = c("东", "南", "东", "北", "南"),
product = c("A", "A", "B", "B", "A"),
amount = c(100, 200, 150, 80, 120)
)
sales %>%
group_by(region) %>%
summarise(
total = sum(amount),
mean = mean(amount)
) %>%
arrange(desc(total))group_by() 打上分组标签后,summarise() 会按组聚合,每组返回一行。arrange(desc(total)) 在汇总后的表上排序,不需要再分组。
第2题 长宽格式转换
给定宽格式数据框 grades <- data.frame(student = c("甲", "乙"), math = c(85, 78), english = c(80, 88), physics = c(90, 85)),将其转为长格式,列名拆分为 subject 与 score 两列,再转回宽格式。
参考答案
grades <- data.frame(
student = c("甲", "乙"),
math = c(85, 78),
english = c(80, 88),
physics = c(90, 85)
)
# 宽转长
grades_long <- grades %>%
pivot_longer(
cols = -student,
names_to = "subject",
values_to = "score"
)
# 长转宽
grades_wide <- grades_long %>%
pivot_wider(
names_from = subject,
values_from = score
)pivot_longer() 的 cols = -student 表示除 student 列外都参与堆叠。pivot_wider() 是逆操作,names_from 指定来源列名,values_from 指定取值列。
第3题 缺失值识别与填充
给定向量 bp <- c(120, NA, 135, 140, NA, 128),写代码统计缺失数量,并用中位数填充缺失值。
参考答案
bp <- c(120, NA, 135, 140, NA, 128)
# 统计缺失数量
sum(is.na(bp))
# 用中位数填充
bp_filled <- ifelse(is.na(bp), median(bp, na.rm = TRUE), bp)is.na() 返回逻辑向量,sum() 把 TRUE 当作 1 求和得到缺失数。median(bp, na.rm = TRUE) 必须加 na.rm = TRUE,否则遇到 NA 会返回 NA。ifelse() 逐元素判断,缺失位置填中位数,其余位置保留原值。
常见错误
错误 1 · Error: object '.' not found
原因:在原生管道 |> 后接的函数里使用了点号占位 .。点号占位是 magrittr 管道 %>% 的特性,|> 不支持。
解决:改用 %>% 替代 |>,或在 R 4.2 以上版本用下划线 _ 占位。例如 mtcars |> lm(mpg ~ disp, data = _)。
错误 2 · filter() 静默丢弃 NA 行
原因:filter(sys_bp > 130) 中,NA > 130 的结果是 NA 而非 TRUE,filter() 只保留 TRUE 的行,NA 行被无声删除。
解决:若需保留 NA 行,显式写出 is.na(sys_bp) | sys_bp > 130。养成在 filter 后用 nrow() 检查行数的习惯,避免数据量意外减少。
错误 3 · as.numeric() 把字符转为 NA
原因:age 列存为字符型,其中混有 "不详" 等无法解析的字符串,as.numeric() 会静默把这些值转为 NA,并给出警告 NAs introduced by coercion。
解决:转换后用 sum(is.na(age)) 检查是否产生了预期外的缺失。若需保留原值,先用 readr::parse_number() 或正则提取数字部分。
错误 4 · left_join 后行数膨胀
原因:右表的连接键有重复值,一对多关系导致左表的某些行被复制多份,行数无声增加。
解决:连接前用 count(id) %>% filter(n > 1) 检查右表键的唯一性。若右表确实有多行,先用 group_by() %>% summarise() 聚合到键唯一,或明确接受一对多关系并在后续步骤处理。