Estimands

Named, serializable estimand system: declarative counterfactual quantities (contribution ROI, marginal ROAS, contributions) realized as mean + HDI.

First-class, declarative estimands — the counterfactual causal lens.

A model declares named, serializable Estimand objects; the framework realizes them from the posterior as mean + HDI, either post-hoc (EstimandEvaluator) or in the PyMC graph (build_estimand_expr()). This is the single registry that subsumes the framework’s previously-scattered estimand logic (decomposition ROI, counterfactual ROI, marginal ROAS, calibration estimands) while keeping every existing number bit-stable.

spec is dependency-light (pure Pydantic); the realization engines and the registry import numpy/pytensor lazily, so importing this package is cheap.

class mmm_framework.estimands.Constant(**data)[source]

Bases: BaseModel

A fixed scalar.

type: Literal['constant']
value: float
model_config: ClassVar[ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class mmm_framework.estimands.Contrast(**data)[source]

Bases: BaseModel

reduce_over(op(quantity|intervention, quantity|baseline)).

With baseline=None the baseline is the factual world (Observed). paired_seed is a post-hoc realization hint: when set and no explicit seed is supplied, the two worlds share one synthesized seed so observation noise cancels in the per-draw difference (the marginal path); the in-graph engine ignores it.

quantity: Quantity
intervention: Intervention
baseline: Intervention | None
op: Literal['difference', 'ratio', 'identity']
reduce: Literal['sum', 'mean']
over: list[str]
paired_seed: bool
model_config: ClassVar[ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class mmm_framework.estimands.Contribution(**data)[source]

Bases: BaseModel

A channel’s contribution.

source="counterfactual" realizes it as outcome|observed outcome|zero(target) (a Contrast); source="in_graph_deterministic" reads the in-graph channel_contributions Deterministic from the posterior (the dashboard decomposition). The two are different numbers — see the registry’s counterfactual_roi vs contribution_roi.

type: Literal['contribution']
target: str
source: Literal['counterfactual', 'in_graph_deterministic']
model_config: ClassVar[ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class mmm_framework.estimands.CustomIntervention(**data)[source]

Bases: BaseModel

An intervention realized by a registered callable, keyed by ref.

type: Literal['custom']
ref: str
params: dict[str, Any]
model_config: ClassVar[ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class mmm_framework.estimands.Estimand(**data)[source]

Bases: BaseModel

A named, serializable counterfactual estimand.

The headline value is numerator (a Contrast or bare Quantity), optionally divided by denominator. mean / hdi_low / hdi_high in the result are in units; companion quantities (the raw contribution + its HDI, spend, contribution_pct) land in result.extra.

name: str
kind: str
numerator: Contrast | Quantity
denominator: Contrast | Quantity | None
op_ratio_zero_denominator: Literal['zero', 'skip', 'nan']
window: TimeWindow | None
hdi_prob: float
summaries: list[Summary]
realization: Realization
required_capabilities: list[str]
units: str
causal_assumptions: str
schema_version: str
to_dict()[source]

JSON-ready dict (mirrors ExperimentMeasurement.to_dict).

Return type:

dict[str, Any]

classmethod from_dict(data)[source]

Parse a serialized estimand (mirrors ExperimentMeasurement.from_dict).

Return type:

Estimand

model_config: ClassVar[ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class mmm_framework.estimands.EstimandResult(**data)[source]

Bases: BaseModel

Realized value of an Estimand from a fitted posterior.

status="unsupported" (with reason) is returned — never raised — when required_capabilities are not met, mirroring the agent ops’ _err shape. mean/hdi_* are None for an unsupported result.

name: str
kind: str
status: Literal['ok', 'unsupported']
mean: float | None
hdi_low: float | None
hdi_high: float | None
hdi_prob: float
units: str
extra: dict[str, Any]
reason: str
to_dict()[source]
Return type:

dict[str, Any]

model_config: ClassVar[ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class mmm_framework.estimands.LatentVar(**data)[source]

Bases: BaseModel

A named latent posterior variable (e.g. an awareness state).

type: Literal['latent_var']
name: str
model_config: ClassVar[ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class mmm_framework.estimands.MarginalSpend(**data)[source]

Bases: BaseModel

The incremental spend implied by a ScaleInput.

intervention_ref="numerator" reads the numerator contrast’s intervention factor (never a second literal percentage — a desync guard): the marginal denominator is current_spend * (factor - 1).

type: Literal['marginal_spend']
target: str
intervention_ref: str
model_config: ClassVar[ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class mmm_framework.estimands.Observed(**data)[source]

Bases: BaseModel

The factual world: inputs as observed (no intervention).

type: Literal['observed']
model_config: ClassVar[ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class mmm_framework.estimands.ObservedInput(**data)[source]

Bases: BaseModel

An observed input level (spend), used as a denominator.

source="raw" sums X_media_raw[window, target]; source="panel" uses the reporting spend extractor (panel.X_media). The dashboard ROI uses panel; the counterfactual / marginal paths use raw.

type: Literal['observed_input']
target: str
source: Literal['raw', 'panel']
model_config: ClassVar[ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class mmm_framework.estimands.Outcome(**data)[source]

Bases: BaseModel

The model’s predicted outcome (KPI), in original scale.

type: Literal['outcome']
model_config: ClassVar[ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class mmm_framework.estimands.Realization(**data)[source]

Bases: BaseModel

Per-estimand realization knobs that pin bit-stable arithmetic.

These encode where a naive engine diverges between the four legacy notions (see technical-docs/estimands.md), kept separate from the causal definition above:

  • point_rule"diff_of_means" (point = reduced per-call means then op; the counterfactual / marginal paths) vs "mean_of_samples" (point = mean of the per-draw ratio; the dashboard decomposition ROI).

  • hdi_method"percentile" (compute_hdi_bounds), "az_hdi" (az.hdi w/ percentile fallback; the dashboard) or "finite_percentile" (_hdi_finite; the marginal path, filters non-finite draws).

point_rule: Literal['diff_of_means', 'mean_of_samples']
hdi_method: Literal['percentile', 'az_hdi', 'finite_percentile']
model_config: ClassVar[ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class mmm_framework.estimands.ScaleInput(**data)[source]

Bases: BaseModel

Multiply target’s input by factor (e.g. 1.1 for +10%).

type: Literal['scale_input']
target: str
factor: float
model_config: ClassVar[ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class mmm_framework.estimands.SetInput(**data)[source]

Bases: BaseModel

Set target’s input to a fixed value.

sustained / carryover_state carry the off-panel adstock convention for the in-graph engine (steady-state vs cold-start ramp); the post-hoc engine sets the column to value over the active window.

type: Literal['set_input']
target: str
value: float
sustained: bool
carryover_state: Literal['steady_state', 'cold_start'] | None
model_config: ClassVar[ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class mmm_framework.estimands.Summary(**data)[source]

Bases: BaseModel

A posterior tail probability over the estimand’s per-draw samples.

side="gt"/"lt" with threshold carries prob_positive (gt 0) and prob_profitable (gt 1).

kind: Literal['tail_prob']
name: str
threshold: float
side: Literal['gt', 'lt']
model_config: ClassVar[ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class mmm_framework.estimands.SupportsEstimands(*args, **kwargs)[source]

Bases: Protocol

The surface an Estimand is realized against.

BayesianMMM implements it today; a future non-MMM family implements the same four members (declaring its own capabilities + quantities) to gain the whole estimand stack. predict_under returns a PredictionResults-like object exposing y_pred_mean and y_pred_samples.

channel_names: list[str]
declared_estimands: list[Estimand]
predict_under(intervention, time_period=Ellipsis, random_seed=Ellipsis)[source]
Return type:

Any

model_capabilities()[source]
Return type:

set[str]

evaluate_estimands(estimands=Ellipsis)[source]
Return type:

dict[str, EstimandResult]

__init__(*args, **kwargs)
class mmm_framework.estimands.TimeWindow(**data)[source]

Bases: BaseModel

An inclusive [start, end] window in period-index units.

Lives at the Estimand level so the numerator and denominator share one time mask (BayesianMMM._get_time_mask((start, end))).

start: int
end: int
as_tuple()[source]
Return type:

tuple[int, int]

model_config: ClassVar[ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class mmm_framework.estimands.ZeroInput(**data)[source]

Bases: BaseModel

Set target’s input to zero (the channel-off counterfactual).

type: Literal['zero_input']
target: str
model_config: ClassVar[ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

mmm_framework.estimands.__getattr__(name)[source]

Lazily expose the heavier members (numpy/pytensor) on first access.

Uses import_module (not from . import) so binding the submodule name does not re-enter this hook (_handle_fromlist__getattr__).

Spec

Declarative estimand specification — the counterfactual causal lens.

An Estimand is a named, serializable counterfactual contrast:

reduce_over( op( quantity | intervention,  quantity | baseline ) ) / normalizer

It is the single source of truth shared by the two realization engines:

  • mmm_framework.estimands.evaluatepost-hoc realization (numpy; reads the posterior-predictive via BayesianMMM.predict_under(), paired/unpaired seeds, boolean time masks).

  • mmm_framework.estimands.graphin-graph realization (pytensor; builds a likelihood-time scalar; deterministic, integer-indexed windows).

This module imports neither numpy nor pytensor so it can be loaded anywhere (host or kernel) for serialization / validation without paying the model-stack import cost — mirroring mmm_framework.garden.contract.

The schema is intentionally model-agnostic: a future non-MMM family (CFA / LCA / EFA) declares its own estimands over its own quantities (a latent factor, a class membership) and gates them with Estimand.required_capabilities. The MMM built-ins live in mmm_framework.estimands.registry.

mmm_framework.estimands.spec.ESTIMAND_SCHEMA_VERSION = '1.0'

Bumped when the serialized estimand surface changes. Stored on every Estimand so a consumer (a reloaded model, a saved spec) can detect drift.

mmm_framework.estimands.spec.ALL_CHANNELS = '*'

Sentinel target meaning “expand this estimand once per channel”. The evaluator substitutes each channel name into every wildcard target and emits one result per channel (keyed "{name}:{channel}"). A non-MMM model with no channels yields nothing — natural capability filtering.

class mmm_framework.estimands.spec.Observed(**data)[source]

Bases: BaseModel

The factual world: inputs as observed (no intervention).

type: Literal['observed']
model_config: ClassVar[ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class mmm_framework.estimands.spec.ZeroInput(**data)[source]

Bases: BaseModel

Set target’s input to zero (the channel-off counterfactual).

type: Literal['zero_input']
target: str
model_config: ClassVar[ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class mmm_framework.estimands.spec.ScaleInput(**data)[source]

Bases: BaseModel

Multiply target’s input by factor (e.g. 1.1 for +10%).

type: Literal['scale_input']
target: str
factor: float
model_config: ClassVar[ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class mmm_framework.estimands.spec.SetInput(**data)[source]

Bases: BaseModel

Set target’s input to a fixed value.

sustained / carryover_state carry the off-panel adstock convention for the in-graph engine (steady-state vs cold-start ramp); the post-hoc engine sets the column to value over the active window.

type: Literal['set_input']
target: str
value: float
sustained: bool
carryover_state: Literal['steady_state', 'cold_start'] | None
model_config: ClassVar[ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class mmm_framework.estimands.spec.CustomIntervention(**data)[source]

Bases: BaseModel

An intervention realized by a registered callable, keyed by ref.

type: Literal['custom']
ref: str
params: dict[str, Any]
model_config: ClassVar[ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class mmm_framework.estimands.spec.Outcome(**data)[source]

Bases: BaseModel

The model’s predicted outcome (KPI), in original scale.

type: Literal['outcome']
model_config: ClassVar[ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class mmm_framework.estimands.spec.Contribution(**data)[source]

Bases: BaseModel

A channel’s contribution.

source="counterfactual" realizes it as outcome|observed outcome|zero(target) (a Contrast); source="in_graph_deterministic" reads the in-graph channel_contributions Deterministic from the posterior (the dashboard decomposition). The two are different numbers — see the registry’s counterfactual_roi vs contribution_roi.

type: Literal['contribution']
target: str
source: Literal['counterfactual', 'in_graph_deterministic']
model_config: ClassVar[ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class mmm_framework.estimands.spec.ObservedInput(**data)[source]

Bases: BaseModel

An observed input level (spend), used as a denominator.

source="raw" sums X_media_raw[window, target]; source="panel" uses the reporting spend extractor (panel.X_media). The dashboard ROI uses panel; the counterfactual / marginal paths use raw.

type: Literal['observed_input']
target: str
source: Literal['raw', 'panel']
model_config: ClassVar[ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class mmm_framework.estimands.spec.MarginalSpend(**data)[source]

Bases: BaseModel

The incremental spend implied by a ScaleInput.

intervention_ref="numerator" reads the numerator contrast’s intervention factor (never a second literal percentage — a desync guard): the marginal denominator is current_spend * (factor - 1).

type: Literal['marginal_spend']
target: str
intervention_ref: str
model_config: ClassVar[ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class mmm_framework.estimands.spec.LatentVar(**data)[source]

Bases: BaseModel

A named latent posterior variable (e.g. an awareness state).

type: Literal['latent_var']
name: str
model_config: ClassVar[ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class mmm_framework.estimands.spec.Constant(**data)[source]

Bases: BaseModel

A fixed scalar.

type: Literal['constant']
value: float
model_config: ClassVar[ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class mmm_framework.estimands.spec.Contrast(**data)[source]

Bases: BaseModel

reduce_over(op(quantity|intervention, quantity|baseline)).

With baseline=None the baseline is the factual world (Observed). paired_seed is a post-hoc realization hint: when set and no explicit seed is supplied, the two worlds share one synthesized seed so observation noise cancels in the per-draw difference (the marginal path); the in-graph engine ignores it.

quantity: Quantity
intervention: Intervention
baseline: Intervention | None
op: Literal['difference', 'ratio', 'identity']
reduce: Literal['sum', 'mean']
over: list[str]
paired_seed: bool
model_config: ClassVar[ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class mmm_framework.estimands.spec.Summary(**data)[source]

Bases: BaseModel

A posterior tail probability over the estimand’s per-draw samples.

side="gt"/"lt" with threshold carries prob_positive (gt 0) and prob_profitable (gt 1).

kind: Literal['tail_prob']
name: str
threshold: float
side: Literal['gt', 'lt']
model_config: ClassVar[ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class mmm_framework.estimands.spec.Realization(**data)[source]

Bases: BaseModel

Per-estimand realization knobs that pin bit-stable arithmetic.

These encode where a naive engine diverges between the four legacy notions (see technical-docs/estimands.md), kept separate from the causal definition above:

  • point_rule"diff_of_means" (point = reduced per-call means then op; the counterfactual / marginal paths) vs "mean_of_samples" (point = mean of the per-draw ratio; the dashboard decomposition ROI).

  • hdi_method"percentile" (compute_hdi_bounds), "az_hdi" (az.hdi w/ percentile fallback; the dashboard) or "finite_percentile" (_hdi_finite; the marginal path, filters non-finite draws).

point_rule: Literal['diff_of_means', 'mean_of_samples']
hdi_method: Literal['percentile', 'az_hdi', 'finite_percentile']
model_config: ClassVar[ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class mmm_framework.estimands.spec.TimeWindow(**data)[source]

Bases: BaseModel

An inclusive [start, end] window in period-index units.

Lives at the Estimand level so the numerator and denominator share one time mask (BayesianMMM._get_time_mask((start, end))).

start: int
end: int
as_tuple()[source]
Return type:

tuple[int, int]

model_config: ClassVar[ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class mmm_framework.estimands.spec.Estimand(**data)[source]

Bases: BaseModel

A named, serializable counterfactual estimand.

The headline value is numerator (a Contrast or bare Quantity), optionally divided by denominator. mean / hdi_low / hdi_high in the result are in units; companion quantities (the raw contribution + its HDI, spend, contribution_pct) land in result.extra.

name: str
kind: str
numerator: Contrast | Quantity
denominator: Contrast | Quantity | None
op_ratio_zero_denominator: Literal['zero', 'skip', 'nan']
window: TimeWindow | None
hdi_prob: float
summaries: list[Summary]
realization: Realization
required_capabilities: list[str]
units: str
causal_assumptions: str
schema_version: str
to_dict()[source]

JSON-ready dict (mirrors ExperimentMeasurement.to_dict).

Return type:

dict[str, Any]

classmethod from_dict(data)[source]

Parse a serialized estimand (mirrors ExperimentMeasurement.from_dict).

Return type:

Estimand

model_config: ClassVar[ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class mmm_framework.estimands.spec.EstimandResult(**data)[source]

Bases: BaseModel

Realized value of an Estimand from a fitted posterior.

status="unsupported" (with reason) is returned — never raised — when required_capabilities are not met, mirroring the agent ops’ _err shape. mean/hdi_* are None for an unsupported result.

name: str
kind: str
status: Literal['ok', 'unsupported']
mean: float | None
hdi_low: float | None
hdi_high: float | None
hdi_prob: float
units: str
extra: dict[str, Any]
reason: str
to_dict()[source]
Return type:

dict[str, Any]

model_config: ClassVar[ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class mmm_framework.estimands.spec.SupportsEstimands(*args, **kwargs)[source]

Bases: Protocol

The surface an Estimand is realized against.

BayesianMMM implements it today; a future non-MMM family implements the same four members (declaring its own capabilities + quantities) to gain the whole estimand stack. predict_under returns a PredictionResults-like object exposing y_pred_mean and y_pred_samples.

channel_names: list[str]
declared_estimands: list[Estimand]
predict_under(intervention, time_period=Ellipsis, random_seed=Ellipsis)[source]
Return type:

Any

model_capabilities()[source]
Return type:

set[str]

evaluate_estimands(estimands=Ellipsis)[source]
Return type:

dict[str, EstimandResult]

__init__(*args, **kwargs)

Registry

Canonical built-in estimands + capability-keyed defaults.

Each built-in is a faithful, serializable re-expression of one number the framework already produces (the four legacy notions) plus two demonstrators that prove the schema generalizes beyond ROI. The realization knobs (Realization) pin the bit-stable arithmetic — see technical-docs/estimands.md and the equivalence tests.

The four legacy notions, by built-in:

  • contribution_roireporting.helpers.roi.compute_roi_with_uncertainty (the dashboard decomposition ROI — reads the in-graph Deterministic; the number the UI shows).

  • counterfactual_roianalysis.MMMAnalyzer.compute_channel_roi (zero-out predict; a different number).

  • marginal_roasmodel.compute_marginal_contributions.

  • contributionmodel.compute_counterfactual_contributions totals.

Two demonstrators: awareness_lift (mean lift, no denominator) and cost_per_conversion (inverted ratio: spend / incremental conversions).

mmm_framework.estimands.registry.BUILTINS = {'awareness_lift': <function _awareness_lift>, 'contribution': <function _contribution>, 'contribution_roi': <function _contribution_roi>, 'cost_per_conversion': <function _cost_per_conversion>, 'counterfactual_roi': <function _counterfactual_roi>, 'marginal_roas': <function _marginal_roas>}

name -> zero-arg factory returning a fresh Estimand (avoids shared mutable state across callers).

mmm_framework.estimands.registry.DEFAULT_NAMES = ['contribution_roi', 'marginal_roas', 'contribution']

The MMM default list surfaced when a model declares no estimands of its own.

mmm_framework.estimands.registry.DEFAULT_MARGINAL_FACTOR = 1.1

Default +10% perturbation for the marginal estimand (matches compute_marginal_contributions’ default spend_increase_pct=10).

mmm_framework.estimands.registry.get(name)[source]

A fresh copy of the named built-in estimand. Raises KeyError if unknown.

Return type:

Estimand

mmm_framework.estimands.registry.all_builtins()[source]

Fresh copies of every built-in estimand.

Return type:

list[Estimand]

mmm_framework.estimands.registry.defaults_for(capabilities)[source]

Default estimands whose required_capabilities are met by capabilities.

Keyed by capability, not class name, so a non-MMM model that lacks (say) HAS_CONTRIBUTION_DETERMINISTIC auto-filters contribution_roi rather than erroring.

Return type:

list[Estimand]

mmm_framework.estimands.registry.latent_scalar(name, *, var=None, kind='latent', units='', hdi_prob=0.94, causal_assumptions='Posterior summary of a fitted latent quantity.')[source]

A declarative estimand that summarizes the scalar posterior variable var (default: name). Used by non-MMM families to surface fit indices, factor loadings, class sizes, etc. through the estimand engine.

Return type:

Estimand

mmm_framework.estimands.registry.fit_index(name, *, var=None)[source]

A model fit index (e.g. cfi, tli, srmr) read per draw.

Return type:

Estimand

mmm_framework.estimands.registry.factor_loading(name, *, var=None)[source]

A single (named, scalar) factor loading read per draw.

Return type:

Estimand

Evaluate

Post-hoc estimand realization from a fitted posterior.

EstimandEvaluator walks an Estimand and realizes it as a mean + HDI (+ tail-probability summaries) from the fitted model, via BayesianMMM.predict_under() (per-draw posterior-predictive) and the in-graph channel_contributions Deterministic.

Bit-stability is the contract: each built-in reproduces its legacy number to the bit under a fixed seed. This is achieved by (a) reusing the exact legacy sample-extraction helpers for the decomposition path and (b) per-estimand realization knobs (point arithmetic, HDI function, seed pairing, spend source) — see Realization and technical-docs/estimands.md.

This engine is numpy-only and post-hoc; the in-graph (pytensor) realization lives in mmm_framework.estimands.graph and the two never call into each other (they share only spec.py + the tiny window/reduce helpers here).

class mmm_framework.estimands.evaluate.EstimandEvaluator(model, *, random_seed=None)[source]

Bases: object

Realize estimands against a fitted model.

Parameters

model:

A fitted BayesianMMM (or anything implementing SupportsEstimands).

random_seed:

Seed threaded to every predict_under call. None reproduces the legacy default (unpaired counterfactual draws; a single synthesized seed for paired/marginal contrasts).

__init__(model, *, random_seed=None)[source]
evaluate(estimands)[source]

Realize estimands; wildcard-target estimands expand per channel.

Return type:

dict[str, EstimandResult]

Graph

In-graph estimand realization (PyTensor) — likelihood-time scalars.

This is the in-graph counterpart to mmm_framework.estimands.evaluate: it assembles a model-implied estimand as a PyTensor expression to be compared against an experiment measurement inside the PyMC graph. It is the canonical home of build_estimand_expr() (subsuming mmm_framework.calibration.likelihood.build_estimand_expr, which now delegates here).

The two realizations deliberately share only spec and never call into each other: this one is deterministic, integer-indexed and scale-folded (delta = (pert base) * scale), whereas the post-hoc engine is numpy, paired/unpaired-seeded and boolean-masked. pytensor is imported lazily so importing the package stays cheap.

The contribution-window inputs (contrib_window / contrib_window_pert) are produced by the caller — the in-panel masked sum or the off-panel global-curve evaluation (BayesianMMM._offpanel_contribution_std) — so the eval_spend / adstock_state branches live with the model that owns the channel handle; this module is the estimand algebra over those windows.

mmm_framework.estimands.graph.build_estimand_expr(estimand, *, contrib_window, spend_window, scale=1.0, contrib_window_pert=None, lift=None)[source]

Assemble a model-implied estimand from a window’s contribution.

contrib_window is the summed per-obs contribution over the experiment window in model units; scale converts it to the estimand’s natural scale (y_std for a standardized model, 1.0 for a raw-y model). spend_window is the observed window spend (the ROAS / marginal-spend denominator).

For MROAS, contrib_window_pert is the contribution under spend scaled by (1 + lift) within the window; the result is the incremental KPI per incremental dollar.

Returns a PyTensor scalar. Bit-identical to the historical calibration.likelihood.build_estimand_expr (which now delegates here).

Return type:

Any

Capabilities

Model capability detection for estimand gating.

model_capabilities() returns a set[str] of capability flags by cheap duck-typing — no graph build, no posterior-predictive sampling. An Estimand declares required_capabilities; the evaluator returns an unsupported result when they are not met (so a non-MMM model auto-filters MMM-only estimands rather than crashing).

Capabilities are plain strings; parameterized ones use "NS:arg" (e.g. "HAS_LATENT:awareness") so a typed need — a future CFA’s "HAS_FACTOR_LOADINGS" — is gateable without a schema change.

mmm_framework.estimands.capabilities.model_capabilities(model)[source]

Capability flags for model (safe on an unfitted model).

Return type:

set[str]

Parameters

model:

Any object duck-typing the MMM surface (channel_names, optionally a fitted _trace).

mmm_framework.estimands.capabilities.missing_capabilities(required, available)[source]

The subset of required not present in available (order-preserving).

Return type:

list[str]