第11讲:缺失值处理—NA的识别、删除与填补

数据分析与R语言

2026年10月08日

本讲导览

  • 一、复习导入——小测四题 + 情境导入
  • 二、缺失值的识别——is.na() 一族
  • 三、缺失值的删除——三种写法+na.rm
  • 四、缺失值的填补——六条路:删除、填固定值、均值中位数、前值后值、前后平均(线性插值)、多源拼合
  • 五、综合实战——用 airquality 走全流程
  • 六、课堂总结——成果自查与 Exit Ticket

重要

今天是项目三"数据清洗"的开篇课。项目二教会我们"准备"数据,但现实数据往往还不"干净":有缺失、有异常、有重复。项目三四课系统解决这些问题,今天从最常见的缺失值开始。

一、复习导入与新课导论

项目二够用了吗?先花三分钟,四道小题测一测

1.1 开篇小测:四道小题(全部是前三课学过的内容)

1.(第 8 讲)df %>% filter(score >= 60, grade == "A") 里,两个条件要同时满足还是满足一个就行?想"满足一个就行"该怎么写?

2.(第 9 讲)mutate() 和 summarise(),哪一个会把表"变小"(行数变少)?哪一个保持原来的行数?

3.(第 10 讲)left_join(A, B) 合并两表时,A 里那些在 B 中找不到匹配的行——会保留吗?B 带过来的列里会填进什么?

4.(第 10 讲)把"宽表"变成"长表"用哪个函数?反过来呢?

1.2 情境导入

展示一份 company_data:蜀汉制造和锦江生物两家公司的 revenue 是空的(NA)——为什么会这样?(可能是财务人员漏填、系统对接失败、新公司还没有历史数据等)

提问:如果直接对 revenue 列求 mean(),R 会给出什么结果?

二、缺失值的识别——is.na()与它的伙伴们

处理之前,先看清楚哪里缺、缺多少

2.1 造一份"有缺失"的财务表

▶️ 查看代码
library(dplyr)                               # 本章起要用 dplyr
company_data <- data.frame(
  company = c("锦城科技", "蜀汉制造", "天府物流", "青羊金融", "锦江生物", "浣花实业"),
  revenue = c(1200, NA, 1560, 760, NA, 892),
  cost    = c(800, 650, NA, 500, 900, 620),
  rating  = c("A", "B", "A", "C", "A", "B")
)

company_data    # 营收缺 2 家、成本缺 1 家 —— 这份数据是故意造的
   company revenue cost rating
1 锦城科技    1200  800      A
2 蜀汉制造      NA  650      B
3 天府物流    1560   NA      A
4 青羊金融     760  500      C
5 锦江生物      NA  900      A
6 浣花实业     892  620      B

2.2 is.na()——标出来、数一数

▶️ 查看代码
is.na(company_data$revenue)        # 逐个判断:第 2、5 格是 TRUE(缺失)
[1] FALSE  TRUE FALSE FALSE  TRUE FALSE
▶️ 查看代码
sum(is.na(company_data$revenue))   # 数一数:一共几个缺失
[1] 2

2.3 常见错误:用 == NA 找缺失

▶️ 查看代码
company_data$revenue == NA            # 一串 NA,没有一个 TRUE
[1] NA NA NA NA NA NA
▶️ 查看代码
is.na(company_data$revenue) %>% sum()  # 正确写法:数出 2 个缺失
[1] 2

NA 的含义是"不知道"——任何数和"不知道"比较,结果仍是"不知道"(NA)。所以判断缺失只有一个正确写法:is.na()。

2.4 mean() 与 na.rm——缺失值会"传染"

▶️ 查看代码
mean(company_data$revenue)               # 直接求均值 → NA(缺失值会"传染")
[1] NA
▶️ 查看代码
mean(company_data$revenue, na.rm = TRUE) # 加 na.rm = TRUE:忽略缺失值再算
[1] 1103

NA 会"传染":对 mean() 这类默认不忽略缺失值的函数,只要输入中有 NA,结果通常也会是 NA。许多常用统计函数提供 na.rm = TRUE,允许计算时移除缺失项;但要查具体函数文档,且这不会替你处理原数据。

2.5 定位到每一列——colSums(is.na())

注记

colSums(is.na()) 一次给出各列的"体检报告"——revenue 缺 2 个(33.3%)、cost 缺 1 个(16.7%)。

▶️ 查看代码
colSums(is.na(company_data))       # 每一列各有多少个NA,一眼看清
company revenue    cost  rating 
      0       2       1       0 
▶️ 查看代码
mean(is.na(company_data$revenue))  # revenue 这一列的缺失占比 = 2/6
[1] 0.333

2.6 为什么 mean(is.na()) 算的就是占比?

▶️ 查看代码
f <- is.na(company_data$revenue)  # 逻辑向量:FALSE TRUE FALSE FALSE TRUE FALSE
sum(f)      # TRUE 当 1、FALSE 当 0 → 求和 = 缺失个数
[1] 2
▶️ 查看代码
mean(f)     # 缺失个数 ÷ 总个数 → 占比
[1] 0.333

6 格里两个 TRUE(2.2 图的第二行):均值 = 缺失个数 2 ÷ 总个数 6 = 0.333——所以 mean(is.na(x)) 与 sum(is.na(x)) / length(x) 完全等价。

2.7 NA、NULL、NaN——三种"空"不是一回事

▶️ 查看代码
is.na(c(NA, NaN, 0/0))     # TRUE TRUE TRUE:is.na() 也会把 NaN 识别为缺失
[1] TRUE TRUE TRUE
▶️ 查看代码
is.nan(c(NA, NaN, 0/0))    # FALSE TRUE TRUE:要单独识别 NaN,用 is.nan()
[1] FALSE  TRUE  TRUE
▶️ 查看代码
length(NULL)               # 0:NULL 表示对象/元素不存在,不是表格中的缺失单元格
[1] 0

表格单元格的常见缺失是 NA;NaN 是无效数值运算结果,也会被 is.na() 识别;NULL 则是长度为 0 的"什么都没有",不能把三者当成同一种空值。Excel 导入时还要留心空字符串、"N/A"、"-" 等字符型假缺失(见下一页)。

2.8 查不出来的"假缺失"

从 Excel 导出的报表里,"空单元格"常以空字符串、"N/A"、"-" 的面目进来——它们是字符串,is.na() 根本查不出来:

▶️ 查看代码
fake <- c("1200", "", "N/A")
is.na(fake)                        # 全 FALSE —— "假缺失"骗过了 is.na()
[1] FALSE FALSE FALSE
▶️ 查看代码
# 导入环节就把"假缺失"转成真 NA(示意):
read.csv("报表.csv", na.strings = c("", "NA", "N/A", "-"))

2.9 课堂练习:换一份"真数据"看看

R 内置的 airquality——1973 年纽约的空气质量日观测,153 行、6 列。它是本讲练习的"主数据",后面的作业也用它:

  1. 用 colSums(is.na()) 统计每一列各有多少缺失;
  2. 用 mean(is.na()) 算出 Ozone 列的缺失比例。

2.10 缺失为什么发生?先别急着删或填

提示

识别出 NA 只是"体检",还要追问它为什么缺。缺失机制通常分三类(用财务场景举例):

  • MCAR(完全随机缺失):缺失与已观测、未观测信息都无关,例如财务系统偶发宕机、网络中断,随机丢了几笔录入;完整案例删除在一些条件下可能可行,但仍要检查样本量。
  • MAR(随机缺失):缺失与已观测变量有关,例如某分公司/某类单据更容易漏填(缺失和"是哪个分公司"有关,和漏填的金额本身无关);可考虑按已观测分组或建模处理,并说明假设。
  • MNAR(非随机缺失):缺失与该缺失值本身有关,例如金额越大、越敏感的科目越不愿如实填报;仅看现有数据通常无法证实,需业务知识、敏感性分析或额外数据。

重要

缺失比例告诉你“有多少”,不告诉你“为什么”。 <5% 不是自动删除线;>20% 也不是自动均值填补线。本讲的方法是入门工具,正式推断还要考虑机制、分析目标、分组结构和不确定性。

2.11 用数据看缺失"扎堆"在哪

机制不是空谈——本讲主数据 airquality 里,Ozone 的缺失并不是均匀散布的:

▶️ 查看代码
library(dplyr)
airquality %>%
  group_by(Month) %>%
  summarise(Ozone缺失率 = round(mean(is.na(Ozone)), 3)) %>%
  ungroup()
# A tibble: 5 × 2
  Month Ozone缺失率
  <int>       <dbl>
1     5       0.161
2     6       0.7  
3     7       0.161
4     8       0.161
5     9       0.033

5、7、8 月各缺约 16%、9 月只缺 3%,6 月却缺了 70%——缺失明显和"月份"这个已观测变量挂钩,更像 MAR(当然也不能完全排除 MNAR,机制往往要靠业务背景判断)。这就提醒我们:删或填之前,先看缺失"扎堆"在哪里(6 月这个坑,第四章"分组填补"会正式回收)。

三、缺失值的删除——na.omit()、drop_na()与filter()

最简单直接,但不一定总合适

3.1 造一份"缺口分散"的小表

▶️ 查看代码
dirty_data <- data.frame(
  company = c("锦城科技", "蜀汉制造", "天府物流", "青羊金融", "锦江生物", "浣花实业"),
  revenue = c(1200,   NA,   1560, 760,  NA,   892),
  cost    = c(800,    650,  NA,   500,  900,  NA)
)

dirty_data    # 两列各有 2 个 NA,且分散在 4 个不同的行:revenue 缺在"蜀汉制造/锦江生物",cost 缺在"天府物流/浣花实业"
   company revenue cost
1 锦城科技    1200  800
2 蜀汉制造      NA  650
3 天府物流    1560   NA
4 青羊金融     760  500
5 锦江生物      NA  900
6 浣花实业     892   NA

3.2 na.omit()——base R 的删除方式

▶️ 查看代码
na.omit(dirty_data)       # 只要某一行有任意一列是NA,整行都会被删除
   company revenue cost
1 锦城科技    1200  800
4 青羊金融     760  500

na.omit() 是"一行有一个 NA 就整行删"——两列各有 2 个 NA、分散在 4 行,一刀切删完只剩 2 行;它的短板是没法"只按某一列删",要按列删就得换下面的 drop_na()/filter()。

3.3 tidyr::drop_na()——指定列来删

▶️ 查看代码
library(tidyr)                      # 本章起要用 tidyr(drop_na)
dirty_data %>% drop_na()             # 删除任意列含 NA 的行
   company revenue cost
1 锦城科技    1200  800
2 青羊金融     760  500
▶️ 查看代码
dirty_data %>% drop_na(revenue)      # 只删除 revenue 缺失的行,cost 列的 NA 不影响
   company revenue cost
1 锦城科技    1200  800
2 天府物流    1560   NA
3 青羊金融     760  500
4 浣花实业     892   NA

drop_na() 能指定只看某几列(如只对关键字段删),比 na.omit() 灵活得多。返回类型通常跟随输入:data.frame 输入仍是 data.frame,tibble 输入仍是 tibble;不必为了换类型而使用它。

同一份 dirty_data(revenue 缺在蜀汉制造、锦江生物,cost 缺在天府物流、浣花实业,两列各有 2 个 NA),两种删法结果完全不同:

重要

关键差别就在"天府物流""浣花实业"这两行:它们的 cost 是 NA,na.omit() 一刀切全删掉;而 drop_na(revenue) 只盯 revenue 列,它们 revenue 有值(1560、892)就留下,cost 的 NA 一动不动。删哪几列、删哪几行,drop_na() 由你指定,na.omit() 自己做不到按列删(要按列删就换 drop_na()/filter(),或 base R 的 complete.cases() 子集)。

3.4 dplyr::filter(!is.na())——把"删缺失"写进筛选条件

▶️ 查看代码
library(dplyr)                                       # 本章起要用 dplyr(filter)
dirty_data %>% filter(!is.na(revenue))                # 等价于 drop_na(revenue)
   company revenue cost
1 锦城科技    1200  800
2 天府物流    1560   NA
3 青羊金融     760  500
4 浣花实业     892   NA
▶️ 查看代码
dirty_data %>% filter(!is.na(revenue), !is.na(cost))  # 两个条件一起上
   company revenue cost
1 锦城科技    1200  800
2 青羊金融     760  500

!is.na(x) 读作"x 不缺失",就是第 8 讲 filter() 里最普通的一个条件,好处是能和别的条件写在同一句里——下一页对比。

▶️ 查看代码
dirty_data %>% filter(revenue > 1000)   # 只写了"营收>1000",没提缺失……结果也只剩 2 行
   company revenue cost
1 锦城科技    1200  800
2 天府物流    1560   NA

警告

当心 filter() 的一个"隐藏动作":条件算出来是 NA 的行,filter() 会当成"不满足"直接丢掉。上面蜀汉制造、锦江生物的 revenue 是 NA,NA > 1000 的结果还是 NA,于是这两行没写 !is.na() 也被悄悄删了。但要说清一个边界:filter() 只按条件里出现的那一列判断——天府物流的 cost 是 NA,可条件里只写了 revenue,它 revenue=1560 满足,就照样留下、cost 的 NA 一动不动。一句话:条件涉及的列缺失→行被丢;没涉及的列缺失→filter() 不管。

3.5 四种"删除" + 一个"不删"——到底怎么选?

我想…… 用这个
一口气清掉整表里所有含缺失的行 na.omit(df)
在管道里、只按某几列删 df %>% drop_na(col1, col2)
不装 tidyr,用 base R 按列删 df[complete.cases(df$col1), ]
把"删缺失"和别的条件写在一起 df %>% filter(!is.na(col), revenue > 1000)
不想动数据,只想先算个数字看看 mean(x, na.rm = TRUE)

注:当条件里已经有 revenue > 1000 时,再写 !is.na(revenue) 其实可省略(NA > 1000 本就被 filter() 丢掉);把它显式写出来,是为了表明"我知道这列有缺失、我就是要按它删"这个意图。

对着一张 6 行的 dirty_data:图中这三种删除(na.omit/drop_na/filter,未含 base R 的 complete.cases)"剩几行"越删越少,mean(x, na.rm = TRUE) 数据一动不动。

重要

表里前四种(na.omit/drop_na/complete.cases/filter)都会真的把行删掉,na.rm = TRUE 一行都不删——一句话:前四种改数据,na.rm 不改数据。

3.6 删除前的关键一问——缺失比例

▶️ 查看代码
mean(is.na(dirty_data$revenue))     # 缺失值占比:TRUE当1、FALSE当0,求均值就是占比
[1] 0.333

警告

不要只凭缺失比例决定删还是补。 先问:缺失可能怎样产生?它是否和变量本身、分组或结果有关?若缺失近似完全随机(MCAR)、删后样本仍足够且目标分析允许,可考虑完整案例删除;若缺失与已观测信息相关(MAR)或与缺失值本身相关(MNAR),直接删可能造成系统偏差。缺失多也不代表应机械地用均值填补——先说明假设、比较方案,并保留缺失标记与处理记录。

3.7 课堂练习:用 airquality 练三种删除

airquality 共 153 行(1973 年 5–9 月日观测),其中 Ozone 缺 37 个、Solar.R 缺 7 个。

  1. 直接 na.omit(airquality) 会删掉多少行?还剩多少?
  2. 只删 Ozone 缺失的行、保留 Solar.R 的缺失,写出两种等价写法;
  3. 选出 Ozone 不缺失、且 Temp 低于 70 的行——drop_na() 能单独做到吗?该用什么?

四、缺失值的填补——六条路,别只会一个 mean()

删掉最省事,但很多时候你删不起。这一章把"填补"的招数过一遍

4.1 面对缺失,你有六条路(外加"不处理")

0 不处理——只算数、不改数据(na.rm = TRUE)
1 直接删除——缺失机制与分析目标允许、删后样本足(na.omit()/drop_na()/filter(!is.na()))
2 填固定值——业务上"缺就是 0"(ifelse()/if_else()/case_when())
3 均值 / 中位数——数值列的简易填补示范;表内有分组/季节结构时改用分组填补(组内各算各的,4.10–4.11);须说明代表值假设及其统计代价
4 前值 / 后值——相邻记录应共享值时(如合并单元格导出)的结构性缺失(fill())
5 前后平均(线性插值)——时间序列、顺着趋势补空位(lag()/lead())
6 多源拼合——同一指标有多个来源时,按优先级取第一个非缺失值(coalesce())

还有更专门的:模型填补(mice 多重插补)、真正的移动平均(zoo::rollmean(),选做加练见)——上面"删除 + 五种填补"这六条路做熟,这些专门方法知道什么时候该找它们就够了。

4.2 造一段"结构性缺失"的报表片段

▶️ 查看代码
report <- data.frame(
  dept    = c("销售部", NA, NA, "行政部", NA),
  month   = c("一月", "一月", "二月", "一月", "二月"),
  revenue = c(500, 320, 410, 200, 230)
)

report    # dept 只在每组第一行写一次 —— 后面是"省略",不是"忘了填"
    dept month revenue
1 销售部  一月     500
2   <NA>  一月     320
3   <NA>  二月     410
4 行政部  一月     200
5   <NA>  二月     230

纸面报表里,"销售部"写过一次后下面几行常常省略不写;可 R 读不懂"省略",它看到的只有 NA。这种缺失是报表格式造出来的,叫"结构性缺失"——注意 dept 是文本列,待会儿你就知道这意味着什么。

4.3 方法二之一:填一个固定值(0 / "其他")

▶️ 查看代码
revenue <- c(1200, NA, 1560, 760, NA)
revenue[is.na(revenue)] <- 0          # 数值列:缺就填 0(原地改,原值就没了)
revenue
[1] 1200    0 1560  760    0
▶️ 查看代码
rating <- c("A", "B", NA, "C", NA); rating[is.na(rating)] <- "其他"   # 分类列:缺就填"其他"
rating
[1] "A"    "B"    "其他" "C"    "其他"

警告

"填 0"是最危险的一招:营收缺填 0 → 这家公司被当成"没有收入";成本缺填 0 → 凭空造出一个"零成本"的异常值。只有业务上能确定"缺失就是零"时才用它;分类列填"其他"只是兜底,不等于真相。

4.4 方法二之二:换成 ifelse()——一样的效果,还能写进管道

▶️ 查看代码
library(dplyr)
revenue <- c(1200, NA, 1560, 760, NA)        # 重新取一份 4.3 的原始数据
data.frame(revenue) %>% mutate(revenue = ifelse(is.na(revenue), 0, revenue))
  revenue
1    1200
2       0
3    1560
4     760
5       0

ifelse(条件, 成立值, 不成立值) 是向量化的——一次处理整列,配上 mutate() 就是第 9 讲"按条件改列"的完整写法;后续几页的条件替换填补会沿用这组句式(4.3–4.7 讲透)。结构性延续、多来源取值和时间插值还有各自专用函数。

4.5 方法二之三:ifelse() 的两个坑,先看清

▶️ 查看代码
x <- c(10, NA, 30)
ifelse(is.na(x), "缺", x)           # 坑一:本想只把 NA 换成"缺",结果 10、30 也被拖成字符 "10"、"30"
[1] "10" "缺" "30"
▶️ 查看代码
ifelse(x > 15, "大", "小")          # 坑二:x[2] > 15 得 NA,对应位置结果也是 NA
[1] "小" NA   "大"

警告

坑一 · 类型被强制:ifelse() 的结果类型由实际被取到的那些值决定——只要"成立值/不成立值"两支都真的被取到、且其中一支是字符,结果整列就会被拖成字符(若某一支从头到尾没被取到,则不参与定型,见下方备注)。所以本想只把 NA 换成"缺",连本来好好的数值 10、30 也被拖成 "10"、"30",之后就不能做算术了。 坑二 · NA 条件:NA > 15 的结果是 NA,ifelse() 把它原样传下去,"大/小"这一列里又多出一个缺失——填补填补,补出一个新缺口。

4.6 方法二之四:dplyr::if_else()——类型严格,还给 NA 留了位置

if_else(条件, TRUE 值, FALSE 值, missing = NA 值)——ifelse() 只管前两种,if_else() 把第三种也摆上台面,逼你表态。

▶️ 查看代码
if_else(c(TRUE, FALSE), 1, "缺")     # 类型不一致 → 直接报错,不让你糊弄过去
Error in `if_else()`:
! Can't combine `true` <double> and `false` <character>.
▶️ 查看代码
x <- c(85, NA, 58, 77)
if_else(x >= 60, "及格", "不及格", missing = "缺考")   # 用 missing 交代 NA 怎么办
[1] "及格"   "缺考"   "不及格" "及格"  

4.7 方法二之五:case_when()——分情况填不同的值

顺序就是优先级:「原值不动」必须写在最前面;最后的 TRUE ~ ... 是兜底的那张网——不写它,没命中的格子直接漏成 NA。

▶️ 查看代码
df <- data.frame(region = c("华东", "西南", "华北"), revenue = c(1200, NA, NA))

df %>%
  mutate(revenue = case_when(
    !is.na(revenue)  ~ revenue,     # 本来就有值:不动
    region == "华东" ~ 1200,         # 华东:默认 1200
    TRUE             ~ 1000))        # 其余:兜底
  region revenue
1   华东    1200
2   西南    1000
3   华北    1000

把同一套"分档规则"落到本课的 company_data 上——按 rating 给缺失的 revenue 填不同的数:

▶️ 查看代码
company_data %>%
  mutate(revenue_filled = case_when(
    !is.na(revenue) ~ revenue,                       # 本来就有值:原样保留
    rating == "A"   ~ 1000,                          # A 级公司:按行业均值 1000 填
    rating == "B"   ~ 800,                           # B 级公司:按 800 填
    TRUE            ~ median(revenue, na.rm = TRUE)  # 其余兜底:用中位数
  ))
   company revenue cost rating revenue_filled
1 锦城科技    1200  800      A           1200
2 蜀汉制造      NA  650      B            800
3 天府物流    1560   NA      A           1560
4 青羊金融     760  500      C            760
5 锦江生物      NA  900      A           1000
6 浣花实业     892  620      B            892

蜀汉制造(B 级)的 NA 填 800、锦江生物(A 级)的 NA 填 1000——同一次 case_when() 调用里,不同行走不同分支,这就是它比 if_else() 强的地方:if_else() 只有"是/否"两条路,case_when() 可以写任意多条。

case_when() 从上往下逐个判断、取第一个命中的结果,因此条件顺序就是优先级。上面这段先写 !is.na(revenue) ~ revenue 保留已有值,再按 rating 给缺失值套业务规则;要是把 !is.na(revenue) 那条挪到最后,本来有值的行会先被 rating 规则覆盖,原值就丢了。最后的 TRUE ~ ... 相当于 else;若没有兜底,未命中的位置会得到 NA。

4.8 方法三:均值填补——还是 4.6 那个 if_else() 句式

▶️ 查看代码
company_data %>%
  mutate(revenue = if_else(is.na(revenue),             # 哪里缺?
                           mean(revenue, na.rm = TRUE), # 缺的位置填:本列均值 1103
                           revenue))                    # 不缺的位置:原值不动
   company revenue cost rating
1 锦城科技    1200  800      A
2 蜀汉制造    1103  650      B
3 天府物流    1560   NA      A
4 青羊金融     760  500      C
5 锦江生物    1103  900      A
6 浣花实业     892  620      B

警告

这页用其余 4 家的均值(1200、1560、760、892 的均值正好是 1103)演示简单填补,不代表两家缺失营收真的等于平均值。均值填补会压缩方差、改变变量之间的关系,也低估填补的不确定性;不宜把它当成严谨统计推断的通用方案。

▶️ 查看代码
company_data %>%
  mutate(revenue_was_missing = is.na(revenue),
         revenue_filled = if_else(is.na(revenue),
                                  mean(revenue, na.rm = TRUE),
                                  revenue))
   company revenue cost rating revenue_was_missing revenue_filled
1 锦城科技    1200  800      A               FALSE           1200
2 蜀汉制造      NA  650      B                TRUE           1103
3 天府物流    1560   NA      A               FALSE           1560
4 青羊金融     760  500      C               FALSE            760
5 锦江生物      NA  900      A                TRUE           1103
6 浣花实业     892  620      B               FALSE            892

提示

做真实项目时尽量保留原始列,新增 revenue_filled 和缺失标记,并记录填补规则;这样可以追溯哪些值是观测值、哪些是后来填入的。

4.9 同一句式,把填补值换成中位数

▶️ 查看代码
company_data %>%
  mutate(revenue = if_else(is.na(revenue), median(revenue, na.rm = TRUE), revenue))
   company revenue cost rating
1 锦城科技    1200  800      A
2 蜀汉制造    1046  650      B
3 天府物流    1560   NA      A
4 青羊金融     760  500      C
5 锦江生物    1046  900      A
6 浣花实业     892  620      B

提示

中位数比均值更不容易受极端值影响——如果 revenue 里有一两家公司营收特别高或特别低,中位数填补通常比均值更稳健(这 4 家的中位数是 1046、均值是 1103,两者已经不同)。从 4.8 起,均值、中位数、分组、前后平均这几种填补大多沿用 if_else(is.na(x), 填补值, x) 这一个句式,变的只是"填补值"是什么数;fill()、coalesce() 则各有专用函数。

4.10 分组填补(一):为什么不能用全局均值填一张"有分组"的表

▶️ 查看代码
gm <- mean(airquality$Ozone, na.rm = TRUE)     # 全局均值 42.1

airquality %>%
  filter(Month == 6) %>%                          # 只看缺得最多的 6 月
  summarise(原组均值 = round(mean(Ozone, na.rm = TRUE), 1),
            组内均值填 = round(mean(if_else(is.na(Ozone), mean(Ozone, na.rm = TRUE), Ozone)), 1),
            全局均值填 = round(mean(if_else(is.na(Ozone), gm, Ozone)), 1))
  原组均值 组内均值填 全局均值填
1     29.4       29.4       38.3

重要

5–9 月的 Ozone 差一倍(23.6 → 59.1 → 60.0),6 月还缺得最多(21 个)。用全局均值 42.1 填,6 月被抬到 38.3(真实 29.4),7、8 月反被压低——季节规律被抹平。

4.11 分组填补(二):就在 mutate() 前面加一个 group_by()

▶️ 查看代码
filled <- airquality %>%
  group_by(Month) %>%                # 第 1 步:声明"按月分组"
  mutate(Ozone = if_else(is.na(Ozone),                # 第 2 步:还是 4.8 那个句式
                         mean(Ozone, na.rm = TRUE),   #   但"填补值"在**组内**各算各的
                         Ozone)) %>%
  ungroup()                         # 第 3 步:解除分组(第 9 讲的约定)

filled %>%
  group_by(Month) %>%
  summarise(填补后月均 = round(mean(Ozone), 1))   # 验证:各月均值分毫未动
# A tibble: 5 × 2
  Month 填补后月均
  <int>      <dbl>
1     5       23.6
2     6       29.4
3     7       59.1
4     8       60  
5     9       31.4

group_by() 让 mutate() 里的 mean() 在每个组内分别计算:6 月的空位填的是 6 月自己的均值 29.4,而不是全局的 42.1——对照 4.10 里"6 月被抬到 38.3",这就是分组填补的全部秘密。

警告

但别忘了 2.11 的发现:6 月 30 天里 Ozone 缺了 21 天(70%)——分组填补之后,这一整月的均值 29.4 其实是由9 个真实观测算出、再回填到 21 个空位上的。一个月七成数据是"补"出来的,这种填补只是让表看起来完整,绝不能当成 6 月的真实测量。缺失越扎堆,填补越要谨慎,必要时宁可保留 NA 并在结论里说明。

4.12 方法四:前值 / 后值填补(fill)——结构性缺失常用

▶️ 查看代码
library(tidyr)                                  # 本章起要用 tidyr(fill)
report %>% fill(dept, .direction = "down")   # 前值填补:把上面的值抄进空位
    dept month revenue
1 销售部  一月     500
2 销售部  一月     320
3 销售部  二月     410
4 行政部  一月     200
5 行政部  二月     230

重要

dept 是分类列——不存在"销售部与行政部的均值",这里的 NA 是报表省略造成的结构性缺失,应继承同组标签,所以用 fill(.direction = "down")。fill() 并不限于文本列,也能对数值列做前值/后值延续;但它只适用于“邻近记录确实应共享同一值”的情境。财务报表中,部门、科目只在每组首行有值时,才适合向下填;若 NA 代表真实未知,就不能机械抄邻居。

4.13 fill() 的两个方向:"down" 抄上面,"up" 抄下面

提示

✓ "down" 把组名正确抄下来;✗ "up" 会把销售部错标成行政部,末行还填不上——省略式报表的组名写在组首,所以用 down。

4.14 方法五:前后平均填补(线性插值)——先看清 lag 和 lead 两个邻居

▶️ 查看代码
y <- c(100, 108, NA, 124, 131, 145)             # 某公司 1–6 月的营收(万元)

data.frame(y, before = lag(y), after = lead(y)) # 每个格子看看自己的左右邻居
    y before after
1 100     NA   108
2 108    100    NA
3  NA    108   124
4 124     NA   131
5 131    124   145
6 145    131    NA

lag(y) 把整个序列下移一行:每格取上一行的值,第 1 格没有上一行 → NA;lead(y) 把序列上移一行:每格取下一行的值,末格 → NA。

4.15 取左右邻居的中点,只补中间那一个空位

注记

蓝色格 = 第 3 行的左右邻居(lag 取上一行、lead 取下一行);红色格 = 缺失。首尾行各缺一侧邻居、连续两空时邻居自己也是 NA——都填不了。

▶️ 查看代码
data.frame(y) %>%
  mutate(filled = if_else(is.na(y),               # 条件:哪里缺?
                          (lag(y) + lead(y)) / 2, # 填补值:(前值 + 后值) ÷ 2
                          y))                     # 不缺的位置:原值不动
    y filled
1 100    100
2 108    108
3  NA    116
4 124    124
5 131    131
6 145    145

空位取左右邻居的中点:(108 + 124) ÷ 2 = 116——只填空位、不动原值。它可看作等间隔、单个内部缺口的线性插值简例;必须先按时间排序,且连续缺失、序列首尾都不能靠这一行 lag() / lead() 代码填。真实时间序列若间隔不等或有连续缺口,应使用带时间坐标的插值方法,并避免跨组/跨月份借用邻居。移动平均是另一种任务:它会重算窗口(选做第 3 题对比)。

4.16 方法六:多来源取第一个非缺失值——coalesce()

▶️ 查看代码
revenue_A <- c(1200, NA, 980)      # 数据源A:部分缺失
revenue_B <- c(1150, 900, NA)      # 数据源B:另一部分缺失

coalesce(revenue_A, revenue_B)     # 优先用A的值,A缺失时用B补上
[1] 1200  900  980

第 2 行 A 是 NA → 落回 B 取 900;第 1、3 行 A 有值就直接取 A(第 3 行 B 虽然是 NA,但根本轮不到它)。注意第 1 行 A、B 都有值却不一样(1200 vs 1150),coalesce() 只管"A 有没有",一律取 A,不会告诉你两边对不上。

提示

多源补缺:同一指标有两个来源(系统自动记录 + 人工补录)、各自都缺一些时,coalesce() 按优先级"拼"出一份尽量完整的数据——只挑值、不做算术,每个数都整个来自某个来源,事后可追溯它来自 A 还是 B。

警告

但它不是"对账":第 1 行 A=1200、B=1150 其实对不上,coalesce() 会默默取 A、把 B 的差异藏起来。真要核对两个来源是否一致,得自己先比对(如 which(!is.na(revenue_A) & !is.na(revenue_B) & revenue_A != revenue_B),返回 1,正指向第 1 行),别指望 coalesce() 替你发现冲突。

4.17 课堂练习(A):airquality

airquality(153 行;Ozone 缺 24.2%、Solar.R 缺 4.6%)

  1. Solar.R 只缺 7 个——用中位数填补它(提示:if_else());
  2. Ozone 缺 24%——分别用"整列均值"和"按月分组均值"填补,比较 6 月的结果差异;
  3. 用 lag()/lead() 取前后相邻观测的平均,只填 Ozone 中"左右紧邻值都存在"的孤立缺口;先按 Month、Day 排序并按月分组,避免跨月借值。数一数还有多少 NA,并说明连续缺失和月首/月末缺口为什么填不了。

4.18 课堂练习(B):starwars

starwars(87 行;mass 缺 28 个、height 缺 6 个)

  1. 把 mass 的缺失按 gender 分组、用组内中位数填补;
  2. 把 hair_color 的缺失统一填成 "unknown";
  3. 用 case_when() 把 height 分成 "≥190 高 / 170–189 中 / <170 矮 / 缺失" 四档。

五、综合实战——识别→处理→验证

把本讲的工具串成一条完整工作流

5.1 动手实战:用 airquality 走完整流程

任务:以 airquality(153 行;Ozone 缺 37 个/24.2%、Solar.R 缺 7 个/4.6%)为对象,做一次完整的缺失值处理:

  1. 识别——用 colSums(is.na()) 看清每列的缺失,算出两列的缺失比例;
  2. 决策——为 Solar.R 与 Ozone 各选一种处理方法,并写清理由;
  3. 处理——用代码落实你的选择(drop_na() / if_else() / group_by() + mutate() / fill() / lag() 都行);
  4. 验证——处理后再次 colSums(is.na()),确认缺失已经清干净;
  5. 把这段代码整理成一份 Quarto(.qmd)小报告,配上简短的文字说明。

5.2 课堂展示

邀请 1–2 名同学展示自己的处理流程,并说明"为什么这样选"——重点不是代码写得多快,而是选择理由是否站得住脚。

六、课堂总结、学习成果确认与Exit Ticket

一条流水线,贯穿本课始终

6.1 知识回顾——完整的四步流水线

识别(is.na() / colSums(is.na()))→ 决策(删还是补?看缺失比例、看业务)→ 处理(删:na.omit()/drop_na()/filter(!is.na());补:固定值/均值·中位数(含分组)/前值后值/前后平均/多源拼合)→ 验证(再看一遍 colSums(is.na()))

重要

最容易混的一点:na.rm = TRUE 不是"处理缺失",它只是算的时候闭一只眼,数据本身一动不动。 真要处理,就用上面那六条路里的任意一条,而且处理完必须再验一遍——这一步最容易被略过,也最容易在期末项目里翻车。

6.2 学习成果自查

  • ✓ 能用 is.na()/colSums() 找出并统计缺失值,不被 == NA 和字符串"假缺失"骗到
  • ✓ 能说清几种"删除"(na.omit/drop_na/complete.cases/filter)与 na.rm 的区别
  • ✓ 能用五种写法填补:固定值、均值/中位数(含分组填补 group_by() + if_else())、前值后值(fill())、前后平均(线性插值)、多源拼合(coalesce())——加上"删除"就是 4.1 的"六条路","不处理"(na.rm)另算
  • ✓ 能用 ifelse()/if_else()/case_when() 条件替换着填
  • ✓ 能判断该删还是该补,并说出理由

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

请用 3 分钟,在纸条或在线问卷上简要回答:

  1. filter(!is.na(x)) 和 mean(x, na.rm = TRUE) 都能"对付"缺失值,本质区别在哪?
  2. 什么情况下"用全局均值填补"会出问题?这时该换成什么做法?
  3. if_else() 比 ifelse() 好在哪两点?(一点关于类型,一点关于 NA)

课后作业

综合练习(一):msleep 与 presidents

本次不再自造数据,全部使用 R 内置数据集(msleep 在 ggplot2 里,先 library(ggplot2))。按《实验报告5》模板完成:在"实现代码"处贴代码、"效果截图"处贴运行结果截图,并在"结果与分析"里写出结论。

  1. 用 colSums(is.na()) 与 mean(is.na()) 给出 msleep 每列的缺失个数与占比,指出缺得最多和最少的列;
  2. 分别用"全局中位数"和"按 vore(食性)分组中位数"填补 sleep_rem 的缺失,比较食草动物(herbi)那一组的结果差异,解释为什么会有这个差别;
  3. presidents 是 1945–1974 年季度支持率时间序列(含 NA)。先用 data.frame(q = time(presidents), appr = as.numeric(presidents)) 转成数据框,再用 lag()/lead() 的前后邻居平均,填 appr 中左右紧邻值均非缺失的孤立内部缺口;统计剩余 NA,并解释序列开头和连续缺失为什么不能这样填。

综合练习(二):starwars 与收尾

  1. 把 mass 的缺失按 gender 分组用组内中位数填补;把 hair_color 的缺失统一填成 "unknown";
  2. 用 case_when() 把 height 分成 "≥190 高 / 170–189 中 / <170 矮 / 缺失" 四档;
  3. 收尾:用 colSums(is.na()) 验证缺失是否已清干净,并在结论里写明"删除"与"填补"各适用什么条件。

提交要求与选做加练

提交要求

在《实验报告5》模板里,把每题"代码 + 运行结果"的截图粘贴到相应位置,并在"结果与分析"里写出结论;文件命名为"学号_姓名_实验报告5",提交该 Word 文档。本次作业计入平时成绩的"实验报告"部分考核,请确保代码可运行、结果完整。

提示

选做加练(3 题)——课上讲到、但作业里没覆盖的三个延伸点:

  1. fill() 的方向:4.13 已经把 "down" 和 "up" 的结果摆在一起了——解释为什么 "up" 会把销售部那两行填成"行政部",再想一想现实报表里什么样的表才适合"往上填";
  2. coalesce() 做多源补缺:造两份能互相补位的营收数据(系统导出 + 人工补录),用 coalesce() 拼成完整数据,再和"直接取平均"的结果比一比,说明补缺时为什么优先用 coalesce()(每个数都来自真实来源、可追溯);再想一想:如果两个来源在同一行都有值却对不上,coalesce() 会怎么处理、这样安全吗?
  3. 真正的移动平均:library(zoo) 后用 zoo::rollmean(x, k = 3, fill = NA, align = "center"),对比它与 4.15 手写线性插值的结果差异,并说明各自适用场合。

下讲预告

异常值检测与处理、数据标准化与归一化

  • 箱线图(boxplot)与 IQR(四分位距)法识别异常值
  • 3σ 原则——基于正态分布假设的识别方法
  • 异常值的处理:删除、截断(winsorize)、替换为 NA
  • 数据标准化(z-score)与归一化(min-max)

提示

课前准备:保留好今天处理缺失值后的数据,下节课会在这份"干净"数据的基础上继续排查异常值。

谢谢!

第11讲:缺失值处理

"In God we trust; all others must bring data — and that data had better not be missing." 财务数据中的缺失不能"拍脑袋"填写,每一个"补数"决定都应当有据可查、有理可依,这与审计工作中对异常科目要"查明原因、审慎处理"的要求是相通的。

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