跳转至

归因分析

每个看板都会引出同一个问题:这个指标为什么变了? 人工回答——或让 AI Agent 回答——通常意味着一轮探索式查询循环:先查两个周期的总量,再一个维度 一个维度地分组查询,然后自己对齐、比较数字,还要小心两个周期之间新出现或 消失的分段。

归因分析把这个循环压缩成一次调用。给 Dosi 一个指标、候选维度和两个日期 窗口,引擎自己跑完所有查询,返回排好序、可直接引用的分解结果:

  • 哪个维度最能解释这次变化;
  • 哪个分段驱动了变化、贡献了总变化的百分之多少;
  • 对比率类指标,变化来自结构还是率值——比如"客单价上升,是因为已完成 订单自身客单价从 75 → 100,尽管它的订单占比在下降"。

数字由引擎按每种指标类型选用精确的方法算出,并且加得起来——每次分解都与 总量对账,结论可核对,而不是即兴拼凑。

所有接口面都可用:CLI(dosi attribute)、REST (POST /v1/query/attribute)、MCP(attribute_metric 工具)和 Python (Engine.attribute)。

快速开始

dosi --model model.yaml attribute \
  --metric revenue \
  --dimensions status,customers.region,products.category \
  --baseline 2024-01-01..2024-02-01 \
  --current  2024-02-01..2024-03-01 \
  --db warehouse.duckdb

维度怎么选

--dimensions 在语法上不是必填,但实际必须给:不给的话引擎会以 dimensions_required 拒绝,并把该指标的候选清单(好的在前)连同可直接粘贴的 片段一起交给你。

$ dosi --model model.yaml attribute --metric revenue \
    --baseline 2024-01-01..2024-02-01 --current 2024-02-01..2024-03-01
error: dimensions_required: name the dimensions to analyze; `candidates` lists what
  `revenue` can be grouped by, best first
  candidates: orders.status, customers.region, products.category
  retry with: "dimensions": ["orders.status", "customers.region", "products.category"]

引擎故意不替你选。哪些列能解释这次变化是业务问题,而分析自身用的排序回答不了它: 集中度(max |Δ分段| / |Δtotal|)随基数升高,让引擎去猜就会把客户 ID 排在区域 前面 —— 而且每猜一个还要多发一条数仓查询。同一份清单也可以提前拿到: dosi list dimensions --metric <名称>(is_dimension: true 的行,去掉时间列); 这份判断从哪来(声明还是推断)见 D-DIM。

窗口是半开 ISO 区间(START..END)。--connection 指定命名数仓连接, --where 为整个分析加过滤,默认时间路由不合适时用 --time-dimension。

REST 或 Python 的请求是同一个形状:

from dosi_engine import Engine

engine = Engine("model.yaml")
result = engine.attribute({
    "metric": "revenue",
    "dimensions": ["status", "customers.region"],
    "baseline": {"start": "2024-01-01", "end": "2024-02-01"},
    "current":  {"start": "2024-02-01", "end": "2024-03-01"},
}, db_path="warehouse.duckdb")

参数化指标(D-PARAM)用 --param name=value 绑定 —— 每个参数一个值,不接受列表 —— 结果会把解析后的绑定回显在 comparison_metadata.params 里。

返回什么

{
  "metric": "avg_order_value",
  "strategy": "mix_shift",                  // 引擎采用的分解方式
  "total_change": {"baseline_value": 75.0, "current_value": 76.67,
                   "delta": 1.67, "pct_change": 2.22},
  "dimension_ranking": [                    // 排好序的根因候选
    {"dimension": "status", "score": 6.0}
  ],
  "top_dimension_values": [                 // 变动最大的分段
    {"dimension": "status", "value": "cancelled",
     "delta": 10.0, "contribution_pct": 600.0,
     "segment_kind": "entered",             // 本周期新出现的分段
     "drill_down": {"where_sql": "status = 'cancelled'"}},
    {"dimension": "status", "value": "completed",
     "delta": -8.33, "contribution_pct": -500.0,
     "mix_effect": -28.69, "rate_effect": 20.35,
     "baseline_rate": 75.0, "current_rate": 100.0,
     "drill_down": {"where_sql": "status = 'completed'"}}
  ],
  "factor_totals": {"mix_effect": -18.7, "rate_effect": 20.4, ...},
  "warnings": []                            // 结构化的注意事项,见下文
}

怎么读:

  • dimension_ranking 按解释力排序候选维度——第一名就是"该按这个维度 切"。
  • contribution_pct 可以直接引用:"该分段解释了 X% 的变化"。超过 100% (或为负)说明分段朝相反方向变动、相互抵消——响应会明确标记这种情况。
  • segment_kind 指出分段是本周期新出现(entered)还是消失 (exited),而不是普通涨跌——这正是手工对比最容易悄悄出错的地方。第四种 取值 fallback 表示该分段的结构/率值拆分无定义(分量为零或为负);它依然 完整携带自己的 delta,所以整个分解仍然对得上账。
  • 对比率类指标,每个分段的贡献拆成结构效应(它在总体中的权重变了) 和率值效应(它自身的比率变了),并附带各分段的率值与占比——"结构 vs 表现"的叙事直接从响应里读出来,且各部分永远加总等于总变化。 mix_effect、rate_effect、baseline_share、current_share 仅在 normal 分段上出现:在其他路径上这些量本身无定义,引擎选择省略该字段, 而不是给出一个名不副实的数字。读取占比前请先看 segment_kind(或直接判断 mix_effect 是否存在)。
  • per_dimension(示例中省略)携带每个维度的完整分段明细,包括该维度 的数字是否与总量对得上账。

如何帮助 Agent 推理

响应的设计目标,是让 Agent 用一两次调用就从"指标动了"走到可验证的根因, 全程不用自己做算术:

  1. 不再探查循环。 排序和贡献值都是算好、排好的,Agent 直接引用, 不用自己编排一串查询再对账。
  2. 每个发现自带下一步。 每个分段都带 drill_down.where_sql——可直接 粘贴的过滤条件。想继续下钻,就带上它再调一次 attribute(根因递归), 或交给指标查询看该分段的走势。
  3. 注意事项机器可读。 warnings 是一组稳定代码——分段相互抵消、高基数 维度被截断、某维度对不上账、总变化接近于零——Agent 据此知道哪些结论要 打折扣,不用解析散文。
  4. 拒绝也在引导,而不是报错。 引擎无法忠实分解的指标(窗口指标、去重 计数、复杂表达式)返回 strategy: "unsupported" 和结构化原因,Agent 由此转向——例如窗口指标改为对其基础指标在显式窗口上归因——而不是陷入 重试循环。dosi list metrics 会预先报告每个指标的 attribution_strategy,调用前即可确认支持度。

指标覆盖

  • 可加指标(term_wise)——求和、计数及其线性组合(含线性派生指标, 另附每个成员对变化的分解):完整维度归因。加法常数(SUM(x) + 100) 不再拒绝:可变部分照常分解,常数在 affine_constant 中报出。
  • 比率指标(mix_shift)——上述线性形式的单层比率,含平均值:上文的 结构/率值分解。
  • 一般表达式(factor_shapley)——聚合的乘积、嵌套比率,以及任何 + − × ÷ 组合的 SUM/COUNT 表达式:引擎对表达式的叶子因子做一次扁平 Shapley 分解,再把每个因子的精确效应按分段在该因子变化中的份额分摊 下去。响应新增 factors 块(各因子的基线/当前值与效应,精确加总 等于总变化);每个分段的 delta 是它分摊到的变化份额——对这类指标, baseline_value/current_value 是该分段自身的指标水平(上下文), 不是可加项。交互项在因子间平均分摊(Shapley 惯例;对乘积 X·Y 即 熟悉的 ΔX·Ȳ + ΔY·X̄ 中点公式)。一个刻意保留的例外:单层比率仍用 mix_shift 的结构/率值语义而不切换到 Shapley——两种惯例数值不同,而 mix_effect/rate_effect 是既有的业务叙事。
  • filter 派生指标——照常归因,并额外携带 filter_breakdown:精确的 子集恒等式 Δbase = Δfiltered + Δcomplement,补集序列由引擎合成。
  • offset 窗口指标(delta / percent_change / 环比族)——分析分解 的是指标的基础聚合在你显式给出的两个窗口之间的变化,并在 window_mapping 中标注变换本身;窗口变换是逐桶序列,不是两窗口对比。
  • 不支持——frame/rank/value 窗口指标与去重计数返回结构化的 unsupported 响应,而不是给出误导性的数字。dosi list metrics 预先 报告每个指标的 attribution_strategy 和 attributable 标记(代数可 分解 + 有可用的默认时间轴)。
  • 细节层次(D-LOD)——暂不支持。读了细节 层次的指标(聚合 fixed 字段的普通指标、include、exclude,以及组合 在它们之上的指标)返回 unsupported,理由是 level_of_detail,并点名它 读了什么,attributable 预先就是 false;读了细节层次的候选维度会在发出 任何语句之前以 dimension_skipped 告警跳过;where 里引用它则直接拒绝。 原因是结构性的:汇总值是每个窗口过滤上下文的函数,而两窗口合一的语句只算 一次汇总——要跨细节层次做分解,得每个窗口各算一次汇总(见 D-LOD 的边界)。要解释一个占比 (revenue / revenue_grand)为什么变,归因它的基础指标即可:mix_effect 就是占比的变动。

限制

  • 每次调用最多 16 个候选维度,各维度独立分析;更深的下钻用 drill_down.where_sql 递归。
  • 每个维度的分段数有上限(默认 500,最高 1000);超过后该维度打上 truncated 标记。上限之内引擎按两个窗口合并的绝对量级取一份统一的 top-N——在窗口间坍缩(或暴涨)的分段会以完整的一对基线/当前值留下来, 而不是只被看到一半的幻影。
  • 只在一个窗口出现的分段由引擎兜底处理——零填充或标记为 entered/exited—— 对比永远不会悄悄丢掉它们。
  • 引擎每个维度只发一条语句(外加一条总量语句),每条语句同时覆盖两个 窗口——即使在持续写入的数仓上分析也是一致的,D 个维度的调用只需 1 + D 次往返。

错误

两条刻意区分的失败通道:

  • 请求错误——请求本身有问题(未知指标、超过 16 个维度、非法窗口): HTTP 400 / CLI 用法错误。没给维度的 dimensions_required 也属于这一类, 它会带上候选清单,并且不会向数仓发出任何语句。
  • 不可计算(not computable)——请求没问题,是数据撑不起这次分析: denominator_nonpositive(比率指标在某个窗口上的分母为零或为负)或 nonfinite_evaluation(factor_shapley 表达式在窗口的因子总量上遇到 零除数)。REST 返回 HTTP 422 与 {"error": {"code": "denominator_nonpositive", ...}},Python 抛出 NotComputableError, CLI 打印同样的结构化代码——换窗口或换过滤条件重试可能成功,这正是它与 请求错误的区别。