17  数据质量控制

18 数据质量控制

数据清洗和特征工程解决了”数据怎么处理”的问题,数据质量控制解决的是”怎么确保数据可靠、可追溯、可复用”。这是可重复性研究的核心环节。

library(tidyverse)

18.1 数据验证

18.1.1 范围检查

确保数值在合理范围内:

validate_soil <- function(data) {
  issues <- list()

  # pH 范围检查(0-14)
  bad_ph <- data |> filter(ph < 0 | ph > 14)
  if (nrow(bad_ph) > 0) issues$ph <- paste(nrow(bad_ph), "条 pH 超出范围")

  # 有机碳不能为负
  bad_oc <- data |> filter(organic_carbon < 0)
  if (nrow(bad_oc) > 0) issues$oc <- paste(nrow(bad_oc), "条有机碳为负值")

  # 百分比之和检查
  if ("clay_pct" %in% names(data) && "sand_pct" %in% names(data)) {
    bad_pct <- data |> filter(clay_pct + sand_pct > 100)
    if (nrow(bad_pct) > 0) issues$pct <- paste(nrow(bad_pct), "条粒径百分比之和超过100%")
  }

  if (length(issues) == 0) {
    cat("✅ 所有验证通过\n")
  } else {
    cat("⚠️ 发现以下问题:\n")
    for (name in names(issues)) {
      cat("  -", issues[[name]], "\n")
    }
  }

  invisible(issues)
}

# 示例
set.seed(2027)
soil <- tibble(
  site_id = paste0("S", 1:10),
  ph = c(6.2, 5.8, 7.1, -0.5, 6.5, 15.2, 6.8, 5.9, 6.3, 7.0),
  organic_carbon = c(25, 30, 22, 28, -5, 35, 20, 27, 31, 24),
  clay_pct = c(30, 45, 20, 55, 40, 35, 25, 50, 38, 42),
  sand_pct = c(40, 30, 50, 60, 35, 45, 55, 30, 40, 35)
)

validate_soil(soil)

18.1.2 一致性检查

# 检查同一样点不同深度的数据是否一致
# 例如:表层有机碳应该高于深层
check_depth_consistency <- function(data) {
  if (!("depth" %in% names(data))) return(invisible(NULL))

  inconsistent <- data |>
    group_by(site_id) |>
    arrange(depth) |>
    mutate(oc_decreasing = organic_carbon < lag(organic_carbon)) |>
    filter(!is.na(oc_decreasing) & !oc_decreasing) |>
    ungroup()

  if (nrow(inconsistent) > 0) {
    cat("⚠️", nrow(inconsistent), "条记录的有机碳未随深度递减(可能需要核实)\n")
  } else {
    cat("✅ 有机碳随深度递减,符合预期\n")
  }

  invisible(inconsistent)
}

# 示例:不同深度的土壤数据
soil_depth <- tibble(
  site_id = rep(c("S1", "S2", "S3"), each = 3),
  depth = rep(c(10, 20, 30), 3),
  organic_carbon = c(
    35, 22, 15,   # S1: 正常递减
    28, 32, 18,   # S2: 20cm 处异常升高
    40, 30, 25    # S3: 正常递减
  )
)

check_depth_consistency(soil_depth)

18.2 元数据(Metadata)

元数据是”描述数据的数据”。没有元数据的数据集,就像没有说明书的仪器——别人(包括未来的你)无法正确使用。

18.2.1 元数据应包含的内容

项目 说明 示例
数据集名称 简明描述 广西大学校园土壤调查数据
采集时间 数据采集的时间范围 2027年3月15日-17日
采集地点 地理位置和坐标 广西南宁市广西大学校园(108.29°E, 22.83°N)
采集方法 采样设计和方法 随机布设20个样点,每点采集0-30cm三层土壤
变量说明 每个变量的含义和单位 ph: 土壤pH值(水土比1:2.5)
数据处理 清洗和转换的方法 缺失值用中位数填充,pH异常值(<0或>14)标记为NA
联系人 数据负责人 刘华清 huaqingliu@gxu.edu.cn
许可证 数据使用许可 CC BY 4.0

18.2.2 编写元数据文件

在项目中创建 data/README.md

# 数据说明

## 数据集:广西大学校园土壤调查

- **采集时间**:2027年3月15日-17日
- **采集地点**:广西南宁市广西大学校园
- **采集人**:XXX, XXX
- **样本量**:20个样点 × 3个深度 = 60条记录

### 变量说明

| 变量名 | 类型 | 单位 | 说明 |
|:---|:---|:---|:---|
| site_id | 字符 | - | 样点编号(S01-S20) |
| depth | 因子 | cm | 采样深度(0-10, 10-20, 20-30) |
| ph | 数值 | - | 土壤pH(水土比1:2.5) |
| organic_carbon | 数值 | g/kg | 土壤有机碳含量 |
| nitrogen | 数值 | g/kg | 全氮含量 |
| clay_pct | 数值 | % | 黏粒含量 |
| sand_pct | 数值 | % | 砂粒含量 |

### 数据处理记录

1. 去除重复记录(3条)
2. pH异常值(<0或>14)标记为NA
3. 有机碳异常值(<0或>100)标记为NA
4. 缺失值用各变量中位数填充
5. 字符串统一为小写并去除空格

18.3 数据存储格式

18.3.1 常见格式对比

格式 优点 缺点 适用场景
CSV 通用、人类可读 不保留数据类型 数据共享、发布
RDS 保留 R 对象完整信息 仅 R 可读 R 项目中间结果
Excel 直观、易编辑 格式易损坏 数据录入(不推荐分析用)
Parquet 高效压缩、列式存储 需要额外包 大数据集

18.3.2 推荐做法

# 原始数据保存为 CSV(通用性最好)
write_csv(soil_raw, "data/raw/soil_survey_raw.csv")

# 处理后的数据保存为 RDS(保留因子等类型信息)
saveRDS(soil_clean, "data/processed/soil_survey_clean.rds")

# 读取
soil_clean <- readRDS("data/processed/soil_survey_clean.rds")
Tip数据存储的黄金法则
  1. 原始数据只读:永远不修改 data/raw/ 中的文件
  2. 处理过程可追溯:所有清洗步骤都写在脚本里,不要手动修改数据
  3. 中间结果可重建data/processed/ 中的文件都能通过运行脚本重新生成
  4. 共享用 CSV:给别人的数据用 CSV 格式,附带元数据说明

18.4 Codebook(数据字典)

Codebook 是比 README 更详细的变量说明文档,可以用 R 自动生成:

# 自动生成 codebook
generate_codebook <- function(data) {
  tibble(
    variable = names(data),
    type = map_chr(data, ~class(.x)[1]),
    n_missing = map_int(data, ~sum(is.na(.x))),
    n_unique = map_int(data, ~n_distinct(.x)),
    example = map_chr(data, ~paste(head(unique(.x), 3), collapse = ", "))
  )
}

generate_codebook(soil)

18.5 数据质量检查清单

在提交数据或开始分析之前,逐项检查:

18.6 实战案例:从”脏数据”到”干净数据”

下面通过一个完整的案例,演示数据质量控制的全流程。假设我们收到了一份来自野外调查的土壤数据,需要在分析前进行质量控制。

18.6.1 原始数据

# 模拟一份典型的"脏数据"
set.seed(2027)
n <- 30

raw_soil <- tibble(
  sample_id = paste0("GX", sprintf("%03d", 1:n)),
  site = rep(c("林地", "林地 ", "Linland", "草地", "草地", "农田"), each = 5),
  date = c(rep("2027-03-15", 10), rep("2027/03/16", 10), rep("15/3/2027", 10)),
  ph = c(6.2, 5.8, 7.1, 6.5, 6.8, 5.9, 6.3, 7.0, 5.5, 6.1,
         -0.5, 6.4, 15.2, 5.7, 6.6, 6.0, 6.9, 5.3, 6.7, 6.2,
         NA, 6.5, 5.8, 7.2, 6.1, 6.8, 5.6, 6.3, 6.0, 6.4),
  organic_carbon = c(25, 30, 22, 28, 35, 20, 27, 31, 24, 29,
                     33, -5, 26, 999, 21, 28, 32, 19, 27, 30,
                     24, 26, 31, 23, 28, 25, 29, 22, 27, 26),
  nitrogen = c(2.1, 2.5, 1.8, 2.3, 2.9, 1.7, 2.2, 2.6, 2.0, 2.4,
               2.8, 2.1, 2.2, 2.5, 1.9, 2.3, 2.7, 1.6, 2.2, 2.5,
               2.0, 2.1, 2.6, 1.9, 2.3, 2.1, 2.4, 1.8, 2.2, 2.1)
)

cat("原始数据概览:\n")
glimpse(raw_soil)

这份数据至少有以下问题:

  1. site 列有拼写不一致(“林地” vs “林地” vs “Linland”)
  2. date 列有三种不同的日期格式
  3. ph 列有超出范围的值(-0.5, 15.2)和缺失值
  4. organic_carbon 列有负值(-5)和明显的录入错误(999)

18.6.2 逐步清洗

clean_soil <- raw_soil |>
  # 1. 统一站点名称
  mutate(
    site = str_trim(site),
    site = case_match(site,
      "Linland" ~ "林地",
      .default = site
    )
  ) |>
  # 2. 统一日期格式
  mutate(
    date = case_when(
      str_detect(date, "^\\d{4}-") ~ ymd(date),
      str_detect(date, "^\\d{4}/") ~ ymd(date),
      str_detect(date, "^\\d{2}/") ~ dmy(date),
      TRUE ~ NA_Date_
    )
  ) |>
  # 3. 标记并处理异常值
  mutate(
    ph_flag = case_when(
      is.na(ph) ~ "缺失",
      ph < 0 | ph > 14 ~ "超出范围",
      TRUE ~ "正常"
    ),
    oc_flag = case_when(
      organic_carbon < 0 ~ "负值",
      organic_carbon > 200 ~ "录入错误",
      TRUE ~ "正常"
    )
  ) |>
  # 4. 将异常值替换为 NA
  mutate(
    ph = if_else(ph < 0 | ph > 14, NA_real_, ph),
    organic_carbon = if_else(organic_carbon < 0 | organic_carbon > 200, NA_real_, organic_carbon)
  )

# 查看清洗结果
cat("--- 站点名称统一 ---\n")
count(clean_soil, site)

cat("\n--- 异常值标记 ---\n")
count(clean_soil, ph_flag)
count(clean_soil, oc_flag)

18.6.3 生成质量报告

# 汇总数据质量
quality_report <- clean_soil |>
  summarise(
    total_records = n(),
    ph_missing = sum(is.na(ph)),
    ph_flagged = sum(ph_flag != "正常"),
    oc_missing = sum(is.na(organic_carbon)),
    oc_flagged = sum(oc_flag != "正常"),
    date_formats_fixed = sum(!is.na(date)),
    site_categories = n_distinct(site)
  )

quality_report |>
  pivot_longer(everything(), names_to = "检查项", values_to = "结果") |>
  mutate(检查项 = case_match(检查项,
    "total_records" ~ "总记录数",
    "ph_missing" ~ "pH 缺失数",
    "ph_flagged" ~ "pH 异常标记数",
    "oc_missing" ~ "有机碳缺失数",
    "oc_flagged" ~ "有机碳异常标记数",
    "date_formats_fixed" ~ "日期成功解析数",
    "site_categories" ~ "站点类别数"
  ))
Important关键原则:标记优先于删除

在上面的案例中,我们先用 _flag 列标记异常值,再决定如何处理。这样做的好处是:

  1. 保留了原始信息,方便回溯
  2. 可以统计异常值的数量和分布
  3. 不同的分析可能对异常值有不同的处理策略

18.7 课后练习

  1. 为你的课程项目数据编写一份完整的元数据文件(README.md)
  2. 编写一个数据验证函数,检查你的数据是否满足预期的范围和一致性
  3. generate_codebook() 函数为你的数据生成 codebook
  4. 将原始数据保存为 CSV,处理后的数据保存为 RDS
  5. 确保你的数据处理流程完全可重复:删除 data/processed/ 中的文件,重新运行脚本能得到相同结果