ADR-015 — Validación espacial de outliers debe filtrar vecinos flag_outlier == "original" (B2)¶
Fecha: 2026-05-07
Estado: Aceptado — corregido en este repo
Origen: Proyecto hermano CAR (02_Preprocesamiento/01_outliers_iqr.py paso 2) —
documentado en Plan/Feedback/feed_aire.md §"Bug fix B2 — Vecino sin filtro flag_outlier"
y en Plan/Feedback/repo_estadistica_ambiental_feedback.md §3.1.D.
Contexto¶
Cuando un episodio sostenido en una estación se valida contra estaciones vecinas ("¿también estuvo elevado el vecino en la misma ventana?"), el umbral del vecino se calcula con su propio Q1/Q3/IQR mensuales. El patrón "original" (con bug) era:
# El Q3 del vecino se calcula sobre TODA su serie, incluyendo valores
# que ya fueron intervenidos en iteraciones anteriores
vecino_q3 = df_vecino["pm25"].quantile(0.75)
umbral_vec = vecino_q3 + 1.5 * iqr_vec
Síntoma del bug¶
El loop de validación procesa estaciones secuencialmente. Cuando le toca el turno
a la estación N, las estaciones 1..N-1 ya tuvieron sus outliers reemplazados por la
mediana local (paso 3 del pipeline) o etiquetados como cap_absoluto. Si la
estación N es vecina de alguna de esas estaciones ya intervenidas, el cálculo
del Q3 del vecino mezcla:
- Valores reales originales.
- Medianas locales (artificialmente cercanas al centro de la distribución).
- Valores capeados (truncados al percentil 99.9).
Esto aplana la distribución del vecino, baja Q3 e IQR, y baja por tanto el
umbral Q3 + 1.5×IQR con el que se confirma el episodio en la estación N. El
sesgo va siempre en el mismo sentido: más episodios se confirman como
"regionales" de los que realmente lo son. La confirmación se vuelve recursivamente
optimista.
Por qué pasó desapercibido¶
- Sin un test que comparase resultados con/sin filtro, el sesgo es invisible: no hay excepción, no hay NaN, las cuentas finales tienen sentido.
- El bug es dependiente del orden de iteración: la primera estación procesada no sufre del bug (no hay vecinos intervenidos todavía). Solo las últimas estaciones del loop ven el efecto completo.
- En tests unitarios con 2-3 estaciones, el efecto era marginal o nulo.
- A nivel agregado, los conteos de "regional vs puntual" no eran obviamente incorrectos (el patrón meteorológico real produce muchos episodios regionales reales en Bogotá invierno).
Cómo se descubrió en CAR¶
Durante la Sesión S2 (2026-04-23 mañana) del pipeline CAR, al re-ejecutar el
pipeline con un orden de estaciones diferente (alfabético vs por código DANE), los
conteos de episodios regionales por estación cambiaban hasta en un 8%. Eso fue la
señal: un algoritmo determinista no debe depender del orden de procesamiento.
La inspección reveló que el Q3 del vecino estaba siendo calculado sobre la serie
ya intervenida. Ver Plan/Feedback/feed_aire.md líneas 429-437.
Decisión¶
Cualquier estadístico de un vecino usado para confirmar/descartar un episodio en
otra estación debe restringirse a observaciones con flag_outlier == "original"
(o equivalente: el flag inicial antes de cualquier intervención).
# Filtro explícito: solo valores no intervenidos del vecino
mask_vec_mes = (
(result[station_col] == vecino)
& (result["_month"] == mes)
& (result["flag_episode"] == "original")
& result[value_col].notna()
)
serie_vec = result.loc[mask_vec_mes, value_col]
q3v = serie_vec.quantile(0.75)
iqrv = q3v - serie_vec.quantile(0.25)
umbral_vec = q3v + 1.5 * iqrv
El mismo principio aplica al pivot estación×tiempo usado para verificar la
elevación simultánea: debe excluir valores ya marcados como cap_absoluto o
imputados.
Estado en este repo¶
flag_spatial_episodes() en src/estadistica_ambiental/preprocessing/air_quality.py
ya implementa el filtro correcto en los dos puntos críticos:
| Punto | Línea | Código clave |
|---|---|---|
Filtro del pivot espacial — excluye cap_absoluto antes del unstack |
air_quality.py:258 |
result.loc[result["flag_episode"] != "cap_absoluto", ...] |
| Filtro del Q1/Q3/IQR de la propia estación | air_quality.py:279-281 |
mask_mes & (result["flag_episode"] == "original") & ... |
| Filtro del Q3/IQR del vecino para umbral de confirmación | air_quality.py:336-341 |
(result[station_col] == vecino) & ... & (result["flag_episode"] == "original") & result[value_col].notna() |
detect_regional_episodes() en src/estadistica_ambiental/preprocessing/outliers.py
es por construcción inmune al bug:
- No imputa valores: solo etiqueta original / regional / puntual
(ver docstring outliers.py:137 "NO modifica los valores").
- El cálculo de Q1/Q3/IQR del vecino (outliers.py:265-271) opera sobre la serie
cruda, ya que ningún valor cambió jamás durante el flujo.
flag_outliers() (outliers.py:24-69) tampoco aplica: es una API univariada que
no compara con vecinos espaciales.
ADR-010 (PR #6, §7 "flag_spatial_episodes determinismo") ya cubrió la corrección
en el código de producción. Este ADR-015 lo eleva a una regla de diseño
arquitectónico: cualquier futura API de "spatial outlier confirmation" debe
documentar explícitamente cómo filtra vecinos para evitar confirmaciones
recursivamente sesgadas.
Lección aprendida¶
Algoritmos que iteran sobre estaciones (o cualquier entidad) y modifican el estado del DataFrame en cada iteración deben tratar el estado del vecino como inmutable a efectos del cálculo de umbrales. La regla mnemotécnica:
"Cuando preguntas algo a un vecino, pregunta por su versión original — no por la versión que tu propio algoritmo le dejó."
La señal canaria del bug es: el resultado del algoritmo cambia con el orden de iteración. Si una corrida con estaciones ordenadas alfabéticamente difiere de otra con orden por código, hay un acoplamiento entre iteraciones que debe romperse.
Equivalentes en otros dominios: - Algoritmos jerárquicos (Bayesianos): los hyperparámetros del nivel padre no se re-estiman con los priors del nivel hijo. - Cross-validation espacial: en LOO-CV geográfico, el modelo del fold k no ve ningún punto del fold k. - Calibración entre estaciones (cokriging): la matriz de covarianza se calcula con datos no intervenidos, no con los re-imputados.
Consecuencias¶
- El resultado de
flag_spatial_episodeses ahora determinista respecto al orden de iteración (testtests/test_regression_pr6.py::test_b2_orden_estaciones_estable). - Cualquier extensión futura (ej. clustering espacial de episodios, kriging
bayesiano sobre flags) debe heredar el mismo invariante: las estadísticas
inter-estación se calculan sobre observaciones
original. - Este ADR es referenciado desde el docstring de
flag_spatial_episodesy dedetect_regional_episodes.