Skip to main content

do-attribution

Multi-touch attribution and experimental calibration for Python — closed-form Markov, exact Shapley, temporal attribution, and explicit calibration from external lift-test evidence.

License: MIT Python: 3.10+

do-attribution has two explicit layers:

journey data        → attribution
external experiment → post-attribution calibration

The attribution layer turns customer journeys such as "Email > Social > Purchase" and a 0/1 conversion flag per journey into raw per-channel model diagnostics and, when those values satisfy the normalization contract, per-channel shares using two observational allocation models:

  • an absorbing-Markov-chain removal-effect model (MarkovAttribution, orders 1+, four removal strategies), and
  • a cooperative-game Shapley-value model (ShapleyAttribution, exact factorial weights over the observed coalition lattice).

The calibration layer applies caller-supplied lift-test evidence after a static or temporal attribution fit. Direct adjustments to tested entities, residual allocation to other entities, temporal evidence schedules, and provenance remain separate and inspectable.

Around these layers the package ships side-by-side model comparison (CompareAttribution), explicit conversion-endpoint handling (conversion_labels), plain-text reporting (summary()), flat DataFrame export (to_dataframes()), matplotlib/networkx visualizations, and temporal epoch analysis (TemporalMarkovAttribution, TemporalShapleyAttribution, recommend_epochs) that refits the same static models independently inside chronological epochs.

Scope guard: attribution outputs are probabilistic decompositions of observed co-occurrence between channel exposure and conversion — not causal inference. A high share means the model assigned relatively more normalized attribution to the channel from the observed paths; it does not identify the channel that caused a conversion. Calibration can apply external experimental evidence, but it does not convert attribution internals into causal effects or replace sound experimental design.


Key capabilities

  • Deterministic, closed-form Markov attribution. Removal effects come analytically from the absorbing-chain fundamental matrix (I − Q)⁻¹ — no Monte Carlo, no seed, no simulation count. Identical inputs always produce identical outputs.
  • Four removal-effect definitions. removal_strategy= "fail", "fail_refit" (signed effects), "detour" (signed effects), or "redirect_to_null" — four operational meanings of "remove this channel", selected explicitly instead of implied.
  • Exact Shapley values. Per-channel values use the exact factorial weight w(s, n) = s!·(n−s−1)!/n!. Raw signed values and the coalition value function are always returned; normalized shares are returned when the raw values admit a valid share vector, and reported as unavailable when they do not.
  • Conservation and contract guarantees. When normalized attribution is available its shares are finite, lie on [0, 1], and sum to 1, and attributed conversions conserve the observed total. When it is not, attribution is None with a machine-readable status and reason rather than a fabricated share vector. redirect_to_null removal effects are nonnegative by construction, and absorption probabilities stay on [0, 1] — enforced by the implementation.
  • Model reporting. Every fitted model renders a compact ASCII summary() or a natural-language summary(output_format="report") — plain Python strings with bounded, distilled detail tables (signed removal effects, top transitions and drop-offs, top coalitions, path diagnostics).
  • Flat DataFrame export. Temporal models expose to_dataframes() -> dict[str, pd.DataFrame]: flat, string-keyed, Parquet/SQL-ready tables for attribution, raw model values, and sample diagnostics — no text parsing, no MultiIndex, no lazy refits.
  • Visualization. plot_attribution, plot_removal_effects, plot_transition_graph, plot_coalition_values, plot_attribution_comparison, and plot_epoch_recommendation each return a matplotlib.figure.Figure.
  • Temporal epoch analysis. Calendar buckets (freq=), explicit labels (fit_by_epoch), or data-driven boundaries (recommend_epochs + fit_recommended) — every epoch is an independent static fit; complete paths are never split across epochs.
  • Experimental calibration. Apply externally supplied lift-test evidence to static or fixed-epoch attribution results while keeping direct test adjustments, residual allocation, evidence schedules, epoch-level reductions, and provenance explicitly separate.
  • Calibration diagnostics. One static adjustment-decomposition plot and six temporal diagnostics show evidence alignment, ratio construction, direct adjustments, residual allocation, entity mix, and evidence strength.
  • Reproducible outputs. Deterministic model fits, deterministic renderers, and deterministic tie ordering — identical inputs always produce identical reports, tables, and figures.

Core runtime dependencies are numpy and pandas.


Installation

pip install do-attribution
Extra Installs Enables
viz matplotlib, networkx all plotting helpers
temporal statsmodels, ruptures global-signal epoch recommendation
excel openpyxl Excel-friendly workflows
dev all of the above + pytest running the test suite
pip install "do-attribution[viz]"
pip install "do-attribution[temporal,viz]"

Requires Python 3.10 or newer.


Quick start

A complete, runnable analysis — ten journeys, a fitted Markov model, shares, and a readable report:

from do_attribution import MarkovAttribution

journeys = [
    "Email > Social > Purchase",
    "Search > Email > Purchase",
    "Social > Purchase",
    "Email > Search",                       # no conversion
    "Search > Social > Email > Purchase",
    "Direct > Purchase",
    "Email > Social",                       # no conversion
    "Search > Email > Social > Purchase",
    "Social > Email > Purchase",
    "Search",                               # no conversion
]
conversions = [1, 1, 1, 0, 1, 1, 0, 1, 1, 0]

model = MarkovAttribution(
    order=1,
    removal_strategy="redirect_to_null",
    conversion_labels=["Purchase"],   # endpoint token, not a channel
    return_transition_matrix=True,
)
result = model.fit(journeys, conversions)

print({ch: round(share, 4) for ch, share in result["attribution"].items()})
print(f"P(conversion | start) = {result['total_conversion_probability']:.4f}")
print(model.summary())

Output (abridged — the summary continues with top transitions, drop-off bottlenecks, and path diagnostics):

{'Direct': 0.085, 'Email': 0.3912, 'Search': 0.1913, 'Social': 0.3324}
P(conversion | start) = 0.7000
Attribution Summary
Model: MarkovAttribution
Epoch source: static
Overall: 10 paths, 7 conversions, 70.00% conversion rate

Data
+-----------+-------+-------------+---------+--------------------------------------------+
| Cohort    | Paths | Conversions | Conv. % | Top channels                               |
+-----------+-------+-------------+---------+--------------------------------------------+
| All paths | 10    | 7           | 70.00%  | Email 39.12%, Social 33.24%, Search 19.13% |
+-----------+-------+-------------+---------+--------------------------------------------+

Removal effects (raw, signed) vs normalized shares
+---------+----------------+--------+
| Channel | Removal effect | Share  |
+---------+----------------+--------+
| Email   | +0.6571        | 39.12% |
| Social  | +0.5584        | 33.24% |
| Search  | +0.3214        | 19.13% |
| Direct  | +0.1429        | 8.50%  |
+---------+----------------+--------+

The Shapley model is a drop-in alternative on the same inputs. Note that attribution may be None — always check before indexing it:

from do_attribution import ShapleyAttribution

shapley = ShapleyAttribution(conversion_labels=["Purchase"]).fit(journeys, conversions)

if shapley["attribution"] is None:
    print(shapley["normalization_status"], shapley["normalization_reason"])
    # unavailable near_cancellation
    print({ch: round(v, 4) for ch, v in shapley["shapley_values"].items()})
    # {'Direct': -0.5833, 'Email': 0.25, 'Search': -0.0833, 'Social': 0.4167}
else:
    print({ch: round(share, 4) for ch, share in shapley["attribution"].items()})

On this dataset no journey touches all four channels, so v(N) = 0, the raw values cancel to float residue, and no valid share vector exists. The model reports that rather than dividing by the residue. The raw signed values are still returned — they are diagnostics on the game's own scale, not shares.

Lists, NumPy arrays, and pandas Series all work as path and conversion inputs.


Visual examples

The GA4 ecommerce example shows how the package allocates attribution across channels, represents journey transitions, compares model outputs, and identifies candidate temporal boundaries.

GA4 Markov attribution shares bar chart GA4 Markov transition graph
plot_attribution — Markov attribution shares by channel plot_transition_graph — fitted journey transitions with edges ≥ 3%
GA4 Markov vs Shapley comparison chart GA4 epoch recommendation diagnostic plot
plot_attribution_comparison — Markov and Shapley attribution on the same journeys plot_epoch_recommendation — candidate and retained epoch boundaries on the conversion-rate signal

Calibration diagnostics keep experimental evidence, constructed calibration ratios, direct adjustments, and policy-based residual allocation visually distinct.

Static calibration effects Temporal epoch and experiment alignment
Decomposes the calibrated estimate into original attribution, direct test adjustment, and residual allocation Shows where experimental evidence falls on caller-defined epochs without redefining the epoch boundaries
Temporal ratio schedule Temporal residual allocation
Distinguishes observed experimental anchors from constructed epoch-level calibration ratios Shows the required residual adjustment and its policy-based allocation across eligible recipients

Experimental calibration

LiftTestEvidence represents external experimental evidence for one tested entity: an observed lift and the attribution estimate matched to the same population, outcome, and window, plus identifying metadata and optional uncertainty and temporal coordinates. Its calibration ratio is:

calibration ratio = observed lift / matched attribution estimate

Optional standard_error is validated and retained on the LiftTestEvidence record as experimental evidence metadata and provenance. Deterministic point calibration does not use or propagate it: changing standard_error does not change the calibration ratio, direct adjustment, residual allocation, or calibrated total. Calibrated point outputs therefore do not supply confidence intervals, posterior uncertainty, or uncertainty-adjusted calibration ratios.

AttributionCalibration applies that evidence to caller-approved static attribution estimates. TemporalAttributionCalibration applies dated evidence to estimates on a caller-supplied, authoritative epoch grid; the evidence coordinates do not redefine the epochs.

Every static application requires an explicit residual policy. Every temporal application additionally requires an explicit ratio policy and epoch-ratio policy. The result keeps the tested entity's evidence-backed direct adjustment separate from any residual allocated to untested entities. Those recipient changes are policy allocations, not effects measured by the experiment. Interpolated schedule values and constant-policy values transported to declared epochs are constructed values, not new experimental observations.

This runnable example uses the full-period values and evidence identity from the calibration notebook:

from do_attribution import AttributionCalibration, LiftTestEvidence

attributed_conversions = {
    "Experimental Media": 1149.1519,
    "Organic Search": 760.3327,
    "Referral": 672.0069,
    "Direct": 591.4872,
    "Other": 193.9282,
    "Paid Search": 41.3767,
    "Organic Social": 7.8723,
    "Affiliate": 7.6895,
    "Email": 2.1544,
}

evidence = LiftTestEvidence(
    tested_entity="Experimental Media",
    observed_lift=1375.0555,
    matched_mta_estimate=attributed_conversions["Experimental Media"],
    test_id="experimental-media-ga4-full-eligible-period",
    outcome_name="journey conversion",
)

calibration = AttributionCalibration([evidence])
calibrated = calibration.apply_estimates(
    estimates=attributed_conversions,
    residual_policy="proportional_untested",
    eligible_entities=[
        entity
        for entity in attributed_conversions
        if entity != evidence.tested_entity
    ],
)

print(calibrated.summary())
figure = calibrated.plot_calibration_effects()

Calibration consumes attribution estimates that the caller has already reviewed and approved. It does not reinterpret Markov removal effects, Shapley values, or other attribution internals as causal effects, and it does not choose the experiment, matching design, or calibration policies for the caller.

See docs/calibration.md for the policy contract, docs/calibration_examples.md for static examples, and docs/calibration_examples_temporal.md for temporal schedules and epoch reduction.


Supported attribution methods

Markov removal-effect attribution

MarkovAttribution(order=k) models journeys as an absorbing Markov chain (START, channels or sliding k-tuples of channels, CONVERSION, NULL) and credits each channel by its removal effect, computed in closed form. Four operational definitions of removal are provided:

Strategy Population Behavior Raw-effect sign contract
"fail" Fixed: every original journey and weight is retained. Strip the channel, force affected conversions to failure, rebuild the chain, and recompute P(CONVERSION | START). No blanket sign guarantee; normalization is returned only when the shared share contract succeeds.
"fail_refit" Refit: only usable transformed journeys remain, so count and total weight may change. Preserve the legacy strip-and-refit procedure under an explicit name. Signed.
"detour" Refit after trimming; the usable population may change if trimming empties paths. Strip the channel while retaining original conversion flags, then refit. A negative effect means removing the channel lowered modelled conversion probability. Signed.
"redirect_to_null" Fixed fitted transition matrix; no journey refit. Redirect the removed channel's transition mass to NULL on the fitted matrix and recompute absorption analytically — the classic removal-effect definition. Nonnegative by construction.

Under fixed-population "fail", removing the selected channel can leave a journey with no modeled channel states. That journey is retained with its original weight as an explicit START -> NULL failure; it is never silently discarded.

"fail_refit" keeps the previous strip-and-refit procedure: emptied transformed paths are dropped before the chain is rebuilt. The transformed journey count and total weight can therefore be smaller than the originals, so its result answers a different population question from "fail". result["population_diagnostics"] reports both counts, both total weights, the number of emptied and dropped paths, and the empty-path rule for every removed channel.

Migration is explicit when preserving the previous behavior:

paths = ["A"] * 4 + ["B"] * 4
converted = [1, 1, 1, 0, 1, 0, 0, 0]

fixed = MarkovAttribution(removal_strategy="fail").fit(paths, converted)
legacy_refit = MarkovAttribution(
    removal_strategy="fail_refit"
).fit(paths, converted)

fixed["population_diagnostics"]["A"]["transformed_journey_count"]         # 8
legacy_refit["population_diagnostics"]["A"]["transformed_journey_count"]  # 4

Raw effects from fail, fail_refit, and detour are diagnostics rather than probabilities. Signed effects from fail_refit or detour do not necessarily define a probability distribution and must not be interpreted as percentages. The model normalizes raw effects only when they satisfy the shared finite, nonnegative, bounded, unit-sum contract. If signed effects cancel at or below the scale-aware tolerance, attribution is None; normalization_status, normalization_reason, and the raw-sum, magnitude, denominator, and cancellation-ratio diagnostics explain why while removal_effects remain available.

Higher orders (order=2, order=3, …) use sliding tuples as states; short paths are left-padded with START. fit returns attribution, removal_effects, total_conversion_probability, and (opt-in) the transition_matrix.

Shapley coalition attribution

ShapleyAttribution uses the value function v(S) = conversions on paths whose unique channel set is exactly S and exact factorial weights. fit returns attribution, raw signed shapley_values, and the observed value_function:

shapley["value_function"]
# {('Email', 'Social'): 2, ('Email', 'Search'): 1, ('Social',): 1, ...}

Raw signed shapley_values and normalized attribution are separate outputs with separate contracts:

Output Contract
shapley_values Always present. Signed, on the game's own scale. Not shares, percentages, or probabilities.
attribution A share mapping when available, otherwise None. Every value is finite and in [0, 1], and the values sum to 1.

Because no journey may touch every channel, the efficiency axiom forces the raw values to sum to v(N) = 0 by construction. Dividing by that float residue produces numerically explosive output, so normalization is refused instead: attribution is None, a warning is emitted, and the fit still succeeds with its raw values and diagnostics intact.

shapley["normalization_status"]      # 'available' | 'unavailable'
shapley["normalization_reason"]      # e.g. 'near_cancellation'
shapley["raw_sum"]                   # ~1e-17 — float residue, not a real total
shapley["raw_magnitude"]             # 1.3333
shapley["cancellation_ratio"]        # ~1e-17 — far below the 1e-12 threshold

The exact residue in raw_sum is not reproducible across summation orders, which is precisely why the scale-aware ratio, rather than the bare sum, drives the decision.

Normalization is unavailable when the raw magnitude is zero, when the scale-aware cancellation ratio |Σrᵢ| / Σ|rᵢ| falls at or below 1e-12, or when the candidate shares leave [0, 1] or fail to sum to 1. The reason codes are zero_raw_magnitude, near_cancellation, nonfinite_result, negative_share, share_above_one, and invalid_share_sum.

Attributed conversions are only defined when attribution is available; they are never derived from raw signed values.

Side-by-side comparison

from do_attribution import CompareAttribution

comparison = CompareAttribution(conversion_labels=["Purchase"])
comparison.fit(journeys, conversions)

comparison.attribution_frame()      #           markov  shapley
                                    # Direct  0.026316      NaN
                                    # Email   0.394737      NaN
                                    # Search  0.201754      NaN
                                    # Social  0.377193      NaN
comparison.unavailable_models()     # {'shapley': 'near_cancellation'}
comparison.difference_frame("markov")

The two methods answer different statistical questions and can disagree materially without either being defective — difference_frame makes the disagreement explicit. extra_models={...} plugs any additional BaseAttribution implementation into the same aligned tables, so first-touch, last-touch, or any custom baseline can sit beside the two built-in models.

Conversion-endpoint declaration

Terminal outcome tokens ("Purchase", "Sale", …) are declared, not guessed: conversion_labels=["Purchase"] strips them before channel extraction, state construction, coalition enumeration, reporting, and plotting. Labels colliding with the reserved START/CONVERSION/NULL tokens raise ValueError; conversion_labels=None treats every token as a channel.

Temporal epoch analysis

TemporalMarkovAttribution and TemporalShapleyAttribution are chronological orchestrators: each complete path is assigned to exactly one epoch by a single anchor timestamp, each epoch gets an independent static fit, and adjacent epochs share nothing — jumps at boundaries are valid outputs of a discontinuous piecewise model, not smoothing artifacts.

import pandas as pd
from do_attribution import TemporalShapleyAttribution

anchors = pd.to_datetime([
    "2025-01-05", "2025-01-12", "2025-02-08", "2025-02-17", "2025-04-03",
    "2025-04-21", "2025-07-09", "2025-07-19", "2025-10-02", "2025-10-18",
])

by_quarter = TemporalShapleyAttribution(
    freq="Q",                      # calendar buckets; per-year mappings supported
    min_samples_per_epoch=1,       # demo-sized floor
    conversion_labels=["Purchase"],
)
by_quarter.fit(journeys, anchors, conversions)

by_quarter.get_trend("Email")             # share by epoch; NaN where unavailable
tables = by_quarter.to_dataframes()       # flat DataFrames, ready for Parquet/SQL
list(tables)
# ['attribution', 'shapley_values', 'epoch_summary', 'channel_epoch_sample_sizes']

The Markov variant additionally exports removal_effects, conversion_probability, and transition_matrices tables. Three entry points cover epoch selection:

  • fit(paths, timestamps, conversions) — calendar frequency buckets;
  • fit_by_epoch(paths, epoch_labels, conversions) — explicit labels;
  • fit_recommended(paths, conversions, anchor_timestamps) — data-driven boundaries from recommend_epochs, which segments a global daily conversion-rate signal with a penalized piecewise-affine detector (weekly/multi-seasonal STL decomposition, robust second-difference scale with serial-correlation correction, PELT with a custom weighted affine cost), enforces minimum epoch width and path-count floors, and returns auditable left-closed, right-open boundaries. A Markov-only signal="transition_drift" variant segments aligned order-1 transition matrices. plot_epoch_recommendation renders the fitted segment geometry, kept and rejected knots, and per-epoch path counts.

Sample-size diagnostics (get_epoch_summary, get_channel_epoch_sample_sizes, order- and strategy-aware effective floors, and the suggest_freq calendar sanity check) flag thin epochs before you interpret an adjacent-epoch jump. After fitting, detect_threshold_crossings(channel, threshold=...) returns adjacent epoch changes whose absolute delta exceeds the caller-supplied threshold. This is a descriptive comparison, not a statistical test. detect_significant_changes(...) remains temporarily available as a deprecated compatibility alias.

Reporting and export

Every fitted model — static, temporal, or a standalone recommendation — renders deterministic plain text:

print(model.summary())                          # compact ASCII tables
print(model.summary(output_format="report"))    # natural-language narrative

Summaries distill rather than dump: bounded top-k detail tables (signed removal effects, top transitions and drop-offs, top coalitions, path diagnostics) with the complete grids available programmatically via to_dataframes() and the get_* accessors. The reporting language is descriptive; it never labels a movement statistically significant or causal.


Detailed examples

All six notebooks run end to end on the same GA4 journey extract (examples/ga4_attribution_journeys.csv) — a transformed journey-level extract created from Google's public obfuscated GA4 ecommerce sample (Google Merchandise Store): one row per journey with an ordered channel path, a binary conversion flag, and an anchor timestamp.

Example Shows
examples/basic_markov.ipynb static Markov fit, shares, removal effects, summary, transition graph
examples/basic_shapley.ipynb Shapley shares, raw values, coalition results, coalition plot
examples/side_by_side.ipynb CompareAttribution aligned tables, differences, comparison plot
examples/temporal_markov.ipynb weekly Markov epochs, diagnostics, to_dataframes(), epoch recommendation
examples/temporal_shapley.ipynb weekly Shapley epochs, channel trends, diagnostics, epoch recommendation
examples/attribution_experiment_calibration.ipynb seeded semi-synthetic randomized campaign on the GA4 rows, matched static and temporal Markov estimates, calibration, and seven diagnostics

Project status

do-attribution is in pre-1.0 development. The public API may change before version 0.1.


License and attribution

MIT © 2026 Aaron Goodin — see LICENSE.

The bundled example dataset and the figures generated from it derive from Google's public obfuscated GA4 ecommerce sample (Google Merchandise Store), transformed into a journey-level extract for demonstration. The calibration example additionally uses deterministic semi-synthetic randomized campaign assignment and lift-test outcomes generated by this project; those experimental variables are not part of Google's source dataset. Attribution outputs describe statistical association under each model's assumptions; they are not causal effect estimates.

Metadata

Release files for do-attribution 0.0.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for do-attribution 0.0.2
File Size Uploaded
do_attribution-0.0.2.tar.gz 6.5 MB Details

Built distribution (wheel)

Table of built distributions (wheels) for do-attribution 0.0.2
File Interpreter ABI Platform
do_attribution-0.0.2-py3-none-any.whl Python 3 none any Details

Total release size: 6.6 MB

Release files / do_attribution-0.0.2.tar.gz

Download URL do_attribution-0.0.2.tar.gz
Size 6.5 MB
Tags Source
SHA-256 checksum
How to use checksums
a909b6c3f1f3e04373ebb995b014948b9b62ff1f75106a50fa2a7b54b2689efe
BLAKE2b-256 checksum
How to use checksums
671ebe8806cc0bb8cb01876e0cd128f045e405ae60d1a0c6297194e9dc770028
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.13

Release files / do_attribution-0.0.2-py3-none-any.whl

Download URL do_attribution-0.0.2-py3-none-any.whl
Size 163.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
793320da4a27e9549731543842a4643364c76183c6c292c30e803ab97fc229e5
BLAKE2b-256 checksum
How to use checksums
0b756cd61de08a4134d17dd83108725e78c2ffc7f5e1d5ffad5a0d11c3b4a1c9
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.13

Release history Release notifications | RSS feed

This release

0.0.2 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page