统计与噪声带
判定内核的条件 DSL 回答「变化大不大」—— 但「大」是相对于什么的?两个数据点的对比没有噪声概念:基期恰好偏低, 任何回升都像显著。这页讲噪声带背后的统计器械:为什么是稳健统计(中位数与 MAD)、 为什么它用纯 stdlib 写成、为什么「算不出」绝不返回 0、为什么刻意不输出 p 值, 以及「位置分数(非概率)」这句限定语的由来。它是纯函数,零 LLM、零 I/O—— 结论永远能被重新算出来。
两个数据点的对比没有噪声概念
一条「变化超过 10% 就告警」的规则,默认了一个未经检验的前提:这个指标的日常波动远小于 10%。 对一个平稳的指标,这个前提成立;对一个本就在 ±15% 之间摆动的指标,这条规则每天都会告警—— 告警疲劳从此开始。反过来,若上调阈值到 30% 迁就波动,平稳的指标上又漏掉了真实的问题。
缺的不是阈值,是参照系:这个指标过去是什么样?噪声带就是这个参照系—— 把历史同粒度的块(月、周、日)当成样本,用它们的分布回答「当期值算不算异常」。 它把「多大算大」从拍脑袋的数字,换成一条有样本量、有方法、可复算的陈述。
块:噪声带的样本单位
噪声带由规则的 seasonal 声明定义,它不是第二个窗口,而是块规格——
「这条规则的历史基线由哪些块构成」:
| 参数 | 含义 | 为什么 |
|---|---|---|
grain |
块粒度:day / week / month;留空由窗口形状推导 |
粒度对齐是硬性的:声明与窗口形状不一致时拒绝确认——宁可不判,不判错 |
mode |
trailing(近期连续块)或 same_phase(去年同期,处理季节相位) |
「上个月 vs 去年同月」的对照系不同:一个吸收近期漂移,一个消除季节性 |
lookback |
历史块个数,默认 12 | 只计历史块——被测窗口不参与,混入会自我稀释(当期越异常,带被撑得越宽) |
k |
噪声带宽(稳健 z 单位),默认 3.5 | Iglewicz-Hoaglin 推荐的稳健离群阈值——不是拍的新数字,是文献里的惯例 |
flowchart LR
W["被测窗口
(本月)"]:::entry --> P["块规划 plan_blocks
粒度对齐 · trailing / same_phase"]:::exec
P --> S["块序列
至多 1 条 SQL · 按维对齐"]:::exec
S --> BD["噪声带 band
median ± k · 1.4826 · MAD"]:::exec
S --> EV["并列证据
bootstrap 区间 · n_eff · low_n"]:::exec
W --> G{"gate:当期值超出带?"}:::gate
BD --> G
G -->|"是"| OK["gated = true
附位置分数(非概率)"]:::entry
G -->|"否 / 判不了"| NO["gated = false
reason 区分「在带内」与「判不了」"]:::entry
classDef entry fill:#71717a14,stroke:#71717a59,stroke-width:1px;
classDef exec fill:#14b8a614,stroke:#14b8a699,stroke-width:1.5px;
classDef gate fill:#f59e0b14,stroke:#f59e0b99,stroke-width:1.5px;
linkStyle 6 stroke:#f59e0b,stroke-width:1.5px
判定侧的取数有三处刻意收紧
series_source.py:135 fetch_block_series:
按维分组仍然只花一条 SQL(逐维一条会随维值数量爆炸,而噪声带是「门」,预算纪律优先);
粒度对齐是硬性的(引擎侧序列是增强,宽松最多是附录错;判定侧序列是门的判据);
落不进块的桶计入 unmatched 记账——桶标签与块窗口对不上说明粒度或时区假设错了,外面要看得见。
还有一个安静但重要的选择:无数据的块占位保留(值为 None 而不是丢块)——
丢块会把 n 悄悄缩小,而 n 正是噪声带的全部前提。
为什么是稳健统计
带的本体是一行公式:中位数 ± k × 1.4826 × MAD stats.py:199 band。 换掉教科书的均值 ± k×标准差,是因为后者被离群值绑架: 一个月的事故把均值拽高、把标准差撑大,带随之变宽,真异常反而躲进「正常范围」。 中位数与 MAD(中位绝对偏差)对离群值不敏感——历史里有一个坏块,带的中心与宽度几乎不动。
1.4826 是把 MAD 换算到「正态分布下与标准差一致」的尺度(= 1/Φ⁻¹(0.75)),
所以稳健 z 的形态与经典 z 一样:z = (x − center) / scale
stats.py:110 robust_z。
3.5 这个阈值有出处(Iglewicz-Hoaglin 对稳健 z 的推荐值),不是为 Trove 新拍的。
一个容易被忽略的边界:散布为 0(历史块全部同值)时,带算得出(center 有值)但
z 无定义——除以 0 尺度没有任何统计意义。此处返回 None(判不了),
绝不返回 ±inf 或一个假的 0。代码把这件事分工写死:mad() 对全同序列返回
0.0(「序列确实没有散布」是算得出的结论),而不可用发生在下一层的
robust_z() 与 band()——别在前一层把 0.0 偷换成 None。
算不出返回 None,绝不返回 0
整个统计模块共享三条纪律(每条都有对应测试),其中第二条是气质上最「不像库」的:
算不出返回 None,绝不返回 0/±inf。
原因是消费者语义:0.0 是一个自信的「没触发」,None 是诚实的「判不了」——
与判定内核的 Kleene 三值逻辑是同一条要求,
只是换到了数字层。
| 算不出的情形 | 返回 / 记账 | 为什么不是 0 |
|---|---|---|
| 历史无数据 | 带不可用(no_data),outside() → None | 0 会被读成「在带内且很确定」 |
| 序列无散布(全同值) | 带不可用(zero_scale) | 除零之后的任何数都是编的 |
块数 < 8(MIN_BLOCKS) | 带可能算得出,但 不予确认(insufficient_n) | 样本太少的「显著性」是噪声放大器——宁可不确认 |
块数 < 12(LOW_N) | 结论可给,强制打「样本不足」标 | 标不可省:小样本结论要么带标,要么没有 |
三个纪律里的另外两条同样是为了复算:种子一律由 sha256 派生(绝不用
hash()——PYTHONHASHSEED 随机化会让同一输入在不同进程给出不同采样);
每个结论都带 method、种子材料和样本量。种子材料由调用方钉住语境
(如 datasource|rule_id|dim|首块日期),同一语境复算出逐位相同的区间。
p 值被刻意排除
n≈8–12 上算出来的 p 值是伪精度——比没有更糟,因为它看起来像科学。 所以判定层不输出任何 p 值,并列的证据换成了三样都能复算的东西:
| 证据 | 是什么 | 回答什么问题 |
|---|---|---|
| 稳健 z | (x − median) / (1.4826·MAD) |
当期值离历史中心有多少个「稳健散布」 |
| bootstrap 分位区间 | 中位数的 90% 百分位区间,2000 次重采样,种子可复算 stats.py:241 bootstrap_ci | 「这个中位数本身有多稳」——区间窄,结论才值得引用 |
| 有效样本量 n_eff | n·(1−ρ₁)/(1+ρ₁),自相关修正
stats.py:277 effective_n |
块序列相邻月天然相似,裸 n 是伪精度——有效样本量把冗余扣掉 |
同款节制也在别处:两组的 Welch t 统计量只作为证据输出,不附带 p 值 (源码注释直说「n≈8–12 上的 p 是伪精度」)。
位置分数(非概率)
判定在语义模型里可以引用一个叫 confidence 的变量,它由稳健 z 换算:
(|z| − k) / k,截断到 [0, 1]
stats.py:126 confidence_from_margin。
0 = 恰好压在带缘,1 = 到了两倍阈值封顶。它度量的是离带缘多远,
不含任何分布假设,所以不是概率——字段名沿用业界直觉(confidence),
但每个出口都钉着一句限定语
significance.py:51 CONFIDENCE_NOTE:
「位置分数非概率:(|z|−k)/k 截断到 [0,1]……无分布假设,不输出 p 值。」
这个公式的本体放在 analysis/stats.py 而不是判定侧,是一次刻意的搬家:
效果验收(闭环与契约)要用同一个公式判断「行动后的变化是否超出噪声带」——
把它放在共用位置,「判定说的超带」与「验收说的超带」由同一个数与同一条线定义,
谁也无法悄悄换尺子。限定语与出口措辞则留在各自的消费面。
门怎么合上
gate()
significance.py:85 gate
是判定侧的合流点,输出恒为 {z, outside, confidence, gated, reason}。
gated=True 要求四个条件同时成立:带可用(且有数据)∧ 块数 ≥ 8 ∧ 落在带外 ∧
位置分数 ≥ min_confidence。其余一律 gated=False 并带原因——
「判不了」与「判了在带内」用 reason 区分,不混为一谈:
| reason | 含义 | 语义 |
|---|---|---|
within_band | 判了,在带内 | 诚实的「没异常」 |
insufficient_n | 块数不足,不予确认 | 「判不了」——require: outside_band 的规则会把它们升级成 error run,绝不静默放行 |
no_data / zero_scale | 带不可用 | |
no_current_value / no_band | 当期无值 / 证据里没有带 | |
below_min_confidence | 超带但未到位置分数下限 |
与判定内核对齐的还有一处成本纪律:显著性阶段只在声明了 significance
且有事要判时才跑——未触发的规则零成本;条件引用 confidence 的规则必须跑,
因为那是它的判据。触发但无法确认的组,在 require 之下把整条规则升级为
error run(「触发但不能确认的触发,不是确认的触发」);未 require 时只记录——
近失(near-miss)留在行卡里如实展示:triggered: false, gated: false,
连命中的条件都保留,审计线必须看得见「差一点」。
怎么用
规则里声明两块即可(schema v3 起):
seasonal: # 噪声带的块规格
grain: month # 空 = 由窗口形状推导;声明则必须与窗口一致
lookback: 12 # 只计历史块——被测窗口不参与
mode: trailing # trailing 近期连续块 | same_phase 去年同期
k: 3.5 # 噪声带宽(稳健 z 单位)
significance: # 声明即要证据;require 非空即要拦
require: outside_band # 触发必须再通过「超出噪声带」这道门
min_confidence: 0.0 # 位置分数下限(仅 require 时生效)
判定的证据里随之多出一节 significance:逐维的 band、
bootstrap 区间、n_eff、low_n、
z / outside / confidence / gated / reason,
以及那句限定语;查询账目进 budget。通知行由
significance.py:144 band_line
生成:只列已确认的组(至多 2 个,按 |z| 降序、dim 升序——确定性排序),
形如「噪声带:北区 |z|=4.20 > k=3.50(位置 0.20)」——限定语在证据节里,通知保持一行可读。
完整字段与界面操作见判定规则 · 显著性; 分析侧(「为什么跌了」的答案里那条噪声带行)共享同一套器械。
取舍
| 选择 | 放弃了什么 | 为什么值得 |
|---|---|---|
| 中位数 + MAD(而非均值 + 标准差) | 统计效率——正态数据下 MAD 的方差大于标准差 | 离群值不绑架带:历史里有一个坏块,中心与宽度几乎不动。告警系统的样本永远不干净 |
| 纯 stdlib(零 numpy) | 向量化的性能——2000 次 bootstrap 纯 Python 约 100–300ms | 可复算承诺不外包:random.Random(seed) 的行为是语言规范的一部分(跨版本稳定),第三方采样实现在历史上变过。量级也够:n ≤ 366,全部发生在定时路径 |
| 不输出 p 值 | 「显著性检验」这套大家熟悉的说法 | n≈8–12 上的 p 是伪精度,比没有更糟。要检验就比对 bootstrap 区间——看得见、可复算 |
MIN_BLOCKS = 8 硬门 |
数据很少的规则也想得到「显著」结论 | 样本不足时显著性从门变成噪声放大器。宁可不确认——「判不了」是合法结局 |
字段名 confidence 但限定「非概率」 |
一个更贴切但没人认识的名字 | 命名贴合直觉、语义靠限定语钉死:每个出口(证据 note、文档、通知)都必须带上它,防止被读成置信度 |
| 公式本体放 analysis(共用) | 模块界限更「干净」的摆放 | 判定与效果验收说同一句话——同一把尺子量两次,才不会一次一个结论 |
优势在哪
- 可复算。种子由 sha256 派生、方法显式署名、样本量随行——任何一个统计结论都能被重新算出来,且逐位相同。
- 三态诚实。超带 / 在带内 / 判不了,三者由
reason严格区分;「判不了」在require下响亮失败,绝不冒充「没事」。 - 不放大噪声。块数硬门 + 稳健统计是一对:前者拒绝在样本不足时开口,后者拒绝让单个坏块改写结论。
- 成本有界。无论多少个维度,块序列至多一条 SQL;它是「门」,不参与展示性计算。
- 一把尺子。同一个公式(
confidence_from_margin与噪声带)同时服务判定与效果验收——升级、换实现、加维度都不会让两处悄悄分叉。
继续读
| 想了解 | 去哪 |
|---|---|
| 条件语言、三值逻辑、失败必响、查询预算 | 判定内核 |
| 超出噪声带之后:因果升级梯与 what-if 回放 | 因果与模拟 |
| 效果验收怎样复用同一套噪声带 | 闭环与契约 |
| 显著性字段与管理端操作 | 判定规则 · 显著性 |
| 块序列的取数与粒度推导细节 | series.py:50 derive_grain |