Reporte de Auditoría y Remediación

EpiForecast-MX — Plataforma de Inteligencia Epidemiológica Multi-Modelo, IMSS

Fecha: 1 de marzo de 2026 Modelos: Prophet, DeepAR, Ensemble, Stacking Commit base: ff10783c

1. Resumen ejecutivo

Una auditoría de producción detectó 3 hallazgos que inflaban las métricas de pronóstico entre un 5% y un 15%. Se aplicaron 3 correcciónes quirúrgicas en commits separados. Posteriormente se realizó una segunda auditoría exhaustiva que revisó la totalidad del código de preprocesamiento, entrenamiento, evaluación y selección de modelos para verificar que no existen problemas residuales de data leakage ni overfitting.

Veredicto: APROBADO

Los 3 hallazgos críticos y las 3 observaciones menores fueron remediados en 4 commits. La segunda auditoría confirma que el pipeline cumple las mejores prácticas de MLOps temporal. Quedan únicamente 2 items documentados como decisiónes intencionales de diseño. 707 tests pasan con coverage del 70%.

2. Hallazgos originales y remediación

2.1 Hallazgo 1 — Future Leakage en _ajusta_negativos()

Crítico   Severidad original: alta — contaminaba datos de todos los modelos.

Problema

El método _ajusta_negativos() en transformer.py:178 utilizaba shift(-1) para acceder al valor de la semana siguiente y calcular una interpolación bidireccional (t-1 + t+1) / 2 para reemplazar incrementos negativos. Este cálculo se ejecutaba sobre el DataFrame completo antes del split train/test, lo que filtraba informacion futura hacia el pasado en la frontera del corte temporal.

Impacto

Remediación

Se elimino shift(-1) por completo. La corrección ahora usa exclusivamente datos pasados con una media móvil retrospectiva de 3 semanas, agrupada por ["Padecimiento", "Entidad"]:

-  prev_val = self.df[columna].shift(1)
-  next_val = self.df[columna].shift(-1)    # LEAKAGE: mira al futuro
-  extrap = (prev_val + next_val) / 2.0

+  prev3_mean = self.df.groupby(["Padecimiento", "Entidad"])[columna].transform(
+      lambda s: s.shift(1).rolling(window=3, min_periods=1).mean()
+  )

El patrón shift(1).rolling(3) garantiza que solo se utilizan valores de las 3 semanas anteriores, sin incluir la semana actual ni futuras.

Tests agregados

Commit: 2fc4b620fix(data): eliminar future leakage en _ajusta_negativos

2.2 Hallazgo 2 — OOF con ventana unica en Stacking

Moderado   Pesos del meta-learner frágiles ante cambios de tendencia.

Problema

StackingMetaLearner.fit_oof() utilizaba un único split temporal con oof_cutoff="2024-01-01", generando aproximadamente 52 filas de validación OOF. Los pesos Ridge se optimizaban para un solo régimen temporal, haciendolos frágiles ante cambios de tendencia, estacionalidad o eventos atipicos.

Impacto

Remediación

Se implementó expanding-window OOF con múltiples folds:

  1. Nuevo método _compute_oof_folds() que distribuye N cutoffs equidistantes entre (oof_cutoff - 18 meses) y oof_cutoff.
  2. fit_oof() ahora itera sobre los folds, entrena copias independientes de los expertos (copy.deepcopy) en cada fold de entrenamiento, predice el fold de validación, y concatena todas las predicciones OOF.
  3. Ridge se entrena sobre el conjunto concatenado de todos los folds, produciendo pesos que ven múltiples régimenes temporales.
  4. Fallback automático a pesos iguales (1/N) si no hay folds validos.

Configuración

# config/models/stacking.yaml
stacking:
  oof_n_folds: 4            # Numero de folds expanding-window
  oof_min_train_weeks: 104  # Minimo de semanas para cada fold de entrenamiento

Tests agregados

Commit: 73f8c67bfeat(stacking): expanding-window OOF para meta-learner

2.3 Hallazgo 3 — Residuos In-Sample en Ensemble

Moderado   XGBoost aprendia a corregir errores menores de lo que vera en producción.

Problema

EnsembleForecaster._fit_xgboost() calculaba los residuos de Prophet como y - prophet.predict(train), es decir, residuos in-sample. Dado que Prophet ya ha visto los datos de entrenamiento, sus predicciones in-sample son optimistamente buenas. XGBoost aprendia a corregir errores mas pequeños de los que realmente encontraria en producción.

Impacto

Remediación

Se creo un nuevo módulo oof_residuals.py con la función generate_oof_residuals() que implementa expanding-window CV para Prophet:

  1. Divide train_data en K=3 folds expanding-window.
  2. Para cada fold, crea un Prophet temporal (no toca self._prophet), lo entrena en fold_train, y predice fold_val.
  3. Calcula residuos OOF = y_real - yhat_prophet_oof, que son realistas porque Prophet no ha visto los datos de validación.
  4. Construye features XGBoost usando la historia de fold_train para los lags.
  5. Concatena features y residuos de todos los folds para entrenar XGBoost.

Se mantiene retrocompatibilidad: oof_residual_folds=0 activa el modo legacy (in-sample).

Configuración

# config/models/ensemble.yaml
oof_residual_folds: 3  # 0 = legacy in-sample

Tests agregados

Commit: 66410259feat(ensemble): residuos out-of-fold para XGBoost

3. Segunda auditoría exhaustiva

Tras aplicar las 3 correcciónes, se realizó una auditoría completa de todo el pipeline de datos, entrenamiento, evaluación y selección de modelos. Se revisaron 25 archivos en busca de:

3.1 Componentes auditados y resultado

Componente Archivo(s) Veredicto
Pipeline de datos: limpieza cleaner.py Limpio
Pipeline de datos: transformación transformer.py Limpio (corregido)
Pipeline de datos: imputación imputation.py Limpio
Prophet: modelo prophet/model.py Intencional
Prophet: preparación de datos prophet/data_prep.py Limpio
Prophet: validación cruzada prophet/cross_validator.py Limpio
Prophet: tuner prophet/tuner.py Limpio
DeepAR: modelo deepar/model.py Intencional
DeepAR: validación cruzada deepar/cross_validator.py Limpio
Ensemble: modelo ensemble/model.py Limpio (corregido)
Ensemble: helpers ensemble/helpers.py Limpio
Ensemble: feature builder ensemble/feature_builder.py Limpio (corregido)
Ensemble: residuos OOF ensemble/oof_residuals.py Limpio (nuevo)
Ensemble: tuner XGB ensemble/xgb_tuner.py Limpio (corregido)
Stacking: modelo stacking/model.py Limpio (corregido)
Stacking: meta-learner stacking/meta_learner.py Limpio (corregido)
Stacking: expertos stacking/experts.py Limpio
Evaluación: métricas evaluation/metrics.py Limpio
Tableau: selección productiva scripts/build_tableau.py Limpio (corregido)

3.2 Observaciones menores (corregidas)

Las 3 observaciones menores fueron corregidas en el commit 3fb29aec.

Observacion A: Ventanas móviles en feature_builder.py

Corregido

Las features roll_4, roll_8 y roll_12 se calculaban como y_series.rolling(N).mean() sin shift(1), lo que incluía el valor actual y[t] en la media móvil, introduciendo una correlación leve entre el feature y el target.

Corrección: Se agregó shift(1) antes de rolling() para que las medias móviles solo utilicen datos anteriores a t.

-  feats["roll_4"] = y_series.rolling(4).mean()
+  shifted = y_series.shift(1)
+  feats["roll_4"] = shifted.rolling(4).mean()

Observacion B: Tuner XGB usaba residuos in-sample

Corregido

EnsembleXGBTuner.run() calculaba los residuos Prophet como y - prophet.predict(train) (in-sample). Los residuos objetivo eran optimistamente pequeños porque Prophet ya habia visto los datos.

Corrección: Ahora cada fold de la CV temporal re-entrena un Prophet temporal sobre el fold de entrenamiento y calcula residuos sobre el fold de validación. Los hiperparámetros de XGBoost se selecciónan contra residuos OOF realistas.

Observacion C: MASE en Tableau usaba naive de 1 paso

Corregido

build_tableau.py calculaba el denominador de MASE usando diff() (naive de 1 paso), mientras que metrics.py usa diff(52) (naive estacional). Habia una inconsistencia entre las métricas del dashboard Tableau y las métricas de CV de los modelos.

Corrección: Se cambio a diff(52) en build_tableau.py para ser consistente con el naive estacional lag-52 de metrics.py.

-  tmp["_naive_diff"] = tmp.groupby(grp)["y_real"].diff().abs()
+  tmp["_naive_diff"] = tmp.groupby(grp)["y_real"].diff(52).abs()

3.3 Decisiones intencionales documentadas

Diseño D1: Entrenamiento final sobre serie completa (Prophet y DeepAR)

Intencional

Tanto ProphetForecaster.run() como DeepARForecaster.run() re-entrenan el modelo final sobre la serie completa (train + test) para maximizar la calidad del pronóstico en producción. Las métricas oficiales provienen de la validación cruzada temporal realizada antes del re-entrenamiento.

Para series insuficientes que no permiten CV completa, se usa eval_rapida(), cuyas métricas son ligeramente optimistas. Esto esta documentado en el código (deepar/model.py:697-702) y el sesgo es consistente entre todos los modelos estatales, permitiendo comparacion valida entre ellos.

Diseño D2: Early stopping en train_loss para DeepAR

Intencional

GluonTS no soporta nativamente un split de validación dentro del estimador DeepAR. El early stopping se configura sobre train_loss, lo que solo detiene el entrenamiento cuando el modelo deja de mejorar en datos de entrenamiento. Es una limitacion conocida del framework, no un error de implementacion.

4. Resumen de cambios por archivo

Archivo Tipo de cambio Lineas Commit
data/preprocessing/transformer.py Reescritura de _ajusta_negativos() +17 / -23 2fc4b62
tests/unit/data/test_transformer.py +2 tests anti-leakage +42 2fc4b62
models/stacking/meta_learner.py Expanding-window OOF, _compute_oof_folds() +95 / -25 73f8c67
models/stacking/model.py Nuevos params oof_n_folds, min_train_weeks +7 / -1 73f8c67
config/models/stacking.yaml Nuevas claves OOF +2 73f8c67
tests/unit/models/test_stacking_model.py +2 tests expanding-window y fallback +44 73f8c67
models/ensemble/oof_residuals.py Nuevo módulo: residuos OOF para XGBoost +93 6641025
models/ensemble/model.py OOF residuals + _insample_residuals() +38 / -6 6641025
config/models/ensemble.yaml Nueva clave oof_residual_folds +2 6641025
tests/unit/models/test_ensemble_model.py +3 tests OOF residuos +88 6641025
models/ensemble/feature_builder.py Rolling windows con shift(1) +2 / -1 3fb29ae
models/ensemble/xgb_tuner.py Re-entrena Prophet por fold para residuos OOF +111 / -21 3fb29ae
scripts/build_tableau.py MASE usa diff(52) naive estacional +1 / -1 3fb29ae

5. Validación cruzada temporal: antes vs después

5.1 Disciplina temporal por modelo

Modelo CV / OOF Antes Despues
Prophet TimeSeriesSplit con pesos progresivos OK OK
DeepAR TimeSeriesSplit multi-serie por fecha OK OK
Ensemble (Prophet) Prophet: TimeSeriesSplit OK OK
Ensemble (XGBoost) Residuos para entrenar XGB In-sample OOF 3-fold
Ensemble (XGB tuner) Residuos para grid search HP In-sample OOF por fold
Ensemble (features) Rolling windows (roll_4/8/12) Incluía y[t] shift(1)
Stacking (meta-learner) OOF para pesos Ridge 1 split 4-fold expanding
Datos (negativos) Corrección de incrementos negativos shift(-1) Retrospectivo

5.2 Diagrama de flujo temporal

Datos crudos
    |
    v
[Preprocesamiento] --- shift(1).rolling(3) solo datos pasados
    |
    v
[Split temporal] --- cutoff fijo por config (2025-01-01)
    |
    +--- train_data (< cutoff)
    |       |
    |       +--- [Prophet CV] TimeSeriesSplit, N folds temporales
    |       +--- [DeepAR CV]  TimeSeriesSplit, multi-serie
    |       +--- [Ensemble]   XGB tuner (TimeSeriesSplit) + OOF residuos (3-fold)
    |       +--- [Stacking]   Expanding-window OOF (4-fold) para Ridge
    |
    +--- test_data (>= cutoff)
            |
            v
        [Evaluacion] --- metricas sobre datos NO vistos durante entrenamiento

6. Estado de la suite de tests

Metrica Antes Despues
Tests totales (rápidos) 702 707
Tests pasando 702 707
Tests fallando 0 0
Coverage 70% 70%
Pre-commit (ruff + mypy) Passing Passing
Tests nuevos de anti-leakage 7

6.1 Desglose de tests nuevos

Test Archivo Que verifica
test_ajusta_negativos_no_usa_futuro test_transformer.py Modificar datos futuros no cambia la corrección de negativos
test_ajusta_negativos_respeta_grupos test_transformer.py La corrección no cruza entre entidades
test_expanding_window_multiple_folds test_stacking_model.py Múltiples folds OOF, pesos suman 1.0
test_fallback_datos_insuficientes test_stacking_model.py Datos cortos: fallback a pesos iguales
test_oof_residuals_backward_compatible test_ensemble_model.py Retrocompatibilidad con oof_residual_folds=0
test_oof_residuals_uses_oof_when_enabled test_ensemble_model.py OOF se activa cuando oof_residual_folds > 0
test_oof_residuals_empty_fallback test_ensemble_model.py Datos insuficientes: fallback a in-sample

7. Conclusiones y recomendaciones

7.1 Estado actual

El pipeline de EpiForecast-MX cumple las mejores prácticas de MLOps para series de tiempo tras la remediación de los 3 hallazgos críticos:

7.2 Próximos pasos recomendados

  1. Re-entrenar Prophet, Ensemble y Stacking con los fixes aplicados y comparar métricas antes/después.
  2. Ejecutar make compare-metrics para generar la comparativa Excel con las nuevas métricas.
  3. Regenerar make tableau para actualizar el dashboard con MASE corregido.

7.3 Items NO requeridos