De Modelos de Mezcla de Marketing a GAMs Bayesianos Personalizados: Ampliando el Núcleo de PyMC-Marketing#

Introducción#

Mientras que PyMC-Marketing es ampliamente reconocido por sus avanzadas capacidades de Modelado de Mezcla de Marketing (MMM), su verdadero potencial se extiende mucho más allá de los marcos tradicionales de MMM. En su núcleo, PyMC-Marketing proporciona una arquitectura flexible y componible que permite la construcción de modelos probabilísticos complejos—particularmente modelos aditivos generalizados (GAMs)—dentro del paradigma bayesiano. Al aprovechar la sintaxis de modelado expresiva de PyMC y la estructura modular de PyMC-Marketing, cualquier persona puede integrar transformaciones no lineales, priors jerárquicos, dinámicas auto-regresivas con solo unas pocas líneas de código o definir sus propias transformaciones personalizadas que reflejen conocimientos específicos del dominio y perspectivas basadas en datos. Al hacerlo, PyMC-Marketing se convierte no solo en un marco para la optimización del marketing, sino también en un motor de propósito general para construir GAMs bayesianos interpretables. Esta flexibilidad lo convierte en una herramienta invaluable para cualquiera que busque combinar razonamiento causal, flexibilidad funcional e inferencia probabilística en un flujo de trabajo de modelado coherente.

Esto permite a los investigadores y analistas moverse sin problemas de los MMM estándar a modelos gráficos completamente especificados que capturan relaciones causales y funcionales más ricas entre las variables.

En el siguiente cuaderno, le mostraremos varias de estas funcionalidades. Puede leer más sobre los componentes individuales y cómo construir MMM en el cuaderno de componentes.

Importar bibliotecas#

from __future__ import annotations

import time
import warnings
from copy import deepcopy

import arviz as az
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import pymc as pm
import xarray as xr
from pymc_extras.prior import Prior

from pymc_marketing.mmm import (
    GeometricAdstock,
    LogisticSaturation,
    NoAdstock,
    NoSaturation,
)
from pymc_marketing.mmm.components.base import Transformation
from pymc_marketing.mmm.multidimensional import MMM
from pymc_marketing.special_priors import LogNormalPrior, MaskedPrior

Configuración del cuaderno#

warnings.filterwarnings("ignore", category=UserWarning)

seed: int = sum(map(ord, "pymc-marketing is more than just a marketing model"))

az.style.use("arviz-darkgrid")
plt.rcParams["figure.figsize"] = [12, 7]
plt.rcParams["figure.dpi"] = 100
plt.rcParams["xtick.labelsize"] = 10
plt.rcParams["ytick.labelsize"] = 8

%load_ext autoreload
%autoreload 2
%config InlineBackend.figure_format = "retina"

Proceso de generación de datos#

Para los siguientes ejemplos, no construiremos un gran proceso estructural, ya que el objetivo de este cuaderno es simplemente mostrar las capacidades de la biblioteca. En su lugar, generaremos algunas series temporales siguiendo caminatas aleatorias y definiremos una variable objetivo lineal simple que depende del número de controladores creados.

La serie puede verse como variables de marketing o impulsores dirigidos a un objetivo específico. A medida que avancemos por el cuaderno, generaremos numerosos ejemplos de conjuntos de datos potenciales que pueden utilizarse con cada uno de los modelos.

def random_walk(mu, sigma, steps, lower=None, upper=None, seed=None):
    """
    Generate a bounded random walk with specified mean and standard deviation.

    Parameters
    ----------
    mu : float
        Target mean of the random walk
    sigma : float
        Target standard deviation of the random walk
    steps : int
        Number of steps in the random walk
    lower : float, optional
        Lower bound for the random walk values
    upper : float, optional
        Upper bound for the random walk values
    seed : int, optional
        Random seed for reproducibility

    Returns
    -------
    np.ndarray
        Random walk array with specified mean, std, and bounds
    """
    # if seed none then set 123
    if seed is None:
        seed = 123
    # Create a random number generator with the given seed
    rng = np.random.RandomState(seed)

    # Start from the target mean
    walk = np.zeros(steps)
    walk[0] = mu

    # Generate the walk step by step with bounds checking
    for i in range(1, steps):
        # Generate a random increment using the seeded RNG
        increment = rng.normal(0, sigma * 0.1)  # Scale increment size

        # Propose next value
        next_val = walk[i - 1] + increment

        # Apply bounds if specified
        if lower is not None and next_val < lower:
            # Reflect off lower bound
            next_val = lower + (lower - next_val)
        if upper is not None and next_val > upper:
            # Reflect off upper bound
            next_val = upper - (next_val - upper)

        # Final bounds check (hard clipping as backup)
        if lower is not None:
            next_val = max(next_val, lower)
        if upper is not None:
            next_val = min(next_val, upper)

        walk[i] = next_val

    # Adjust to match target mean and std while respecting bounds
    current_mean = np.mean(walk)
    current_std = np.std(walk)

    if current_std > 0:
        # Center around zero, scale to target std, then shift to target mean
        walk_centered = (walk - current_mean) / current_std * sigma + mu

        # Apply bounds again after scaling
        if lower is not None:
            walk_centered = np.maximum(walk_centered, lower)
        if upper is not None:
            walk_centered = np.minimum(walk_centered, upper)

        walk = walk_centered

    return walk
n_days = 365
n_years = 6
n_observations = n_days * n_years
min_date = pd.to_datetime("2022-01-01")
max_date = min_date + pd.Timedelta(days=n_observations) - pd.Timedelta(days=1)
date_range = pd.date_range(start=min_date, end=max_date, freq="D")
df = pd.DataFrame(data={"date_week": date_range})

x1 = random_walk(
    mu=500, sigma=50, steps=n_observations, lower=10, upper=1000, seed=seed + 1
)
x2 = random_walk(
    mu=300, sigma=100, steps=n_observations, lower=10, upper=1000, seed=seed + 2
)
x3 = random_walk(
    mu=600, sigma=80, steps=n_observations, lower=10, upper=1000, seed=seed - 3
)
x4 = random_walk(
    mu=1000, sigma=100, steps=n_observations, lower=10, upper=3000, seed=seed - 1
)

Genial, ¡visualicemos nuestra serie temporal!

fig, axs = plt.subplots(2, 2, figsize=(10, 8))
axs[0, 0].plot(x1, color="blue")
axs[0, 0].set_title("x1")
axs[0, 1].plot(x2, color="red")
axs[0, 1].set_title("x2")
axs[1, 0].plot(x3, color="green")
axs[1, 0].set_title("x3")
axs[1, 1].plot(x4, color="orange")
axs[1, 1].set_title("x4")
plt.show()

Construiremos un modelo lineal simple basado en estos cuatro factores. Puede pensar en ellos como representaciones de impresiones, gasto o cualquier otra variable de marketing típica, mientras que el objetivo podría corresponder a resultados como ingresos, instalaciones, registros en el sitio o compras.

intercept = 100
noise = np.random.normal(0, 10, n_observations)
y = x1 * 0.5 + x2 * 0.3 + x3 * 0.2 + x4 * 0.1 + intercept + noise

df["x1"] = x1
df["x2"] = x2
df["x3"] = x3
df["x4"] = x4
df["y"] = y

fig, ax = plt.subplots(figsize=(10, 5))
ax.plot(y, color="black")
ax.set_title("y")
plt.show()

Juntamos todos los datos en el mismo conjunto de datos.

df.head()
date_week x1 x2 x3 x4 y
0 2022-01-01 594.252746 156.373242 382.601781 878.408481 610.662948
1 2022-01-02 592.904201 150.323907 381.212926 878.912259 608.355639
2 2022-01-03 591.613607 151.588310 383.524140 881.134924 612.128530
3 2022-01-04 587.537071 154.705806 381.000766 889.327060 626.685458
4 2022-01-05 585.676798 158.286095 379.739704 886.491672 599.619464

Configuración del muestreador#

Crearemos un conjunto de configuraciones de muestreo predefinidas para todos nuestros modelos.

sample_kwargs = {
    "tune": 800,
    "draws": 200,
    "chains": 2,
    "random_seed": seed,
    "target_accept": 0.84,
}

Modelo Lineal Simple#

Para comenzar, construiremos un modelo lineal básico utilizando la clase MMM. Este ejemplo demuestra cómo PyMC-Marketing puede representar fácilmente una regresión lineal estándar.

Nota

PyMC-Marketing nunca obliga al uso de no linealidad. Puedes construir modelos puramente lineales cuando eso es lo que tus datos y el contexto empresarial requieren.

En esta configuración, la variable objetivo \(Y_t\) se modela como una combinación lineal de varios predictores \(X_{i,t}\), cada uno ponderado por su correspondiente coeficiente \(b_i\), más un término de error aleatorio \(\varepsilon_t\):

\[::\]

Aquí:

  • \(Y_t\) representa el objetivo en el tiempo \(t\) (por ejemplo, ingresos, instalaciones o registros),

  • \(X_{i,t}\) son las variables explicativas (por ejemplo, impresiones, gasto u otros impulsores de marketing),

  • \(b_i\) son los coeficientes específicos del canal que capturan la contribución marginal de cada variable, y

  • \(\varepsilon_t\) es el término residual que contabiliza la variación no observada.

Advertencia

Este caso representa nuestro preciso proceso de generación de datos anterior, pero lo importante es lo fácil que es escribir esta ecuación utilizando la API de MMM.

linear_model = MMM(
    date_column="date_week",
    target_column="y",
    channel_columns=["x1", "x2", "x3", "x4"],
    adstock=NoAdstock(l_max=1),
    saturation=NoSaturation().set_dims_for_all_priors("channel"),
    sampler_config=sample_kwargs,
)
linear_model.build_model(
    X=df.drop(columns=["y"]),
    y=df["y"],
)
linear_model.model.to_graphviz()
../../_images/98d0f5a88183808080e30795aba7cae60834c9e27ff8eb3b11eda80bea429710.svg

Con solo unas pocas líneas de código, puede definir un modelo lineal que ya incluye configuraciones previas flexibles y una estructura multidimensional.

La verdadera ventaja, sin embargo, no es solo construir un modelo gráfico más complejo tan fácilmente; es obtener acceso a todas las poderosas herramientas que PyMC-Marketing proporciona, incluso para una regresión simple.

PyMC-Marketing le ofrece, listo para usar:

  • Escalado y reescalado automáticos de entradas y salidas.

  • Herramientas de trazado completas para la evaluación posterior.

  • Análisis de sensibilidad y efecto marginal para cada nodo en el modelo.

  • Calibración del modelo utilizando evidencia experimental o causal.

  • Capacidades de asignación y optimización del presupuesto.

Todas estas características están disponibles incluso para los modelos más simples, lo que le permite pasar sin problemas de regresiones básicas a marcos de marketing probabilísticos completos.

Una vez que cada modelo esté listo, ¡puedes entrenarlos de la misma manera!

linear_model.add_original_scale_contribution_variable(
    ["channel_contribution", "y", "intercept_contribution"]
)

linear_model.fit(
    X=df.drop(columns=["y"]),
    y=df["y"],
)

linear_model.sample_posterior_predictive(
    X=df.drop(columns=["y"]),
)
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (2 chains in 2 jobs)
NUTS: [intercept_contribution, saturation_beta, y_sigma]

Sampling 2 chains for 800 tune and 200 draw iterations (1_600 + 400 draws total) took 12 seconds.
We recommend running at least 4 chains for robust computation of convergence diagnostics
The rhat statistic is larger than 1.01 for some parameters. This indicates problems during sampling. See https://arxiv.org/abs/1903.08008 for details
The effective sample size per chain is smaller than 100 for some parameters.  A higher number is needed for reliable rhat and ess computation. See https://arxiv.org/abs/1903.08008 for details

Sampling: [y]

<xarray.Dataset> Size: 14MB
Dimensions:           (date: 2190, sample: 400)
Coordinates:
  * date              (date) datetime64[ns] 18kB 2022-01-01 ... 2027-12-30
  * sample            (sample) object 3kB MultiIndex
  * chain             (sample) int64 3kB 0 0 0 0 0 0 0 0 0 ... 1 1 1 1 1 1 1 1 1
  * draw              (sample) int64 3kB 0 1 2 3 4 5 ... 194 195 196 197 198 199
Data variables:
    y                 (date, sample) float64 7MB 0.7863 0.7965 ... 0.8445 0.8392
    y_original_scale  (date, sample) float64 7MB 605.1 612.9 ... 649.8 645.7
Attributes:
    created_at:                 2026-03-17T20:34:24.628362+00:00
    arviz_version:              0.23.4
    inference_library:          pymc
    inference_library_version:  5.28.1

Como puede ver, ya hemos creado un modelo, añadido variables para ser transformadas en la escala original y lo hemos entrenado. Todo esto se guarda en mi objeto. ¿Cómo podríamos hacer esto un poco más complejo?

Antes de ir allí, necesitaremos agregar algo más a nuestros datos; en este caso, añadiremos algunas dimensiones adicionales para tener un conjunto de datos más grande.

countries = ["Venezuela", "Colombia", "Ecuador", "Panama"]
regions = ["South", "North", "East", "West"]
product_types = ["Type A", "Type B", "Type C", "Type D"]

multi_country_df = pd.DataFrame(
    [
        {
            "date_week": date,
            "country": country,
            "region": region,
            "product_type": product_type,
        }
        for country in countries
        for region in regions
        for product_type in product_types
        for date in date_range
    ]
)

# Create columns x1, x2, x3, x4 -> They must have for each combination of dimensions the N observations
for country_idx, country in enumerate(countries):
    for region_idx, region in enumerate(regions):
        for product_idx, product_type in enumerate(product_types):
            combination_mask = (
                (multi_country_df["country"] == country)
                & (multi_country_df["region"] == region)
                & (multi_country_df["product_type"] == product_type)
            )

            for col_idx, col in enumerate(["x1", "x2", "x3", "x4"]):
                mu = np.random.uniform(700, 800)
                sigma = np.random.uniform(50, 100)
                _seed = np.random.uniform(5, 30)
                walk_values = random_walk(
                    mu=mu,
                    sigma=sigma,
                    steps=n_observations,
                    lower=10,
                    upper=1000,
                    seed=seed
                    + country_idx * 100
                    + region_idx * 10
                    + product_idx * 1000
                    + col_idx,
                )
                multi_country_df.loc[combination_mask, col] = walk_values

multi_country_df.head()
date_week country region product_type x1 x2 x3 x4
0 2022-01-01 Venezuela South Type A 765.436919 823.799535 584.513184 573.025002
1 2022-01-02 Venezuela South Type A 767.879888 822.156462 574.722849 572.739882
2 2022-01-03 Venezuela South Type A 768.306886 820.583998 576.769176 571.897202
3 2022-01-04 Venezuela South Type A 767.682061 815.617131 581.814580 574.540813
4 2022-01-05 Venezuela South Type A 768.254702 813.350567 587.608973 577.418828

Ahora nuestro conjunto de datos se ve mejor, tenemos una serie temporal con longitud de un día para cada país, región y tipo de producto. Eche un vistazo al número de series temporales hechas solo para \(x1\).

# Create subplots for each combination of country, region, and product type
fig, axes = plt.subplots(4, 4, figsize=(20, 16))
fig.suptitle("X1 Time Series by Country, Region, and Product Type", fontsize=16, y=0.98)

for _, country in enumerate(countries):
    for j, (region, product_type) in enumerate(
        [(r, p) for r in regions for p in product_types]
    ):
        if j >= 16:  # Only plot first 16 combinations to fit in 4x4 grid
            break

        row = j // 4
        col = j % 4

        # Filter data for this specific combination using query syntax
        subset = multi_country_df.query(
            f"country == '{country}' and region == '{region}' and product_type == '{product_type}'"
        )

        if len(subset) > 0:  # Only plot if data exists
            subset = subset.set_index("date_week")
            axes[row, col].plot(subset.index, subset["x1"], label=f"{country}")
            axes[row, col].set_title(f"{region} - {product_type}", fontsize=10)
            axes[row, col].tick_params(axis="x", rotation=45, labelsize=8)
            axes[row, col].tick_params(axis="y", labelsize=8)
            axes[row, col].legend(fontsize=8)

plt.tight_layout(rect=[0, 0, 1, 0.96])

Para cada serie temporal, haremos una combinación lineal como antes para obtener una variable objetivo.

# Define different coefficients, intercepts, and noise levels for each combination
combination_params = {}

# Create parameters for each combination of country, region, and product type
for country in countries:
    for region in regions:
        for product_type in product_types:
            # Create unique parameters for each combination
            base_coeffs = [0.5, 0.3, 0.2, 0.1]
            base_intercept = 100
            base_noise = 10

            # Add variation based on country
            country_multiplier = {
                "Venezuela": 1.2,
                "Colombia": 1.0,
                "Ecuador": 0.9,
                "Panama": 1.1,
            }[country]

            # Add variation based on region
            region_multiplier = {"North": 1.1, "South": 0.9, "East": 1.0, "West": 0.95}[
                region
            ]

            # Add variation based on product type
            product_multiplier = {
                "Type A": 1.05,
                "Type B": 0.95,
                "Type C": 1.0,
                "Type D": 0.98,
            }[product_type]

            # Calculate final parameters
            final_multiplier = (
                country_multiplier * region_multiplier * product_multiplier
            )

            combination_params[(country, region, product_type)] = {
                "coeffs": [c * final_multiplier for c in base_coeffs],
                "intercept": base_intercept * final_multiplier,
                "noise_std": base_noise
                * (final_multiplier * 0.5 + 0.5),  # Moderate noise variation
            }

# Initialize y column
multi_country_df["y"] = 0.0

# Calculate y for each combination with different parameters
for (country, region, product_type), params in combination_params.items():
    combination_mask = (
        (multi_country_df["country"] == country)
        & (multi_country_df["region"] == region)
        & (multi_country_df["product_type"] == product_type)
    )

    if combination_mask.sum() > 0:
        # Generate combination-specific noise
        combination_noise = np.random.normal(
            0, params["noise_std"], combination_mask.sum()
        )

        # Calculate y for this combination
        combination_y = (
            multi_country_df.loc[combination_mask, "x1"] * params["coeffs"][0]
            + multi_country_df.loc[combination_mask, "x2"] * params["coeffs"][1]
            + multi_country_df.loc[combination_mask, "x3"] * params["coeffs"][2]
            + multi_country_df.loc[combination_mask, "x4"] * params["coeffs"][3]
            + params["intercept"]
            + combination_noise
        )

        multi_country_df.loc[combination_mask, "y"] = combination_y

multi_country_df.head()
date_week country region product_type x1 x2 x3 x4 y
0 2022-01-01 Venezuela South Type A 765.436919 823.799535 584.513184 573.025002 1038.867295
1 2022-01-02 Venezuela South Type A 767.879888 822.156462 574.722849 572.739882 1034.681303
2 2022-01-03 Venezuela South Type A 768.306886 820.583998 576.769176 571.897202 1008.752762
3 2022-01-04 Venezuela South Type A 767.682061 815.617131 581.814580 574.540813 1029.568033
4 2022-01-05 Venezuela South Type A 768.254702 813.350567 587.608973 577.418828 1016.451906
# Create subplots for each combination of country, region, and product type
fig, axes = plt.subplots(4, 4, figsize=(20, 16))
fig.suptitle("Y Time Series by Country, Region, and Product Type", fontsize=16)

for _, country in enumerate(countries):
    for j, (region, product_type) in enumerate(
        [(r, p) for r in regions for p in product_types]
    ):
        if j >= 16:  # Only plot first 16 combinations to fit in 4x4 grid
            break

        row = j // 4
        col = j % 4

        # Filter data for this specific combination
        # Filter data for this specific combination using query syntax
        subset = multi_country_df.query(
            f"country == '{country}' and region == '{region}' and product_type == '{product_type}'"
        )

        if len(subset) > 0:  # Only plot if data exists
            subset = subset.set_index("date_week")
            axes[row, col].plot(subset.index, subset["y"], label=f"{country}")
            axes[row, col].set_title(f"{region} - {product_type}", fontsize=10)
            axes[row, col].tick_params(axis="x", rotation=45, labelsize=8)
            axes[row, col].tick_params(axis="y", labelsize=8)
            axes[row, col].legend(fontsize=8)

plt.tight_layout(rect=[0, 0, 1, 0.96])

Añadiendo dimensiones adicionales a nuestro modelo aditivo#

Si quisiéramos construir un modelo lineal similar en múltiples dimensiones, podría parecer que requeriría una gran cantidad de código — ¿verdad? Respuesta corta: no.

Con PyMC-Marketing, solo se necesita una línea adicional. Al agregar simplemente el parámetro dims, puede incluir tantas dimensiones como contenga su conjunto de datos, y la clase se encargará automáticamente de toda la estructura y la contabilidad por usted.

linear_model_with_country_dimensionality = MMM(
    date_column="date_week",
    target_column="y",
    dims=("country", "region", "product_type"),
    channel_columns=["x1", "x2", "x3", "x4"],
    adstock=NoAdstock(l_max=1),
    saturation=NoSaturation().set_dims_for_all_priors(
        ("country", "region", "product_type", "channel")
    ),
    # You can set priors for each country not needed country-channel
    sampler_config=sample_kwargs,
)
linear_model_with_country_dimensionality.build_model(
    X=multi_country_df.drop(columns=["y"]),
    y=multi_country_df["y"],
)
linear_model_with_country_dimensionality.model.to_graphviz()
../../_images/42ff844fee513fbe771232d7849a6670c46308cbd0d043bddcac81522cfa9521.svg
linear_model_with_country_dimensionality.sample_prior_predictive(
    X=multi_country_df.drop(columns=["y"]),
)
Sampling: [intercept_contribution, saturation_beta, y, y_sigma]
<xarray.Dataset> Size: 224MB
Dimensions:       (date: 2190, country: 4, region: 4, product_type: 4,
                   sample: 200)
Coordinates:
  * date          (date) datetime64[ns] 18kB 2022-01-01 ... 2027-12-30
  * country       (country) <U9 144B 'Colombia' 'Ecuador' 'Panama' 'Venezuela'
  * region        (region) <U5 80B 'East' 'North' 'South' 'West'
  * product_type  (product_type) <U6 96B 'Type A' 'Type B' 'Type C' 'Type D'
  * sample        (sample) object 2kB MultiIndex
  * chain         (sample) int64 2kB 0 0 0 0 0 0 0 0 0 0 ... 0 0 0 0 0 0 0 0 0 0
  * draw          (sample) int64 2kB 0 1 2 3 4 5 6 ... 194 195 196 197 198 199
Data variables:
    y             (date, country, region, product_type, sample) float64 224MB ...
Attributes:
    created_at:                 2026-03-17T20:34:37.252003+00:00
    arviz_version:              0.23.4
    inference_library:          pymc
    inference_library_version:  5.28.1
    pymc_marketing_version:     0.18.2

Ahora tenemos un modelo multidimensional que aún representa la misma función lineal que antes, pero con índices adicionales a través de las dimensiones.

Creando un modelo multidimensional jerárquico#

Otra gran característica de PyMC-Marketing es su capacidad para manejar la difusión automáticamente. Esto significa que no necesitas mantener la misma forma de parámetro para cada variable aleatoria; puedes ajustar los tamaños de los parámetros según sea necesario, y la clase MMM se encargará de la difusión por ti.

Aquí hay un ejemplo rápido:

hierarchical_linear_model_with_country_dimensionality = MMM(
    date_column="date_week",
    target_column="y",
    dims=("country", "region", "product_type"),
    channel_columns=["x1", "x2", "x3", "x4"],
    adstock=NoAdstock(l_max=1),
    saturation=NoSaturation(
        priors={
            "beta": LogNormalPrior(
                mean=Prior("Normal", mu=1, sigma=2, dims="country"),
                std=Prior("Normal", mu=1, sigma=2, dims="region"),
                dims=("country", "region"),
            )
        }
    ),  # You can set priors for each country not needed country-channel
    sampler_config=sample_kwargs,
)
hierarchical_linear_model_with_country_dimensionality.build_model(
    X=multi_country_df.drop(columns=["y"]),
    y=multi_country_df["y"],
)
hierarchical_linear_model_with_country_dimensionality.model.to_graphviz()
../../_images/b737be027c6df2428bbd1a323d7b09f64634709669346b2607bf76648ab15f22.svg

Como puede ver, fue increíblemente fácil construir un modelo jerárquico con mayor dimensionalidad. Sin embargo, hay un pequeño compromiso: a medida que el modelo crece en tamaño (tanto en parámetros como en datos), el muestreo puede comenzar a tardar más.

Exploraremos varias técnicas para manejar esto de manera más eficiente más adelante. Por ahora, ¡tomemos rápidamente muestras solo de la distribución predictiva previa!

hierarchical_linear_model_with_country_dimensionality.sample_prior_predictive(
    X=multi_country_df.drop(columns=["y"]),
    y=multi_country_df["y"],
)
Sampling: [intercept_contribution, saturation_beta_log, saturation_beta_mean, saturation_beta_std, y, y_sigma]
<xarray.Dataset> Size: 224MB
Dimensions:       (date: 2190, country: 4, region: 4, product_type: 4,
                   sample: 200)
Coordinates:
  * date          (date) datetime64[ns] 18kB 2022-01-01 ... 2027-12-30
  * country       (country) <U9 144B 'Colombia' 'Ecuador' 'Panama' 'Venezuela'
  * region        (region) <U5 80B 'East' 'North' 'South' 'West'
  * product_type  (product_type) <U6 96B 'Type A' 'Type B' 'Type C' 'Type D'
  * sample        (sample) object 2kB MultiIndex
  * chain         (sample) int64 2kB 0 0 0 0 0 0 0 0 0 0 ... 0 0 0 0 0 0 0 0 0 0
  * draw          (sample) int64 2kB 0 1 2 3 4 5 6 ... 194 195 196 197 198 199
Data variables:
    y             (date, country, region, product_type, sample) float64 224MB ...
Attributes:
    created_at:                 2026-03-17T20:34:41.439325+00:00
    arviz_version:              0.23.4
    inference_library:          pymc
    inference_library_version:  5.28.1
    pymc_marketing_version:     0.18.2

Añadiendo componentes de tendencia y estacionalidad#

Para hacer que nuestro modelo sea más realista, podemos extender la estructura lineal añadiendo componentes de tendencia y estacionalidad.

PyMC-Marketing proporciona clases convenientes para esto: LinearTrend y WeeklyFourier.

El componente LinearTrend introduce una tendencia lineal por tramos con un número configurable de puntos de cambio. Permite que el modelo se adapte a cambios en el comportamiento a largo plazo, como el crecimiento o la disminución gradual a lo largo del tiempo. Matemáticamente, la tendencia se puede expresar como:

\[::\]

donde:

  • \(k\) representa la pendiente base,

  • \(\delta_j\) son ajustes de punto de cambio, y

  • \(s_j(t)\) son funciones indicadoras que se activan después de cada punto de cambio.

El componente WeeklyFourier captura patrones estacionales utilizando una representación de serie de Fourier. Esto es particularmente útil para efectos semanales o periódicos en datos de marketing, como la actividad cíclica de los usuarios. El término estacional se puede escribir como:

\[::\]

donde \(P\) es el período (por ejemplo, 7 días para ciclos semanales) y \(N\) es el número de términos de Fourier.

De nuevo, agregar estos términos complejos no es difícil en absoluto. Verifique el siguiente ejemplo:

from pymc_marketing.mmm.additive_effect import FourierEffect, LinearTrendEffect
from pymc_marketing.mmm.fourier import WeeklyFourier
from pymc_marketing.mmm.linear_trend import LinearTrend

trend = LinearTrend(
    n_changepoints=6,
    include_intercept=False,
    dims=linear_model_with_country_dimensionality.dims,
)
trend_effect = LinearTrendEffect(trend=trend, prefix="trend")

weekly = WeeklyFourier(n_order=3, prefix="weekly")
weekly_effect = FourierEffect(fourier=weekly)

linear_model_with_country_dimensionality.mu_effects.extend(
    [trend_effect, weekly_effect]
)
linear_model_with_country_dimensionality.build_model(
    X=multi_country_df.drop(columns=["y"]),
    y=multi_country_df["y"],
)
linear_model_with_country_dimensionality.model.to_graphviz()
../../_images/ed9bd86c8e67f37b594d4042fe5ffaa902a31be9b9902a318c4503eb8a3085a7.svg
linear_model_with_country_dimensionality.sample_prior_predictive(
    X=multi_country_df.drop(columns=["y"]),
    y=multi_country_df["y"],
)
Sampling: [delta, intercept_contribution, saturation_beta, weekly_beta, y, y_sigma]
<xarray.Dataset> Size: 224MB
Dimensions:       (date: 2190, country: 4, region: 4, product_type: 4,
                   sample: 200)
Coordinates:
  * date          (date) datetime64[ns] 18kB 2022-01-01 ... 2027-12-30
  * country       (country) <U9 144B 'Colombia' 'Ecuador' 'Panama' 'Venezuela'
  * region        (region) <U5 80B 'East' 'North' 'South' 'West'
  * product_type  (product_type) <U6 96B 'Type A' 'Type B' 'Type C' 'Type D'
  * sample        (sample) object 2kB MultiIndex
  * chain         (sample) int64 2kB 0 0 0 0 0 0 0 0 0 0 ... 0 0 0 0 0 0 0 0 0 0
  * draw          (sample) int64 2kB 0 1 2 3 4 5 6 ... 194 195 196 197 198 199
Data variables:
    y             (date, country, region, product_type, sample) float64 224MB ...
Attributes:
    created_at:                 2026-03-17T20:34:44.866006+00:00
    arviz_version:              0.23.4
    inference_library:          pymc
    inference_library_version:  5.28.1
    pymc_marketing_version:     0.18.2

Nuestro modelo se está volviendo más sofisticado, sin embargo, aún representa una regresión lineal enriquecida con componentes estructurales de series temporales. Pero, ¿qué pasa si queremos capturar dinámicas de marketing más realistas, como saturación o efectos de adstock?

Ahí es donde alcanzamos la verdadera esencia del Modelado de Mezcla de Marketing (MMM). Estos componentes nos permiten modelar cómo evoluciona el impacto del marketing a lo largo del tiempo y cómo los retornos disminuyen a medida que aumenta el gasto. ¿Qué tan difícil es reemplazar nuestras funciones lineales?

adstock = GeometricAdstock(l_max=6).set_dims_for_all_priors(
    ("country", "region", "product_type", "channel")
)
saturation = LogisticSaturation().set_dims_for_all_priors(
    ("country", "region", "product_type", "channel")
)

non_linear_model_with_country_dimensionality = MMM(
    date_column="date_week",
    target_column="y",
    dims=("country", "region", "product_type"),
    channel_columns=["x1", "x2", "x3", "x4"],
    adstock=adstock,
    saturation=saturation,
    sampler_config=sample_kwargs,
)
non_linear_model_with_country_dimensionality.mu_effects.extend(
    [trend_effect, weekly_effect]
)
non_linear_model_with_country_dimensionality.build_model(
    X=multi_country_df.drop(columns=["y"]),
    y=multi_country_df["y"],
)
non_linear_model_with_country_dimensionality.model.to_graphviz()
../../_images/c9ae2718acfdd6c765ef1be19a60ffb52494c7bf386b03c25ef71291d5c40eac.svg
non_linear_model_with_country_dimensionality.sample_prior_predictive(
    X=multi_country_df.drop(columns=["y"]),
)
Sampling: [adstock_alpha, delta, intercept_contribution, saturation_beta, saturation_lam, weekly_beta, y, y_sigma]
<xarray.Dataset> Size: 224MB
Dimensions:       (date: 2190, country: 4, region: 4, product_type: 4,
                   sample: 200)
Coordinates:
  * date          (date) datetime64[ns] 18kB 2022-01-01 ... 2027-12-30
  * country       (country) <U9 144B 'Colombia' 'Ecuador' 'Panama' 'Venezuela'
  * region        (region) <U5 80B 'East' 'North' 'South' 'West'
  * product_type  (product_type) <U6 96B 'Type A' 'Type B' 'Type C' 'Type D'
  * sample        (sample) object 2kB MultiIndex
  * chain         (sample) int64 2kB 0 0 0 0 0 0 0 0 0 0 ... 0 0 0 0 0 0 0 0 0 0
  * draw          (sample) int64 2kB 0 1 2 3 4 5 6 ... 194 195 196 197 198 199
Data variables:
    y             (date, country, region, product_type, sample) float64 224MB ...
Attributes:
    created_at:                 2026-03-17T20:34:50.735872+00:00
    arviz_version:              0.23.4
    inference_library:          pymc
    inference_library_version:  5.28.1
    pymc_marketing_version:     0.18.2

Como puede ver, el modelo ha ido creciendo en complejidad mientras sigue dependiendo completamente de los componentes integrados de PyMC-Marketing.

Lo notable es que la cantidad de código permanece casi idéntica a nuestro primer modelo lineal, lo que significa que construir un modelo no lineal con varios componentes estructurales de series temporales requiere prácticamente el mismo esfuerzo que una regresión simple. ¿Qué tan genial es eso?

Pero, ¿qué pasa si necesitamos una transformación que aún no está incluida en la biblioteca PyMC-Marketing? No hay problema: siempre puedes definir tus propias funciones personalizadas y especificar exactamente cómo debe transformarse cada canal.

Por ejemplo, suponga que desea que sus canales contribuyan exponencialmente al objetivo. ¿Podemos hacerlo? ¡Absolutamente!

Funciones personalizadas en PyMC-Marketing#

import pymc.dims as pmd

from pymc_marketing.mmm.components.saturation import SaturationTransformation


class ExponentialTransformation(SaturationTransformation):
    """Exponential transformation of the input."""

    lookup_name = "exponential"

    def function(self, x, beta, dim: str | None = None):
        """Transform the input using exponential function."""
        # beta * (1 - exp(-x))
        return beta * (1.0 - pmd.math.exp(-x))

    # Choose sensible, positive prior for amplitude (beta)
    default_priors = {
        "beta": Prior("HalfNormal", sigma=2.0),
    }


custom_saturation = ExponentialTransformation(
    priors={
        "beta": Prior(
            "Beta",
            alpha=3,
            beta=Prior("HalfNormal", sigma=1),
            dims=("country", "region", "product_type", "channel"),
        ),
    },
    prefix="exponential",
)

Si sigue el protocolo SaturationTransformation, puede implementar fácilmente cualquier función personalizada e integrarla de manera segura en su modelo.

Dado que estará trabajando directamente con los internos de PyMC, puede requerir algunas líneas de código adicionales, pero PyMC-Marketing proporciona clases base bien diseñadas que hacen que este proceso sea mucho más simple y menos propenso a errores.

Este enfoque le brinda total flexibilidad para diseñar y probar sus propias formas funcionales, mientras sigue beneficiándose de todas las utilidades de nivel superior de la biblioteca.

custom_non_linear_model_with_country_dimensionality = MMM(
    date_column="date_week",
    target_column="y",
    dims=("country", "region", "product_type"),
    channel_columns=["x1", "x2", "x3", "x4"],
    adstock=adstock,
    saturation=custom_saturation,
    sampler_config=sample_kwargs,
)
custom_non_linear_model_with_country_dimensionality.mu_effects.extend(
    [trend_effect, weekly_effect]
)
custom_non_linear_model_with_country_dimensionality.build_model(
    X=multi_country_df.drop(columns=["y"]),
    y=multi_country_df["y"],
)
custom_non_linear_model_with_country_dimensionality.model.to_graphviz()
../../_images/f9d1bd6fc7386ad9f5b73d2cbcaf82491183f1e1fb5deb7fa7638503a73a3c56.svg
custom_non_linear_model_with_country_dimensionality.sample_prior_predictive(
    X=multi_country_df.drop(columns=["y"]),
    y=multi_country_df["y"],
)
Sampling: [adstock_alpha, delta, exponential_beta, exponential_beta_beta, intercept_contribution, weekly_beta, y, y_sigma]
<xarray.Dataset> Size: 224MB
Dimensions:       (date: 2190, country: 4, region: 4, product_type: 4,
                   sample: 200)
Coordinates:
  * date          (date) datetime64[ns] 18kB 2022-01-01 ... 2027-12-30
  * country       (country) <U9 144B 'Colombia' 'Ecuador' 'Panama' 'Venezuela'
  * region        (region) <U5 80B 'East' 'North' 'South' 'West'
  * product_type  (product_type) <U6 96B 'Type A' 'Type B' 'Type C' 'Type D'
  * sample        (sample) object 2kB MultiIndex
  * chain         (sample) int64 2kB 0 0 0 0 0 0 0 0 0 0 ... 0 0 0 0 0 0 0 0 0 0
  * draw          (sample) int64 2kB 0 1 2 3 4 5 6 ... 194 195 196 197 198 199
Data variables:
    y             (date, country, region, product_type, sample) float64 224MB ...
Attributes:
    created_at:                 2026-03-17T20:34:55.810316+00:00
    arviz_version:              0.23.4
    inference_library:          pymc
    inference_library_version:  5.28.1
    pymc_marketing_version:     0.18.2

Una vez más, hemos ampliado nuestro modelo con solo una cantidad mínima de código adicional para la nueva transformación. La API principal permanece exactamente igual: sin configuración adicional, sin código adicional y sin tener que alternar entre componentes.

Y aún no hemos terminado. Podemos seguir mejorando nuestro modelo añadiendo componentes lineales adicionales para las variables de control. Como de costumbre, esto solo requiere una sola línea de código — solo un parámetro más, y el resto del flujo de trabajo permanece sin cambios.

Añadiendo covariables lineales#

Estas variables adicionales a menudo se denominan controles — covariables externas que ayudan a explicar las variaciones en el objetivo que no son impulsadas directamente por nuestros canales de marketing. Ejemplos pueden incluir indicadores macroeconómicos, actividad de competidores u otras características contextuales como el clima o índices de estacionalidad.

En PyMC-Marketing, podemos incorporar estos controles como un término lineal adicional en la función media del modelo. Formalmente, si denotamos nuestras variables de control como \(Z_{i,t}\) y sus correspondientes coeficientes como \(\beta_{Z_i}\), el nuevo modelo se convierte en:

\[::\]

Aquí:

  • \(X_{i,t}\) son los impulsores de marketing con parámetros \(\theta_i\),

  • \(Z_{i,t}\) son las covariables de control con parámetros \(\beta_{Z_i}\), y

  • \(\varepsilon_t\) sigue siendo el término de ruido residual.

En la práctica, esto significa que después de definir sus componentes principales de marketing, puede simplemente pasar sus variables de control al modelo —típicamente como un tensor con forma (fecha, control)— y PyMC-Marketing se encargará automáticamente del resto.

multi_country_df["control_a"] = multi_country_df["x1"] * 2 // multi_country_df["y"]
multi_country_df["control_b"] = multi_country_df["x4"] * 2 // multi_country_df["y"]
custom_non_linear_model_with_country_dimensionality_and_controls = MMM(
    date_column="date_week",
    target_column="y",
    dims=("country", "region", "product_type"),
    channel_columns=["x1", "x2", "x3", "x4"],
    control_columns=["control_a", "control_b"],
    adstock=adstock,
    saturation=custom_saturation,
    sampler_config=sample_kwargs,
)
custom_non_linear_model_with_country_dimensionality_and_controls.mu_effects.extend(
    [trend_effect, weekly_effect]
)
custom_non_linear_model_with_country_dimensionality_and_controls.build_model(
    X=multi_country_df.drop(columns=["y"]),
    y=multi_country_df["y"],
)
custom_non_linear_model_with_country_dimensionality_and_controls.model.to_graphviz()
../../_images/a355c6cf8cca540cc3ae3b40e205d44703eb3c5d82229d146b45ea1d54fe9b29.svg
custom_non_linear_model_with_country_dimensionality_and_controls.sample_prior_predictive(
    X=multi_country_df.drop(columns=["y"]),
    y=multi_country_df["y"],
)
Sampling: [adstock_alpha, delta, exponential_beta, exponential_beta_beta, gamma_control, intercept_contribution, weekly_beta, y, y_sigma]
<xarray.Dataset> Size: 224MB
Dimensions:       (date: 2190, country: 4, region: 4, product_type: 4,
                   sample: 200)
Coordinates:
  * date          (date) datetime64[ns] 18kB 2022-01-01 ... 2027-12-30
  * country       (country) <U9 144B 'Colombia' 'Ecuador' 'Panama' 'Venezuela'
  * region        (region) <U5 80B 'East' 'North' 'South' 'West'
  * product_type  (product_type) <U6 96B 'Type A' 'Type B' 'Type C' 'Type D'
  * sample        (sample) object 2kB MultiIndex
  * chain         (sample) int64 2kB 0 0 0 0 0 0 0 0 0 0 ... 0 0 0 0 0 0 0 0 0 0
  * draw          (sample) int64 2kB 0 1 2 3 4 5 6 ... 194 195 196 197 198 199
Data variables:
    y             (date, country, region, product_type, sample) float64 224MB ...
Attributes:
    created_at:                 2026-03-17T20:35:01.180176+00:00
    arviz_version:              0.23.4
    inference_library:          pymc
    inference_library_version:  5.28.1
    pymc_marketing_version:     0.18.2

Términos arbitrarios en PyMC-Marketing#

A veces necesita un término que no forma parte de los elementos incorporados. PyMC-Marketing le permite ser completamente personalizado: puede agregar cualquier función de sus entradas—de cualquier forma o dimensionalidad—directamente en la función media. Esto requiere algunas líneas más de código (está interfiriendo con los internos de PyMC), pero el diseño basado en protocolos proporciona barandillas para que no rompa el modelo. La compensación es sencilla: más flexibilidad y control a cambio de un poco más de código repetitivo. Si implementa una clase que sigue el protocolo esperado, puede componer prácticamente cualquier cosa.

En el ejemplo a continuación, presentamos dos piezas personalizadas:

  1. Una transformación personalizada InverseHillSaturation que transforma las entradas con una forma inversa de Hill:

\[::\]
  1. Un efecto envolvente TransformedControlsEffect que (a) ingiere un conjunto de covariables de control, (b) aplica cualquier Transformation (aquí, nuestra InverseHillSaturation personalizada) respetando las coordenadas del modelo, y (c) agrega la contribución resultante a la media \(\mu_t\). Concretamente, si los controles especiales son \(H_{k,t}\) con parámetros \((\lambda_k, \beta_k)\), el término adicional es:

\[::\]

Al combinar esto con los impulsores de marketing existentes \(X_{i,t}\) y cualquier control lineal estándar, la función media se convierte en:

\[::\]

¿Cuáles son esas pautas que debes seguir?

Un componente que se inserta en mu_effects se espera que se comporte como los ejemplos en pymc_marketing/mmm/additive_effect.py. El implícito “protocolo MuEffect” consta de solo tres métodos, pero cada uno debe satisfacer algunas invariantes para que la clase pueda ser llamada durante el tiempo de construcción del modelo y nuevamente más tarde cuando el modelo se clone para el muestreo posterior predictivo.

  • crear efecto: Debe devolver el tensor que se añadirá aditivamente al modelo mu_var.

  • set_data: Proceso para poder ejecutar sample_posterior_predictive en datos existentes o nuevos.

  • create_data: Proceso para agregar el contenedor pm.Data con la información para el componente personalizado.

No olvides lo siguiente 👇🏻

  1. Nombres únicos: elija un prefijo distinto para que sus variables no colisionen con los elementos integrados como «control_contribution». Siempre añada un determinista con un sufijo de “contribution” si desea que el usuario pueda recuperarlo más tarde; de lo contrario, son posibles conflictos de nombres.

  2. Dimensiones de retorno: el tensor devuelto por create_effect debe ajustarse exactamente a las dimensiones de destino MMM ((«fecha», *mmm.dims)). No hacerlo genera errores de dimensión cuando se suma el modelo.

  3. Ayudantes de transmisión: cuando su efecto interno tiene menos dimensiones, use create_dim_handler para alinearlo.

  4. Adiciones de coordenadas: si introduces una nueva dimensión (por ejemplo, «event» o «fourier_mode»), añádela una vez con model.add_coord(name, values) dentro de create_data.

  5. Reutilice los nombres de pm.Data de manera consistente entre create_data y set_data; de lo contrario, pm.set_data no podrá encontrar la variable compartida.

  6. No mutes mmm.model.coords[«date»]. Si necesitas fechas, léelas de las coords; para nuevas fechas de predicción, confía en las coords del modelo clonado dentro de set_data.

Siga estas reglas y su componente se integrará en los flujos de trabajo de entrenamiento MMM, muestreo posterior y predicción posterior sin sorpresas.

Dicho esto, ¡construyamos nuestras covariables especiales!

multi_country_df["control_special_c"] = (
    multi_country_df["x2"] * 2 // multi_country_df["y"]
)
multi_country_df["control_special_d"] = (
    multi_country_df["x3"] * 2 // multi_country_df["y"]
)
from pydantic import Field

from pymc_marketing.mmm.additive_effect import MuEffect


class InverseHillSaturation(SaturationTransformation):
    """Inverse Hill saturation transformation."""

    lookup_name = "inverse_hill"

    def function(self, x, lam, beta, dim: str | None = None):
        """Transform the input using inverse Hill saturation."""
        # beta / (1 + (x / lam)**2)
        return beta / (1.0 + (x / lam) ** 2)

    # Choose sensible, positive priors for scale (lam) and amplitude (beta)
    default_priors = {
        "lam": Prior("HalfNormal", sigma=1.5, dims=()),
        "beta": Prior("HalfNormal", sigma=2.0, dims=()),
    }


class TransformedControlsEffect(MuEffect):
    """Wrap a custom transformation and add the transformed controls into μ."""

    model_config = {"arbitrary_types_allowed": True, "extra": "allow"}

    name: str
    control_columns: list[str]
    transformer: Transformation = Field(exclude=True)
    dim_suffix: str = "control_tf"

    def model_post_init(self, __context) -> None:
        """Post-initialization to set up derived attributes."""
        # Clone the transformer so we never mutate the one used by the main MMM
        self.transformer = deepcopy(self.transformer)
        self.dim_name = f"{self.name}_{self.dim_suffix}"
        self.data_name = f"{self.name}_data"

    # ------------------------------------------------------------------ helpers
    def _controls_to_xarray(self, mmm, df: pd.DataFrame) -> xr.DataArray:
        ds = mmm._create_xarray_from_pandas(
            data=df,
            date_column=mmm.date_column,
            dims=mmm.dims,
            metric_list=self.control_columns,
            metric_coordinate_name=self.dim_name,
        )
        return ds[f"_{self.dim_name}"].transpose("date", *mmm.dims, self.dim_name)

    def _build_dataset(self, mmm, df: pd.DataFrame) -> xr.DataArray:
        da = self._controls_to_xarray(mmm, df)
        return da.transpose("date", *mmm.dims, self.dim_name)

    # ----------------------------------------------------------------- protocol
    def create_data(self, mmm) -> None:
        """Create the data container for the transformed controls."""
        model: pm.Model = mmm.model
        df = mmm.X[[mmm.date_column, *mmm.dims, *self.control_columns]].copy()
        da = self._build_dataset(mmm, df)

        model.add_coord(self.dim_name, da.coords[self.dim_name].to_numpy())

        pmd.Data(
            name=self.data_name,
            value=da.values,
            dims=("date", *mmm.dims, self.dim_name),
        )

    def create_effect(self, mmm):
        """Create the effect for the transformed control."""
        model: pm.Model = mmm.model

        transformed = pmd.Deterministic(
            f"{self.name}_contribution",
            self.transformer.apply(
                x=model[self.data_name],
            ),
            dims=("date", *mmm.dims, self.dim_name),
        )

        return transformed.sum(dim=self.dim_name)

    def set_data(self, mmm, model: pm.Model, X: xr.Dataset) -> None:
        """Set the data container for the transformed controls."""
        df = (
            X.to_dataframe()
            .reset_index()
            .loc[:, [mmm.date_column, *mmm.dims, *self.control_columns]]
        )
        da = self._build_dataset(mmm, df).reindex(
            {
                "date": model.coords["date"],
                **{dim: model.coords[dim] for dim in mmm.dims},
                self.dim_name: model.coords[self.dim_name],
            },
            fill_value=0,
        )
        pm.set_data({self.data_name: da.values}, model=model)
custom_control_sat = InverseHillSaturation(
    priors={
        "beta": Prior(
            "Beta",
            alpha=3,
            beta=Prior("HalfNormal", sigma=1),
            dims=("country", "region", "product_type", "special_control"),
        ),
        "lam": Prior(
            "Gamma",
            mu=1,
            sigma=2,
            dims=("country", "region", "product_type", "special_control"),
        ),
    },
    prefix="desaturated_controls",
)

effect = TransformedControlsEffect(
    name="special",
    control_columns=["control_special_c", "control_special_d"],
    transformer=custom_control_sat,
    dim_suffix="control",
)

custom_non_linear_model_with_country_dimensionality_and_controls_special = MMM(
    date_column="date_week",
    target_column="y",
    dims=("country", "region", "product_type"),
    channel_columns=["x1", "x2", "x3", "x4"],
    control_columns=["control_a", "control_b"],
    adstock=adstock,
    saturation=custom_saturation,
    sampler_config=sample_kwargs,
)

custom_non_linear_model_with_country_dimensionality_and_controls_special.mu_effects.extend(
    [trend_effect, weekly_effect]
)
custom_non_linear_model_with_country_dimensionality_and_controls_special.mu_effects.append(
    effect
)

custom_non_linear_model_with_country_dimensionality_and_controls_special.build_model(
    X=multi_country_df.drop(columns=["y"]),
    y=multi_country_df["y"],
)
custom_non_linear_model_with_country_dimensionality_and_controls_special.model.to_graphviz()
../../_images/d06ae52db59fa87ad1b32614b5ce94a6145b451d6e8d6d79676e1d42887a7758.svg
custom_non_linear_model_with_country_dimensionality_and_controls_special.sample_prior_predictive(
    X=multi_country_df.drop(columns=["y"]),
    y=multi_country_df["y"],
)
Sampling: [adstock_alpha, delta, desaturated_controls_beta, desaturated_controls_beta_beta, desaturated_controls_lam, exponential_beta, exponential_beta_beta, gamma_control, intercept_contribution, weekly_beta, y, y_sigma]
<xarray.Dataset> Size: 224MB
Dimensions:       (date: 2190, country: 4, region: 4, product_type: 4,
                   sample: 200)
Coordinates:
  * date          (date) datetime64[ns] 18kB 2022-01-01 ... 2027-12-30
  * country       (country) <U9 144B 'Colombia' 'Ecuador' 'Panama' 'Venezuela'
  * region        (region) <U5 80B 'East' 'North' 'South' 'West'
  * product_type  (product_type) <U6 96B 'Type A' 'Type B' 'Type C' 'Type D'
  * sample        (sample) object 2kB MultiIndex
  * chain         (sample) int64 2kB 0 0 0 0 0 0 0 0 0 0 ... 0 0 0 0 0 0 0 0 0 0
  * draw          (sample) int64 2kB 0 1 2 3 4 5 6 ... 194 195 196 197 198 199
Data variables:
    y             (date, country, region, product_type, sample) float64 224MB ...
Attributes:
    created_at:                 2026-03-17T20:35:08.252615+00:00
    arviz_version:              0.23.4
    inference_library:          pymc
    inference_library_version:  5.28.1
    pymc_marketing_version:     0.18.2

¿Dónde están los GAM?#

En este punto, podría estar preguntándose — ¿dónde están los GAMs? Después de todo, la mayor parte de lo que hemos construido hasta ahora ha parecido una secuencia de regresiones lineales y no lineales con estructura adicional. Y eso es cierto: envolver un predictor lineal en una función no lineal no lo convierte automáticamente en un Modelo Aditivo Generalizado.

Sin embargo, si introducimos funciones de adstock no paramétrico y saturación monótona, el modelo realmente comenzó a tomar la forma de un GAM. Cada canal ahora contribuye con su propia función suave de la exposición transformada — no una curva paramétrica fija, sino una forma regularizada impulsada por los datos. La media de la respuesta ahora se puede expresar como una combinación aditiva de términos suaves:

\[::\]

donde:

  • \(e_{i,t} = (x_i * w_i)_t\) es la exposición adstock — una convolución de rezago distribuido con un núcleo suave \(w_i\),

  • \(f_i(\cdot)\) es una función de saturación suave monótona, aprendida de manera no paramétrica,

  • y \(s_{\text{trend}}\) y \(s_{\text{season}}\) son efectos temporales suaves construidos a partir de tendencias lineales y términos de Fourier.

Dicho de otra manera, lo que hace que nuestro modelo sea un GAM no es ninguna transformación única, sino el hecho de que su estructura media es aditiva sobre componentes suaves. Cada efecto — adstock, saturación, tendencia, estacionalidad o control — actúa como uno de los suavizados aditivos que definen un GAM.

Así que, aunque no todos los MMM son GAM, un MMM flexible como el nuestro sí lo es. Ese es el verdadero poder del marco PyMC-Marketing: te permite pasar de manera natural de una regresión lineal estándar a un MMM tradicional hacia un Modelo Aditivo Generalizado completamente bayesiano, sin cambiar tu flujo de trabajo ni la estructura de tu código.

¡Verifique cómo se puede implementar esto siguiendo las mismas estructuras mostradas arriba!

Advertencia

Las siguientes implementaciones son experimentales y sirven como demostraciones de prueba de concepto para este cuaderno. No forman parte de la suite oficial de PyMC-Marketing y deben ser utilizadas únicamente con fines educativos.

import pytensor.tensor as pt
import pytensor.xtensor as ptx

from pymc_marketing.mmm.components.adstock import AdstockTransformation
from pymc_marketing.mmm.transformers import batched_convolution


class KernelSoftplusAdstock(AdstockTransformation):
    """Adstock transformation with a softplus kernel."""

    lookup_name = "kernel_softplus"
    default_priors = {
        "logit_sigma": Prior("HalfNormal", sigma=1.0, dims=()),
        "lambda_smooth": Prior("HalfNormal", sigma=5.0, dims=()),
    }

    def __init__(self, l_max=28, **kwargs):
        super().__init__(l_max=l_max, **kwargs)
        if l_max >= 3:
            D = np.zeros((l_max - 2, l_max))
            for i in range(l_max - 2):
                D[i, i] = 1.0
                D[i, i + 1] = -2.0
                D[i, i + 2] = 1.0
            self._D2 = pt.as_tensor_variable(D)
        else:
            self._D2 = None

    def function(self, x, logit_sigma, lambda_smooth, dim: str | None = None):
        """Transform the input using function."""
        kernel_dim = f"{dim}_kernel"
        model = pm.modelcontext(None)
        model.add_coord(kernel_dim, np.arange(self.l_max))

        logits = pmd.Normal(
            f"{self.prefix}_kernel_logits", 0.0, logit_sigma, dims=(kernel_dim,)
        )
        w_pos = pmd.math.softplus(logits)
        w = w_pos / (w_pos.sum(dim=kernel_dim) + 1e-12)
        pmd.Deterministic(f"{self.prefix}_kernel", w)

        if self._D2 is not None:
            diff2 = self._D2 @ w.values
            pm.Potential(
                f"{self.prefix}_kernel_smoothness",
                -0.5 * lambda_smooth.values * pt.sum(diff2**2),
            )

        return batched_convolution(
            x,
            w,
            mode=self.mode,
            dim=dim,
            kernel_dim=kernel_dim,
        )


class IsotonicPWLSaturation(SaturationTransformation):
    """Isotonic Piecewise Linear Saturation Transformation."""

    lookup_name = "isotonic_pwl"
    default_priors = {
        "beta": Prior("HalfNormal", sigma=2.0, dims=()),
        "lambda_sat": Prior("HalfNormal", sigma=3.0, dims=()),
    }

    def __init__(self, n_knots: int = 21, prefix: str | None = None) -> None:
        super().__init__(priors=None, prefix=prefix)
        if n_knots < 2:
            raise ValueError("n_knots must be >= 2")
        self.n_knots = int(n_knots)
        if n_knots >= 3:
            D = np.zeros((n_knots - 2, n_knots), dtype="float64")
            for i in range(n_knots - 2):
                D[i, i] = 1.0
                D[i, i + 1] = -2.0
                D[i, i + 2] = 1.0
            self._D2 = pt.as_tensor_variable(D)
        else:
            self._D2 = None

    def function(self, x, beta, lambda_sat, dim: str | None = None):
        """Transform the input using function."""
        x = ptx.as_xtensor(x)
        beta = ptx.as_xtensor(beta)
        lambda_sat = ptx.as_xtensor(lambda_sat)

        theta = pm.Normal(f"{self.prefix}_theta", 0.0, 1.0, shape=(self.n_knots - 1,))
        delta = pt.softplus(theta)
        v = pt.concatenate([pt.zeros((1,), dtype=delta.dtype), pt.cumsum(delta)])
        pm.Deterministic(f"{self.prefix}_levels", v)

        if self._D2 is not None:
            diff2 = self._D2 @ v
            pm.Potential(
                f"{self.prefix}_sat_smooth",
                -0.5 * lambda_sat.values * pt.sum(diff2**2),
            )

        x_raw = x.values
        xx = pt.clip(x_raw, 0.0, 1.0 - 1e-9).astype(delta.dtype)
        step = pt.as_tensor_variable(1.0 / (self.n_knots - 1)).astype(xx.dtype)

        i = pt.clip(pt.floor(xx / step).astype("int64"), 0, self.n_knots - 2)
        t = (xx - i.astype(xx.dtype) * step) / step

        vi = v.take(i)
        vip1 = v.take(i + 1)
        g = vi * (1.0 - t) + vip1 * t

        # normalize to remove scale confounding; beta carries amplitude
        g = g / (v[-1] + 1e-12)

        g_xt = ptx.as_xtensor(g, dims=x.dims)
        return beta * g_xt
gam_non_linear_model_with_country_dimensionality_and_controls_special = MMM(
    date_column="date_week",
    target_column="y",
    dims=("country", "region", "product_type"),
    channel_columns=["x1", "x2", "x3", "x4"],
    control_columns=["control_a", "control_b"],
    adstock=KernelSoftplusAdstock(),
    saturation=IsotonicPWLSaturation().set_dims_for_all_priors(
        dims=("country", "region", "product_type", "channel")
    ),
    sampler_config=sample_kwargs,
)

gam_non_linear_model_with_country_dimensionality_and_controls_special.mu_effects.extend(
    [trend_effect, weekly_effect]
)
gam_non_linear_model_with_country_dimensionality_and_controls_special.mu_effects.append(
    effect
)

gam_non_linear_model_with_country_dimensionality_and_controls_special.build_model(
    X=multi_country_df.drop(columns=["y"]),
    y=multi_country_df["y"],
)


gam_non_linear_model_with_country_dimensionality_and_controls_special.model.to_graphviz()
../../_images/40a281314d1244b18fb7f380e4bcebc3c9d181d4adbbcfce1b1f3fadd0893839.svg
gam_non_linear_model_with_country_dimensionality_and_controls_special.sample_prior_predictive(
    X=multi_country_df.drop(columns=["y"]),
    y=multi_country_df["y"],
)
Sampling: [adstock_kernel_logits, adstock_lambda_smooth, adstock_logit_sigma, delta, desaturated_controls_beta, desaturated_controls_beta_beta, desaturated_controls_lam, gamma_control, intercept_contribution, saturation_beta, saturation_lambda_sat, saturation_theta, weekly_beta, y, y_sigma]
<xarray.Dataset> Size: 224MB
Dimensions:       (date: 2190, country: 4, region: 4, product_type: 4,
                   sample: 200)
Coordinates:
  * date          (date) datetime64[ns] 18kB 2022-01-01 ... 2027-12-30
  * country       (country) <U9 144B 'Colombia' 'Ecuador' 'Panama' 'Venezuela'
  * region        (region) <U5 80B 'East' 'North' 'South' 'West'
  * product_type  (product_type) <U6 96B 'Type A' 'Type B' 'Type C' 'Type D'
  * sample        (sample) object 2kB MultiIndex
  * chain         (sample) int64 2kB 0 0 0 0 0 0 0 0 0 0 ... 0 0 0 0 0 0 0 0 0 0
  * draw          (sample) int64 2kB 0 1 2 3 4 5 6 ... 194 195 196 197 198 199
Data variables:
    y             (date, country, region, product_type, sample) float64 224MB ...
Attributes:
    created_at:                 2026-03-17T20:35:29.801159+00:00
    arviz_version:              0.23.4
    inference_library:          pymc
    inference_library_version:  5.28.1
    pymc_marketing_version:     0.18.2

Increíble, nuestro Modelo de Mezcla de Medios Aditivos Generalizados está activo y muestreando 🔥

Resumen: De líneas simples a estructuras complejas#

A lo largo de este cuaderno, hemos visto cómo PyMC-Marketing evoluciona de una simple regresión lineal a un marco probabilístico flexible capaz de modelar estructuras de series temporales complejas. Comenzamos con un modelo lineal básico, mostrando cómo cada componente — adstock, saturación, tendencias, estacionalidad de Fourier y controles — puede ser añadido sin problemas utilizando una API consistente y solo unas pocas líneas de código. Cada adición preservó la misma base conceptual mientras expandía el poder expresivo del modelo.

Introdujimos dimensionalidad (como países o regiones) sin añadir complejidad al código, aprovechamos broadcasting para alinear parámetros automáticamente, e integramos tanto covariables lineales como transformaciones no lineales para capturar comportamientos más realistas. Finalmente, extendimos el sistema con efectos arbitrarios, donde los usuarios pueden definir sus propias transformaciones, funciones de saturación o procesos aditivos, siempre que sigan el diseño basado en protocolos de la biblioteca.

Al hacerlo, sus modelos pueden crecer tanto como desee en complejidad, y puede confiar en PyMC-Marketing para manejarlo de manera elegante. Con mínimas líneas de código, puede aumentar la dimensionalidad, habilitar la difusión automática y agregar transformaciones lineales o no lineales, así como covariables lineales. Y con solo unas pocas líneas más, puede implementar transformaciones completamente personalizadas o componentes aditivos arbitrarios adaptados a su caso de uso específico.

Por supuesto, esta flexibilidad tiene un costo: a medida que su modelo crece, también lo hace el número de parámetros, a veces hasta el punto en que el muestreo se vuelve costoso computacionalmente o incluso inviable. Echemos un vistazo más de cerca a cómo ha evolucionado el conteo de parámetros en nuestros modelos hasta ahora, considerando cada variable aleatoria multiplicada por su respectiva dimensionalidad.

# count and print the total number of parameters (considering dimensions) in each model
def count_total_parameters(model):
    """Count total parameters considering their dimensions."""
    total = 0
    for rv in model.free_RVs:
        # Get the size of the random variable (product of all dimensions)
        rv_size = rv.size.eval() if hasattr(rv.size, "eval") else rv.size
        total += rv_size
    return total


print(f"Total parameters in linear_model: {count_total_parameters(linear_model.model)}")
print(
    f"Total parameters in linear_model_with_country_dimensionality: "
    f"{count_total_parameters(linear_model_with_country_dimensionality.model)}"
)
print(
    f"Total parameters in custom_non_linear_model_with_country_dimensionality: "
    f"{count_total_parameters(custom_non_linear_model_with_country_dimensionality.model)}"
)
print(
    f"Total parameters in custom_non_linear_model_with_country_dimensionality_and_controls: "
    f"{count_total_parameters(custom_non_linear_model_with_country_dimensionality_and_controls.model)}"
)
print(
    f"Total parameters in custom_non_linear_model_with_country_dimensionality_and_controls_special: "
    f"{count_total_parameters(custom_non_linear_model_with_country_dimensionality_and_controls_special.model)}"
)
Total parameters in linear_model: 6
Total parameters in linear_model_with_country_dimensionality: 396
Total parameters in custom_non_linear_model_with_country_dimensionality: 653
Total parameters in custom_non_linear_model_with_country_dimensionality_and_controls: 781
Total parameters in custom_non_linear_model_with_country_dimensionality_and_controls_special: 1038

Parámetro de Crecimiento y Tiempo de Muestreo#

A medida que el modelo evolucionó, el conteo de parámetros creció rápidamente.

Esto ilustra una realidad clave de los modelos bayesianos aditivos y multidimensionales: el espacio de parámetros a menudo crece de manera superlineal (y puede parecer exponencial) a medida que apilas efectos (tendencia, estacionalidad, adstock, saturación), agregas controles y te expandes sobre índices (por ejemplo, país, producto, región). El costo computacional típicamente aumenta tanto con el tamaño de los datos como con la dimensionalidad de los parámetros; para MCMC basado en gradientes como NUTS, el tiempo de muro por muestra efectiva puede aumentar drásticamente a medida que las distribuciones posteriores se vuelven de mayor dimensión y más correlacionadas. Por eso no ajustamos cada variante aquí; por mucho que nos encante escribir cuadernos, no nos encanta escribirlos durante horas. 😉

Excluyendo variables del muestreo con MaskedPrior#

Cuando los modelos crecen en muchas dimensiones — países, regiones, tipos de productos y canales — no todas las combinaciones son significativas o incluso observadas en los datos. Por ejemplo, algunos productos pueden no venderse en ciertos países, o algunos canales pueden no operar en regiones específicas. En esos casos, es ineficiente (y potencialmente engañoso) asignar priors y muestrear parámetros para cada combinación. Ahí es donde entra MaskedPrior.

MaskedPrior le permite definir priors solo sobre el subconjunto activo de su espacio de parámetros. Usted le proporciona:

  • una base previa (por ejemplo, Prior("Normal", mu=0, sigma=1, dims=("country", "channel"))), y

  • una máscara booleana (xarray.DataArray) que indica qué entradas de parámetro deben estar activas (True) o excluidas (False).

Internamente, MaskedPrior realiza tres pasos principales:

  1. Creación de subconjuntos: Se construye una variable reducida solo sobre las entradas activas (aquellas marcadas como True en la máscara).

  2. Muestreo en subconjunto activo: El muestreador extrae valores solo para este subconjunto más pequeño, ignorando completamente las combinaciones inactivas.

  3. Expansión de nuevo a la forma completa: La variable reducida se reexpande a la forma dimensional original, llenando las posiciones inactivas con ceros deterministas.

Vea lo siguiente, si define una máscara \(M_{i,j} \in \{0,1\}\) sobre un parámetro \(\theta_{i,j}\), el parámetro enmascarado se convierte en:

\[::\]

La muestreo ocurre solo para el subconjunto activo:

\[::\]

y el número total de variables aleatorias se reduce a \(\sum_{i,j} M_{i,j}\).

Los beneficios son significativos:

  • Reducción de parámetros: Solo se muestrean los parámetros significativos, lo que reduce la carga computacional y mejora la eficiencia del muestreo.

  • Convergencia más rápida: Con menos parámetros, NUTS y ADVI pueden converger de manera más rápida y estable.

  • Claridad estructural: La máscara documenta explícitamente qué combinaciones de parámetros son válidas en su modelo.

  • Compatibilidad: Los priors enmascarados se integran a la perfección con los componentes de PyMC-Marketing, como las transformaciones de adstock y saturación, a través de definiciones de prior estándar.

# Helper to build a mask given excluded
def make_country_channel_mask(countries, channels, excluded_combinations):
    mask = xr.DataArray(
        np.ones(
            (len(countries), len(regions), len(product_types), len(channels)),
            dtype=bool,
        ),
        dims=("country", "region", "product_type", "channel"),
        coords={
            "country": countries,
            "region": regions,
            "product_type": product_types,
            "channel": channels,
        },
    )
    for country, region, product_type, channel in excluded_combinations:
        # Ensure labels match exactly your coords (e.g., "Venezuela", "Ecuador", "Colombia", "Panama")
        if (
            (country in mask.country.values)
            and (region in mask.region.values)
            and (product_type in mask.product_type.values)
            and (channel in mask.channel.values)
        ):
            mask.loc[
                dict(
                    country=country,
                    region=region,
                    product_type=product_type,
                    channel=channel,
                )
            ] = False
    return mask


feature_cols = [
    c
    for c in multi_country_df.columns
    if c
    not in (
        "date_week",
        "y",
        "country",
        "region",
        "product_type",
        "control_a",
        "control_b",
        "control_special_c",
        "control_special_d",
    )
]

excluded_combinations = [
    ("Venezuela", "South", "Type A", "x1"),
    ("Ecuador", "North", "Type B", "x3"),
    ("Colombia", "East", "Type C", "x4"),
    ("Panama", "West", "Type D", "x2"),
    ("Panama", "West", "Type D", "x4"),
    ("Venezuela", "West", "Type B", "x4"),
    ("Colombia", "West", "Type B", "x4"),
    ("Colombia", "East", "Type C", "x1"),
    # . . . add as many as you want
]
mask_cc = make_country_channel_mask(countries, feature_cols, excluded_combinations)

# Priors
adstock_masked = GeometricAdstock(
    l_max=6,
    priors={
        "alpha": MaskedPrior(
            Prior(
                "Beta",
                alpha=1,
                beta=1,
                dims=("country", "region", "product_type", "channel"),
            ),
            mask=mask_cc,
        )
    },
)

saturation_masked = ExponentialTransformation(
    priors={
        "beta": MaskedPrior(
            Prior(
                "Beta",
                alpha=3,
                beta=Prior("HalfNormal", sigma=1),
                dims=("country", "region", "product_type", "channel"),
            ),
            mask=mask_cc,
        ),
    },
    prefix="exponential",
)

Esto significa que PyMC-Marketing no muestreará parámetros de adstock o saturación para esas combinaciones excluidas; en su lugar, llena esas entradas con ceros deterministas mientras mantiene una alineación completa de coordenadas.

Al aprovechar MaskedPrior, puede podar modelos grandes de manera inteligente, manteniendo la consistencia estructural mientras reduce drásticamente el espacio de parámetros. Este enfoque mantiene su modelo tanto escalable como interpretable, especialmente al trabajar con conjuntos de datos de marketing de alta dimensión.

Advertencia

La funcionalidad MaskedPrior se fusionó recientemente y aún es experimental. Úsela con precaución en entornos de producción.

custom_non_linear_model_with_country_dimensionality_and_controls_special_masked = MMM(
    date_column="date_week",
    target_column="y",
    dims=("country", "region", "product_type"),
    channel_columns=["x1", "x2", "x3", "x4"],
    control_columns=["control_a", "control_b"],
    adstock=adstock_masked,
    saturation=saturation_masked,
    sampler_config=sample_kwargs,
)
custom_non_linear_model_with_country_dimensionality_and_controls_special_masked.mu_effects.extend(
    [trend_effect, weekly_effect]
)
custom_non_linear_model_with_country_dimensionality_and_controls_special_masked.mu_effects.append(
    effect
)
custom_non_linear_model_with_country_dimensionality_and_controls_special_masked.build_model(
    X=multi_country_df.drop(columns=["y"]),
    y=multi_country_df["y"],
)
custom_non_linear_model_with_country_dimensionality_and_controls_special_masked.model.to_graphviz()
../../_images/55edede105c57fd55663339eb726227e69bf3861a9a4b627c40f315cab341422.svg
custom_non_linear_model_with_country_dimensionality_and_controls_special_masked.sample_prior_predictive(
    X=multi_country_df.drop(columns=["y"]),
    y=multi_country_df["y"],
)
Sampling: [adstock_alpha_active, delta, desaturated_controls_beta, desaturated_controls_beta_beta, desaturated_controls_lam, exponential_beta_active, exponential_beta_active_beta, gamma_control, intercept_contribution, weekly_beta, y, y_sigma]
<xarray.Dataset> Size: 224MB
Dimensions:       (date: 2190, country: 4, region: 4, product_type: 4,
                   sample: 200)
Coordinates:
  * date          (date) datetime64[ns] 18kB 2022-01-01 ... 2027-12-30
  * country       (country) <U9 144B 'Colombia' 'Ecuador' 'Panama' 'Venezuela'
  * region        (region) <U5 80B 'East' 'North' 'South' 'West'
  * product_type  (product_type) <U6 96B 'Type A' 'Type B' 'Type C' 'Type D'
  * sample        (sample) object 2kB MultiIndex
  * chain         (sample) int64 2kB 0 0 0 0 0 0 0 0 0 0 ... 0 0 0 0 0 0 0 0 0 0
  * draw          (sample) int64 2kB 0 1 2 3 4 5 6 ... 194 195 196 197 198 199
Data variables:
    y             (date, country, region, product_type, sample) float64 224MB ...
Attributes:
    created_at:                 2026-03-17T20:35:37.027409+00:00
    arviz_version:              0.23.4
    inference_library:          pymc
    inference_library_version:  5.28.1
    pymc_marketing_version:     0.18.2
print(f"Total parameters in linear_model: {count_total_parameters(linear_model.model)}")
print(
    f"Total parameters in linear_model_with_country_dimensionality: "
    f"{count_total_parameters(linear_model_with_country_dimensionality.model)}"
)
print(
    f"Total parameters in custom_non_linear_model_with_country_dimensionality: "
    f"{count_total_parameters(custom_non_linear_model_with_country_dimensionality.model)}"
)
print(
    f"Total parameters in custom_non_linear_model_with_country_dimensionality_and_controls: "
    f"{count_total_parameters(custom_non_linear_model_with_country_dimensionality_and_controls.model)}"
)
print(
    f"Total parameters in custom_non_linear_model_with_country_dimensionality_and_controls_special: "
    f"{count_total_parameters(custom_non_linear_model_with_country_dimensionality_and_controls_special.model)}"
)
print(
    f"Total parameters in non_linear_model_with_country_dimensionality_masked: "
    f"{count_total_parameters(custom_non_linear_model_with_country_dimensionality_and_controls_special_masked.model)}"
)
Total parameters in linear_model: 6
Total parameters in linear_model_with_country_dimensionality: 396
Total parameters in custom_non_linear_model_with_country_dimensionality: 653
Total parameters in custom_non_linear_model_with_country_dimensionality_and_controls: 781
Total parameters in custom_non_linear_model_with_country_dimensionality_and_controls_special: 1038
Total parameters in non_linear_model_with_country_dimensionality_masked: 1022

Inferencia variacional para rejillas de alta dimensión#

Ya hemos visto cómo el diseño dimensional y MaskedPrior pueden reducir el espacio de parámetros al excluir combinaciones irrelevantes. Sin embargo, hay regímenes donde esto no es suficiente: a medida que añades transformaciones no lineales, tendencias/estacionalidad, estructuras de múltiples índices y efectos personalizados, puedes terminar en geometrías posteriores muy de alta dimensión. En estos casos, el MCMC exacto (por ejemplo, NUTS) puede volverse prohibitivamente costoso. Aquí es donde la inferencia aproximada brilla. Como señala acertadamente Michael Betancourt, “En altas dimensiones, las expectativas son lo único que tiene sentido.” Las aproximaciones te permiten apuntar a resúmenes informativos (por ejemplo, medias posteriores, intervalos creíbles) sin pagar el costo total del muestreo exacto.

PyMC-Marketing expone los métodos variacionales y aproximados de PyMC directamente, para que pueda cambiar de muestreo exacto a aproximación con un código mínimo. Por ejemplo, utilizando ADVI a través de pm.fit(...) y luego extrayendo muestras de la aproximación. Esta herramienta le permite prototipar y clasificar modelos grandes rápidamente. Posteriormente, puede escalar a MCMC completo si la aproximación parece adecuada y el presupuesto de cómputo lo permite. Para obtener detalles sobre los métodos de aproximación disponibles (por ejemplo, ADVI, ADVI de rango completo, SVGD), consulte la documentación de inferencia de PyMC (busque «inferencia variacional de PyMC» y pm.fit).

Advertencia

Approximations: Useful but Not Magic:

  • Compensación entre sesgo y varianza: Las familias variacionales pueden subestimar la varianza posterior y perder correlaciones.

  • Patologías en geometría: La multimodalidad, las colas pesadas y la fuerte curvatura pueden romper supuestos variacionales simples o llevar a óptimos locales deficientes.

  • Los diagnósticos son importantes: Monitoree las trayectorias de ELBO, verifique la estabilidad de las soluciones y compárelas con ejecuciones cortas de NUTS en modelos reducidos cuando sea posible.

  • Riesgo de calibración: Las decisiones posteriores (por ejemplo, la asignación de presupuesto) pueden ser excesivamente confiadas si se subestima la varianza. Considere políticas conservadoras o utilidades robustas.

_start_time = time.time()
linear_model.approximate_fit(
    X=df.drop(columns=["y"]),
    y=df["y"],
    fit_kwargs={"method": "advi"},  # goes to pm.fit(...)
    sample_kwargs={"draws": 1_000},  # goes to approximation.sample(...)
)
_end_time = time.time()
elapsed_time = _end_time - _start_time
print(f"Approximation took {elapsed_time:.2f} seconds")

Finished [100%]: Average Loss = 1,556.4

Approximation took 5.02 seconds

Reflexiones Finales#

Lo que hemos explorado aquí es solo un vistazo de lo que PyMC-Marketing puede hacer. Más allá de ser un conjunto de herramientas para el Modelado de Mezcla de Marketing, es un marco de modelado probabilístico impulsado por PyMC que te proporciona los bloques de construcción para diseñar, extender y experimentar con modelos bayesianos de cualquier complejidad, desde regresiones lineales hasta sistemas ricos, jerárquicos y conscientes del tiempo.

PyMC-Marketing no se limita para marketing y, lo más importante, no estás limitado por plantillas. Puedes construir, modificar y extender tus modelos libremente mientras te apoyas en la sólida base del motor probabilístico de PyMC. Cada capa adicional — tendencia, estacionalidad, saturación o efecto personalizado — añade expresividad sin perder claridad ni estructura.

Así que, ¡sigue explorando! Prueba nuevas transformaciones, mezcla efectos causales y funcionales, evalúa estrategias de inferencia y comparte tus ideas con la comunidad. Si hay una función que desearías que existiera, pídela o, aún mejor, ayúdanos a construirla.

PyMC-Marketing está evolucionando rápidamente, y su curiosidad es lo que impulsa su próximo paso! 🔥

%reload_ext watermark
%watermark -n -u -v -iv -w
Last updated: Tue, 17 Mar 2026

Python implementation: CPython
Python version       : 3.13.12
IPython version      : 9.11.0

arviz         : 0.23.4
matplotlib    : 3.10.8
numpy         : 2.4.2
pandas        : 2.3.3
pydantic      : 2.12.5
pymc          : 5.28.1
pymc_extras   : 0.9.3
pymc_marketing: 0.18.2
pytensor      : 2.38.2
xarray        : 2026.2.0

Watermark: 2.6.0