"""Enumerations for dimensions, variable roles, transforms, priors, and inference."""
from __future__ import annotations
from enum import Enum
[docs]
class DimensionType(str, Enum):
"""Standard MFF dimensions."""
PERIOD = "Period"
GEOGRAPHY = "Geography"
PRODUCT = "Product"
CAMPAIGN = "Campaign"
OUTLET = "Outlet"
CREATIVE = "Creative"
[docs]
class VariableRole(str, Enum):
"""Role of a variable in the model."""
KPI = "kpi"
MEDIA = "media"
CONTROL = "control"
AUXILIARY = "auxiliary" # For allocation weights, etc.
[docs]
class MeasurementUnit(str, Enum):
"""How a media channel's modeled variable is measured.
This drives how the channel's efficiency is reported, **not** how its
response curve is fit (the curve is always fit on the modeled variable):
- ``SPEND`` (the default, backward-compatible): the modeled variable *is*
dollars spent, so ROI / marginal ROAS divide the channel's incremental
KPI by the summed spend exactly as before.
- ``IMPRESSIONS`` / ``CLICKS``: the modeled variable is a *volume*, not
dollars. ROI cannot be formed by summing the variable. If a cost is known
(a separate ``spend_column``, or a ``cpm`` / ``cpc`` constant) the volume
is converted to a spend series and normal ROI / mROAS are reported;
otherwise the framework reports **efficiency** instead — incremental KPI
per 1,000 impressions (or per click) and the marginal efficiency of an
extra 1,000 impressions (or click), whose break-even reference is 0, not
1.0.
- ``OTHER``: a volume with no natural per-1,000 unit; treated like
``IMPRESSIONS`` for efficiency but labeled per unit.
See :class:`mmm_framework.config.variables.MediaChannelConfig` for the
companion ``spend_column`` / ``cpm`` / ``cpc`` fields and
:mod:`mmm_framework.reporting.helpers.measurement` for the resolver that
turns this into a divisor + metric labels.
"""
SPEND = "spend"
IMPRESSIONS = "impressions"
CLICKS = "clicks"
OTHER = "other"
@property
def is_spend(self) -> bool:
return self is MeasurementUnit.SPEND
[docs]
class CausalControlRole(str, Enum):
"""Causal role of a *control* variable, used to prevent "bad control" bias.
The functional :class:`VariableRole` (``CONTROL``) says a variable is a
regressor; it says nothing about *why* it is in the model. Conditioning on
the wrong kind of variable induces bias rather than removing it (see the
confounder-vs-precision-control proof in the project README). This enum lets
the framework distinguish the four causal kinds so it can route each one
correctly:
- ``CONFOUNDER``: a common cause of media and the KPI. Must be included and
must **not** be shrunk toward zero (shrinking a confounder biases the media
coefficient). Routed to a wide, un-shrunk coefficient prior.
- ``PRECISION_CONTROL``: a cause of the KPI only (not of media). Safe to
include for efficiency and safe to shrink/select. This is the default,
backward-compatible behavior for any unmarked control.
- ``MEDIATOR``: lies on the causal path media -> ... -> KPI (post-treatment).
Conditioning on it blocks part of the effect being estimated, so it must
**not** be used as a control for a total-effect estimate.
- ``COLLIDER``: a common effect of two causes on a backdoor path.
Conditioning on it *opens* a spurious path, so it must **not** be used as a
control.
``MEDIATOR`` and ``COLLIDER`` are refused at model-construction time. A
control left unmarked (``None``) is treated as ``PRECISION_CONTROL`` for
backward compatibility.
"""
CONFOUNDER = "confounder"
PRECISION_CONTROL = "precision_control"
MEDIATOR = "mediator"
COLLIDER = "collider"
[docs]
class AdstockType(str, Enum):
"""Supported adstock transformations."""
GEOMETRIC = "geometric"
WEIBULL = "weibull"
DELAYED = "delayed"
NONE = "none"
[docs]
class SaturationType(str, Enum):
"""Supported saturation transformations.
Note on completeness: two forms practitioners sometimes name separately are
already available here under different names — the **ADBUDG** S-curve
``x**s / (k**s + x**s)`` is exactly :attr:`HILL`, and the **exponential-CDF**
form ``1 - exp(-lam * x)`` is exactly :attr:`LOGISTIC`. :attr:`ROOT` (power)
is the genuinely distinct concave form the others don't cover.
"""
HILL = "hill"
LOGISTIC = "logistic"
MICHAELIS_MENTEN = "michaelis_menten"
TANH = "tanh"
ROOT = "root"
NONE = "none"
[docs]
class PriorType(str, Enum):
"""Supported prior distributions."""
HALF_NORMAL = "HalfNormal"
NORMAL = "Normal"
LOG_NORMAL = "LogNormal"
GAMMA = "Gamma"
BETA = "Beta"
TRUNCATED_NORMAL = "TruncatedNormal"
HALF_STUDENT_T = "HalfStudentT"
[docs]
class AllocationMethod(str, Enum):
"""Methods for allocating national data to sub-dimensions."""
EQUAL = "equal" # Equal split across all levels
POPULATION = "population" # By population weight
SALES = "sales" # By historical sales weight
CUSTOM = "custom" # User-provided weights
[docs]
class InferenceMethod(str, Enum):
"""Available inference methods.
The three Bayesian entries differ only in the **NUTS backend** used to
sample the same graph (``ModelConfig.nuts_sampler`` maps them onto the
``pm.sample(nuts_sampler=...)`` value): reference PyMC, JAX/NumPyro, or
the Rust ``nutpie`` sampler.
"""
BAYESIAN_PYMC = "bayesian_pymc"
BAYESIAN_NUMPYRO = "bayesian_numpyro"
BAYESIAN_NUTPIE = "bayesian_nutpie"
FREQUENTIST_RIDGE = "frequentist_ridge"
FREQUENTIST_CVXPY = "frequentist_cvxpy"
[docs]
class FitMethod(str, Enum):
"""How the posterior is obtained at fit time.
Two methods are *exact* (asymptotically unbiased posterior samplers):
* ``NUTS`` — full gradient MCMC, the default and the workhorse for final
inference.
* ``SMC`` — tempered Sequential Monte Carlo (``pm.sample_smc``). Slower
than NUTS on well-behaved posteriors, but it handles **multimodal**
posteriors NUTS gets mode-locked in (reflected factor modes, label
switching, adstock↔AR ridges) and yields a **log marginal likelihood**
for model comparison. Use it to confirm suspected multimodality or to
compute model evidence — not as a speedup.
The remaining methods are *approximate* and exist to fit a model in
seconds so you can spot problems — bad priors, broken geometry,
pathological saturation/adstock — before paying for a full sample. Treat
their uncertainty as unreliable. ``MAP`` is a point estimate; ``LAPLACE``
adds a Gaussian curvature approximation around the MAP point (cheap
uncertainty, better-behaved than bare MAP on high-dimensional models);
``ADVI``/``FULLRANK_ADVI`` are variational; ``PATHFINDER`` is quasi-Newton
variational (via the declared ``pymc-extras`` dependency, like LAPLACE).
"""
NUTS = "nuts"
SMC = "smc"
MAP = "map"
LAPLACE = "laplace"
ADVI = "advi"
FULLRANK_ADVI = "fullrank_advi"
PATHFINDER = "pathfinder"
@property
def is_approximate(self) -> bool:
"""True for the fast-check methods whose uncertainty is not calibrated.
NUTS **and SMC** are exact samplers — SMC must not trip the
"approximate fit — uncertainty is not calibrated" banners/report
gating even though it is not NUTS.
"""
return self not in (FitMethod.NUTS, FitMethod.SMC)
[docs]
class ModelSpecification(str, Enum):
"""Model functional form."""
ADDITIVE = "additive"
MULTIPLICATIVE = "multiplicative"
[docs]
class LikelihoodFamily(str, Enum):
"""Observation (likelihood) family for the KPI.
``NORMAL`` is the historical default and the only family the **built-in
additive** model fits directly on standardized ``y`` (all of its component
priors are calibrated in KPI standard deviations). ``STUDENT_T`` is a safe,
heavier-tailed drop-in on the same standardized, identity-link scale.
The remaining families (``LOGNORMAL`` and the count/bounded
``BINOMIAL``/``BETA_BINOMIAL``/``POISSON``/``NEGATIVE_BINOMIAL``/``BETA``)
change the observation scale and require a non-identity link, so the
additive model does **not** fit them directly — they are read by models that
define their own observation block (override ``_build_model`` / subclass
``CustomMMM``), e.g. a binomial awareness model. ``is_gaussian`` /
``standardizes_y`` route this in :class:`LikelihoodConfig` and the model.
"""
NORMAL = "normal"
STUDENT_T = "student_t"
LOGNORMAL = "lognormal"
BINOMIAL = "binomial"
BETA_BINOMIAL = "beta_binomial"
POISSON = "poisson"
NEGATIVE_BINOMIAL = "negative_binomial"
BETA = "beta"
@property
def is_gaussian(self) -> bool:
"""Identity-link, continuous families the additive model fits directly
on standardized ``y`` (the built-in dispatch supports these)."""
return self in (LikelihoodFamily.NORMAL, LikelihoodFamily.STUDENT_T)
@property
def standardizes_y(self) -> bool:
"""Whether ``y`` is z-scored before entering the graph. Count/bounded
families work in their natural (link) scale and are **not** standardized;
``LOGNORMAL`` is fit on standardized ``log(y)`` upstream, so the in-graph
observation is still standardized."""
return self in (
LikelihoodFamily.NORMAL,
LikelihoodFamily.STUDENT_T,
LikelihoodFamily.LOGNORMAL,
)
[docs]
class LinkFunction(str, Enum):
"""Link mapping the linear predictor ``mu`` to the observation's natural
parameter. ``IDENTITY`` for Gaussian families; ``LOGIT`` for bounded
proportions (binomial/beta); ``LOG`` for counts (Poisson/neg-binomial)."""
IDENTITY = "identity"
LOGIT = "logit"
LOG = "log"