4. 缺失值识别与批量填充策略

在污染源监测数据的批量清洗中,缺失值处理往往是最先遇到、也最容易处理不当的环节。污染物浓度、流量、监测时间这三类字段的缺失模式差异很大:浓度缺失可能是仪器故障或低于检出限,流量缺失可能是工况停运或信号中断,时间缺失则往往意味着整条记录的时间归属不可信。如果对所有缺失一视同仁地填一个均值,后续的排放量核算、超标判定和趋势分析都会失真。本节围绕“先识别模式、再分类填充、最后留痕标记”的思路,给出可落地的批量处理方案。

一、先看清缺失的三种形态

在动手填充之前,必须先用代码把缺失的分布摸清楚。工程上通常区分三种形态:完全随机缺失(如某次传输丢包导致个别浓度值空缺)、随机缺失(如高浓度时段仪器饱和导致缺失,与数值大小相关)、非随机缺失(如停产期间流量恒为缺失,缺失本身携带“停运”信息)。前两种可以谨慎填充,第三种若强行填充反而会制造虚假排放。

识别时建议同时统计缺失率、连续缺失长度和缺失发生的时间段。下面是一段基于 pandas 的批量缺失画像代码,示例数据为某废水排放口 2024 年 3 月的小时均值记录:

import pandas as pd
import numpy as np

df = pd.read_csv("outlet_202403.csv", parse_dates=["monitor_time"])
df = df.sort_values("monitor_time").set_index("monitor_time")

fields = ["cod_mgL", "nh3n_mgL", "flow_m3h"]
report = []
for f in fields:
    s = df[f]
    miss = s.isna()
    # 连续缺失长度
    grp = (miss != miss.shift()).cumsum()
    run = miss.groupby(grp).sum().max() if miss.any() else 0
    report.append({
        "字段": f,
        "缺失率": round(miss.mean() * 100, 2),
        "最长连续缺失": int(run),
        "缺失时段数": int(miss.sum())
    })
print(pd.DataFrame(report))

假设输出显示 cod_mgL 缺失率 6.8%、最长连续缺失 5 小时,nh3n_mgL 缺失率 3.1%、最长连续 2 小时,flow_m3h 缺失率 11.4%、最长连续 26 小时。这个结果已经提示:流量存在超过一天的连续缺失,极可能是停运或仪表断电,不能简单用前后值插补;而浓度字段的短缺口更适合时间序列插值。

缺失模式识别 → 分类填充 → 标记留痕 ① 缺失画像 浓度:缺失率 6.8% 最长连续 5 h 氨氮:缺失率 3.1% 最长连续 2 h 流量:缺失率 11.4% 最长连续 26 h ⚠ 判断:短缺口可插补 长缺口疑为停运 缺失率 / 连续长度 / 时段 ② 分类填充策略 短缺口(≤3 h) 时间线性插值 limit=3, limit_direction=both 中缺口(4–6 h) 同工况时段中位数 按班次/生产状态分组 长缺口(>6 h) 不填充,标记停运/无效 保留 NaN 并写入标记列 阈值需结合采样频次设定 ③ 标记与留痕 fill_flag 列 0原始 1插值 2分组 3停运 fill_method 列 记录具体填充方法 原始值备份 raw_ 前缀列保留 下游核算只取 fill_flag ≤ 2 的记录 可追溯、可回滚

二、按缺口长度分档填充

结合上面的画像结果,可以设计分档规则。核心原则是:缺口越短,插值越可信;缺口越长,越应保留缺失并说明原因。以下实现以 3 小时和 6 小时为分界(示例阈值,实际应结合监测频次,如小时均值可取 3~6 小时,日均值则需另设):

# 先备份原始值
for f in fields:
    df[f"raw_{f}"] = df[f]

# 短缺口:时间线性插值,最多补 3 个连续点
df[fields] = df[fields].interpolate(
    method="time", limit=3, limit_direction="both"
)

# 中缺口:按生产班次分组取中位数(示例:班次列 shift)
for f in fields:
    med = df.groupby("shift")[f].transform("median")
    df[f] = df[f].fillna(med)

# 长缺口:不填充,仅标记
df["fill_flag"] = np.where(df[fields].isna().any(axis=1), 3, 0)

需要注意两个细节。其一,interpolate(method="time") 依赖索引为时间类型,若索引是普通整数,会退化为等距插值,导致时间间隔不均时结果偏差。其二,分组中位数填充必须保证分组变量本身没有缺失,否则该组中位数无法计算,应先用前向填充补齐班次标签或直接归入“未知班次”组。

三、时间字段的缺失要单独处理

监测时间缺失与浓度缺失性质不同:时间一旦缺失,该条记录就无法确定属于哪个时段,插值也无从谈起。实践中常见两种情形:一是时间列为空字符串或非法格式(如“2024/03/05 8:00”混用),二是时间重复或倒序。建议先用 pd.to_datetime(..., errors="coerce") 统一转换,转换失败的行单独导出为“时间异常待核”清单,不参与批量填充;对于仅缺少分钟秒的,可按整点补齐并标记 time_fixed=1。切勿用相邻记录的时间直接覆盖,否则可能把两条不同时刻的数据错误合并。

四、填充后的校验与标记落地

填充完成不等于处理结束。应至少做三项校验:填充前后各字段的均值、最大值变化是否在合理范围(例如浓度均值漂移超过 5% 需复核);fill_flag 的分布是否与缺失画像一致;被标记为停运的长缺口是否与生产台账的停运记录对得上。最终输出一张带标记的结果表,字段设计可参考下表:

字段含义示例值
monitor_time监测时间2024-03-05 08:00
cod_mgL填充后 COD 浓度42.6
raw_cod_mgL原始 COD 浓度NaN
fill_flag填充状态码1
fill_method填充方法time_interpolate
建议把 fill_flag 的取值约定固化为:0=原始有效、1=时间插值、2=分组中位数、3=停运/无效未填充、4=时间修复。下游统计脚本按此码过滤,避免把插补值当作实测值参与超标判定。
不要对流量字段使用全局均值填充。流量与生产工况强相关,停产时段的真实流量接近零,用全月均值填充会凭空放大排放量。对连续超过 6 小时的流量缺失,应保留 NaN 并关联停运标记,在排放量核算时按停运处理或单独说明。

把上述步骤串起来,就形成了一条“画像—分档—填充—标记—校验”的批量处理链。它的价值不在于把缺失率降到零,而在于让每一个被填补的值都有据可查、让每一个未填补的缺口都有明确解释,从而保证后续污染物排放核算与趋势分析建立在可信的数据基础之上。