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 阈值设定与告警:别让告警变成噪音
阈值设得太松,问题漏报;设得太紧,告警刷屏,运维同学会直接把你拉黑。
我个人习惯用动态阈值,而不是拍脑袋定一个固定值。
具体做法分三步:
- 建立基线:取过去 7 天同一时段的数据,计算缺失率的均值和标准差。
- 设定告警线:均值 + 3σ 作为 warning,均值 + 5σ 作为 critical。
- 排除已知波动:比如每周一凌晨有例行维护,那段时间的缺失率波动要排除。
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 基线
- 异常开始时间
- 受影响的数据量
- 建议排查方向
4.5 完整性监控的整体流程
把上面四个环节串起来,就是一条完整的监控链路。我画了一张图,方便你理解:
从图上你能看到:空值检测是入口,缺失率计算是量化,缺失模式分析决定阈值策略,最后告警触发时一定要带上上下文。这四步缺一不可。
嗯,完整性监控做到这个程度,基本就能覆盖 90% 的缺失问题了。剩下的 10%,往往藏在业务逻辑里——比如某个字段在特定条件下「允许为空」,那就不该告警。这种场景,我们会在后面的章节里专门讲「业务规则适配」时再展开。