Source code for mmm_framework.eda.config
"""
Configuration for pre-fit data quality: outlier detection, validation, EDA.
Mirrors the frozen-dataclass config pattern of ``mmm_framework.validation``.
"""
from __future__ import annotations
from dataclasses import dataclass, replace
# Consensus weights per detector. ``level_shift`` is intentionally absent: a
# level shift is a structural changepoint, not a point outlier, and is
# reported as its own kind rather than voted on.
METHOD_WEIGHTS: dict[str, float] = {
"stl_residual": 0.35,
"spend_spike": 0.30,
"spend_drop": 0.30,
"robust_z": 0.20,
"iqr": 0.15,
}
[docs]
@dataclass(frozen=True)
class OutlierConfig:
"""Thresholds for the outlier detectors.
Defaults are deliberately conservative: in MMM data, seasonal peaks and
promo flights are real signal, so only points that survive the
seasonally-adjusted (STL) view or an isolated-spike test should be flagged.
"""
methods: tuple[str, ...] = (
"robust_z",
"iqr",
"stl_residual",
"level_shift",
"spend_spike",
"spend_drop",
)
# Robust z-score (MAD-based): |0.6745 * (x - med) / MAD| > threshold.
robust_z_threshold: float = 3.5
# IQR fences: Tukey "far out" multiplier (3.0, not 1.5) so seasonal peaks
# don't fire the box-plot rule.
iqr_multiplier: float = 3.0
# STL residual robust z threshold; STL runs when >= 2 full seasonal cycles
# exist, otherwise a centered rolling-median detrend is used.
stl_residual_threshold: float = 3.5
stl_period: int | None = None # None -> inferred from frequency (52 weekly)
rolling_window: int = 13
# Level shift: |median(next w) - median(prev w)| compared in units of the
# standard error of that difference (sigma * sqrt(pi/w)), sustained for at
# least min_run periods. Runs on seasonally-adjusted non-media series only.
level_shift_window: int = 8
level_shift_threshold: float = 3.25
level_shift_min_run: int = 4
# Spend spike (media only): x_t / max(neighborhood excl. t) > spike_ratio.
spike_ratio: float = 5.0
spike_neighborhood: int = 4
# max/p99 above this means a single point sets the channel's
# saturation/normalization scale.
normalization_damage_ratio: float = 3.0
# Drop (missed-load) detection: the neighborhood minimum must exceed
# drop_context_ratio * median for the channel to count as always-on there
# (flighted channels' dark weeks never satisfy this).
drop_context_ratio: float = 0.25
# A point is flagged when its weighted vote reaches this score.
consensus_threshold: float = 0.3
# Heavy-tail classification: residual excess kurtosis above this AND at
# least min_flags flags -> flags become a heavy_tail_member cluster.
# min_flags=5 because even a clean (Gaussian) world yields ~3 structural
# misses from the linear screen of a saturated response.
heavy_tail_kurtosis: float = 2.0
heavy_tail_min_flags: int = 5
# How many of the largest heavy-tail shocks get dummy recommendations.
heavy_tail_top_k: int = 5
[docs]
@classmethod
def for_sensitivity(cls, sensitivity: str = "default") -> "OutlierConfig":
"""Preset configs: 'low' flags less, 'high' flags more."""
base = cls()
if sensitivity == "low":
return replace(
base,
robust_z_threshold=4.5,
iqr_multiplier=4.0,
stl_residual_threshold=4.5,
spike_ratio=8.0,
)
if sensitivity == "high":
return replace(
base,
robust_z_threshold=3.0,
iqr_multiplier=2.0,
stl_residual_threshold=3.0,
spike_ratio=4.0,
)
return base
[docs]
@dataclass(frozen=True)
class DataValidationConfig:
"""Thresholds for pre-fit dataset validation."""
missing_warn_pct: float = 5.0
missing_error_pct: float = 30.0
zero_inflation_warn_pct: float = 60.0
near_constant_cv: float = 1e-6
# max/median across variable scales beyond this suggests a unit mismatch.
scale_ratio_threshold: float = 1e4
# Warn when n_periods / approx_n_params falls below this.
min_obs_per_param: float = 4.0
[docs]
@dataclass(frozen=True)
class EDAConfig:
"""Knobs for the EDA analyses."""
correlation_threshold: float = 0.8
vif_threshold: float = 10.0
top_correlations: int = 10
# statsmodels robust STL degenerates to near-interpolation on few cycles;
# see eda.decomposition.decompose_series.
stl_robust: bool = False
significance_level: float = 0.05
max_series_per_chart: int = 12
__all__ = ["OutlierConfig", "DataValidationConfig", "EDAConfig", "METHOD_WEIGHTS"]