Introduciendo el descubrimiento causal en PyMC-Marketing#
En marketing, nos encanta contar historias sobre qué impulsa qué: “la televisión aumenta el tráfico orgánico”, “Meta impulsa las inscripciones”, “las reducciones de precios incrementan el volumen de búsqueda”. Sin embargo, esas historias se basan en una suposición no expresada: que realmente sabemos la dirección de la influencia. ¿Qué pasaría si el “impulsor” es en realidad un efecto secundario de algo más, como la estacionalidad o los lanzamientos de productos? Sin un mapa causal, incluso regresiones elegantes o modelos bayesianos pueden confundir correlación con causalidad, llevándonos a optimizar las palancas equivocadas.
Eso es donde entra el descubrimiento causal: algoritmos que aprenden el gráfico subyacente de causa y efecto al probar independencias condicionales en los datos. Los algoritmos clásicos PC y FCI, utilizados en campos como la biología y la neurociencia, son brillantes para descubrir esqueletos causales bajo suposiciones fuertes: datos i.i.d., sin retroalimentación y mecanismos bien separados. Pero el marketing rara vez juega según esas reglas. Los canales interactúan, las campañas se superponen en el tiempo y los KPI a menudo retroalimentan las decisiones de exposición. ¿El resultado? Ejecutar un PC o FCI estándar en datos de marketing en bruto probablemente te dará tonterías: aristas que apuntan de conversiones a impresiones, de CTR a gasto, o bucles que desafían la lógica empresarial.
¿Significa esto que el descubrimiento causal es inútil para el marketing? En absoluto. Los sistemas de marketing son estructurales, no caóticos. Ya sabemos que muchas relaciones no pueden existir: las impresiones no dependen de las conversiones, y cada camino causal debería eventualmente apuntar hacia un objetivo medible o KPI. También tenemos fuertes priors sobre factores latentes como la estacionalidad o la dinámica de la cuota de mercado. Este conocimiento reduce drásticamente el espacio de búsqueda y hace que el descubrimiento causal sea factible si lo incorporamos de manera reflexiva. Eso es exactamente lo que explora este cuaderno.
Visión general del algoritmo TBFPC#
El algoritmo TBFPC — Factor de Bayes orientado al objetivo primero — es una variante ligera y orientada al objetivo del enfoque clásico de PC, diseñada con el marketing en mente. Prueba la independencia utilizando factores de Bayes en lugar de pruebas frecuentistas, y te permite codificar bordes prohibidos para reflejar los priors del dominio. Añade una regla de borde objetivo — "any", "conservative" o "fullS" — para sesgar el descubrimiento hacia relaciones genuinas de driver → target. El factor de Bayes se calcula a través de la aproximación ΔBIC:
TBFPC es experimental, pero cierra una brecha importante entre el modelado bayesiano y el razonamiento causal para la ciencia del marketing. Su objetivo no es reemplazar los marcos de descubrimiento clásicos, sino adaptarlos —respetando la estructura, las restricciones y las realidades de los datos de marketing— para generar gráficos que tengan sentido tanto estadísticamente como estratégicamente.
Glosario#
Descubrimiento causal – Algoritmos que inferen relaciones direccionales entre variables al probar independencias condicionales en datos observacionales.
Grafo Acíclico Dirigido (DAG) – Un grafo completamente orientado y sin ciclos que codifica un proceso específico de generación de datos causal.
Grafo Acíclico Dirigido Parcialmente Completo (CPDAG) – Un grafo con bordes tanto dirigidos como no dirigidos que resume cada DAG en la misma clase de equivalencia de Markov.
Clase de equivalencia de Markov (MEC) – La colección de DAGs que implican declaraciones de d-separación idénticas y, por lo tanto, no pueden ser distinguidos únicamente a través de pruebas de independencia condicional.
Factor Bayes PC orientado al objetivo (TBFPC) – Adaptación orientada al objetivo del algoritmo de descubrimiento PC que aplica pruebas de factor Bayes (ΔBIC) más restricciones específicas de marketing para aprender esqueletos causales.
Prueba del factor de Bayes ΔBIC – El logaritmo del factor de Bayes calculado a partir del cambio en el Criterio de Información Bayesiano, utilizado para decidir si las variables permanecen dependientes después de condicionar sobre los controles.
Importar dependencias#
# avoid all warnings types
import warnings
warnings.filterwarnings("ignore")
import os
import tempfile
import arviz as az
import matplotlib.image as mpimg
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import preliz as pz
import pymc as pm
from graphviz import Digraph, Source
from IPython.display import SVG, display
from pymc_marketing.causal_utils import same_markov_equivalence_class_CPdag
from pymc_marketing.mmm.causal import (
TBFPC,
BuildModelFromDAG,
)
OMP: Info #276: omp_set_nested routine deprecated, please use omp_set_max_active_levels instead.
Configuración del cuaderno#
az.style.use("arviz-darkgrid")
plt.rcParams["figure.figsize"] = [12, 7]
plt.rcParams["figure.dpi"] = 200
plt.rcParams.update({"figure.constrained_layout.use": True})
%load_ext autoreload
%autoreload 2
%config InlineBackend.figure_format = "retina"
seed = sum(map(ord, "Causality and Bayes as you never saw it before"))
rng = np.random.default_rng(seed)
n_observations = 1050
Comencemos con una historia causal simple pero poderosa, adaptada de un artículo de Ben Vincent — “Inferencia Causal: ¿has estado haciendo ciencia mal todo este tiempo?”. En él, Ben ilustra (probablemente sin saberlo) una situación común en la analítica de marketing donde búsqueda, medios y ventas están estrechamente entrelazados. Recrearemos una estructura similar aquí, representando el verdadero (pero generalmente oculto) proceso de generación de datos causal.
Imagine el siguiente sistema:
Las personas primero participan en la actividad de búsqueda genérica (
Q), como buscar productos o comparar opciones.Esta intención general influye en la exposición a los medios (
X) — aquellos que buscan más también son más propensos a ver o hacer clic en anuncios.Tanto
QcomoXmoldean la búsqueda de marca (Y), que captura la fuerza de la marca en la mente del cliente.Finalmente, tanto
XcomoYconducen a compras (P).
Este gráfico cuenta una historia causal clara: la intención (Q) impulsa tanto la media como la búsqueda de marca, que juntas impulsan las ventas. Sin embargo, si no conociéramos este mapa y simplemente realizáramos una regresión de P ~ X + Y, podríamos sobreestimar el efecto de X (media) porque parte de su variación está confundida por Q. O podríamos interpretar erróneamente Y (búsqueda de marca) como un impulsor independiente de las compras, cuando en realidad está mediado por la media y la intención. Esta es la trampa clásica de confundir correlación con causalidad.
TEXT: Tales estructuras no son hipotéticas; reflejan dinámicas de marketing reales. Los canales, las señales de marca y la intención del consumidor a menudo se refuerzan mutuamente. Sin una comprensión causal de cómo interactúan estos procesos, cualquier modelo econométrico o bayesiano corre el riesgo de atribuir los efectos incorrectos a las palancas equivocadas. El descubrimiento causal nos proporciona una forma fundamentada de descubrir (o al menos aproximar) este DAG oculto, ayudándonos a construir modelos que expliquen por qué sucede algo, no solo qué sucede. La siguiente celda visualiza este mapa causal canónico.
# Define the true DAG structure for our synthetic data
true_dag = Digraph(comment="True Causal DAG")
true_dag.attr(rankdir="LR")
true_dag.node("Q", "generic_search")
true_dag.node("X", "media_activities")
true_dag.node("Y", "brand_search")
true_dag.node("P", "purchase", fillcolor="lightgrey", style="filled")
# edges
true_dag.edge("Q", "X") # Q influences X
true_dag.edge("Q", "Y") # Q influences Y
true_dag.edge("X", "Y") # X influences Y
true_dag.edge("X", "P") # X influences P
true_dag.edge("Y", "P") # Y influences P
display(SVG(true_dag.pipe(format="svg")))
Ahora generaremos datos que siguen la misma estructura causal.
q = pz.LogNormal(0, 1).rvs(n_observations, random_state=rng)
x = pz.LogNormal(0.1, 0.4).rvs(n_observations, random_state=rng) + (q * 0.4)
y = (
pz.LogNormal(0.7 + 0.11, 0.24).rvs(n_observations, random_state=rng)
+ (x * 0.1)
+ (q * 0.2)
)
p = (
pz.LogNormal(0.43 + 0.21, 0.22).rvs(n_observations, random_state=rng)
+ (x * 0.4)
+ (y * 0.2)
)
initial_dag_dataset = pd.DataFrame(
{"generic_search": q, "media_activities": x, "brand_search": y, "purchase": p}
)
initial_dag_dataset.head()
| generic_search | media_activities | brand_search | purchase | |
|---|---|---|---|---|
| 0 | 1.525706 | 1.286901 | 2.364840 | 2.638178 |
| 1 | 0.279419 | 1.678668 | 2.333737 | 4.088818 |
| 2 | 0.797957 | 1.062336 | 1.892284 | 3.001699 |
| 3 | 0.530282 | 2.711975 | 2.359973 | 3.994984 |
| 4 | 1.605857 | 2.417843 | 3.135235 | 3.702778 |
Ahora que tenemos nuestro conjunto de datos sintético y su estructura causal subyacente, veamos cuán fácilmente podemos recuperarlo utilizando el algoritmo TBFPC. La clase está diseñada para ser simple e intuitiva: solo debe especificar la variable objetivo (en nuestro caso, purchase) y los factores candidatos. Detrás de escena, el algoritmo prueba independencias condicionales utilizando factores de Bayes y construye un gráfico que representa el esqueleto causal más plausible consistente con los datos.
Bajo el capó, TBFPC comienza con un grafo completamente conectado entre todas las variables candidatas y el objetivo. Luego realiza una búsqueda sistemática de independencias condicionales, comenzando desde pruebas no condicionadas (sin variables de control) y condicionando gradualmente sobre subconjuntos más grandes de otras variables. Siempre que encuentra que dos variables son independientes dado algún conjunto de condicionamiento, elimina la arista que las conecta; este paso poda progresivamente el esqueleto. El proceso continúa hasta que no se pueden eliminar más aristas bajo la regla de significancia elegida.
Cada conexión restante representa una dependencia que no puede ser «explicada» condicionando sobre otras variables. Los bordes dirigidos (especialmente hacia el objetivo) están orientados de acuerdo con la regla de borde objetivo que especifique, asegurando que la búsqueda favorezca relaciones interpretables de conductor → objetivo mientras respeta cualquier borde prohibido o requerido que pueda imponer. La salida de este procedimiento es un grafo causal — específicamente, un Grafo Dirigido Acíclico Causal (Causal DAG) o, más precisamente, un Grafo Acíclico Dirigido Parcialmente Completado (CPDAG).
¿Cuál es la diferencia entre un CPDAG y un CDAG?#
Un DAG Causal representa una única estructura generadora de datos completamente orientada donde cada arista tiene una dirección (por ejemplo, media_activities → brand_search → purchase). Codifica una historia causal completa.
A CPDAG, por otro lado, representa una clase de equivalencia de Markov: todos los DAG que son estadísticamente indistinguibles entre sí dado los datos observados. Contiene tanto aristas dirigidas como no dirigidas; las dirigidas son aquellas que podemos inferir con confianza a partir de los datos y la lógica, mientras que las no dirigidas permanecen ambiguas (podrían apuntar en cualquier dirección sin contradecir las independencias observadas).
Con TBFPC, obtener esta estructura requiere solo unas pocas líneas de código. El algoritmo devolverá el grafo aprendido en formato DOT (compatible con Graphviz) y puedes visualizarlo o inspeccionar las aristas dirigidas y no dirigidas directamente. A continuación, ajustamos el modelo e imprimimos su estructura descubierta.
¡Vea cómo funciona!
model = TBFPC(target="purchase")
model.fit(
df=initial_dag_dataset,
drivers=initial_dag_dataset.drop(columns=["purchase"]).columns.tolist(),
)
digraph_text = model.to_digraph()
print(digraph_text)
digraph G {
node [shape=ellipse];
"generic_search";
"media_activities";
"brand_search";
"purchase" [style=filled, fillcolor="#eef5ff"];
"brand_search" -> "purchase";
"media_activities" -> "purchase";
"brand_search" -> "generic_search" [style=dashed, dir=none];
"brand_search" -> "media_activities" [style=dashed, dir=none];
"generic_search" -> "media_activities" [style=dashed, dir=none];
}
La salida anterior muestra el grafo causal aprendido en formato Graphviz DOT. Cada línea representa un nodo o un borde, y la sintaxis captura tanto relaciones dirigidas como no dirigidas.
Las flechas dirigidas como "brand_search" -> "purchase"; o "media_activities" -> "purchase"; representan bordes orientados — direcciones causales que el algoritmo puede inferir con suficiente confianza basándose en pruebas de independencia condicional. En este caso, tanto brand_search como media_activities se estiman como causas directas de purchase, lo que se alinea con el verdadero DAG subyacente que simulamos anteriormente.
Los bordes discontinuos y no dirigidos, como "brand_search" -> "generic_search" [style=dashed, dir=none], representan relaciones ambiguas — conexiones que permanecen no dirigidas en el CPDAG. La etiqueta [style=dashed, dir=none] codifica esto de manera explícita:
style=dashedmarca visualmente el borde como no orientado.dir=noneindica a Graphviz que dibuje el borde sin una punta de flecha.
Matemáticamente, esta ambigüedad significa que ambas direcciones posibles (por ejemplo, brand_search → generic_search y generic_search → brand_search) son equivalentes de Markov — inducen el mismo conjunto de independencias condicionales en los datos. En el lenguaje de los gráficos causales, dos DAGs son equivalentes de Markov si codifican las mismas relaciones de d-separación; es decir, para cada tripleta de variables \((X, Y, Z)\),
TEXT: Así, ambas orientaciones se ajustarían igualmente bien a la estructura de independencia observada. Sin suposiciones adicionales o datos experimentales, el algoritmo no puede determinar cuál dirección es correcta, por lo que las preserva como enlaces no dirigidos discontinuos.
Por lo tanto, estas conexiones discontinuas representan la incertidumbre restante después de condicionar sobre todas las demás variables hasta un tamaño de subconjunto dado (controlado por max_k dentro del algoritmo). La prueba del factor de Bayes detrás de TBFPC compara dos modelos en competencia para cada consulta de independencia:
y declara independencia si
donde \(\tau\) es el umbral del factor de Bayes elegido. Si esta desigualdad se mantiene de manera simétrica a través de los conjuntos de condicionamiento (es decir, ninguna dirección proporciona una evidencia más fuerte de dependencia), el borde permanece ambiguo — por lo tanto, discontinuo.
En otras palabras, los bordes discontinuos significan que los datos apoyan una dependencia entre variables, pero no existe suficiente evidencia direccional para resolver la flecha. Estos son precisamente los límites de la clase de equivalencia de Markov, y explorar o restringirlos (a través de conocimientos previos o experimentos adicionales) es el siguiente paso hacia un DAG causal orientado de manera única.
¡Pongamos esto en Graphviz para visualizarlo mejor!
discovered_dag = Source(digraph_text)
display(SVG(discovered_dag.pipe(format="svg")))
Una dura verdad en el descubrimiento causal es que los datos rara vez identifican un único “verdadero” DAG. Muchos gráficos diferentes pueden codificar el mismo conjunto de independencias condicionales y, por lo tanto, ajustarse a la evidencia observada con igual eficacia. Estos gráficos forman una clase de equivalencia de Markov (MEC) y se resumen en el CPDAG que vio arriba: flechas sólidas para orientaciones que podemos justificar y enlaces discontinuos para bordes que podrían apuntar en cualquier dirección sin contradecir las pruebas. En la práctica, el tamaño de la MEC crece exponencialmente con el número de bordes no dirigidos, razón por la cual determinar el DAG solo a partir de datos observacionales es notoriamente difícil.
Aún así, entender el MEC es a menudo suficiente para tomar decisiones fundamentadas. Cada DAG en el MEC comparte las mismas relaciones de d-separación (las mismas independencias testables), por lo que coinciden en qué controles bloquean puertas traseras y qué variables son efectos descendentes. Eso significa que puedes diseñar conjuntos de ajuste válidos, poner a prueba historias causales y decidir dónde un experimento o instrumento sería más informativo — todo sin comprometerse a una única orientación.
Cuando desee explorar candidatos concretos, TBFPC expone get_all_cdags_from_cpdag. En el fondo, toma cada arista discontinua, prueba ambas direcciones, filtra orientaciones cíclicas, y devuelve el conjunto de grafos dirigidos y acíclicos que son consistentes con el CPDAG. Formalmente, orienta cada par no decidido \((u,v)\) a \(u\to v\) o \(v\to u\), y mantiene solo aquellas orientaciones para las cuales la matriz de adyacencia resultante es acíclica. Esto le proporciona un menú de mundos plausibles que se ajustan a la evidencia.
all_dags_v0 = model.get_all_cdags_from_cpdag(digraph_text)
print(f"Number of DAGs in the Markov equivalence class: {len(all_dags_v0)}")
Number of DAGs in the Markov equivalence class: 6
¿Qué deberías hacer con ese menú? Usa sentido comercial y diagnósticos de modelos (hablaremos sobre la construcción de modelos a partir de dags pronto) para elegir el DAG que mejor se alinee con el conocimiento del mecanismo (por ejemplo, “las impresiones no dependen de las conversiones”) y que valide empíricamente (comprobaciones predictivas posteriores, rendimiento fuera de muestra, robustez a ajustes).
# Create a figure with subplots for each DAG
fig, axes = plt.subplots(1, len(all_dags_v0), figsize=(5 * len(all_dags_v0), 5))
# If there's only one DAG, make axes a list for consistency
if len(all_dags_v0) == 1:
axes = [axes]
# Create temporary directory for storing images
temp_dir = tempfile.mkdtemp()
try:
# Render and plot each DAG
for i, dag_dot in enumerate(all_dags_v0):
dag_source = Source(dag_dot)
# Create temporary file path
temp_file = os.path.join(temp_dir, f"dag_{i}")
# Render to PNG
dag_source.render(format="png", filename=temp_file, cleanup=True)
# Load and display the image
axes[i].imshow(mpimg.imread(f"{temp_file}.png"))
axes[i].set_title(f"DAG {i + 1}", fontsize=12)
axes[i].axis("off")
# Clean up the temporary PNG file
if os.path.exists(f"{temp_file}.png"):
os.remove(f"{temp_file}.png")
# Add main title
plt.suptitle("Markov Equivalence Class - All Possible DAGs", fontsize=16)
plt.tight_layout()
plt.show()
finally:
# Clean up temporary directory
if os.path.exists(temp_dir):
os.rmdir(temp_dir)
El panel trazado muestra cada DAG completamente orientado que es consistente con el CPDAG que aprendimos: cada enlace punteado ha sido asignado una dirección, se filtraron los ciclos, y lo que queda son historias causales alternativas que todas se ajustan a la misma estructura de independencia. Aunque las flechas ahora apuntan en una dirección u otra en cada subgráfico, estos DAGs siguen siendo miembros de la misma clase de equivalencia de Markov (MEC) — comparten el mismo esqueleto y el mismo conjunto de colisionadores no protegidos (estructuras v), y por lo tanto implican las mismas d-separaciones. En otras palabras, para cualquier triple \((X,Y,Z)\) y cualquier conjunto de condicionamiento \(S\), tenemos \(X \perp Y \mid S\) en un DAG si y solo si se sostiene en todos los DAGs del panel.
Aquí podemos hacer dos cosas: (A) preguntar si cada uno de los DAGs recuperados se encuentra en el mismo MEC que la verdadera estructura, o (B) preguntar si el CPDAG que descubrimos representa esa misma clase de equivalencia. En problemas de marketing del mundo real, no podemos realizar ninguna de las dos verificaciones: el “verdadero DAG” es desconocido. Esa es precisamente la razón por la que el descubrimiento causal es útil: reduce el espacio de mundos causales plausibles y hace explícito dónde permanece la incertidumbre. La decisión de en qué DAG confiar debe provenir entonces de una combinación de evidencia de datos y juicio del dominio — lo que usted cree que es causalmente razonable dado cómo funcionan realmente su negocio y sus campañas.
# check how many of the dags are in the same markov equivalence class as the true dag
mec_v0 = 0
for dag_text in all_dags_v0:
mec_v1 = same_markov_equivalence_class_CPdag(true_dag.source, dag_text)
if mec_v1:
mec_v0 += 1
print(f"Number of DAGs in the Markov equivalence class: {mec_v0}")
Number of DAGs in the Markov equivalence class: 6
mec_v = same_markov_equivalence_class_CPdag(true_dag.source, digraph_text)
print(
f"Discovered DAG is in the same markov equivalence class as the true DAG: {mec_v}"
)
Discovered DAG is in the same markov equivalence class as the true DAG: True
Aquí, podemos ver que la clase pudo recuperar el True DAG y todos los DAGs causales del CPDAG, que se encuentran en el mismo MEC. Pasemos a otro ejemplo y veamos cómo se desempeña la clase si cambiamos el proceso de generación de datos (DAG bajo el capó).
TEXT:
Hagamos las cosas un poco más interesantes. El siguiente ejemplo representa un nuevo mundo causal — un mecanismo de marketing ligeramente diferente que desafiará nuestro procedimiento de descubrimiento. Esta vez, imagina que la búsqueda genérica (A) impulsa las actividades de medios (B), que a su vez aumentan la búsqueda de marca (C), llevando finalmente a las compras (P). Junto a este camino principal, un factor exógeno (D) — piénsalo como la economía en general, las tendencias del mercado o la estacionalidad — también influye directamente en las compras, de manera independiente del embudo de marketing.
# Define the true DAG structure for our synthetic data
true_dag_v2 = Digraph(comment="True Causal DAG")
true_dag_v2.attr(rankdir="LR")
true_dag_v2.node("A", "generic_search")
true_dag_v2.node("B", "media_activities")
true_dag_v2.node("C", "brand_search")
true_dag_v2.node("D", "exogenous")
true_dag_v2.node("P", "purchase", fillcolor="lightgrey", style="filled")
# edges
true_dag_v2.edge("A", "B") # A influences B
true_dag_v2.edge("B", "C") # B influences C
true_dag_v2.edge("C", "P") # C influences P
true_dag_v2.edge("D", "P") # D influences P
display(SVG(true_dag_v2.pipe(format="svg")))
Esta estructura cuenta una historia de marketing familiar: la conciencia y la consideración fluyen a través de un embudo (desde la intención de búsqueda hasta la exposición mediática, el aumento de la marca y las ventas), mientras que fuerzas externas como las condiciones macroeconómicas añaden variación de fondo que no podemos controlar completamente. Es un sistema elegante pero sutil, porque ahora dos caminos causales se encuentran en el nodo de compra: uno endógeno (impulsado por el marketing) y uno exógeno (impulsado por el contexto). Detectar esa distinción es exactamente el tipo de desafío que el descubrimiento causal está destinado a abordar.
Nota
Todos los ejemplos en este cuaderno son sintéticos. Están diseñados intencionadamente para poner a prueba el algoritmo y ilustrar diferentes motivos causales — algunos plausibles en marketing, otros menos. El objetivo no es afirmar que estos gráficos son «verdaderos», sino mostrar cómo se comporta la clase TBFPC bajo estructuras variadas, y qué tipo de conocimientos (o trampas) puede revelar cada configuración.
Como antes, necesitamos generar datos con esta estructura generativa.
a = pz.LogNormal(0, 1).rvs(n_observations, random_state=rng)
b = pz.LogNormal(0.7, 0.24).rvs(n_observations, random_state=rng) + (a * 0.3)
c = pz.LogNormal(0.43, 0.22).rvs(n_observations, random_state=rng) + (b * 0.2)
d = pz.LogNormal(0, 1).rvs(n_observations, random_state=rng)
p = (
pz.LogNormal(0.21, 0.22).rvs(n_observations, random_state=rng)
+ (c * 0.2)
+ (d * 0.2)
)
second_dag_dataset = pd.DataFrame(
{
"generic_search": a,
"media_activities": b,
"brand_search": c,
"exogenous": d,
"purchase": p,
}
)
second_dag_dataset.head()
| generic_search | media_activities | brand_search | exogenous | purchase | |
|---|---|---|---|---|---|
| 0 | 1.433902 | 3.226875 | 2.513919 | 3.787091 | 2.738195 |
| 1 | 0.323365 | 2.113860 | 2.014727 | 1.310316 | 1.710342 |
| 2 | 1.724658 | 2.676359 | 1.716047 | 0.520260 | 1.720063 |
| 3 | 0.962705 | 2.477789 | 2.167171 | 1.530894 | 1.932865 |
| 4 | 0.269313 | 1.922700 | 2.271828 | 0.731237 | 1.868330 |
¿Podríamos averiguar el verdadero DAG ahora?
model2 = TBFPC(target="purchase")
model2.fit(
df=second_dag_dataset,
drivers=second_dag_dataset.drop(columns=["purchase"]).columns.tolist(),
)
digraph_text_v2 = model2.to_digraph()
discovered_dag_v2 = Source(digraph_text_v2)
mec_v2 = same_markov_equivalence_class_CPdag(true_dag_v2.source, digraph_text_v2)
print(
f"Discovered DAG is in the same markov equivalence class as the true DAG: {mec_v2}"
)
display(SVG(discovered_dag_v2.pipe(format="svg")))
Discovered DAG is in the same markov equivalence class as the true DAG: True
Hermoso — la clase recuperó con éxito la verdadera estructura causal de los datos, lo que significa que el CPDAG que descubrió se encuentra en la misma clase de equivalencia de Markov que nuestro DAG simulado. En otras palabras, el algoritmo identificó correctamente la columna vertebral del sistema causal: el flujo secuencial de búsqueda genérica → medios → marca → compra, además de la influencia independiente del factor exógeno en las compras.
Este resultado destaca la eficiencia y fiabilidad del enfoque TBFPC para problemas pequeños y bien estructurados. Incluso sin acceso al verdadero DAG subyacente, el algoritmo puede reconstruir un esqueleto causal consistente solo a partir de datos observacionales, lo que indica fuertemente que la combinación de pruebas de independencia basadas en el factor de Bayes y reglas de borde orientadas a objetivos está funcionando como se esperaba.
Por supuesto, estos siguen siendo sistemas sintéticos simples — limpios, controlados en ruido y diseñados para comportarse bien. Los datos de marketing del mundo real son más desordenados: las dependencias temporales, los errores de medición, los efectos latentes y las intervenciones correlacionadas complican la inferencia causal. Así que sigamos aumentando la dificultad y veamos cómo se desempeña el algoritmo cuando la estructura se vuelve menos ideal y más cercana a lo que enfrentamos en la práctica.
TEXT:
Elevemos el estándar nuevamente — esta vez con un sistema causal más complejo y realista. Imagina un ecosistema de marketing donde la búsqueda genérica (A) actúa como una señal de embudo superior: alimenta tanto las actividades de medios (B) como la búsqueda de marca (C), ya que los consumidores que ya están buscando de manera genérica se vuelven más propensos a ver anuncios y luego realizar consultas de marca. A su vez, los medios también influyen en la búsqueda de marca, reforzando la conciencia y familiaridad de la marca. Finalmente, las compras (P) son impulsadas conjuntamente por los tres — la exposición a los medios, la fortaleza de la marca y la influencia de un factor exógeno (D), que podría representar condiciones macroeconómicas, promociones o actividad de competidores.
# Define the true DAG structure for our synthetic data
true_dag_v3 = Digraph(comment="True Causal DAG")
true_dag_v3.attr(rankdir="LR")
true_dag_v3.node("A", "generic_search")
true_dag_v3.node("B", "media_activities")
true_dag_v3.node("C", "brand_search")
true_dag_v3.node("D", "exogenous")
true_dag_v3.node("P", "purchase", fillcolor="lightgrey", style="filled")
# edges
true_dag_v3.edge("B", "P") # B influences P
true_dag_v3.edge("C", "P") # C influences P
true_dag_v3.edge("D", "P") # D influences P
true_dag_v3.edge("A", "B") # A influences B
true_dag_v3.edge("A", "C") # A influences C
true_dag_v3.edge("B", "C") # B influences C
a = pz.LogNormal(0, 1).rvs(n_observations, random_state=rng)
d = pz.LogNormal(0, 1).rvs(n_observations, random_state=rng)
b = pz.LogNormal(0.7, 0.24).rvs(n_observations, random_state=rng) + (a * 0.3)
c = (
pz.LogNormal(0.43, 0.22).rvs(n_observations, random_state=rng)
+ (a * 0.2)
+ (b * 0.2)
)
p = (
pz.LogNormal(0.21, 0.22).rvs(n_observations, random_state=rng)
+ (b * 0.2)
+ (c * 0.2)
+ (d * 0.2)
+ pz.LogNormal(0.01, 0.01).rvs(n_observations, random_state=rng)
)
third_dag_dataset = pd.DataFrame(
{
"generic_search": a,
"media_activities": b,
"brand_search": c,
"exogenous": d,
"purchase": p,
}
)
display(SVG(true_dag_v3.pipe(format="svg")))
Esta configuración está mucho más cerca de la realidad que enfrentan los analistas de marketing: efectos superpuestos, múltiples caminos hacia el mismo KPI y influencias no observadas que acechan en el fondo. Observe que ahora tanto los medios como la búsqueda de marca son hijos de la intención genérica (A) y padres de la compra (P). Esto crea una red de efectos mediáticos y confundidos, que son notoriamente difíciles de desenredar con enfoques de regresión estándar, incluso si todas las variables son observadas.
model3 = TBFPC(target="purchase")
model3.fit(
df=third_dag_dataset,
drivers=third_dag_dataset.drop(columns=["purchase"]).columns.tolist(),
)
digraph_text_v3 = model3.to_digraph()
discovered_dag_v3 = Source(digraph_text_v3)
mec_v3 = same_markov_equivalence_class_CPdag(true_dag_v3.source, digraph_text_v3)
print(
f"Discovered DAG is in the same markov equivalence class as the true DAG: {mec_v3}"
)
display(SVG(discovered_dag_v3.pipe(format="svg")))
Discovered DAG is in the same markov equivalence class as the true DAG: True
Hasta ahora, nuestras tres configuraciones han mostrado los clásicos motivos causales que importan en marketing: cadenas (cascadas de impulso como generic_search → media → brand → purchase), bifurcaciones (causas compartidas como exogenous → {purchase, other signals} que crean confusión), y colisionadores (flechas convergentes como media → brand ← intent, que permanecen bloqueadas a menos que se condicione sobre ellas o sus descendientes).
En cada caso, TBFPC manejó lo esencial: podó el esqueleto donde existían independencias condicionales, orientó los bordes driver → target cuando la evidencia de Bayes los respaldaba, y dejó los enlaces ambiguos como discontinuos (el CPDAG), reflejando fielmente el MEC.
Ahora observe la siguiente estructura, que es más complicada:
D → A,A → {B, C},B → C, yD → C, con{B, C} → P.Aquí, C es un colisionador de múltiples padres (
A → C ← D) y también es un hijo deB.Ddesempeña un papel de división/confusor tanto paraAcomo paraC.Rutas como
D → A → B → Ccoexisten con la directaD → C, creando rutas redundantes y correlaciones estrechas entre los predictores.
¿Por qué podría TBFPC tener dificultades en el primer intento?
Apertura de colisionador durante las pruebas. Debido a que probamos muchos conjuntos de condicionamiento, incluir
C(o sus descendientes) en un conjunto de condicionamiento puede abrir caminos (por ejemplo, entreAyD) y hacer que las variables parezcan dependientes, bloqueando las eliminaciones de aristas que deberían ocurrir. El PC clásico mitiga esto con fases de orientación y reglas de colisionador; nuestra clase intencionalmente no aplica las reglas de Meek, por lo que es más fácil mantener aristas adicionales en el esqueleto cuando están involucrados colisionadores.Caminos redundantes y casi infidelidades. Múltiples rutas paralelas hacia
C(a través deA,ByD) pueden producir fuertes colinealidades. Con muestras finitas y ruido, las pruebas de CI ΔBIC pueden carecer de potencia para encontrar un conjunto separador (especialmente cuando las señales se cancelan o refuerzan parcialmente), por lo que los bordes permanecen.Orientación solo hacia el objetivo. Solo sesgamos las flechas hacia el objetivo; las orientaciones entre los controladores (
A, B, C, D) son deliberadamente conservadoras. Esto preserva la corrección a costa de dejar más enlaces discontinuos y, a veces, un esqueleto sobrecargado en subgrafos de controladores complejos.
La buena noticia: podemos guiar el procedimiento utilizando funciones integradas. En el siguiente paso, (i) codificaremos los priors del dominio con forbidden_edges (por ejemplo, «las compras no causan medios»), (ii) bloquearemos mecanismos esenciales con required_edges, y (iii) ajustaremos cómo tratamos los bordes objetivo a través de la target_edge_rule (por ejemplo, "fullS" para una retención más estricta). Estas palancas restringen el espacio de búsqueda, protegen las estructuras de colisionadores de ser inadvertidamente «abiertas» durante las pruebas, y ayudan al algoritmo a converger a un CPDAG más escaso y fiel que coincida con la historia de marketing en la que realmente crees.
# Define the true DAG structure for our synthetic data
true_dag_v4 = Digraph(comment="True Causal DAG")
true_dag_v4.attr(rankdir="LR")
true_dag_v4.node("A", "generic_search")
true_dag_v4.node("B", "media_activities")
true_dag_v4.node("C", "brand_search")
true_dag_v4.node("D", "exogenous")
true_dag_v4.node("P", "purchase", fillcolor="lightgrey", style="filled")
# edges
true_dag_v4.edge("A", "B") # A influences B
true_dag_v4.edge("D", "A") # D influences A
true_dag_v4.edge("D", "C") # D influences C
true_dag_v4.edge("C", "P") # C influences P
true_dag_v4.edge("B", "P") # B influences P
true_dag_v4.edge("A", "C") # A influences C
true_dag_v4.edge("B", "C") # B influences C
d = pz.LogNormal(0, 1).rvs(n_observations, random_state=rng)
a = pz.LogNormal(0, 1).rvs(n_observations, random_state=rng) + (d * 0.3)
b = pz.LogNormal(0.7, 0.24).rvs(n_observations, random_state=rng) + (a * 0.3)
c = (
pz.LogNormal(0.43, 0.22).rvs(n_observations, random_state=rng)
+ (d * 0.2)
+ (a * 0.1)
+ (b * 0.1)
)
p = (
pz.LogNormal(0.21, 0.22).rvs(n_observations, random_state=rng)
+ (b * 0.2)
+ (c * 0.2)
)
fourth_dag_dataset = pd.DataFrame(
{
"generic_search": a,
"media_activities": b,
"brand_search": c,
"exogenous": d,
"purchase": p,
}
)
display(SVG(true_dag_v4.pipe(format="svg")))
model4 = TBFPC(target="purchase")
model4.fit(
df=fourth_dag_dataset,
drivers=fourth_dag_dataset.drop(columns=["purchase"]).columns.tolist(),
)
digraph_text_v4 = model4.to_digraph()
discovered_dag_v4 = Source(digraph_text_v4)
mec_v4 = same_markov_equivalence_class_CPdag(true_dag_v4.source, digraph_text_v4)
print(
f"Discovered DAG is in the same markov equivalence class as the true DAG: {mec_v4}"
)
display(SVG(discovered_dag_v4.pipe(format="svg")))
Discovered DAG is in the same markov equivalence class as the true DAG: False
Qué salió mal (y por qué): El procedimiento orientó correctamente los bordes objetivo — brand_search → purchase y media_activities → purchase — pero suborientó (dejó en guion) varias relaciones conductor–conductor e incluso eliminó una adyacencia que existe en la estructura verdadera:
Falta de borde: no hay ningún enlace
media_activities — brand_searchen el CPDAG aprendido, a pesar de que el verdadero DAG tienemedia_activities → brand_search. Esto es un falso negativo en el esqueleto.Aristas no dirigidas (discontinuas) donde el verdadero DAG es dirigido:
exógeno — búsqueda_genérica
búsqueda_genérica — actividades_media(verdadero:búsqueda_genérica → actividades_media)msgid
generic_search — brand_search(true:generic_search → brand_search)
msgstrbúsqueda_genérica — búsqueda_de_marca(verdadero:búsqueda_genérica → búsqueda_de_marca)exógeno — búsqueda_de_marca
¿Por qué puede suceder esto? (i) Manejo de colisionadores: condicionar sobre (o sobre descendientes de) un colisionador puede crear dependencia espuria, impidiendo la eliminación de bordes que esperarías; no aplicamos las reglas de Meek, por lo que mantenemos más ambigüedad. (ii) Caminos redundantes y colinealidad: con rutas como exogenous → generic_search → media_activities → brand_search coexistiendo con exogenous → brand_search directo, las pruebas de ΔBIC pueden tener dificultades para encontrar conjuntos separadores en muestras finitas, por lo que los bordes quedan sin dirección o podados incorrectamente. (iii) Sesgo de objetivo primero: las orientaciones entre los impulsores son conservadoras por diseño; priorizamos flechas confiables de driver → target y dejamos sin resolver las direcciones de conductor a conductor a menos que la evidencia sea fuerte.
Cómo guiar el algoritmo con conocimiento del dominio:#
El marketing nos proporciona priors sólidos y defendibles. Úselos para restringir el espacio de búsqueda:
Elegir la regla de borde objetivo:#
«cualquier» (poda más agresiva): mantener X → objetivo a menos que cualquier conjunto de condiciones haga que \(X \perp \text{target} \mid S\). Pros: preciso, recorta enlaces de objetivo espurios. Contras: puede eliminar verdaderos impulsores cuando las señales son débiles o casi infieles (mayores falsos negativos).
«conservador» (más protector): mantener X → objetivo si al menos un conjunto de condicionamiento muestra dependencia. Pros: mejor recuerdo de los verdaderos impulsores (menos falsos negativos); robusto cuando los caminos son redundantes. Contras: puede mantener algunos enlaces espurios (gráfico más denso).
«fullS» (estabilidad a través de la condicionamiento máximo): prueba solo con el conjunto completo de otros controladores como \(S\). Pros: menos elecciones arbitrarias de \(S\); comportamiento estable. Contras: riesgos de sobrecontrol (bloqueo de mediadores) o apertura de colisionadores si los descendientes están en \(S\); puede suborientar.
model4 = TBFPC(
target="purchase", target_edge_rule="conservative"
) # changing to "conservative"
model4.fit(
df=fourth_dag_dataset,
drivers=fourth_dag_dataset.drop(columns=["purchase"]).columns.tolist(),
)
digraph_text_v4 = model4.to_digraph()
discovered_dag_v4 = Source(digraph_text_v4)
mec_v4 = same_markov_equivalence_class_CPdag(true_dag_v4.source, digraph_text_v4)
print(
f"Discovered DAG is in the same markov equivalence class as the true DAG: {mec_v4}"
)
display(SVG(discovered_dag_v4.pipe(format="svg")))
Discovered DAG is in the same markov equivalence class as the true DAG: False
Cambiar target_edge_rule de "any" a "conservative" cambia cómo se retienen las aristas hacia purchase. Con "any", una arista como media_activities → purchase (o brand_search → purchase) es eliminada si existe cualquier conjunto de condicionamiento S tal que los datos apoyan la independencia entre ese impulsor y purchase dado S (poda agresiva). Con "conservative", la arista es mantenida si al menos uno de los conjuntos de condicionamiento muestra dependencia (retención protectora). En otras palabras:
«cualquiera»: eliminar
driver → purchasesi existeScon \(\log \mathrm{BF}_{10}(\text{driver} \to \text{purchase} \mid S) < \tau\).«conservative»: mantener
driver → purchasesi existeScon \(\log \mathrm{BF}_{10}(\text{driver} \to \text{purchase} \mid S) \ge \tau\).
En nuestro nuevo CPDAG, los bordes objetivo permanecen orientados — brand_search → purchase y media_activities → purchase — que es exactamente lo que "conservative" fomenta: hay al menos un conjunto de condicionamiento donde cada impulsor aún muestra dependencia con purchase. Las relaciones impulsor–impulsor permanecen discontinuas (no dirigidas):
brand_search — exógeno
búsqueda_marca — búsqueda_genérica
exógeno — búsqueda_genérica
búsqueda_genérica — actividades_media
Estos permanecen no dirigidos porque la regla de arista objetivo no orienta aristas entre controladores. Sus direcciones son equivalentes de Markov dadas las independencias observadas, por lo que el algoritmo las deja sin resolver.
¿Qué tan consistente es esto con el verdadero DAG? Está más cerca del lado objetivo (retendremos las verdaderas causas brand_search y media_activities de purchase), pero aún está suborientado aguas arriba. La verdadera estructura favorece direcciones como exogenous → generic_search, generic_search → media_activities y caminos hacia brand_search (generic_search → brand_search, media_activities → brand_search).
Es hora de aportar conocimiento general.
Conocimiento externo del ecosistema:#
Rara vez es sensato que la búsqueda de marca cause condiciones exógenas o intención genérica; su experiencia favorece fuertemente lo contrario. Codificar eso como direcciones requeridas arriba refleja el mecanismo en el que realmente cree.
Es probable que espere generic_search → media_activities y algún camino de medios a marca (efectos creativos/de concienciación), así que impulse media_activities → brand_search en lugar de dejar que desaparezca.
¡Utilicemos esta información ahora!
model4 = TBFPC(
target="purchase",
target_edge_rule="conservative",
forbidden_edges=[("generic_search", "purchase"), ("exogenous", "purchase")],
required_edges=[
("media_activities", "brand_search"),
("exogenous", "brand_search"),
],
)
model4.fit(
df=fourth_dag_dataset,
drivers=fourth_dag_dataset.drop(columns=["purchase"]).columns.tolist(),
)
digraph_text_v4 = model4.to_digraph()
discovered_dag_v4 = Source(digraph_text_v4)
mec_v4 = same_markov_equivalence_class_CPdag(true_dag_v4.source, digraph_text_v4)
print(
f"Discovered DAG is in the same markov equivalence class as the true DAG: {mec_v4}"
)
display(SVG(discovered_dag_v4.pipe(format="svg")))
Discovered DAG is in the same markov equivalence class as the true DAG: True
Éxito: con una pequeña dosis de conocimiento del dominio hemos orientado el descubrimiento hacia la verdadera estructura. Al prohibir enlaces implausibles en purchase (generic_search, exogenous) y requerir mecanismos centrales (media_activities → brand_search, exogenous → brand_search), limitamos el espacio de búsqueda para que las pruebas CI del factor Bayesiano pudieran centrarse en lo que es causalmente plausible. Este es el flujo de trabajo previsto: comenzar desde un CPDAG aprendido a partir de datos, luego inyectar de manera iterativa prioris (bordes prohibidos/requeridos, regla de borde objetivo) para refinar el gráfico en una historia que sea tanto estadísticamente respaldada como sensata desde el punto de vista empresarial.
Como siempre, recuerde que un CPDAG representa una clase de equivalencia de Markov. Incluso cuando recuperamos la estructura verdadera, puede haber otros DAG que se ajusten a las mismas independencias condicionales. Si desea auditar ese espacio, enumere todos los DAG consistentes (por ejemplo, get_all_cdags_from_cpdag) y verifíquelos contra el conocimiento del mecanismo y los diagnósticos de validación. El objetivo no es un dogma sobre un solo gráfico, sino una narrativa causal confiable que sobreviva tanto a los datos como a su comprensión de cómo funciona realmente el marketing.
all_dags_v4 = model4.get_all_cdags_from_cpdag(digraph_text_v4)
# Create a figure with subplots for each DAG
fig, axes = plt.subplots(1, len(all_dags_v4), figsize=(5 * len(all_dags_v4), 5))
# If there's only one DAG, make axes a list for consistency
if len(all_dags_v4) == 1:
axes = [axes]
# Create temporary directory for storing images
temp_dir = tempfile.mkdtemp()
try:
# Render and plot each DAG
for i, dag_dot in enumerate(all_dags_v4):
dag_source = Source(dag_dot)
# Create temporary file path
temp_file = os.path.join(temp_dir, f"dag_{i}")
# Render to PNG
dag_source.render(format="png", filename=temp_file, cleanup=True)
# Load and display the image
axes[i].imshow(mpimg.imread(f"{temp_file}.png"))
axes[i].set_title(f"DAG {i + 1}", fontsize=12)
axes[i].axis("off")
# Clean up the temporary PNG file
if os.path.exists(f"{temp_file}.png"):
os.remove(f"{temp_file}.png")
# Add main title
plt.suptitle("Markov Equivalence Class - All Possible DAGs (V4)", fontsize=16)
plt.tight_layout()
plt.show()
finally:
# Clean up temporary directory
if os.path.exists(temp_dir):
os.rmdir(temp_dir)
Hemos sometido a prueba TBFPC en mundos de juguete ordenados y transversales. Ahora viene la verdadera bestia: series temporales. Los datos de marketing llegan como secuencias — presupuestos, impresiones, búsquedas, conversiones — con autocorrelación, estacionalidad y retroalimentación de políticas (el rendimiento de ayer moldea el gasto de hoy). En tales entornos, las simples independencias condicionales «instantáneas» pueden llevar a engaños: la correlación serial reduce el tamaño de la muestra efectiva, la no estacionariedad hace que las relaciones se desplacen, y los rezagos crean caminos mediados a lo largo del tiempo (por ejemplo, media_{t-2} → brand_{t-1} → sales_t). Aún peor, condicionar ciertos rezagos puede abrir colisionadores, mientras que no incluirlos puede dejar puertas traseras sin bloquear.
¿Por qué es más difícil el descubrimiento causal en el tiempo? Primero, muchos DAG que difieren en direcciones instantáneas son equivalentes de Markov una vez que se añade una fuerte dependencia serial; sus d-separaciones lucen iguales después de incluir tendencias comunes/controladores estacionales. Segundo, el retroalimentación (sales_{t-1} → spend_t) viola la aciclicidad del DAG si se modela en un único índice temporal; la “flecha del tiempo” debe ser impuesta a través de los rezagos. Tercero, la agregación y los retrasos en la medición difuminan el tiempo, por lo que \(X_t\) puede parecer contemporáneamente relacionado con \(Y_t\) incluso si el verdadero efecto es \(X_{t-1} \to Y_t\). La predictibilidad de Granger no es causalidad; es necesaria pero está lejos de ser suficiente.
Capturando series temporales de CPDAGs#
Antes de sumergirnos en gráficos causales dependientes del tiempo, necesitamos una forma de simular series temporales de marketing realistas — datos que se mueven gradualmente, no que saltan aleatoriamente. Para eso, utilizaremos una utilidad simple pero poderosa: un paseo aleatorio acotado.
La función a continuación, random_walk, genera una secuencia de valores que evolucionan paso a paso con pequeños incrementos estocásticos alrededor de una media objetivo (mu) y una desviación estándar (sigma). También puede especificar límites inferiores y superiores, asegurando que los valores se mantengan dentro de un rango realista — por ejemplo, entre 0 y 1 para el gasto publicitario normalizado, o dentro de un rango porcentual limitado para el interés de búsqueda de la marca. El resultado es una trayectoria suave y autocorrelacionada que imita cómo se comportan las señales como el gasto, las impresiones, el volumen de búsqueda, las conversiones, etc. a lo largo del tiempo.
Este tipo de estocasticidad controlada es crucial para nuestros próximos experimentos: nos proporciona entradas correlacionadas en el tiempo que crean dependencias temporales entre variables (por ejemplo, media_{t-1} → brand_{t} → sales_{t+1}) sin hacer explotar la varianza ni generar oscilaciones irreales. En otras palabras, este paseo aleatorio nos ayuda a simular la inercia y la memoria inherentes a los procesos de marketing — las mismas características que hacen que el descubrimiento causal de series temporales sea tanto fascinante como desafiante.
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
¡Definamos una estructura DAG simple!
# Create the true DAG for the time series data
true_dag_v5 = """
digraph {
generic_search -> purchase;
media_activities -> purchase;
brand_search -> purchase;
exogenous -> purchase;
}
"""
true_dag_v5 = Source(true_dag_v5)
display(SVG(true_dag_v5.pipe(format="svg")))
¡Pero ahora, populamos con series temporales cada uno de los factores para la variable objetivo!
x1 = random_walk(mu=0.41, sigma=0.21, steps=n_observations, lower=0, upper=1, seed=1)
x2 = random_walk(mu=0.1, sigma=0.01, steps=n_observations, lower=0, upper=1, seed=10)
x3 = random_walk(mu=0.2, sigma=0.05, steps=n_observations, lower=0, upper=1, seed=100)
x4 = random_walk(mu=0.05, sigma=0.05, steps=n_observations, lower=0, upper=1, seed=1000)
y = (
(x1 * 0.3)
+ (x2 * 0.4)
+ (x3 * 0.3)
+ (x4 * 0.1)
+ pz.Normal(0, 0.1).rvs(n_observations, random_state=rng)
)
timeseries_data = pd.DataFrame(
{
"generic_search": x1,
"media_activities": x2,
"brand_search": x3,
"exogenous": x4,
"purchase": y,
}
)
fig, axs = plt.subplots(2, 2, figsize=(10, 8))
axs[0, 0].plot(x1, color="blue")
axs[0, 0].set_title("generic_search")
axs[0, 1].plot(x2, color="red")
axs[0, 1].set_title("media_activities")
axs[1, 0].plot(x3, color="green")
axs[1, 0].set_title("brand_search")
axs[1, 1].plot(x4, color="orange")
axs[1, 1].set_title("exogenous")
plt.show()
¿Qué tiene que decir nuestro modelo? ¿Podría encontrar la estructura adecuada?
model5 = TBFPC(
target="purchase",
target_edge_rule="conservative",
)
model5.fit(
df=timeseries_data,
drivers=timeseries_data.drop(columns=["purchase"]).columns.tolist(),
)
digraph_text_v5 = model5.to_digraph()
discovered_dag_v5 = Source(digraph_text_v5)
mec_v5 = same_markov_equivalence_class_CPdag(true_dag_v5.source, digraph_text_v5)
print(
f"Discovered DAG is in the same markov equivalence class as the true DAG: {mec_v5}"
)
display(SVG(discovered_dag_v5.pipe(format="svg")))
Discovered DAG is in the same markov equivalence class as the true DAG: False
¿Por qué falló este simple caso de series temporales? A pesar de que el gráfico verdadero es contemporáneo (generic_search, media_activities, brand_search, exogenous apuntan a purchase en el mismo índice de tiempo), los datos generados son fuertemente autocorrelacionados. La autocorrelación hace que los predictores se muevan juntos durante largos períodos, creando casi colinealidad y asociaciones contemporáneas espurias. En ese contexto, las pruebas de independencia condicional sobre \(Y_t \sim \{X_{1,t},X_{2,t},X_{3,t},X_{4,t}\}\) no pueden determinar si una señal es un verdadero impulsor instantáneo o simplemente un proxy para valores pasados (por ejemplo, \(X_{1,t-1}\)) o una tendencia compartida. Peor aún, el factor Bayesiano ΔBIC utiliza \(n\) en su penalización; cuando las series son autocorrelacionadas, el tamaño de muestra efectivo es mucho menor, por lo que la evidencia está sobrestimada/subestimada de maneras que pueden cambiar decisiones marginales cerca del umbral.
En resumen: con series temporales, el gráfico correcto rara vez es “plano y contemporáneo.” Los mecanismos reales están rezagados (carryover/adstock), y la estructura temporal compartida (tendencia/estacionalidad) actúa como confusión latente. Si omitimos rezagos y controles temporales, el CPDAG (i) mantendrá enlaces adicionales discontinuos entre los impulsores, (ii) perderá verdaderos bordes (bajo poder después de parcializar series correlacionadas), o (iii) orientará bordes objetivo de manera inconsistente dependiendo de qué conjuntos de condicionamiento absorban más autocorrelación.
¿Cómo manejarlo?
Agregar rezagos de un período (x1_t1, x2_t1, …) aísla la persistencia temporal dentro de la variable—el propio impulso de cada serie—mientras que prohibir los bordes de rezago cruzado previene vínculos espurios entre los impulsores que simplemente se mueven juntos a través de tendencias compartidas o autocorrelación. En la configuración contemporánea original, predictores como brand_search y media_activities subieron y bajaron juntos, por lo que las pruebas de independencia condicional no podían determinar si un efecto observado en purchase era causal o simplemente heredado de su deriva temporal conjunta.
TEXT:
Introducir el retraso de cada variable permite al modelo descomponer esa memoria interna: el retraso explica la mayor parte del componente lento, dejando el residual (innovación) más cerca de la señal causal instantánea. Al prohibir los bordes entre las características retrasadas, aseguramos que estos retrasos actúen solo como autocontroles, no como nuevos confundidores entre canales. Esta configuración simple desestacionaliza y desautocorrela efectivamente los impulsores, estabilizando las pruebas de factores de Bayes y aclarando qué bordes contemporáneos hacia purchase permanecen después de tener en cuenta el propio pasado de cada serie. Por lo tanto, es una estrategia mínima sólida para el descubrimiento causal en series temporales: ligera, interpretable y proporcionando una primera aproximación a la estructura dinámica antes de introducir redes de retraso más ricas o términos estacionales.
timeseries_data["x1_t2"] = timeseries_data["generic_search"].shift(2).fillna(0)
timeseries_data["x2_t2"] = timeseries_data["media_activities"].shift(2).fillna(0)
timeseries_data["x3_t2"] = timeseries_data["brand_search"].shift(2).fillna(0)
timeseries_data["x4_t2"] = timeseries_data["exogenous"].shift(2).fillna(0)
# timeseries_data.info()
model5 = TBFPC(
target="purchase",
target_edge_rule="conservative",
forbidden_edges=[
("x1_t2", "purchase"),
("x2_t2", "purchase"),
("x3_t2", "purchase"),
("x4_t2", "purchase"),
######
("x1_t2", "x2_t2"),
("x1_t2", "x3_t2"),
("x1_t2", "x4_t2"),
("x2_t2", "x3_t2"),
("x2_t2", "x4_t2"),
("x3_t2", "x4_t2"),
],
)
model5.fit(
df=timeseries_data,
drivers=timeseries_data.drop(columns=["purchase"]).columns.tolist(),
)
digraph_text_v5 = model5.to_digraph()
discovered_dag_v5 = Source(digraph_text_v5)
mec_v5 = same_markov_equivalence_class_CPdag(true_dag_v5.source, digraph_text_v5)
print(
f"Discovered DAG is in the same markov equivalence class as the true DAG: {mec_v5}"
)
display(SVG(discovered_dag_v5.pipe(format="svg")))
Discovered DAG is in the same markov equivalence class as the true DAG: False
Brillante: el nuevo DAG captura exactamente la estructura dinámica que estábamos buscando.
Después de introducir los rezagos de dos períodos, el algoritmo recuperó correctamente que los cuatro impulsores contemporáneos (generic_search, media_activities, brand_search y exogenous) influyen directamente en purchase, mientras que las variables rezagadas aparecen solo como sus continuaciones temporales naturales (generic_search → x1_t2, media_activities → x2_t2, etc.). La presencia de estos enlaces de auto-rezagado en línea discontinua simplemente refleja la propia persistencia de cada serie, no nuevos caminos causales.
Si eliminamos conceptualmente los nodos de rezago—tratándolos como controles de fondo que absorben la autocorrelación—el gráfico contemporáneo restante entre los principales impulsores y purchase coincide casi perfectamente con la verdadera estructura causal. En otras palabras, al agregar una estructura temporal mínima, obligamos al modelo a explicar la autocorrelación a través del propio pasado de cada variable, lo que liberó el paso de descubrimiento para revelar el genuino patrón causal contemporáneo que originalmente codificamos. Esta es una confirmación clara e interpretable de que el enfoque aumentado por rezago acerca el DAG recuperado mucho más a la verdad subyacente.
# Create a copy of the DAG and remove all nodes and edges with '_t1'
dag_text_no_lags = digraph_text_v5
# Remove both edges and node declarations that involve _t1
lines = dag_text_no_lags.split("\n")
filtered_lines = []
for line in lines:
if "_t2" in line:
continue # Skip any line that mentions _t1 nodes
filtered_lines.append(line)
dag_text_no_lags = "\n".join(filtered_lines)
discovered_dag_no_lags = Source(dag_text_no_lags)
mec_v5_no_lags = same_markov_equivalence_class_CPdag(
true_dag_v5.source, discovered_dag_no_lags
)
print(
f"Discovered DAG is in the same markov equivalence class as the true DAG: {mec_v5_no_lags}"
)
display(SVG(discovered_dag_no_lags.pipe(format="svg")))
Discovered DAG is in the same markov equivalence class as the true DAG: True
¿Cómo funciona esto para estructuras más largas? ¡Sigamos probando!
x1 = random_walk(mu=0.41, sigma=0.21, steps=n_observations, lower=0, upper=1, seed=1)
x2 = random_walk(
mu=0.1, sigma=0.01, steps=n_observations, lower=0, upper=1, seed=10
) + (x1 * 0.3)
x3 = (
random_walk(mu=0.2, sigma=0.05, steps=n_observations, lower=0, upper=1, seed=100)
+ (x2 * 0.2)
+ (x1 * 0.4)
)
x4 = random_walk(mu=0.05, sigma=0.05, steps=n_observations, lower=0, upper=1, seed=1000)
y = (
(x2 * 0.4)
+ (x3 * 0.3)
+ (x4 * 0.1)
+ pz.Normal(0, 0.1).rvs(n_observations, random_state=rng)
)
timeseries_data = pd.DataFrame(
{
"generic_search": x1,
"media_activities": x2,
"brand_search": x3,
"exogenous": x4,
"purchase": y,
}
)
# Create the true DAG for the time series data
true_dag_v6 = """
digraph {
rankdir=LR
# nodes (optional—you can omit these and let Graphviz infer them)
generic_search
media_activities
brand_search
exogenous
purchase [fillcolor="lightgrey" style="filled"]
# edges matching your DGP
generic_search -> media_activities # x1 → x2
generic_search -> brand_search # x1 → x3
media_activities -> brand_search # x2 → x3
media_activities -> purchase # x2 → y
brand_search -> purchase # x3 → y
exogenous -> purchase # x4 → y
}
"""
true_dag_v6 = Source(true_dag_v6)
display(SVG(true_dag_v6.pipe(format="svg")))
Esta estructura es similar a la exposición anterior. ¡Respondamos el mismo proceso!
timeseries_data["x1_t2"] = timeseries_data["generic_search"].shift(2).fillna(0)
timeseries_data["x2_t2"] = timeseries_data["media_activities"].shift(2).fillna(0)
timeseries_data["x3_t2"] = timeseries_data["brand_search"].shift(2).fillna(0)
timeseries_data["x4_t2"] = timeseries_data["exogenous"].shift(2).fillna(0)
# timeseries_data.info()
model6 = TBFPC(
target="purchase",
target_edge_rule="conservative",
forbidden_edges=[
("x1_t2", "purchase"),
("x2_t2", "purchase"),
("x3_t2", "purchase"),
("x4_t2", "purchase"),
######
("x1_t2", "x2_t2"),
("x1_t2", "x3_t2"),
("x1_t2", "x4_t2"),
("x2_t2", "x3_t2"),
("x2_t2", "x4_t2"),
("x3_t2", "x4_t2"),
######
],
)
model6.fit(
df=timeseries_data,
drivers=timeseries_data.drop(columns=["purchase"]).columns.tolist(),
)
digraph_text_v6 = model6.to_digraph()
discovered_dag_v6 = Source(digraph_text_v6)
mec_v6 = same_markov_equivalence_class_CPdag(true_dag_v6.source, digraph_text_v6)
print(
f"Discovered DAG is in the same markov equivalence class as the true DAG: {mec_v6}"
)
display(SVG(discovered_dag_v6.pipe(format="svg")))
Discovered DAG is in the same markov equivalence class as the true DAG: False
Una vez más, obtuvimos algo similar al original, pero esta vez, como antes, no pudimos refinar el dag con nuestro conocimiento del dominio para obtener algo como se esperaba.
model7 = TBFPC(
target="purchase",
target_edge_rule="conservative",
forbidden_edges=[
("x1_t2", "purchase"),
("x2_t2", "purchase"),
("x3_t2", "purchase"),
("x4_t2", "purchase"),
######
("x1_t2", "x2_t2"),
("x1_t2", "x3_t2"),
("x1_t2", "x4_t2"),
("x2_t2", "x3_t2"),
("x2_t2", "x4_t2"),
("x3_t2", "x4_t2"),
######
("generic_search", "purchase"),
],
required_edges=[
("generic_search", "brand_search"),
],
)
model7.fit(
df=timeseries_data,
drivers=timeseries_data.drop(columns=["purchase"]).columns.tolist(),
)
digraph_text_v7 = model7.to_digraph()
discovered_dag_v7 = Source(digraph_text_v7)
mec_v7 = same_markov_equivalence_class_CPdag(true_dag_v6.source, digraph_text_v7)
print(
f"Discovered DAG is in the same markov equivalence class as the true DAG: {mec_v7}"
)
display(SVG(discovered_dag_v7.pipe(format="svg")))
Discovered DAG is in the same markov equivalence class as the true DAG: False
Brillante — una vez más, el nuevo DAG captura exactamente la estructura dinámica que estábamos buscando. Después de limpiar las variables de rezago, el DAG debería estar en el mismo MEC.
# Create a copy of the DAG and remove all nodes and edges with '_t1'
dag_text_no_lags = digraph_text_v7
# Remove both edges and node declarations that involve _t1
lines = dag_text_no_lags.split("\n")
filtered_lines = []
for line in lines:
if "_t2" in line:
continue # Skip any line that mentions _t1 nodes
filtered_lines.append(line)
dag_text_no_lags = "\n".join(filtered_lines)
discovered_dag_no_lags = Source(dag_text_no_lags)
mec_v6_no_lags = same_markov_equivalence_class_CPdag(
true_dag_v6.source, discovered_dag_no_lags
)
print(
f"Discovered DAG is in the same markov equivalence class as the true DAG: {mec_v6_no_lags}"
)
display(SVG(discovered_dag_no_lags.pipe(format="svg")))
Discovered DAG is in the same markov equivalence class as the true DAG: True
¡Increíble! Habiendo alcanzado una estructura causal coherente y respetuosa del tiempo, ahora estamos listos para pasar de descubrimiento a estimación.
El siguiente paso es cuantificar la fuerza de las relaciones causales codificadas en nuestro DAG, lo que significa pasar del grafo a los estimandos que describen cuánto contribuye realmente cada factor al objetivo.
Construyendo modelos a partir de DAGs - ¡Enfoques bayesianos causales de lujo completos!#
Presentamos la clase BuildModelFromDAG, un puente entre el grafo causal y un modelo bayesiano completamente especificado. Una vez que se establece la estructura (por ejemplo, a través de TBFPC o cualquier otro algoritmo de descubrimiento), BuildModelFromDAG traduce automáticamente esa estructura en un programa probabilístico donde cada arista dirigida se convierte en un camino causal candidato, y los padres de cada nodo definen su conjunto de condicionamiento. Este enfoque nos permite ajustar todas las relaciones conjuntamente, preservando la propagación de la incertidumbre a través de todo el sistema en lugar de estimar cada arista a través de regresiones separadas.
Conceptualmente, este es un flujo de trabajo “de lujo completo” causal-bayesiano: en lugar de identificar conjuntos de ajuste a través del criterio clásico de puerta trasera (como exploramos en nuestra publicación anterior Identificación Causal en MMM) y ajustar muchos modelos condicionales, dejamos que el DAG determine directamente las dependencias jerárquicas dentro de un solo modelo bayesiano. El resultado es una inferencia unificada de todos los estimandos — distribuciones posteriores para cada efecto causal — consistente con la estructura descubierta y lista para integrarse con el modelado de mezcla de marketing u otros marcos de decisión.
En resumen, mientras que la sección anterior se centró en cuáles flechas existen, BuildModelFromDAG ahora nos indica qué tan fuertes son esas flechas. Completa la transición de descubrimiento causal a cuantificación causal, permitiendo un flujo de trabajo de extremo a extremo desde datos en bruto hasta estimaciones causales interpretables y conscientes de la incertidumbre.
BuildModelFromDAG?
Init signature:
BuildModelFromDAG(
*,
dag: 'str' = FieldInfo(annotation=NoneType, required=True, description='DAG in DOT string format or A->B list'),
df: 'InstanceOf[pd.DataFrame]' = FieldInfo(annotation=NoneType, required=True, description='DataFrame containing all DAG node columns'),
target: 'str' = FieldInfo(annotation=NoneType, required=True, description='Target node name present in DAG and df'),
dims: 'tuple[str, ...]' = FieldInfo(annotation=NoneType, required=True, description='Dims for observed/likelihood variables'),
coords: 'dict' = FieldInfo(annotation=NoneType, required=True, description='Required coords mapping for dims and priors. All coord keys must exist as columns in df.'),
model_config: 'dict | None' = FieldInfo(annotation=NoneType, required=False, default=None, description="Optional model config with Priors for 'intercept', 'slope' and 'likelihood'. Keys not supplied fall back to defaults."),
) -> 'None'
Docstring:
Build a PyMC probabilistic model directly from a Causal DAG and a tabular dataset.
The class interprets a Directed Acyclic Graph (DAG) where each node is a column
in the provided `df`. For every edge ``A -> B`` it creates a slope prior for
the contribution of ``A`` into the mean of ``B``. Each node receives a
likelihood prior. Dims and coords are used to align and index observed data
via ``pm.Data`` and xarray.
Parameters
----------
dag : str
DAG in DOT format (e.g. ``digraph { A -> B; B -> C; }``) or as a simple
comma/newline separated list of edges (e.g. ``"A->B, B->C"``).
df : pandas.DataFrame
DataFrame that contains a column for every node present in the DAG and
all columns named by the provided ``dims``.
target : str
Name of the target node present in both the DAG and ``df``. This is not
used to restrict modeling but is validated to exist in the DAG.
dims : tuple[str, ...]
Dims for the observed variables and likelihoods (e.g. ``("date", "channel")``).
coords : dict
Mapping from dim names to coordinate values. All coord keys must exist as
columns in ``df`` and will be used to pivot the data to match dims.
model_config : dict, optional
Optional configuration with priors for keys ``"intercept"``, ``"slope"`` and
``"likelihood"``. Values should be ``pymc_extras.prior.Prior`` instances.
Missing keys fall back to :pyattr:`default_model_config`.
Examples
--------
Minimal example using DOT format:
.. code-block:: python
import numpy as np
import pandas as pd
from pymc_marketing.mmm.causal import BuildModelFromDAG
dates = pd.date_range("2024-01-01", periods=5, freq="D")
df = pd.DataFrame(
{
"date": dates,
"X": np.random.normal(size=5),
"Y": np.random.normal(size=5),
}
)
dag = "digraph { X -> Y; }"
dims = ("date",)
coords = {"date": dates}
builder = BuildModelFromDAG(
dag=dag, df=df, target="Y", dims=dims, coords=coords
)
model = builder.build()
Edge-list format and custom likelihood prior:
.. code-block:: python
from pymc_extras.prior import Prior
dag = "X->Y" # equivalent to the DOT example above
model_config = {
"likelihood": Prior(
"StudentT", nu=5, sigma=Prior("HalfNormal", sigma=1), dims=("date",)
),
}
builder = BuildModelFromDAG(
dag=dag,
df=df,
target="Y",
dims=("date",),
coords={"date": dates},
model_config=model_config,
)
model = builder.build()
File: ~/Documents/GitHub/pymc-marketing/pymc_marketing/mmm/causal.py
Type: type
Subclasses:
La clase trabaja con DAGs causales, no con CPDAGs. Podemos utilizar nuestra función para obtener los diferentes DAGs causales que se encuentran en el mismo descubrimiento de MEC por el algoritmo, y ahora, usar esos para construir un modelo.
all_dags_v7 = model7.get_all_cdags_from_cpdag(dag_text_no_lags)
print(f"All DAGs: {len(all_dags_v7)}")
All DAGs: 3
timeseries_data["date"] = timeseries_data.index.tolist()
builder = BuildModelFromDAG(
dag=all_dags_v7[0].replace('"', ""),
target="purchase",
df=timeseries_data,
coords={"date": timeseries_data.index.tolist()},
dims=("date",),
)
model_from_dag = builder.build()
Genial, con estas pocas líneas tenemos un modelo pymc que replica nuestra precisa estructura causal. Veamos el gráfico computacional derivado del gráfico causal.
model_from_dag.to_graphviz()
Genial, si tu grafo computacional sigue nuestra estructura causal, entonces podemos muestrear y obtener fácilmente los verdaderos coeficientes de cada variable sobre las compras.
with model_from_dag:
idata = pm.sample(
draws=800,
tune=800,
chains=2,
progress_bar=True,
)
idata.extend(pm.sample_posterior_predictive(idata))
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (2 chains in 2 jobs)
NUTS: [exogenous_intercept, exogenous_sigma, generic_search_intercept, generic_search_sigma, generic_search:brand_search, brand_search_intercept, brand_search_sigma, brand_search:media_activities, generic_search:media_activities, media_activities_intercept, media_activities_sigma, brand_search:purchase, exogenous:purchase, media_activities:purchase, purchase_intercept, purchase_sigma]
Sampling 2 chains for 800 tune and 800 draw iterations (1_600 + 1_600 draws total) took 6 seconds.
We recommend running at least 4 chains for robust computation of convergence diagnostics
Sampling: [brand_search, exogenous, generic_search, media_activities, purchase]
Como puede ver, una vez que el modelo está construido, puede tratarlo como cualquier otro modelo de PyMC.
¿Podríamos recuperar los verdaderos efectos? - ¡De hecho, veamos la siguiente imagen!
fig, axes = plt.subplots(1, 3, figsize=(15, 5))
az.plot_posterior(
idata, var_names=["media_activities:purchase"], ref_val=0.4, ax=axes[0]
)
az.plot_posterior(idata, var_names=["exogenous:purchase"], ref_val=0.1, ax=axes[1])
az.plot_posterior(idata, var_names=["brand_search:purchase"], ref_val=0.3, ax=axes[2])
plt.tight_layout()
plt.show()
Excelente — las medias posteriores estimadas se alinean estrechamente con los verdaderos coeficientes subyacentes, confirmando que nuestra reconstrucción bayesiana capturó fielmente la señal causal incrustada en los datos.
Lo que es particularmente poderoso acerca de este enfoque es su simplicidad: en lugar de construir y ajustar múltiples modelos de regresión — uno por efecto o por conjunto de ajustes — recuperamos todos los estimandos simultáneamente dentro de un único modelo probabilístico coherente. Cada parámetro refleja su interpretación causal directamente del DAG, y la incertidumbre se propaga a través de todo el sistema en lugar de estimarse de manera fragmentada.
En este ejemplo, el gráfico sirvió como el plano, el modelo bayesiano realizó la estimación conjunta, y el resultado es una vista unificada y transparente de cómo cada factor contribuye al resultado. Esto no solo ahorra esfuerzo de modelado, sino que también garantiza la consistencia interna: todos los efectos se infieren juntos bajo las mismas suposiciones causales y probabilísticas.
Advertencia
Advertencias sobre el descubrimiento causal
Todos los métodos de descubrimiento causal se basan en supuestos fuertes:
(i) El proceso generador de datos es Markov y fiel a un DAG, por lo que las d-separaciones se alinean con las independencias condicionales. (ii) Las variables medidas capturan cada causa relevante (sin confusores ocultos). (iii) Las muestras son i.i.d. o al menos lo suficientemente independientes como para que las pruebas de independencia se comporten correctamente. (iv) Las pruebas de muestra finita son lo suficientemente potentes como para separar la señal del ruido.
El algoritmo clásico de PC asume suficiencia causal (sin causas comunes latentes) y aciclicidad; elimina aristas a través de pruebas de independencia condicional y orienta el resto con las reglas de Meek, por lo que cualquier violación de la fidelidad o pruebas débiles puede dejar aristas adicionales o faltantes. FCI relaja la suficiencia causal al permitir variables latentes y sesgo de selección, pero a cambio produce un conjunto más rico de aristas parcialmente orientadas (bidireccionales, extremos en círculo) que aún requieren suposiciones fuertes para su interpretación. Dado que los conjuntos de datos de marketing reales a menudo incluyen bucles de retroalimentación, no estacionariedad, errores de medición y factores latentes, tratamos estos algoritmos como generadores de hipótesis en lugar de oráculos de verdad y aplicamos restricciones del dominio siempre que sea posible.
Flujo de trabajo de modelado MMM#
Si está pensando en cómo esto se integra en el flujo de trabajo para desarrollar un modelo, puede visualizarlo como comenzar con el descubrimiento causal, identificar un DAG creíble en un MEC consistente con sus datos, y luego cuantificar de dos maneras opcionales.
Ruta de identificación causal: Una vez que el CPDAG te proporciona un conjunto de conductores candidatos para el objetivo elegido, coloca esa estructura en el modelo
pymc_marketing.mmm.causal. Condiciona exactamente sobre el conjunto de ajuste de puerta trasera implicado por el DAG, por lo que estimas el efecto causal de cada conductor con regresiones específicas o variantes de MMM. Ideal para realizar pruebas de estrés en múltiples DAGs y comparar estimandos lado a lado.Ruta completa de DAG a modelo: Introduzca un DAG completamente orientado en
BuildModelFromDAGpara generar un único modelo PyMC que respete todo el gráfico causal. Cada arista recibe una pendiente, el objetivo mantiene su verosimilitud y usted muestrea la posterior conjunta para leer directamente los efectos causales lineales. Esto es ideal cuando desea un único modelo bayesiano coherente en lugar de condicionales separados.
Advertencia
TEXT:
Pronto extenderemos BuildModelFromDAG para que pueda conectar transformaciones de respuesta no lineales (por ejemplo, saturación, adstock) a lo largo de los bordes individuales. Eso le permitirá mantenerse en el flujo de trabajo impulsado por el DAG mientras captura rendimientos decrecientes o dinámicas específicas de canal directamente dentro del modelo PyMC generado.
Conclusión#
A través de este flujo de trabajo completo — desde el descubrimiento causal hasta la estimación bayesiana — demostramos cómo el razonamiento estructural y el modelado probabilístico pueden unificarse en un único marco coherente.
Partiendo de datos en bruto, o datos de series temporales autocorrelacionados, primero aprendimos que descubrir un DAG puramente es poco fiable por diferentes razones. Al introducir conocimiento del dominio y/o restricciones explícitas de orden temporal, corregimos y evitamos conexiones espurias, permitiendo que la estructura del grafo refleje la verdadera dirección causal.
Una vez que emergió un DAG estable, pasamos de la estructura a la sustancia: utilizando BuildModelFromDAG, convertimos las relaciones causales descubiertas en un modelo bayesiano completamente especificado. Esto nos permitió estimar todos los efectos causales de manera conjunta, obteniendo distribuciones posteriores para cada arista sin necesidad de construir un mosaico de regresiones separadas. Los estimandos resultantes coincidieron estrechamente con los verdaderos coeficientes subyacentes, validando tanto el diseño causal como el proceso de inferencia bayesiana.
En última instancia, este flujo de trabajo ilustra la fortaleza de un enfoque causal-bayesiano integrado:
El descubrimiento causal proporciona la estructura (quién influye en quién).
La estimación bayesiana proporciona la magnitud (qué tan fuerte y con qué incertidumbre).
Juntos forman un ciclo completo — desde aprender el gráfico, hasta cuantificar sus efectos, hasta validar contra la realidad.
Aunque ningún método es inmune a la confusión o a la especificación incorrecta del modelo, este pipeline ofrece una forma fundamentada y eficiente de razonar sobre sistemas complejos, como los datos de marketing, donde muchos factores interactúan entre sí y a lo largo del tiempo. Reemplaza análisis fragmentados con un modelo causal unificado, interpretable y consciente de la incertidumbre — aporta significado a nuestro modelo y al ecosistema que representan.
Le invitamos a ampliar estas herramientas—incorporar priors más ricos, extensiones jerárquicas o componentes dinámicos—para desbloquear conocimientos causales aún más profundos y tomar decisiones más seguras y basadas en datos.
Referencias#
%load_ext watermark
%watermark -n -u -v -iv -w -p pymc_marketing,pymc,pytensor
Last updated: Mon Oct 13 2025
Python implementation: CPython
Python version : 3.12.11
IPython version : 9.6.0
pymc_marketing: 0.16.0
pymc : 5.25.1
pytensor : 2.31.7
IPython : 9.6.0
numpy : 2.3.3
pandas : 2.3.3
graphviz : 0.21
preliz : 0.21.0
pymc : 5.25.1
pymc_marketing: 0.16.0
matplotlib : 3.10.6
arviz : 0.22.0
Watermark: 2.5.0