Source code for mmm_framework.reporting.extractors.factor_analysis

"""Report data extractor for **non-MMM latent-structure** garden models — a
confirmatory factor analysis (CFA), a latent class analysis (LCA), or any model
declaring a non-``"mmm"`` ``__garden_model_kind__``.

These have no channels / spend / single KPI, so the extractor populates the
generic latent-structure fields of :class:`MMMDataBundle` (a summary table + the
declared estimands + section titles) plus the model-agnostic convergence
diagnostics — leaving the channel/ROI fields empty so those sections gate off.
The summary table is read from the model's family-appropriate method
(``factor_loadings_summary`` for a CFA, ``class_profile_summary`` for an LCA)."""

from __future__ import annotations

from typing import Any

from .base import DataExtractor
from .bundle import MMMDataBundle

#: Per-family display + data wiring: (section title, summary-table title,
#: estimands-block title, the model method that returns the summary DataFrame).
_FAMILY = {
    "cfa": (
        "Confirmatory Factor Analysis",
        "Factor loadings",
        "Fit indices",
        "factor_loadings_summary",
    ),
    "latent_class": (
        "Latent Class Analysis",
        "Class item-endorsement profiles",
        "Class sizes",
        "class_profile_summary",
    ),
    "clv": (
        "Customer Lifetime Value",
        "Customer value profile",
        "Value estimands",
        "customer_value_summary",
    ),
}
_DEFAULT_FAMILY = ("Latent Structure", "Latent summary", "Estimands", None)


[docs] class FactorAnalysisExtractor(DataExtractor): """Extract a latent-structure summary + estimands from a non-MMM garden model into an :class:`MMMDataBundle` (family-agnostic: CFA, LCA, …)."""
[docs] def __init__(self, model: Any, ci_prob: float = 0.94, **_: Any): self.model = model self._ci_prob = ci_prob
[docs] def extract(self) -> MMMDataBundle: kind = self._model_kind() section_title, table_title, estimands_title, method = _FAMILY.get( kind, _DEFAULT_FAMILY ) bundle = MMMDataBundle() bundle.model_kind = kind bundle.model_specification = {"model_kind": kind} bundle.latent_section_title = section_title bundle.latent_table_title = table_title bundle.latent_estimands_title = estimands_title bundle.factor_loadings = self._table(method) bundle.cfa_fit_indices = self._estimands() bundle.diagnostics = self._diagnostics() return bundle
def _model_kind(self) -> str: from ...garden.contract import model_kind return model_kind(self.model) def _table(self, method: str | None) -> list[dict[str, Any]] | None: """The latent summary table as a list of column dicts. Tries the family-specific method first, then any of the known summary methods, so a model that names its method differently still renders.""" candidates = [method] if method else [] candidates += [ "factor_loadings_summary", "class_profile_summary", "customer_value_summary", ] for name in candidates: if not name: continue fn = getattr(self.model, name, None) if not callable(fn): continue try: df = fn(hdi_prob=self._ci_prob) except TypeError: df = fn() except Exception: # noqa: BLE001 continue return [ {k: (float(v) if _is_num(v) else str(v)) for k, v in row.items()} for row in df.to_dict("records") ] return None def _estimands(self) -> dict[str, dict[str, float]] | None: fn = getattr(self.model, "evaluate_estimands", None) if not callable(fn): return None try: results = fn() # the model's declared / default estimands except Exception: # noqa: BLE001 return None out: dict[str, dict[str, float]] = {} for name, r in (results or {}).items(): mean = getattr(r, "mean", None) if getattr(r, "status", "ok") != "ok" or mean is None: continue out[name] = { "mean": float(mean), "lower": float(getattr(r, "hdi_low", mean) or mean), "upper": float(getattr(r, "hdi_high", mean) or mean), } return out or None def _diagnostics(self) -> dict[str, Any] | None: try: from ...diagnostics import compute_fit_diagnostics diag = compute_fit_diagnostics(self.model) return diag.get("convergence") or None except Exception: # noqa: BLE001 return None
def _is_num(v: Any) -> bool: return isinstance(v, (int, float)) and not isinstance(v, bool) __all__ = ["FactorAnalysisExtractor"]