Saltar al contenido
← Todos los proyectos

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.

📦 Datos: INEGI – RAIAVL API INEGISARIMA + intervención COVIDETSWalk-forward 12 orígenesIntervalos de predicción 🧬 Alternativa: Ensamble con pesos genéticos 🔬 Mejor algoritmo: Promedio de 5 modelos

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.
Ventas 12 m1.58 M
MAPE Estacional ingenuo3.6 %
Cobertura IC 95 %100 %
Participación Nissan17.4 %

Resultados

Ventas mensuales y pronóstico SARIMA con intervalos

Banda: intervalo de predicción al 95 %

  • Real
  • Pronóstico
  • ETS
050 k100 k150 k2021-102022-092023-082024-072025-062026-052027-04Mes
Unidades

Backtest de los últimos 12 meses (con IC 95 % del SARIMA)

  • Real
  • SARIMA
  • ETS
050 k100 k150 k2025-102025-122026-022026-042026-062026-08Mes
Unidades

Error (MAPE %) según el horizonte de pronóstico

  • Estacional ingenuo
  • SARIMA
  • ETS amortiguado
  • LightGBM (rezagos)
0 %2 %4 %6 %24681012Meses hacia adelante
MAPE %

MAPE promedio por modelo (walk-forward)

Estacional ingenuo 3.6 %
ETS amortiguado 4.9 %
SARIMA 4 %
LightGBM (rezagos) 4.6 %
Ensamble SARIMA+ETS 4 %

Estacionalidad: % sobre / bajo un mes típico (STL)

Ene -3.4 %
Feb -7.4 %
Mar 1.5 %
Abr -10.1 %
May -3.8 %
Jun -2.6 %
Jul -3.2 %
Ago -0.4 %
Sep -6.3 %
Oct 0 %
Nov 12.2 %
Dic 29 %

Participación de mercado de Nissan por año

0 %10 %20 %2005200820112014201720202023Año
% de las ventas totales

Participación por marca, últimos 12 meses

Nissan 17.4 %
General Motors 12.8 %
Volkswagen 8.9 %
Toyota 8.3 %
KIA 7.1 %
Mazda 7 %
Chrysler 5.1 %
Hyundai 3.6 %
Ford Motor 3.4 %
MG Motor 3.4 %

Ventas anuales del mercado

2005 1,131,768
2006 1,139,736
2007 1,099,890
2008 1,025,544
2009 754,925
2010 820,413
2011 905,893
2012 988,042
2013 1,065,098
2014 1,136,965
2015 1,354,444
2016 1,607,165
2017 1,534,943
2018 1,427,086
2019 1,317,931
2020 950,063
2021 1,014,735
2022 1,094,728
2023 1,363,714
2024 1,504,322
2025 1,524,635

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)

Promedio simple 3.3 %
Ingenuo estacional 3.6 %
Ensamble genético 3.7 %
SARIMA + COVID 4.1 %
LightGBM 4.6 %
ETS 4.8 %
Ingenuo × crecimiento anual 6.6 %

Pesos del ensamble (promedio sobre orígenes)

Ingenuo estacional 1.9 %
Ingenuo × crecimiento anual 16.6 %
SARIMA + COVID 18.1 %
ETS 17.7 %
LightGBM 45.7 %

MAPE por origen: ensamble genético vs. ingenuo

  • Ensamble genético
  • Ingenuo estacional
  • Promedio simple
0 %2 %4 %2024-102024-122025-022025-042025-062025-082025-10Origen del pronóstico
MAPE %

Convergencia del genético (último origen)

  • Mejor
  • Promedio de la población
4 %4.5 %5 %010203040Generación
MAPE % en orígenes de entrenamiento

Error por enfoque (media ± desv. en orígenes de evaluación)

ModeloMAPE % medioDesv.
Promedio simple3.310.68
Ingenuo estacional3.640.56
Ensamble genético3.670.72
SARIMA + COVID4.051.17
LightGBM4.560.47
ETS4.82.28
Ingenuo × crecimiento anual6.633.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.
alt10_ensamble_genetico.py127 líneas
"""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.

Línea base10.0275Ingenuo estacional
Campeón4.5519Promedio de 5 modelos
Mejora en MAPE % (validación)+5.4756menos es mejor
  • 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
0 %2.5 %5 %7.5 %10 %0246Iteración
MAPE %

Ranking de candidatos (MAPE % en validación)

Promedio de 5 modelos 4.6 %
Promedio de 6 (con Theta) 4.6 %
Promedio recortado de 6 4.7 %
Promedio de 5 sin ingenuo × crecimiento 4.9 %
Mediana de 6 5.1 %
Promedio ingenuo + SARIMA + Theta 6.1 %
Theta 6.2 %
Ingenuo estacional 10 %

MAPE por origen: campeón vs. ingenuo (selección | prueba)

  • Promedio de 5 modelos
  • Ingenuo estacional
0 %5 %10 %15 %2023-052023-102024-032024-082025-012025-06OrigenInicio prueba
MAPE %

Modelos individuales: MAPE % en selección

Ingenuo estacional 10 %
Ingenuo × crecimiento anual 11.3 %
SARIMA + COVID 5.2 %
ETS 6 %
LightGBM 4.9 %
Theta 6.2 %

Prueba final: campeón vs. línea base (una sola evaluación)

MétodoMAPE % prueba (12 orígenes)
Línea base: Ingenuo estacional3.61
Campeón: Promedio de 5 modelos3.274

Bitácora de experimentos

IteraciónHipótesisCandidatoMAPE %Desv.DecisiónNotaΔ vs. campeónp
0Línea base: el ganador individual del proyecto original.Ingenuo estacional10.0284.535Línea base——
1El promedio simple de 5 modelos (hallazgo del enfoque alternativo).Promedio de 5 modelos4.5521.226Nuevo campeón-5.476—
2Theta: descompone tendencia y suaviza; ganó la competencia M3 de pronósticos.Theta6.1522.246Se descarta1.6—
3Agregar Theta al promedio diversifica los errores.Promedio de 6 (con Theta)4.5721.076Se descarta0.02—
4La mediana es robusta a un modelo que se desvía mucho en un origen.Mediana de 65.0621.181Se descarta0.51—
5Promedio recortado (quita el más alto y el más bajo).Promedio recortado de 64.6621.232Se descarta0.11—
6Menos es más: solo los métodos estadísticos sólidos.Promedio ingenuo + SARIMA + Theta6.1062.262Se descarta1.554—
7Sin el modelo más volátil (ingenuo × crecimiento).Promedio de 5 sin ingenuo × crecimiento4.9381.629Se descarta0.386—
exp10_autos.py108 líneas
"""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)

ModeloMAPE % (media ± desv.)MASE (media ± desv.)
Estacional ingenuo3.61 ± 0.570.35 ± 0.06
ETS amortiguado4.91 ± 2.340.48 ± 0.21
SARIMA4.02 ± 1.220.39 ± 0.11
LightGBM (rezagos)4.55 ± 0.490.44 ± 0.06
Ensamble SARIMA+ETS4.03 ± 1.260.40 ± 0.11

Diagnóstico de residuos SARIMA (Ljung-Box)

RezagosEstadístico Qp-valor
129.540.657
2421.350.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.

demo.py · editable
La primera ejecución descarga Python + NumPy/pandas (~15 MB, una sola vez).

Código completo

El script exacto que generó los resultados de esta página (python run_all.py).

p10_autos_mexico.py250 líneas
"""#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()