Asignación de Presupuesto con PyMC-Marketing#

El propósito de este cuaderno es explorar la función recientemente incluida en la biblioteca PyMC-Marketing que se centra en la asignación de presupuestos. Los fundamentos de esta función se basan en las metodologías inspiradas en el trabajo de Bolt en el artículo, «Budgeting with Bayesian Models».

Conocimientos Previos#

El cuaderno asume que el lector tiene conocimiento de las funcionalidades esenciales de PyMC-Marketing. Si no está familiarizado, el «Cuaderno de Ejemplo de MMM» sirve como un excelente punto de partida, ofreciendo una introducción completa a los modelos de mezcla de medios en este contexto.

Presentamos el asignador de presupuesto#

Este cuaderno provoca un examen de la función dentro de la biblioteca PyMC-Marketing, que aborda estos desafíos utilizando modelos bayesianos. La función tiene la intención de proporcionar:

  1. Medidas cuantitativas de la efectividad de diferentes canales de medios.

  2. Estimaciones de ROI probabilísticas bajo una variedad de escenarios de presupuesto.

Configuración básica#

Al igual que los cuadernos anteriores relacionados con PyMC-Marketing, este se basa en un conjunto específico de bibliotecas. A continuación se presentan las importaciones necesarias para ejecutar los fragmentos de código proporcionados.

import warnings

import arviz as az
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import xarray as xr

from pymc_marketing.mmm.builders.yaml import build_mmm_from_yaml
from pymc_marketing.mmm.multidimensional import (
    MultiDimensionalBudgetOptimizerWrapper,
)
from pymc_marketing.paths import data_dir

warnings.filterwarnings("ignore")

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

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

Estas importaciones y configuraciones forman la configuración fundamental necesaria para todo el alcance de este cuaderno.

La expectativa es que un modelo ya ha sido entrenado utilizando las funcionalidades proporcionadas en versiones anteriores de la biblioteca PyMC-Marketing. Por lo tanto, los procesos de generación de datos y entrenamiento se replicarán en un cuaderno diferente. Se aconseja a aquellos que no estén familiarizados con estos procedimientos que consulten el «Cuaderno de Ejemplo de MMM.»

Cargando un modelo preentrenado#

Para utilizar un modelo guardado, cárguelo en una nueva instancia de la clase MMM utilizando el método build_mmm_from_yaml a continuación.

seed: int = sum(map(ord, "mmm_allocation_example"))
rng: np.random.Generator = np.random.default_rng(seed=seed)
data_path = data_dir / "multidimensional_mock_data.csv"
data_df = pd.read_csv(data_path, parse_dates=["date"], index_col=0)
data_df.head()
date y x1 x2 event_1 event_2 dayofyear t geo
0 2018-04-02 3984.662237 159.290009 0.0 0.0 0.0 92 0 geo_a
1 2018-04-09 3762.871794 56.194238 0.0 0.0 0.0 99 1 geo_a
2 2018-04-16 4466.967388 146.200133 0.0 0.0 0.0 106 2 geo_a
3 2018-04-23 3864.219373 35.699276 0.0 0.0 0.0 113 3 geo_a
4 2018-04-30 4441.625278 193.372577 0.0 0.0 0.0 120 4 geo_a
x_train = data_df.drop(columns=["y"])
y_train = data_df["y"]
mmm = build_mmm_from_yaml(
    X=x_train,
    y=y_train,
    config_path=data_dir / "config_files" / "multi_dimensional_example_model.yml",
)

Para más detalles sobre build_mmm_from_yaml, consulte la documentación de pymc-marketing sobre el Despliegue de Modelos.

Alternativamente, cargue un modelo que ha sido guardado en MLflow a través de pymc_marketing.mlflow.log_inference_data o que ha sido registrado automáticamente en MLflow a través de pymc_marketing.mlflow.autolog(log_mmm=True), desde el módulo PyMC-Marketing MLflow.

## If you have a hosted MLflow server, you will of course need to authenticate first.
# RUN_ID = "your_run_id"
# from pymc_marketing.mlflow import load_mmm
# mmm = load_mmm(RUN_ID)

# # Load the full model with the InferenceData
# mmm = load_mmm(
#     run_id=RUN_ID,         # The MLflow run ID from which to load the model
#     full_model=True,       # Set to True to get the full MMM model with InferenceData
#     keep_idata=True,       # Set to True if you want to keep the downloaded InferenceData saved locally
# )

Declaración del problema#

Antes de sumergirnos en los datos, primero definamos el problema empresarial que estamos tratando de resolver. En un escenario cada vez más competitivo, se encarga a los especialistas en marketing la distribución de un presupuesto de marketing predeterminado entre varios canales para maximizar una determinada respuesta. Consideremos un próximo trimestre en el que un equipo de marketing debe decidir la división de sus operaciones entre dos canales publicitarios, representados como x1 y x2. Estos podrían simbolizar efectivamente cualquier medio, como televisión, publicidad digital, impresión, etc.

La tarea consiste en tomar decisiones que invoquen datos, cumplan con la evidencia fáctica y se alineen con la lógica empresarial. Por ejemplo, ¿cómo se puede incorporar información previa como restricciones presupuestarias, tendencias de la plataforma, limitaciones o incluso características distintivas de cada canal en el proceso de toma de decisiones?

Introduciendo la Función de Asignación de Presupuesto#

Las capacidades de asignación de presupuesto en PyMC-Marketing tienen como objetivo abordar este problema al ofrecer un marco bayesiano para la asignación óptima. Esto permite a los comercializadores:

  • Integre los resultados de la Modelización de Mezcla de Medios (MMM), cuantificando la efectividad de cada canal en métricas como el ROI, las ventas incrementales, etc.

  • Fusiona estos datos empíricos con el conocimiento y la lógica empresarial previos para tomar decisiones holísticas y sólidas.

Al utilizar esta función, los especialistas en marketing pueden garantizar que la distribución del presupuesto no solo obedezca al rigor matemático proporcionado por los resultados del MMM, sino que también incorpore factores específicos del negocio, logrando así un plan de presupuesto equilibrado y optimizado.

Comenzando#

El Modelado de Mezcla de Medios (MMM) actúa como un método confiable para estimar la contribución de cada canal (por ejemplo, x1, x2) a una variable objetivo como las ventas o cualquier variable.

La función saturation_scatterplot() permite visualizar este impacto directo del canal. Sin embargo, es crucial recordar que esto solo revela el «espacio observable» para los valores de X (gasto) e Y (contribución).

mmm.plot.saturation_scatterplot(original_scale=True);

El espacio observable solo abarca nuestros puntos de datos y no ilustra lo que ocurre más allá de esos puntos. Como resultado, no se asegura que el punto de máxima contribución para cada canal se encuentre dentro de este rango observable.

Si queremos visualizar cierto nivel de respuesta, podemos utilizar sample_curve para obtener una estimación de nuestra respuesta en un espacio escalado dado un valor máximo de X en el espacio escalado también. En el ejemplo a continuación, estamos utilizando el valor 3, que representa 3X el valor histórico máximo en cada canal. Dependiendo de su método de escalado, max_value podría representar algo diferente.

Después de eso, utilizando la función saturation_curves, podemos predecir la forma de la curva de ajuste del modelo para la cantidad gastada que no se había observado previamente.

curve = mmm.saturation.sample_curve(
    mmm.idata.posterior.sel(channel=["x1", "x2"]),
    max_value=3,
)
fig, axes = mmm.plot.saturation_curves(
    curve,
    original_scale=True,
    n_samples=10,
    hdi_probs=0.85,
    random_seed=rng,
    subplot_kwargs={"figsize": (12, 8), "ncols": 2},
    rc_params={
        "xtick.labelsize": 10,
        "ytick.labelsize": 10,
        "axes.labelsize": 10,
        "axes.titlesize": 10,
    },
)

for ax in axes.ravel():
    ax.title.set_fontsize(10)

if fig._suptitle is not None:
    fig._suptitle.set_fontsize(12)

plt.tight_layout()
plt.show()
Sampling: []

/var/folders/pz/_cz6r8vd1q52dttrgg406lph0000gn/T/ipykernel_79210/2382627145.py:28: UserWarning: The figure layout has changed to tight
  plt.tight_layout()
../../_images/56120895e865af9836b9368616c426865b5b9483c12029bd9e89ebc0f60fb29e.png

Podemos identificar qué función de saturación se utilizó en el modelo preentrenado:

print(f"Model was train using the {mmm.saturation.__class__.__name__} function")
print(f"and the {mmm.adstock.__class__.__name__} function")
Model was train using the LogisticSaturation function
and the GeometricAdstock function

Dentro de PyMC-Marketing tenemos diferentes funciones de saturación, puedes observar todas en el módulo transformer.

Una vez que se obtienen estos parámetros, puede visualizarlos utilizando la función arviz.summary (cada parámetro tiene el prefijo saturation o adstock respectivamente) y, si lo desea, puede recrear las curvas para cada canal de manera independiente basándose en ellos. Más crucialmente, estos valores de parámetros son indispensables al utilizar la función budget_allocator, que aprovecha esta información para optimizar su presupuesto de marketing a través de distintos canales. Esta sección es fundamental para la optimización del presupuesto.

az.summary(
    data=mmm.fit_result,
    var_names=[
        "saturation_beta",
        "saturation_lam",
        "adstock_alpha",
    ],
)
mean sd hdi_3% hdi_97% mcse_mean mcse_sd ess_bulk ess_tail r_hat
saturation_beta[x1] 0.370 0.021 0.332 0.410 0.001 0.001 929.0 1019.0 1.0
saturation_beta[x2] 0.272 0.060 0.189 0.383 0.002 0.003 1021.0 900.0 1.0
saturation_lam[x1] 4.015 0.429 3.211 4.831 0.015 0.012 869.0 870.0 1.0
saturation_lam[x2] 2.729 0.948 1.175 4.549 0.030 0.032 1042.0 914.0 1.0
adstock_alpha[x1] 0.394 0.033 0.336 0.457 0.001 0.001 1307.0 1245.0 1.0
adstock_alpha[x2] 0.184 0.040 0.106 0.256 0.001 0.001 1191.0 864.0 1.0

Ejemplos de Casos de Uso#

La función optimize_budget dentro de PyMC-Marketing cuenta con una multitud de aplicaciones que pueden resolver diversas problemáticas empresariales. Aquí, presentamos cinco casos de uso críticos que ejemplifican su utilidad en escenarios de marketing del mundo real.

¿Qué estamos optimizando?#

Antes de entrar en los ejemplos, necesitamos entender la base de nuestro optimizador.

Nuestro objetivo es optimizar la asignación de presupuestos a través de múltiples canales para maximizar la contribución general a los indicadores clave de rendimiento (KPI), como las ventas o las conversiones. Cada canal tiene su propia función de avance, que puede considerar internamente una curva sigmoide o de Michaelis-Menten, representando la relación entre la cantidad gastada y el rendimiento resultante.

Estas curvas varían en características: algunos canales se saturan rápidamente, lo que significa que el gasto adicional produce rendimientos decrecientes, mientras que otros pueden ofrecer un crecimiento más lineal en la contribución con un aumento del gasto.

Para resolver este problema de optimización, empleamos el algoritmo de Programación Cuadrática de Mínimos Cuadrados Secuenciales (SLSQP), una técnica de optimización basada en gradientes. SLSQP es especialmente adecuado para esta aplicación, ya que permite la imposición de restricciones tanto de igualdad como de desigualdad, asegurando que la asignación del presupuesto se adhiera a las reglas o limitaciones del negocio.

El algoritmo funciona aproximando iterativamente la función objetivo y las restricciones utilizando funciones cuadráticas y resolviendo los subproblemas resultantes para encontrar un mínimo local. Esto nos permite navegar de manera efectiva por el espacio multidimensional de asignaciones presupuestarias para encontrar la distribución de recursos más eficiente.

El optimizador tiene como objetivo maximizar la contribución total de todos los canales, cumpliendo con las siguientes restricciones:

  1. Limitaciones del presupuesto: El gasto total en todos los canales no debe exceder el presupuesto de marketing general.

  2. Restricciones específicas del canal: Algunos canales pueden tener límites de gasto mínimos o máximos.

Al aprovechar el algoritmo SLSQP, podemos optimizar la asignación del presupuesto multicanal de manera rigurosa y matemáticamente sólida, asegurando que obtengamos el mayor retorno de inversión posible.

Maximizando la Contribución#

Asuma que está gestionando el marketing de una empresa minorista con un presupuesto sustancial para asignar a la publicidad en múltiples canales. Dado esto, está contemplando formas de optimizar el gasto del próximo trimestre para maximizar la contribución general.

Es posible que haya considerado dispersar su dinero de la misma manera que lo hizo históricamente sin un modelo MMM; repitamos la fórmula conocida. Sin embargo, desea explorar mejores alternativas ahora que posee un modelo MMM. Dado que carece de conocimientos previos, impone las mismas restricciones a ambos canales. Cada uno debe gastar un mínimo de 500 euros y no más de 2,000 euros, lo que equivale a su presupuesto total.

from pymc_marketing.mmm.budget_optimizer import optimizer_xarray_builder

time_unit_budget = 4_000  # Budget per time unit
campaign_period = 12  # Number of time units
print(
    f"Total budget for the {campaign_period} Weeks: {time_unit_budget * campaign_period:,}"
)
# Define your channels
channels = ["x1", "x2"]
geos = ["geo_a", "geo_b"]
# The initial split per channel
budget_per_channel = time_unit_budget / (len(channels) * len(geos))
# Initial budget per channel.
initial_budget = optimizer_xarray_builder(
    np.array(
        [
            [budget_per_channel * 0.5, budget_per_channel * 1.5],
            [budget_per_channel * 0.6, budget_per_channel * 1.4],
        ]
    ),
    channel=channels,
    geo=geos,
)  # Using this function we can create the initial allocation strategy for each channel and geo

print("-" * 50)
print("Budget per channel per geo:")
for geo in geos:
    for channel in channels:
        print(
            f"  {geo} - {channel}: {initial_budget.sel(geo=geo, channel=channel).item():.2f}"
        )

# bounds for each channel
min_budget, max_budget = 500, 2_000
budget_bounds = optimizer_xarray_builder(
    np.array(
        [
            [[min_budget, max_budget], [min_budget, max_budget]],
            [[min_budget, max_budget], [min_budget, max_budget]],
        ]
    ),
    channel=channels,
    geo=geos,
    bound=["lower", "upper"],
)  # Using this function we can create a budget bounds for each channel and geo as well
Total budget for the 12 Weeks: 48,000
--------------------------------------------------
Budget per channel per geo:
  geo_a - x1: 500.00
  geo_a - x2: 600.00
  geo_b - x1: 1500.00
  geo_b - x2: 1400.00

Nuestro modelo actual fue entrenado con datos semanales, lo que significa que cada período (unidad de tiempo) representa una semana. Si planeamos crear una asignación de presupuesto para un trimestre específico, necesitamos añadir 12 semanas a nuestra fecha inicial. Al hacerlo, podemos inicializar nuestra clase que envuelve nuestro MMM.

# Get the maximum date and add one day to it
max_date = mmm.idata.posterior.coords["date"].max().item()
start_date = (
    pd.Timestamp(max_date) + pd.Timedelta(weeks=1)
).strftime(  # mmm.adstock.l_max+2
    "%Y-%m-%d"
)

end_date = (pd.Timestamp(start_date) + pd.Timedelta(weeks=campaign_period)).strftime(
    "%Y-%m-%d"
)

print(f"Start date: {start_date}, End date: {end_date}")
Start date: 2021-09-06, End date: 2021-11-29
optimizable_model = MultiDimensionalBudgetOptimizerWrapper(
    model=mmm, start_date=start_date, end_date=end_date
)
optimizable_model.adstock.l_max, optimizable_model.num_periods
../../_images/dec5b6eae60136942f88108e87103d975a65ecfd27003d42f02bb984563bb324.png

Antes de proceder a evaluar la efectividad de nuestra optimización, podemos estimar la respuesta siguiendo nuestro plan inicial, que implica distribuir nuestro presupuesto en función de los patrones de gasto históricos.

sample_response_give_initial_budget = optimizable_model.sample_response_distribution(
    allocation_strategy=initial_budget,  # Here we add the initial budget allocation strategy
    include_carryover=True,
    include_last_observations=False,
)
Sampling: [y]

La respuesta se expondrá como un array de datos con diferentes variables, tales como:

  • y (Variables objetivo)

  • asignación (La estrategia de asignación compartida)

  • variables de canal (Cada columna de canal con las unidades correspondientes utilizadas para obtener la predicción).

  • Contribución Total del Canal de Medios en Escala Original (La distribución posterior de la suma del canal de medios por fecha)

initial_budget.sum(dim="geo")
<xarray.DataArray (channel: 2)> Size: 16B
array([2000., 2000.])
Coordinates:
  * channel  (channel) <U2 16B 'x1' 'x2'
sample_response_give_initial_budget.allocation.sum(dim="geo")
<xarray.DataArray 'allocation' (channel: 2)> Size: 16B
array([2000., 2000.])
Coordinates:
  * channel  (channel) <U2 16B 'x1' 'x2'
sample_response_give_initial_budget["x1"].sum(dim="geo")
<xarray.DataArray 'x1' (date: 21)> Size: 168B
array([2001.05516828, 1998.17680521, 2000.86259682, 2000.53354004,
       2001.24720109, 2001.03767898, 2000.10398136, 2002.55438021,
       2000.10373669, 1999.06157133, 2000.07774446, 2000.98811459,
       2000.96803296,    0.        ,    0.        ,    0.        ,
          0.        ,    0.        ,    0.        ,    0.        ,
          0.        ])
Coordinates:
  * date     (date) datetime64[ns] 168B 2021-09-06 2021-09-13 ... 2022-01-24
fig, ax = plt.subplots()
az.plot_posterior(
    sample_response_give_initial_budget.total_media_contribution_original_scale.values.flatten(),
    hdi_prob=0.95,
    color="blue",
    label="Intial planned allocation",
    ax=ax,
);

Excelente, podemos ver que nuestra estimación inicial nos está dando alrededor de 146K nuevas unidades (ventas en este caso) dado el marketing. Pero, dado el mismo presupuesto, ¿podríamos hacerlo mejor?

allocation_xarray, res_scipy = optimizable_model.optimize_budget(
    budget=time_unit_budget,  # Total budget to allocate here is spend in Millions
    budget_bounds=budget_bounds,  # Budget bounds for each channel
)

sample_response_given_allocation = optimizable_model.sample_response_distribution(
    allocation_strategy=allocation_xarray,
    include_carryover=True,
    include_last_observations=False,
)
/Users/imrisofer/projects/pymc-marketing/pymc_marketing/mmm/budget_optimizer.py:745: UserWarning: Using default equality constraint
  self.set_constraints(
Sampling: [y]
res_scipy
     message: Optimization terminated successfully
     success: True
      status: 0
         fun: -151562.67382532053
           x: [ 9.619e+02  1.031e+03  9.702e+02  1.037e+03]
         nit: 20
         jac: [-4.078e+00 -4.078e+00 -4.078e+00 -4.078e+00]
        nfev: 20
        njev: 20
 multipliers: [-4.078e+00]
fig, ax = plt.subplots()

# Initial planned allocation
initial_data = sample_response_give_initial_budget.total_media_contribution_original_scale.values.flatten()
initial_mean = initial_data.mean()
az.plot_dist(
    initial_data,
    # hdi_prob=0.75,
    color="blue",
    label=f"Intial planned allocation: Response {initial_mean:,.0f}",
    ax=ax,
    # kind="hist",
)
ax.axvline(initial_mean, color="blue", linestyle="--")

# Optimized allocation
optimized_data = sample_response_given_allocation.total_media_contribution_original_scale.values.flatten()
optimized_mean = optimized_data.mean()
az.plot_dist(
    optimized_data,
    # hdi_prob=0.75,
    color="red",
    label=f"Optimized allocation: Response {optimized_mean:,.0f}",
    ax=ax,
    # kind="hist",
)
ax.axvline(optimized_mean, color="red", linestyle="--")

ax.set_title("Comparison of Intial and Optimized allocation")
ax.set_xlabel("Response")
ax.set_ylabel("Density")
ax.legend()

plt.show()

Excelente, podemos ver que dada la asignación, el optimizador maximiza la respuesta total de ambos canales y nos devuelve 5,000 unidades adicionales, dado el mismo gasto. Podemos visualizar la respuesta media por canal, dado el gasto, utilizando la función plot.budget_allocation.

optimizable_model.plot.budget_allocation(
    samples=sample_response_given_allocation,
);
/Users/imrisofer/projects/pymc-marketing/pymc_marketing/mmm/plot.py:1873: UserWarning: The figure layout has changed to tight
  fig.tight_layout()
../../_images/975c9e1326bd0fbe44a06ab9eeedbdced687efcf5c9d281ef327c58faa4f6834.png

Podríamos visualizar la respuesta a lo largo del tiempo si lo deseamos.

optimizable_model.plot.allocated_contribution_by_channel_over_time(
    samples=sample_response_given_allocation,
);
/Users/imrisofer/projects/pymc-marketing/pymc_marketing/mmm/plot.py:2142: UserWarning: The figure layout has changed to tight
  fig.tight_layout()
../../_images/c22884d7a662ca8a776f261400d40020e2460461b187917e0f15c9a21fc4095c.png

Como probablemente observe, la respuesta es bastante plana y saturada. Como se mostró anteriormente en la distribución conjunta de la suma de efectos, la media solo aumenta porque la incertidumbre fue mayor, pero la mayoría de la densidad no está muy lejos de la mayor densidad en la asignación inicial.

¿Por qué sucede esto? ¡Echemos un vistazo a las curvas de respuesta!

curve = mmm.saturation.sample_curve(mmm.idata.posterior, max_value=3)
fig, axes = mmm.plot.saturation_curves(
    curve,
    original_scale=True,
    n_samples=10,
    hdi_probs=0.85,
    random_seed=rng,
    subplot_kwargs={"figsize": (12, 8), "ncols": 2},
    rc_params={
        "xtick.labelsize": 10,
        "ytick.labelsize": 10,
        "axes.labelsize": 10,
        "axes.titlesize": 10,
    },
)

# Add vertical lines for each geo-channel combo from the allocation
channels = sample_response_given_allocation.channel.values
geos = sample_response_given_allocation.geo.values

# Iterate over all channel-geo combinations
subplot_idx = 0
for channel in channels:
    for geo in geos:
        # Make sure we're accessing the correct axis object
        ax = axes.flat[subplot_idx] if isinstance(axes, np.ndarray) else axes

        # Get the budget value for this specific channel-geo combination
        budget_value = sample_response_given_allocation.allocation.sel(
            channel=channel, geo=geo
        ).item()

        # Add vertical line with a label
        ax.axvline(
            x=budget_value,
            color="red",
            linestyle="--",
            label=f"{channel}-{geo}: {budget_value:.1f}",
        )

        subplot_idx += 1

# Ensure we're working with actual axes objects, not numpy arrays
for i in range(len(channels) * len(geos)):
    ax = axes.flat[i] if isinstance(axes, np.ndarray) else axes
    if hasattr(ax, "title"):
        ax.title.set_fontsize(10)

if hasattr(fig, "_suptitle") and fig._suptitle is not None:
    fig._suptitle.set_fontsize(12)

plt.tight_layout()
plt.show()
Sampling: []

/var/folders/pz/_cz6r8vd1q52dttrgg406lph0000gn/T/ipykernel_79210/922976499.py:54: UserWarning: The figure layout has changed to tight
  plt.tight_layout()
../../_images/5eb779702480594a59de81ec7bb0ec3ebe8cf6f6f0794a055d3934c5868c7fa6.png

Como era de esperar, el presupuesto asignado (línea roja) se encuentra en la zona de saturación, lo que significa que tenemos muy poco movimiento dado el gasto actual. Al menos para algunos canales.

Podemos iterar sobre diferentes presupuestos, añadiendo un poco menos o más y validar cuánto avanza nuestra respuesta dado el presupuesto adicional.

scenarios = np.array([0.8, 1, 1.8, 2.2])
colors = ["blue", "green", "red", "purple"]

# Create a larger figure with 2 rows
fig = plt.figure(figsize=(23, 25), layout="constrained")
gs = fig.add_gridspec(2, 1, height_ratios=[1, 1])

# Create a 2x2 grid for budget allocations in the top row
gs_top = gs[0].subgridspec(2, 2)

# Store responses and allocations for later use
responses = []
allocations = []

# Budget allocations in a 2x2 grid
for i, scenario in enumerate(scenarios):
    row, col = divmod(i, 2)  # Calculate row and column position in 2x2 grid

    tmp_budget = time_unit_budget * scenario
    print(f"Optimization for budget: {tmp_budget:.2f}M")
    tmp_allocation_strategy, tmp_optimization_result = (
        optimizable_model.optimize_budget(
            budget=tmp_budget,
        )
    )

    # Save allocation for later use
    allocations.append(tmp_allocation_strategy)

    tmp_response = optimizable_model.sample_response_distribution(
        allocation_strategy=tmp_allocation_strategy,
        include_carryover=True,
        include_last_observations=False,
    )
    # Save response for later use
    responses.append(tmp_response)

    # Add subplot for budget allocation in 2x2 grid
    ax = fig.add_subplot(gs_top[row, col])
    result = optimizable_model.plot.budget_allocation(
        samples=tmp_response,
        ax=ax,
        dims={"geo": ["geo_a"]},  # Filter to a single geo for better visibility
    )
    ax.set_title(f"Budget: {tmp_budget:.0f}M")

# Second row: Response distributions (spanning the full width)
ax_dist = fig.add_subplot(gs[1])
for i, response in enumerate(responses):
    az.plot_dist(
        response.total_media_contribution_original_scale.values.flatten(),
        rug=True,
        color=colors[i],
        label=(
            f"Budget: {scenarios[i] * time_unit_budget:,.0f} - "
            f"Mean response: {response.total_media_contribution_original_scale.values.flatten().mean():,.0f}"
        ),
        ax=ax_dist,
    )

    # Add vertical line for mean
    mean_value = (
        response.total_media_contribution_original_scale.values.flatten().mean()
    )
    ax_dist.axvline(mean_value, color=colors[i], linestyle="--")

ax_dist.set_title("Response Distributions for Different Budget Scenarios")
ax_dist.set_xlabel("Response")
ax_dist.set_ylabel("Density")
ax_dist.legend()

fig.suptitle(
    "Budget Allocation and Response Distributions for Different Scenarios",
    fontsize=18,
    fontweight="bold",
)
fig.tight_layout(rect=[0, 0, 1, 0.99])  # leave space at the top for the suptitle
plt.subplots_adjust(hspace=0.20)
Optimization for budget: 3200.00M
/Users/imrisofer/projects/pymc-marketing/pymc_marketing/mmm/budget_optimizer.py:745: UserWarning: Using default equality constraint
  self.set_constraints(
/Users/imrisofer/projects/pymc-marketing/pymc_marketing/mmm/multidimensional.py:2662: UserWarning: No budget bounds provided. Using default bounds (0, total_budget) for each channel.
  return allocator.allocate_budget(
Sampling: [y]
/Users/imrisofer/projects/pymc-marketing/pymc_marketing/mmm/plot.py:1873: UserWarning: The figure layout has changed to tight
  fig.tight_layout()
Optimization for budget: 4000.00M
/Users/imrisofer/projects/pymc-marketing/pymc_marketing/mmm/budget_optimizer.py:745: UserWarning: Using default equality constraint
  self.set_constraints(
/Users/imrisofer/projects/pymc-marketing/pymc_marketing/mmm/multidimensional.py:2662: UserWarning: No budget bounds provided. Using default bounds (0, total_budget) for each channel.
  return allocator.allocate_budget(
Sampling: [y]
Optimization for budget: 7200.00M
/Users/imrisofer/projects/pymc-marketing/pymc_marketing/mmm/budget_optimizer.py:745: UserWarning: Using default equality constraint
  self.set_constraints(
/Users/imrisofer/projects/pymc-marketing/pymc_marketing/mmm/multidimensional.py:2662: UserWarning: No budget bounds provided. Using default bounds (0, total_budget) for each channel.
  return allocator.allocate_budget(
Sampling: [y]
Optimization for budget: 8800.00M
/Users/imrisofer/projects/pymc-marketing/pymc_marketing/mmm/budget_optimizer.py:745: UserWarning: Using default equality constraint
  self.set_constraints(
/Users/imrisofer/projects/pymc-marketing/pymc_marketing/mmm/multidimensional.py:2662: UserWarning: No budget bounds provided. Using default bounds (0, total_budget) for each channel.
  return allocator.allocate_budget(
Sampling: [y]
../../_images/a60acef983b192ea8de82e29daf11654d2381263a3bc3adc1ecf48d2cf1e71be.png

Esto deja todo claro, incluso añadiendo el doble del presupuesto no podemos mover nuestra respuesta total de manera significativa. Por supuesto, estamos maximizando la respuesta, pero ¿a qué costo? Echemos un vistazo al número de unidades devueltas por unidad gastada, similar al ROAS.

fig, ax = plt.subplots()

for index, response in enumerate(responses):
    optimized_data = (
        response.total_media_contribution_original_scale.values.flatten()
        / allocations[index].sum().item()
    )
    optimized_mean = optimized_data.mean()
    az.plot_dist(
        optimized_data,
        # hdi_prob=0.75,
        color=f"C{index + 1}",
        label=(
            f"Optimized allocation - Budget: {scenarios[index] * time_unit_budget:,.0f} - "
            f"Mean response: {optimized_data.mean():,.0f}"
        ),
        ax=ax,
        rug=True,
        # kind="hist",
    )
    ax.axvline(optimized_mean, color=f"C{index + 1}", linestyle="--")

Como era de esperar, cuanto mayor es el presupuesto, menores son los retornos. Esto sucede porque la respuesta se mantiene similar, pero el presupuesto aumenta más rápido (Sí, el efecto de rendimientos decrecientes). Podemos plantear una pregunta diferente: si queremos obtener 145,000, ¿cuál es la forma más económica de lograrlo?

Optimizando hacia un objetivo#

Otra forma de abordar la optimización es ajustar hacia una respuesta objetivo. Esto puede ser útil si desea asegurarse de que la respuesta esté por encima de un cierto nivel. En lugar de optimizar un presupuesto dado, podemos optimizar para encontrar el presupuesto adecuado para alcanzar una respuesta objetivo.

El siguiente ejemplo muestra cómo crear una restricción personalizada para minimizar el presupuesto necesario para alcanzar una respuesta objetivo. En pocas palabras, le estamos preguntando al optimizador, ¿cuál es el presupuesto mínimo para alcanzar una cierta respuesta?

from pymc_marketing.mmm.budget_optimizer import BudgetOptimizer
from pymc_marketing.mmm.constraints import Constraint
from pymc_marketing.mmm.utility import _check_samples_dimensionality

target_response = 145_000


def mean_response_eq_constraint_fun(budgets_sym, total_budget_sym, optimizer):
    """Enforces mean_response(budgets_sym) = target_response, i.e. returns (mean_resp - target_response)."""
    resp_dist = optimizer.extract_response_distribution(
        "total_media_contribution_original_scale"
    )
    mean_resp = _check_samples_dimensionality(resp_dist).mean()
    return mean_resp - target_response


def minimize_budget_utility(samples, budgets):
    return -budgets.sum()


optimizer = BudgetOptimizer(
    num_periods=campaign_period,
    model=optimizable_model,
    response_variable="total_media_contribution_original_scale",
    utility_function=minimize_budget_utility,
    default_constraints=False,
    custom_constraints=[
        Constraint(
            key="target_response_constraint",
            constraint_fun=mean_response_eq_constraint_fun,
            constraint_type="ineq",
        )
    ],
)

allocation_xarray_target_response, res = optimizer.allocate_budget(
    total_budget=time_unit_budget // 2,
    x0=res_scipy.x,
    minimize_kwargs={"options": {"maxiter": 2_500}},
    budget_bounds=budget_bounds,
)

print("Optimal allocation:", allocation_xarray_target_response)
print("Solver result:", res)
Optimal allocation: <xarray.DataArray (geo: 2, channel: 2)> Size: 32B
array([[1410.7648079 , 1247.27830317],
       [1429.60001426, 1254.87268808]])
Coordinates:
  * geo      (geo) <U5 40B 'geo_a' 'geo_b'
  * channel  (channel) <U2 16B 'x1' 'x2'
Solver result:      message: Optimization terminated successfully
     success: True
      status: 0
         fun: 5342.5158134079675
           x: [ 1.411e+03  1.247e+03  1.430e+03  1.255e+03]
         nit: 30
         jac: [ 1.000e+00  1.000e+00  1.000e+00  1.000e+00]
        nfev: 31
        njev: 30
 multipliers: [ 4.236e-01]
sample_response_given_allocation_target_response = (
    optimizable_model.sample_response_distribution(
        allocation_strategy=allocation_xarray_target_response,
        include_carryover=True,
        include_last_observations=False,
    )
)
Sampling: [y]
sample_response_given_allocation_target_response
<xarray.Dataset> Size: 2MB
Dimensions:                                  (date: 21, geo: 2, sample: 1600,
                                              channel: 2)
Coordinates:
  * date                                     (date) datetime64[ns] 168B 2021-...
  * geo                                      (geo) <U5 40B 'geo_a' 'geo_b'
  * sample                                   (sample) object 13kB MultiIndex
  * channel                                  (channel) <U2 16B 'x1' 'x2'
  * chain                                    (sample) int64 13kB 0 0 0 ... 1 1 1
  * draw                                     (sample) int64 13kB 0 1 ... 798 799
Data variables:
    y                                        (date, geo, sample) float64 538kB ...
    channel_contribution                     (date, geo, channel, sample) float64 1MB ...
    total_media_contribution_original_scale  (sample) float64 13kB 1.489e+05 ...
    allocation                               (geo, channel) float64 32B 1.411...
    x1                                       (date, geo) float64 336B 1.411e+...
    x2                                       (date, geo) float64 336B 1.246e+...
Attributes:
    created_at:                 2026-01-15T19:47:04.575068+00:00
    arviz_version:              0.22.0
    inference_library:          pymc
    inference_library_version:  5.26.1
fig, ax = plt.subplots()

# Initial planned allocation
initial_data = sample_response_give_initial_budget.total_media_contribution_original_scale.values.flatten()
initial_mean = initial_data.mean()
az.plot_dist(
    initial_data,
    # hdi_prob=0.75,
    color="blue",
    label=f"Intial planned allocation: Response {initial_mean:,.0f}",
    ax=ax,
    # kind="hist",
)
ax.axvline(initial_mean, color="blue", linestyle="--")

# Optimized allocation based on maximizing the response
optimized_data = sample_response_given_allocation.total_media_contribution_original_scale.values.flatten()
optimized_mean = optimized_data.mean()
az.plot_dist(
    optimized_data,
    # hdi_prob=0.75,
    color="red",
    label=f"Optimized allocation Maximizing: Response {optimized_mean:,.0f}",
    ax=ax,
    # kind="hist",
)
ax.axvline(optimized_mean, color="red", linestyle="--")

# Optimized allocation based on minimizing the budget
optimized_data_target_response = sample_response_given_allocation_target_response.total_media_contribution_original_scale.values.flatten()  # noqa: E501
optimized_mean_target_response = optimized_data_target_response.mean()
az.plot_dist(
    optimized_data_target_response,
    # hdi_prob=0.75,
    color="green",
    label=f"Optimized allocation Minimizing: Response {optimized_mean_target_response:,.0f}",
    ax=ax,
    # kind="hist",
)
ax.axvline(optimized_mean_target_response, color="green", linestyle="--")

ax.set_title("Comparison of Intial and Optimized allocation")
ax.set_xlabel("Response")
ax.set_ylabel("Density")
ax.legend()

plt.show()

¡Genial! Parece que utilizando 5K euros, podríamos obtener una respuesta incluso mayor que la optimización inicial. Considerando que el gasto es ligeramente mayor para obtener esta cantidad de respuesta, el ROAS debería ser bueno. ¡Echemos un vistazo!

fig, ax = plt.subplots()

# Initial planned allocation
initial_data = (
    sample_response_give_initial_budget.total_media_contribution_original_scale.values.flatten()
    / initial_budget.sum().item()
)
initial_mean = initial_data.mean()
az.plot_dist(
    initial_data,
    # hdi_prob=0.75,
    color="blue",
    label=f"Intial planned allocation: Response {initial_mean:,.0f}",
    ax=ax,
    # kind="hist",
)
ax.axvline(initial_mean, color="blue", linestyle="--")

# Optimized allocation based on maximizing the response
optimized_data = (
    sample_response_given_allocation.total_media_contribution_original_scale.values.flatten()
    / allocation_xarray.sum().item()
)
optimized_mean = optimized_data.mean()
az.plot_dist(
    optimized_data,
    # hdi_prob=0.75,
    color="red",
    label=f"Optimized allocation Maximizing: Response {optimized_mean:,.0f}",
    ax=ax,
    # kind="hist",
)
ax.axvline(optimized_mean, color="red", linestyle="--")

# Optimized allocation based on minimizing the budget
optimized_data_target_response = (
    sample_response_given_allocation_target_response.total_media_contribution_original_scale.values.flatten()
    / allocation_xarray_target_response.sum().item()
)
optimized_mean_target_response = optimized_data_target_response.mean()
az.plot_dist(
    optimized_data_target_response,
    # hdi_prob=0.75,
    color="green",
    label=f"Optimized allocation Minimizing: Response {optimized_mean_target_response:,.0f}",
    ax=ax,
    # kind="hist",
)
ax.axvline(optimized_mean_target_response, color="green", linestyle="--")

ax.set_title("Comparison of Intial and Optimized allocation")
ax.set_xlabel("Response")
ax.set_ylabel("Density")
ax.legend()

plt.show()

El nuevo resultado es mucho más claro. Al utilizar un poco más de presupuesto, podríamos lograr más resultados que en nuestra configuración inicial, de una manera más rentable. Por otro lado, la asignación óptima distribuye el presupuesto en niveles similares al modelo, sin aumentar la incertidumbre en torno al impacto estimado, al menos no tanto como al aumentar el presupuesto en un 2X.

Tenga en cuenta que la estimación proporcionada asume un gasto constante cada semana. Sin embargo, en el ámbito del marketing, incluso con un nivel de gasto fijo, el gasto real puede fluctuar en función de factores como el número de personas que pujan por su anuncio o que ven anuncios en un día determinado.

Para tener en cuenta esta variación impredecible, hemos incluido un parámetro llamado noise_level que le permite introducir ruido blanco en la proyección. Esto puede proporcionar una idea de cómo podría ser el resultado si el presupuesto recomendado pudiera fluctuar en cierta medida. El valor predeterminado para noise_level es 1%, pero puede ajustarlo según sea necesario. En el ejemplo a continuación, hemos utilizado un valor del 10%.

¡Eche un vistazo a la firma a continuación!

optimizable_model.sample_response_distribution?
Signature:
optimizable_model.sample_response_distribution(
    allocation_strategy: 'xr.DataArray',
    noise_level: 'float' = 0.001,
    additional_var_names: 'list[str] | None' = None,
    include_last_observations: 'bool' = False,
    include_carryover: 'bool' = True,
    budget_distribution_over_period: 'xr.DataArray | None' = None,
) -> 'az.InferenceData'
Docstring:
Generate synthetic dataset and sample posterior predictive based on allocation.

Parameters
----------
allocation_strategy : DataArray
    The allocation strategy for the channels.
noise_level : float
    The relative level of noise to add to the data allocation.
additional_var_names : list[str] | None
    Additional variable names to include in the posterior predictive sampling.
include_last_observations : bool
    Whether to include the last observations for continuity.
include_carryover : bool
    Whether to include carryover effects.
budget_distribution_over_period : xr.DataArray | None
    Distribution factors for budget allocation over time. Should have dims ("date", *budget_dims)
    where date dimension has length num_periods. Values along date dimension should sum to 1 for
    each combination of other dimensions. If provided, multiplies the noise values by this distribution.

Returns
-------
az.InferenceData
    The posterior predictive samples based on the synthetic dataset.
File:      ~/projects/pymc-marketing/pymc_marketing/mmm/multidimensional.py
Type:      method

Si no desea asumir una asignación distribuida de manera uniforme, puede utilizar un patrón personalizado. Proporcionando al optimizador una forma de cómo gastar el dinero a lo largo del tiempo. El parámetro se llama budget_distribution_over_period y puede leer sobre él en la siguiente firma.

optimizer?
Type:           BudgetOptimizer
String form:    num_periods=12 mmm_model=<pymc_marketing.mmm.multidimensional.MultiDimensionalBudgetOptimizerWrap <...>  0x31ea4e4e0>] default_constraints=False budget_distribution_over_period=None compile_kwargs=None
File:           ~/projects/pymc-marketing/pymc_marketing/mmm/budget_optimizer.py
Docstring:     
A class for optimizing budget allocation in a marketing mix model.

The goal of this optimization is to maximize the total expected response
by allocating the given budget across different marketing channels. The
optimization is performed using the Sequential Least Squares Quadratic
Programming (SLSQP) method, which is a gradient-based optimization algorithm
suitable for solving constrained optimization problems.

For more information on the SLSQP algorithm, refer to the documentation:
https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html

Parameters
----------
num_periods : int
    Number of time units at the desired time granularity to allocate budget for.
model : MMMModel
    The marketing mix model to optimize.
response_variable : str, optional
    The response variable to optimize. Default is "total_contribution".
utility_function : UtilityFunctionType, optional
    The utility function to maximize. Default is the mean of the response distribution.
budgets_to_optimize : xarray.DataArray, optional
    Mask defining a subset of budgets to optimize. Non-optimized budgets remain fixed at 0.
custom_constraints : Sequence[Constraint], optional
    Custom constraints for the optimizer.
default_constraints : bool, optional
    Whether to add a default sum constraint on the total budget. Default is True.
budget_distribution_over_period : xarray.DataArray, optional
    Distribution factors for budget allocation over time. Should have dims ("date", *budget_dims)
    where date dimension has length num_periods. Values along date dimension should sum to 1 for
    each combination of other dimensions. If None, budget is distributed evenly across periods.
Init docstring:
Create a new model by parsing and validating input data from keyword arguments.

Raises [`ValidationError`][pydantic_core.ValidationError] if the input data cannot be
validated to form a valid model.

`self` is explicitly positional-only to allow `self` as a field name.
# Get dimensions from the sample response
dates = sample_response_give_initial_budget.date.values[
    : -(optimizable_model.adstock.l_max)
]
geos = sample_response_give_initial_budget.geo.values
channels = ["x1", "x2"]
n_dates = len(dates)

print(f"Number of dates: {n_dates}")
print(f"Number of geos: {len(geos)}")
print(f"Number of channels: {len(channels)}")

# Create decreasing values for each date that sum to 1
decreasing_values = np.linspace(0.5, 0, n_dates)
# Normalize to make the sum equal to 1
decreasing_values = decreasing_values / decreasing_values.sum()

# Create the data array with the specified dimensions
data = np.zeros((len(dates), len(geos), len(channels)))
for i in range(len(geos)):
    for j in range(len(channels)):
        data[:, i, j] = decreasing_values

# Create xarray DataArray with proper dimensions
custom_budget_distribution = xr.DataArray(
    data,
    dims=["date", "geo", "channel"],
    coords={"date": dates, "geo": geos, "channel": channels},
)
Number of dates: 13
Number of geos: 2
Number of channels: 2

Nota: Al utilizar una distribución de presupuesto personalizada a lo largo del tiempo, asegúrese de que los valores para cada canal y geo sumen 1 en la dimensión temporal. Esto se demuestra en el ejemplo anterior donde creamos valores decrecientes que se normalizan para sumar 1.

custom_budget_distribution.sum(dim="date")
<xarray.DataArray (geo: 2, channel: 2)> Size: 32B
array([[1., 1.],
       [1., 1.]])
Coordinates:
  * geo      (geo) <U5 40B 'geo_a' 'geo_b'
  * channel  (channel) <U2 16B 'x1' 'x2'

Podemos pasar este nuevo parámetro en el modelo optimizable.

allocation_xarray_custom_budget_distribution, _ = optimizable_model.optimize_budget(
    budget=time_unit_budget,  # Total budget to allocate here
    budget_distribution_over_period=custom_budget_distribution,
    minimize_kwargs={"options": {"maxiter": 2_000}},
    budget_bounds=budget_bounds,
)

sample_response_given_allocation_custom_budget_distribution = (
    optimizable_model.sample_response_distribution(
        allocation_strategy=allocation_xarray_custom_budget_distribution,
        include_carryover=True,
        include_last_observations=False,
        budget_distribution_over_period=custom_budget_distribution,
    )
)
/Users/imrisofer/projects/pymc-marketing/pymc_marketing/mmm/budget_optimizer.py:745: UserWarning: Using default equality constraint
  self.set_constraints(
Sampling: [y]

¡Puede visualizar el patrón para el acceso a sus variables en la muestra de respuesta!

sample_response_given_allocation_custom_budget_distribution["x1"].plot(hue="geo");

Y al dar ese patrón, ¡verás alguna respuesta!

optimizable_model.plot.allocated_contribution_by_channel_over_time(
    samples=sample_response_given_allocation_custom_budget_distribution,
);
/Users/imrisofer/projects/pymc-marketing/pymc_marketing/mmm/plot.py:2142: UserWarning: The figure layout has changed to tight
  fig.tight_layout()
../../_images/d25af9779c629aca53b8aa5b0e9900b60d0a6075f8c2ae1032e0279e1ac24214.png

Como era de esperar, ahora el gasto sigue un patrón específico de gasto, y el proceso de optimización también lo considera. Este cambio puede afectar de manera bastante radical la respuesta total, añadiendo más o menos complejidad a sus desafíos de optimización.

fig, ax = plt.subplots()

# Initial planned allocation
initial_data = sample_response_give_initial_budget.total_media_contribution_original_scale.values.flatten()
initial_mean = initial_data.mean()
az.plot_dist(
    initial_data,
    # hdi_prob=0.75,
    color="blue",
    label=f"Intial planned allocation: Response {initial_mean:,.0f}",
    ax=ax,
    # kind="hist",
)
ax.axvline(initial_mean, color="blue", linestyle="--")

# Optimized allocation based on maximizing the response
optimized_data = sample_response_given_allocation.total_media_contribution_original_scale.values.flatten()
optimized_mean = optimized_data.mean()
az.plot_dist(
    optimized_data,
    # hdi_prob=0.75,
    color="red",
    label=f"Optimized allocation Maximizing: Response {optimized_mean:,.0f}",
    ax=ax,
    # kind="hist",
)
ax.axvline(optimized_mean, color="red", linestyle="--")

# Optimized allocation based on minimizing the budget
optimized_data_target_response = sample_response_given_allocation_target_response.total_media_contribution_original_scale.values.flatten()  # noqa: E501
optimized_mean_target_response = optimized_data_target_response.mean()
az.plot_dist(
    optimized_data_target_response,
    # hdi_prob=0.75,
    color="green",
    label=f"Optimized allocation Minimizing: Response {optimized_mean_target_response:,.0f}",
    ax=ax,
    # kind="hist",
)
ax.axvline(optimized_mean_target_response, color="green", linestyle="--")

ax.set_title("Comparison of Intial and Optimized allocation")
ax.set_xlabel("Response")
ax.set_ylabel("Density")
ax.legend()

# Optimized allocation maximizing response based on custom budget distribution
optimized_data_custom_budget_distribution = sample_response_given_allocation_custom_budget_distribution.total_media_contribution_original_scale.values.flatten()  # noqa: E501
optimized_mean_custom_budget_distribution = (
    optimized_data_custom_budget_distribution.mean()
)
az.plot_dist(
    optimized_data_custom_budget_distribution,
    color="purple",
    label=f"Optimization with Custom budget distribution: Response {optimized_mean_custom_budget_distribution:,.0f}",
    ax=ax,
)
ax.axvline(optimized_mean_custom_budget_distribution, color="purple", linestyle="--")

ax.set_title("Comparison of Intial and Optimized allocation")
ax.set_xlabel("Response")
ax.set_ylabel("Density")
ax.legend()

plt.show()

Otros métodos para explorar#

La optimización actual utiliza el posterior completo, y se puede usar para más que solo minimizar o maximizar; puede considerar toda la información para realizar evaluaciones de riesgo. Puede consultar Asignación de Riesgo para Modelos de Mezcla de Medios. Al mismo tiempo, podría ser una solución poderosa e interesante, como se describe en el siguiente blog «Uso de la toma de decisiones bayesianas para optimizar cadenas de suministro».

La metodología actual es similar a las utilizadas en otras bibliotecas como Robyn de Meta y Lightweight de Google. Puede explorar las soluciones y comparar si es necesario.

Conclusión#

Los modelos y metodologías MMM utilizados aquí están diseñados para cerrar la brecha entre el rigor teórico y los conocimientos de marketing aplicables. Representan un avance significativo hacia un enfoque más basado en datos y analítico para la asignación del presupuesto de marketing, lo que podría cambiar la forma en que las organizaciones invierten en la adquisición y retención de clientes.

En consecuencia, sus compromisos, comentarios y pensamientos no solo son bienvenidos, sino que se solicitan activamente para hacer que esta herramienta sea lo más práctica y universalmente aplicable posible.

%load_ext watermark
%watermark -n -u -v -iv -w -p pytensor
Last updated: Thu Jan 15 2026

Python implementation: CPython
Python version       : 3.12.12
IPython version      : 9.6.0

pytensor: 2.35.1

pytensor      : 2.35.1
pymc_marketing: 0.17.0
matplotlib    : 3.10.7
arviz         : 0.22.0
pandas        : 2.3.3
numpy         : 2.3.4
xarray        : 2025.10.1

Watermark: 2.5.0