4. 数据清洗实战

环境监测数据在采集、传输、入库的每个环节都可能引入“脏数据”:传感器掉线导致整段缺失、量程溢出产生离谱读数、设备重传造成重复记录、CSV 导入把数值列读成字符串。数据清洗的目标不是把数据“改好看”,而是让后续的统计、绘图和超标判定建立在可信的数据基础上。本节以一个典型的空气质量小时数据集为例,完整走一遍从原始表到清洗后数据集的流程。

一、先看清数据:结构与问题诊断

假设我们拿到一份 air_hourly.csv,字段包括站点编号 station、监测时间 ts、PM2.5 浓度 pm25、PM10 浓度 pm10、二氧化硫 so2、温度 temp。先用 pandas 读入并做一次“体检”:

import pandas as pd
import numpy as np

df = pd.read_csv("air_hourly.csv", dtype=str)
print(df.shape)
print(df.dtypes)
print(df.isna().sum())
print(df.duplicated(subset=["station", "ts"]).sum())
print(df["pm25"].head(10).tolist())

这里刻意用 dtype=str 全部按字符串读入,是为了先观察原始形态,避免 pandas 自动推断把 "--"、"缺测"、"1,2.5" 之类的内容悄悄变成 NaN 或报错。诊断阶段要回答四个问题:哪些列有缺失、缺失比例多少;哪些列存在非数值字符;是否存在同一站点同一时刻的重复行;数值分布是否出现明显越界。下面的 SVG 概括了本节清洗流程中各环节的输入、判断与输出关系。

环境监测数据清洗:从原始表到可信数据集 原始 CSV(全字符串) station / ts / pm25 pm10 / so2 / temp 问题诊断 缺失 / 非数值 / 重复 越界 / 类型错误 ① 缺失值:NaN / “--” ② 异常值:超量程读数 ③ 重复记录:同站同时刻 ④ 类型/字符串:逗号、单位 标记缺测 → 插补/剔除 按站点时间序列插值 量程判定 → 置 NaN 负值/超上限视为无效 保留首条或均值聚合 drop_duplicates 去逗号/单位 → to_numeric ts → datetime 清洗后数据集 air_clean 数值列 float / ts 为 datetime / 无重复 清洗质量校验 缺失率、越界数、重复数、时间连续性 示例量程与有效范围 PM2.5:0 ~ 1000 μg/m³ PM10:0 ~ 1500 μg/m³ SO₂:0 ~ 2000 μg/m³ 温度:-40 ~ 60 ℃ 超出范围或负值 → 判为无效

二、统一缺失标记与类型转换

原始数据里缺失可能写成空串、"--"、"缺测"、"NA"。第一步把它们统一成 np.nan,再对数值列做类型转换。字符串清洗的常见动作包括去掉千分位逗号、去掉单位后缀(如 "μg/m³")、去除首尾空白:

missing_tokens = ["", "--", "缺测", "NA", "N/A", "null"]
df = df.replace(missing_tokens, np.nan)

num_cols = ["pm25", "pm10", "so2", "temp"]
for col in num_cols:
    df[col] = (df[col]
               .str.replace(",", "", regex=False)
               .str.replace("μg/m³", "", regex=False)
               .str.strip())
    df[col] = pd.to_numeric(df[col], errors="coerce")

df["ts"] = pd.to_datetime(df["ts"], errors="coerce")
df["station"] = df["station"].str.strip().str.upper()

关键点是 errors="coerce":无法解析的内容会变成 NaN 而不是抛异常,这样我们能在转换后统计“有多少条原本是非数值文本”,而不是让程序中途崩溃。ts 转换失败的行同样会变成 NaT,这类记录无法参与时间序列分析,应单独计数后剔除。

三、重复记录:先定义“重复”

环境监测中的重复通常指同一站点、同一监测时刻出现多条记录,可能来自设备重传或多次入库。判断重复的键应选业务主键,而不是整行完全相同:

dup_mask = df.duplicated(subset=["station", "ts"], keep=False)
print("重复行数:", dup_mask.sum())

# 方案A:保留首条
df = df.drop_duplicates(subset=["station", "ts"], keep="first")

# 方案B:同键多条取均值(需先剔除无效值)
# df = (df.groupby(["station", "ts"], as_index=False)[num_cols].mean())

两种方案各有适用场景:若重复来自完全相同的重传,保留首条即可;若同一时刻有多次有效观测(如不同仪器),取均值更合理。选择后要在文档中写明,保证结果可复现。

四、异常值:用量程和物理意义双重判定

“超出量程”的读数是最典型的异常值。例如 PM2.5 传感器量程上限为 1000 μg/m³,出现 65535 往往是寄存器溢出或通信错误;负值在浓度指标上物理上不成立。处理策略是先标记为无效(置 NaN),再决定插补或剔除,而不是直接删除整行——因为同一行的温度可能仍然有效。

valid_range = {
    "pm25": (0, 1000),
    "pm10": (0, 1500),
    "so2":  (0, 2000),
    "temp": (-40, 60),
}

for col, (low, high) in valid_range.items():
    out_of_range = (df[col] < low) | (df[col] > high)
    print(col, "越界条数:", out_of_range.sum())
    df.loc[out_of_range, col] = np.nan

除了量程,还可以用统计方法辅助发现异常,例如按站点计算 pm25 的 99.5% 分位数,或使用“超过均值 ± 3 倍标准差”的规则。但统计规则只能作为提示,最终是否判为异常要结合量程和业务常识,避免把真实的污染过程误删。

五、缺失值处理:插补还是剔除

缺失处理取决于缺失比例和用途。若某站点某指标缺失率超过 30%,通常不建议强行插补,应标记该时段数据不可用;短时缺口(如 1~3 小时)可用时间序列线性插值。pandas 中按站点分组插值可以避免跨站点“借值”:

df = df.sort_values(["station", "ts"])

# 统计缺失率
missing_rate = df[num_cols].isna().mean().round(3)
print(missing_rate)

# 按站点做时间插值,限制最多连续插补 3 个点
df[num_cols] = (df.groupby("station")[num_cols]
                  .apply(lambda g: g.interpolate(method="linear", limit=3))
                  .reset_index(level=0, drop=True))

# 仍缺失的(长缺口)保留 NaN,不伪造数据
df = df.dropna(subset=["ts"])

这里要强调一个原则:插补是“估计”,不是“观测”。清洗后的数据集应保留一列标记,说明哪些值是原始观测、哪些是插补值,便于后续分析时区分。可以用 df["pm25_imputed"] = df["pm25"].isna() 在插补前记录标记。

六、输出与质量校验

清洗完成后不要急着保存,先做一次校验,确认关键指标达到预期:

校验项检查方法示例期望结果
数值列类型df.dtypespm25/pm10/so2/temp 为 float64
时间列类型df["ts"].dtypedatetime64[ns]
重复记录df.duplicated(["station","ts"]).sum()0
越界值按量程重新统计0(已置 NaN 或剔除)
缺失率df[num_cols].isna().mean()各列低于设定阈值
时间连续性相邻 ts 差值是否为 1 小时缺口数量可解释
df.to_csv("air_clean.csv", index=False, encoding="utf-8-sig")
print(df.shape, df["ts"].min(), df["ts"].max())

使用 utf-8-sig 编码可以让 Excel 直接打开中文列名不乱码。最终交付的 air_clean.csv 应满足:时间列为 datetime、数值列为 float、无重复主键、越界值已处理、缺失情况有记录。至此,原始监测数据才真正变成可以进入统计与可视化环节的可信数据集。