Proyecto #10 · Series de tiempo
Pronóstico de ventas de autos en México
Serie oficial de INEGI vía API: 5 modelos, walk-forward y pronóstico con intervalos.
Resumen ejecutivo
- Serie oficial INEGI RAIAVL de 2005 a 09/2026: últimos 12 meses 1,579,472 unidades (+4.7% vs. año previo).
- Walk-forward con 12 orígenes × 12 meses y 5 modelos: el mejor es Estacional ingenuo con MAPE 3.6 % y MASE 0.35. Mejor modelo estadístico: SARIMA (4.0 %); diferencia vs. ingenuo Wilcoxon p = 0.569.
- En el régimen estable reciente el ingenuo estacional es muy difícil de vencer; la intervención COVID mejora al SARIMA y le da intervalos útiles. Cobertura empírica del IC 95 %: 100 % (intervalos conservadores); Ljung-Box (12 rezagos) p = 0.657 → residuos sin autocorrelación.
- Pronóstico próximos 12 meses: 1,645,781 unidades (IC 95 % mensual en la gráfica). Diciembre vende +29 % vs. un mes típico. Nissan lidera con 17.4 % del mercado.
Resultados
Ventas mensuales y pronóstico SARIMA con intervalos
Banda: intervalo de predicción al 95 %
- Real
- Pronóstico
- ETS
Backtest de los últimos 12 meses (con IC 95 % del SARIMA)
- Real
- SARIMA
- ETS
Error (MAPE %) según el horizonte de pronóstico
- Estacional ingenuo
- SARIMA
- ETS amortiguado
- LightGBM (rezagos)
MAPE promedio por modelo (walk-forward)
Estacionalidad: % sobre / bajo un mes típico (STL)
Participación de mercado de Nissan por año
Participación por marca, últimos 12 meses
Ventas anuales del mercado
Metodología y validación
Ingenuo estacional, ETS amortiguado, SARIMA(1,1,1)(0,1,1)12 en logaritmo con intervención COVID, LightGBM con rezagos (recursivo) y ensamble.
✔ Cómo se validó
- Validación walk-forward con ventana expansiva: 12 orígenes consecutivos × 12 meses.
- MAPE y MASE (escala libre y comparable con el ingenuo); error por horizonte.
- Cobertura empírica de los intervalos de predicción; Ljung-Box sobre residuos; STL para estacionalidad.
- Prueba de Wilcoxon pareada contra el ingenuo estacional.
⚙ Supuestos
- La estructura estacional de los últimos años se mantiene.
- No se incluyen variables exógenas (tasas, tipo de cambio).
⚠ Limitaciones
- 2020 (COVID) es un choque atípico dentro de la historia de entrenamiento.
- Las cifras del último mes son preliminares en INEGI y pueden revisarse.
🧬 Enfoque alternativo · Algoritmo genético (pesos del ensamble) + walk-forward anidado
Ensamble de pronósticos con pesos de algoritmo genético
¿Por qué otro enfoque? El ingenuo estacional ganó a todos los modelos individuales. Cada modelo falla distinto; un ensamble con pesos evolucionados puede combinar lo mejor de cada uno sin elegir a ciegas.
- Evaluación anidada en 13 orígenes (pesos aprendidos solo con el pasado): ensamble genético MAPE 3.67 % vs. ingenuo estacional 3.64 % (Wilcoxon p = 0.376). Mejor enfoque: Promedio simple.
- Pesos promedio aprendidos: LightGBM 46%, SARIMA + COVID 18%, ETS 18%, Ingenuo × crecimiento anual 17%, Ingenuo estacional 2%.
- La mejora no es significativa: en un mercado tan estable, el ingenuo estacional sigue siendo un rival difícil. Lección: sin variables exógenas (tasas, inventario, precios) ningún ensamble de la misma historia le gana con claridad.
- Sorpresa clásica (el "puzzle de la combinación de pronósticos"): el promedio simple de los 5 modelos logra 3.31 % y le gana al ingenuo (Wilcoxon p = 0.033) y al ensamble optimizado. Con pocos orígenes, los pesos optimizados se sobreajustan; pesos iguales son más robustos.
MAPE % por enfoque (orígenes de evaluación anidada)
Pesos del ensamble (promedio sobre orígenes)
MAPE por origen: ensamble genético vs. ingenuo
- Ensamble genético
- Ingenuo estacional
- Promedio simple
Convergencia del genético (último origen)
- Mejor
- Promedio de la población
Error por enfoque (media ± desv. en orígenes de evaluación)
| Modelo | MAPE % medio | Desv. |
|---|---|---|
| Promedio simple | 3.31 | 0.68 |
| Ingenuo estacional | 3.64 | 0.56 |
| Ensamble genético | 3.67 | 0.72 |
| SARIMA + COVID | 4.05 | 1.17 |
| LightGBM | 4.56 | 0.47 |
| ETS | 4.8 | 2.28 |
| Ingenuo × crecimiento anual | 6.63 | 3.26 |
✔ Cómo se validó
- Walk-forward con 30 orígenes; pesos re-aprendidos en cada origen solo con horizontes ya observados.
- Comparación contra cada modelo individual, el promedio simple y prueba de Wilcoxon contra el ingenuo.
⚙ Supuestos
- La mezcla óptima del pasado reciente es buena guía para el siguiente año.
⚠ Limitaciones
- Pocos orígenes de evaluación: las diferencias pequeñas no son concluyentes.
"""Alternativa #10 — Ensamble de pronósticos con pesos optimizados por algoritmo genético.
Problema que resuelve: en el proyecto #10 el ingenuo estacional le ganó a SARIMA, ETS y LightGBM. Cada modelo
se equivoca distinto; un ensamble bien ponderado puede superar a todos. El algoritmo genético busca los pesos
(no negativos, suman 1) que minimizan el MAPE.
Validación anidada sin fuga: para evaluar el origen k, los pesos se aprenden SOLO con orígenes cuyo horizonte
de 12 meses ya se había observado antes de k. Así el ensamble nunca ve el futuro que pronostica.
"""
import numpy as np
import pandas as pd
from scipy import stats
from bioinspirados import algoritmo_genetico
from common import SEED, bars, export_alternativa, line, table
from p10_autos_mexico import (HORIZONTE, descargar_inegi, mape, pronostico_ets, pronostico_lgbm,
pronostico_sarima)
from validacion import model_card
N_ORIG = 30 # orígenes consecutivos de walk-forward
MIN_ENTRENAR = 6 # orígenes mínimos ya observados para aprender pesos
def base_forecasts(hist: pd.Series) -> dict[str, np.ndarray]:
crecimiento = hist.iloc[-12:].sum() / hist.iloc[-24:-12].sum()
return {
"Ingenuo estacional": hist.iloc[-12:].values,
"Ingenuo × crecimiento anual": hist.iloc[-12:].values * crecimiento,
"SARIMA + COVID": pronostico_sarima(hist, HORIZONTE)[0],
"ETS": pronostico_ets(hist, HORIZONTE),
"LightGBM": pronostico_lgbm(hist, HORIZONTE),
}
def run():
df = descargar_inegi()
total = df.groupby("fecha")["ventas"].sum().asfreq("MS")
y = total[total > 0].asfreq("MS").loc["2010":]
origenes = [len(y) - HORIZONTE - k for k in range(N_ORIG - 1, -1, -1)]
F, R = [], []
for o in origenes:
F.append(base_forecasts(y.iloc[:o]))
R.append(y.iloc[o:o + HORIZONTE].values)
modelos = list(F[0])
P = np.array([[F[i][m] for m in modelos] for i in range(len(F))]) # orígenes × modelos × horizonte
R = np.array(R)
def mape_pesos(w, idx):
w = np.asarray(w) / (np.sum(w) + 1e-12)
pred = np.einsum("m,omh->oh", w, P[idx])
return float(np.mean(np.abs(R[idx] - pred) / R[idx]) * 100)
filas, pesos_hist, conv = [], [], None
for k in range(len(origenes)):
# orígenes cuyo horizonte completo terminó antes de este origen
entrenables = [j for j in range(k) if j + HORIZONTE <= k]
if len(entrenables) < MIN_ENTRENAR:
continue
w, _, hist = algoritmo_genetico(lambda g: -mape_pesos(g, entrenables), len(modelos),
poblacion=30, generaciones=40, p_mut=0.2, seed=SEED + k)
w = w / w.sum()
pesos_hist.append(w)
conv = hist
fila = {"origen": y.index[origenes[k]].strftime("%Y-%m"),
"Ensamble genético": mape_pesos(w, [k]),
"Promedio simple": mape_pesos(np.ones(len(modelos)), [k])}
for i, m in enumerate(modelos):
fila[m] = mape(R[k], P[k, i])
filas.append(fila)
ev = pd.DataFrame(filas)
cols = ["Ensamble genético", "Promedio simple"] + modelos
res = ev[cols].agg(["mean", "std"]).T.round(2).sort_values("mean")
res.columns = ["MAPE % medio", "Desv."]
p_val = float(stats.wilcoxon(ev["Ensamble genético"], ev["Ingenuo estacional"]).pvalue)
p_prom = float(stats.wilcoxon(ev["Promedio simple"], ev["Ingenuo estacional"]).pvalue)
w_final = np.mean(pesos_hist, axis=0)
gana = res.index[0]
ens, ing = res.loc["Ensamble genético", "MAPE % medio"], res.loc["Ingenuo estacional", "MAPE % medio"]
res_tbl = res.reset_index().rename(columns={"index": "Modelo"})
result = {
"titulo": "Ensamble de pronósticos con pesos de algoritmo genético",
"algoritmo": "Algoritmo genético (pesos del ensamble) + walk-forward anidado",
"veredicto": f"El promedio simple ({res.loc['Promedio simple', 'MAPE % medio']:.2f} %) vence al ingenuo "
f"({ing:.2f} %, p = {p_prom:.3f}); los pesos genéticos se sobreajustan.",
"por_que": "El ingenuo estacional ganó a todos los modelos individuales. Cada modelo falla distinto; un ensamble "
"con pesos evolucionados puede combinar lo mejor de cada uno sin elegir a ciegas.",
"summary": [
f"Evaluación anidada en {len(ev)} orígenes (pesos aprendidos solo con el pasado): ensamble genético MAPE "
f"{ens:.2f} % vs. ingenuo estacional {ing:.2f} % (Wilcoxon p = {p_val:.3f}). Mejor enfoque: {gana}.",
"Pesos promedio aprendidos: " + ", ".join(f"{m} {w:.0%}" for m, w in sorted(zip(modelos, w_final), key=lambda t: -t[1])) + ".",
("El ensamble mejora al ingenuo: combinar modelos sí agrega valor."
if ens < ing and p_val < 0.05 else
"La mejora no es significativa: en un mercado tan estable, el ingenuo estacional sigue siendo un rival "
"difícil. Lección: sin variables exógenas (tasas, inventario, precios) ningún ensamble de la misma "
"historia le gana con claridad."),
f"Sorpresa clásica (el \"puzzle de la combinación de pronósticos\"): el promedio simple de los 5 modelos logra "
f"{res.loc['Promedio simple', 'MAPE % medio']:.2f} % y le gana al ingenuo (Wilcoxon p = {p_prom:.3f}) y al ensamble "
"optimizado. Con pocos orígenes, los pesos optimizados se sobreajustan; pesos iguales son más robustos.",
],
"charts": [
bars("mape", "MAPE % por enfoque (orígenes de evaluación anidada)", res.index, res["MAPE % medio"].values, fmt="pct"),
bars("pesos", "Pesos del ensamble (promedio sobre orígenes)", modelos, (w_final * 100).round(1), fmt="pct"),
line("evol", "MAPE por origen: ensamble genético vs. ingenuo", {
"Ensamble genético": ev[["origen", "Ensamble genético"]].round(2).values.tolist(),
"Ingenuo estacional": ev[["origen", "Ingenuo estacional"]].round(2).values.tolist(),
"Promedio simple": ev[["origen", "Promedio simple"]].round(2).values.tolist()},
x_label="Origen del pronóstico", y_label="MAPE %", fmt="pct"),
line("conv", "Convergencia del genético (último origen)", {
"Mejor": [[a, round(-b, 3)] for a, b, _ in conv],
"Promedio de la población": [[a, round(-c, 3)] for a, _, c in conv]},
x_label="Generación", y_label="MAPE % en orígenes de entrenamiento", fmt="pct"),
],
"tables": [table("Error por enfoque (media ± desv. en orígenes de evaluación)", res_tbl)],
"card": model_card(
"Ensamble lineal de 5 pronósticos (incluye un ingenuo ajustado por crecimiento) con pesos ≥ 0 que suman 1.",
[f"Walk-forward con {N_ORIG} orígenes; pesos re-aprendidos en cada origen solo con horizontes ya observados.",
"Comparación contra cada modelo individual, el promedio simple y prueba de Wilcoxon contra el ingenuo."],
["La mezcla óptima del pasado reciente es buena guía para el siguiente año."],
["Pocos orígenes de evaluación: las diferencias pequeñas no son concluyentes."]),
}
export_alternativa("p10-autos-mexico", result)
return result
if __name__ == "__main__":
run()
🔬 Búsqueda del mejor algoritmo · 8 iteraciones
Búsqueda del mejor método de pronóstico
Objetivo: Vencer de forma consistente al ingenuo estacional.
- 8 métodos evaluados en los primeros 18 orígenes (2023-05 a 2024-10, recuperación post-COVID). Campeón: Promedio de 5 modelos (MAPE 4.55 % vs. 10.03 % del ingenuo).
- Prueba final ciega (últimos 12 orígenes): Promedio de 5 modelos 3.27 % vs. ingenuo 3.61 % (Wilcoxon p = 0.052); gana en 10 de 12 orígenes.
- El ingenuo cambia mucho según el régimen: 10.0 % en la recuperación vs. 3.6 % en el periodo estable. El mejor modelo individual en selección fue LightGBM (4.92 %), pero en prueba logra 4.55 %: solo la combinación gana en ambos regímenes. La diversidad de errores vale más que un modelo sofisticado.
- Lección del puzzle de combinación: combinaciones simples y robustas (promedios, medianas) le ganan a pesos optimizados cuando hay pocos datos para estimarlos.
💡 Ningún modelo individual gana en todos los regímenes; el promedio simple sí.
Progreso de la búsqueda (MAPE %, validación)
- Cada experimento
- Mejor hasta el momento
Ranking de candidatos (MAPE % en validación)
MAPE por origen: campeón vs. ingenuo (selección | prueba)
- Promedio de 5 modelos
- Ingenuo estacional
Modelos individuales: MAPE % en selección
Prueba final: campeón vs. línea base (una sola evaluación)
| Método | MAPE % prueba (12 orígenes) |
|---|---|
| Línea base: Ingenuo estacional | 3.61 |
| Campeón: Promedio de 5 modelos | 3.274 |
Bitácora de experimentos
| Iteración | Hipótesis | Candidato | MAPE % | Desv. | Decisión | Nota | Δ vs. campeón | p |
|---|---|---|---|---|---|---|---|---|
| 0 | Línea base: el ganador individual del proyecto original. | Ingenuo estacional | 10.028 | 4.535 | Línea base | — | — | |
| 1 | El promedio simple de 5 modelos (hallazgo del enfoque alternativo). | Promedio de 5 modelos | 4.552 | 1.226 | Nuevo campeón | -5.476 | — | |
| 2 | Theta: descompone tendencia y suaviza; ganó la competencia M3 de pronósticos. | Theta | 6.152 | 2.246 | Se descarta | 1.6 | — | |
| 3 | Agregar Theta al promedio diversifica los errores. | Promedio de 6 (con Theta) | 4.572 | 1.076 | Se descarta | 0.02 | — | |
| 4 | La mediana es robusta a un modelo que se desvía mucho en un origen. | Mediana de 6 | 5.062 | 1.181 | Se descarta | 0.51 | — | |
| 5 | Promedio recortado (quita el más alto y el más bajo). | Promedio recortado de 6 | 4.662 | 1.232 | Se descarta | 0.11 | — | |
| 6 | Menos es más: solo los métodos estadísticos sólidos. | Promedio ingenuo + SARIMA + Theta | 6.106 | 2.262 | Se descarta | 1.554 | — | |
| 7 | Sin el modelo más volátil (ingenuo × crecimiento). | Promedio de 5 sin ingenuo × crecimiento | 4.938 | 1.629 | Se descarta | 0.386 | — |
"""Búsqueda del mejor método — #10 Pronóstico de ventas de autos.
Punto de partida: el ingenuo estacional le ganó a todos los modelos individuales y el promedio simple de
5 modelos le ganó al ingenuo. Hipótesis: el método Theta (ganador de la competencia M3), combinaciones robustas
(mediana, promedio recortado) y subconjuntos de modelos. Se usan 30 orígenes de walk-forward: los primeros 18
para elegir y los últimos 12 como prueba final ciega.
"""
import warnings
import numpy as np
import pandas as pd
from scipy import stats
from statsmodels.tsa.forecasting.theta import ThetaModel
from alt10_ensamble_genetico import base_forecasts
from common import bars, export_experimento, line
from experimentos import Bitacora
from p10_autos_mexico import HORIZONTE, descargar_inegi
N_ORIG, N_SELECCION = 30, 18
def theta(hist: pd.Series) -> np.ndarray:
with warnings.catch_warnings():
warnings.simplefilter("ignore")
m = ThetaModel(np.log(hist), period=12, deseasonalize=True, method="auto").fit()
return np.exp(m.forecast(HORIZONTE).values)
def run():
df = descargar_inegi()
total = df.groupby("fecha")["ventas"].sum().asfreq("MS")
y = total[total > 0].asfreq("MS").loc["2010":]
origenes = [len(y) - HORIZONTE - k for k in range(N_ORIG - 1, -1, -1)]
P, R = [], []
for o in origenes:
f = base_forecasts(y.iloc[:o])
f["Theta"] = theta(y.iloc[:o])
P.append(f)
R.append(y.iloc[o:o + HORIZONTE].values)
R = np.array(R)
modelos = list(P[0])
def metodo(fn):
"""MAPE por origen de un método de combinación fn(dict de pronósticos) -> arreglo."""
return np.array([np.mean(np.abs(R[i] - fn(P[i])) / R[i]) * 100 for i in range(N_ORIG)])
todos5 = ["Ingenuo estacional", "Ingenuo × crecimiento anual", "SARIMA + COVID", "ETS", "LightGBM"]
mezcla = lambda nombres, agg=np.mean: (lambda f: agg([f[m] for m in nombres], axis=0))
recortado = lambda nombres: (lambda f: stats.trim_mean([f[m] for m in nombres], 0.2, axis=0))
candidatos = [
("Línea base: el ganador individual del proyecto original.", "Ingenuo estacional", lambda f: f["Ingenuo estacional"]),
("El promedio simple de 5 modelos (hallazgo del enfoque alternativo).", "Promedio de 5 modelos", mezcla(todos5)),
("Theta: descompone tendencia y suaviza; ganó la competencia M3 de pronósticos.", "Theta", lambda f: f["Theta"]),
("Agregar Theta al promedio diversifica los errores.", "Promedio de 6 (con Theta)", mezcla(modelos)),
("La mediana es robusta a un modelo que se desvía mucho en un origen.", "Mediana de 6", mezcla(modelos, np.median)),
("Promedio recortado (quita el más alto y el más bajo).", "Promedio recortado de 6", recortado(modelos)),
("Menos es más: solo los métodos estadísticos sólidos.", "Promedio ingenuo + SARIMA + Theta",
mezcla(["Ingenuo estacional", "SARIMA + COVID", "Theta"])),
("Sin el modelo más volátil (ingenuo × crecimiento).", "Promedio de 5 sin ingenuo × crecimiento",
mezcla([m for m in modelos if m != "Ingenuo × crecimiento anual"])),
]
bit = Bitacora("MAPE %", mayor_es_mejor=False)
errores = {}
for hip, nombre, fn in candidatos:
e = metodo(fn)
errores[nombre] = e
bit.probar(hip, nombre, e[:N_SELECCION])
campeon = bit.campeon["Candidato"]
sel = slice(N_SELECCION, N_ORIG)
e_c, e_b = errores[campeon][sel], errores["Ingenuo estacional"][sel]
p = float(stats.wilcoxon(e_c, e_b).pvalue)
prueba = pd.DataFrame([{"Método": "Línea base: Ingenuo estacional", "MAPE % prueba (12 orígenes)": round(e_b.mean(), 3)},
{"Método": f"Campeón: {campeon}", "MAPE % prueba (12 orígenes)": round(e_c.mean(), 3)}])
etiquetas = [y.index[o].strftime("%Y-%m") for o in origenes]
indiv = {m: metodo(lambda f, m=m: f[m]) for m in modelos}
mejor_ind = min(indiv, key=lambda m: indiv[m][:N_SELECCION].mean())
result = bit.resultado(
"Búsqueda del mejor método de pronóstico",
"Vencer de forma consistente al ingenuo estacional.",
[f"{len(bit.filas)} métodos evaluados en los primeros {N_SELECCION} orígenes ({etiquetas[0]} a {etiquetas[N_SELECCION - 1]}, "
f"recuperación post-COVID). Campeón: {campeon} (MAPE "
f"{bit.campeon['MAPE %']:.2f} % vs. {bit.filas[0]['MAPE %']:.2f} % del ingenuo).",
f"Prueba final ciega (últimos 12 orígenes): {campeon} {e_c.mean():.2f} % vs. ingenuo {e_b.mean():.2f} % "
f"(Wilcoxon p = {p:.3f}); gana en {int((e_c < e_b).sum())} de 12 orígenes.",
f"El ingenuo cambia mucho según el régimen: {errores['Ingenuo estacional'][:N_SELECCION].mean():.1f} % en la "
f"recuperación vs. {e_b.mean():.1f} % en el periodo estable. El mejor modelo individual en selección fue "
f"{mejor_ind} ({indiv[mejor_ind][:N_SELECCION].mean():.2f} %), pero en prueba logra {indiv[mejor_ind][sel].mean():.2f} %: "
"solo la combinación gana en ambos regímenes. La diversidad de errores vale más que un modelo sofisticado.",
"Lección del puzzle de combinación: combinaciones simples y robustas (promedios, medianas) le ganan a pesos "
"optimizados cuando hay pocos datos para estimarlos."],
prueba, fmt="pct",
graficas_extra=[line("origenes", "MAPE por origen: campeón vs. ingenuo (selección | prueba)", {
campeon: [[etiquetas[i], round(float(errores[campeon][i]), 2)] for i in range(N_ORIG)],
"Ingenuo estacional": [[etiquetas[i], round(float(errores["Ingenuo estacional"][i]), 2)] for i in range(N_ORIG)]},
x_label="Origen", y_label="MAPE %", fmt="pct", markers=[[etiquetas[N_SELECCION], "Inicio prueba"]]),
bars("indiv", "Modelos individuales: MAPE % en selección", modelos,
[round(float(indiv[m][:N_SELECCION].mean()), 2) for m in modelos], fmt="pct")],
leccion="Ningún modelo individual gana en todos los regímenes; el promedio simple sí.",
)
export_experimento("p10-autos-mexico", result)
return result
if __name__ == "__main__":
run()
Walk-forward: error por modelo (12 orígenes)
| Modelo | MAPE % (media ± desv.) | MASE (media ± desv.) |
|---|---|---|
| Estacional ingenuo | 3.61 ± 0.57 | 0.35 ± 0.06 |
| ETS amortiguado | 4.91 ± 2.34 | 0.48 ± 0.21 |
| SARIMA | 4.02 ± 1.22 | 0.39 ± 0.11 |
| LightGBM (rezagos) | 4.55 ± 0.49 | 0.44 ± 0.06 |
| Ensamble SARIMA+ETS | 4.03 ± 1.26 | 0.40 ± 0.11 |
Diagnóstico de residuos SARIMA (Ljung-Box)
| Rezagos | Estadístico Q | p-valor |
|---|---|---|
| 12 | 9.54 | 0.657 |
| 24 | 21.35 | 0.618 |
Pruébalo: Python en tu navegador
Una versión compacta del análisis, ejecutada con Pyodide (CPython en WebAssembly). Edita el código y vuelve a ejecutarlo; si defines la variable grafica, se dibuja abajo.
Código completo
El script exacto que generó los resultados de esta página (python run_all.py).
"""#10 Pronóstico de ventas de vehículos ligeros en México (y participación de Nissan).
Datos: INEGI – Registro Administrativo de la Industria Automotriz de Vehículos Ligeros (RAIAVL),
consultados directo de la API PX-Web de INEGI (venta al público por marca y modelo, mensual).
Modelos: ingenuo estacional, ETS, SARIMA, LightGBM con rezagos y ensamble, evaluados con walk-forward.
"""
import io
import json
import urllib.request
import warnings
import numpy as np
import pandas as pd
from lightgbm import LGBMRegressor
from scipy import stats
from statsmodels.stats.diagnostic import acorr_ljungbox
from statsmodels.tsa.holtwinters import ExponentialSmoothing
from statsmodels.tsa.seasonal import STL
from statsmodels.tsa.statespace.sarimax import SARIMAX
from common import RAW, SEED, bars, export, line, table
from validacion import model_card
API = "https://www.inegi.org.mx/app/tabulados/pxwebv2/api/v1/es/RAIAVL/RAIAVL_8_9.px"
MESES = ["Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio", "Julio", "Agosto", "Septiembre",
"Octubre", "Noviembre", "Diciembre"]
HORIZONTE = 12
def descargar_inegi() -> pd.DataFrame:
"""Descarga ventas mensuales por marca (año por año) y las guarda en caché."""
cache = RAW / "inegi_raiavl_ventas_marca.csv"
if cache.exists():
return pd.read_csv(cache, parse_dates=["fecha"])
RAW.mkdir(parents=True, exist_ok=True)
meta = json.load(urllib.request.urlopen(API, timeout=120))
anio, mes, modelo, unidades = [x["code"] for x in meta["variables"]]
anios = meta["variables"][0]["values"]
partes = []
for a in anios:
q = {"query": [{"code": anio, "selection": {"filter": "item", "values": [a]}},
{"code": mes, "selection": {"filter": "all", "values": ["*"]}},
{"code": modelo, "selection": {"filter": "all", "values": ["*"]}},
{"code": unidades, "selection": {"filter": "item", "values": ["0"]}}], # 0 = Ventas
"response": {"format": "csv"}}
req = urllib.request.Request(API, data=json.dumps(q).encode(),
headers={"Content-Type": "application/json"})
txt = urllib.request.urlopen(req, timeout=300).read().decode("utf-8-sig")
d = pd.read_csv(io.StringIO(txt), na_values=["-"])
d.columns = ["anio", "mes", "modelo", "ventas"]
d["marca"] = d["modelo"].str.split(" - ").str[0].str.strip()
d["fecha"] = pd.to_datetime(dict(year=d.anio, month=d.mes.map(MESES.index) + 1, day=1))
partes.append(d.groupby(["fecha", "marca"], as_index=False)["ventas"].sum(min_count=1).dropna())
print(f" ↓ INEGI {d.anio.iloc[0]}")
df = pd.concat(partes, ignore_index=True)
df.to_csv(cache, index=False)
return df
def features_lag(s: pd.Series) -> pd.DataFrame:
f = pd.DataFrame({"y": s})
for l in [1, 2, 3, 6, 12, 24]:
f[f"lag_{l}"] = s.shift(l)
f["media_3"] = s.shift(1).rolling(3).mean()
f["media_12"] = s.shift(1).rolling(12).mean()
f["mes"] = s.index.month
return f
def pronostico_lgbm(hist: pd.Series, h: int) -> np.ndarray:
"""Pronóstico recursivo: cada predicción alimenta los rezagos del siguiente mes."""
s = hist.copy()
f = features_lag(s).dropna()
m = LGBMRegressor(n_estimators=400, learning_rate=0.03, num_leaves=8, min_child_samples=10,
random_state=SEED, verbose=-1).fit(f.drop(columns="y"), f["y"])
for _ in range(h):
nxt = s.index[-1] + pd.offsets.MonthBegin(1)
s.loc[nxt] = np.nan
x = features_lag(s).iloc[[-1]].drop(columns="y")
s.loc[nxt] = m.predict(x)[0]
return s.iloc[-h:].values
def covid(idx: pd.DatetimeIndex) -> pd.DataFrame:
"""Variables de intervención: cierre (abr–jun 2020) y recuperación con escasez de chips (jul-2020 a dic-2021)."""
return pd.DataFrame({"cierre": ((idx >= "2020-04-01") & (idx <= "2020-06-01")).astype(float),
"escasez": ((idx >= "2020-07-01") & (idx <= "2021-12-01")).astype(float)}, index=idx)
def pronostico_sarima(hist: pd.Series, h: int, alpha=0.05):
"""SARIMA(1,1,1)(0,1,1)12 en logaritmo con intervención COVID; devuelve media e intervalo de predicción."""
with warnings.catch_warnings(): # avisos de convergencia del optimizador en orígenes cortos
warnings.simplefilter("ignore")
m = SARIMAX(np.log(hist), exog=covid(hist.index), order=(1, 1, 1), seasonal_order=(0, 1, 1, 12)).fit(disp=False)
fi = pd.date_range(hist.index[-1] + pd.offsets.MonthBegin(1), periods=h, freq="MS")
f = m.get_forecast(h, exog=covid(fi))
ci = np.exp(f.conf_int(alpha=alpha).values)
return np.exp(f.predicted_mean.values), ci[:, 0], ci[:, 1], m
def pronostico_ets(hist: pd.Series, h: int) -> np.ndarray:
m = ExponentialSmoothing(np.log(hist), trend="add", damped_trend=True, seasonal="add",
seasonal_periods=12).fit()
return np.exp(m.forecast(h).values)
def mape(y, p):
return float(np.mean(np.abs((y - p) / y)) * 100)
def mase(y, p, hist):
"""MASE: error relativo al ingenuo estacional dentro de la historia (< 1 = mejor que el ingenuo)."""
escala = np.mean(np.abs(hist.values[12:] - hist.values[:-12]))
return float(np.mean(np.abs(y - p)) / escala)
def run():
df = descargar_inegi()
total = df.groupby("fecha")["ventas"].sum().asfreq("MS")
total = total[total > 0].asfreq("MS")
nissan = df[df.marca.str.upper() == "NISSAN"].groupby("fecha")["ventas"].sum().reindex(total.index)
share = (nissan / total * 100)
y = total.loc["2010":]
# --- 1) Validación walk-forward: 12 orígenes mensuales consecutivos, horizonte de 12 meses ---
# (ventana expansiva; cada origen solo ve el pasado)
N_ORIG = 12
origenes = [len(y) - HORIZONTE - k for k in range(N_ORIG - 1, -1, -1)]
modelos = ["Estacional ingenuo", "ETS amortiguado", "SARIMA", "LightGBM (rezagos)", "Ensamble SARIMA+ETS"]
filas, por_h, cobertura = [], {m: np.zeros(HORIZONTE) for m in modelos}, []
for o in origenes:
hist, real = y.iloc[:o], y.iloc[o:o + HORIZONTE].values
sar, lo, hi, _ = pronostico_sarima(hist, HORIZONTE)
ets = pronostico_ets(hist, HORIZONTE)
preds = {"Estacional ingenuo": hist.iloc[-12:].values, "ETS amortiguado": ets, "SARIMA": sar,
"LightGBM (rezagos)": pronostico_lgbm(hist, HORIZONTE), "Ensamble SARIMA+ETS": (sar + ets) / 2}
for mname, pr in preds.items():
filas.append({"origen": y.index[o], "modelo": mname, "MAPE": mape(real, pr), "MASE": mase(real, pr, hist)})
por_h[mname] += np.abs((real - pr) / real) * 100 / N_ORIG
cobertura.append(np.mean((real >= lo) & (real <= hi)))
ev = pd.DataFrame(filas)
res = ev.groupby("modelo", sort=False)[["MAPE", "MASE"]].agg(["mean", "std"])
mejor = res[("MAPE", "mean")].idxmin()
cob = float(np.mean(cobertura) * 100)
# Prueba pareada del mejor modelo contra el ingenuo estacional (errores por origen)
retador = res.drop(index="Estacional ingenuo")[("MAPE", "mean")].idxmin()
p_dm = float(stats.wilcoxon(ev[ev.modelo == retador].MAPE.values,
ev[ev.modelo == "Estacional ingenuo"].MAPE.values).pvalue)
# --- 2) Diagnóstico de residuos del SARIMA y descomposición estacional ---
_, _, _, m_full = pronostico_sarima(y, 1)
lb = acorr_ljungbox(m_full.resid.iloc[13:], lags=[12, 24], return_df=True)
stl = STL(np.log(y), period=12, robust=True).fit()
est_mes = pd.Series(np.exp(stl.seasonal.values) * 100 - 100, index=y.index.month).groupby(level=0).mean()
# --- 3) Pronóstico a futuro con intervalos (80 % y 95 %) ---
fut_idx = pd.date_range(y.index[-1] + pd.offsets.MonthBegin(1), periods=HORIZONTE, freq="MS")
fut, lo95, hi95, _ = pronostico_sarima(y, HORIZONTE, 0.05)
_, lo80, hi80, _ = pronostico_sarima(y, HORIZONTE, 0.20)
ets_f = pronostico_ets(y, HORIZONTE)
hist, real = y.iloc[:-HORIZONTE], y.iloc[-HORIZONTE:]
p_sar, b_lo, b_hi, _ = pronostico_sarima(hist, HORIZONTE)
p_ets = pronostico_ets(hist, HORIZONTE)
def pts(idx, vals):
return [[d.strftime("%Y-%m"), float(round(v))] for d, v in zip(idx, vals)]
def banda(idx, lo, hi):
return [[d.strftime("%Y-%m"), float(round(a)), float(round(b))] for d, a, b in zip(idx, lo, hi)]
ult = y.index[-1]
yoy = y.iloc[-12:].sum() / y.iloc[-24:-12].sum() - 1
anual = total.groupby(total.index.year).sum()
anual = anual[anual.index < ult.year]
share_anual = (nissan.groupby(nissan.index.year).sum() / total.groupby(total.index.year).sum() * 100)
share_anual = share_anual[share_anual.index < ult.year]
ult12 = df[df.fecha > ult - pd.DateOffset(months=12)].groupby("marca")["ventas"].sum()
marcas = (ult12 / ult12.sum() * 100).sort_values(ascending=False).head(10)
res_tbl = pd.DataFrame({"Modelo": res.index,
"MAPE % (media ± desv.)": [f"{m:.2f} ± {s:.2f}" for m, s in res["MAPE"].values],
"MASE (media ± desv.)": [f"{m:.2f} ± {s:.2f}" for m, s in res["MASE"].values]})
lb_tbl = pd.DataFrame({"Rezagos": lb.index, "Estadístico Q": lb.lb_stat.round(2), "p-valor": lb.lb_pvalue.round(4)})
meses_c = ["Ene", "Feb", "Mar", "Abr", "May", "Jun", "Jul", "Ago", "Sep", "Oct", "Nov", "Dic"]
result = {
"title": "Pronóstico de ventas de autos en México",
"summary": [
f"Serie oficial INEGI RAIAVL de {total.index[0]:%Y} a {ult:%m/%Y}: últimos 12 meses "
f"{y.iloc[-12:].sum():,.0f} unidades ({yoy:+.1%} vs. año previo).",
f"Walk-forward con {N_ORIG} orígenes × 12 meses y 5 modelos: el mejor es {mejor} con MAPE "
f"{res.loc[mejor, ('MAPE', 'mean')]:.1f} % y MASE {res.loc[mejor, ('MASE', 'mean')]:.2f}. Mejor modelo "
f"estadístico: {retador} ({res.loc[retador, ('MAPE', 'mean')]:.1f} %); diferencia vs. ingenuo "
f"Wilcoxon p = {p_dm:.3f}.",
"En el régimen estable reciente el ingenuo estacional es muy difícil de vencer; la intervención COVID "
"mejora al SARIMA y le da intervalos útiles. "
f"Cobertura empírica del IC 95 %: {cob:.0f} %"
+ (" (intervalos conservadores)" if cob > 97 else "") +
f"; Ljung-Box (12 rezagos) p = {lb.lb_pvalue.iloc[0]:.3f}"
+ (" → residuos sin autocorrelación." if lb.lb_pvalue.iloc[0] > 0.05 else " → queda estructura sin modelar."),
f"Pronóstico próximos 12 meses: {fut.sum():,.0f} unidades (IC 95 % mensual en la gráfica). Diciembre vende "
f"{est_mes.loc[12]:+.0f} % vs. un mes típico. Nissan lidera con {share.iloc[-12:].mean():.1f} % del mercado.",
],
"metrics": [
{"label": "Ventas 12 m", "value": f"{y.iloc[-12:].sum() / 1e6:.2f} M"},
{"label": f"MAPE {mejor.split(' (')[0]}", "value": f"{res.loc[mejor, ('MAPE', 'mean')]:.1f} %"},
{"label": "Cobertura IC 95 %", "value": f"{cob:.0f} %"},
{"label": "Participación Nissan", "value": f"{share.iloc[-12:].mean():.1f} %"},
],
"charts": [
line("serie", "Ventas mensuales y pronóstico SARIMA con intervalos",
{"Real": pts(y.index[-60:], y.iloc[-60:].values), "Pronóstico": pts(fut_idx, fut),
"ETS": pts(fut_idx, ets_f)},
x_label="Mes", y_label="Unidades", fmt="int", bands={"Pronóstico": banda(fut_idx, lo95, hi95)},
subtitle="Banda: intervalo de predicción al 95 %"),
line("backtest", "Backtest de los últimos 12 meses (con IC 95 % del SARIMA)",
{"Real": pts(real.index, real.values), "SARIMA": pts(real.index, p_sar), "ETS": pts(real.index, p_ets)},
x_label="Mes", y_label="Unidades", fmt="int", bands={"SARIMA": banda(real.index, b_lo, b_hi)}),
line("horiz", "Error (MAPE %) según el horizonte de pronóstico",
{m: [[h + 1, round(float(v), 2)] for h, v in enumerate(por_h[m])] for m in
["Estacional ingenuo", "SARIMA", "ETS amortiguado", "LightGBM (rezagos)"]},
x_label="Meses hacia adelante", y_label="MAPE %", fmt="pct"),
bars("mape", "MAPE promedio por modelo (walk-forward)", res.index, res[("MAPE", "mean")].round(2).values, fmt="pct"),
bars("estac", "Estacionalidad: % sobre / bajo un mes típico (STL)", meses_c, est_mes.round(1).values, fmt="pct"),
line("share", "Participación de mercado de Nissan por año",
{"Nissan": [[str(a), round(float(v), 2)] for a, v in share_anual.items()]},
x_label="Año", y_label="% de las ventas totales", fmt="pct"),
bars("marcas", "Participación por marca, últimos 12 meses", marcas.index, marcas.values.round(2), fmt="pct"),
bars("anual", "Ventas anuales del mercado", anual.index.astype(str), anual.values, fmt="int"),
],
"tables": [table("Walk-forward: error por modelo (12 orígenes)", res_tbl),
table("Diagnóstico de residuos SARIMA (Ljung-Box)", lb_tbl)],
"card": model_card(
"Ingenuo estacional, ETS amortiguado, SARIMA(1,1,1)(0,1,1)12 en logaritmo con intervención COVID, "
"LightGBM con rezagos (recursivo) y ensamble.",
[f"Validación walk-forward con ventana expansiva: {N_ORIG} orígenes consecutivos × 12 meses.",
"MAPE y MASE (escala libre y comparable con el ingenuo); error por horizonte.",
"Cobertura empírica de los intervalos de predicción; Ljung-Box sobre residuos; STL para estacionalidad.",
"Prueba de Wilcoxon pareada contra el ingenuo estacional."],
["La estructura estacional de los últimos años se mantiene.", "No se incluyen variables exógenas (tasas, tipo de cambio)."],
["2020 (COVID) es un choque atípico dentro de la historia de entrenamiento.",
"Las cifras del último mes son preliminares en INEGI y pueden revisarse."]),
}
sample = pd.DataFrame({"fecha": total.index.strftime("%Y-%m"), "ventas": total.values,
"nissan": nissan.values})
export("p10-autos-mexico", result, sample)
return result
if __name__ == "__main__":
run()