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

数据分析与R语言

2026年10月08日

本讲导览

  • 一、问题——NA 到底会让你的计算错到什么程度?(12 min)
  • 二、识别——summary() 与 is.na(),看清缺在哪、缺多少(14 min)
  • 三、删除——na.omit() / drop_na() / filter()(12 min)
  • 四、填补——固定值 if_else()/case_when()、fill() 前值、分组均值(40 min)
  • 五、综合实战——用 airquality 走全流程(10 min)
  • 六、课堂总结——成果自查与 Exit Ticket(6 min)

重要

今天是项目三"数据清洗"的开篇课。项目二教会我们"准备"数据,但现实数据往往还不"干净":有缺失、有异常、有重复。

本讲只精讲四套处理方案,其余方法一句话带过、留页备查:

方案 什么时候用
① 删除 na.omit() / drop_na() / filter() 缺失机制允许、且删得起
② 填固定值 if_else() / case_when() 业务上"缺就是某个确定值"
③ 前值填充 fill() 报表省略造成的结构性缺失
④ 分组均值 group_by() + if_else() 表里有分组/季节结构

每讲完一个知识点马上做一道即时练习,练习全部使用 R 内置数据集,代码能直接跑。

一、问题的提出——不处理 NA 会出什么问题

先不谈怎么处理,先让你看看"不处理"的后果

1.1 复习导入:两道小题

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

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

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

▶️ 查看代码
company_data <- data.frame(
  company = c("锦城科技", "蜀汉制造", "天府物流", "青羊金融", "锦江生物", "浣花实业",
              "锦西商贸", "蜀都餐饮", "青城建材", "天府冷链",
              "锦江农产", "浣花纺织", "锦西物流", "蜀汉电子", "青羊置业", "天府食品"),
  region  = c("华东", "西南", "西南", "华东", "西南", "华东",
              "西南", "西南", "华东", "西南",
              "华东", "华东", "西南", "西南", "华东", "西南"),
  revenue = c(1200, NA, 1560,  760, NA,  892, 1340, 680, NA, 2100,
               450,  980,  NA, 1720, 560, 1380),
  cost    = c( 800, 650,   NA,  500, 900,  620,  880,  NA, 720, 1350,
               310,  640, 760,   NA, 390,  910),
  rating  = c("A", "B", "A", "C", "A", "B", "B", "C", "A", "A",
               "C", "B", "B", "A", "C", "B")
)

company_data    # 16 家公司;revenue 缺 4 家、cost 缺 3 家 —— 缺失是故意造的
    company region 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
7  锦西商贸   西南    1340  880      B
8  蜀都餐饮   西南     680   NA      C
9  青城建材   华东      NA  720      A
10 天府冷链   西南    2100 1350      A
11 锦江农产   华东     450  310      C
12 浣花纺织   华东     980  640      B
13 锦西物流   西南      NA  760      B
14 蜀汉电子   西南    1720   NA      A
15 青羊置业   华东     560  390      C
16 天府食品   西南    1380  910      B

这份表是本讲前半段的唯一"自造数据",所有演示都基于它。16 行是刻意设计的:足够长到能看出分组结构,又短到打印出来能一屏放下。

两列现在还没用到,先埋伏笔——region(华东/西南)是 4.5 分组填补的伏笔,rating(A/B/C)是 4.1 case_when() 的伏笔。讲到时自然会用到。

1.3 灾难现场:一个 NA 毁掉整列的合计

这是本讲最重要的一页。财务人员最常做的动作就是求和——一个 NA 就能让整列合计变成 NA:

▶️ 查看代码
sum(company_data$revenue)                          # 16 个数里有 4 个 NA
[1] NA
▶️ 查看代码
mean(company_data$revenue)                         # 均值也一样
[1] NA
▶️ 查看代码
sum(company_data$revenue, na.rm = TRUE)            # 加 na.rm = TRUE:跳过缺失
[1] 13622
▶️ 查看代码
mean(company_data$revenue, na.rm = TRUE)
[1] 1135

16 个数,一个都加不进去。 R 不会给你"剩下 12 个的和",它直接交出 NA——这是刻意的设计,不是 bug。

NA 的含义是"不知道",而"不知道"加进任何算式,结果还是"不知道"。所以:

重要

na.rm = TRUE 只是"算的时候闭一只眼"——它不是"处理缺失"。 数据本身一动不动:这个 16 行的表跑完 sum() 之后,4 个 NA 还在原地。剩下 12 个数的和是 13622、均值是 1135。

但请记住:这只是让你能算,不代表这个 1135 可以直接写进报告。 4.6 分组均值那一节会回来算这笔账——1135 这个数填进不同分组,会把结论改掉。

1.4 更致命:分组求和时,两组的数字都拿不到

单列求和废掉还只是"一列",一旦分组求和,每一组都可能变成 NA——这才是财务报表里最常见的翻车方式:

▶️ 查看代码
library(dplyr)                               # 本章起要用 dplyr
company_data %>%
  group_by(region) %>%
  summarise(直接合计 = sum(revenue),
            跳过缺失 = sum(revenue, na.rm = TRUE)) %>%
  ungroup()
# A tibble: 2 × 3
  region 直接合计 跳过缺失
  <chr>     <dbl>    <dbl>
1 华东         NA     4842
2 西南         NA     8780

这就是财务报表里最典型的场景:你只想知道华东和西南各多少钱,结果一个数都拿不到。

现在问题摆在这里了:你只有一张"坏了的表"和几个坏掉的合计。接下来三章 answering 三个问题——先看清缺什么(第二章)、能不删就删(第三章)、删不得就补(第四章)。

1.5 【即时练习 1】三个问题,先动笔,不用写代码

  1. mean(x) 里 x 有 3 个 NA,mean(x, na.rm = TRUE) 算的是什么?它把数据改了吗?
  2. 一列 100 个数里有 2 个 NA,sum() 得到什么?sum(x, na.rm=TRUE) 又得到什么?这两个结果能不能直接写进月度报告?
  3. 分组求和时,10 个组里有 1 个组含 NA、其余 9 组干净——输出的 10 行里有几行是 NA?

二、识别缺失——summary() 与 is.na()

看清缺在哪、缺多少

2.1 先用 summary()——一个已经会用的函数

summary() 是 R 自带的"体检报告"。你可能只是还没注意,它已经把每个数值列的缺失数算好了:

▶️ 查看代码
summary(company_data)
      company         region      revenue          cost            rating  
 Length   :16   Length   :16   Min.   : 450   Min.   : 310   Length   :16  
 N.unique :16   N.unique : 2   1st Qu.: 740   1st Qu.: 620   N.unique : 3  
 N.blank  : 0   N.blank  : 0   Median :1090   Median : 720   N.blank  : 0  
 Min.nchar: 4   Min.nchar: 2   Mean   :1135   Mean   : 725   Min.nchar: 1  
 Max.nchar: 4   Max.nchar: 2   3rd Qu.:1425   3rd Qu.: 880   Max.nchar: 1  
                               Max.   :2100   Max.   :1350                 
                               NAs    :4      NAs    :3                    

看数值列的最后一行——NAs : 4(revenue)和 NAs : 3(cost)。

重要

summary() 的优点:不用记新函数,一个命令看全表——先用它扫一遍。 它的局限:只给个数,不告诉你缺在哪几行、也不给比例。所以看完 summary() 通常还要追问一句——"那 4 个 NA 具体是哪几家?占多少?"

2.2 【即时练习 2】用 summary() 摸清 airquality

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

  1. 对 airquality 跑一次 summary(),哪些列有缺失?各缺几个?
  2. 它和 2.1 的 company_data 相比,缺失的严重程度有什么不同?

2.3 只看某一列:is.na() 与 sum()

既然 summary() 说不清"在哪几行",那就自己点名问。is.na(x) 对一列逐格判断,配 sum() 数个数:

▶️ 查看代码
is.na(company_data$revenue)                    # 逐格判断:哪几个是 NA
 [1] FALSE  TRUE FALSE FALSE  TRUE FALSE FALSE FALSE  TRUE FALSE FALSE FALSE
[13]  TRUE FALSE FALSE FALSE
▶️ 查看代码
sum(is.na(company_data$revenue))               # 数一数:一共几个缺失
[1] 4
▶️ 查看代码
company_data %>% filter(is.na(revenue))        # 更实用:把缺失的行直接挑出来看
   company region revenue cost rating
1 蜀汉制造   西南      NA  650      B
2 锦江生物   西南      NA  900      A
3 青城建材   华东      NA  720      A
4 锦西物流   西南      NA  760      B

is.na() 是本章唯一真正的新函数,值得多花两分钟。 它输出的不是数字,是一串 TRUE/FALSE——TRUE 就是"这一格缺"。

警告

常见错误:用 == NA 找缺失。 company_data$revenue == NA 返回的还是一串 NA,没有一个 TRUE。 因为 NA 的含义是"不知道"——任何数和"不知道"比较,结果仍是"不知道"。所以判断缺失只有一个正确写法:is.na()。

2.4 table()——不用记新函数,直接看分布

sum(is.na(x)) 得到一个数字。想看"缺失 vs 不缺失"各占几行,有个更直观的内置函数:

▶️ 查看代码
table(is.na(company_data$revenue))   # FALSE 12、TRUE 4

FALSE  TRUE 
   12     4 

FALSE 12、TRUE 4 —— 一眼看懂,不用再心算。

这里有个值得说的分寸:summary()、table()、sum()、mean() 都是 R 自带的,你只要会用就行;is.na() 之所以要重点讲,是因为后面四套处理方案全靠它定位缺失。

2.5 缺失占比:mean(is.na()) 的小技巧

is.na() 返回逻辑值,求和是"缺几个",求均值就是"缺百分之几":

▶️ 查看代码
sum(is.na(company_data$revenue))    # 4 个
[1] 4
▶️ 查看代码
mean(is.na(company_data$revenue))   # 0.25 —— 4 ÷ 16
[1] 0.25
▶️ 查看代码
mean(is.na(company_data$cost))      # 0.1875 —— 3 ÷ 16
[1] 0.188

原理和第 1 讲的小测一样:逻辑值里 TRUE 当 1、FALSE 当 0,求和 = 个数,求均值 = 占比。

那回到 2.2 留的问题——company_data 和 airquality 的缺失比例几乎一样(25% vs 24.2%),但一个删 4 行就完事,另一个删 37 行就伤筋动骨。比例说明"严重程度",绝对量说明"代价",两个都要看。

2.6 【即时练习 3】三招齐用,摸清 msleep

R 内置的 msleep(在 ggplot2 包里,先 library(ggplot2))——哺乳动物睡眠数据,83 行、11 列。

  1. 用 summary(msleep) 看哪几列有缺失;
  2. 用 colSums(is.na(msleep)) 把每一列的缺失数一次列全(colSums() 是 base R 函数,作用是对每一列求和——把 is.na() 套进去就等于逐列数缺失);
  3. 用 mean(is.na(msleep$conservation)) 算 conservation 列的缺失比例。

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

最简单的方案,但不一定总合适

3.1 base R 的 na.omit()——一行有 NA,整行删

▶️ 查看代码
nrow(company_data)                    # 原来 16 行
[1] 16
▶️ 查看代码
nrow(na.omit(company_data))            # 删完剩几行?
[1] 9
▶️ 查看代码
na.omit(company_data)                  # 看删掉了谁
    company region revenue cost rating
1  锦城科技   华东    1200  800      A
4  青羊金融   华东     760  500      C
6  浣花实业   华东     892  620      B
7  锦西商贸   西南    1340  880      B
10 天府冷链   西南    2100 1350      A
11 锦江农产   华东     450  310      C
12 浣花纺织   华东     980  640      B
15 青羊置业   华东     560  390      C
16 天府食品   西南    1380  910      B

na.omit() 的规则只有一条:只要某一行有任意一列是 NA,整行都删。

company_data 里 revenue 缺 4 家、cost 缺 3 家,散落在 7 个不同的行上——na.omit() 一刀切,16 行只剩 9 行(近 4 成没了)。

3.2 tidyr::drop_na()——指定只看某几列

一刀切太粗。drop_na() 能指定只看某几列:

▶️ 查看代码
library(tidyr)                      # 本章起要用 tidyr(drop_na)
nrow(drop_na(company_data))                 # 9 行:任意列缺就删
[1] 9
▶️ 查看代码
nrow(drop_na(company_data, revenue))        # 12 行:只盯 revenue 缺的那 4 家
[1] 12
▶️ 查看代码
nrow(drop_na(company_data, revenue, cost))  # 9 行:两列都盯
[1] 9

drop_na(revenue) 留下的 12 行里,仍有 3 行的 cost 是 NA——它只管你点名的那几列。

重要

drop_na() vs na.omit() 的差别:前者由你指定"只看哪几列",后者一律"任意一列缺就整行删"。数据里多数列都干净时,na.omit() 删掉的行往往远超你的预期——company_data 里删掉的 7 行中,只有 4 行是 revenue 真的缺,另外 3 行 cost 缺但 revenue 是好的,信息被白白扔掉了。

3.3 dplyr::filter(!is.na())——删缺失也能写进筛选条件

第三种写法是把它当成一个筛选条件,好处是能和其它条件写进同一句:

▶️ 查看代码
library(dplyr)                                       # 本章起要用 dplyr(filter)
nrow(filter(company_data, !is.na(revenue)))                  # 12 行,等价于 drop_na(revenue)
[1] 12
▶️ 查看代码
nrow(filter(company_data, !is.na(revenue), !is.na(cost)))   # 9 行,两个条件一起上
[1] 9
▶️ 查看代码
nrow(filter(company_data, revenue > 1000))                  # 6 行
[1] 6

!is.na(x) 读作"x 不缺失",就是第 8 讲 filter() 里最普通的一个条件。

一个"隐藏动作"值得提醒:条件算出来是 NA 的行,filter() 会当成"不满足"直接丢掉。第三行代码只写了 revenue > 1000、没写 !is.na(revenue),但那 4 个 NA 行照样被悄悄删了(因为 NA > 1000 的结果是 NA)。不过要说清边界:filter() 只按条件里出现的那一列判断——cost 是 NA 的那 3 行,条件里没提 cost,revenue 又是好的,就照样留下。

3.4 三种删除 + 一个"不删"——对照着记

我想…… 用这个 在 company_data 上的结果
一口气清掉所有含缺失的行 na.omit(df) 16 → 9 行
只按某几列删 df %>% drop_na(revenue) 16 → 12 行
把"删缺失"和别的条件写一起 df %>% filter(!is.na(revenue), revenue > 1000) 16 → 6 行
不装 tidyr,用 base R 按列删 df[complete.cases(df$revenue), ] 16 → 12 行
不想动数据,只想先算个数字 mean(x, na.rm = TRUE) 16 → 仍 16 行

重要

前四种都会真的把行删掉,na.rm = TRUE 一行都不删。一句话:前四种改数据,na.rm 不改数据。

3.5 【即时练习 4】用 msleep 练三种删除

msleep 83 行,缺失情况见 2.6。回答:

  1. 直接 na.omit(msleep) 会删掉多少行、剩多少?
  2. 只删 conservation 缺失的行,写出两种等价写法(提示:第三章那三种里挑两种)。
  3. 想选出 vore 不缺失、且 sleep_total 大于 10 的行——该用哪个函数?为什么 drop_na() 单独做不到?

四、填补缺失——四套方案

删不起的时候,就得补

4.0 什么时候不能删?

删除虽然简单,但代价可能很大:16 行删到 9 行是丢掉 44% 的公司。删之前至少问两句:

  • 缺失可能怎么产生?和变量本身有关吗?
  • 删掉的行占多少比例?剩下的样本还代表原群体吗?

更关键的是:删掉一整行,往往连带丢掉了其它列的好数据。上节已经看到,na.omit() 扔掉的 7 行里有 3 行只是 cost 缺,revenue 是好的。如果"缺"只是某个数没录入、其它信息都有,那该补不该删。 下面三节讲三套补法。

4.1 方案一:填一个固定值

最直接的一招——业务上如果能确定"缺的就是某个值",就填那个值。

写法 A:if_else()(一个填补值)

if_else(条件, 填补值, 原值) —— 三个参数各管一件事。

▶️ 查看代码
company_data %>%
  mutate(revenue_f = if_else(is.na(revenue), 0, revenue)) %>%   # 缺就填 0
  select(company, revenue, revenue_f)
    company revenue revenue_f
1  锦城科技    1200      1200
2  蜀汉制造      NA         0
3  天府物流    1560      1560
4  青羊金融     760       760
5  锦江生物      NA         0
6  浣花实业     892       892
7  锦西商贸    1340      1340
8  蜀都餐饮     680       680
9  青城建材      NA         0
10 天府冷链    2100      2100
11 锦江农产     450       450
12 浣花纺织     980       980
13 锦西物流      NA         0
14 蜀汉电子    1720      1720
15 青羊置业     560       560
16 天府食品    1380      1380

4 个 NA 全部变成 0,其余 12 家原样不动。

警告

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

它比 base R 的 ifelse() 好在哪——两个理由,建议现场跑一下:

▶️ 查看代码
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] "及格"   "缺考"   "不及格" "及格"  

missing = 不是必填的——省略时结果默认仍是 NA。课堂上要提醒学生检查这个默认值是否符合业务规则。

写法 B:case_when()(按不同情况填不同的值)

现实里很少"缺的都是同一个值"。按评级分别填才符合业务逻辑:

顺序就是优先级,「原值不动」必须写在最前面:

▶️ 查看代码
company_data %>%
  mutate(revenue_f = case_when(
    !is.na(revenue)  ~ revenue,                       # 本来就有值:原样保留
    rating == "A"    ~ 1000,                          # A 级公司:按行业均值 1000 填
    rating == "B"    ~ 800,                           # B 级公司:按 800 填
    TRUE             ~ median(revenue, na.rm = TRUE)  # 其余兜底:用中位数 1090
  )) %>%
  select(company, rating, revenue, revenue_f)
    company rating revenue revenue_f
1  锦城科技      A    1200      1200
2  蜀汉制造      B      NA       800
3  天府物流      A    1560      1560
4  青羊金融      C     760       760
5  锦江生物      A      NA      1000
6  浣花实业      B     892       892
7  锦西商贸      B    1340      1340
8  蜀都餐饮      C     680       680
9  青城建材      A      NA      1000
10 天府冷链      A    2100      2100
11 锦江农产      C     450       450
12 浣花纺织      B     980       980
13 锦西物流      B      NA       800
14 蜀汉电子      A    1720      1720
15 青羊置业      C     560       560
16 天府食品      B    1380      1380

同一次 case_when() 调用里,不同行走不同分支——这就是它比 if_else() 强的地方:if_else() 只有"是/否"两条路,case_when() 可以写任意多条。

重要

两个必须记住的写法要点: ① 最后的 TRUE ~ ... 是兜底的那张网——不写它,没命中的格子直接漏成 NA; ② 顺序就是优先级。要是把 !is.na(revenue) 那条挪到最后,本来有值的行会先被评级规则覆盖,原值就丢了。

4.2 【即时练习 5】if_else 与 case_when

  1. 用 if_else() 把 company_data 的 cost 列缺失全部填成 700(一个"假定成本");
  2. 用 case_when() 做另一件事:cost 缺失时,A 级公司填 700、B 级公司填 600、其余走中位数兜底;
  3. 跑完检查:填补前 mean(cost, na.rm = TRUE) 是多少?填补后呢?这个变化说明了什么?

4.3 方案二:前值填充 fill()——报表省略造成的缺失

前两节的缺失是"数据源没给",但财务报表里还有另一种:表格上本来就有这个值,只是没重复写。

纸面报表里,"销售部"写过一次后下面几行常常省略不写。可 R 读不懂"省略":

▶️ 查看代码
report <- data.frame(
  dept    = c("销售部", NA, NA, "行政部", NA, NA, "财务部", NA, "技术部", NA),
  quarter = c("Q1", "Q1", "Q2", "Q1", "Q2", "Q2", "Q1", "Q2", "Q1", "Q2"),
  revenue = c( 500, 320, 410,  200, 230,  260,  180, 190,  350, 370)
)

report    # dept 只在每组第一行写一次 —— 后面是"省略",不是"忘了填"
     dept quarter revenue
1  销售部      Q1     500
2    <NA>      Q1     320
3    <NA>      Q2     410
4  行政部      Q1     200
5    <NA>      Q2     230
6    <NA>      Q2     260
7  财务部      Q1     180
8    <NA>      Q2     190
9  技术部      Q1     350
10   <NA>      Q2     370

这种缺失是报表格式造出来的,叫"结构性缺失"。它和 4.1 那两种完全不同:revenue 缺是真的不知道,而 dept 缺是"上面写着呢"。

fill() 的思路很朴素:把上面那一行的值抄下来。

▶️ 查看代码
library(tidyr)                                  # 本章起要用 tidyr(fill)
report %>% fill(dept, .direction = "down")   # 前值填补:把上面的值抄进空位
     dept quarter revenue
1  销售部      Q1     500
2  销售部      Q1     320
3  销售部      Q2     410
4  行政部      Q1     200
5  行政部      Q2     230
6  行政部      Q2     260
7  财务部      Q1     180
8  财务部      Q2     190
9  技术部      Q1     350
10 技术部      Q2     370

重要

fill() 适用于"邻近记录确实该共享同一个值"的场景。这里 dept 是分类列——不存在"销售部和行政部的平均值",这些 NA 是报表省略造成的,本来就该继承同组标签。

它也能对数值列做前值/后值延续(比如"这个数一直保持到下个月"),但前提同样是"邻近记录确实应共享同一值"。若 NA 代表真实未知,就不能机械抄邻居——练习 6 第 3 小题就是专门问这个。

方向的选择:省略式报表的组名写在组首,所以用 "down"。如果哪天拿到一张组名写在组末(每组末行才是部门名)的表,才改用 .direction = "up"。判断口诀:填完抽查每组的行数对不对。

4.4 【即时练习 6】fill() 修报表

report 里 dept 有 6 个空位。回答:

  1. 用 fill() 补全 dept,然后 count(dept) 看各部门各有几行——对不对得上(销售部 3 行、行政部 3 行、财务部 2 行、技术部 2 行)?
  2. 如果误写成 .direction = "up",第 2、3 行会被填成什么?为什么这是静默出错(程序不报错,但数据是错的)?
  3. fill() 能不能用来补 revenue 的空位?先想清楚再回答。

4.5 方案三:分组均值填补——为什么不能用一个数填全表

前两节补的都是"有依据的固定值"(业务规则、上下行标签)。可很多时候没有任何规则可循:数值列的缺失,既不知道是多少,也和邻居无关。

最朴素的想法:填个平均值。但"平均"这件事本身有陷阱。

先看这份数据的结构——company_data 里有 region 列(华东/西南),两组的营收水平差得很远。1.2 埋的伏笔在这里回收:

▶️ 查看代码
company_data %>%
  group_by(region) %>%
  summarise(组均值 = round(mean(revenue, na.rm = TRUE), 1),
            缺几家 = sum(is.na(revenue)),
            公司数 = n()) %>%
  ungroup()
# A tibble: 2 × 4
  region 组均值 缺几家 公司数
  <chr>   <dbl>  <int>  <int>
1 华东     807       1      7
2 西南    1463.      3      9

华东 807、西南 1463——差了近一倍。如果用一个"全局均值"去填,两组都会被往中间拽:

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

company_data %>%
  group_by(region) %>%
  mutate(revenue_g = if_else(is.na(revenue), gm, revenue)) %>%   # 全部填全局均值
  ungroup() %>%
  group_by(region) %>%
  summarise(真实组均值 = round(mean(revenue, na.rm = TRUE), 1),
            全局填后 = round(mean(revenue_g), 1)) %>%
  ungroup()
# A tibble: 2 × 3
  region 真实组均值 全局填后
  <chr>       <dbl>    <dbl>
1 华东         807      854.
2 西南        1463.    1354.

填完数据"完整"了,但结论变形了。 你要报的正是"华东和西南差多少",现在这个差被你自己填出来的东西改小了。

4.6 补救:加一个 group_by(),让填补值在组内各算各的

解决办法和第 9 讲学过的 group_by() + mutate() 组合完全一样——只是现在让 mean() 在组内计算:

▶️ 查看代码
company_data %>%
  group_by(region) %>%                # 第 1 步:声明"按 region 分组"
  mutate(revenue_g = if_else(is.na(revenue),           # 第 2 步:还是 4.1 那个句式
                             mean(revenue, na.rm = TRUE),  #   但"填补值"在**组内**各算各的
                             revenue)) %>%
  ungroup()                         # 第 3 步:解除分组
# A tibble: 16 × 6
   company  region revenue  cost rating revenue_g
   <chr>    <chr>    <dbl> <dbl> <chr>      <dbl>
 1 锦城科技 华东      1200   800 A          1200 
 2 蜀汉制造 西南        NA   650 B          1463.
 3 天府物流 西南      1560    NA A          1560 
 4 青羊金融 华东       760   500 C           760 
 5 锦江生物 西南        NA   900 A          1463.
 6 浣花实业 华东       892   620 B           892 
 7 锦西商贸 西南      1340   880 B          1340 
 8 蜀都餐饮 西南       680    NA C           680 
 9 青城建材 华东        NA   720 A           807 
10 天府冷链 西南      2100  1350 A          2100 
11 锦江农产 华东       450   310 C           450 
12 浣花纺织 华东       980   640 B           980 
13 锦西物流 西南        NA   760 B          1463.
14 蜀汉电子 西南      1720    NA A          1720 
15 青羊置业 华东       560   390 C           560 
16 天府食品 西南      1380   910 B          1380 

华东的空位填 807,西南的空位填 1463——各填各的组均值,和 4.5 那张表一比,差距原封不动。

▶️ 查看代码
company_data %>%
  group_by(region) %>%
  mutate(revenue_g = if_else(is.na(revenue), mean(revenue, na.rm = TRUE), revenue)) %>%
  ungroup() %>%
  group_by(region) %>%
  summarise(填补后组均值 = round(mean(revenue_g), 1)) %>%   # 验证:和真实组均值一致
  ungroup()
# A tibble: 2 × 2
  region 填补后组均值
  <chr>         <dbl>
1 华东           807 
2 西南          1463.

group_by() 让 mutate() 里的 mean() 在每个组内分别计算——这就是分组填补的全部秘密。代码和 4.1 的 if_else() 一模一样,只多了一个 group_by()。

重要

但别忘了 1.2 埋的那句话:"缺失不是随机的"。company_data 里 revenue 的 4 个 NA 分散在 7 行里,问题不大。可如果某个月整月都在缺(airquality 的 Ozone 在 6 月缺了 21 天/30 天),那一组"组内均值"其实是由少数几个真实观测算出来、再回填到一大片空位上的。缺失越扎堆,填补越要谨慎,必要时宁可保留 NA 并在结论里说明。

4.7 【即时练习 7】分组均值填补

  1. 对 company_data,用 group_by(region) + if_else() 把 revenue 的缺失按组均值填补;
  2. 验证:填补后每组的 mean() 应该和填补前的真实组均值完全一样(华东 807、西南 1463)——为什么数学上必然这样?
  3. 换 airquality 试试:按 Month 分组、用组内均值补 Ozone 的缺失,然后看填补后各月的均值有没有变。

4.8 其余方法:一句话知道它们存在

上面四套覆盖了绝大多数场景。还有几个更专门的工具,知道什么时候该找它们就够了:

方法 一句话
中位数填补 median(x, na.rm = TRUE) 有极端值时比均值更稳健(4.1 的 case_when() 兜底就用过)
前后平均 if_else(is.na(y), (lag(y) + lead(y)) / 2, y) 时间序列顺着趋势补空位;连续缺失和首尾填不了
多源拼合 coalesce(A, B) 同一指标有多个来源时取第一个非缺失值;不做对账
模型填补 mice 包 缺失很多、变量多时的多重插补,属于进阶内容

本讲要牢牢记住的是 4.1–4.6 那三套补法 + 第三章的删除。其余的,作业选做里有延伸题,需要时再取用。

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

把四套方案串成一条完整工作流

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

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

  1. 识别——summary() 或 colSums(is.na()) 看清每列缺失,算出缺失比例;
  2. 决策——为 Solar.R 与 Ozone 各选一套处理方案,并写清理由;
  3. 处理——用代码落实(三套补法里挑合适的,或组合使用);
  4. 验证——处理后再次统计缺失,确认清干净;
  5. 把代码整理成一份 Quarto(.qmd)小报告,配上简短的文字说明。

5.2 课堂展示

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

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

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

6.1 知识回顾——四步流水线

识别(summary() / colSums(is.na()) / is.na())→ 决策(删还是补?缺失占多少、扎不扎堆)→ 处理(删:na.omit()/drop_na()/filter(!is.na());补:固定值 if_else()/case_when()、结构性 fill()、分组均值 group_by())→ 验证(再统计一遍缺失)

重要

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

6.2 学习成果自查

  • ✓ 能说清 NA 为什么会毁掉一次求和/分组求和,知道 na.rm = TRUE 改不了数据
  • ✓ 能用 summary() 快速看缺失数,用 is.na() + sum() / table() / mean() 定位到具体某列
  • ✓ 能删除:na.omit / drop_na(col) / filter(!is.na(col)) 三种写法,并说出各自删了几行
  • ✓ 能填固定值:用 if_else() 填一个数、用 case_when() 按不同规则填不同数
  • ✓ 能填前值:判断缺失是"结构性"还是"真不知道",只有前者才用 fill()
  • ✓ 能分组均值:group_by() + if_else(),并说清为什么不能用一个全局均值填全表
  • ✓ 能判断该删还是该补,并说出理由

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

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

  1. sum(x, na.rm = TRUE) 算出来的数,能直接写进月度报告吗?为什么?
  2. if_else() 和 case_when() 什么时候该用哪个?fill() 呢?
  3. 为什么"用全局均值填补一张有分组结构的表"会出问题?该怎么改?
  4. filter(!is.na(x)) 和 mean(x, na.rm = TRUE) 都能"对付"缺失值,本质区别在哪?

课后作业

本次作业全部使用 R 内置数据集,不需要额外准备任何数据文件。msleep 在 ggplot2 里(先 library(ggplot2)),airquality、starwars 是 base R 自带的。按《实验报告5》模板完成:在"实现代码"处贴代码、"效果截图"处贴运行结果截图,并在"结果与分析"里写出结论。

第 1 组:识别与删除(msleep)

  1. 用 summary() 和 colSums(is.na()) 两种方法分别摸清 msleep 的缺失情况,说说为什么 summary() 看到的信息不如 colSums() 具体;
  2. 分别用"删任意列"(na.omit)和"只删 conservation 列"(drop_na)处理 msleep,各剩多少行?为什么删 conservation 比删任意列"划算"?

第 2 组:填补(airquality)

  1. Solar.R 只缺 7 个——用固定值填补它(提示:if_else() 填该列的中位数 205);
  2. Ozone 缺 37 个——分别用"整列均值"和"按 Month 分组均值"填补,比较 6 月的结果差异,解释为什么会有这个差别;
  3. 承上:填补完成后用 colSums(is.na()) 验证缺失是否清干净,并在结论里说明你是怎么决定的。

第 3 组:fill 与 case_when(starwars)

  1. starwars(87 行;mass 缺 28、height 缺 6)里,homeworld 列的缺失是结构性的吗?先判断,再决定要不要用 fill();
  2. 用 case_when() 把 height 分成 "≥190 高 / 170–189 中 / <170 矮 / 缺失" 四档——注意 is.na() 那行必须写最前面;
  3. 收尾:写一段 100 字左右的结论,说明"删除"与"填补"各适用什么条件。

提交要求与选做加练

提交要求

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

提示

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

  1. coalesce() 做多源补缺:造两份能互相补位的营收数据(系统导出 + 人工补录),用 coalesce() 拼成完整数据,再和"直接取平均"的结果比一比,说明为什么补缺时优先用 coalesce();再想一想:如果两个来源在同一行都有值却对不上,它会怎么处理、这样安全吗?
  2. 真正的移动平均:library(zoo) 后用 zoo::rollmean(x, k = 3, fill = NA, align = "center"),对比它与"前后邻居取中点"的结果差异,并说明各自适用场合。
  3. 缺失机制三分类(MCAR / MAR / MNAR):查资料了解这三种机制的定义,然后回来看 company_data 和 airquality——它们的缺失分别更像是哪一种?说说你的判断依据。

下讲预告

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

  • 箱线图(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." 财务数据中的缺失不能"拍脑袋"填写,每一个"补数"决定都应当有据可查、有理可依,这与审计工作中对异常科目要"查明原因、审慎处理"的要求是相通的。

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