本节摘要:第 1.2 节的三重风险里,输入漂移最隐蔽:代码一行没改、模型一个字没动,用户群的构成变了,质量就悄悄滑走——而业务指标(客诉率、负反馈率)是滞后信号,等它报警往往已过数周。对冲办法是监控输入与中间量的分布:置信度、场景标签、输入长度、分桶命中率——它们是漂移的先导指标。度量工具用 PSI(Population Stability Index,群体稳定性指数):PSI = Σ(实际占比 − 预期占比) × ln(实际占比 / 预期占比),把"本周分布"与"基线分布"在分桶后逐桶对比,压缩成一个数。风控业通行口径(社区):<0.1 稳定、0.1~0.25 观察、>0.25 显著漂移——立即触发抽样人工与回归集复跑。工程要点有三:数值型变量用等频分桶(基线分位数定桶边界,避免空桶与长尾失衡);零桶用小常数平滑(ln(0) 会炸);漂移检测要多维同时看(单看均值会被"左右互搏"的分布变形骗过)。本节附完整 PSI 实现与周度体检报告脚本(MOCK 可跑),并给出每周的固定动作清单。
阅读完本节,你应当能够:
监控对象选输入与中间量的分布而非业务指标,理由是时滞:
| 信号 | 类型 | 典型时滞 |
|---|---|---|
| 客诉率、负反馈率 | 业务结果 | 滞后:用户受损在先,反馈在后(数天~数周) |
| 场景占比、输入长度分布 | 输入侧 | 先导:新用户群进来的当周就变 |
| 置信度分布、分桶命中率 | 模型侧行为 | 先导:输入变了,模型行为立刻变(第 4.2 节低置信占比告警同源) |
PSI 的直觉:对每个桶算"实际占比相对预期占比的倍数"取对数,再乘占比差加权——占比不变的桶贡献为 0;质量从 A 桶大规模迁往 B 桶时,两桶各贡献一大块,加总放大。手算例(示意):基线 {高置信 80%, 低置信 20%},本周 {高置信 60%, 低置信 40%}:
PSI = (0.6−0.8)×ln(0.6/0.8) + (0.4−0.2)×ln(0.4/0.2) = (−0.2)×(−0.288) + (0.2)×(0.693) ≈ 0.196 → 落入"观察"档(0.1~0.25)
# psi_monitor.py(纯标准库,可直接运行) """PSI 漂移检测:等频分桶 + 平滑 + 多维周报。基线来自上线时的稳定周期。""" import math import random def equal_freq_edges(baseline: list[float], bins: int = 10) -> list[float]: """等频分桶:边界取基线的分位数,保证基线每桶样本量近似相等。""" s = sorted(baseline) return [s[min(len(s) - 1, int(len(s) * k / bins))] for k in range(1, bins)] def bucketize(values: list[float], edges: list[float]) -> list[int]: """按边界把值分桶(返回桶号 0..bins)。""" return [sum(v > e for e in edges) for v in values] def psi(expected: list[float], actual: list[float], bins: int = 10, smooth: float = 1e-4) -> tuple[float, list[tuple[int, float, float]]]: """返回 (PSI, [(桶号, 基线占比, 实际占比)])。数值型输入自动等频分桶。""" edges = equal_freq_edges(expected, bins) e_bins = bucketize(expected, edges) a_bins = bucketize(actual, edges) n_e, n_a = len(e_bins), len(a_bins) detail, total = [], 0.0 for b in range(bins + 1): pe = max((e_bins.count(b) / n_e), smooth) # 平滑:零桶防 ln 爆炸 pa = max((a_bins.count(b) / n_a), smooth) total += (pa - pe) * math.log(pa / pe) detail.append((b, e_bins.count(b) / n_e, a_bins.count(b) / n_a)) return total, detail def classify(value: float) -> str: """三级口径(风控通行,社区经验)。""" if value < 0.10: return "稳定" return "观察" if value < 0.25 else "★漂移★" def psi_for_categorical(base: dict, cur: dict, smooth: float = 1e-4) -> float: """分类变量(如场景标签)的 PSI:直接按类别占比算。""" keys = set(base) | set(cur) nb, nc = sum(base.values()), sum(cur.values()) return sum((cur.get(k, 0) / nc - base.get(k, 0) / nb) * math.log(max(cur.get(k, 0) / nc, smooth) / max(base.get(k, 0) / nb, smooth)) for k in keys) if __name__ == "__main__": rng = random.Random(7) # 基线:上线月(示意)。三个被监控量:置信度 / 输入长度 / 场景占比 base_conf = [rng.betavariate(9, 2) for _ in range(5000)] base_len = [rng.gauss(180, 60) for _ in range(5000)] base_scn = {"billing": 4000, "technical": 800, "security": 200} weeks = { "W1": (lambda: [rng.betavariate(9, 2) for _ in range(2000)], lambda: [rng.gauss(182, 61) for _ in range(2000)], {"billing": 1560, "technical": 370, "security": 70}), "W2": (lambda: [rng.betavariate(8.2, 2.5) for _ in range(2000)], # 置信轻度下移 lambda: [rng.gauss(179, 58) for _ in range(2000)], {"billing": 1490, "technical": 440, "security": 70}), "W3": (lambda: [rng.betavariate(4, 4) for _ in range(2000)], # 大幅下移 lambda: [rng.gauss(240, 90) for _ in range(2000)], # 输入变长 {"billing": 900, "technical": 1000, "security": 100}), # 场景迁移 } print("周度体检:<0.10 稳定 | 0.10~0.25 观察 | >0.25 ★漂移★") for name, (conf_f, len_f, scn) in weeks.items(): v1, _ = psi(base_conf, conf_f()) v2, _ = psi(base_len, len_f()) v3 = psi_for_categorical(base_scn, scn) print(f"{name} 置信度 PSI={v1:.2f} [{classify(v1)}]" f" 输入长度 PSI={v2:.2f} [{classify(v2)}]" f" 场景占比 PSI={v3:.2f} [{classify(v3)}]")
运行输出(实测,本机 Python 3.12):W1 三项全部"稳定"(各 PSI < 0.1);W2 置信度 PSI ≈ 0.18 进入"观察"(长度、场景仍稳定);W3 三项齐报"★漂移★"(置信度 PSI > 3、长度 0.6+、场景 0.5+)——一条随周次逐步升级的漂移时间线,正是输入漂移"慢刀子"的典型形态。
max(占比, 1e-4) 一行防御 ln(0);新类别出现(场景表里多了个键)本身就是漂移信号,别把它平滑没了——分类 PSI 前先 diff 键集合;周一上午:跑 PSI 矩阵(脚本定时任务,数据来自 6.2 的 trace 或日志抽样) ├─ 全绿(<0.10) → 归档,无动作 ├─ 观察(0.10~0.25) → 周四评审会过一遍:抽样 50 条人工快看 │ 连续两周同向观察 → 升级处理 └─ ★漂移★(>0.25) → 当日动作: ① 分桶下钻:哪个桶在涨/跌(脚本 detail 列表) ② 抽样 100 条人工评估质量是否受损 ③ 回归集在新分布上复跑(第 3 章考卷验证) ④ 受损 → 触发第 8.3 节回退/重训流程;未受损 → 重立基线并记录
💡 把 PSI 报告并进第 8.2 节监控面板的"漂移层":它不是独立报表,而是告警体系的一个数据源——>0.25 的判级直接映射到告警分级表。
漂移检测守住"上线之后",但它只是报警器。报警响了谁来做决定、怎么切流量、多快能回滚——下一章把线上评测与监控的全套防线补齐:影子模式与 A/B、监控面板与告警、回退预案。