← Volver al blog

El data leakage que no ves: ML en series de tiempo industriales

Una historia real de un proyecto de Machine Learning sobre datos de planta: un circuito rougher de flotación de cobre con 7 celdas, 34,319 registros cada 10 minutos durante todo el 2024. El objetivo era predecir la recuperación de Cu a partir de las variables operativas.

La primera iteración del modelo obtuvo R² = 0.64 con Random Forest. Números decentes, aparentemente todo en orden. Hasta que empecé a cuestionar algo: ¿cómo se evaluó ese R²?

from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42
)

Ese train_test_split aleatorio es el responsable del problema. En series de tiempo industriales, nunca partas los datos al azar.

El problema con el split aleatorio

Imagina que entrenamos con el minuto 10:00 de un turno y testeamos con el minuto 10:10 del mismo turno. El proceso físico entre esos dos minutos prácticamente no cambió — misma ley de alimentación, mismo pH, misma dosificación. El modelo "ve" la respuesta del instante siguiente casi idéntica a la que tiene en su ejemplo de entrenamiento.

El resultado: el modelo aprende a memorizar el contexto temporal, no a generalizar a condiciones nuevas. Pero la generalización a condiciones nuevas es precisamente lo que necesitas cuando despliegas el modelo en producción, donde mañana es siempre un conjunto de datos que el modelo jamás vio.

Esto se conoce como data leakage temporal y es uno de los errores más comunes — y más caros — en ML aplicado a procesos industriales.

El fix: split cronológico

La regla es simple: lo antiguo entrena, lo nuevo testea.

# Ordenar por fecha
df = df.sort_values('Fecha').reset_index(drop=True)

# Split cronológico: 80% más antiguo / 20% más reciente
cutoff = int(0.8 * len(df))
X_train, X_test = X.iloc[:cutoff], X.iloc[cutoff:]
y_train, y_test = y.iloc[:cutoff], y.iloc[cutoff:]

Y para cross-validation dentro del train, TimeSeriesSplit — que genera folds expandiéndose hacia adelante, nunca "mirando al futuro":

from sklearn.model_selection import TimeSeriesSplit

tscv = TimeSeriesSplit(n_splits=5)
for train_idx, val_idx in tscv.split(X_train):
    # fold 1: train[0:n/5], val[n/5:2n/5]
    # fold 2: train[0:2n/5], val[2n/5:3n/5]
    # ...
    ...

Time series split vs random split

Qué pasó con el R²

Con el split cronológico correcto:

Validación R² (out-of-time)
train_test_split aleatorio 0.64 ← inflado
Split cronológico + TimeSeriesSplit 0.15 ← honesto

El R² honesto es cuatro veces más bajo. Ese 0.64 era literalmente ficción estadística — el modelo no iba a funcionar en planta.

El balance matemático detrás de la métrica

El R² mide cuánto mejor es tu modelo que predecir simplemente la media:

\[R^2 = 1 - \frac{\sum_{i}(y_i - \hat{y}_i)^2}{\sum_{i}(y_i - \bar{y})^2}\]

Con \(R^2 = 0.15\), el modelo explica solo el 15 % de la varianza. En la práctica, muchas veces es casi igual a predecir la media histórica — algo que una regla simple hecha a mano puede hacer.

Por qué el modelo "real" es tan malo

Tres hipótesis comunes para modelos de planta con R² honesto bajo:

  1. Señal realmente débil en las features disponibles. Quizás la recuperación depende fuertemente de variables que no se miden: mineralogía del alimento, temperatura del pulp, desgaste de los impulsores de celda, calidad del agua.
  2. Distribución cambió entre train y test. La planta pudo haber tenido cambios operativos, mantenimientos, ajuste de setpoints, campañas de mineral distinto. El modelo entrenado en el pasado ya no se aplica.
  3. Dinámica de largo plazo no capturada con lags de minutos. Un turno dura 8–12 horas; los efectos de la dosificación de reactivos pueden tardar turnos en estabilizarse.

La lección

No es "deja de usar Random Forest" ni "tus datos son malos". La lección es más profunda:

Una validación incorrecta te da números bonitos pero inútiles. Antes de alegrarte con un R² alto, pregúntate: ¿cómo se construyó el split? Si hay cualquier componente temporal o espacial en los datos (y casi siempre lo hay), el split aleatorio es sospechoso.

Otro ángulo práctico: si al cambiar a validación correcta tu modelo se derrumba, era un espejismo. Mejor descubrirlo en tu notebook que en producción, cuando ya se comprometió un proyecto a un cliente o jefe.

Checklist antes de publicar un resultado de ML

  • [ ] ¿Mi split respeta la estructura temporal/espacial de los datos?
  • [ ] ¿Usé TimeSeriesSplit / GroupKFold según corresponda?
  • [ ] ¿Mi train contiene información que no estará disponible al momento de predecir?
  • [ ] ¿Comparé contra un baseline simple (media, persistence, regresión lineal)?
  • [ ] ¿Reporté el R² del test out-of-time, no el de validación cruzada optimista?

Si respondiste "no" a cualquiera, tu número está inflado. Mejor saberlo ahora.

← Volver al blog