Skip to content

Attribution analysis

Every dashboard raises the same question: why did this metric change? Answering it by hand — or letting an AI agent answer it — normally means a loop of exploratory metric queries: totals for two periods, then one grouped query after another, then joining and comparing the numbers without slipping on segments that appear or disappear between the periods.

Attribution analysis collapses that loop into one call. Give Dosi a metric, the candidate dimensions, and two date windows; the engine runs everything itself and returns a ranked, ready-to-quote decomposition:

  • which dimension best explains the change,
  • which segment drove it, and by how much (as a share of the total change),
  • for ratio metrics, whether the change came from structure or from rate — e.g. "average order value rose because completed orders' own AOV went 75 → 100, even though their share of orders fell".

The numbers are computed by the engine with the method that is exact for each metric's type, and they add up — every decomposition reconciles against the totals, so the explanation is checkable rather than improvised.

Available on every surface: the CLI (dosi attribute), REST (POST /v1/query/attribute), MCP (the attribute_metric tool), and Python (Engine.attribute).

Quick start

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

Windows are half-open ISO ranges (START..END). Use --connection for a named warehouse profile, --where to scope the whole analysis, and --time-dimension when the default time routing is not what you want.

Over REST or Python the request is the same shape:

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")

What comes back

{
  "metric": "avg_order_value",
  "strategy": "mix_shift",                  // how the engine decomposed it
  "total_change": {"baseline_value": 75.0, "current_value": 76.67,
                   "delta": 1.67, "pct_change": 2.22},
  "dimension_ranking": [                    // ranked root-cause candidates
    {"dimension": "status", "score": 6.0}
  ],
  "top_dimension_values": [                 // the biggest movers
    {"dimension": "status", "value": "cancelled",
     "delta": 10.0, "contribution_pct": 600.0,
     "segment_kind": "entered",             // this segment is new this period
     "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": []                            // structured caveats, see below
}

Reading it:

  • dimension_ranking orders the candidate dimensions by how well each one explains the change — read the first entry as "slice it this way".
  • contribution_pct is directly quotable: "this segment explains X% of the change". Percentages above 100 (or negative) mean segments moved in opposite directions and partly cancelled out — the response flags this.
  • segment_kind tells you when a segment is entered (new this period) or exited (gone this period) rather than a normal shift — the cases that silently skew hand-rolled comparisons.
  • For ratio metrics, each segment's contribution splits into a mix effect (its weight in the population shifted) and a rate effect (its own ratio changed), with the per-segment rates and shares included — the "structure vs performance" narrative reads straight off the response, and the parts always sum to the total change.
  • per_dimension (not shown) carries the full value-level detail per dimension, including whether that dimension's numbers reconcile with the totals.

How it helps an agent reason

The response is designed so an agent can go from "metric moved" to a verified root cause in one or two calls, without doing arithmetic:

  1. No probing loop. The ranking and contributions arrive pre-computed and pre-sorted; the agent quotes them instead of orchestrating and reconciling its own query sequence.
  2. Every finding carries its next step. Each segment includes drill_down.where_sql — a ready-to-paste filter. To go deeper, the agent calls attribute again with that filter (root-cause recursion), or hands it to a metric query to chart that segment's trend.
  3. Caveats are machine-readable. warnings is a list of stable codes — offsetting segments, truncated high-cardinality dimensions, a dimension whose numbers don't reconcile, near-zero total change — so the agent knows which conclusions to soften without parsing prose.
  4. Refusals guide instead of failing. A metric the engine cannot decompose faithfully (window metrics, distinct counts, complex expressions) returns strategy: "unsupported" with a structured reason, so the agent pivots — for a window metric, attribute its base metric over explicit windows — rather than retry-looping. dosi list metrics reports each metric's attribution_strategy up front, so support can be checked before asking.

Metric coverage

  • Additive metrics — sums, counts, and their linear combinations (including linear derived metrics, which additionally get a per-member breakdown of the change): full dimension attribution.
  • Ratio metrics — including averages: the structure/rate decomposition shown above.
  • Not yet supported — window metrics, distinct counts, and more complex expressions return a structured unsupported response (planned for a later phase) instead of a misleading number.

Limits

  • Up to 16 candidate dimensions per call, each analyzed independently; go deeper by recursing with drill_down.where_sql.
  • Per-dimension values are capped (default 500, max 1000); beyond the cap the dimension is flagged truncated.
  • Segments present in only one window are handled for you — zero-filled or reported as entered/exited — so the comparison never silently drops them.