causal-impact
Ejecuta análisis CausalImpact (inferencia causal bayesiana con series temporales estructurales BSTS) de extremo a extremo, desde un Excel de datos hasta un informe HTML entregable al cliente. Usar par
SKILL.mdskills/causal-impact/SKILL.md
GitHubCausalImpact Analyst
Ejecuta análisis CausalImpact profesionales de extremo a extremo: desde cualquier Excel hasta un informe HTML entregable al cliente.
Entorno requerido
| Componente | Valor |
|---|---|
| Python | 3.12 (.venv en raíz del proyecto) |
| Script principal | analysis/causal_impact/run_analysis.py |
| Config template | analysis/causal_impact/config_template.json |
| Outputs | outputs/charts/, outputs/results/, outputs/reports/ |
Variables de entorno obligatorias (ya en el script):
TF_USE_LEGACY_KERAS=1 # TFP 0.25 necesita tf_keras
TF_ENABLE_ONEDNN_OPTS=0 # silencia logs oneDNN
TF_CPP_MIN_LOG_LEVEL=3 # silencia logs C++ de TF
Versiones críticas (no cambiar):
tensorflow==2.21.0
tensorflow-probability==0.25.0
tf-keras==2.21.0
tfp-causalimpact==0.2.0
pandas==2.1.4 # pandas 3.x rompe la API de causalimpact
numpy==1.26.4 # numpy 2.x elimina np.long (scipy falla)
scipy==1.11.4
Flujo de trabajo
1. Recibir el Excel del usuario
Aceptar cualquier Excel. Moverlo a CAUSAL IMPACT/<cliente>/ en el repo.
2. Inspeccionar la estructura
import pandas as pd
xl = pd.ExcelFile("archivo.xlsx")
print(xl.sheet_names)
df = pd.read_excel("archivo.xlsx", sheet_name=0, index_col=0, parse_dates=True)
print(df.shape, df.dtypes, df.head(3))
Determinar:
- Columna response (variable objetivo, e.g. "sessions", "revenue")
- Columnas control (covariables, máx ~10)
- Rango de fechas y fecha de intervención (preguntar al usuario si no está clara)
3. Crear config JSON
Copiar analysis/causal_impact/config_template.json y rellenar:
{
"project": {
"name": "Nombre del análisis",
"client": "Cliente",
"analyst": "Advanced Analytics WPP"
},
"data": {
"file": "ruta/al/archivo.xlsx",
"sheet_main": "Hoja 1",
"sheet_extra": null,
"date_column": "index",
"response_column": "NombreColumnaResponse",
"control_columns": ["Control_A", "Control_B", "Control_C"]
},
"model": {
"pre_period": ["YYYY-MM-DD", "YYYY-MM-DD"],
"post_period": ["YYYY-MM-DD", "YYYY-MM-DD"],
"alpha": 0.05
},
"validation": {
"min_correlation": 0.1,
"max_vif": 10
}
}
Truco ñ: nombres con caracteres especiales en JSON → usar escape Unicode (
ñ=\u00f1)
4. Ejecutar el análisis
Set-Location <repo_root>
$env:PYTHONIOENCODING = "utf-8"
& .\.venv\Scripts\python.exe analysis\causal_impact\run_analysis.py --config analysis\causal_impact\config_<cliente>.json 2>&1 | Where-Object { $_ -notmatch "^WARNING|^I0000|oneDNN|tensorflow|DeprecationWarning|FutureWarning|UserWarning|note:" }
5. Verificar outputs
outputs/charts/01_time_series_overview.png
outputs/charts/02_pre_post_distribution.png
outputs/charts/03_correlation_analysis.png
outputs/charts/04_vif_multicollinearity.png
outputs/charts/05_causal_impact_main.png
outputs/charts/06_effect_summary.png
outputs/charts/07_model_diagnostics.png
outputs/charts/08_placebo_test.png
outputs/reports/causal_impact_report.html ← entregable cliente
outputs/results/*_series.csv
outputs/results/*_effect_summary.csv
Narrative Report
Además del summary tabular, el modelo genera un informe narrativo en inglés:
ci_summary_text = str(ci.summary(result)) # tabla resumen
ci_report_text = ci.summary(result, output_format='report') # texto narrativo
El report describe el efecto en lenguaje natural (media observada vs predicha, efecto absoluto y relativo, p-valor interpretado). Se incluye en el HTML con traducción al español en el bloque de explicación.
Tests Placebo Múltiples (5 cortes)
Se ejecutan 5 tests placebo en cortes del pre-período: 20%, 35%, 50%, 65% y 80%. Cada test finge una intervención ficticia en esa fecha y verifica que el modelo NO detecta efecto (p > 0.05).
- Todos pasan: modelo válido, el efecto real es distinguible del ruido ✅
- 1 falla: aceptable, puede ser varianza natural en ese corte ⚠️
- 2+ fallan: revisar controles, posible sobreajuste o tendencias compartidas ❌
El gráfico 08_placebo_tests.png incluye:
- Fila superior: barras de p-valores de los 5 tests
- Filas inferiores: 5 mini-series con observado vs contrafactual en cada corte
# La función retorna:
{
"tests": [{"fraction": 0.2, "date": "...", "p_value": 0.34, "rel_effect_pct": 2.1, "passed": True}, ...],
"passed_count": 4,
"total_count": 5,
"all_passed": False,
"chart": "outputs/charts/08_placebo_tests.png"
}
API de tfp-causalimpact (v0.2.0)
import causalimpact as ci
# IMPORTANTE: usar índices enteros, NO fechas como índice
df_int = df.reset_index(drop=True)
pre_period = (0, 403) # enteros
post_period = (404, 454) # enteros
result = ci.fit_causalimpact(df_int, pre_period, post_period)
# Acceso a resultados
result.series # DataFrame con columnas:
# observed, posterior_mean, posterior_lower, posterior_upper
# point_effects_mean, point_effects_lower, point_effects_upper
# cumulative_effects_mean, cumulative_effects_lower, cumulative_effects_upper
# pre_period_start, pre_period_end, post_period_start, post_period_end
result.summary # DataFrame con columnas:
# actual, predicted, predicted_lower, predicted_upper, predicted_sd
# abs_effect, abs_effect_lower, abs_effect_upper, abs_effect_sd
# rel_effect, rel_effect_lower, rel_effect_upper, rel_effect_sd
# p_value, alpha
# rows: 'average', 'cumulative'
# Métricas clave
p_value = float(result.summary["p_value"].iloc[0])
rel_effect_pct = float(result.summary["rel_effect"].iloc[0]) * 100
abs_effect = float(result.summary["abs_effect"].iloc[0])
# Re-alinear series con fechas originales
series = result.series.copy()
series.index = df.index[:len(series)] # df tiene índice datetime
Errores conocidos y soluciones
| Error | Causa | Solución |
|---|---|---|
AttributeError: 'CausalImpactAnalysis' has no attribute 'inferences' |
API antigua documentada en red | Usar result.series (no result.inferences) |
TypeError: '>' not supported between Timestamp and str |
Índice de fechas string en pandas 2.1.4 | df.reset_index(drop=True) → índices enteros |
resample("ME") KeyError |
ME introducido en pandas 2.2 |
Usar resample("M") |
ax.boxplot(labels=...) warning/error |
Deprecado en matplotlib 3.11 | Separar: ax.boxplot(...) + ax.set_xticklabels(...) |
| TFP no encuentra tf_keras | TFP 0.25 requiere tf-keras separado | $env:TF_USE_LEGACY_KERAS=1 antes de ejecutar |
np.long not found |
numpy 2.x eliminó np.long |
Pin numpy==1.26.4 |
is_datetime_or_timedelta_dtype missing |
pandas 3.x eliminó esta función | Pin pandas==2.1.4 |
Pipeline de 8 pasos (interno)
- Carga — lee Excel con openpyxl, detecta índice de fechas, une hojas si hay columnas extra
- Validación — nulos, outliers IQR, estadísticas pre/post
- EDA — serie temporal con media móvil 7d, distribución pre vs post
- Correlaciones + VIF — heatmap de Pearson, gráfico VIF; selección automática de controles (min corr 0.1, max VIF 10, eliminación iterativa)
- Estacionariedad — ADF test en todas las series; aviso si no estacionarias (normal en datos de marketing)
- Modelo —
ci.fit_causalimpactcon índices enteros; convierte fechas a índices y back - Diagnósticos — residuos en tiempo, histograma + normal teórica, Q-Q plot, ACF; Shapiro-Wilk, Durbin-Watson
- Placebo + Informe — placebo en 70% del pre-período; informe HTML autocontenido con imágenes en base64
Interpretación de resultados
¿Es significativo el resultado?
p_value < 0.05→ efecto estadísticamente significativop_value < 0.01→ muy significativo- Siempre reportar también el intervalo de confianza
Test placebo
- PASSED (p_placebo > 0.05): No hay efecto falso en pre → modelo válido ✅
- FAILED (p_placebo < 0.05): Señal en pre-período → posibles causas:
- Controles correlacionan demasiado con tendencias comunes (típico en marketing)
- Cambio de régimen en el pre-período
- Recomendar al cliente interpretar con cautela
Estacionariedad
- Series no estacionarias → normal en marketing (tendencias, estacionalidad)
- CausalImpact maneja esto internamente con su modelo estructural de series temporales
- Mencionar como limitación en el informe si hay muchas series no estacionarias
Controles óptimos (para Paradisus)
Los controles seleccionados automáticamente para Paradisus fueron:
Control_SEM Clicks US sin PARControl_OMCControl_Demanda_US_PUJControl_Landings_CanadaControl_Landings_UK
Caso de referencia: Paradisus
| Parámetro | Valor |
|---|---|
| Archivo | CAUSAL IMPACT/ES Holistic/CAUSAL PARADISUS para el notebook - Landings Paradisus Sessions - Ok.xlsx |
| Config | analysis/causal_impact/config_paradisus.json |
| Response | Response (sesiones de landing) |
| Pre-período | 2025-01-01 → 2026-02-08 (404 días) |
| Post-período | 2026-02-09 → 2026-03-31 (51 días) |
| Efecto medio diario | +708 sesiones (+23.1%) |
| Efecto acumulado | +36,112 sesiones |
| p-valor | 0.0133 (significativo) |
| Placebo | FAILED (señal en pre, interpretar con cautela) |
Checklist antes de entregar al cliente
- p-valor < 0.05 (o explicar por qué no es significativo)
- Gráfico 05 (causal main) legible con bandas de confianza claras
- Test placebo OK, o si falla: explicación en el informe
- Diagnósticos: residuos sin patrón claro, ACF sin autocorrelación significativa
- Período pre suficientemente largo (mínimo 3× el período post)
- Informe HTML abierto y revisado visualmente antes de enviar
- Commit al repositorio con análisis completo
Scripts de referencia rápida
# Nuevo análisis desde cero
Set-Location <repo_root>
$env:PYTHONIOENCODING = "utf-8"
& .\.venv\Scripts\python.exe analysis\causal_impact\run_analysis.py --config analysis\causal_impact\config_<cliente>.json 2>&1 | Where-Object { $_ -notmatch "^WARNING|^I0000|oneDNN|tensorflow|DeprecationWarning|FutureWarning|UserWarning|note:" }
# Verificar venv
& .\.venv\Scripts\python.exe -c "import causalimpact; import tensorflow as tf; print('TF:', tf.__version__); print('CI OK')"
# Kernel Jupyter
& .\.venv\Scripts\python.exe -m ipykernel install --user --name advanced_analytics_melia --display-name "Python 3.12 (advanced_analytics_melia)"
Diseño del informe HTML entregable al cliente
Principios irrenunciables
- Es el documento que recibe el cliente. Sin frases internas: nada de "Para el cliente:", "Lectura rápida:", "Para presentar al cliente:", "Nota interna:".
- Tono directo y profesional. El lector es el cliente, no el analista.
- El output técnico en inglés (texto de
ci.summary()) va en un<details>colapsable — disponible si se necesita, pero no intrusivo. - Probabilidad, no p-valor como valor principal. El cliente entiende "98.1% de probabilidad de efecto causal" mejor que "p = 0.0189". El p-valor aparece entre paréntesis para referencia técnica.
Estructura de secciones
1. Resumen Ejecutivo — KPIs y conclusión en una pantalla
2. Datos y Período — descripción serie temporal, EDA
3. Validación del Modelo — correlaciones, VIF, estacionariedad
4. Resultados — gráfico principal CI, efecto observado vs contrafactual
5. Magnitud del Efecto — KPI cards (efecto diario, acumulado, % relativo, probabilidad)
6. Diagnóstico — residuos, Q-Q, ACF, Shapiro-Wilk, Durbin-Watson
7. Tests Placebo — 5 tests con gráfico combinado + tabla
KPI box "Probabilidad de efecto causal"
# En el gráfico matplotlib (06_effect_summary.png)
("p-valor", f"{p_val:.4f}", f"{(1-p_val)*100:.1f}% prob. efecto causal", sig_color)
# En el HTML
kpi_label = "Probabilidad de efecto causal"
kpi_value = f"{(1 - p_value) * 100:.1f}%"
kpi_sub = f"Efecto significativo (α = {alpha}, p = {p_value:.4f})"
CSS: variables y clases clave
:root {
--blue-dark: #1a3a5c;
--blue-mid: #2e6da4;
--blue-light:#4a9fd5;
--gold: #c8a84b;
--green: #28a745;
--red: #dc3545;
--bg: #f8f9fa;
--card-bg: #ffffff;
}
.insight { border-left: 4px solid var(--blue-mid); padding: 1rem 1.5rem; margin: 1.5rem 0; background: #f0f7ff; }
.finding { background: #fff; border: 1px solid #dee2e6; border-radius: 6px; padding: 1rem; margin-top: 0.75rem; }
.kpi { text-align: center; padding: 1.5rem; border-radius: 8px; }
.kpi.positive { background: #d4edda; border: 1px solid #28a745; }
.kpi.negative { background: #f8d7da; border: 1px solid #dc3545; }
.kpi.neutral { background: #fff3cd; border: 1px solid #ffc107; }
.kpi-label { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; }
.kpi-value { font-size: 2rem; font-weight: 700; margin: 0.5rem 0; }
.kpi-sub { font-size: 0.85rem; color: #666; }
Técnica KPI box matplotlib (evitar fondo blanco en savefig)
ax.set_facecolor() con plt.subplots() no persiste en fig.savefig(). Solución:
fig = plt.figure(figsize=(14, 5), facecolor="#f8f9fa")
for i, (label, value, sub, color) in enumerate(kpis):
ax = fig.add_axes([x_pos, 0.1, width, 0.8]) # posicionamiento manual
ax.set_facecolor(color)
patch = FancyBboxPatch((0.05, 0.05), 0.9, 0.9, ...)
ax.add_patch(patch)
ax.text(0.5, 0.7, label, ...)
ax.text(0.5, 0.45, value, ...)
ax.text(0.5, 0.2, sub, ...)
ax.axis("off")
fig.savefig(path, facecolor=fig.get_facecolor(), bbox_inches="tight")
Bug vif_rows (resuelto)
El helper que genera las filas de la tabla VIF debe incluir <tr><td>:
def vif_rows_fn():
rows = []
for k, v in vif.items():
color = "green" if v < 5 else ("orange" if v < 10 else "red")
rows.append(f'<tr><td>{k.replace("Control_","")}</td>'
f'<td style="color:{color};font-weight:700">{v:.2f}</td>'
f'<td>{"✅ OK" if v < 10 else "⚠️ Alto"}</td></tr>')
return "".join(rows)
Sección cerrada correctamente (bug resuelto)
Cada <div class="section"> debe cerrarse con </div> antes de abrir la siguiente. La omisión colapsa el layout completo.
Tests placebo — criterios de diseño
- 5 cortes en el pre-período: fracciones [0.20, 0.35, 0.50, 0.65, 0.80]
- Misma duración que el post-período real (calculada desde
config["model"]["post_period"]) - Sin solapamiento: todos operan solo con
df[df.index <= pre_end]; siplacebo_end > pre_end, se recorta - Interpretación: ≥ 4/5 PASSED = validación sólida; 3/5 = aceptable con cautela; ≤ 2/5 = revisar controles
post_start = pd.to_datetime(config["model"]["post_period"][0])
post_end = pd.to_datetime(config["model"]["post_period"][1])
post_window = (post_end - post_start).days + 1 # misma duración que el real
⚠️ REGLA OBLIGATORIA: Archivar cada proyecto
Al finalizar CUALQUIER análisis CausalImpact, crear obligatoriamente una carpeta en projects/causal-impact/:
projects/
├── causal-impact/ ← TODOS los causal impacts aquí
│ ├── paradisus-us-2026/
│ ├── <próximo-proyecto>/
│ └── ...
├── mmm/ ← futuros Media Mix Models
├── forecasting/ ← futuros modelos de previsión
└── ...
projects/causal-impact/<nombre-proyecto>/
├── data/ # Excel original del cliente (renombrar: <proyecto>-data.xlsx)
├── config/ # config JSON usado en el análisis
├── outputs/
│ ├── charts/ # Los 8 PNGs generados
│ └── reports/ # HTML entregable (nombre: causal_impact_report_<proyecto>.html)
└── README.md # Ficha: cliente, fecha, intervención, métricas clave, notas
Pasos de archivado al terminar:
New-Item -ItemType Directory -Force -Path "projects\<nombre>\data"(y subcarpetas)- Copiar Excel →
projects/causal-impact/<nombre>/data/<nombre>-data.xlsx - Copiar config JSON →
projects/causal-impact/<nombre>/config/ - Copiar
outputs/charts/*.png→projects/causal-impact/<nombre>/outputs/charts/ - Copiar HTML →
projects/causal-impact/<nombre>/outputs/reports/causal_impact_report_<nombre>.html - Crear
README.mdcon ficha del proyecto - Añadir fila a la tabla "Proyectos archivados" de este SKILL.md
git add projects/<nombre>/ && git commit && git push
projects/<nombre>/
├── data/ # Excel original
├── config/ # config JSON usado
├── outputs/
│ ├── charts/ # 8 PNGs
│ └── reports/ # HTML entregable (nombre_<proyecto>.html)
└── README.md # Ficha del proyecto: fechas, métricas clave, notas
Proyectos archivados
| Proyecto | Carpeta | Intervención | Efecto | p-valor |
|---|---|---|---|---|
| Paradisus US 2026 | projects/causal-impact/paradisus-us-2026/ |
9 feb 2026 | +23.1% | 0.019 (98.1%) |