Source code for mmm_framework.builders.variable
"""
Variable configuration builders.
Provides builders for MediaChannelConfig, ControlVariableConfig, and KPIConfig.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from ..config import (
AdstockConfig,
CausalControlRole,
ControlVariableConfig,
DimensionType,
KPIConfig,
MeasurementUnit,
MediaChannelConfig,
PriorConfig,
SaturationConfig,
)
from .base import VariableConfigBuilderMixin
from .prior import AdstockConfigBuilder, PriorConfigBuilder, SaturationConfigBuilder
if TYPE_CHECKING:
from typing import Self
[docs]
class MediaChannelConfigBuilder(VariableConfigBuilderMixin):
"""
Builder for MediaChannelConfig objects.
Examples
--------
>>> tv_config = (MediaChannelConfigBuilder("TV")
... .with_dimensions(DimensionType.PERIOD)
... .with_adstock(AdstockConfigBuilder().geometric().with_max_lag(8).build())
... .with_saturation(SaturationConfigBuilder().hill().build())
... .with_positive_prior(sigma=2.0)
... .build())
>>> social_meta = (MediaChannelConfigBuilder("Meta")
... .with_dimensions(DimensionType.PERIOD)
... .with_parent_channel("Social")
... .with_split_dimensions(DimensionType.OUTLET)
... .build())
"""
[docs]
def __init__(self, name: str) -> None:
self._name = name
self._init_variable_fields() # From VariableConfigBuilderMixin
self._adstock: AdstockConfig | None = None
self._saturation: SaturationConfig | None = None
self._coefficient_prior: PriorConfig | None = None
self._roi_prior_mu: float | None = None
self._roi_prior_sigma: float | None = None
self._parent_channel: str | None = None
self._split_dimensions: list[DimensionType] = []
self._time_varying: bool = False
self._tvp_innovation_sigma: float = 0.15
self._measurement_unit: MeasurementUnit = MeasurementUnit.SPEND
self._spend_column: str | None = None
self._cpm: float | None = None
self._cpc: float | None = None
[docs]
def with_adstock(self, config: AdstockConfig) -> Self:
"""Set adstock configuration."""
self._adstock = config
return self
[docs]
def with_adstock_builder(self, builder: AdstockConfigBuilder) -> Self:
"""Set adstock from builder."""
self._adstock = builder.build()
return self
[docs]
def with_geometric_adstock(self, l_max: int = 8) -> Self:
"""Convenience: set geometric adstock with max lag."""
self._adstock = AdstockConfigBuilder().geometric().with_max_lag(l_max).build()
return self
[docs]
def with_saturation(self, config: SaturationConfig) -> Self:
"""Set saturation configuration."""
self._saturation = config
return self
[docs]
def with_saturation_builder(self, builder: SaturationConfigBuilder) -> Self:
"""Set saturation from builder."""
self._saturation = builder.build()
return self
[docs]
def with_hill_saturation(self) -> Self:
"""Convenience: set Hill saturation with defaults."""
self._saturation = SaturationConfigBuilder().hill().build()
return self
[docs]
def with_logistic_saturation(self) -> Self:
"""Convenience: set logistic saturation with defaults."""
self._saturation = SaturationConfigBuilder().logistic().build()
return self
[docs]
def with_michaelis_menten_saturation(self) -> Self:
"""Convenience: set Michaelis-Menten saturation with defaults."""
self._saturation = SaturationConfigBuilder().michaelis_menten().build()
return self
[docs]
def with_tanh_saturation(self) -> Self:
"""Convenience: set tanh saturation with defaults."""
self._saturation = SaturationConfigBuilder().tanh().build()
return self
[docs]
def with_root_saturation(self) -> Self:
"""Convenience: set root / power saturation (``x ** k``) with defaults."""
self._saturation = SaturationConfigBuilder().root().build()
return self
[docs]
def with_coefficient_prior(self, prior: PriorConfig) -> Self:
"""Set coefficient prior."""
self._coefficient_prior = prior
return self
[docs]
def with_positive_prior(self, sigma: float = 2.0) -> Self:
"""Convenience: set HalfNormal prior for positive effects."""
self._coefficient_prior = PriorConfigBuilder().half_normal(sigma).build()
return self
[docs]
def with_roi_prior(
self,
*,
median: float | None = None,
mu: float | None = None,
sigma: float | None = None,
) -> Self:
"""State the channel's prior directly on the ROI (decision) scale.
Sets the hyper-parameters of the ROI-parameterized prior
``roi_<ch> ~ LogNormal(mu, sigma)`` — the channel then uses the ROI
parameterization even under ``media_prior_mode="coefficient"``. Give
the location as ``median`` (ROI units, e.g. ``1.2`` for a break-even+
belief; ``mu = log(median)``) or as ``mu`` (log scale) — not both.
``sigma`` is the log-scale spread: 0.5 ⇒ 90% within ~[0.44×, 2.3×] of
the median; the default 1.0 within ~[0.19×, 5.2×].
"""
if median is not None and mu is not None:
raise ValueError("with_roi_prior: give median OR mu, not both.")
if median is not None:
if median <= 0:
raise ValueError(
f"with_roi_prior: median must be positive, got {median}."
)
import math
mu = math.log(median)
self._roi_prior_mu = mu if mu is not None else self._roi_prior_mu
self._roi_prior_sigma = sigma if sigma is not None else self._roi_prior_sigma
if self._roi_prior_mu is None and self._roi_prior_sigma is None:
raise ValueError("with_roi_prior: set at least one of median/mu/sigma.")
return self
[docs]
def with_parent_channel(self, parent: str) -> Self:
"""Set parent channel for hierarchical grouping."""
self._parent_channel = parent
return self
[docs]
def with_split_dimensions(self, *dims: DimensionType) -> Self:
"""Set additional split dimensions (e.g., Outlet for platforms)."""
self._split_dimensions = list(dims)
return self
[docs]
def with_time_varying(
self, enabled: bool = True, innovation_sigma: float = 0.15
) -> Self:
"""Model this channel's coefficient as time-varying (a random walk on
``log(beta_t)``) so effectiveness can drift. ``innovation_sigma`` is the
per-step log-innovation scale (smaller ⇒ closer to constant beta)."""
self._time_varying = enabled
self._tvp_innovation_sigma = innovation_sigma
return self
[docs]
def measured_in(self, unit: MeasurementUnit | str) -> Self:
"""Declare how the modeled variable is measured.
``"spend"`` (default) keeps normal ROI. ``"impressions"`` / ``"clicks"``
/ ``"other"`` mark the variable as a *volume*, so ROI is resolved from a
``spend_column`` / ``cpm`` / ``cpc`` if provided, else reported as
efficiency per 1,000 impressions (or per click / unit). The response
curve is always fit on the modeled variable regardless.
"""
self._measurement_unit = MeasurementUnit(unit)
return self
[docs]
def with_spend_column(self, column: str) -> Self:
"""Use a SEPARATE MFF variable as the dollar spend for ROI (option a).
Implies a non-spend ``measurement_unit``; defaults it to impressions if
still ``spend``."""
self._spend_column = column
if self._measurement_unit is MeasurementUnit.SPEND:
self._measurement_unit = MeasurementUnit.IMPRESSIONS
return self
[docs]
def with_cpm(self, cpm: float) -> Self:
"""Derive ROI by costing the modeled impressions at ``cpm`` per 1,000
(option b). Implies impressions if ``measurement_unit`` is still spend."""
self._cpm = cpm
if self._measurement_unit is MeasurementUnit.SPEND:
self._measurement_unit = MeasurementUnit.IMPRESSIONS
return self
[docs]
def with_cpc(self, cpc: float) -> Self:
"""Derive ROI by costing the modeled clicks at ``cpc`` per click
(option b). Sets ``measurement_unit`` to clicks."""
self._cpc = cpc
self._measurement_unit = MeasurementUnit.CLICKS
return self
[docs]
def build(self) -> MediaChannelConfig:
"""Build the MediaChannelConfig object."""
# Set defaults if not specified
adstock = (
self._adstock or AdstockConfigBuilder().geometric().with_max_lag(8).build()
)
# Default saturation is logistic: that is what the core model has
# always fit, so unspecified channels keep historical behavior now
# that BayesianMMM honors the configured saturation type.
saturation = self._saturation or SaturationConfigBuilder().logistic().build()
kwargs: dict = dict(
name=self._name,
display_name=self._display_name,
unit=self._unit,
dimensions=self._dimensions,
adstock=adstock,
saturation=saturation,
parent_channel=self._parent_channel,
split_dimensions=self._split_dimensions,
time_varying=self._time_varying,
tvp_innovation_sigma=self._tvp_innovation_sigma,
measurement_unit=self._measurement_unit,
spend_column=self._spend_column,
cpm=self._cpm,
cpc=self._cpc,
)
# Pass coefficient_prior ONLY when explicitly configured: the core
# model honors an *explicitly set* prior (pydantic ``model_fields_set``)
# and keeps its historical built-in beta prior otherwise. Fabricating a
# default here would mark every channel "explicitly configured" and
# silently change all default fits.
if self._coefficient_prior is not None:
kwargs["coefficient_prior"] = self._coefficient_prior
if self._roi_prior_mu is not None:
kwargs["roi_prior_mu"] = self._roi_prior_mu
if self._roi_prior_sigma is not None:
kwargs["roi_prior_sigma"] = self._roi_prior_sigma
return MediaChannelConfig(**kwargs)
[docs]
class ControlVariableConfigBuilder(VariableConfigBuilderMixin):
"""
Builder for ControlVariableConfig objects.
Examples
--------
>>> price = (ControlVariableConfigBuilder("Price")
... .with_dimensions(DimensionType.PERIOD)
... .allow_negative()
... .with_normal_prior(mu=0, sigma=1)
... .build())
>>> distribution = (ControlVariableConfigBuilder("Distribution")
... .national()
... .with_shrinkage()
... .build())
"""
[docs]
def __init__(self, name: str) -> None:
self._name = name
self._init_variable_fields() # From VariableConfigBuilderMixin
self._allow_negative: bool = True
self._coefficient_prior: PriorConfig | None = None
self._use_shrinkage: bool = False
self._causal_role: CausalControlRole | None = None
self._causal_role_reason: str | None = None
[docs]
def with_causal_role(
self, role: CausalControlRole | str, reason: str | None = None
) -> Self:
"""Declare the control's causal role (confounder vs precision control).
Accepts the enum or its string value (``"confounder"`` /
``"precision_control"``; the spec-level shorthand ``"precision"`` is
normalized). The role drives back-door reporting and the
explicit-prior-on-confounder warning in the core model.
"""
if isinstance(role, str):
normalized = role.strip().lower()
if normalized == "precision":
normalized = "precision_control"
role = CausalControlRole(normalized)
self._causal_role = role
if reason is not None:
self._causal_role_reason = reason
return self
[docs]
def allow_negative(self, allow: bool = True) -> Self:
"""Allow negative coefficient (e.g., for price)."""
self._allow_negative = allow
return self
[docs]
def positive_only(self) -> Self:
"""Constrain coefficient to be positive."""
self._allow_negative = False
return self
[docs]
def with_coefficient_prior(self, prior: PriorConfig) -> Self:
"""Set coefficient prior."""
self._coefficient_prior = prior
return self
[docs]
def with_normal_prior(self, mu: float = 0.0, sigma: float = 1.0) -> Self:
"""Convenience: set Normal prior."""
self._coefficient_prior = PriorConfigBuilder().normal(mu, sigma).build()
return self
[docs]
def with_shrinkage(self, use: bool = True) -> Self:
"""Enable shrinkage prior for variable selection."""
self._use_shrinkage = use
return self
[docs]
def build(self) -> ControlVariableConfig:
"""Build the ControlVariableConfig object."""
# Pass coefficient_prior ONLY when the caller chose one — either
# directly, or implicitly via positive_only() (which materializes as an
# explicit HalfNormal so the core model actually constrains the sign).
# An unset prior keeps the core model's historical role-based width;
# fabricating one here would mark every control "explicitly configured".
coefficient_prior = self._coefficient_prior
if coefficient_prior is None and not self._allow_negative:
coefficient_prior = PriorConfigBuilder().half_normal(1).build()
kwargs: dict = dict(
name=self._name,
display_name=self._display_name,
unit=self._unit,
dimensions=self._dimensions,
allow_negative=self._allow_negative,
use_shrinkage=self._use_shrinkage,
)
if coefficient_prior is not None:
kwargs["coefficient_prior"] = coefficient_prior
if self._causal_role is not None:
kwargs["causal_role"] = self._causal_role
if self._causal_role_reason is not None:
kwargs["causal_role_reason"] = self._causal_role_reason
return ControlVariableConfig(**kwargs)
[docs]
class KPIConfigBuilder(VariableConfigBuilderMixin):
"""
Builder for KPIConfig objects.
Examples
--------
>>> kpi = (KPIConfigBuilder("Sales")
... .with_dimensions(DimensionType.PERIOD, DimensionType.GEOGRAPHY)
... .multiplicative()
... .build())
"""
[docs]
def __init__(self, name: str) -> None:
self._name = name
self._init_variable_fields() # From VariableConfigBuilderMixin
self._log_transform: bool = False
self._floor_value: float = 1e-6
[docs]
def additive(self) -> Self:
"""Use additive model specification (no log transform)."""
self._log_transform = False
return self
[docs]
def multiplicative(self) -> Self:
"""Use multiplicative model specification (log transform)."""
self._log_transform = True
return self
[docs]
def with_floor_value(self, floor: float) -> Self:
"""Set minimum value for log transform safety."""
self._floor_value = floor
return self
[docs]
def build(self) -> KPIConfig:
"""Build the KPIConfig object."""
return KPIConfig(
name=self._name,
display_name=self._display_name,
unit=self._unit,
dimensions=self._dimensions,
log_transform=self._log_transform,
floor_value=self._floor_value,
)
__all__ = [
"MediaChannelConfigBuilder",
"ControlVariableConfigBuilder",
"KPIConfigBuilder",
]