首页/设计深潜/分解数学与驱动器树

分解数学与驱动器树

「为什么这个数变了」是分析的第一问。分解就是把总变化拆给各维度、各组件—— 这件事有两种败法:拆得不诚实(子项之和并不等于父项,却摆出一副恒等式的样子) 与拆得没边(每条分支一趟查询,成本爆炸)。 这页讲 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-share
shift_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;
图 1 · 表达式 → 组件树骨架。只有加/减链标 decomposable=true;乘除链的子组件仍然收集(比率根的分子分母有用),但恒等式不声称。 上面这段以源码显示:图渲染库没加载起来(离线、CDN 被拦或浏览器禁用了 JavaScript 时会这样)。这段 Mermaid 文本本身就说明了上面那些框与连线。

组件收集 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 数据源注册表)。 驱动器树的取数编排把「可能出错的钱」都花在了明处:

  1. 预检只编译不执行(零查询成本)——逐候选独立编译,把三类取不到值的候选剔除: 解析不了(unresolvable)、裸名被字段遮蔽 (编译器「字段优先」会把裸名当维度投影,值变成分组列)、结果带 GROUP BY(维度混入)。按解析后的度量名去重——编译器投影按度量名去重, 同名会少列、位置映射会错位(engine.py:845 预检)。
  2. 组合路径:每期一条多度量 SQL——所有存活组件合成一次查询、两期各一次 (metrics=[...] 一次投影多列)。这决定成本上界:树是 O(期数) 条 SQL,不是 O(组件数) (engine.py:962 组合路径)。
  3. 组合编译失败 → 逐候选回退——回退也逐条数预算,超预算的候选截断记账, 宁缺勿错(engine.py:972 回退路径)。
  4. 预算不足 → 只留骨架+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;
图 2 · 驱动器树取数编排。预检零查询成本;组合路径两步(两期各一条 SQL);一切让路都记 degraded。 上面这段以源码显示:图渲染库没加载起来(离线、CDN 被拦或浏览器禁用了 JavaScript 时会这样)。这段 Mermaid 文本本身就说明了上面那些框与连线。

树根的值有一个刻意的优先级 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 只记账不设限 全局硬预算的简单叙事 老路径必须逐字节不变;让路永远记账让「不受约束」可见,上限只在新阶段(决策桥)显式生效
保序并集 + 双键排序 更简洁的集合/单键写法 分解结果进证据链与缓存,跨进程逐字节一致是硬要求;哈希序与插入序都不能泄漏进输出

优势在哪

继续读

想了解去哪
分解之外的因果主张:对照、平行趋势、反事实因果与模拟
噪声带与「变了多少才算变了」统计与噪声带
驱动这些分析的是哪条判定、条件怎么写判定内核
分析卡与瀑布图在界面上的样子归因分析卡
本页源码锚点的基准提交:anchor-base: 211ae86 GitHub 仓库 文档首页