Source code for mmm_framework.mmm_extensions.components.cross_effects

"""
Cross-effect builders for multivariate MMM.

These functions build cross-effect structures (cannibalization,
halo effects) between outcomes in multivariate models.
"""

from __future__ import annotations

from dataclasses import dataclass

import pymc as pm
import pytensor.tensor as pt


[docs] @dataclass class CrossEffectSpec: """Specification for a single cross-effect.""" source_idx: int target_idx: int effect_type: str # "cannibalization", "halo", "unconstrained" prior_sigma: float = 0.3
[docs] def build_cross_effect_matrix( specs: list[CrossEffectSpec], n_outcomes: int, name_prefix: str = "psi", ) -> tuple[pt.TensorVariable, dict[tuple[int, int], pt.TensorVariable]]: """ Build cross-effect coefficient matrix. Parameters ---------- specs : list[CrossEffectSpec] Cross-effect specifications n_outcomes : int Number of outcomes name_prefix : str Prefix for parameter names Returns ------- tuple (cross_effect_matrix, individual_params_dict) """ psi_matrix = pt.zeros((n_outcomes, n_outcomes)) params = {} for spec in specs: param_name = f"{name_prefix}_{spec.source_idx}_{spec.target_idx}" if spec.effect_type == "cannibalization": # Sign imposed negative: psi = -HalfNormal (free RV is "<name>_raw"). psi_raw = pm.HalfNormal(f"{param_name}_raw", sigma=spec.prior_sigma) psi = -psi_raw elif spec.effect_type == "halo": # Sign imposed positive: psi = +HalfNormal. psi = pm.HalfNormal(param_name, sigma=spec.prior_sigma) else: # No sign imposed: psi ~ Normal (free RV is "<name>", no "_raw" suffix). Covers # "unconstrained" (and the legacy "symmetric"/"asymmetric" directionality flags). # The data picks the sign; on observed outcomes psi is confounded with the # residual correlation, so read it as a cross-outcome association. psi = pm.Normal(param_name, mu=0, sigma=spec.prior_sigma) psi_matrix = pt.set_subtensor(psi_matrix[spec.source_idx, spec.target_idx], psi) params[(spec.source_idx, spec.target_idx)] = psi return psi_matrix, params
[docs] def compute_cross_effect_contribution( Y: pt.TensorVariable, psi_matrix: pt.TensorVariable, target_idx: int, n_outcomes: int, modulation: dict[int, pt.TensorVariable] | None = None, ) -> pt.TensorVariable: """ Compute cross-effect contribution for a single target outcome. Parameters ---------- Y : TensorVariable Outcome matrix (n_obs, n_outcomes) psi_matrix : TensorVariable Cross-effect coefficients (n_outcomes, n_outcomes) target_idx : int Index of target outcome n_outcomes : int Total number of outcomes modulation : dict | None Optional modulation by source index (e.g., promotion indicators) Returns ------- TensorVariable Cross-effect contribution (n_obs,) """ contribution = pt.zeros(Y.shape[0]) for source_idx in range(n_outcomes): if source_idx == target_idx: continue psi = psi_matrix[source_idx, target_idx] if modulation and source_idx in modulation: # Modulated effect (e.g., only when source is promoted) effect = psi * modulation[source_idx] else: effect = psi contribution = contribution + effect * Y[:, source_idx] return contribution
__all__ = [ "CrossEffectSpec", "build_cross_effect_matrix", "compute_cross_effect_contribution", ]