Source code for mmm_framework.validation.geo_identification
"""Geo-based identification diagnostic (P2-1, honestly scoped).
Cross-geo spend variation is quasi-experimental identifying signal -- but only
when it actually exists and is exogenous. This module REPORTS whether a model's
geos carry enough differential spend to support geo-level inference; it does NOT
turn geo into a model term (geo-heterogeneous media coefficients are a separate,
deferred change). The verdict is a *necessary* condition for credible geo
identification, never a sufficient one (see ``caveat``).
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import numpy as np
# Below this coefficient of variation, per-geo spend is effectively uniform and
# carries no geo-level identifying variation; above ~0.3 is comfortably strong.
DEFAULT_CV_THRESHOLD = 0.15
_GEO_EXOGENEITY_CAVEAT = (
"Cross-geo spend variation identifies effects only if geo-level spend is "
"exogenous -- not the result of targeting higher-demand geographies. Where "
"spend follows local demand, geo variation does NOT remove unobserved-demand "
"confounding; anchor geo-level claims with a randomized geo-lift experiment."
)
[docs]
@dataclass
class GeoSpendVariation:
"""Cross-geo spend dispersion for a single channel."""
channel: str
cv_across_geos: float # coefficient of variation of per-geo total spend
sufficient: bool # cv >= threshold -> enough variation to inform geo inference
[docs]
def to_dict(self) -> dict[str, Any]:
return {
"channel": self.channel,
"cv_across_geos": self.cv_across_geos,
"sufficient": self.sufficient,
}
[docs]
@dataclass
class GeoIdentificationDiagnostic:
"""Whether cross-geo spend variation can support geo-level identification."""
n_geos: int
channels: list[GeoSpendVariation]
cv_threshold: float
caveat: str
@property
def weak_channels(self) -> list[str]:
"""Channels whose spend is too uniform across geos to inform inference."""
return [c.channel for c in self.channels if not c.sufficient]
@property
def has_identifying_variation(self) -> bool:
return any(c.sufficient for c in self.channels)
[docs]
def to_dict(self) -> dict[str, Any]:
return {
"n_geos": self.n_geos,
"channels": [c.to_dict() for c in self.channels],
"cv_threshold": self.cv_threshold,
"weak_channels": self.weak_channels,
"caveat": self.caveat,
}
[docs]
def geo_spend_variation_diagnostic(
model: Any, cv_threshold: float = DEFAULT_CV_THRESHOLD
) -> GeoIdentificationDiagnostic:
"""Report per-channel cross-geo spend variation for a fitted/built model.
Parameters
----------
model
A model exposing ``has_geo``, ``n_geos``, ``geo_idx`` (obs -> geo code),
``X_media_raw`` (n_obs x n_channels) and ``channel_names``.
cv_threshold
Minimum coefficient of variation of per-geo spend for a channel to be
considered to carry geo-level identifying variation.
Raises
------
ValueError
If the model is national (no geo dimension); the diagnostic only applies
to multi-geo panels.
"""
if not getattr(model, "has_geo", False) or int(getattr(model, "n_geos", 1)) < 2:
raise ValueError(
"Geo identification diagnostic requires a multi-geo model "
"(has_geo=True and n_geos >= 2)."
)
X = np.asarray(model.X_media_raw, dtype=float)
geo_idx = np.asarray(model.geo_idx).astype(int)
names = list(model.channel_names)
n_geos = int(model.n_geos)
channels: list[GeoSpendVariation] = []
for c, name in enumerate(names):
per_geo = np.array(
[X[geo_idx == g, c].sum() for g in range(n_geos)], dtype=float
)
mean = float(per_geo.mean())
cv = float(per_geo.std() / mean) if mean > 0 else 0.0
channels.append(
GeoSpendVariation(
channel=name, cv_across_geos=cv, sufficient=cv >= cv_threshold
)
)
return GeoIdentificationDiagnostic(
n_geos=n_geos,
channels=channels,
cv_threshold=cv_threshold,
caveat=_GEO_EXOGENEITY_CAVEAT,
)
__all__ = [
"GeoSpendVariation",
"GeoIdentificationDiagnostic",
"geo_spend_variation_diagnostic",
"DEFAULT_CV_THRESHOLD",
]