NavyasriAmand/stream-drift-sentinel

GitHub: NavyasriAmand/stream-drift-sentinel

一个基于误报预算自动校准 PSI 阈值的实时特征漂移检测器,解决传统经验阈值不可靠且检测延迟高的问题。

Stars: 0 | Forks: 0

# stream-drift-sentinel **实时特征漂移检测,其阈值根据设定的误报预算进行校准,相比标准的 PSI 经验法则,在 0.5 sigma 偏移上的检测延迟降低了 40%。** ![CI](https://static.pigsec.cn/wp-content/uploads/repos/cas/99/993938d8ce5e902ccfb9d6747725c320d855dea3235ed9a304cedf0d94c9321f.svg) ![覆盖率](https://img.shields.io/badge/coverage-88%25-green) ![许可证](https://img.shields.io/badge/license-MIT-blue) ![检测延迟](https://img.shields.io/badge/detection%20lag-749%20samples-informational) ## 解决的问题 - 漂移阈值往往是从教程中照搬过来的(如 PSI 0.1, PSI 0.25),没人能说清它们在正常流量下的触发频率。本项目则根据你选定的预算,从你自身的平稳数据中推导阈值,并报告实际测量到的频率:1.27% 的窗口被标记,零次确凿告警。 - 宽松的阈值检测往往滞后。在 0.5 sigma 的均值偏移下,校准后的阈值在偏移发生后 749 个样本点即发出告警,而 PSI 0.25 需要 1249 个样本点,延迟降低了 40%。 - 如果校准使用的数据中悄无声息地包含了周期性波动,会导致阈值虚高并让检测器变成“瞎子”,且毫无察觉。平稳性防护机制会拒绝此类参考数据,而不是返回一个看似正常的数据。 ## 开发初衷 每个在生产环境中运行模型的团队最终都会增加漂移监控,但大多数团队只是从博客文章中照搬两个数字。PSI 大于 0.1 被认为是中度漂移,大于 0.25 被认为是显著漂移。这些数字背后并没有零假设分布:PSI 的尺度取决于分箱数和窗口大小,因此在两个看起来都很合理的部署中,相同的阈值代表着完全不同的含义。结果就是没人信任这个监控。它如果从不触发,那就是个摆设;如果频繁触发导致告警被静音,那情况更糟,因为这种静音是隐形的。 stream-drift-sentinel 颠覆了这种设定。它不再凭感觉挑选阈值,而是将误报预算作为输入:切分已知干净的参考数据,在其中一半上进行拟合,从留出的另一半中提取数百个连续窗口,并取所得统计量的 (1 - 预算) 分位数。根据构造,任何高于该分位数的窗口都属于平稳噪声中排名前 `budget` 的部分。得出的阈值具有明确的实际意义,你可以直接向待命工程师解释。该系统在一个接口下运行了两个检测器:用 PSI 衡量效应大小(漂移有多大),用 KS 衡量可检测性(是否还能区分),因为在流式窗口大小下,KS 会标记出那些太小以至于根本无法影响模型的差异,而效应大小正是对此的制衡。 在内置的固定种子数据流上,1% 的预算产生了 0.0197 的 PSI 阈值,相对于传闻中的 0.25 收紧了十二倍以上。这个更严格的阈值在 0.5 sigma 均值偏移发生后 749 个样本即检测到漂移,而 PSI 0.25 需要 1249 个样本;同时它仅标记了 1.27% 的平稳窗口,而连续确认规则将这些标记减少为零次确凿告警。在单容器、无 GPU 的环境下,测得的回放吞吐量为每秒 135,000 到 163,000 个样本。 ## 架构 ``` flowchart LR subgraph src [Sources] K[Kafka topic] F[Recorded stream JSONL] end subgraph cal [Calibration, offline] REF[Reference sample] SG{Stationarity guard} Q[Quantile of stationary statistics] end subgraph run [Runner, per stride] W[Sliding window] P[PSI: effect size] KS[KS: detectability] C{Consecutive confirmations} end REF --> SG SG -- cyclical or trending: exit 2 --> X[refuse to calibrate] SG --> Q --> P K --> W F --> W W --> P W --> KS P --> C KS --> C C -- confirmed --> A[Alert plus Prometheus counter] C -- single flag --> D[Discard, reset streak] ``` 两个边界承载了核心设计。平稳性防护位于校准之前,因为基于波动数据推导出的阈值比没有阈值更糟。确认规则位于检测之后,因为滑动窗口存在重叠,所以从构造上看,一个异常窗口往往会连续触发多次。 ## 技术栈 | 技术 | 在本项目中的作用 | 选择理由 | |---|---|---| | Python 3.11+ | 全局基础 | 单一语言仓库,与部署目标匹配 | | SciPy stats | KS 双样本检验,平稳性检查 | 零假设分布的参考实现,避免重写带来的风险 | | NumPy | 分箱、分位数、自相关 | 向量化窗口评分使得校准成本极低,足以支撑按部署进行运行 | | Kafka (kafka-python) | 实时流数据源 | 她的生产环境数据流是 Kafka;同一个运行器既可消费 topic 也可消费录制文件 | | Prometheus client | 指标发布 | 导出阈值的同时也导出统计量,因此仪表盘不再需要硬编码常量 | | Grafana | 可视化 | Prometheus endpoint 的标准消费者;通过 compose 文件进行配置集成 | | pytest | 测试套件,55 个测试 | 统计代码往往存在隐性失效,因此需要通过测试而非肉眼检查来锁定行为 | | GitHub Actions | CI | 断言检测器的行为,而不仅仅是验证代码能否导入(见下文) | ## 快速开始 前置条件:Python 3.11+,或使用 Docker 运行完整的 Kafka 技术栈。 ``` git clone https://github.com/NavyasriAmand/stream-drift-sentinel.git cd stream-drift-sentinel pip install -e ".[dev]" # 从平稳数据中推导出针对 1 percent budget 的阈值 drift-sentinel calibrate --stream data/streams/none.jsonl # 重放已记录的 stream;exit 1 意味着触发了已确认的警报 drift-sentinel replay --stream data/streams/sudden.jsonl # guard 拒绝包含 cycle 的 reference(exit 2) drift-sentinel calibrate --stream data/streams/seasonal.jsonl # 测试和基准测试 pytest --cov=stream_drift_sentinel python benchmark/detection_lag.py && python benchmark/false_positive_rate.py python benchmark/summarize.py # 完整技术栈:Kafka、sentinel、Prometheus、Grafana docker compose up -d ``` 在你自己的数据流上使用:将样本导出为带有 `value` 字段的 JSONL 文件,将 `calibrate` 指向你确认干净的时段,然后针对捕获的流量运行 `replay`,或针对实时 topic 运行 `consume`。退出码专为 CI 设计:0 代表正常静默,1 代表确凿告警,2 代表数据或配置问题。 ## 实测行为 配置:窗口 1000,步长 250,连续确认 2,预算 0.01,参考样本数 10,000。 | 模式 | 漂移起点 | 平稳性检查 | PSI 阈值 | PSI 延迟 | KS 延迟 | 告警次数 | 吞吐量 (samples/s) | |---|---|---|---|---|---|---|---| | 无 | 无 | 通过 | 0.019739 | 不适用 | 不适用 | 0 | 162,724 | | 季节性 | 无 | 拒绝 | 0.297911 | 不适用 | 不适用 | 8 | 135,491 | | 突变 | 20000 | 通过 | 0.019739 | 499 | 499 | 2 | 153,197 | | 渐变 | 20000 | 通过 | 0.019739 | 4249 | 4249 | 3 | 153,648 | | 方差 | 20000 | 通过 | 0.019739 | 749 | 749 | 2 | 148,180 | 客观解读上述数据: - **无** 是对照组。在 157 次评估中零告警,这一结果是让其他所有数据行都具备可信度的基础。 - **季节性** 是一个值得关注的失败案例。防护机制正确地拒绝在其上进行校准。显示的 0.297911 阈值是禁用防护机制后产生的,这正是下文实战故事中描述的 Bug。那 8 次告警是 KS 检测对合理周期性波动的触发,这正反映了固定 alpha 在具有周期性规律的现实世界中会发生什么。 - **渐变** 相比突变 499 的延迟,其延迟达到了 4249 个样本。渐变确实更难检测:滑动窗口会追逐趋势,导致窗口均值在很长一段时间内都接近参考均值。这是基于窗口的检测方法的真实局限性,而不是参数调优的问题。 - **方差** 证明了分布检验有其不可替代的价值。均值从未移动,只有数据分布范围发生了变化。如果仅使用均值比较监控器,将检测不到任何异常。 ### 误报行为 平稳数据流,157 次评估。根据设定,此处的每一次告警都属于误报。 | 阈值 | 标记窗口数 | 标记率 | 确凿告警数 | 预期告警/天 | |---|---|---|---|---| | psi @ 0.25 (经验法则) | 0 | 0.0% | 0 | 0.0 | | psi @ 0.10 (经验法则) | 0 | 0.0% | 0 | 0.0 | | psi @ 0.0197 (校准) | 2 | 1.27% | 0 | 0.0 | | ks @ alpha=0.01 | 1 | 0.64% | 0 | 0.0 | 单看这个表格其实美化了那些经验法则阈值,因为从不触发的阈值确实拥有完美的误报率,但也毫无用处。在同样的阈值下测试灵敏度(0.5 sigma 均值偏移): | 阈值 | 是否检测到 | 延迟 (样本数) | |---|---|---| | psi @ 0.25 (经验法则) | 是 | 1249 | | psi @ 0.10 (经验法则) | 是 | 999 | | psi @ 0.0197 (校准) | 是 | 749 | 综合来看:相比 PSI 0.25,校准将检测速度提升了 40%,而其付出的 1.27% 标记率的代价则被确认规则完全吸收。实测标记率接近 1% 的预算值,这正是校准机制有效运转的证据。延迟以样本数而非秒来报告,因为这是算法本身的属性;用其除以流速率即可转换为实际时间。 ## CI 关注行为,而不仅仅是导入是否成功 该工作流断言了三项单纯通过测试套件无法发现的问题: 1. 在平稳数据流上运行 `replay` 必须返回退出码 0。如果某次改动导致检测器变得大惊小怪,将直接导致构建失败,而不是让运维人员的寻呼机半夜响个不停。 2. 在突变数据流上运行 `replay` 必须返回退出码 1。如果某次改动导致检测器变“瞎”,同样会导致构建失败,这种隐性失效往往代价最为惨重。 3. 在季节性数据流上运行 `calibrate` 必须返回退出码 2,以确保平稳性防护机制切实生效。 数据流文件被提交并在 CI 中重新生成,通过逐字节的 diff 进行比对,因此上述断言都锚定在固定的数据上。 ## 架构决策 - [ADR-0001:采用校准阈值而非公开的经验法则](docs/adr/0001-calibrated-thresholds.md) - [ADR-0002:采用经典双样本检验而非基于学习的漂移检测器](docs/adr/0002-classical-tests.md)(一个略显保守的选择,但也明确指出了其盲点) ## 有意排除的范围 多变量漂移检测:即每个特征的边缘分布保持稳定,但它们之间的联合结构发生变化的情况。本项目监控的是边缘分布,因此这类漂移无法被检测到,ADR-0002 直言不讳地指出了这一点,而不是遮遮掩掩。增加该功能的触发条件应当基于实际业务需求而非美好愿景:当模型性能下降,而所有单变量检测器都毫无反应时,相关性偏移就成了首要假设,此时会在同一个 `DriftDetector` 接口下引入基于 domain-classifier 的检测器。同样被推迟的还有:针对特征的 Bonferroni 式校正,当特征数量达到数十个时,这一功能才会显得重要。 ## 安全与合规 ## 失效模式 | 失效场景 | 检测方式 | 系统行为 | 恢复方法 | |---|---|---|---| | 参考期包含周期或趋势 | 平稳性防护:分段 KS D 统计量和自相关 z 分数 | 拒绝校准,返回退出码 2,提示信息指明失败的检查项 | 选择已知干净的时期,或者在知情的情况下手动传入 `--allow-non-stationary` | | 特征包含 NaN 或 inf | 评分前的输入验证 | 直接抛出异常而不是静默丢弃,因为丢弃会改变待检分布 | 在上游刻意进行插补或过滤 | | 特征为常量或接近常量 | 等频分箱坍缩为少于两个箱 | 抛出异常,提示该特征计算 PSI 毫无意义 | 将该特征移出监控范围,或改用类别检验 | | 参考数据过小,无法校准 | 显式的最小样本量检查 | 抛出异常并指出所需的样本数量 | 收集更多参考数据,或者缩小窗口 | | Kafka broker 无法连接 | 启动时的消费者连接错误 | 快速失败并记录 broker 列表日志 | 修复网络连接;在修复期间可使用回放模式利用已捕获的数据填补空缺 | | 单个噪声窗口 | 连续确认规则 | 记录并导出标记,但不生成告警,在下一个干净窗口到来时重置计数 | 无需处理;这正是系统的设计行为 | | 真实漂移 | 连续两个窗口被标记 | 以结构化 JSON 记录告警,Prometheus 计数器递增,`replay` 退出码为 1 | 调查被标记特征的分布箱;阈值和统计量均已被导出 | ##的最棘手问题 基准测试中的季节性行那行数据曾以一种“看似正确”的方式表现错误。在该数据流上进行校准得出了 0.2979 的 PSI 阈值,而平稳数据上仅为 0.0197,阈值被虚高了十五倍。系统没有崩溃,测试也没有报错。检查该阈值在真实漂移下的表现:0.5 sigma 的均值变化得出的 PSI 得分为 0.117,远低于 0.2979,因此它永远不会触发。这个本意是为了让检测器值得信赖的校准步骤,却在暗中使其变成了“瞎子”。 根本原因在于一个被固化在参数名中的假设。`calibrate()` 接受一个名为 `stationary` 的参数,但从未核实过数据是否真的平稳。从周期性数据中提取的校准窗口继承了周期本身的波动性,导致统计量的分布跨度膨胀,从中提取的分位数也因此高得离谱。解决方法是在校准之前增加一个检查该假设是否成立的防护机制([commit 7a92bec](https://github.com/NavyasriAmand/stream-drift-sentinel/commit/7a92bec))。 编写防护机制的过程引出了一个更有趣的错误。第一版使用 KS 检验比较参考数据的前后两半,并在 p 值低于 1e-3 时拒绝。测试套件立刻捕捉到了两个问题。首先,它无法检测出在整个半区内完成整数个周期的循环,因为两半数据此时具有相同的边缘分布,仅仅是排列顺序不同。其次,它在 p 值为 8.9e-05 时拒绝了纯白噪声序列,因为当样本量 n 达到数万时,KS 检验的 p 值具有极强的功效,以至于普通的采样噪声都能轻松越过任何合理的 alpha 临界值。这正是本项目旨在警示的典型病态现象,却在旨在防范它的防护机制中重现了。最终发布的版本采用了两项基于效应大小的标准:使用四段式 KS D 统计量检测趋势和状态变化,以及使用经过样本量标准化的自相关 z 分数检测那些分布检验无法察觉的、相位对齐的周期。临界值是经过实测的,而非凭空猜测的——涵盖了 12 个平稳样本(D 从 0.009 到 0.037,z 从 2.55 到 3.87)和 4 个非平稳样本(D 从 0.019 到 0.203,z 从 7.94 到 13.19)。相位对齐的周期正是 KS D 统计量失效而自相关方法奏效的确切实例,这也是为什么这两种检验必须同时提供。 ## 未来工作 - 结合族错误率校正(family-wise error correction)的特征级监控,这是达到生产规模后首要的需求。 - 基于类别比例卡方检验的类别漂移检测,复用现有的校准和确认机制。 - 增加 `--explain` 参数,在触发告警时打印贡献度最高的分布箱,让一串冷冰冰的数字变成两分钟就能搞清原因的调查。 - 结合人工显式审批机制的自适应参考窗口,从而在不完全禁用检测的前提下,合理吸纳合法的业务模式更迭。 - 部署后首要关注的指标:被标记窗口与确凿告警的比例。如果该比例攀升,说明确认规则正在掩盖真实信号,需要重新评估窗口大小或步长。
标签:Apex, MLOps, 安全规则引擎, 数据漂移检测, 机器学习, 模型监控, 统计分析, 自定义请求头, 请求拦截, 逆向工具