EpiForecast-MX — Plataforma de Inteligencia Epidemiológica Multi-Modelo, IMSS
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.
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%.
_ajusta_negativos()Crítico Severidad original: alta — contaminaba datos de todos los modelos.
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.
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.
test_ajusta_negativos_no_usa_futuro — Modifica el valor después de un negativo y verifica que la corrección no cambia (prueba de fuego anti-leakage).test_ajusta_negativos_respeta_grupos — Verifica que un negativo en entidad B no utiliza valores de entidad A.Commit: 2fc4b620 —
fix(data): eliminar future leakage en _ajusta_negativos
Moderado Pesos del meta-learner frágiles ante cambios de tendencia.
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.
Se implementó expanding-window OOF con múltiples folds:
_compute_oof_folds() que distribuye N cutoffs equidistantes entre (oof_cutoff - 18 meses) y oof_cutoff.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.# 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
test_expanding_window_multiple_folds — Serie larga (350 semanas), verifica que los expertos se entrenan múltiples veces y los pesos suman ~1.0.test_fallback_datos_insuficientes — Serie corta (50 semanas), verifica que retorna pesos iguales.Commit: 73f8c67b —
feat(stacking): expanding-window OOF para meta-learner
Moderado XGBoost aprendia a corregir errores menores de lo que vera en producción.
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.
Se creo un nuevo módulo oof_residuals.py con la función
generate_oof_residuals() que implementa expanding-window CV para Prophet:
train_data en K=3 folds expanding-window.self._prophet), lo entrena en fold_train, y predice fold_val.y_real - yhat_prophet_oof, que son realistas porque Prophet no ha visto los datos de validación.fold_train para los lags.Se mantiene retrocompatibilidad: oof_residual_folds=0 activa el modo legacy (in-sample).
# config/models/ensemble.yaml
oof_residual_folds: 3 # 0 = legacy in-sample
test_oof_residuals_backward_compatible — Con oof_residual_folds=0, el flujo es idéntico al legacy.test_oof_residuals_uses_oof_when_enabled — Con oof_residual_folds=3, se invoca generate_oof_residuals().test_oof_residuals_empty_fallback — Datos insuficientes, fallback automático a in-sample.Commit: 66410259 —
feat(ensemble): residuos out-of-fold para XGBoost
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:
| 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) |
Las 3 observaciones menores fueron corregidas en el commit 3fb29aec.
feature_builder.pyCorregido
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()
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.
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()
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.
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.
| 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 |
| 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 |
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
| 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 |
| 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 |
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:
TimeSeriesSplit en Prophet, DeepAR y Ensemble.make compare-metrics para generar la comparativa Excel con las nuevas métricas.make tableau para actualizar el dashboard con MASE corregido.