分解数学与驱动器树
「为什么这个数变了」是分析的第一问。分解就是把总变化拆给各维度、各组件—— 这件事有两种败法:拆得不诚实(子项之和并不等于父项,却摆出一副恒等式的样子) 与拆得没边(每条分支一趟查询,成本爆炸)。 这页讲 Trove 的两道纪律:分解要么精确(恒等式成立),要么 如实标注(残差入 payload、不可分解就不声称); 取数编排每一步都有预算与记账,降级永远说出口。
两种败法,两道纪律
一个「哪些地区贡献了贷款余额下降」的问题,机器可以给出三种答案: 数字对但无法复算、数字漂亮但恒等式不成立、数字对且能被任何人在纸面上验证。 只有第三种值得相信——分解数学是纯函数 (decompose.py 模块头: 零 LLM、零 I/O、无时间依赖),任何一份分解都可以拿原始数字手工复算—— 这是它敢作为证据链一环的前提。
驱动器树则回答更结构化的一问:「净利息收入」本身是不是可以拆成 「利息收入 − 利息支出」,各组件又变了多少?这在数学上只是沿表达式树的一次 加法——但表达式树里混着乘法与除法时,这个加法不成立。 本页的两道纪律就来自这两处:恒等式能证的才声称,查询能数清的才去发。
分解数学:加性、比率、占比
三种分解,都是纯函数(decompose.py:36 contribution / decompose.py:71 shift_share / decompose.py:123 ratio_share):
| 形态 | 前提 | 数学 | 细节上的诚实 |
|---|---|---|---|
加性贡献contribution |
指标可加(求和类),有基期与本期 | delta_i = cur_i − base_i;contribution_i = delta_i / Σ|delta_i|(带符号) |
全无变化(Σ|Δ| = 0)时退化为占比归因(cur_i / Σcur),而不是除零或全零——「没变」也是一句要说清的话 |
比率 shift-shareshift_share |
指标是比率 R = N/D(AVG、率类),且能从语义模型拿到分子/分母 |
ΔR 精确拆成三效应:within = Σ w_b·(r_c − r_b)(本征)composition = Σ (w_c − w_b)·r_b(结构)interaction = Σ (w_c − w_b)·(r_c − r_b)(交叉) |
三效应之和逐项精确等于 ΔR(每组的 contribution = 三项和)——这是一条可验证的恒等式,不是近似;比率变了到底是「各地区的率都变了」还是「结构从低率区挪到高率区」,三效应分开回答 |
比率单期占比ratio_share |
比率指标但无基期(只给了一个期) | 贡献 = 分子份额 n_i / N |
三效应字段如实填 0.0 而不是伪装成小效应——「没有基期,无变化可拆」在结构里看得见 |
两个容易被忽略的工程细节,都是为了跨进程可复现:
维度并集用保序去重(dict.fromkeys)——集合迭代序随
PYTHONHASHSEED 变化,而浮点求和的次序影响末位比特;
排序用双键(|contribution| 降序,并列按维度名升序)——
单键排序的并列次序会退到插入序。同一份数据、两个进程,输出逐字节一致。
驱动器树:沿表达式树拆组件
语义模型里的 metric 表达式本身就是一棵树(SUM(x)、a+b、
n/d、AVG(x)、以及指向别的 metric 的裸标识符)。
驱动器树把这棵树解析成组件骨架
(expr_tree.py:91 metric_components,纯函数不取数),
再给「能单独取到值」的组件各取两期值。骨架构建的判定表:
| 表达式形状 | 节点 | 可分解? | 为什么 |
|---|---|---|---|
AVG(x) |
ratio,子节点 SUM(x) / COUNT(x) |
false |
平均值之比可拆成分子分母两个可取值的分量(expr_tree.py:123),但父项的变化量并不等于两者变化量之和——子组件有用,恒等式不成立 |
a / b、SafeDivide(a, b) |
ratio |
false |
同上:比率链上子项之和 ≠ 父变化量,不许硬拆 |
a + b、a − b |
derived |
true |
加法链上 Δ 精确相加——唯一可以声称恒等式的链 |
a * b |
derived |
false |
乘法下 Δ(ab) = aΔb + bΔa + ΔaΔb——展开就会引入交叉项,宁可不拆 |
| 裸标识符(metric 名) | 递归展开被引用 metric 的表达式 | 随展开结果 | 深度上限 5(note="depth");命名环(metric 互引)在该引用处截停标 note="cycle"(expr_tree.py:146 Column 分支) |
| 解析失败 / 超长 / 未知形状 | 叶子 + note |
— | 根自身仍可执行;unparseable / too_large / unresolved 如实标注,不是静默丢弃 |
flowchart TB
EXPR["metric 表达式
sqlglot 解析"]:::entry --> KIND{"顶层形状?"}:::gate
KIND -->|"AVG(x)"| RAT1["ratio · decomposable=false
子: SUM(x) / COUNT(x)"]:::exec
KIND -->|"a / b · SafeDivide"| RAT2["ratio · decomposable=false
子: 递归 a, b"]:::exec
KIND -->|"a + b · a − b"| ADD["derived · decomposable=true
子: 递归 a, b"]:::exec
KIND -->|"a * b"| MUL["derived · decomposable=false
子: 递归 a, b"]:::exec
KIND -->|"裸标识符 = metric 名"| REF["递归展开被引用 metric
环停 cycle / 深度停 depth"]:::exec
KIND -->|"聚合函数 · 解析不了"| LEAF["叶子(自身可执行)
note 标注原因"]:::err
ADD --> COLLECT["collect_components
preorder · 去重 · max_components 截断"]:::exec
RAT1 --> COLLECT
RAT2 --> COLLECT
MUL --> COLLECT
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;
classDef err fill:#dc262614,stroke:#dc262699,stroke-width:1.5px;
组件收集
expr_tree.py:165 collect_components
只收「candidate 非空」的节点——它们才是能单独取到值的;
不可分解节点的子组件照样收集:比率根的分子/分母就是这种情况,
父不可加,子各自有值仍然有用。超出 max_components 的部分截断并计数——
截断本身也会进 degraded 记账。
残差:宁可不拆,不造恒等式
树组装完成时做一次诚实性判定
decompose.py:175 residual:
residual = Δparent − ΣΔchildren,exact=True
当且仅当残差在浮点容差内为零(_EXACT_TOL = 1e-9,误差只来自浮点表示)。
加减链上这个值精确为零——这是恒等式成立的证明;
乘除链上不为零——残差如实写进 payload。
减法链还有一个专门的修正
decompose.py:163 signed_children:
Δ(a − b) = Δa − Δb——直接对子 delta 求和会把减法链误判出残差,
所以第二项取负。这是「分解正确性」与「分解诚实性」互相支撑的一处小结构:
符号错了会产生假残差,假残差会让真恒等式看起来不可信。
分解完全发生在算术层:它说「结构效应占了变化的大部分」, 不说「挪动结构会带来这个变化」。后者要过实验设计(对照、平行趋势、安慰剂)—— 那是因果与模拟的升级梯,两处共用同一套分解数学, 但声称的强度完全不同。
取数编排:零查询预检与预算记账
AnalysisEngine 是分析包里唯一有 I/O 的地方
(执行器从外面注入;姿态守卫测试断言这个包不 import 数据源注册表)。
驱动器树的取数编排把「可能出错的钱」都花在了明处:
- 预检只编译不执行(零查询成本)——逐候选独立编译,把三类取不到值的候选剔除:
解析不了(
unresolvable)、裸名被字段遮蔽 (编译器「字段优先」会把裸名当维度投影,值变成分组列)、结果带GROUP BY(维度混入)。按解析后的度量名去重——编译器投影按度量名去重, 同名会少列、位置映射会错位(engine.py:845 预检)。 - 组合路径:每期一条多度量 SQL——所有存活组件合成一次查询、两期各一次
(
metrics=[...]一次投影多列)。这决定成本上界:树是 O(期数) 条 SQL,不是 O(组件数) (engine.py:962 组合路径)。 - 组合编译失败 → 逐候选回退——回退也逐条数预算,超预算的候选截断记账, 宁缺勿错(engine.py:972 回退路径)。
- 预算不足 → 只留骨架+
degraded{"stage":"driver_tree","reason":"query_budget_exceeded"}—— 树骨架仍有信息(结构、表达式、可分解性),数值缺位如实说 (engine.py:923 预算门)。
预算由 AnalysisLimits 装配
engine.py:501 AnalysisLimits:
max_dimensions=3、max_hops=2、max_components=4、
max_queries=12、tree_max_depth=5;
每个阶段用 QueryLedger
budget.py:21 QueryLedger
记账(can() / record() / yield_())。
注意 total_query_budget=None 时上限不生效、只记账——
「老路径不受预算约束」这个事实从不可见变成可观测;上限只在显式设置时生效(新阶段、决策桥),
让路永远记账、绝不静默。
flowchart LR
TREE["组件树骨架
(纯函数·零查询)"]:::entry --> PRE["预检·只编译不执行
剔除: 解析不了 / 字段遮蔽 / GROUP BY"]:::exec
PRE --> BUD{"预算够吗?"}:::gate
BUD -->|"不够"| SKEL["只留骨架
degraded: query_budget_exceeded"]:::err
BUD -->|"够"| COMB["组合编译
每期一条多度量 SQL"]:::gen
COMB -->|"编译失败(交互)"| FALL["逐候选回退
逐条数预算·超则截断记账"]:::err
COMB -->|"成功"| RUN["执行两期
purpose=driver_tree"]:::exec
FALL --> RUN
RUN --> MARK["_mark_tree
子项求和 · 残差判定"]:::exec
SKEL --> FIN["_finish
根值优先 hop0 官方口径"]:::exec
MARK --> FIN
classDef entry fill:#71717a14,stroke:#71717a59,stroke-width:1px;
classDef gen fill:#6366f114,stroke:#6366f199,stroke-width:1.5px;
classDef exec fill:#14b8a614,stroke:#14b8a699,stroke-width:1.5px;
classDef gate fill:#f59e0b14,stroke:#f59e0b99,stroke-width:1.5px;
classDef err fill:#dc262614,stroke:#dc262699,stroke-width:1.5px;
树根的值有一个刻意的优先级
engine.py:909 _finish:
优先用主跳(hop0)的官方口径——树卡头条的数字必须与页面头条一致,
哪怕树的查询自己算出的根值有细微差别(过滤条件构造路径不同)。
hop0 没跑成时才回落到树查询自己的值(value_source="tree_query"),
再缺则 "none"——不拿 0.0 顶替,数字缺位在结构里看得见。
怎么用
驱动器树默认开(存量行为即默认值),预算在 conf/agent.yml 可调:
# conf/agent.yml
agent:
analysis:
driver_tree: true # false = 只出维度分解表
max_components: 4 # 树里可执行组件数上限(超出截断 + degraded)
max_queries: 12 # 树阶段双期查询预算(超出 → 骨架 + degraded)
series_grain: "" # 块序列粒度 day/week/month;留空 = 序列阶段关闭
block_lookback: 12 # 序列回看历史块数(不足 MIN_BLOCKS 退化并标注)
结果落到 state.analysis(结构化 payload,version=1,
engine.py:1517 analysis_payload),
与旧的 state.attribution 双写兼容
(attribution.py:460 双写)——
归因节点本体已瘦身为适配器(make_attribution 签名不变,
attribution.py:192 make_attribution),
数学升格成服务后有了三个调用方:图内节点、前端分析卡、决策桥。
决策侧还有一个复用点:规则触发后的「为什么变了」,由
_analysis_bridge
service.py:1150 _analysis_bridge
调同一套引擎出「首要驱动」一行(
bridge.py:144 run_bridge),
失败只记 degraded、绝不影响判定本身——判定与叙事是两条独立的路
(见判定内核)。
前端瀑布图由 render.py 的
render.py:12 waterfall_chart /
render.py:44 ratio_waterfall_chart
出图,界面见归因分析卡。
取舍
| 选择 | 放弃了什么 | 为什么值得 |
|---|---|---|
乘除链 decomposable=false,不硬拆 |
「每层都能拆到底」的完整树观感 | 硬拆乘法要引入交叉项管理,任何一处近似都会让「残差不为零」的树看起来像 bug。宁可不拆,不造恒等式——子组件(分子/分母)仍然收集,有用性保留了 |
| 残差入 payload 而不是丢弃 | 更干净的输出形状 | 残差是唯一能向读者证明「这条链上恒等式成立/不成立」的字段;丢掉它,exact 就没有验证依据 |
| 预检只编译不执行 | 少一次编译往返就完事的写法 | 字段遮蔽这类坑只有编译结果能看出来;编译零查询成本,把错误发生在「花钱」之前 |
| 组合路径 + 逐候选回退的两层结构 | 统一走逐候选的简单实现 | 组合让树成本降到 O(期数),回退只在组合编译失败(度量间交互)时触发——常见路径便宜,罕见路径正确 |
| 树根值优先 hop0 口径 | 树内部数值的自洽性 | 卡片头条与树根不一致是用户最先发现的不信任源;官方口径只有一个 |
total_query_budget=None 只记账不设限 |
全局硬预算的简单叙事 | 老路径必须逐字节不变;让路永远记账让「不受约束」可见,上限只在新阶段(决策桥)显式生效 |
| 保序并集 + 双键排序 | 更简洁的集合/单键写法 | 分解结果进证据链与缓存,跨进程逐字节一致是硬要求;哈希序与插入序都不能泄漏进输出 |
优势在哪
- 每个数字可手工复算。分解数学是纯函数(零 LLM、零 I/O、无时间依赖),给定输入必然给定输出——归因结论是证据,不是叙事。
- 恒等式是判定出来的,不是声称的。
residual.exact逐树判定;比率的 shift-share 三效应之和精确等于 ΔR。 - 成本有上界。每期一条多度量 SQL 是组合路径的常态;预检零查询;预算截断记账——驱动器树不会把一次提问变成十几趟查询。
- 降级永远说出口。
degraded[]逐条记 stage 与 reason,partial如实置位;没有静默的缺失值。 - 一份数学三个消费者。图内归因节点、前端分析卡、决策桥共用同一引擎与同一套预算纪律——行为漂移不可能,因为只有一份实现。