Datos en el tiempo: series temporales y ventanas

Tema 13 · Fase 3, Más modelos, tiempo y estructura · Toda la ruta

En esta ficha · 5 secciones
  1. Antes de empezar
  2. Ejercicios
  3. Mini-proyecto: la temperatura de mañana en Cochabamba
  4. Errores comunes
  5. ¿Lo lograste?

Antes de empezar

Varios ejercicios usan las variables y funciones de la teoría: temp, tabla_ventana, X e y, X_ent, y_ent, X_prueba, y_prueba, mae_hoy, con_huecos, faltan, tss, modelo, clics y los import. Abre un cuaderno nuevo en Colab, pega y ejecuta en orden los bloques de la ficha Código (la primera celda descarga los datos, así que necesitas internet) y después trabaja debajo. Intenta cada ejercicio antes de abrir la respuesta.

Los datos de temperatura son de Weather data by Open-Meteo.com (CC BY 4.0), calculados con el reanálisis ERA5-Land; las cifras de las respuestas son cálculos nuestros a partir de ellos.

Ejercicios

1. Tendencia, estacionalidad y ruido

Para cada serie, di si esperas una tendencia, una estacionalidad (y con qué período) y mucho o poco ruido:

  1. La temperatura media diaria de La Paz.
  2. Los pasajeros mensuales de vuelos internacionales de 1949 a 1960 (tema 4).
  3. La cantidad de personas que suben a un teleférico, hora por hora, durante un mes.
  4. El precio de cierre diario de una acción en la bolsa.
Respuesta
  1. Estacionalidad anual clara (frío a mitad de año) y mucho ruido de un día a otro; casi nada de tendencia en diez años.
  2. Tendencia fuerte hacia arriba (se multiplican por más de cuatro) y estacionalidad anual, con un pico a mitad de año que crece con los años.
  3. Dos estacionalidades a la vez: la del día (horas pico de mañana y de tarde, nada de madrugada) y la de la semana (los días hábiles no se parecen al domingo). La tendencia, en un mes, será pequeña.
  4. Sobre todo ruido, con tendencias que cambian sin aviso y ninguna estacionalidad fuerte. Por eso, para el precio de mañana, «igual que hoy» es una línea base muy difícil de superar.

2. Rezagos a mano

Una serie de cinco días vale 10, 12, 11, 15 y 14. Sin computadora, escribe para cada día el valor de shift(1), de shift(-2) y de rolling(3).mean(). ¿Por qué algunos quedan vacíos? Después compruébalo.

Respuesta

shift(1) le da a cada día el valor del día anterior; shift(-2), el de dos días después; rolling(3).mean(), el promedio de ese día y los dos anteriores. Quedan vacíos (NaN) cuando no existe ese día: el primero no tiene ayer, los dos últimos no tienen «dentro de dos días» y los dos primeros no tienen tres días de historia.

s = pd.Series([10, 12, 11, 15, 14],
              index=pd.date_range("2024-03-01", periods=5))
print(pd.DataFrame({"s": s, "shift(1)": s.shift(1),
                    "shift(-2)": s.shift(-2),
                    "media 3": s.rolling(3).mean().round(2)}))
             s  shift(1)  shift(-2)  media 3
2024-03-01  10       NaN       11.0      NaN
2024-03-02  12      10.0       15.0      NaN
2024-03-03  11      12.0       14.0    11.00
2024-03-04  15      11.0        NaN    12.67
2024-03-05  14      15.0        NaN    13.33

3. Variables de calendario

La estacionalidad depende de la fecha, y la fecha es algo que sí se sabe de antemano. Agrega a la tabla el mes y la posición del día dentro del año. Para la posición, en lugar del número de día (de 1 a 366), usa su seno y su coseno: así el 31 de diciembre y el 1 de enero quedan cerca, como en un reloj, en vez de en los dos extremos. ¿Mejora la validación por tiempo del modelo?

Respuesta
def con_calendario(X):
    """Agrega el mes y la posición del día dentro del año."""
    X = X.copy()
    dia = X.index.day_of_year
    X["mes"] = X.index.month
    # Seno y coseno: el 31 de diciembre queda pegado al 1 de enero
    X["dia_sin"] = np.sin(2 * np.pi * dia / 365.25)
    X["dia_cos"] = np.cos(2 * np.pi * dia / 365.25)
    return X

X_cal = con_calendario(X_ent)
print(X_cal[["mes", "dia_sin", "dia_cos"]].head(3).round(3))
for nombre, tabla_x in [("Sin calendario", X_ent),
                        ("Con calendario", X_cal)]:
    errores = -cross_val_score(modelo, tabla_x, y_ent, cv=tss,
                               scoring="neg_mean_absolute_error")
    print(f"{nombre}:  MAE = {errores.mean():.3f} °C")
            mes  dia_sin  dia_cos
fecha                            
2015-01-07    1    0.120    0.993
2015-01-08    1    0.137    0.991
2015-01-09    1    0.154    0.988
Sin calendario:  MAE = 0.633 °C
Con calendario:  MAE = 0.620 °C

Mejora un poco, de 0,633 a 0,620 °C. Con solo la temperatura de los últimos días, el modelo no sabe si va hacia el invierno o hacia el verano; la fecha se lo dice. Fíjate que el calendario no es fuga: la fecha de mañana se conoce hoy. Lo que no se puede usar es la temperatura de mañana.

4. Encuentra la fuga

Alguien te pasa este cuaderno, orgulloso de su MAE. Encuentra los problemas antes de abrir la respuesta. Una pista: hay más de uno.

# El cuaderno de otra persona: ¿dónde está la fuga?
semana = temp.groupby(temp.index.to_period("W")).transform("mean")
X_mal = X.copy()
X_mal["media_semana"] = semana[X.index]
cv = KFold(n_splits=5, shuffle=True, random_state=0)
errores = -cross_val_score(modelo, X_mal, y, cv=cv,
                           scoring="neg_mean_absolute_error")
print(f"MAE del cuaderno con fugas: {errores.mean():.3f} °C")
MAE del cuaderno con fugas: 0.538 °C
Respuesta

Hay tres fugas:

  1. media_semana promedia la semana de calendario completa (de lunes a domingo) a la que pertenece cada día. Para un martes incluye el miércoles (el objetivo) y los días siguientes: datos del futuro.
  2. KFold(shuffle=True) baraja los días: el modelo entrena con días posteriores a los que valida.
  3. Usa X e y completos, incluidos 2023 y 2024, que habíamos guardado como prueba. Si eliges el modelo mirando la prueba, ya no es una prueba.

Corregido: sin media_semana, solo con el entrenamiento y con TimeSeriesSplit:

errores = -cross_val_score(modelo, X_ent, y_ent, cv=tss,
                           scoring="neg_mean_absolute_error")
print(f"MAE corregido:              {errores.mean():.3f} °C")
MAE corregido:              0.633 °C

El error «sube» de 0,538 a 0,633 °C. No es que el modelo empeorara: 0,633 es lo que de verdad se puede esperar, apenas mejor que la línea base.

5. Forward-fill frente a la media

Con con_huecos de la teoría, rellena los faltantes de tres formas: con forward-fill, con la media de toda la serie y con interpolate(), que traza una recta entre el último valor conocido y el siguiente. Compara el error en los días rellenados. ¿Cuál usarías para predecir en tiempo real, día a día?

Respuesta
rellenos = {
    "ffill": con_huecos.ffill(),
    "media de toda la serie": con_huecos.fillna(con_huecos.mean()),
    "interpolación": con_huecos.interpolate(),
}
for nombre, serie in rellenos.items():
    error = (serie[faltan] - temp[faltan]).abs().mean()
    print(f"{nombre:24s} error medio = {error:.2f} °C")
ffill                    error medio = 0.64 °C
media de toda la serie   error medio = 1.08 °C
interpolación            error medio = 0.50 °C
  • La media de toda la serie es la peor (1,08 °C): un hueco en julio recibe el promedio del año, que es de otra estación. Además usa el futuro: el promedio incluye días posteriores al hueco.
  • La interpolación es la mejor (0,50 °C), porque usa el valor de antes y el de después. Justamente por eso no sirve para predecir en tiempo real: el día del hueco todavía no conoces el valor siguiente. Sirve para limpiar datos históricos que no vas a usar para evaluar un pronóstico.
  • El forward-fill (0,64 °C) solo usa el pasado: es lo que tendrías de verdad ese día. Es la opción segura para un modelo que predice el futuro.

6. Otra línea base: la climatología

Además de «mañana igual que hoy», hay otra línea base clásica: la climatología, predecir para cada fecha el promedio de ese mismo día del año en los años de entrenamiento. Calcúlala para 2023 y 2024 con horizontes de 1 y de 7 días y compárala con la persistencia. ¿Cuál conviene en cada caso?

Respuesta
# Climatología: el promedio de ese mismo día del año, 2015 a 2022
pasado = temp[:"2022-12-31"]
clima_dia = pasado.groupby(pasado.index.day_of_year).mean()

print("Horizonte   climatología   igual que hoy   (MAE en °C)")
for h in [1, 7]:
    X_h, y_h = tabla_ventana(temp, horizonte=h)
    prueba_h = X_h.index >= "2023-01-01"
    # El día que se predice, como número de día del año
    dias_obj = (X_h.index[prueba_h] + pd.Timedelta(days=h)).day_of_year
    pred_clima = clima_dia.loc[dias_obj].to_numpy()
    c = mean_absolute_error(y_h[prueba_h], pred_clima)
    b = mean_absolute_error(y_h[prueba_h], X_h["hoy"][prueba_h])
    print(f"h = {h}       {c:.3f}          {b:.3f}")
Horizonte   climatología   igual que hoy   (MAE en °C)
h = 1       1.034          0.632
h = 7       1.030          1.107

A un día, la persistencia gana con claridad (0,632 frente a 1,034 °C): ayer dice mucho más que el calendario. A siete días se invierte: «igual que hoy» se equivoca 1,107 °C y la climatología, que no depende del horizonte, 1,030 °C. Por eso un modelo con buenas variables mezcla las dos ideas: la ventana reciente y la época del año. En la teoría, a 7 días el modelo llegó a 0,937 °C, mejor que las dos líneas base.

7. Remuestrear

¿Cuál fue la semana más fría de los diez años? ¿Cuántos días con una media de menos de 5 °C hubo cada año? Usa resample.

Respuesta
semanal = temp.resample("W").mean()
print("Semana más fría, la que termina el",
      semanal.idxmin().date(), f"con {semanal.min():.1f} °C")

# Días con una media de menos de 5 °C, año por año
frios = (temp < 5).resample("YS").sum()
print(frios.set_axis(frios.index.year).to_string())
Semana más fría, la que termina el 2015-08-16 con 3.5 °C
fecha
2015    15
2016     5
2017     4
2018    15
2019    10
2020     0
2021     6
2022     6
2023     4
2024     2

resample("W") agrupa por semanas que terminan en domingo, y rotula cada grupo con ese domingo. Para contar días, temp < 5 da una serie de True y False, y al sumar, cada True cuenta 1. Los años con más días fríos fueron 2015 y 2018 (15 cada uno); en 2020 no hubo ninguno.

8. Más variables para secuencias de largo variable

A partir de clics, agrega dos variables por visita: cuántas páginas distintas visitó y la pausa más larga entre dos clics. ¿Qué visita parece la de alguien que se distrajo?

Respuesta
resumen_2 = clics.groupby("visita").agg(
    paginas_distintas=("pagina", "nunique"),
    pausa_maxima=("segundo", lambda s: s.diff().max()),
)
print(resumen_2)
        paginas_distintas  pausa_maxima
visita                                 
1                       3          55.0
2                       2         300.0
3                       3          29.0

La visita 2: solo dos clics y una pausa de 300 segundos entre ellos, sin llegar a pagar. La visita 3 tiene muchos clics seguidos en la tienda y termina pagando. Cada visita, de cualquier largo, queda como una fila con las mismas columnas.

Mini-proyecto: la temperatura de mañana en Cochabamba

Repite todo el tema con otra ciudad, ahora con tres variables por día, y supera a la línea base.

  1. Escribe una función descargar(latitud, longitud, variables) que pida a Open-Meteo la temperatura media, máxima y mínima diarias de Cochabamba (latitud −17,39, longitud −66,16) de 2015 a 2024. Revisa los faltantes y, si los hay, rellénalos con forward-fill. Grafica la media con su media móvil de 30 días (la referencia no incluye el gráfico).
  2. Arma la tabla con tabla_ventana sobre la media y agrega la máxima y la mínima de hoy y las variables de calendario del ejercicio 3.
  3. Deja 2023 y 2024 como prueba. Con TimeSeriesSplit, compara la línea base, el modelo con solo la ventana y el modelo con todo.
  4. Mide una sola vez con la prueba y reporta cuánto mejora el modelo a la línea base.
  5. Escribe tres frases: qué variables ayudaron, por qué no barajaste y qué información le falta al modelo para predecir mejor.
Resultados de referencia, para comprobar los tuyos
def descargar(latitud, longitud, variables):
    """Temperatura diaria de 2015 a 2024 desde Open-Meteo."""
    parametros = {
        "latitude": latitud, "longitude": longitud,
        "start_date": "2015-01-01", "end_date": "2024-12-31",
        "daily": ",".join(variables), "models": "era5_land",
        "timezone": "America/La_Paz", "format": "csv",
    }
    url = API + "?" + urlencode(parametros)
    tabla = pd.read_csv(url, skiprows=3, index_col=0,
                        parse_dates=True)
    tabla.columns = variables
    tabla.index.name = "fecha"
    return tabla

# Cochabamba
cbba = descargar(-17.39, -66.16, ["temperature_2m_mean",
                                  "temperature_2m_max",
                                  "temperature_2m_min"])
cbba.columns = ["media", "maxima", "minima"]
print(cbba.describe().round(1))
print("Faltantes:", cbba.isna().sum().sum())
cbba = cbba.ffill()                 # por si hubiera huecos
        media  maxima  minima
count  3653.0  3653.0  3653.0
mean     15.9    21.7    10.7
std       1.7     2.3     2.8
min       7.9    10.7    -0.6
25%      14.8    20.4     8.8
50%      16.0    21.8    11.2
75%      17.1    23.2    12.9
max      20.9    29.5    16.2
Faltantes: 0
# Paso 2: la tabla de la ventana y dos variables más de hoy
Xc, yc = tabla_ventana(cbba["media"])
Xc["maxima_hoy"] = cbba["maxima"][Xc.index]
Xc["minima_hoy"] = cbba["minima"][Xc.index]
Xc = con_calendario(Xc)

ent_c = Xc.index < "2023-01-01"
Xc_ent, yc_ent = Xc[ent_c], yc[ent_c]
Xc_prueba, yc_prueba = Xc[~ent_c], yc[~ent_c]

# Paso 3: validación por bloques de tiempo
for nombre, columnas in [("Solo la ventana", X.columns),
                         ("Todo", Xc.columns)]:
    errores = -cross_val_score(modelo, Xc_ent[columnas], yc_ent,
                               cv=tss,
                               scoring="neg_mean_absolute_error")
    print(f"{nombre:16s} MAE de validación = {errores.mean():.3f}")
base_c = [mean_absolute_error(yc_ent.iloc[v], Xc_ent["hoy"].iloc[v])
          for _, v in tss.split(Xc_ent)]
print(f"{'Línea base':16s} MAE de validación = {np.mean(base_c):.3f}")

# Paso 4: una sola vez, la prueba con 2023 y 2024
modelo.fit(Xc_ent, yc_ent)
mae_c = mean_absolute_error(yc_prueba, modelo.predict(Xc_prueba))
mae_base_c = mean_absolute_error(yc_prueba, Xc_prueba["hoy"])
print(f"Prueba:  modelo {mae_c:.3f} °C   "
      f"igual que hoy {mae_base_c:.3f} °C")
Solo la ventana  MAE de validación = 0.853
Todo             MAE de validación = 0.808
Línea base       MAE de validación = 0.888
Prueba:  modelo 0.782 °C   igual que hoy 0.803 °C
  • Cochabamba, en estos datos, promedia 15,9 °C, con máximas de unos 22 °C y mínimas de unos 11 °C. Recuerda que es el promedio de una celda de unos 11 km que incluye cerros alrededor del valle, así que puede ser más fresco que la ciudad. Tampoco hay faltantes, así que el forward-fill no cambia nada; la línea queda en el código por si otra ciudad los tiene.
  • En la validación por tiempo, la línea base se equivoca 0,888 °C; el modelo con solo la ventana, 0,853, y con la máxima, la mínima y el calendario, 0,808: un 9 % menos que la línea base.
  • En la prueba (2023 y 2024), el modelo se equivoca 0,782 °C y la línea base 0,803: la mejora se achica a un 2,6 %. Es normal que la prueba dé distinto que la validación: son otros años, con otro clima.
  • Al modelo le falta lo que mueve el clima de un día a otro y no está en la temperatura: nubes, humedad, viento, lo que viene de otras regiones.

Errores comunes

  • Barajar una serie de tiempo. train_test_split baraja por defecto, y KFold, con shuffle=True. Para una serie, divide por fecha y valida con TimeSeriesSplit.
  • Ventanas que miran adelante. rolling(..., center=True), los promedios de la semana o del mes completos y cualquier shift negativo en una entrada meten el futuro en la fila. Pregúntate por cada columna: ¿la sabría el día de la predicción?
  • Olvidar la línea base. Un MAE de 0,6 °C suena muy bien hasta que ves que «mañana igual que hoy» saca casi lo mismo. Compara siempre, y en los mismos días.
  • Confundir el sentido de shift. shift(1) trae el pasado (ayer); shift(-1) trae el futuro (mañana). El objetivo se construye con shift(-h); las entradas, nunca.
  • Imputar con la media de toda la serie o interpolar antes de evaluar. Las dos usan datos posteriores al hueco. Para un modelo que predice, usa forward-fill.
  • Usar el índice como texto. Si las fechas no son de tipo fecha, resample, rolling con fechas y los cortes como temp["2024-06"] no funcionan. Convierte con parse_dates=True o pd.to_datetime.
  • Medir con la prueba varias veces. Elige con la validación por tiempo y usa 2023 y 2024 una sola vez, al final.
  • Descargar un rango que todavía no terminó. Si pides datos hasta hoy, cada ejecución da otros números. Para estudiar y comparar, usa un rango fijo y pasado.

¿Lo lograste?

Al terminar este tema deberías poder hacer lo siguiente. Al lado, dónde lo practicaste.

  • Cargar y graficar una serie con índice de fechas en pandas, y remuestrearla (ejercicio 7 y paso 1 del mini-proyecto).
  • Crear variables de ventana deslizante: rezagos, media y desviación móviles (ejercicios 2 y 3 y paso 2 del mini-proyecto).
  • Dividir respetando el tiempo con TimeSeriesSplit y explicar por qué no se baraja (ejercicio 4 y pasos 3 y 5 del mini-proyecto).
  • Imputar faltantes en secuencias con forward-fill y explicar por qué la media y la interpolación usan el futuro (ejercicio 5).
  • Comparar un modelo con una línea base ingenua (ejercicio 6 y paso 4 del mini-proyecto).
  • Resumir secuencias de largo variable en variables fijas (ejercicio 8).