Más sobre forecasting en: cienciadedatos.net


Data drift

En el contexto del forecasting y el machine learning, el data drift se refiere a un cambio en las propiedades estadísticas de los datos de entrada a lo largo del tiempo respecto a los datos con los que se entrenó originalmente el modelo. Cuando esto ocurre, el modelo puede empezar a generar predicciones menos precisas o poco fiables, ya que deja de generalizar bien a la nueva distribución de los datos.

El data drift puede presentarse de varias formas:

  • Covariate Drift (Feature Drift): cambia la distribución de las variables de entrada, pero la relación entre estas y la variable respuesta se mantiene. Ejemplo: un modelo se entrenó cuando una variable tomaba valores en un determinado rango. Si con el tiempo esa variable pasa a tomar valores en un rango distinto, se produce covariate drift.

  • Prior Probability Drift (Label Drift): cambia la distribución de la variable respuesta. Ejemplo: un modelo entrenado para predecir el consumo energético durante una estación puede fallar si los patrones estacionales cambian debido a factores externos.

  • Concept Drift: cambia la relación entre las variables de entrada y la variable respuesta. Ejemplo: un modelo que predice el consumo energético a partir de datos meteorológicos puede fallar si nuevas tecnologías o comportamientos alteran la forma en que el clima afecta al consumo.

Detectar y gestionar el data drift es fundamental para mantener la fiabilidad de los modelos en producción. Algunas estrategias habituales son:

  • Monitorizar los datos de entrada durante la predicción para detectar cambios cuanto antes.

  • Hacer seguimiento de las métricas de rendimiento del modelo (por ejemplo, MAE, RMSE) a lo largo del tiempo.

  • Reentrenar los modelos periódicamente con datos recientes para adaptarlos a la evolución de las condiciones.

Skforecast incluye dos clases específicas para la detección de data drift:

  • PopulationDriftDetector: detecta cambios a nivel de población, lo que ayuda a identificar cuándo es necesario reentrenar un modelo de forecasting.

  • RangeDriftDetector: detecta cambios a nivel de observación individual, adecuado para validar los datos de entrada durante la fase de predicción.

Detección de drift durante el entrenamiento del modelo

El objetivo último de la detección de drift es responder a una pregunta sencilla pero importante: ¿la distribución de los nuevos datos es distinta de la distribución de los datos de entrenamiento?

Cuando no hay componente temporal y las observaciones son independientes e idénticamente distribuidas (i.i.d.), esta pregunta suele abordarse con tests estadísticos. Estos tests miden algún tipo de distancia entre las distribuciones de los dos conjuntos de datos y calculan un valor de probabilidad (p-value) para determinar si la diferencia es lo suficientemente grande como para indicar un cambio significativo.

Sin embargo, esta aproximación no puede aplicarse directamente a series temporales, cuya distribución cambia de forma natural a lo largo del tiempo debido a patrones como la estacionalidad o la tendencia. Detectar drift en este contexto requiere métodos que tengan en cuenta esta dinámica temporal esperada.

Para ilustrar mejor este concepto, en el siguiente ejemplo se comparan dos meses de una serie temporal con el conjunto de datos completo. En este caso, los meses evaluados se comportan según lo esperado, por lo que no debería detectarse drift.

# Librerías
# ==============================================================================
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from skforecast.datasets import fetch_dataset
from skforecast.plot import set_dark_theme
from skforecast.drift_detection import PopulationDriftDetector
from scipy.stats import ks_2samp
# Datos
# ==============================================================================
datos = fetch_dataset('bike_sharing', verbose=False)
datos = datos[['temp', 'hum']]
display(datos.head())
datos_train = datos.iloc[: 9000].copy()
temp hum
date_time
2011-01-01 00:00:00 9.84 81.0
2011-01-01 01:00:00 9.02 80.0
2011-01-01 02:00:00 9.02 80.0
2011-01-01 03:00:00 9.84 75.0
2011-01-01 04:00:00 9.84 75.0
# Gráfico de los datos de referencia y del segmento de dos meses a evaluar
# ==============================================================================
set_dark_theme()
fig, ax = plt.subplots(figsize=(8, 3))
inicio_datos_test = '2011-12-01 22:00:00'
fin_datos_test = '2012-01-31 23:00:00'
datos_referencia = datos_train.loc[:, 'temp'].copy()
datos_test = datos_train.loc[inicio_datos_test:fin_datos_test, 'temp'].copy()
datos_referencia.plot(ax=ax, label='Datos de referencia')
datos_test.plot(ax=ax, label='Datos test')
ax.legend();

Se utiliza un test de Kolmogorov-Smirnov para comparar la distribución del segmento de dos meses seleccionado con la de la serie de referencia completa. La hipótesis nula establece que ambas muestras proceden de la misma distribución. Si el p-value es inferior a un determinado umbral (habitualmente 0.05), se rechaza la hipótesis nula, lo que indica que las distribuciones son significativamente distintas y sugiere la presencia de drift.

# Test de Kolmogorov-Smirnov para comparar ambos conjuntos de datos
# ==============================================================================
ks_2samp(datos_referencia, datos_test)
KstestResult(statistic=np.float64(0.4571829521829522), pvalue=np.float64(8.54562775188432e-166), statistic_location=np.float64(18.86), statistic_sign=np.int8(-1))
# Gráficos para comparar ambos conjuntos de datos
# ==============================================================================
fig, axs = plt.subplots(ncols=2, figsize=(9, 3))
sns.kdeplot(datos_referencia, label='Datos de referencia', color='#30a2da', ax=axs[0])
sns.kdeplot(datos_test, label='Datos test', color='red', ax=axs[0])
axs[0].set_title('Comparación de distribuciones')
axs[0].set_ylabel('Densidad')
axs[0].legend()

sns.ecdfplot(datos_referencia, label='Datos de referencia', color='#30a2da', ax=axs[1])
sns.ecdfplot(datos_test, label='Datos test', color='red', ax=axs[1])
axs[1].set_title('Comparación de distribuciones acumuladas')
axs[1].set_ylabel('Probabilidad acumulada')
axs[1].legend();

Tanto el test estadístico como las visualizaciones anteriores muestran una clara diferencia entre las distribuciones, aunque se sabe que no existe drift. Esto pone de manifiesto la importancia de utilizar métodos diseñados específicamente para series temporales a la hora de detectar drift, ya que los tests estadísticos tradicionales pueden no ser adecuados debido a las dependencias temporales y los patrones inherentes a este tipo de datos.

Marco basado en distancias para la detección de drift temporal

Este marco implementa una aproximación basada en distancias y guiada por los datos para detectar drift temporal (cambios en la distribución subyacente de los datos a lo largo del tiempo) en series temporales. Construye una referencia empírica del comportamiento normal a partir de datos históricos (de referencia) y la utiliza para evaluar si los nuevos datos observados se desvían significativamente de la norma establecida.

La aproximación es independiente tanto del modelo como de la distancia: puede emplearse cualquier distancia o divergencia estadística que cuantifique la disimilitud entre muestras de datos (por ejemplo, Kolmogorov-Smirnov, Chi-cuadrado, divergencia de Jensen-Shannon u otras métricas adecuadas).

1. Fase de referencia: estimación de la distribución empírica

El primer paso consiste en caracterizar la variabilidad natural de la serie temporal en condiciones estables.

  1. Seleccionar una ventana de referencia Se elige un segmento histórico de la serie temporal que represente un comportamiento estable y libre de drift. Este segmento actúa como conjunto de datos de referencia.

  2. Segmentar los datos de referencia Se divide la serie temporal de referencia en chunks de igual longitud y sin solapamiento: $$ \{ C_1, C_2, \ldots, C_n \} $$ Cada chunk $C_i$ corresponde a una ventana temporal fija (por ejemplo, una semana, un mes o un número fijo de observaciones).

  3. Calcular las distancias Para cada chunk $C_i$, se calcula su distancia respecto al resto del conjunto de referencia (o a una agregación representativa del mismo). Con ello se obtiene un conjunto de distancias: $$ \mathbb{D}_{ref} = \{ d_1, d_2, \ldots, d_n \} $$

  4. Construir la distribución empírica El conjunto $\mathbb{D}_{ref}$ representa la distribución de las distancias en condiciones normales (sin drift). Cuantifica el nivel típico de disimilitud entre segmentos de datos estables.

  5. Definir un umbral de drift Se selecciona un cuantil (por ejemplo, el percentil 95) de la distribución empírica $\mathbb{D}_{ref}$ como umbral de drift: $$ \tau = Q_{0.95}(\mathbb{D}_{ref}) $$ Cualquier distancia superior a $\tau$ indica una desviación mayor de la esperada bajo la variabilidad normal.


Detección de drift poblacional - Animación

2. Fase de monitorización: detección de drift en nuevos datos

Una vez establecida la distribución de referencia, los nuevos datos pueden evaluarse de forma continua para detectar drift.

  1. Dividir los nuevos datos en chunks A medida que se dispone de nuevas observaciones, se segmentan en chunks de la misma longitud que la utilizada en la fase de referencia: $$ \{ C'_1, C'_2, \ldots, C'_m \} $$

  2. Calcular las distancias respecto a la referencia Para cada nuevo chunk $C'_j$, se calcula su distancia respecto a la referencia (ya sea frente a todos los chunks de referencia o frente a una representación agregada de la distribución de referencia).

  3. Comparar con el umbral

    • Si $d(C'_j, \text{referencia}) \leq \tau$, los datos son coherentes con la distribución de referencia.
    • Si $d(C'_j, \text{referencia}) > \tau$, el chunk se marca como posible drift.
  4. Interpretación Un chunk marcado indica que el nuevo segmento de datos difiere significativamente de la norma histórica, lo que apunta a un posible drift poblacional o cambio de concepto. Estos casos pueden requerir un análisis más detallado, el reentrenamiento del modelo o ajustes en el pipeline de datos.

PopulationDriftDetector

La clase PopulationDriftDetector está diseñada para detectar feature drift y label drift en series temporales. Evalúa si la distribución de las variables de entrada (tanto la variable respuesta como las exógenas) se mantiene coherente con la de los datos utilizados para entrenar el modelo de forecasting.

Comparando las observaciones recientes con los datos de entrenamiento, el detector identifica cambios significativos en la distribución que pueden indicar que el modelo necesita reentrenarse.

Las métricas estadísticas utilizadas dependen del tipo de dato:

  • Variables numéricas: estadístico de Kolmogorov-Smirnov y distancia de Jensen-Shannon.

  • Variables categóricas: estadístico Chi-cuadrado y distancia de Jensen-Shannon.

La API sigue los mismos principios de diseño que los forecasters de Skforecast:

  • Los mismos datos utilizados para entrenar un forecaster pueden emplearse para ajustar un PopulationDriftDetector.

  • Cuando se dispone de nuevos datos históricos (es decir, varias observaciones nuevas), puede utilizarse el método predict para comprobar si existe drift.

  • Si se detecta drift, conviene analizar su causa y valorar si es necesario reentrenar o recalibrar el modelo de forecasting.

✏️ Nota

Esta implementación está inspirada en la detección univariante de drift de NannyML, pero ofrece una adaptación ligera ajustada al contexto de series temporales de Skforecast.

  • Eficiente en memoria: el detector no almacena los datos de referencia completos. En su lugar, guarda únicamente los estadísticos precalculados necesarios para evaluar el drift de forma eficiente durante la predicción.
  • Umbrales empíricos: además de los umbrales basados en la desviación estándar, los umbrales pueden obtenerse a partir del cuantil indicado de las distribuciones empíricas calculadas con los chunks de referencia, utilizando leave-one-chunk-out para evitar el sesgo de compararse consigo mismo.
  • Detección de valores fuera de rango: también comprueba si hay valores fuera de rango en las variables numéricas y categorías no vistas en las variables categóricas.
  • Soporte para múltiples series temporales: puede trabajar con múltiples series temporales, cada una con sus propias variables exógenas.

Si se necesitan funcionalidades más avanzadas, como la detección de drift multivariante o controles de calidad de los datos, se recomienda utilizar directamente NannyML.

Para ilustrar el funcionamiento de la detección de drift, el conjunto de datos se divide en un conjunto de entrenamiento y una partición de datos nuevos, simulando un escenario real en el que se dispone de datos adicionales después de haber entrenado el modelo.

Para emular el data drift, se modifica intencionadamente la variable temp en la partición de datos nuevos:

  • Junio: las temperaturas se incrementan en +10 °C.

  • Julio: las temperaturas se incrementan en +20 °C.

  • Octubre: las temperaturas se sustituyen por un valor constante igual a la media de los datos originales. Aunque este valor se encuentra dentro del rango original, su falta de variabilidad lo hace estadísticamente atípico.

  • Diciembre: las temperaturas se reducen en 10 °C.

La variable hum se mantiene sin cambios en toda la partición de datos nuevos y actúa como variable de control para demostrar que el detector no identifica drift cuando no existe.

# Datos
# ==============================================================================
datos = fetch_dataset('bike_sharing', verbose=False)
datos = datos[['temp', 'hum']]
datos_train = datos.iloc[: 9000].copy()
datos_nuevos = datos.iloc[9000:].copy()
# Introducir cambios en la distribución
# ==============================================================================
datos_nuevos_drift = datos_nuevos.copy()

# Sumar 10 a las observaciones de junio de 2012
datos_nuevos_drift.loc['2012-06-01 00:00:00':'2012-06-30 23:00:00', 'temp'] = (
    datos_nuevos_drift.loc['2012-06-01 00:00:00':'2012-06-30 23:00:00', 'temp'] + 10
)

# Sumar 20 a las observaciones de julio de 2012
datos_nuevos_drift.loc['2012-07-01 00:00:00':'2012-07-31 23:00:00', 'temp'] = (
    datos_nuevos_drift.loc['2012-07-01 00:00:00':'2012-07-31 23:00:00', 'temp'] + 20
)

# Valor medio constante en octubre de 2012
datos_nuevos_drift.loc['2012-10-01 00:00:00':'2012-10-31 23:00:00', 'temp'] = (
    datos_nuevos_drift.loc['2012-10-01 00:00:00':'2012-10-31 23:00:00', 'temp'].mean()
)

# Restar 10 a las observaciones de diciembre de 2012
datos_nuevos_drift.loc['2012-12-01 00:00:00':'2012-12-31 23:00:00', 'temp'] = (
    datos_nuevos_drift.loc['2012-12-01 00:00:00':'2012-12-31 23:00:00', 'temp'] - 10
)

# Gráfico
# ==============================================================================
set_dark_theme()
fig, ax = plt.subplots(figsize=(8, 4))
datos_train.loc[:, 'temp'].plot(ax=ax, label='Train')
datos_nuevos_drift.loc[:, 'temp'].plot(
    ax=ax, label='Datos nuevos con drift', color='red'
)
datos_nuevos.loc[:, 'temp'].plot(ax=ax, label='Datos nuevos', color='green')
ax.axhline(
    datos_train['temp'].max(), color='white', linestyle=':', label='Máximo train'
)
ax.axhline(
    datos_train['temp'].min(), color='white', linestyle=':', label='Mínimo train'
)
ax.legend();

Al crear una instancia de PopulationDriftDetector, deben especificarse cuatro argumentos clave:

  • chunk_size: define el número de observaciones de cada chunk de datos utilizado para comparar distribuciones. Un tamaño de chunk menor permite comprobar el drift con más frecuencia, pero puede aumentar los falsos positivos debido a una mayor variabilidad. Por el contrario, un tamaño de chunk mayor suaviza la variabilidad, pero puede retrasar la detección del drift. El valor óptimo depende del equilibrio entre sensibilidad y estabilidad que requiera cada aplicación y conjunto de datos.

  • threshold: especifica el umbral utilizado para determinar si se ha producido drift. Cuanto mayor es el umbral, más conservador es el detector a la hora de señalar drift. Su interpretación depende de threshold_method: con 'std' es el número de desviaciones estándar por encima de la media; con 'quantile' es el nivel del cuantil (por ejemplo, 0.95) de la distribución empírica.

  • threshold_method: determina cómo se calcula el umbral a partir de la distribución empírica. Las opciones son:

    • 'std' para umbrales basados en la desviación estándar. Los umbrales se calculan como media + threshold * desviación estándar. Este método es computacionalmente más rápido, ya que no requiere leave-one-chunk-out.
    • 'quantile' para umbrales basados en cuantiles. Los umbrales se obtienen a partir del cuantil indicado de la distribución empírica, utilizando leave-one-chunk-out para evitar el sesgo de compararse consigo mismo.
  • max_out_of_range_proportion: proporción máxima permitida de observaciones fuera del rango de valores de referencia en las variables numéricas. Si la proporción de valores fuera de rango en un nuevo chunk supera este umbral, se señala drift en la variable correspondiente. Este parámetro proporciona una protección adicional frente a cambios en la distribución que se manifiestan como valores fuera del rango observado en los datos de referencia.

# Ajustar el detector con los datos de entrenamiento
# ==============================================================================
detector = PopulationDriftDetector(
               chunk_size                  = 'MS',  # Chunks mensuales
               threshold                   = 3,
               threshold_method            = 'std',
               max_out_of_range_proportion = 0.1
           )
detector.fit(datos_train)
detector

PopulationDriftDetector

General Information
  • Chunk size: MS
  • Threshold: 3
  • Threshold method: std
  • Max out of range proportion: 0.1
  • Is fitted: True
  • Skforecast version: 0.25.0
Fitted features
    ['temp', 'hum']

📖 API Reference    📝 User Guide

Una vez ajustado, el detector puede utilizarse para evaluar nuevos datos mediante el método predict. Este método devuelve dos DataFrames:

  • Resultados detallados: contienen información sobre los estadísticos calculados, los umbrales y el estado de drift de cada chunk de datos.

  • Resumen de resultados: ofrece una visión general con el número y el porcentaje de chunks en los que se ha detectado drift.

# Detectar drift en los nuevos datos
# ==============================================================================
resultados_drift, resumen_drift = detector.predict(datos_nuevos_drift)
# Resultados detallados del drift
# ==============================================================================
resultados_drift
chunk chunk_start chunk_end feature ks_statistic ks_threshold chi2_statistic chi2_threshold js_statistic js_threshold reference_range prop_out_of_range max_out_of_range_proportion is_out_of_range drift_ks_statistic drift_chi2_statistic drift_js drift_detected
0 0 2012-01-11 2012-01-31 23:00:00 temp 0.490175 0.904773 NaN NaN 0.546958 0.806410 (0.8200000000000001, 39.36) 0.000000 0.1 False False False False False
1 1 2012-02-01 2012-02-29 23:00:00 temp 0.477663 0.904773 NaN NaN 0.523748 0.806410 (0.8200000000000001, 39.36) 0.000000 0.1 False False False False False
2 2 2012-03-01 2012-03-31 23:00:00 temp 0.232412 0.904773 NaN NaN 0.373938 0.806410 (0.8200000000000001, 39.36) 0.000000 0.1 False False False False False
3 3 2012-04-01 2012-04-30 23:00:00 temp 0.217000 0.904773 NaN NaN 0.455947 0.806410 (0.8200000000000001, 39.36) 0.000000 0.1 False False False False False
4 4 2012-05-01 2012-05-31 23:00:00 temp 0.443082 0.904773 NaN NaN 0.539446 0.806410 (0.8200000000000001, 39.36) 0.000000 0.1 False False False False False
5 5 2012-06-01 2012-06-30 23:00:00 temp 0.902111 0.904773 NaN NaN 0.877304 0.806410 (0.8200000000000001, 39.36) 0.344444 0.1 True False False True True
6 6 2012-07-01 2012-07-31 23:00:00 temp 1.000000 0.904773 NaN NaN 1.000000 0.806410 (0.8200000000000001, 39.36) 1.000000 0.1 True True False True True
7 7 2012-08-01 2012-08-31 23:00:00 temp 0.637269 0.904773 NaN NaN 0.652528 0.806410 (0.8200000000000001, 39.36) 0.000000 0.1 False False False False False
8 8 2012-09-01 2012-09-30 23:00:00 temp 0.446389 0.904773 NaN NaN 0.518331 0.806410 (0.8200000000000001, 39.36) 0.000000 0.1 False False False False False
9 9 2012-10-01 2012-10-31 23:00:00 temp 0.537556 0.904773 NaN NaN 0.863793 0.806410 (0.8200000000000001, 39.36) 0.000000 0.1 False False False True True
10 10 2012-11-01 2012-11-30 23:00:00 temp 0.468611 0.904773 NaN NaN 0.562611 0.806410 (0.8200000000000001, 39.36) 0.000000 0.1 False False False False False
11 11 2012-12-01 2012-12-31 23:00:00 temp 0.860731 0.904773 NaN NaN 0.843966 0.806410 (0.8200000000000001, 39.36) 0.358871 0.1 True False False True True
12 0 2012-01-11 2012-01-31 23:00:00 hum 0.130825 0.385918 NaN NaN 0.152290 0.423199 (0.0, 100.0) 0.000000 0.1 False False False False False
13 1 2012-02-01 2012-02-29 23:00:00 hum 0.161425 0.385918 NaN NaN 0.199334 0.423199 (0.0, 100.0) 0.000000 0.1 False False False False False
14 2 2012-03-01 2012-03-31 23:00:00 hum 0.119387 0.385918 NaN NaN 0.150733 0.423199 (0.0, 100.0) 0.000000 0.1 False False False False False
15 3 2012-04-01 2012-04-30 23:00:00 hum 0.278944 0.385918 NaN NaN 0.328472 0.423199 (0.0, 100.0) 0.000000 0.1 False False False False False
16 4 2012-05-01 2012-05-31 23:00:00 hum 0.093703 0.385918 NaN NaN 0.205141 0.423199 (0.0, 100.0) 0.000000 0.1 False False False False False
17 5 2012-06-01 2012-06-30 23:00:00 hum 0.171722 0.385918 NaN NaN 0.240059 0.423199 (0.0, 100.0) 0.000000 0.1 False False False False False
18 6 2012-07-01 2012-07-31 23:00:00 hum 0.103219 0.385918 NaN NaN 0.178075 0.423199 (0.0, 100.0) 0.000000 0.1 False False False False False
19 7 2012-08-01 2012-08-31 23:00:00 hum 0.110520 0.385918 NaN NaN 0.196713 0.423199 (0.0, 100.0) 0.000000 0.1 False False False False False
20 8 2012-09-01 2012-09-30 23:00:00 hum 0.076111 0.385918 NaN NaN 0.196889 0.423199 (0.0, 100.0) 0.000000 0.1 False False False False False
21 9 2012-10-01 2012-10-31 23:00:00 hum 0.125477 0.385918 NaN NaN 0.217908 0.423199 (0.0, 100.0) 0.000000 0.1 False False False False False
22 10 2012-11-01 2012-11-30 23:00:00 hum 0.217556 0.385918 NaN NaN 0.280111 0.423199 (0.0, 100.0) 0.000000 0.1 False False False False False
23 11 2012-12-01 2012-12-31 23:00:00 hum 0.096502 0.385918 NaN NaN 0.187856 0.423199 (0.0, 100.0) 0.000000 0.1 False False False False False
# Resumen del drift
# ==============================================================================
resumen_drift
feature n_chunks_with_drift pct_chunks_with_drift chunks_with_drift
0 hum 0 0.000000 []
1 temp 4 33.333333 [5, 6, 9, 11]

Tal y como se esperaba, el detector identifica drift en los datos nuevos modificados, mientras que no detecta drift en los datos sin alterar. Los chunks 5, 6, 9 y 11 corresponden a junio, julio, octubre y diciembre de 2012, los cuatro meses en los que se modificaron los valores de temp. La variable de control hum no muestra drift, como era de esperar.

Las columnas de resultados_drift muestran qué criterio ha provocado cada detección (un chunk se marca si al menos uno de ellos supera su umbral):

  • Julio (+20 °C) es el único chunk marcado por los tres criterios: Kolmogorov-Smirnov, Jensen-Shannon y valores fuera de rango.

  • Junio (+10 °C) y diciembre (-10 °C) se marcan por la distancia de Jensen-Shannon y por la proporción de valores fuera de rango (en torno al 34% y al 36%, muy por encima del 10% permitido), mientras que su estadístico de Kolmogorov-Smirnov se queda justo por debajo del umbral.

  • Octubre (valor constante) solo se marca por la distancia de Jensen-Shannon. El valor constante se encuentra dentro del rango de entrenamiento, por lo que la comprobación de valores fuera de rango no puede detectarlo, pero la concentración de la distribución en un único valor sí queda recogida por la distancia entre histogramas.

Esto muestra por qué combinar varios criterios complementarios hace que el detector sea más robusto que si se basara en un único estadístico.

# Resaltar los chunks con drift detectado
# ==============================================================================
set_dark_theme()
fig, ax = plt.subplots(figsize=(8, 4))
datos_train.loc[:, 'temp'].plot(ax=ax, label='Train')
datos_nuevos_drift.loc[:, 'temp'].plot(
    ax=ax, label='Datos nuevos con drift', color='red'
)
datos_nuevos.loc[:, 'temp'].plot(ax=ax, label='Datos nuevos', color='green')
ax.axhline(
    datos_train['temp'].max(), color='white', linestyle=':', label='Máximo train'
)
ax.axhline(
    datos_train['temp'].min(), color='white', linestyle=':', label='Mínimo train'
)
for row in resultados_drift.query('drift_detected == True').itertuples():
    ax.axvspan(
        row.chunk_start, row.chunk_end, color='red', alpha=0.3,
        label='Drift detectado'
    )

# Eliminar etiquetas repetidas en la leyenda
handles, labels = ax.get_legend_handles_labels()
by_label = dict(zip(labels, handles))
ax.legend(by_label.values(), by_label.keys());

PopulationDriftDetector puede utilizarse con múltiples series temporales de forma simultánea, cada una con sus propias variables. En este caso, los datos de entrada deben ser un DataFrame de pandas con un MultiIndex, en el que el primer nivel es el identificador de la serie y el segundo corresponde al índice temporal.

# Datos multiserie
# ==============================================================================
datos_multiserie = pd.concat(
    [
        datos.assign(series='series_1'),
        datos.assign(series='series_2'),
        datos.assign(series='series_3')
    ]
).set_index('series', append=True).swaplevel(0, 1)
display(datos_multiserie)

# División train/nuevos datos para cada serie
datos_multiserie_train = (
    datos_multiserie
    .groupby(level='series', group_keys=False)
    .apply(lambda x: x.iloc[:9000])
)

datos_multiserie_nuevos = (
    datos_multiserie
    .groupby(level='series', group_keys=False)
    .apply(lambda x: x.iloc[9000:])
)
temp hum
series date_time
series_1 2011-01-01 00:00:00 9.84 81.0
2011-01-01 01:00:00 9.02 80.0
2011-01-01 02:00:00 9.02 80.0
2011-01-01 03:00:00 9.84 75.0
2011-01-01 04:00:00 9.84 75.0
... ... ... ...
series_3 2012-12-31 19:00:00 10.66 60.0
2012-12-31 20:00:00 10.66 60.0
2012-12-31 21:00:00 10.66 60.0
2012-12-31 22:00:00 10.66 56.0
2012-12-31 23:00:00 10.66 65.0

52632 rows × 2 columns

# PopulationDriftDetector multiserie
# ==============================================================================
detector = PopulationDriftDetector(
               chunk_size                  = 'MS',  # Chunks mensuales
               threshold                   = 3,
               threshold_method            = 'std',
               max_out_of_range_proportion = 0.1
           )
detector.fit(datos_multiserie_train)
detector

PopulationDriftDetector

General Information
  • Chunk size: MS
  • Threshold: 3
  • Threshold method: std
  • Max out of range proportion: 0.1
  • Is fitted: True
  • Skforecast version: 0.25.0
Fitted features
    {'series_1': ['temp', 'hum'], 'series_2': ['temp', 'hum'], 'series_3': ['temp', 'hum']}

📖 API Reference    📝 User Guide

# Umbrales de cada serie
# ==============================================================================
detector.get_thresholds()
series_id feature ks_threshold chi2_threshold js_threshold max_out_of_range_proportion
0 series_1 temp 0.904773 NaN 0.806410 0.1
1 series_1 hum 0.385918 NaN 0.423199 0.1
2 series_2 temp 0.904773 NaN 0.806410 0.1
3 series_2 hum 0.385918 NaN 0.423199 0.1
4 series_3 temp 0.904773 NaN 0.806410 0.1
5 series_3 hum 0.385918 NaN 0.423199 0.1
# Detectar drift en los nuevos datos multiserie
# ==============================================================================
resultados_drift, resumen_drift = detector.predict(datos_multiserie_nuevos)
resumen_drift
series_id feature n_chunks_with_drift pct_chunks_with_drift chunks_with_drift
0 series_1 hum 0 0.0 []
1 series_1 temp 0 0.0 []
2 series_2 hum 0 0.0 []
3 series_2 temp 0 0.0 []
4 series_3 hum 0 0.0 []
5 series_3 temp 0 0.0 []

Como los nuevos datos multiserie no se han modificado, no se detecta drift en ninguna de las series.

Detección de drift durante la predicción

Skforecast proporciona la clase RangeDriftDetector para detectar covariate drift tanto en una única serie temporal como en múltiples series, así como en las variables exógenas.

El detector comprueba si los datos de entrada (lags y variables exógenas) utilizados para predecir nuevos valores se encuentran dentro del rango de los datos utilizados para entrenar el modelo.

Su API sigue el mismo diseño que los forecasters:

  • Los datos utilizados para entrenar un forecaster pueden emplearse también para ajustar el RangeDriftDetector.

  • Los datos que se pasan al método predict del forecaster pueden pasarse también al método predict del RangeDriftDetector para comprobar si hay drift en los datos de entrada antes de generar las predicciones.

  • Si se detecta drift, conviene analizar su causa y valorar si el modelo sigue siendo adecuado para predecir con los nuevos datos.

# Librerías
# ==============================================================================
from sklearn.ensemble import HistGradientBoostingRegressor
from skforecast.recursive import ForecasterRecursive
from skforecast.drift_detection import RangeDriftDetector

Detección de valores fuera de rango en una única serie

El RangeDriftDetector comprueba si los valores de una serie temporal siguen siendo coherentes con los datos vistos durante el entrenamiento.

  • En las variables numéricas, verifica que cada nuevo valor se encuentre dentro del rango entre el mínimo y el máximo de los datos de entrenamiento. Los valores fuera de este rango se señalan como posible drift.

  • En las variables categóricas, comprueba si cada nueva categoría se observó durante el entrenamiento. Las categorías no vistas se señalan como posible drift.

Este mecanismo permite identificar rápidamente cuándo el modelo recibe datos de entrada distintos de aquellos con los que se entrenó, lo que ayuda a decidir si conviene reentrenar el modelo o ajustar el preprocesado.

# Datos simulados
# ==============================================================================
rng = np.random.default_rng(123)
y_train = pd.Series(
    rng.normal(loc=10, scale=2, size=100),
    index=pd.date_range(start='2020-01-01', periods=100),
    name='y',
)
exog_train = pd.DataFrame(
    {
        'exog_1': rng.normal(loc=10, scale=2, size=100),
        'exog_2': rng.choice(['A', 'B', 'C', 'D', 'E'], size=100),
    },
    index=y_train.index,
)

display(y_train.head())
display(exog_train.head())
2020-01-01     8.021757
2020-01-02     9.264427
2020-01-03    12.575851
2020-01-04    10.387949
2020-01-05    11.840462
Freq: D, Name: y, dtype: float64
exog_1 exog_2
2020-01-01 8.968465 B
2020-01-02 13.316227 B
2020-01-03 9.405475 A
2020-01-04 7.233246 A
2020-01-05 9.437591 A
# Entrenar RangeDriftDetector
# ==============================================================================
detector = RangeDriftDetector()
detector.fit(y=y_train, exog=exog_train)
detector

RangeDriftDetector

General Information
  • Fitted series: y
  • Fitted exogenous: exog_1, exog_2
  • Series-specific exogenous: False
  • Is fitted: True
  • Skforecast version: 0.25.0
Series value ranges
    {'y': (5.5850578036003915, 14.579819894629157)}
Exogenous value ranges
    {'exog_1': (4.5430286262543085, 14.531041199734418), 'exog_2': {'E', 'B', 'D', 'C', 'A'}}

📖 API Reference    📝 User Guide

Supóngase que el modelo está desplegado en producción y que se utilizan nuevos datos para predecir valores futuros. Se simula un covariate drift en la serie objetivo y en las variables exógenas para ilustrar cómo utilizar la clase RangeDriftDetector para detectarlo.

# Predicción con datos con drift
# ==============================================================================
last_window = pd.Series(
    [6.6, 7.5, 100, 9.3, 10.2], name='y'  # El valor 100 está fuera de rango
)
exog_predict = pd.DataFrame(
    {
        'exog_1': [8, 9, 10, 70, 12],         # El valor 70 está fuera de rango
        'exog_2': ['A', 'B', 'C', 'D', 'W'],  # El valor 'W' está fuera de rango
    }
)

flag_out_of_range, series_out_of_range, exog_out_of_range = detector.predict(
    last_window       = last_window,
    exog              = exog_predict,
    verbose           = True,
    suppress_warnings = False
)

print('Fuera de rango detectado  :', flag_out_of_range)
print('Series fuera de rango     :', series_out_of_range)
print('Exógenas fuera de rango   :', exog_out_of_range)
╭────────────────────────────── FeatureOutOfRangeWarning ──────────────────────────────╮
│ 'y' has values outside the range seen during training [5.58506, 14.57982]. This may  │
│ affect the accuracy of the predictions.                                              │
│                                                                                      │
│ Category : skforecast.exceptions.FeatureOutOfRangeWarning                            │
│ Location :                                                                           │
│ C:\Users\Joaquin\miniconda3\envs\skforecast_24_py13\Lib\site-packages\skforecast\dri │
│ ft_detection\_range_drift.py:284                                                     │
│ Suppress : warnings.simplefilter('ignore', category=FeatureOutOfRangeWarning)        │
╰──────────────────────────────────────────────────────────────────────────────────────╯
╭────────────────────────────── FeatureOutOfRangeWarning ──────────────────────────────╮
│ 'exog_1' has values outside the range seen during training [4.54303, 14.53104]. This │
│ may affect the accuracy of the predictions.                                          │
│                                                                                      │
│ Category : skforecast.exceptions.FeatureOutOfRangeWarning                            │
│ Location :                                                                           │
│ C:\Users\Joaquin\miniconda3\envs\skforecast_24_py13\Lib\site-packages\skforecast\dri │
│ ft_detection\_range_drift.py:284                                                     │
│ Suppress : warnings.simplefilter('ignore', category=FeatureOutOfRangeWarning)        │
╰──────────────────────────────────────────────────────────────────────────────────────╯
╭────────────────────────────── FeatureOutOfRangeWarning ──────────────────────────────╮
│ 'exog_2' has values not seen during training. Seen values: {'E', 'B', 'D', 'C',      │
│ 'A'}. This may affect the accuracy of the predictions.                               │
│                                                                                      │
│ Category : skforecast.exceptions.FeatureOutOfRangeWarning                            │
│ Location :                                                                           │
│ C:\Users\Joaquin\miniconda3\envs\skforecast_24_py13\Lib\site-packages\skforecast\dri │
│ ft_detection\_range_drift.py:284                                                     │
│ Suppress : warnings.simplefilter('ignore', category=FeatureOutOfRangeWarning)        │
╰──────────────────────────────────────────────────────────────────────────────────────╯
╭───────────────────────────── Out-of-range summary ──────────────────────────────╮
│ Series:                                                                         │
│ 'y' has values outside the observed range [5.58506, 14.57982].                  │
│                                                                                 │
│ Exogenous Variables:                                                            │
│ 'exog_1' has values outside the observed range [4.54303, 14.53104].             │
│ 'exog_2' has values not seen during training. Seen values: {'E', 'B', 'D', 'C', │
│ 'A'}.                                                                           │
╰─────────────────────────────────────────────────────────────────────────────────╯
Fuera de rango detectado  : True
Series fuera de rango     : ['y']
Exógenas fuera de rango   : ['exog_1', 'exog_2']

Detección de valores fuera de rango en múltiples series

El mismo proceso se aplica cuando se modelan múltiples series temporales.

  • Para cada serie, el RangeDriftDetector comprueba si los nuevos valores se mantienen dentro del rango de los datos de entrenamiento.

  • Si se incluyen variables exógenas, estas se comprueban agrupadas por serie, de forma que el drift se detecta en el contexto correcto.

Esto permite monitorizar el drift a nivel de cada serie, lo que facilita detectar problemas en series concretas sin que los resultados agregados lleven a conclusiones erróneas.

# Datos simulados: múltiples series temporales
# ==============================================================================
idx = pd.MultiIndex.from_product(
    [
        ['series_1', 'series_2', 'series_3'],
        pd.date_range(start='2020-01-01', periods=3),
    ],
    names=['series_id', 'datetime'],
)
series_train = pd.DataFrame(
    {'values': [1, 2, 3, 10, 20, 30, 100, 200, 300]}, index=idx
)
exog_train = pd.DataFrame(
    {
        'exog_1': [5.0, 6.0, 7.0, 15.0, 25.0, 35.0, 150.0, 250.0, 350.0],
        'exog_2': ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I'],
    },
    index=idx,
)

display(series_train)
display(exog_train)
values
series_id datetime
series_1 2020-01-01 1
2020-01-02 2
2020-01-03 3
series_2 2020-01-01 10
2020-01-02 20
2020-01-03 30
series_3 2020-01-01 100
2020-01-02 200
2020-01-03 300
exog_1 exog_2
series_id datetime
series_1 2020-01-01 5.0 A
2020-01-02 6.0 B
2020-01-03 7.0 C
series_2 2020-01-01 15.0 D
2020-01-02 25.0 E
2020-01-03 35.0 F
series_3 2020-01-01 150.0 G
2020-01-02 250.0 H
2020-01-03 350.0 I
# Entrenar RangeDriftDetector: múltiples series temporales
# ==============================================================================
detector = RangeDriftDetector()
detector.fit(series=series_train, exog=exog_train)
# Datos para la predicción con drift: múltiples series temporales
# ==============================================================================
last_window = pd.DataFrame(
    {
        'series_1': np.array([1.5, 2.3]),
        'series_2': np.array([100, 20]),  # El valor 100 está fuera de rango
        'series_3': np.array([110, 200]),
    },
    index=pd.date_range(start='2020-01-02', periods=2),
)

idx = pd.MultiIndex.from_product(
    [
        ['series_1', 'series_2', 'series_3'],
        pd.date_range(start='2020-01-04', periods=2),
    ],
    names=['series_id', 'datetime'],
)
exog_predict = pd.DataFrame(
    {
        'exog_1': [5.0, 6.1, 10, 70, 220, 290],
        'exog_2': ['A', 'B', 'D', 'F', 'W', 'E'],
    },
    index=idx,
)

display(last_window)
display(exog_predict)
series_1 series_2 series_3
2020-01-02 1.5 100 110
2020-01-03 2.3 20 200
exog_1 exog_2
series_id datetime
series_1 2020-01-04 5.0 A
2020-01-05 6.1 B
series_2 2020-01-04 10.0 D
2020-01-05 70.0 F
series_3 2020-01-04 220.0 W
2020-01-05 290.0 E
# Predicción con datos con drift: múltiples series temporales
# ==============================================================================
flag_out_of_range, series_out_of_range, exog_out_of_range = detector.predict(
    last_window       = last_window,
    exog              = exog_predict,
    verbose           = True,
    suppress_warnings = False
)

print('Fuera de rango detectado  :', flag_out_of_range)
print('Series fuera de rango     :', series_out_of_range)
print('Exógenas fuera de rango   :', exog_out_of_range)
╭────────────────────────────── FeatureOutOfRangeWarning ──────────────────────────────╮
│ 'series_2' has values outside the range seen during training [10.00000, 30.00000].   │
│ This may affect the accuracy of the predictions.                                     │
│                                                                                      │
│ Category : skforecast.exceptions.FeatureOutOfRangeWarning                            │
│ Location :                                                                           │
│ C:\Users\Joaquin\miniconda3\envs\skforecast_24_py13\Lib\site-packages\skforecast\dri │
│ ft_detection\_range_drift.py:284                                                     │
│ Suppress : warnings.simplefilter('ignore', category=FeatureOutOfRangeWarning)        │
╰──────────────────────────────────────────────────────────────────────────────────────╯
╭────────────────────────────── FeatureOutOfRangeWarning ──────────────────────────────╮
│ 'series_2': 'exog_1' has values outside the range seen during training [15.00000,    │
│ 35.00000]. This may affect the accuracy of the predictions.                          │
│                                                                                      │
│ Category : skforecast.exceptions.FeatureOutOfRangeWarning                            │
│ Location :                                                                           │
│ C:\Users\Joaquin\miniconda3\envs\skforecast_24_py13\Lib\site-packages\skforecast\dri │
│ ft_detection\_range_drift.py:284                                                     │
│ Suppress : warnings.simplefilter('ignore', category=FeatureOutOfRangeWarning)        │
╰──────────────────────────────────────────────────────────────────────────────────────╯
╭────────────────────────────── FeatureOutOfRangeWarning ──────────────────────────────╮
│ 'series_3': 'exog_2' has values not seen during training. Seen values: {'H', 'I',    │
│ 'G'}. This may affect the accuracy of the predictions.                               │
│                                                                                      │
│ Category : skforecast.exceptions.FeatureOutOfRangeWarning                            │
│ Location :                                                                           │
│ C:\Users\Joaquin\miniconda3\envs\skforecast_24_py13\Lib\site-packages\skforecast\dri │
│ ft_detection\_range_drift.py:284                                                     │
│ Suppress : warnings.simplefilter('ignore', category=FeatureOutOfRangeWarning)        │
╰──────────────────────────────────────────────────────────────────────────────────────╯
╭────────────────────────────── Out-of-range summary ──────────────────────────────╮
│ Series:                                                                          │
│ 'series_2' has values outside the observed range [10.00000, 30.00000].           │
│                                                                                  │
│ Exogenous Variables:                                                             │
│ 'series_2': 'exog_1' has values outside the observed range [15.00000, 35.00000]. │
│ 'series_3': 'exog_2' has values not seen during training. Seen values: {'H',     │
│ 'I', 'G'}.                                                                       │
╰──────────────────────────────────────────────────────────────────────────────────╯
Fuera de rango detectado  : True
Series fuera de rango     : ['series_2']
Exógenas fuera de rango   : {'series_2': ['exog_1'], 'series_3': ['exog_2']}

Combinar RangeDriftDetector con forecasters

Al desplegar un forecaster en producción, es una buena práctica acompañarlo de un detector de drift. De este modo, ambos se entrenan con el mismo conjunto de datos, y el detector de drift puede verificar los datos de entrada antes de que el forecaster genere las predicciones.

# Datos
# ==============================================================================
datos = fetch_dataset(name='h2o_exog', verbose=False)
datos.index.name = 'datetime'
datos.head(3)
y exog_1 exog_2
datetime
1992-04-01 0.379808 0.958792 1.166029
1992-05-01 0.361801 0.951993 1.117859
1992-06-01 0.410534 0.952955 1.067942
# Entrenar el forecaster y el RangeDriftDetector
# ==============================================================================
steps = 36
datos_train = datos.iloc[:-steps, :]
datos_test  = datos.iloc[-steps:, :]

forecaster = ForecasterRecursive(
                 estimator = HistGradientBoostingRegressor(random_state=123),
                 lags      = 15
             )
detector = RangeDriftDetector()

forecaster.fit(
    y    = datos_train['y'],
    exog = datos_train[['exog_1', 'exog_2']]
)
detector.fit(
    series = datos_train['y'],
    exog   = datos_train[['exog_1', 'exog_2']]
)

Si se utiliza la last_window almacenada en el forecaster, la detección de drift no es necesaria, ya que corresponde a la última ventana de los datos de entrenamiento. Sin embargo, en entornos de producción puede proporcionarse una last_window externa procedente de otro periodo de tiempo. En ese caso, se recomienda aplicar la detección de drift.

En el siguiente ejemplo, la last_window externa es idéntica a la última ventana de entrenamiento, por lo que no se detectará drift.

# Última ventana (igual que forecaster.last_window_)
# ==============================================================================
last_window = datos_train['y'].iloc[-forecaster.max_lag:]
last_window
datetime
2004-04-01    0.739986
2004-05-01    0.795129
2004-06-01    0.856803
2004-07-01    1.001593
2004-08-01    0.994864
2004-09-01    1.134432
2004-10-01    1.181011
2004-11-01    1.216037
2004-12-01    1.257238
2005-01-01    1.170690
2005-02-01    0.597639
2005-03-01    0.652590
2005-04-01    0.670505
2005-05-01    0.695248
2005-06-01    0.842263
Freq: MS, Name: y, dtype: float64
# Comprobar los datos con RangeDriftDetector y predecir con el forecaster
# ==============================================================================
detector.predict(
    last_window       = last_window,
    exog              = datos_test[['exog_1', 'exog_2']],
    verbose           = True,
    suppress_warnings = False
)

predicciones = forecaster.predict(
                   steps       = steps,
                   last_window = last_window,
                   exog        = datos_test[['exog_1', 'exog_2']]
               )
╭───────────────── Out-of-range summary ─────────────────╮
│ Series:                                                │
│ No series with out-of-range values found.              │
│                                                        │
│ Exogenous Variables:                                   │
│ No exogenous variables with out-of-range values found. │
╰────────────────────────────────────────────────────────╯

Información de sesión

import session_info
session_info.show(html=False)
-----
matplotlib          3.10.9
numpy               2.4.6
pandas              2.3.3
scipy               1.18.0
seaborn             0.13.2
session_info        v1.0.1
skforecast          0.25.0
sklearn             1.7.2
-----
IPython             9.15.0
jupyter_client      8.9.1
jupyter_core        5.9.1
-----
Python 3.13.14 | packaged by conda-forge | (main, Jun 12 2026, 09:44:26) [MSC v.1944 64 bit (AMD64)]
Windows-11-10.0.26200-SP0
-----
Session information updated at 2026-09-24 16:53

Instrucciones para citar

¿Cómo citar este documento?

Si utilizas este documento o alguna parte de él, te agradecemos que lo cites. ¡Muchas gracias!

Detección de drift en modelos de forecasting de series temporales por Joaquín Amat Rodrigo y Javier Escobar Ortiz, disponible bajo licencia Attribution-NonCommercial-ShareAlike 4.0 International en https://www.cienciadedatos.net/documentos/py70-deteccion-drift-modelos-forecasting.html

¿Cómo citar skforecast?

Si utilizas skforecast, te agradeceríamos mucho que lo cites. ¡Muchas gracias!

Zenodo:

Amat Rodrigo, Joaquin, & Escobar Ortiz, Javier. (2026). skforecast (v0.25.0). Zenodo. https://doi.org/10.5281/zenodo.8382788

APA:

Amat Rodrigo, J., & Escobar Ortiz, J. (2026). skforecast (Version 0.25.0) [Computer software]. https://doi.org/10.5281/zenodo.8382788

BibTeX:

@software{skforecast, author = {Amat Rodrigo, Joaquin and Escobar Ortiz, Javier}, title = {skforecast}, version = {0.25.0}, month = {09}, year = {2026}, license = {BSD-3-Clause}, url = {https://skforecast.org/}, doi = {10.5281/zenodo.8382788} }


¿Te ha gustado el artículo? Tu ayuda es importante

Tu contribución me ayudará a seguir generando contenido divulgativo gratuito. ¡Muchísimas gracias! 😊

Become a GitHub Sponsor Become a GitHub Sponsor

Creative Commons Licence

Este documento creado por Joaquín Amat Rodrigo y Javier Escobar Ortiz tiene licencia Attribution-NonCommercial-ShareAlike 4.0 International.

Se permite:

  • Compartir: copiar y redistribuir el material en cualquier medio o formato.

  • Adaptar: remezclar, transformar y crear a partir del material.

Bajo los siguientes términos:

  • Atribución: Debes otorgar el crédito adecuado, proporcionar un enlace a la licencia e indicar si se realizaron cambios. Puedes hacerlo de cualquier manera razonable, pero no de una forma que sugiera que el licenciante te respalda o respalda tu uso.

  • No-Comercial: No puedes utilizar el material para fines comerciales.

  • Compartir-Igual: Si remezclas, transformas o creas a partir del material, debes distribuir tus contribuciones bajo la misma licencia que el original.