Datos en el tiempo: series temporales y ventanas

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

Los 15 bloques de código de la teoría, en el mismo orden. Cópialos y córrelos en tu Python o en Colab; en la ficha Teoría está la explicación de cada uno.

Diez años de temperatura en La Paz

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from urllib.parse import urlencode

# Archivo histórico de Open-Meteo: gratis, sin clave, licencia CC BY 4.0
API = "https://archive-api.open-meteo.com/v1/archive"
parametros = {
    "latitude": -16.50,             # La Paz
    "longitude": -68.15,
    "start_date": "2015-01-01",     # un rango fijo y ya pasado:
    "end_date": "2024-12-31",       # así los datos no cambian
    "daily": "temperature_2m_mean",
    "models": "era5_land",
    "timezone": "America/La_Paz",
    "format": "csv",
}
url = API + "?" + urlencode(parametros)

# Las 3 primeras líneas describen el lugar; la tabla empieza después
datos = pd.read_csv(url, skiprows=3, index_col=0, parse_dates=True)
temp = datos.iloc[:, 0]             # la única columna: °C de cada día
temp.name = "temp"
temp.index.name = "fecha"

print(temp.head())
print("Días:", len(temp), "  faltantes:", temp.isna().sum())
# Días entre una fecha y la siguiente: todos a 1, sin saltos
saltos = temp.index.to_series().diff().dt.days
print("Saltos entre fechas:", saltos.dropna().astype(int).unique())

Tendencia, estacionalidad y ruido

import matplotlib.dates as mdates

print(f"Promedio de los diez años: {temp.mean():.1f} °C")
print(f"Día más frío: {temp.idxmin().date()}, {temp.min():.1f} °C")
print(f"Día más cálido: {temp.idxmax().date()}, {temp.max():.1f} °C")

# Media móvil: cada día, el promedio de ese día y los 29 anteriores
media_30 = temp.rolling(30).mean()

fig, ax = plt.subplots()
ax.plot(temp.index, temp, color="C2", linewidth=0.6, label="Cada día")
ax.plot(media_30.index, media_30, color="C1", linewidth=2,
        label="Media móvil de 30 días")
ax.set_ylabel("Temperatura media (°C)")
ax.xaxis.set_major_locator(mdates.YearLocator(2))
ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y"))
ax.set_ylim(-2, 13)
ax.set_title("La Paz, 2015 a 2024")
ax.legend(loc="lower center", ncol=2, fontsize=12)
plt.show()
# Remuestrear: pasar de días a meses (MS: comienzo de mes) o a años
por_mes = temp.resample("MS").mean()
print(por_mes.head(3).round(2))

anual = temp.resample("YS").mean()
print("Promedio de cada año:", anual.round(1).tolist())

# El mismo mes de todos los años, junto: la estacionalidad
clima = temp.groupby(temp.index.month).mean()
print("Promedio de cada mes, de enero a diciembre:")
print(clima.round(1).tolist())

Rezagos y autocorrelación

for k in [1, 2, 7, 30, 182, 365]:
    print(f"Rezago {k:3d}: autocorrelación "
          f"{temp.autocorr(lag=k):+.2f}")

rezagos = np.arange(1, 401)
autocorr = [temp.autocorr(lag=k) for k in rezagos]

fig, ax = plt.subplots()
ax.bar(rezagos, autocorr, width=1.0, color="C0")
# Resaltamos el rezago de 1 día: ayer frente a hoy
ax.scatter([1], [autocorr[0]], color="C1", s=80, zorder=3)
ax.axhline(0, color="C2", linewidth=1)
ax.set_xlabel("Rezago (días)")
ax.set_ylabel("Autocorrelación")
ax.set_title("¿Cuánto se parece hoy a hace k días?")
plt.show()
tabla = pd.DataFrame({"temp": temp})
tabla["ayer"] = temp.shift(1)       # baja los valores un día
tabla["mañana"] = temp.shift(-1)    # los sube un día
print(tabla.head(3))
print(tabla.tail(2))

La ventana: de serie a tabla

def tabla_ventana(serie, dias=7, horizonte=1):
    """Una fila por día: lo que se sabe hoy y lo que pasará."""
    X = pd.DataFrame(index=serie.index)
    X["hoy"] = serie
    X["ayer"] = serie.shift(1)
    X["anteayer"] = serie.shift(2)
    # La ventana: hoy y los días anteriores, «dias» en total
    ventana = serie.rolling(dias)
    X["media"] = ventana.mean()
    X["desv"] = ventana.std()
    X["minimo"] = ventana.min()
    X["maximo"] = ventana.max()
    # El objetivo: el valor de dentro de «horizonte» días
    y = serie.shift(-horizonte)
    completas = X.notna().all(axis=1) & y.notna()
    return X[completas], y[completas]

X, y = tabla_ventana(temp)
print("Tabla:", X.shape)
print(X.loc["2024-06-20"].round(2).to_string())
print("objetivo:", y.loc["2024-06-20"])
hoy = pd.Timestamp("2024-06-20")
manana = hoy + pd.Timedelta(days=1)
inicio = hoy - pd.Timedelta(days=6)
medio_dia = pd.Timedelta(hours=12)
tramo = temp["2024-06-08":"2024-06-28"]

fig, ax = plt.subplots()
ax.axvspan(inicio - medio_dia, hoy + medio_dia, color="C3",
           label="Ventana de 7 días")
ax.plot(tramo.index, tramo, color="C0", marker="o", markersize=4)
ax.hlines(X.loc[hoy, "media"], inicio - medio_dia, hoy + medio_dia,
          color="C2", linestyles="--", label="Media de la ventana")
ax.scatter([manana], [temp[manana]], color="C1", s=120, zorder=3,
           label="Objetivo: mañana")
ax.xaxis.set_major_formatter(mdates.DateFormatter("%d/%m"))
ax.set_ylabel("Temperatura media (°C)")
ax.set_ylim(2.5, 10.8)
ax.set_title("Una ventana se vuelve una fila")
ax.legend(loc="lower left", fontsize=12)
plt.show()

Faltantes en una secuencia: forward-fill

rng = np.random.default_rng(42)
con_huecos = temp.copy()
# Simulamos un termómetro que falla: 60 días sueltos y una semana entera
sueltos = rng.choice(np.arange(1, len(temp)), size=60, replace=False)
con_huecos.iloc[sueltos] = np.nan
con_huecos["2022-03-01":"2022-03-07"] = np.nan
faltan = con_huecos.isna()
print("Días sin dato:", faltan.sum())

# Forward-fill: cada hueco toma el último valor conocido
rellena = con_huecos.ffill()
print(pd.DataFrame({"con huecos": con_huecos, "ffill": rellena,
                    "real": temp})["2022-02-27":"2022-03-09"])
error = (rellena[faltan] - temp[faltan]).abs().mean()
print(f"Error medio en los días rellenados: {error:.2f} °C")

La línea base: «mañana igual que hoy»

from sklearn.metrics import mean_absolute_error

# Entrenamiento: 2015 a 2022. Prueba: 2023 y 2024, el «futuro»
ent = X.index < "2023-01-01"
X_ent, y_ent = X[ent], y[ent]
X_prueba, y_prueba = X[~ent], y[~ent]
print("Entrenamiento:", len(X_ent), "días   prueba:", len(X_prueba), "días")

# Dos líneas base que no aprenden nada
mae_hoy = mean_absolute_error(y_prueba, X_prueba["hoy"])
mae_media = mean_absolute_error(y_prueba, X_prueba["media"])
print(f"«Mañana igual que hoy»:           MAE = {mae_hoy:.3f} °C")
print(f"«Mañana igual que la semana»:     MAE = {mae_media:.3f} °C")

Validar sin mirar el futuro

from sklearn.model_selection import TimeSeriesSplit

tss = TimeSeriesSplit(n_splits=5)
fig, ax = plt.subplots()
for k, (i_ent, i_val) in enumerate(tss.split(X_ent), start=1):
    desde = X_ent.index[i_val[0]].date()
    hasta = X_ent.index[i_val[-1]].date()
    print(f"Pliegue {k}: entrena con {len(i_ent):4d} días, "
          f"valida del {desde} al {hasta}")
    ax.barh(k, len(i_ent), color="C2",
            label="Entrena" if k == 1 else None)
    ax.barh(k, len(i_val), left=len(i_ent), color="C0",
            label="Valida" if k == 1 else None)
ax.invert_yaxis()
ax.set_yticks(range(1, 6))
ax.set_xlabel("Días desde el inicio del entrenamiento")
ax.set_ylabel("Pliegue")
ax.set_title("Cinco pliegues en el tiempo")
ax.legend(loc="upper right", fontsize=12)
plt.show()
from sklearn.model_selection import KFold, cross_val_score
from sklearn.neighbors import KNeighborsRegressor

# Un «modelo» que solo conoce la fecha: el número de día
dia_numero = np.arange(len(X_ent)).reshape(-1, 1)
vecinos = KNeighborsRegressor(n_neighbors=2)
barajada = KFold(n_splits=5, shuffle=True, random_state=0)

for nombre, cv in [("barajada", barajada), ("por tiempo", tss)]:
    errores = -cross_val_score(vecinos, dia_numero, y_ent, cv=cv,
                               scoring="neg_mean_absolute_error")
    rotulo = f"Solo la fecha, validación {nombre}:"
    print(f"{rotulo:38s} MAE = {errores.mean():.3f} °C")

base = mean_absolute_error(y_ent, X_ent["hoy"])
rotulo = "Línea base en el entrenamiento:"
print(f"{rotulo:38s} MAE = {base:.3f} °C")
from sklearn.ensemble import HistGradientBoostingRegressor

modelo = HistGradientBoostingRegressor(max_depth=3, learning_rate=0.05,
                                       max_iter=200, random_state=0)

# La fuga: una media «centrada» usa 3 días antes y 3 días después
X_fuga = X_ent.copy()
X_fuga["media"] = temp.rolling(7, center=True).mean()[X_ent.index]

for nombre, tabla_x in [("Ventana sin fuga", X_ent),
                        ("Ventana centrada", X_fuga)]:
    errores = -cross_val_score(modelo, tabla_x, y_ent, cv=tss,
                               scoring="neg_mean_absolute_error")
    print(f"{nombre}:  MAE = {errores.mean():.3f} °C")

# La línea base, en los mismos pliegues de validación
base_cv = [mean_absolute_error(y_ent.iloc[v], X_ent["hoy"].iloc[v])
           for _, v in tss.split(X_ent)]
print(f"Línea base:        MAE = {np.mean(base_cv):.3f} °C")

Predecir mañana

modelo.fit(X_ent, y_ent)
pred = pd.Series(modelo.predict(X_prueba), index=X_prueba.index)

mae_modelo = mean_absolute_error(y_prueba, pred)
print(f"Modelo:               MAE = {mae_modelo:.3f} °C")
print(f"Mañana igual que hoy: MAE = {mae_hoy:.3f} °C")
mejora = 100 * (1 - mae_modelo / mae_hoy)
print(f"El modelo se equivoca un {mejora:.1f} % menos")

# Las fechas son las del objetivo: el día que se predice
dia_obj = X_prueba.index + pd.Timedelta(days=1)
real = pd.Series(y_prueba.values, index=dia_obj)
modelo_dia = pd.Series(pred.values, index=dia_obj)
base_dia = pd.Series(X_prueba["hoy"].values, index=dia_obj)
junio = slice("2024-06-01", "2024-07-15")

fig, ax = plt.subplots()
ax.plot(real[junio], color="C0", linewidth=2, label="Real")
ax.plot(base_dia[junio], color="C2", linestyle="--",
        label="Igual que hoy")
ax.plot(modelo_dia[junio], color="C1", linewidth=2, label="Modelo")
ax.xaxis.set_major_formatter(mdates.DateFormatter("%d/%m"))
ax.set_ylabel("Temperatura media (°C)")
ax.xaxis.set_major_locator(mdates.DayLocator(bymonthday=[1, 15]))
ax.set_ylim(2.3, 10.8)
ax.set_title("Predecir mañana, junio y julio de 2024")
ax.legend(loc="lower center", ncol=3, fontsize=11)
plt.show()
print("Horizonte   modelo   igual que hoy   (MAE en °C)")
for h in [1, 3, 7]:
    X_h, y_h = tabla_ventana(temp, horizonte=h)
    ent_h = X_h.index < "2023-01-01"
    modelo.fit(X_h[ent_h], y_h[ent_h])
    p = modelo.predict(X_h[~ent_h])
    m = mean_absolute_error(y_h[~ent_h], p)
    b = mean_absolute_error(y_h[~ent_h], X_h["hoy"][~ent_h])
    print(f"h = {h}       {m:.3f}    {b:.3f}")

Secuencias de largo variable

# Datos sintéticos: los clics de tres visitas a una página web
clics = pd.DataFrame({
    "visita":   [1, 1, 1, 2, 2, 3, 3, 3, 3, 3],
    "segundo":  [0, 40, 95, 0, 300, 0, 12, 30, 41, 70],
    "pagina":   ["inicio", "tienda", "pago", "inicio", "blog",
                 "inicio", "tienda", "tienda", "tienda", "pago"],
})
resumen = clics.groupby("visita").agg(
    clics=("segundo", "size"),
    duracion=("segundo", "max"),
    pausa_media=("segundo", lambda s: s.diff().mean()),
    llego_a_pagar=("pagina", lambda p: (p == "pago").any()),
)
print(resumen)