Optimización multiobjetivo con PyMC-Marketing#
¿Alguna vez se ha enfrentado al desafío de decidir si enfocarse en atraer nuevos usuarios o en mantener a los que ya tiene? Es un dilema común en cualquier empresa en crecimiento. Por un lado, la adquisición impulsa la visibilidad, la expansión y la emoción. Por otro, la retención construye lealtad, estabilidad y valor a largo plazo. Cuando los recursos son limitados, elegir dónde invertir—más anuncios para atraer a los recién llegados o más experiencias para mantener a su audiencia actual comprometida—se siente como caminar por una cuerda floja entre las ganancias a corto plazo y el crecimiento duradero.
Este tipo de compensación no es exclusivo del marketing. Los equipos de producto equilibran constantemente la innovación y la fiabilidad. Los gerentes de operaciones sopesan la eficiencia frente a la flexibilidad. Los científicos de datos ajustan modelos para maximizar la precisión mientras minimizan el sesgo o el costo computacional. Todos estos son ejemplos de objetivos en competencia que deben coexistir dentro de recursos finitos. La optimización multiobjetivo proporciona un marco sistemático para navegar estas tensiones. Permite a los tomadores de decisiones explorar cómo mejorar un objetivo puede comprometer a otro, y encontrar soluciones equilibradas a lo largo de la frontera de Pareto: el conjunto de decisiones donde ningún objetivo puede mejorarse sin sacrificar otro. En esencia, transforma compensaciones difíciles en elecciones informadas y estratégicas.
Este cuaderno explora la optimización multiobjetivo utilizando PyMC y PyMC-Marketing, centrándose en la compensación entre la adquisición de nuevos usuarios y la retención de los existentes. Demuestra cómo equilibrar objetivos en competencia e incluye ejemplos prácticos de construcción y optimización de modelos (tanto personalizados como predeterminados) para lograr soluciones equilibradas en escenarios de marketing.
Importar dependencias#
import warnings
import arviz as az
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import pymc as pm
import pymc.dims as pmd
from pymc_extras.prior import Censored, Prior
from pytensor import function
from pytensor.xtensor.type import XTensorVariable
from pymc_marketing import mmm
from pymc_marketing.mmm.budget_optimizer import (
BudgetOptimizer,
BuildMergedModel,
CustomModelWrapper,
)
from pymc_marketing.mmm.builders.yaml import build_mmm_from_yaml
from pymc_marketing.mmm.constraints import Constraint
from pymc_marketing.mmm.multidimensional import MultiDimensionalBudgetOptimizerWrapper
from pymc_marketing.paths import data_dir
from pymc_marketing.pytensor_utils import merge_models
Configuración del cuaderno#
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"
seed: int = sum(map(ord, "mmm_multi_objective_optimization"))
rng: np.random.Generator = np.random.default_rng(seed=seed)
Optimizando Modelos PyMC Personalizados#
Primero, mostraremos algunas de las funciones internas y cómo puede aplicarlas a su modelo PyMC personalizado.
Para ello, primero necesitamos generar un modelo simple y algunos datos.
n_observations = 100
date_range = pd.date_range(start="2020-01-01", periods=n_observations, freq="D")
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
Establecemos la variable impressions como un conjunto de caminatas aleatorias independientes, cada una con una media y una desviación estándar específicas.
impressions_x1_var = random_walk(
mu=0.5, sigma=0.5, steps=n_observations, lower=0, upper=2, seed=seed + 1
)
impressions_x2_var = random_walk(
mu=1, sigma=0.05, steps=n_observations, lower=0, upper=2, seed=seed + 2
)
impressions_x3_var = random_walk(
mu=1, sigma=0.5, steps=n_observations, lower=0, upper=2, seed=seed - 3
)
impressions_x4_var = random_walk(
mu=1.2, sigma=0.5, steps=n_observations, lower=0, upper=2, seed=seed - 1
)
fig, axs = plt.subplots(2, 2, figsize=(10, 8))
axs[0, 0].plot(impressions_x1_var, color="blue")
axs[0, 0].set_title("impressions_x1_var")
axs[0, 1].plot(impressions_x2_var, color="red")
axs[0, 1].set_title("impressions_x2_var")
axs[1, 0].plot(impressions_x3_var, color="green")
axs[1, 0].set_title("impressions_x3_var")
axs[1, 1].plot(impressions_x4_var, color="orange")
axs[1, 1].set_title("impressions_x4_var")
plt.show()
Una vez que tengamos todos los datos en su lugar, podemos consolidarlos en un dataframe. Esto nos ayudará a definir la estructura de nuestro modelo, que en este caso dependerá únicamente de las impresiones de los canales \(x1\), \(x2\), \(x3\) y \(x4\).
dataset = pd.DataFrame(
{
"date": date_range,
"impressions_x1_var": impressions_x1_var,
"impressions_x2_var": impressions_x2_var,
"impressions_x3_var": impressions_x3_var,
"impressions_x4_var": impressions_x4_var,
}
)
dataset["y"] = 0
dataset.head()
| date | impressions_x1_var | impressions_x2_var | impressions_x3_var | impressions_x4_var | y | |
|---|---|---|---|---|---|---|
| 0 | 2020-01-01 | 0.866098 | 0.948983 | 1.686121 | 1.372259 | 0 |
| 1 | 2020-01-02 | 1.208886 | 0.929276 | 1.724699 | 1.247626 | 0 |
| 2 | 2020-01-03 | 1.571934 | 0.934704 | 1.561938 | 1.325415 | 0 |
| 3 | 2020-01-04 | 1.316408 | 0.942521 | 1.697812 | 1.473127 | 0 |
| 4 | 2020-01-05 | 1.468910 | 0.899849 | 1.712968 | 1.365724 | 0 |
El modelo inicial construido en este cuaderno es un modelo bayesiano sencillo diseñado para capturar la relación entre las características de entrada y una variable objetivo. El modelo se define como:
Dónde:
\(Y\) es la variable objetivo.
\(X\) representa las características de entrada.
\(\theta\) denota los parámetros del modelo.
\(\epsilon\) denota las ventas base, que no dependen del marketing.
La función \(f(X, \theta)\) es una transformación determinista de las características de entrada \(X\) utilizando los parámetros \(\theta\), que en este contexto, se modela utilizando una función de saturación de Michaelis-Menten. Esta función es particularmente útil para capturar rendimientos decrecientes en escenarios de marketing, donde el efecto de aumentar la entrada (por ejemplo, el gasto en publicidad) sobre la salida (por ejemplo, las ventas) eventualmente se estabiliza.
Dado que el modelo es una función de saturación simple, solo necesitamos definir priors para sus parámetros, la intersección y la incertidumbre aleatoria.
alpha_prior = Prior("Beta", alpha=2, beta=1, dims="channel")
lam_prior = Prior("Gamma", mu=1, sigma=0.5, dims="channel")
priors = {
"lam": lam_prior,
"alpha": alpha_prior,
}
saturation = mmm.MichaelisMentenSaturation(priors=priors)
coordinates = {
"date": date_range,
"channel": dataset.drop(columns=["date", "y"]).columns.tolist(),
}
intercept = Prior("Normal", mu=0, sigma=1)
noise = Prior("HalfNormal", sigma=1)
likelihood = Censored(
Prior(
"Normal",
sigma=noise,
dims="date",
),
lower=0,
)
Con todos nuestros priors definidos, podemos crear la estructura del modelo. Ambos modelos compartirán la misma estructura pero tendrán diferentes variables objetivo. (Podemos crear una función para generar el mismo modelo base basado en ciertas entradas).
def build_simple_mmm(dataset, coordinates, target_column):
with pm.Model(coords=coordinates) as base_model:
data = pmd.Data(
"channel_data", dataset[coordinates["channel"]], dims=("date", "channel")
)
target = pmd.Data("target", dataset[target_column], dims="date")
contribution = pmd.Deterministic(
"contribution",
saturation.apply(data),
)
_intercept = intercept.create_variable("intercept", xdist=True)
likelihood.create_likelihood_variable(
"likelihood",
mu=(_intercept + contribution.sum(dim="channel")),
observed=target,
xdist=True,
)
return base_model
Echemos un vistazo a nuestro modelo base.
base_model = build_simple_mmm(dataset, coordinates, "y")
base_model.to_graphviz()
Usando esta estructura simple, ahora podemos generar la variable objetivo basada en parámetros fijos. Para ello, utilizaremos la función pm.do de PyMC. Puede leer más sobre ello aquí.
Estableceremos una cuadrícula de parámetros verdaderos (los que el modelo debería ser capaz de recuperar) para las funciones internas de nuestro modelo.
first_model_grid = {
"intercept": 0.5,
"saturation_lam": np.array([0.1, 0.3, 1, 0.8]),
"saturation_alpha": np.array([0.3, 0.5, 4, 3]),
"likelihood_sigma": 0.05,
}
first_model = pm.do(base_model, first_model_grid)
second_model_grid = {
"intercept": 0.5,
"saturation_lam": np.array([1, 0.8, 0.6, 0.2]),
"saturation_alpha": np.array([4, 3, 0.4, 0.5]),
"likelihood_sigma": 0.09,
}
second_model = pm.do(base_model, second_model_grid)
Ahora que nuestros modelos verdaderos están definidos, podemos extraer de los parámetros fijos para generar datos. Esto nos ayudará a crear un conjunto de datos con dos variables objetivo. Ambas dependen de las mismas entradas, pero aunque se generan de manera similar, sus pesos son diferentes.
Nota
Esto nos posicionará en la situación que deseamos: dos resultados diferentes, impulsados por los mismos factores.
dataset["y_first_model"] = pm.draw(first_model["likelihood"])
dataset["y_second_model"] = pm.draw(second_model["likelihood"])
dataset.head()
| date | impressions_x1_var | impressions_x2_var | impressions_x3_var | impressions_x4_var | y | y_first_model | y_second_model | |
|---|---|---|---|---|---|---|---|---|
| 0 | 2020-01-01 | 0.866098 | 0.948983 | 1.686121 | 1.372259 | 0 | 5.506957 | 4.623229 |
| 1 | 2020-01-02 | 1.208886 | 0.929276 | 1.724699 | 1.247626 | 0 | 5.486536 | 4.903350 |
| 2 | 2020-01-03 | 1.571934 | 0.934704 | 1.561938 | 1.325415 | 0 | 5.580586 | 5.349827 |
| 3 | 2020-01-04 | 1.316408 | 0.942521 | 1.697812 | 1.473127 | 0 | 5.585453 | 5.061801 |
| 4 | 2020-01-05 | 1.468910 | 0.899849 | 1.712968 | 1.365724 | 0 | 5.504953 | 5.157330 |
¡Esto se ve fantástico! Nuestros resultados son bastante diferentes, aunque las variables internas son las mismas. Aquí es donde comienza el verdadero desafío.
Debido a que tenemos dos variables objetivo diferentes, podemos decir, «Construyamos dos modelos diferentes, uno para cada variable objetivo.» Esta es una idea prometedora, y podemos simular esta situación utilizando nuestra función build_simple_mmm nuevamente.
new_users_model = build_simple_mmm(dataset, coordinates, "y_first_model")
reengage_users_model = build_simple_mmm(dataset, coordinates, "y_second_model")
¡Con nuestros dos modelos construidos, ahora podemos entrenarlos!
sample_grid = {
"draws": 200,
"tune": 800,
"chains": 4,
"target_accept": 0.84,
}
with new_users_model:
new_users_idata = pm.sample(**sample_grid)
with reengage_users_model:
reengage_users_idata = pm.sample(**sample_grid)
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (4 chains in 4 jobs)
NUTS: [saturation_alpha, saturation_lam, intercept, likelihood_sigma]
Sampling 4 chains for 800 tune and 200 draw iterations (3_200 + 800 draws total) took 1 seconds.
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (4 chains in 4 jobs)
NUTS: [saturation_alpha, saturation_lam, intercept, likelihood_sigma]
Sampling 4 chains for 800 tune and 200 draw iterations (3_200 + 800 draws total) took 1 seconds.
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
Ahora que ambos de nuestros modelos están entrenados con nuestros datos, podemos proceder con la optimización.
Antes de optimizar, necesitamos envolver nuestros modelos en un protocolo que permita al optimizador utilizarlos. Hemos construido un pequeño envoltorio para hacer que cualquier modelo de PyMC sea compatible rápidamente.
CustomModelWrapper?
Init signature:
CustomModelWrapper(
base_model: pymc.model.core.Model,
idata: arviz.data.inference_data.InferenceData,
channels: collections.abc.Sequence[str],
) -> None
Docstring: Wrapper for the BudgetOptimizer to handle custom PyMC models.
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.
File: ~/Documents/GitHub/pymc-marketing/pymc_marketing/mmm/budget_optimizer.py
Type: ModelMetaclass
Subclasses:
Solo necesita proporcionar su lista de canales (puede utilizar las coordenadas del modelo para esto), el idata y el objeto del modelo PyMC.
Truco
Crear un envoltorio de clase personalizado es bastante fácil. Su modelo solo necesita seguir el protocolo en OptimizerCompatibleModelWrapper. Internamente, puede personalizarlo tanto como desee.
new_users_optimizable_model = CustomModelWrapper(
base_model=new_users_model,
idata=new_users_idata,
channels=coordinates["channel"],
)
reengage_optimizable_model = CustomModelWrapper(
base_model=reengage_users_model,
idata=reengage_users_idata,
channels=coordinates["channel"],
)
Una vez que su modelo esté envuelto, debemos crear una función objetivo personalizada que le permita trabajar con la variable a optimizar. Nuestro modelo tiene la variable determinista contribution, que tiene las dimensiones sample, date, channel. El optimizador trabaja con el posterior sobre todas las dimensiones, lo que significa que necesitamos colapsar las dimensiones date y channel.
¡Echa un vistazo a cómo funciona!
def average_response(
samples: XTensorVariable, budgets: XTensorVariable
) -> XTensorVariable:
"""Compute the average response of the posterior predictive distribution."""
assert set(samples.dims) == {"sample", "channel", "date"}, samples.dims # noqa: S101
return samples.sum(dim=("channel", "date")).mean(dim="sample")
Con nuestra función en su lugar, todo lo que necesitamos hacer es definir nuestro horizonte de optimización, y estamos listos. Pasamos nuestra variable a optimizar y la función de utilidad que obtiene esta variable y realiza una operación. En este caso, toma el valor medio del posterior de la contribución total a través de canales y días.
Debido a que tenemos dos modelos, realizaremos dos optimizaciones. Tómese un momento para observar en detalle cómo debe ser llamado el optimizador 🙌🏻
optimization_horizon = 10
budget_per_time_unit_in_horizon = 2
optimizer_new_users = BudgetOptimizer(
model=new_users_optimizable_model,
num_periods=optimization_horizon,
response_variable="contribution",
utility_function=average_response,
)
allocation_new_users, result_new_users = optimizer_new_users.allocate_budget(
total_budget=budget_per_time_unit_in_horizon,
)
optimizer_reengage = BudgetOptimizer(
model=reengage_optimizable_model,
num_periods=optimization_horizon,
response_variable="contribution",
utility_function=average_response,
)
allocation_reengage, result_reengage = optimizer_reengage.allocate_budget(
total_budget=budget_per_time_unit_in_horizon,
)
Una vez que ambas optimizaciones estén completas, ¡podemos observar los resultados!
# Normalize allocations by budget per time unit and convert to percentages
_normalize_factor = 100
allocation_reengage_norm = (
allocation_reengage.values / budget_per_time_unit_in_horizon
) * _normalize_factor
allocation_new_users_norm = (
allocation_new_users.values / budget_per_time_unit_in_horizon
) * _normalize_factor
# Get channel names from coordinates
channel_names = allocation_reengage.coords["channel"].values
# Calculate absolute differences
abs_diff = np.abs(allocation_new_users_norm - allocation_reengage_norm)
# Create bar plot
fig, ax = plt.subplots(figsize=(12, 5))
x = np.arange(len(channel_names))
width = 0.35
# Create bars
bars1 = ax.bar(
x - width / 2, allocation_reengage_norm, width, label="Reengage", color="lightblue"
)
bars2 = ax.bar(
x + width / 2, allocation_new_users_norm, width, label="New Users", color="orange"
)
# Add value labels inside bars (white text)
for _i, (bar1, bar2) in enumerate(zip(bars1, bars2, strict=False)):
# Reengage values
height1 = bar1.get_height()
if height1 > 0:
ax.text(
bar1.get_x() + bar1.get_width() / 2.0,
height1 / 2,
f"{height1:.1f}%",
ha="center",
va="center",
color="white",
fontweight="bold",
)
# New Users values
height2 = bar2.get_height()
if height2 > 0:
ax.text(
bar2.get_x() + bar2.get_width() / 2.0,
height2 / 2,
f"{height2:.1f}%",
ha="center",
va="center",
color="white",
fontweight="bold",
)
# Add absolute difference on top
max_height = max(height1, height2)
ax.text(
x[_i],
max_height + 2,
f"{abs_diff[_i]:.1f}%",
ha="center",
va="bottom",
fontweight="bold",
)
# Customize plot
ax.set_xlabel("Channels")
ax.set_ylabel("Budget Allocation (%)")
ax.set_title("Budget Allocation Comparison: New Users vs Reengage")
ax.set_xticks(x)
ax.set_xticklabels(channel_names)
ax.set_ylim(0, 45)
ax.grid(True, alpha=0.3)
ax.legend()
plt.show()
print(f"New Users given marketing budget: {abs(result_new_users['fun'])}")
print(f"Reengage given marketing budget: {abs(result_reengage['fun'])}")
New Users given marketing budget: 14.81347876559035
Reengage given marketing budget: 12.280383035889008
La imagen deja todo claro como el agua: los canales que atraen a nuevos usuarios no son los mismos que los que atraen a usuarios reenganchados. Esto es problemático porque si queremos optimizar para nuevos usuarios, nuestra asignación es direccionalmente diferente de la que se utiliza para usuarios reenganchados.
Podemos ver que ambas respuestas son similares en términos del presupuesto, pero las asignaciones presupuestarias para esas respuestas cambian radicalmente. Para visualizar mejor este fenómeno, podemos calcular las distribuciones de las respuestas.
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
_distribution = optimizer_reengage.extract_response_distribution("contribution").sum(
dim=("date", "channel")
)
_distribution_func = function([optimizer_reengage._budgets_flat], _distribution)
posterior_reengage_given_new_users_budget_allocation = _distribution_func(
allocation_new_users.values
)
az.plot_posterior(
posterior_reengage_given_new_users_budget_allocation,
color="lightblue",
# label=f"Total response of reengage given new users budget allocation",
ref_val=round(abs(result_reengage["fun"]), 4),
ax=axes[0],
)
axes[0].set_title("Reengage Response Given New Users Budget Allocation")
_distribution = optimizer_new_users.extract_response_distribution("contribution").sum(
dim=("date", "channel")
)
_distribution_func = function([optimizer_new_users._budgets_flat], _distribution)
posterior_new_users_given_reengage_budget_allocation = _distribution_func(
allocation_reengage.values
)
az.plot_posterior(
posterior_new_users_given_reengage_budget_allocation,
color="purple",
# label=f"Total response of new users given reengage budget allocation",
ref_val=round(abs(result_new_users["fun"]), 4),
ax=axes[1],
)
axes[1].set_title("New Users Response Given Reengage Budget Allocation")
plt.tight_layout()
plt.show()
Esto deja todo claro: si utilizamos la recomendación de presupuesto del modelo de nuevos usuarios, obtendremos un buen número de nuevos usuarios, pero penalizaremos a los usuarios reactivados, obteniendo un 40% menos de lo que podríamos con el mismo presupuesto distribuido de manera diferente. Una historia similar se desarrolla si utilizamos la recomendación para usuarios reactivados; entonces penalizamos a los nuevos usuarios.
¿Cómo podemos crear un problema de optimización que considere ambas respuestas y encuentre una solución que no penalice ninguna métrica objetivo? La respuesta es la clase BuildMergedModel.
BuildMergedModel?
Init signature:
BuildMergedModel(
models: list[pymc_marketing.mmm.budget_optimizer.OptimizerCompatibleModelWrapper],
prefixes: list[str] | None = None,
merge_on: str | None = 'channel_data',
use_every_n_draw: int = 1,
) -> None
Docstring:
Merge multiple optimizer-compatible models into a single model.
This wrapper combines several optimizer-compatible MMM wrappers by:
- Merging their posterior `InferenceData` with per-model prefixes
- Optionally thinning posterior draws via ``use_every_n_draw``
- Exposing a persistent merged PyMC ``Model`` for optimization through
``_set_predictors_for_optimization`` and a dynamic ``model`` property for
inspection when needed
Parameters
----------
models : list[OptimizerCompatibleModelWrapper]
A list of wrappers that each expose ``idata`` and
``_set_predictors_for_optimization(num_periods: int) -> Model``.
prefixes : list[str] | None, optional
Per-model prefixes used when merging. If ``None``, defaults to
``["model1", "model2", ...]`` with one prefix per model.
merge_on : str | None, optional, default "channel_data"
Name of a variable expected to be present in all models and that should
remain unprefixed and be used for aligning/merging dims (e.g.,
``"channel_data"``). If ``None``, no variable is treated as shared and
all variables/dims are prefixed.
use_every_n_draw : int, optional, default 1
Thinning factor applied when merging idatas. Keeps every n-th draw.
Attributes
----------
prefixes : list[str]
The final list of prefixes used for each model.
models : list[OptimizerCompatibleModelWrapper]
The provided list of wrappers.
num_models : int
Number of models being merged.
num_periods : int | None
Number of forecast periods inferred from the primary model (if available).
idata : arviz.InferenceData
The merged and prefixed posterior (and data) container.
adstock : Any
Carried over from the primary model when available.
model : pymc.Model
Property returning a merged PyMC model; see Notes.
Examples
--------
Merge three multidimensional MMMs into a single optimizer model:
.. code-block:: python
from pymc_marketing.mmm.multidimensional import (
MMM,
MultiDimensionalBudgetOptimizerWrapper,
)
from pymc_marketing.mmm.budget_optimizer import (
BuildMergedModel,
BudgetOptimizer,
)
# Assume m1, m2, m3 are already fitted MMM instances
w1 = MultiDimensionalBudgetOptimizerWrapper(
model=m1, start_date=start, end_date=end
)
w2 = MultiDimensionalBudgetOptimizerWrapper(
model=m2, start_date=start, end_date=end
)
w3 = MultiDimensionalBudgetOptimizerWrapper(
model=m3, start_date=start, end_date=end
)
merged = BuildMergedModel(
models=[w1, w2, w3],
prefixes=["north", "south", "west"],
merge_on="channel_data",
use_every_n_draw=2,
)
optimizer = BudgetOptimizer(
model=merged,
num_periods=merged.num_periods,
response_variable="north_total_media_contribution_original_scale",
)
Single model: auto-prefix and thin draws:
.. code-block:: python
merged_single = BuildMergedModel(
models=[w1],
prefixes=None, # auto -> ["model1"]
merge_on="channel_data",
use_every_n_draw=5,
)
m_opt = merged_single._set_predictors_for_optimization(
num_periods=merged_single.num_periods
)
Merge everything with prefixes (no shared variable retained):
.. code-block:: python
merged_all_prefixed = BuildMergedModel(
models=[w1, w2],
prefixes=["a", "b"],
merge_on=None, # do not keep any unprefixed variable
)
File: ~/Documents/GitHub/pymc-marketing/pymc_marketing/mmm/budget_optimizer.py
Type: _ProtocolMeta
Subclasses:
La clase BuildMergedModel está diseñada para fusionar múltiples modelos compatibles con optimizadores en una única representación gráfica unificada. Esto se logra combinando los InferenceData posteriores de cada modelo con prefijos únicos, lo que permite la integración de las salidas de múltiples modelos en un marco cohesivo. La clase opera en un espacio gráfico simbólico, donde los modelos se tratan como procesos generativos que pueden combinarse de manera similar a las consultas SQL. Este enfoque permite la fusión sin problemas de modelos al alinear y fusionar dimensiones basadas en una variable compartida, comúnmente denominada merge_on. El modelo fusionado se expone entonces como un modelo PyMC persistente, que puede ser utilizado para optimización y análisis adicionales.
¡Echemos un vistazo a cómo funciona!
merged_model = BuildMergedModel(
models=[new_users_optimizable_model, reengage_optimizable_model],
prefixes=["new_users", "reengage"],
merge_on="channel_data",
use_every_n_draw=1,
)
Ahora que el modelo está fusionado, podemos reformular el proceso de optimización. Maximizar dos variables, como la adquisición de nuevos usuarios y la reactivación, es inherentemente desafiante porque representan diferentes unidades y objetivos. Intentar maximizar ambos simultáneamente conduce a un espacio de soluciones infinito, ya que mejorar un objetivo a menudo se hace a expensas del otro.
Sin embargo, podemos reformular el problema maximizando una variable mientras mantenemos la otra como una restricción. Este enfoque permite al optimizador centrarse en maximizar un único objetivo mientras asegura que la otra variable permanezca por encima de un umbral especificado \(Z\), que representa nuestra meta mínima para ese «objetivo secundario».
Aquí, estamos diciendo que queremos un presupuesto que atraiga al menos 10 (mil o millones) de usuarios reactivados, y que los nuevos usuarios deben ser maximizados siempre que se cumpla esa restricción.
min_total_response = 8
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("reengage_contribution")
assert set(resp_dist.dims) == {"sample", "date", "channel"} # noqa: S101
mean_resp = resp_dist.sum(dim=("channel", "date")).mean("sample")
return mean_resp - min_total_response
optimizer = BudgetOptimizer(
model=merged_model,
num_periods=optimization_horizon,
response_variable="new_users_contribution",
utility_function=average_response,
custom_constraints=[
Constraint(
key="min_response_constraint",
constraint_fun=mean_response_eq_constraint_fun,
constraint_type="ineq",
)
],
)
allocation, result, callback_results = optimizer.allocate_budget(
total_budget=budget_per_time_unit_in_horizon,
callback=True,
)
La optimización ahora se realiza utilizando un modelo combinado que integra múltiples variables deterministas (variables objetivo). Como consecuencia, podemos utilizar cualquiera de ellas en cualquier parte de la optimización.
Truco
Puede crear cualquier tipo de función objetivo. Este es solo un ejemplo, así que sea tan creativo como desee. Siempre que su objetivo sea factible, el optimizador puede encargarse del resto.
constraint_value = round(callback_results[-1]["constraint_info"][0]["value"], 4)
# Check if constraint is satisfied (should be close to zero for equality, >= 0 for inequality)
if np.isclose(constraint_value, 0, atol=1e-6):
print(f"Constraint satisfied: value {constraint_value} is close to zero")
elif constraint_value > 0:
print(f"Constraint satisfied: value {constraint_value} is greater than zero")
else:
raise ValueError(f"Constraint violated: value {constraint_value} should be >= 0")
Constraint satisfied: value 0.0 is close to zero
merged_allocation_norm = (
allocation.values / budget_per_time_unit_in_horizon
) * _normalize_factor
# Create bar plot
fig, ax = plt.subplots(figsize=(12, 5))
x = np.arange(len(channel_names))
width = 0.25
# Create bars
bars1 = ax.bar(
x - width, allocation_reengage_norm, width, label="Reengage", color="lightblue"
)
bars2 = ax.bar(x, allocation_new_users_norm, width, label="New Users", color="orange")
bars3 = ax.bar(
x + width, merged_allocation_norm, width, label="Merged", color="lightgreen"
)
# Add value labels inside bars (white text)
for _i, (bar1, bar2, bar3) in enumerate(zip(bars1, bars2, bars3, strict=False)):
# Reengage values
height1 = bar1.get_height()
if height1 > 0:
ax.text(
bar1.get_x() + bar1.get_width() / 2.0,
height1 / 2,
f"{height1:.1f}%",
ha="center",
va="center",
color="white",
fontweight="bold",
)
# New Users values
height2 = bar2.get_height()
if height2 > 0:
ax.text(
bar2.get_x() + bar2.get_width() / 2.0,
height2 / 2,
f"{height2:.1f}%",
ha="center",
va="center",
color="white",
fontweight="bold",
)
# Merged values
height3 = bar3.get_height()
if height3 > 0:
ax.text(
bar3.get_x() + bar3.get_width() / 2.0,
height3 / 2,
f"{height3:.1f}%",
ha="center",
va="center",
color="white",
fontweight="bold",
)
# Customize plot
ax.set_xlabel("Channels")
ax.set_ylabel("Budget Allocation (%)")
ax.set_title("Budget Allocation Comparison: New Users vs Reengage vs Merged")
ax.set_xticks(x)
ax.set_xticklabels(channel_names)
ax.set_ylim(0, 45)
ax.grid(True, alpha=0.3)
ax.legend()
plt.show()
¡Increíble! La nueva asignación es más equilibrada entre los canales, mostrando que no estamos optimizando para un solo objetivo. 👏🏻
¿Qué más podemos hacer? Ahora veamos un ejemplo utilizando un modelo MMM nativo de PyMC-Marketing.
Optimización de Modelos MMM Multidimensionales#
Con la clase PyMC-Marketing, el proceso apenas cambia:
Cargamos o construimos nuestro modelo MMM (Multidimensional o no).
Envolvemos el modelo en la clase optimizadora (evite esto si está utilizando la API antigua).
Pasamos el nuevo modelo fusionado al optimizador de presupuesto.
data_path = data_dir / "multidimensional_mock_data.csv"
data_df = pd.read_csv(data_path, parse_dates=["date"], index_col=0)
x_train = data_df.drop(columns=["y"])
y_train = data_df["y"]
mmm1 = build_mmm_from_yaml(
X=x_train,
y=y_train,
config_path=data_dir / "config_files" / "multi_dimensional_example_model.yml",
)
mmm2 = build_mmm_from_yaml(
X=x_train,
y=y_train,
config_path=data_dir / "config_files" / "multi_dimensional_example_model.yml",
)
mmm3 = build_mmm_from_yaml(
X=x_train,
y=y_train,
config_path=data_dir / "config_files" / "multi_dimensional_example_model.yml",
)
Una vez que se construyen los modelos, ¡utilizamos los envoltorios!
max_date = new_users_idata.posterior.coords["date"].max().item()
start_date = (pd.Timestamp(max_date) + pd.Timedelta(weeks=1)).strftime("%Y-%m-%d")
end_date = (
pd.Timestamp(start_date) + pd.Timedelta(weeks=optimization_horizon)
).strftime("%Y-%m-%d")
w1 = MultiDimensionalBudgetOptimizerWrapper(
model=mmm1, start_date=start_date, end_date=end_date
)
w2 = MultiDimensionalBudgetOptimizerWrapper(
model=mmm2, start_date=start_date, end_date=end_date
)
w3 = MultiDimensionalBudgetOptimizerWrapper(
model=mmm3, start_date=start_date, end_date=end_date
)
merged = BuildMergedModel(
models=[w1, w2, w3],
prefixes=["north", "south", "west"],
merge_on="channel_data",
use_every_n_draw=5,
)
¡Hecho! Nuestro modelo combinado está listo para usar. Vamos a visualizarlo.
merged.model.to_graphviz()
Jugaremos aquí con otra opción (algo diferente a lo anterior). Una vez que nuestro modelo esté listo, podemos fusionar y agregar una nueva variable que dependa de variables determinísticas preexistentes de nuestros diferentes modelos. Esto puede ayudar a crear una variable determinística que represente el total de unidades a través de nuestros modelos.
Solo necesitamos acceder al modelo y crear la variable determinista.
with merged.model:
pmd.Deterministic(
"new_variable_calculation",
(
merged.model["north_total_media_contribution_original_scale"]
+ merged.model["south_total_media_contribution_original_scale"]
+ merged.model["west_total_media_contribution_original_scale"]
),
)
Ahora, podemos pedirle al optimizador que haga algo similar a lo que hizo antes: tráigame el número máximo de unidades en todos los mercados (modelos), pero mantenga un mercado específico a un nivel mayor o igual a \(Z\).
Ver cómo funciona esto 👇🏻
min_total_response = 10
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(
"north_total_media_contribution_original_scale"
)
mean_resp = resp_dist.mean()
return mean_resp - min_total_response
optimize_multidimensional_merged_model = BudgetOptimizer(
model=merged,
num_periods=optimization_horizon,
response_variable="new_variable_calculation", # variable to optimize (maximize in this case)
custom_constraints=[
Constraint(
key="min_response_constraint",
constraint_fun=mean_response_eq_constraint_fun,
constraint_type="ineq",
)
],
)
allocation_xarray, scipy_result, callback_results = (
optimize_multidimensional_merged_model.allocate_budget(
total_budget=budget_per_time_unit_in_horizon, callback=True
)
)
constraint_value = round(callback_results[-1]["constraint_info"][0]["value"], 4)
# Check if constraint is satisfied (should be close to zero for equality, >= 0 for inequality)
if np.isclose(constraint_value, 0, atol=1e-6):
print(f"Constraint satisfied: value {constraint_value} is close to zero")
elif constraint_value > 0:
print(f"Constraint satisfied: value {constraint_value} is greater than zero")
else:
raise AssertionError(
f"Constraint violated: value {constraint_value} should be >= 0"
)
Constraint satisfied: value 240.8141 is greater than zero
¡Excelente! Nuevamente, podemos ver que el optimizador encontró una solución y finalizó la tarea respetando las restricciones.
La clase BuildMergedModel permite una amplia gama de nuevas capacidades en optimización. Puede fusionar sus modelos entrenados preexistentes en un solo lugar y optimizar lo que desee, pensando y ajustando el objetivo según las necesidades de su negocio. Pero al mismo tiempo, eso es solo la punta del iceberg.
Aprendiendo sobre la función de combinar modelos#
En el fondo, la clase BuildMergedModel se basa en la función merge_models en pytensor_utils, que le permite aplicar la misma operación de fusión sin idata. Esto le permite crear modelos a partir de otros modelos y luego muestrearlos juntos si es necesario.
merge_models?
Signature:
merge_models(
models: list[pymc.model.core.Model],
*,
prefixes: list[str] | None = None,
merge_on: str | None = None,
) -> pymc.model.core.Model
Docstring:
Merge multiple PyMC models into a single model.
Parameters
----------
models : list of pm.Model
List of models to merge.
prefixes : list of str or None
List of prefixes for each model. If None, will auto-generate as 'model1', 'model2', ...
merge_on : str or None
Variable name to merge on (shared across all models) - this variable will NOT be prefixed.
Returns
-------
pm.Model
Merged model.
File: ~/Documents/GitHub/pymc-marketing/pymc_marketing/pytensor_utils.py
Type: function
En este espacio gráfico simbólico, cada modelo actúa como un nodo en un grafo acíclico dirigido computacional (C-DAG), y el proceso de fusión es similar a realizar una operación de unión en SQL, donde diferentes conjuntos de datos se combinan en función de claves comunes. Al aprovechar el poder de PyMC y la representación simbólica de modelos, merge_models facilita la construcción de estos grandes modelos gráficos sin la necesidad de construirlos todos juntos desde cero. Siempre puedes construir y combinar diferentes piezas, como con Lego.
Descubre cómo, utilizando esta función de nivel inferior, podemos usar los modelos puros de PyMC y obtener un nuevo modelo a partir de ellos.
pm_merge_model = merge_models(
models=[new_users_model, reengage_users_model],
prefixes=["new_users", "reengage"],
merge_on="channel_data",
)
pm_merge_model.to_graphviz()
Ahora que el modelo está construido, podemos muestrear de él como de cualquier otro modelo de PyMC.
with pm_merge_model:
merged_model_idata = pm.sample(**sample_grid)
merged_model_idata
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (4 chains in 4 jobs)
NUTS: [new_users_saturation_alpha, new_users_saturation_lam, new_users_intercept, new_users_likelihood_sigma, reengage_saturation_alpha, reengage_saturation_lam, reengage_intercept, reengage_likelihood_sigma]
Sampling 4 chains for 800 tune and 200 draw iterations (3_200 + 800 draws total) took 2 seconds.
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
-
<xarray.Dataset> Size: 5MB Dimensions: (chain: 4, draw: 200, channel: 4, date: 100) Coordinates: * chain (chain) int64 32B 0 1 2 3 * draw (draw) int64 2kB 0 1 2 3 4 ... 196 197 198 199 * channel (channel) <U18 288B 'impressions_x1_var' ... ... * date (date) datetime64[ns] 800B 2020-01-01 ... 202... Data variables: new_users_intercept (chain, draw) float64 6kB 2.738 2.713 ... 3.27 reengage_intercept (chain, draw) float64 6kB 2.792 2.788 ... 2.854 new_users_saturation_alpha (chain, draw, channel) float64 26kB 0.423 ...... new_users_saturation_lam (chain, draw, channel) float64 26kB 1.003 ...... new_users_likelihood_sigma (chain, draw) float64 6kB 0.6403 ... 0.655 reengage_saturation_alpha (chain, draw, channel) float64 26kB 0.9941 ..... reengage_saturation_lam (chain, draw, channel) float64 26kB 0.238 ...... reengage_likelihood_sigma (chain, draw) float64 6kB 0.5287 ... 0.5364 new_users_contribution (chain, draw, date, channel) float64 3MB 0.19... reengage_contribution (chain, draw, date, channel) float64 3MB 0.77... Attributes: created_at: 2025-10-09T17:42:08.510911+00:00 arviz_version: 0.22.0 inference_library: pymc inference_library_version: 5.25.1 sampling_time: 1.7832472324371338 tuning_steps: 800 -
<xarray.Dataset> Size: 106kB Dimensions: (chain: 4, draw: 200) Coordinates: * chain (chain) int64 32B 0 1 2 3 * draw (draw) int64 2kB 0 1 2 3 4 5 ... 195 196 197 198 199 Data variables: (12/18) tree_depth (chain, draw) int64 6kB 5 4 4 5 5 4 4 ... 5 4 4 4 4 5 perf_counter_start (chain, draw) float64 6kB 1.071e+06 ... 1.071e+06 acceptance_rate (chain, draw) float64 6kB 0.9271 0.583 ... 0.5379 step_size (chain, draw) float64 6kB 0.1555 0.1555 ... 0.1949 energy_error (chain, draw) float64 6kB -0.8538 -0.305 ... 0.865 smallest_eigval (chain, draw) float64 6kB nan nan nan ... nan nan nan ... ... index_in_trajectory (chain, draw) int64 6kB 9 -7 -11 -11 7 ... 8 4 -10 13 step_size_bar (chain, draw) float64 6kB 0.1973 0.1973 ... 0.1935 divergences (chain, draw) int64 6kB 0 0 0 0 0 0 0 ... 0 0 0 0 0 0 energy (chain, draw) float64 6kB 231.9 228.3 ... 227.9 236.5 lp (chain, draw) float64 6kB -220.2 -219.4 ... -230.2 process_time_diff (chain, draw) float64 6kB 0.001645 ... 0.001607 Attributes: created_at: 2025-10-09T17:42:08.522281+00:00 arviz_version: 0.22.0 inference_library: pymc inference_library_version: 5.25.1 sampling_time: 1.7832472324371338 tuning_steps: 800 -
<xarray.Dataset> Size: 2kB Dimensions: (date: 100) Coordinates: * date (date) datetime64[ns] 800B 2020-01-01 ... 2020-04-09 Data variables: new_users_likelihood (date) float64 800B 5.507 5.487 5.581 ... 1.819 1.432 reengage_likelihood (date) float64 800B 4.623 4.903 5.35 ... 4.338 3.943 Attributes: created_at: 2025-10-09T17:42:08.525115+00:00 arviz_version: 0.22.0 inference_library: pymc inference_library_version: 5.25.1 -
<xarray.Dataset> Size: 4kB Dimensions: (date: 100, channel: 4) Coordinates: * date (date) datetime64[ns] 800B 2020-01-01 ... 2020-04-09 * channel (channel) <U18 288B 'impressions_x1_var' ... 'impressions_x... Data variables: channel_data (date, channel) float64 3kB 0.8661 0.949 1.686 ... 0.0 0.09234 Attributes: created_at: 2025-10-09T17:42:08.526460+00:00 arviz_version: 0.22.0 inference_library: pymc inference_library_version: 5.25.1
¿Es este modelo diferente de muestrear dos modelos por separado? No debería serlo. Esto está fusionando todas sus variables y manteniendo cada variable determinista o aleatoria con un prefijo. Esto significa que los nodos no interactúan ni extraen información entre sí; los modelos son completamente independientes incluso cuando comparten la misma variable. Esto es equivalente a ejecutar modelos separados en paralelo.
Veamos cómo se ve la posterior para un parámetro dado en comparación con el modelo individual.
temp = merged_model_idata.copy()
# rename new_users_saturation_lam to saturation_lam
temp.rename({"new_users_saturation_lam": "saturation_lam"}, inplace=True)
az.plot_forest(
data=[temp.posterior.saturation_lam, new_users_idata.posterior.saturation_lam],
model_names=["Merged model", "New users model"],
var_names=["saturation_lam"],
combined=True,
)
¡El parámetro recuperado es el mismo!
No obstante, el mayor beneficio de combinar los modelos es poder conectar o relacionar las variables. No solo para la optimización, sino que durante el muestreo, podrías añadir términos de penalización en una variable de un modelo A, que, si se relaciona con el modelo B, podría condicionar los valores de los parámetros del modelo B.
Dependiendo del modelo, un posible beneficio o problema podría ser el tiempo de muestreo. Esto puede variar bastante una vez que comience a crear estos grandes modelos gráficos.
Para estimar los tiempos de muestreo para cualquier modelo de PyMC, puede utilizar la clase de utilidad ModelSamplerEstimator de pytensor_utils.
from pymc_marketing.pytensor_utils import ModelSamplerEstimator
ModelSamplerEstimator?
Init signature:
ModelSamplerEstimator(
*,
tune: int = 1000,
draws: int = 1000,
chains: int = 1,
sequential_chains: int = 1,
seed: int | None = None,
) -> None
Docstring:
Estimate computational characteristics of a PyMC model using JAX/NumPyro.
This utility measures the average evaluation time of the model's logp and gradients
and estimates the number of integrator steps taken by NUTS during warmup + sampling.
It then compiles the information into a single-row pandas DataFrame with helpful
metadata to guide planning and benchmarking.
Parameters
----------
tune : int, default 1000
Number of warmup iterations to use when estimating NUTS steps.
draws : int, default 1000
Number of sampling iterations to use when estimating NUTS steps.
chains : int, default 1
Intended number of chains (metadata only; not used in JAX runs here).
sequential_chains : int, default 1
Number of chains expected to run sequentially on the target environment.
Used to scale the wall-clock time estimate.
seed : int | None, default None
Random seed used for the step estimation runs.
Examples
--------
.. code-block:: python
est = ModelSamplerEstimator(
tune=1000, draws=1000, chains=4, sequential_chains=1, seed=1
)
df = est.run(model)
print(df)
File: ~/Documents/GitHub/pymc-marketing/pymc_marketing/pytensor_utils.py
Type: type
Subclasses:
Una vez que inicializamos la clase, podemos pasarle un modelo y obtener la estimación de muestreo en diferentes unidades de tiempo. ¡Vea el ejemplo a continuación!
estimator = ModelSamplerEstimator()
estimated_model_time = estimator.run(pm_merge_model)
# change model name of index zero to pm_merge_model
estimated_model_time.loc[0, "model"] = "pm_merge_model"
estimated_model_time
| model_name | num_steps | eval_time_seconds | sequential_chains | estimated_sampling_time_seconds | estimated_sampling_time_minutes | estimated_sampling_time_hours | tune | draws | chains | seed | timestamp | model | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 62551 | 0.000057 | 1 | 3.557603 | 0.059293 | 0.000988 | 1000 | 1000 | 1 | None | 2025-10-09 17:42:17+00:00 | pm_merge_model |
Haciendo lo mismo, ahora podemos estimar el tiempo para nuestros modelos individuales.
new_users_time = estimator.run(new_users_model)
new_users_time.loc[0, "model"] = "new_users_model"
estimated_model_time = pd.concat(
[estimated_model_time, new_users_time], ignore_index=True
)
reengage_users_time = estimator.run(reengage_users_model)
reengage_users_time.loc[0, "model"] = "reengage_users_model"
estimated_model_time = pd.concat(
[estimated_model_time, reengage_users_time], ignore_index=True
)
print(
"Merged model: Assuming 1000 draws and tunes over 4 chains, the minimum time to sample the merged model is: ",
estimated_model_time.query("model == 'pm_merge_model'")[
"estimated_sampling_time_seconds"
].values,
"seconds",
)
print(
"New users model: Assuming 1000 draws and tunes over 4 chains, the minimum time to sample the new users model is: ",
estimated_model_time.query("model == 'new_users_model'")[
"estimated_sampling_time_seconds"
].values,
"seconds",
)
print(
"Re-engage users model: Assuming 1000 draws and tunes over 4 chains, "
"the minimum time to sample the re-engage users model is: ",
estimated_model_time.query("model == 'reengage_users_model'")[
"estimated_sampling_time_seconds"
].values,
"seconds",
)
Merged model: Assuming 1000 draws and tunes over 4 chains, the minimum time to sample the merged model is: [3.55760328] seconds
New users model: Assuming 1000 draws and tunes over 4 chains, the minimum time to sample the new users model is: [1.49876236] seconds
Re-engage users model: Assuming 1000 draws and tunes over 4 chains, the minimum time to sample the re-engage users model is: [2.036205] seconds
estimated_model_time.head()
| model_name | num_steps | eval_time_seconds | sequential_chains | estimated_sampling_time_seconds | estimated_sampling_time_minutes | estimated_sampling_time_hours | tune | draws | chains | seed | timestamp | model | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 62551 | 0.000057 | 1 | 3.557603 | 0.059293 | 0.000988 | 1000 | 1000 | 1 | None | 2025-10-09 17:42:17+00:00 | pm_merge_model | |
| 1 | 42407 | 0.000035 | 1 | 1.498762 | 0.024979 | 0.000416 | 1000 | 1000 | 1 | None | 2025-10-09 17:42:23+00:00 | new_users_model | |
| 2 | 60072 | 0.000034 | 1 | 2.036205 | 0.033937 | 0.000566 | 1000 | 1000 | 1 | None | 2025-10-09 17:42:28+00:00 | reengage_users_model |
Conclusión#
El enfoque principal de este cuaderno es una técnica poderosa: combinar diferentes modelos probabilísticos para crear un marco unificado para la toma de decisiones. Mostramos cómo la clase BuildMergedModel puede integrar sin problemas varios modelos, ya sean modelos personalizados de PyMC o complejos MMMs Multidimensionales de la biblioteca pymc-marketing. Este método nos permite abordar compromisos complicados, como maximizar nuevos usuarios mientras también reactivamos a los existentes, reformulando el problema como una optimización restringida. Podemos maximizar un objetivo mientras aseguramos que otro cumpla con un nivel mínimo de rendimiento, lo que nos ayuda a encontrar una asignación de presupuesto equilibrada sin comprometer objetivos comerciales importantes.
Sin embargo, esto no se trata solo de marketing. Las técnicas presentadas aquí demuestran un enfoque flexible y escalable para el modelado computacional. Al construir modelos de manera modular, “similar a Lego”, utilizando la función merge_models, podemos crear modelos gráficos complejos que realmente capturan las conexiones dentro de los sistemas empresariales. Esto abre posibilidades para una planificación de escenarios detallada y optimización en diversas áreas, como finanzas, operaciones o desarrollo de productos. Además, con herramientas como ModelSamplerEstimator para estimar los costos computacionales, los profesionales pueden manejar las complejidades de modelos más grandes. Esto proporciona un marco práctico y poderoso para la planificación estratégica basada en datos en cualquier organización.
%load_ext watermark
%watermark -n -u -v -iv -w -p pytensor
Last updated: Thu Oct 09 2025
Python implementation: CPython
Python version : 3.12.11
IPython version : 9.4.0
pytensor: 2.31.7
arviz : 0.22.0
numpy : 2.2.6
pymc_marketing: 0.16.0
pandas : 2.3.1
pymc : 5.25.1
pymc_extras : 0.4.0
pytensor : 2.31.7
matplotlib : 3.10.3
Watermark: 2.5.0