未记录事实核验 · 状态:现行内容

记录仅说明核验时间与维护状态,不保证适用于所有环境;使用前请留意正文中的版本、适用条件与风险提示。

warning 适用边界与维护说明

本文适合学习常用 R 清洗函数与自建模拟数据流程,不是可直接套用的生产清洗规范;本次仅作全文结构审阅,未运行代码或进行外部事实核验。异常值检测的小节提供salary、age、reg_date三列自建教学输入,不沿用前文只有date_raw的df;业务阈值不代表真实业务标准。填充方法、删列比例及年龄、薪资阈值应结合业务与数据分布确定;按日期保留最新记录的示例未规定并列日期的唯一取舍。完整管道先填充后按 user_id 保留首条记录,最终断言未检查注册日期完整性,因此输出不代表所有字段均满足生产分析要求。assertr 中明确标注的失败示例会终止所在管道;重塑目录项为普通文本,可按同名章节查找。 已取消无法确定目标的可点击引用「#数据重塑:pivot_longer 与 pivot_wider」,保留原提及;Missing anchor: #数据重塑:pivot_longer 与 pivot_wider

R 数据清洗技巧

数据清洗是数据分析中的常见工作,其耗时占比取决于数据质量、任务和工作流程;本文不提供统一的时间比例。真实数据中几乎必然存在缺失值、重复记录、格式不统一和逻辑异常。本文系统介绍 R 语言中处理这些问题的工具和策略,并给出从原始脏数据到可分析数据框的完整管道示例。

目录


NA 处理

识别 NA

x <- c(1, NA, 3, NA, 5)

is.na(x)           # FALSE TRUE FALSE TRUE FALSE(逐元素)
sum(is.na(x))      # 2(NA 的个数)
which(is.na(x))    # 2 4(NA 的位置索引)

# 数据框中各列的 NA 统计
df <- data.frame(a = c(1, NA, 3), b = c("x", "y", NA))
colSums(is.na(df))            # 各列 NA 个数
sapply(df, function(x) mean(is.na(x)))  # 各列 NA 比例

warning `NA == NA` 返回 `NA` 而不是 `TRUE`。判断某值是否为 NA,必须用 `is.na()`,不能用 `==`。

删除 NA

x <- c(1, NA, 3, NA, 5)

# 向量级删除
na.omit(x)          # 1 3 5(返回去掉NA后的向量)
x[!is.na(x)]        # 同上

# 数据框级删除(行)
df <- data.frame(a = c(1, NA, 3), b = c(4, 5, NA))

na.omit(df)                          # 删除含任意NA的行
df[complete.cases(df), ]             # 同上,等价写法

library(tidyr)
drop_na(df)                          # tidyr 版,删除含任意NA的行
drop_na(df, a)                       # 只删除列 a 中有 NA 的行
drop_na(df, c(a, b))                 # 删除 a 或 b 有 NA 的行

NA 填充策略

library(tidyr)
library(dplyr)

# 数值型:均值填充
df <- df %>%
  mutate(a = ifelse(is.na(a), mean(a, na.rm = TRUE), a))

# 数值型:中位数填充(对异常值更鲁棒)
df <- df %>%
  mutate(a = ifelse(is.na(a), median(a, na.rm = TRUE), a))

# 前向填充(用前一个非NA值填充)
df_ts <- data.frame(
  date  = as.Date("2024-01-01") + 0:5,
  value = c(10, NA, NA, 40, NA, 60)
)
df_ts %>% fill(value, .direction = "down")  # 前向
df_ts %>% fill(value, .direction = "up")    # 后向
df_ts %>% fill(value, .direction = "downup")# 先前向再后向

# replace_na():为不同列指定不同填充值
df %>% replace_na(list(a = 0, b = 0))

填充策略选择指南:

场景 推荐策略 备注
时间序列、有序数据 前向/后向填充 保持时序连贯性
正态分布数值 均值填充 不改变分布均值
偏斜分布数值 中位数填充 对离群值不敏感
分类变量 众数填充 或单独标记"未知"类别
缺失比例 > 30% 考虑删除该列 填充引入过多噪声

重复值处理

df <- data.frame(
  id   = c(1, 2, 2, 3, 4, 4),
  name = c("张三","李四","李四","王五","赵六","赵六"),
  age  = c(25, 30, 30, 28, 35, 35)
)

# 识别重复行
duplicated(df)              # FALSE FALSE TRUE FALSE FALSE TRUE(第一次出现标FALSE)
duplicated(df, fromLast = TRUE)  # 反向检测,最后一次出现标FALSE

# 查看所有重复的行(包括第一次出现)
df[duplicated(df) | duplicated(df, fromLast = TRUE), ]

# 计算重复行数
sum(duplicated(df))         # 2

# 删除重复行,保留第一条
df_unique <- df[!duplicated(df), ]

# dplyr 版(更简洁,推荐)
library(dplyr)
distinct(df)                    # 完全去重
distinct(df, id, .keep_all = TRUE)  # 基于 id 去重,保留其他列
distinct(df, id, name)          # 只保留 id 和 name 列并去重

部分字段重复的处理策略

# 业务场景:同一 id 有多条记录,需要保留最新的一条
df_orders <- data.frame(
  user_id  = c(1, 1, 2, 3, 3),
  amount   = c(100, 200, 150, 80, 120),
  date     = as.Date(c("2024-01-01","2024-03-01","2024-02-01","2024-01-15","2024-04-01"))
)

# 每个 user_id 保留最新的订单
df_orders %>%
  group_by(user_id) %>%
  slice_max(date, n = 1) %>%
  ungroup()

字符串清洗:stringr

stringr 是 tidyverse 的字符串处理包,常用字符串操作函数以 str_ 开头,接口较为一致。

常用函数速查

函数 用途 示例
str_trim(x) 去除两端空白 " 张三 " → "张三"
str_squish(x) 去除两端空白+压缩内部多余空白 "张 三" → "张 三"
str_length(x) 字符数 str_length("张三") → 2
str_to_upper(x) 转大写 "hello" → "HELLO"
str_to_lower(x) 转小写 "HELLO" → "hello"
str_replace(x, pattern, replacement) 替换首次匹配
str_replace_all(x, pattern, replacement) 替换所有匹配
str_detect(x, pattern) 是否包含模式(返回逻辑向量)
str_extract(x, pattern) 提取首次匹配的子串
str_extract_all(x, pattern) 提取所有匹配
str_split(x, pattern) 按模式拆分
str_sub(x, start, end) 截取子串 str_sub("abcde", 2, 4) → "bcd"
str_pad(x, width, side) 补齐宽度 str_pad("5", 3, "left", "0") → "005"
str_c(...) 拼接字符串 等同 paste0

实际清洗示例

library(stringr)

raw <- c("  张三  ", "李四ABC", "  ", "WANG_WU", "138-0000-1234")

# 去空格
str_trim(raw)             # "张三" "李四ABC" "" "WANG_WU" "138-0000-1234"
str_squish(raw)           # 同上,还会压缩内部多余空格

# 正则提取:从混合字符串中提取手机号
phones <- c("联系电话:138-0000-1234,备用:139-8888-9999", "无手机")
str_extract(phones, "\\d{3}-\\d{4}-\\d{4}")      # 首个手机号
str_extract_all(phones, "\\d{3}-\\d{4}-\\d{4}")  # 所有手机号(返回列表)

# 替换:清洗价格字段中的货币符号
prices <- c("¥1,234.56", "$500.00", "EUR 300")
str_replace_all(prices, "[¥$,EUR\\s]", "")  # "1234.56" "500.00" "300"

# 检测:筛选包含特定模式的行
emails <- c("user@qq.com", "invalid", "admin@163.com")
valid  <- str_detect(emails, "^[\\w.]+@[\\w.]+\\.[a-z]{2,}$")
emails[valid]   # "user@qq.com" "admin@163.com"

# 拆分
str_split("北京,上海,广州", ",")[[1]]  # "北京" "上海" "广州"

常用正则表达式速查

模式 含义
\\d 数字(等同 [0-9])
\\w 字母数字下划线
\\s 空白字符(空格、制表符等)
^ / $ 字符串开头/结尾
+ 1次或多次
* 0次或多次
? 0次或1次
{n,m} n 到 m 次
[abc] 字符集合
[^abc] 非字符集合

日期时间处理:lubridate

lubridate 让 R 中的日期运算变得直观。函数名由格式字母拼成:y(年)、m(月)、d(日)、h(时)、m(分)、s(秒)。

解析日期字符串

library(lubridate)

# 函数名 = 格式顺序
ymd("2024-03-15")        # "2024-03-15"(ISO 8601)
mdy("03/15/2024")        # 美国格式
dmy("15-03-2024")        # 欧洲格式
ymd_hms("2024-03-15 14:30:00")  # 带时间

# 批量解析混合格式(parse_date_time 更灵活)
dates <- c("2024-03-15", "03/15/2024", "15.03.2024")
parse_date_time(dates, orders = c("ymd", "mdy", "dmy"))

日期运算

d <- ymd("2024-03-15")

# 提取组件
year(d)     # 2024
month(d)    # 3
day(d)      # 15
wday(d, label = TRUE)   # "Fri"(星期几)
quarter(d)  # 1(第几季度)
yday(d)     # 75(第几天)

# 加减运算(直觉化函数)
d + days(7)       # 一周后
d %m+% months(3)  # 三个月后;目标月份无对应日期时回退到月末
d + years(1)      # 一年后
d - weeks(2)      # 两周前

# 时间间隔
d2 <- ymd("2024-12-31")
as.numeric(d2 - d, "days")      # 291 天
interval(d, d2) / months(1)     # 9.5 个月
difftime(d2, d, units = "weeks") # 41.57 周

# 时区处理
now()                            # 当前时刻(本地时区)
with_tz(now(), "UTC")            # 转换为 UTC
force_tz(now(), "Asia/Shanghai") # 强制设定时区(不改变时钟)

实际清洗场景

# 场景:将多种日期格式统一转换
df <- data.frame(
  date_raw = c("2024-01-15", "20240215", "03/20/2024", "2024年4月5日", NA)
)

df %>% mutate(
  date_clean = case_when(
    str_detect(date_raw, "^\\d{4}-\\d{2}-\\d{2}$") ~ ymd(date_raw),
    str_detect(date_raw, "^\\d{8}$")               ~ ymd(date_raw),
    str_detect(date_raw, "\\d{2}/\\d{2}/\\d{4}")   ~ mdy(date_raw),
    str_detect(date_raw, "年")                      ~ ymd(str_replace_all(date_raw, "[年月日]", "-")),
    TRUE                                            ~ as.Date(NA)
  )
)

类型转换与转换失败

# 基础类型转换
as.numeric("3.14")       # 3.14
as.integer("42")         # 42
as.character(100)        # "100"
as.logical(0)            # FALSE(0→FALSE,非0→TRUE)
as.Date("2024-03-15")    # Date 对象

# 以下数值转换失败会产生 NA,并发出警告
as.numeric("abc")        # NA + 警告 "NAs introduced by coercion"
as.integer("3.5x")       # NA

# 安全转换模式:检测失败数量
safe_to_numeric <- function(x) {
  result <- suppressWarnings(as.numeric(x))
  failures <- sum(is.na(result)) - sum(is.na(x))  # 新增 NA(转换失败的)
  if (failures > 0) {
    warning(sprintf("有 %d 个值无法转换为数值,已转为 NA", failures))
  }
  result
}

x_raw <- c("1", "2", "abc", "4", "5.5")
x_num <- safe_to_numeric(x_raw)  # 警告: 有1个值无法转换

warning 上述数值转换失败会产生 NA 并发出警告,但其他类型转换可能报错。在 ETL 管道中,应比较转换前后的缺失情况并定位新增缺失;单独使用 `sum(is.na())` 只能统计当前缺失总数,不能区分原有缺失与转换失败。


异常值检测

IQR 方法(箱线图标准)

超出 [Q1 - 1.5×IQR, Q3 + 1.5×IQR] 范围的值被标记为异常:

x <- c(23, 25, 27, 24, 26, 100, 22, 28, -5, 25)

Q1  <- quantile(x, 0.25)
Q3  <- quantile(x, 0.75)
IQR_val <- IQR(x)

lower <- Q1 - 1.5 * IQR_val
upper <- Q3 + 1.5 * IQR_val

outliers <- x[x < lower | x > upper]  # 100 和 -5

# 直接用 boxplot.stats() 获取异常值
boxplot.stats(x)$out       # 返回异常点的值:100 -5

Z-score 方法

适用于近似正态分布的数据,Z 值绝对值超过 3 视为异常:

z_scores <- scale(x)                         # 标准化
outliers_z <- x[abs(z_scores) > 3]

# 下列两段共用自建教学输入,不代表真实业务阈值或资料
df <- tibble::tibble(
  salary = c(5000, 6000, 7000, 150000),
  age = c(25, 30, 40, 70),
  reg_date = as.Date(c("2024-01-01", "2024-01-02", "2024-01-03", "2099-01-01"))
)
# 结合 dplyr 在数据框中标记异常
df %>%
  mutate(
    z_score   = as.numeric(scale(salary)),
    is_outlier = abs(z_score) > 3
  )

业务规则检测

统计方法找到的"异常"未必是业务异常,最终应结合领域知识:

df %>%
  mutate(
    age_invalid    = age < 18 | age > 65,         # 工作年龄范围
    salary_invalid = salary < 3000 | salary > 100000,  # 合理薪资区间
    date_future    = reg_date > Sys.Date()          # 未来注册日期
  ) %>%
  filter(age_invalid | salary_invalid | date_future)  # 查看所有异常记录

三种方法对比:

方法 原理 适用场景 局限
IQR 四分位距 偏斜分布、有离群点 不依赖正态假设
Z-score 标准差倍数 近似正态分布 对极端值敏感
业务规则 领域知识 有明确业务约束 需要先验知识

数据重塑:pivot_longer 与 pivot_wider

宽格式转长格式:pivot_longer

library(tidyr)

# 宽格式:每个科目是一列
df_wide <- data.frame(
  name    = c("张三", "李四"),
  math    = c(85, 90),
  english = c(88, 92),
  science = c(78, 85)
)

# 转为长格式:每行一个观测值
df_long <- df_wide %>%
  pivot_longer(
    cols      = c(math, english, science),  # 要转换的列
    names_to  = "subject",                  # 原列名变成新列
    values_to = "score"                     # 原值变成新列
  )
#    name   subject score
# 1  张三   math       85
# 2  张三   english    88
# ...

长格式转宽格式:pivot_wider

# 逆操作:长格式变回宽格式
df_back_wide <- df_long %>%
  pivot_wider(
    names_from  = "subject",   # 哪列的值变成新列名
    values_from = "score"      # 哪列的值填入格子
  )

note 旧版 tidyr 使用 `gather()`(宽转长)和 `spread()`(长转宽),这两个函数已被 `pivot_longer` 和 `pivot_wider` 取代,但仍可用。新代码应优先使用 pivot 系列。

常见重塑场景

# 月度销售数据(宽格式)→ 适合可视化的长格式
sales_wide <- data.frame(
  product = c("A", "B"),
  jan = c(100, 200), feb = c(120, 180), mar = c(140, 220)
)

sales_long <- sales_wide %>%
  pivot_longer(-product, names_to = "month", values_to = "sales")

# 调查问卷(多个选项在多列)→ 分析用长格式
survey <- data.frame(
  id     = 1:3,
  q1_ans = c("满意","不满意","满意"),
  q2_ans = c("一般","满意","非常满意")
)
survey %>% pivot_longer(-id, names_to = "question", values_to = "answer")

列重命名

library(dplyr)

df <- data.frame(Name = 1, AGE = 2, Salary.2024 = 3)

# rename():指定改哪几列(新名 = 旧名)
df %>% rename(name = Name, age = AGE)

# rename_with():用函数批量重命名
df %>% rename_with(tolower)                    # 全部转小写
df %>% rename_with(~ gsub("\\.", "_", .x))    # 点替换为下划线

# 直接赋值 names()
names(df) <- c("name", "age", "salary_2024")

# janitor::clean_names() 一键规范化(推荐)
library(janitor)
df %>% clean_names()  # 转小写、空格替换为_、去特殊字符

数据验证:assertr

assertr 提供管道友好的数据断言,适合在数据管道的关键节点进行质量门控。

# install.packages("assertr")
library(assertr)
library(dplyr)

df <- data.frame(
  id     = c(1, 2, 3, NA),
  age    = c(25, 30, -5, 28),
  salary = c(5000, 6000, 7000, 8000)
)

# verify():整体条件断言
df %>%
  verify(nrow(.) > 0) %>%               # 不能是空数据框
  verify(ncol(.) == 3)                  # 本示例必须有3列

# assert():逐元素条件断言;本例 id 含 NA,断言会失败并终止管道,后续 age 断言不会执行
df %>%
  assert(not_na, id) %>%               # id 列不允许 NA
  assert(within_bounds(18, 65), age)   # age 在 18-65 之间

# insist():基于统计属性的断言
df %>%
  insist(within_n_sds(3), salary)     # salary 不超出均值±3SD

# 自定义断言规则;本例 age 含负数,以下断言会失败
no_negative <- function(x) x >= 0
df %>% assert(no_negative, age, salary)

tip assertr 适合放在数据管道的进口和出口:进口验证输入数据符合预期,出口验证清洗结果满足质量标准。一旦断言失败,立即报错终止,防止带病数据流入分析。


完整清洗流程示例

以下示例模拟从一份原始"脏数据"到可分析数据框的完整管道:

library(dplyr)
library(tidyr)
library(stringr)
library(lubridate)
library(janitor)
library(assertr)

# ─── 1. 原始脏数据 ───────────────────────────────────
raw <- data.frame(
  `User ID`   = c("U001", "U002", "U003", "U002", "U004"),  # 有重复
  `User Name` = c("  张三 ", "李四", NA, "李四", "王五"),    # 有空格、有缺失
  Age         = c("28", "thirty", "25", "30", "22"),         # 有非数值
  Salary      = c("¥5,000", "6000元", "7500", NA, "250000"),  # 格式混乱、有异常值
  Reg_Date    = c("2024-01-15", "2024/02/20", "15-03-2024", "2024-02-20", NA),
  stringsAsFactors = FALSE,
  check.names = FALSE
)

# ─── 2. 列名标准化 ────────────────────────────────────
df <- raw %>%
  clean_names()
# user_id, user_name, age, salary, reg_date

# ─── 3. 字符串清洗 ────────────────────────────────────
df <- df %>%
  mutate(
    user_name = str_trim(user_name),           # 去除前后空格
    user_name = na_if(user_name, "")           # 空字符串转 NA
  )

# ─── 4. 类型转换 ──────────────────────────────────────
df <- df %>%
  mutate(
    # 年龄:去非数字字符后转整数
    age = suppressWarnings(as.integer(str_extract(age, "\\d+"))),

    # 薪资:去货币符号和千分位后转数值
    salary = suppressWarnings(
      as.numeric(str_replace_all(salary, "[¥元,\\s]", ""))
    ),

    # 日期:支持多种格式
    reg_date = parse_date_time(
      reg_date,
      orders = c("Ymd", "Y/m/d", "d-m-Y"),
      quiet  = TRUE
    ) %>% as.Date()
  )

# ─── 5. 缺失值处理 ────────────────────────────────────
df <- df %>%
  mutate(
    age       = if_else(is.na(age),    as.integer(median(age, na.rm=TRUE)), age),
    salary    = if_else(is.na(salary), median(salary, na.rm=TRUE),          salary),
    user_name = if_else(is.na(user_name), paste0("用户_", str_sub(user_id, -3)), user_name)
  )

# ─── 6. 去重 ──────────────────────────────────────────
df <- df %>%
  distinct(user_id, .keep_all = TRUE)   # 保留每个 user_id 的第一条记录

# ─── 7. 异常值处理 ────────────────────────────────────
df <- df %>%
  mutate(
    age_flag    = age < 18 | age > 65,
    salary_flag = salary > 100000,
    age    = if_else(age_flag,    as.integer(median(age, na.rm=TRUE)), age),
    salary = if_else(salary_flag, median(salary, na.rm=TRUE),          salary)
  ) %>%
  select(-age_flag, -salary_flag)        # 删除临时标记列

# ─── 8. 数据验证 ──────────────────────────────────────
df_validated <- df %>%
  verify(nrow(.) > 0) %>%
  assert(not_na, user_id, user_name) %>%
  assert(within_bounds(18, 65), age) %>%
  assert(within_bounds(1000, 100000), salary)

# ─── 9. 结果输出 ──────────────────────────────────────
cat("清洗完成:\n")
cat(sprintf("  原始行数:%d  清洗后:%d\n", nrow(raw), nrow(df_validated)))
cat(sprintf("  NA 数量:%d\n", sum(is.na(df_validated))))
print(df_validated)

note 生产管道中应在每一步之后记录日志(行数变化、NA 变化),便于追溯问题。可以将上面每个步骤封装为独立函数,用 `%>%` 串联,使代码可测试、可复用。


掌握检查

完成本文后,请验证自己能否回答以下问题:

  • [ ] na.omit(df) 和 drop_na(df, col_a) 的行为有何区别?
  • [ ] str_trim() 和 str_squish() 分别适合处理哪类问题?
  • [ ] ymd("2024-03-15") 返回的是什么类型?与 as.Date("2024-03-15") 有何差异?
  • [ ] IQR 方法和 Z-score 方法在什么情况下应该优先选哪个?
  • [ ] pivot_longer 和 pivot_wider 各自把数据变成了什么形状?

练习题

题一(基础):创建一个包含 20 行的"脏"数据框,字段包括:name(含空格和 NA)、phone(含格式如 138-0000-1234 和 13800001234 两种格式,均为练习示例)、salary(含 ¥ 符号和千分位逗号)。完成:

  1. 清洗 name 列(去空格、NA 标记为"未知");
  2. 用 stringr 正则把 phone 统一为 11 位纯数字;
  3. 把 salary 转为 numeric 类型。

题二(进阶):有一份宽格式的月度销售数据(产品 × 月份),要求:

  1. 用 pivot_longer 转为长格式;
  2. 用 lubridate 解析月份字符串为 Date 类型;
  3. 用 assertr::assert 验证所有销售额 > 0;
  4. 用 IQR 方法检测并标记异常值;
  5. 输出清洗后的宽格式数据。

关联文档:R数据结构 | 数据导入导出 | dplyr数据操作

创建时间:2024-12-11 | 更新时间:2026-10-09 预计学习时长:90-120分钟

#R语言 #数据清洗 #数据质量 #缺失值处理 #异常值检测 #stringr #lubridate #tidyr #assertr #数据预处理