4. 完整性监控:空值检测、缺失率计算、缺失模式分析、阈值设定与告警

完整性,说白了就是看数据有没有「窟窿」。
一个特征列里如果空值太多,模型学到的就是残缺的世界观。我见过不少项目,上线后效果崩盘,追根溯源就是某个关键特征的缺失率在线上突然飙升,而监控系统根本没发现。

这一章,我们就把完整性监控的四个环节拆开揉碎:怎么检测空值、怎么算缺失率、怎么分析缺失模式、怎么设阈值告警。

4.1 空值检测:别只盯着 None

很多人以为空值就是 None 或 NaN。其实不然。
我在项目中遇到过一种情况:某个字段存的是字符串 "null",还有存 "N/A"、"Unknown"、甚至全角空格 " " 的。这些在业务上都是「空」,但在代码里它们不是 None。

避坑指南:我曾经因为只检查 isnull(),漏掉了字符串形式的空值,导致缺失率被严重低估。后来我养成了一个习惯——先做空值归一化。

一个比较稳妥的做法是:

import pandas as pd
import numpy as np

def normalize_null(df, column, extra_null_values=None):
    """将各种形式的空值统一转为 np.nan"""
    null_set = {None, np.nan, 'null', 'NULL', 'Null',
                'nan', 'NaN', 'N/A', 'n/a', '', ' ', ' '}
    if extra_null_values:
        null_set.update(extra_null_values)
    
    df[column] = df[column].replace(null_set, np.nan)
    return df

嗯,这里要注意:空值检测的第一步不是算比例,而是先清洗空值定义。否则你算出来的缺失率,可能连你自己都不信。

4.2 缺失率计算:分维度、分窗口

缺失率不是算一个全局数字就完事了。我习惯把它拆成三个维度:

维度 计算方式 典型用途
全局缺失率 空值数 / 总记录数 特征级健康度概览
分组缺失率 按某维度(如日期、渠道)分组后计算 发现局部异常
滑动窗口缺失率 按时间窗口(如最近1小时)滚动计算 实时监控趋势

举个例子,假设我们监控用户画像中的「年龄」字段:

def compute_missing_rate(df, column, group_by=None, window=None):
    if group_by:
        return df.groupby(group_by)[column].apply(
            lambda x: x.isna().mean()
        )
    if window:
        return df[column].isna().rolling(window).mean()
    return df[column].isna().mean()

# 按日期分组看缺失率
print(compute_missing_rate(df, 'age', group_by='date'))

# 最近1000条记录的滑动缺失率
print(compute_missing_rate(df, 'age', window=1000))

你想想看,如果只看全局缺失率是 5%,但某个渠道的缺失率突然跳到 30%,全局数字根本看不出来。这就是分组缺失率的价值。

4.3 缺失模式分析:数据不是随机消失的

数据缺失通常有三种模式。我刚开始做数据质量时,以为缺失都是随机的,后来被现实狠狠教育了。

三种缺失模式:
  • MCAR(完全随机缺失):缺失与任何变量无关。比如传感器偶尔丢包。这种最好处理,但现实中很少见。
  • MAR(随机缺失):缺失与其他观测变量有关。比如高收入人群更不愿意填收入字段。
  • MNAR(非随机缺失):缺失与缺失值本身有关。比如温度传感器在温度过高时损坏,导致高温数据缺失。这种最危险。

怎么判断?我常用的方法是做缺失指示矩阵:

def missing_pattern_analysis(df, columns):
    """分析缺失模式:返回缺失共现矩阵"""
    missing_df = df[columns].isna().astype(int)
    # 计算两两列同时缺失的比例
    co_occur = missing_df.T @ missing_df / len(df)
    return co_occur

# 示例:看 age 和 income 是否经常一起缺失
pattern = missing_pattern_analysis(df, ['age', 'income', 'education'])
print(pattern.round(3))

如果两列的共缺失率很高,说明它们背后可能有同一个数据源问题。我曾经发现「家庭住址」和「邮编」的共缺失率高达 85%,后来查出来是某个上游接口在特定时段同时返回空——这就是 MAR 的典型场景。

小技巧:可以用热力图可视化缺失共现矩阵。颜色越深的地方,越值得深挖。

4.4 阈值设定与告警:别让告警变成噪音

阈值设得太松,问题漏报;设得太紧,告警刷屏,运维同学会直接把你拉黑。
我个人习惯用动态阈值,而不是拍脑袋定一个固定值。

具体做法分三步:

  1. 建立基线:取过去 7 天同一时段的数据,计算缺失率的均值和标准差。
  2. 设定告警线:均值 + 3σ 作为 warning,均值 + 5σ 作为 critical。
  3. 排除已知波动:比如每周一凌晨有例行维护,那段时间的缺失率波动要排除。
def dynamic_threshold(history_rates, new_rate):
    """基于历史数据计算动态阈值"""
    mean = np.mean(history_rates)
    std = np.std(history_rates)
    
    warning = mean + 3 * std
    critical = mean + 5 * std
    
    if new_rate >= critical:
        return 'CRITICAL'
    elif new_rate >= warning:
        return 'WARNING'
    else:
        return 'OK'

# 示例:过去7天每小时的缺失率
history = [0.02, 0.03, 0.01, 0.04, 0.02, 0.03, 0.02]
print(dynamic_threshold(history, 0.08))  # 大概率 WARNING 或 CRITICAL

告警触发后,还要带上上下文信息。我见过最差的告警就是一行「缺失率过高」,连哪个特征、哪个时间段都不说。好的告警应该包含:

  • 特征名称
  • 当前缺失率 vs 基线
  • 异常开始时间
  • 受影响的数据量
  • 建议排查方向
避坑指南:我曾经设过一个固定阈值 5%,结果某个特征在业务低谷期缺失率天然就高,告警天天响,最后被人当成「狼来了」。改用动态阈值后,误报率降了 70%。

4.5 完整性监控的整体流程

把上面四个环节串起来,就是一条完整的监控链路。我画了一张图,方便你理解:

完整性监控流程 空值检测 None / NaN / 字符串空 缺失率计算 全局 / 分组 / 滑动窗口 缺失模式分析 MCAR / MAR / MNAR 阈值设定 动态阈值 / 3σ / 5σ 告警触发 WARNING / CRITICAL 告警上下文 特征 / 时间 / 基线 / 建议 缺失模式分析结果影响阈值设定 MNAR 场景需降低告警灵敏度

从图上你能看到:空值检测是入口,缺失率计算是量化,缺失模式分析决定阈值策略,最后告警触发时一定要带上上下文。这四步缺一不可。

嗯,完整性监控做到这个程度,基本就能覆盖 90% 的缺失问题了。剩下的 10%,往往藏在业务逻辑里——比如某个字段在特定条件下「允许为空」,那就不该告警。这种场景,我们会在后面的章节里专门讲「业务规则适配」时再展开。