数据截至 (上游 commit a4aa4c2b37fe)
数据漂移与统计检验:分布怎么比、20+ stattest 怎么组织
30 秒导读: 模型上线后,线上数据会慢慢"变味"——今天进来的特征分布,和当初训练/评估时的分布不再一样,这叫数据漂移(data drift)。Evidently 检测漂移的做法极其朴素:把"现在的分布"(current)和"基准分布"(reference)放一起比一比,差异超过阈值就报 drift。难点全在"怎么比":数值列、类别列、文本列各有各的比法。Evidently 把 20 多种"比法"做成一张可插拔的注册表——每个统计检验(statistical test,简称 stattest)都是一个统一签名的小函数,返回
(距离/差异值, 是否漂移)。本章讲清这个统计内核:注册表机制、几个代表性检验的公式直觉,以及上层的漂移 metric / preset 如何"选检验、逐列跑、汇总成漂移列占比"。
本章聚焦统计检验这一类计算的内核与选择逻辑。metric / preset 的通用引擎机制(Metric 类型、Context 缓存、Report→Snapshot)已在 02-metrics-engine.md 讲透,这里只补"漂移这类计算里,统计检验是怎么被选中、被调用、被聚合的"。
1. 这是什么(零基础也能懂)
1.1 一句话定义
漂移检测 = 分布对比 + 阈值判定。 你手里有两批同一列的数据:
- reference(基准/参照):通常是训练集、或某个"正常时期"的数据快照。
- current(当前):线上刚收到的新一批数据。
把这两批数据的分布画出来叠在一起,如果形状差得够远(超过某个阈值),就说这一列"漂了"。
1.2 为什么要管它
模型是在 reference 分布上学出来的。一旦线上 current 分布偏离得太多——用户结构变了、上游数据管道改了口径、季节性来了——模型的预测就可能悄悄失准,而准确率指标往往要等真实标签回流才看得到。漂移检测是不需要标签的早期预警:光看输入/输出的分布变化,就能提前拉响警报。
1.3 用起来什么样
Evidently 的招牌用法就是一个 DataDriftPreset,method="psi" 指定用 PSI 这种比法(真实示例见 README.md:162-168):
from evidently import Report
from evidently.presets import DataDriftPreset
report = Report([
DataDriftPreset(method="psi") # 所有列都用 PSI 检验
], include_tests="True")
my_eval = report.run(iris_frame.iloc[:60], iris_frame.iloc[60:]) # (current, reference)
my_eval
跑完你会得到:每一列的漂移分数 + 判定,以及一个总览——"多少列漂了 / 占比多少 / 整个数据集算不算漂移"。
1.4 一句话直觉
把 stattest 想成一台"分布差异计"。 你把两条分布喂进去,它吐出一个数(差异有多大)和一个红绿灯(超没超阈值)。Evidently 备了 20 多台不同原理的差异计,你按名字("psi"、"ks"、"jensenshannon"……)挑一台,或者让它按数据类型自动挑。
2. 顶层全景(它大概怎么转)
一次漂移检测,数据是这样流动的(从上到下,以单列为主线):
preset / metric 层 (core, 新引擎)
┌───────────────────────────────────────────────┐
│ DataDriftPreset ──展开──▶ 每列一个 ValueDrift │
│ └──────▶ DriftedColumnsCount │ ← 汇总"漂了几列"
│ presets/drift.py metrics/column_statistics.py │
└───────────────────────────────────────────────┘
│ 调用(桥接到 legacy)
▼
单列漂移计算 get_one_column_drift() ← legacy/calculations/data_drift.py
┌───────────────────────────────────────────────┐
│ 1. 选检验 get_stattest(ref, cur, 类型, 名字) │
│ 2. 跑检验 drift_test_function(ref, cur, ...) │
│ 3. 拿结果 (drift_score, drifted, threshold) │
└───────────────────────────────────────────────┘
│ get_stattest 返回一个
▼
stattest 注册表 registry.py
┌───────────────────────────────────────────────┐
│ 名字 "psi" ─▶ StatTest 对象 ─▶ 实现函数 _psi │
│ 名字 "ks" ─▶ StatTest 对象 ─▶ 实现函数 ... │
│ ...20+ 个,每个是一个 .py 文件,自注册进表 │
└───────────────────────────────────────────────┘
各部件一句话职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
DataDriftPreset | 招牌入口:一次给所有列配好漂移检测,展开成"总览 + 逐列" metric | src/evidently/presets/drift.py:24 |
ValueDrift | 单列漂移 metric(core 新引擎侧) | src/evidently/metrics/column_statistics.py:530 |
DriftedColumnsCount | 汇总 metric:数几列漂了、占比多少 | src/evidently/metrics/column_statistics.py:748 |
get_one_column_drift | 单列漂移的真正计算:选检验→跑→出结果 | src/evidently/legacy/calculations/data_drift.py:102 |
StatTest / register_stattest | 检验的统一封装 + 注册机制 | src/evidently/legacy/calculations/stattests/registry.py:33,129 |
各 *_stattest.py | 每个检验一个文件,含公式 + 自注册 | src/evidently/legacy/calculations/stattests/ |
注意"两套引擎"这条暗线: 招牌能力"分布漂移"主要落在 legacy 层(src/evidently/legacy/calculations/),core 的新 metric(ValueDrift、DriftedColumnsCount)其实是桥接器——把参数打包成 DataDriftOptions,再调 legacy 的 get_one_column_drift。所以本章的统计内核几乎全在 legacy/calculations/stattests/。
3. 核心机制一:stattest 注册表(可插拔的"分布差异计")
3.1 它要解决的小问题
有 20 多种检验,还允许用户塞自己写的。怎么让"选一个检验"这件事既能按名字("psi")、又能按对象、又能传自定义函数,还不把 if-else 写满一屏?答案:注册表 + 统一签名。
3.2 统一签名:每个检验长一个样
所有检验函数都遵守同一个类型(registry.py:22-23):
StatTestFuncReturns = Tuple[float, bool] # (差异值, 是否漂移)
StatTestFuncType = Callable[[pd.Series, pd.Series, ColumnType, float], # (ref, cur, 列类型, 阈值)
StatTestFuncReturns]
一句话:每个检验就是"吃两条数据 + 列类型 + 阈值,吐出 (差异值, 超没超阈值)"。 这个 (float, bool) 的约定是整章的地基——不管背后是 KS 检验的 p 值、还是 PSI 的距离,对外都被抹平成同一副面孔。
3.3 StatTest:给函数套一层元数据
裸函数不够——还得知道它叫什么、能用于哪些列类型、默认阈值多少。这就是 StatTest 这个 dataclass(registry.py:33-54):
@dataclasses.dataclass
class StatTest:
name: str # "psi"
display_name: str # "PSI"(展示用)
allowed_feature_types: List[ColumnType]# 能用于数值/类别/文本
default_threshold: float = 0.05 # 不传阈值时的默认
它的 __call__(registry.py:40-54)才是对外统一入口:补上默认阈值 → 找到对应引擎的实现 → 调用 → 把 (drift_score, drifted) 包成 StatTestResult(带上 actual_threshold,方便上层展示"用了哪个阈值")。
3.4 注册:每个检验文件自己"上架"
打开任意一个检验文件,结尾都是同一套两步(以 PSI 为例,psi.py:59-66):
psi_stat_test = StatTest( # 1. 造一个 StatTest 元数据对象
name="psi",
display_name="PSI",
allowed_feature_types=[ColumnType.Categorical, ColumnType.Numerical],
default_threshold=0.1,
)
register_stattest(psi_stat_test, _psi) # 2. 把它和实现函数 _psi 注册进表
register_stattest(registry.py:129-134)往三张全局表里各填一格:
| 全局表 | 键 → 值 | 用途 |
|---|---|---|
_registered_stat_tests | 名字 → {列类型: StatTest} | 按名字/类型查检验 |
_impls | StatTest → {引擎: 实现} | 按引擎找具体实现(留了多引擎扩展位) |
_registered_stat_test_funcs | 实现函数 → 名字 | 反查:传进来的裸函数是不是已注册的 |
这三张表加上 stattests/__init__.py:5-29 里"import 即注册"的副作用(一 import 这个包,20 多个检验就全部自注册好了),构成了整张注册表。