归因分析¶
每个看板都会引出同一个问题:这个指标为什么变了? 人工回答——或让 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 用一两次调用就从"指标动了"走到可验证的根因, 全程不用自己做算术:
- 不再探查循环。 排序和贡献值都是算好、排好的,Agent 直接引用, 不用自己编排一串查询再对账。
- 每个发现自带下一步。 每个分段都带
drill_down.where_sql——可直接 粘贴的过滤条件。想继续下钻,就带上它再调一次attribute(根因递归), 或交给指标查询看该分段的走势。 - 注意事项机器可读。
warnings是一组稳定代码——分段相互抵消、高基数 维度被截断、某维度对不上账、总变化接近于零——Agent 据此知道哪些结论要 打折扣,不用解析散文。 - 拒绝也在引导,而不是报错。 引擎无法忠实分解的指标(窗口指标、去重
计数、复杂表达式)返回
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 打印同样的结构化代码——换窗口或换过滤条件重试可能成功,这正是它与 请求错误的区别。