Esta lección es la culminación del pilar de datos. Las lecciones anteriores le enseñaron a leer, limpiar, transformar y describir datos geocientíficos. Esta define qué significa que un conjunto de datos esté listo para la IA (AI-ready), y demuestra los dos modos de falla que arruinan proyectos por lo demás cuidadosos: la fuga de datos por preprocesamiento y las particiones equivocadas para datos autocorrelacionados.

1. Una definición operativa de los datos listos para la IA¶
«Listo para la IA» no es una sensación. Un conjunto de datos está listo para la IA cuando pasa esta lista de verificación:
- Procedencia, licencia y cita documentadas. ¿De dónde salió cada byte, quién es su dueño y cómo debe citarse? Si parte de los datos es sintética, se declara.
- Metadatos legibles por máquina (una ficha de datos). Un archivo que viaja con los datos y declara qué son las variables, en qué unidades, cómo se recolectaron o generaron y cuáles son las limitaciones conocidas. Un README escrito para humanos no basta; la ficha debe poder analizarse por máquina (YAML, JSON).
- Formas ordenadas (tidy). Una fila por observación y una columna por variable en las tablas; dimensiones explícitas y etiquetadas en los arreglos. Sin filas de encabezado fusionadas, sin unidades incrustadas en cadenas de texto.
- Unidades explícitas. Cada variable física carga su unidad en los metadatos, no en un comentario que alguien perderá.
- Una política de datos faltantes. Qué valores faltan, por qué, cómo están codificados (
NaN,pd.NA, un valor centinela) y cuál es el manejo recomendado. - Particiones de referencia que viajan con los datos. La pertenencia a entrenamiento/validación/prueba se almacena como una columna o un archivo índice, de modo que cada usuario evalúe sobre los mismos datos apartados.
- Controles contra fugas. El diseño de las particiones respeta la estructura de correlación de los datos (tiempo, espacio, pertenencia a eventos), y todos los estadísticos de preprocesamiento se calculan solo con la partición de entrenamiento.
- Un inventario de clases y eventos. Cuántas muestras, cuántas de cada clase, cuántos eventos distintos. Los problemas de eventos raros se ven muy distintos de los balanceados, y los usuarios deben saberlo antes de entrenar.
El resto de este cuaderno construye un conjunto de datos pequeño que pasa la lista de verificación, y luego rompe las reglas 6 y 7 a propósito para que usted vea el daño en las métricas.
2. Recordatorios del flujo de trabajo: de los datos crudos a un conjunto de datos de ML¶
Antes de poder satisfacer la lista de verificación, los datos tienen que existir en una forma usable. Un flujo de trabajo condensado, a partir de las lecciones anteriores:
- Plantee el problema en términos generales. ¿Transformar en ? ¿Predecir a partir de ? Elegir una variable objetivo ya plantea el problema (regresión, clasificación, detección).
- Establezca qué es realistamente posible. Revise la literatura, de ML y no. Si los expertos alcanzan una exactitud , ese es su primer punto de referencia.
- Compile y organice los datos temprano. Una sola ubicación, estructuras legibles por máquina (NumPy, Xarray, pandas), guardadas en formatos estándar (Parquet, netCDF, Zarr, HDF5). Nunca sobrescriba los datos crudos.
- Caracterice los datos. Histogramas, distribuciones, gráficos cruzados, matrices de correlación (lecciones 2.4 y 2.7). Guarde los scripts de exploración.
- Considere la extracción de características y la reducción de dimensionalidad. Características estadísticas, temporales o espectrales (lección 2.11); PCA o ICA (lección 2.12).
- Considere el aumento de datos. Bootstrap, ruido sintético, copias transformadas — con procedencia declarada (lecciones 2.6 y 2.10).
- Haga reproducible la cadena de procesamiento. Un script o un
Pipelinede sklearn que vaya de los datos crudos al producto listo para la IA sin pasos manuales.
import os
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import mlgeo_synth
rng = np.random.default_rng(2026)
os.makedirs("data/ai_ready_demo", exist_ok=True)3. Construir el conjunto de datos: una serie tipo caudal de río con crecidas raras¶
Generamos diez años de una serie diaria tipo caudal: un ciclo estacional de flujo base más ruido, con eventos raros de crecida inyectados como un proceso de Poisson. Como el generador es nuestro, conocemos la verdad de referencia: cada muestra carga una etiqueta (event) y un identificador de evento (event_id). Este es exactamente el tipo de conjunto de datos sintético que la lección 2.10 llamó admisible — lo usamos para desarrollar y evaluar metodología, y declaramos cómo se hizo.
La tarea que plantearemos: pronosticar de manera inmediata (nowcast) si hoy es un día de crecida a partir de los siete días previos de caudal. Las crecidas son raras, así que este es un problema de clasificación desbalanceada — común en las geociencias (crecidas, sismos, deslizamientos de tierra, floraciones algales).
n_years = 10
n_days = int(365.25 * n_years)
day = np.arange(n_days)
# Seasonal baseflow: winter-peaking annual cycle plus white noise, in m^3/s
baseflow = 25.0 + 12.0 * np.cos(2 * np.pi * (day - 20) / 365.25) + rng.normal(0, 3.0, n_days)
df = mlgeo_synth.inject_rare_events(
baseflow,
rate_per_year=4.0,
duration_days=(3, 15),
amplitude=(5.0, 60.0),
shape="spike",
samples_per_day=1,
seed=2026,
)
df.index = pd.date_range("2015-01-01", periods=len(df), freq="D")
df.index.name = "date"
df = df.rename(columns={"value": "discharge_m3s"})
df.head()fig, ax = plt.subplots(figsize=(11, 3.5))
ax.plot(df.index, df["discharge_m3s"], lw=0.6, color="steelblue", label="discharge")
ax.fill_between(df.index, 0, df["discharge_m3s"].max(), where=df["event"] == 1,
color="firebrick", alpha=0.3, label="flood (label = 1)")
ax.set_xlabel("date")
ax.set_ylabel("discharge (m$^3$/s)")
ax.legend(loc="upper right")
ax.set_title("Synthetic daily discharge with injected rare floods")
plt.tight_layout()
# Zoom on one event to see the shape: sharp rise, exponential recession
one_event = df[df["event_id"] == df.loc[df["event"] == 1, "event_id"].iloc[0]]
window = df.loc[one_event.index[0] - pd.Timedelta(days=15):
one_event.index[-1] + pd.Timedelta(days=25)]
fig, ax = plt.subplots(figsize=(8, 3))
ax.plot(window.index, window["discharge_m3s"], color="steelblue")
ax.fill_between(window.index, 0, window["discharge_m3s"].max(),
where=window["event"] == 1, color="firebrick", alpha=0.3)
ax.set_ylabel("discharge (m$^3$/s)")
ax.set_title("One flood event (shaded)")
plt.tight_layout()
El inventario de clases y eventos¶
Punto 8 de la lista de verificación. Importan dos números distintos, y no son intercambiables: la fracción de muestras etiquetadas con 1 y el número de eventos distintos. Los pliegues de la validación cruzada se cuentan en eventos, no en muestras: 40 eventos repartidos en cinco partes dejan solo ~8 eventos por pliegue, sin importar cuántos días de crecida haya.
n_events = df.loc[df["event_id"] >= 0, "event_id"].nunique()
durations = df[df["event_id"] >= 0].groupby("event_id").size()
inventory = pd.Series({
"total samples (days)": len(df),
"flood samples": int(df["event"].sum()),
"flood sample fraction": round(df["event"].mean(), 4),
"distinct flood events": n_events,
"median event duration (days)": durations.median(),
"max event duration (days)": int(durations.max()),
})
inventorytotal samples (days) 3652.0000
flood samples 312.0000
flood sample fraction 0.0854
distinct flood events 33.0000
median event duration (days) 9.0000
max event duration (days) 15.0000
dtype: float644. La ficha de datos¶
Los puntos 1, 2, 4, 5 y 8 de la lista de verificación, en un solo archivo legible por máquina. Una ficha de datos viaja con los datos — la misma carpeta, el mismo repositorio, versionados juntos. Abajo está la ficha de este conjunto de datos, en YAML:
name: synthetic_discharge_floods
version: "1.0"
created: "2026-08-11"
description: >
Ten years of synthetic daily river-discharge-like data with rare injected
flood events, for teaching rare-event classification and split design.
provenance:
synthetic: true # disclosed, per lesson 2.10
generator: mlgeo_synth.inject_rare_events
generator_params: {rate_per_year: 4.0, duration_days: [3, 15],
amplitude: [5.0, 60.0], shape: spike, seed: 2026}
background: "25 + 12*cos(annual cycle) + N(0, 3.0), seed 2026"
license: CC-BY-4.0
citation: "MLGeo course materials, University of Washington, 2026 edition."
sampling:
cadence: daily
start: "2015-01-01"
n_samples: 3652
variables:
discharge_m3s: {dtype: float64, units: m^3/s, description: daily mean discharge}
event: {dtype: int64, units: null, description: "label: 1 = flood day"}
event_id: {dtype: int64, units: null, description: "flood id; -1 = background"}
split: {dtype: str, values: [train, val, test], description: benchmark split}
missing_data:
policy: "no missing values by construction; real gauges gap during floods —
document gaps, do not silently interpolate labels"
class_inventory:
flood_sample_fraction: ~0.09
distinct_events: ~40
splits:
scheme: chronological
train: "2015-01-01 to 2021-12-31"
val: "2022-01-01 to 2022-12-31"
test: "2023-01-01 to 2024-12-31"
rule: "no event spans a split boundary check; preprocessing statistics
from train only"
known_limitations:
- synthetic; event shapes are idealized (linear rise, exponential decay)
- no measurement gaps, rating-curve errors, or sensor driftNada de esto es decoración. Cada entrada responde una pregunta que un usuario futuro (incluido su yo futuro) tendría que adivinar de otro modo.
# Ship the splits WITH the data (checklist item 6): a split column, assigned
# chronologically, then write data + card to the same folder.
df["split"] = "train"
df.loc["2022-01-01":"2022-12-31", "split"] = "val"
df.loc["2023-01-01":, "split"] = "test"
print(df["split"].value_counts())
print("\nFlood events per split:")
print(df[df["event_id"] >= 0].groupby("split", observed=True)["event_id"].nunique())split
train 2557
test 730
val 365
Name: count, dtype: int64
Flood events per split:
split
test 6
train 22
val 5
Name: event_id, dtype: int64
data_card = """\
name: synthetic_discharge_floods
version: "1.0"
created: "2026-08-11"
description: >
Ten years of synthetic daily river-discharge-like data with rare injected
flood events, for teaching rare-event classification and split design.
provenance:
synthetic: true
generator: mlgeo_synth.inject_rare_events
generator_params: {rate_per_year: 4.0, duration_days: [3, 15], amplitude: [5.0, 60.0], shape: spike, seed: 2026}
license: CC-BY-4.0
citation: "MLGeo course materials, University of Washington, 2026 edition."
sampling: {cadence: daily, start: "2015-01-01", n_samples: %d}
variables:
discharge_m3s: {dtype: float64, units: m^3/s}
event: {dtype: int64, description: "label: 1 = flood day"}
event_id: {dtype: int64, description: "flood id; -1 = background"}
split: {dtype: str, values: [train, val, test]}
missing_data: {policy: "no missing values by construction"}
splits: {scheme: chronological, train: "2015-2021", val: "2022", test: "2023-2024"}
""" % len(df)
df.to_csv("data/ai_ready_demo/synthetic_discharge_floods.csv")
with open("data/ai_ready_demo/data_card.yml", "w") as f:
f.write(data_card)
sorted(os.listdir("data/ai_ready_demo"))['data_card.yml', 'synthetic_discharge_floods.csv']# "Machine-readable" means a script can consume it. Read the card back:
import yaml
with open("data/ai_ready_demo/data_card.yml") as f:
card = yaml.safe_load(f)
print(card["variables"]["discharge_m3s"])
print(card["splits"]){'dtype': 'float64', 'units': 'm^3/s'}
{'scheme': 'chronological', 'train': '2015-2021', 'val': '2022', 'test': '2023-2024'}
5. Características para un clasificador de crecidas¶
Construimos un conjunto de características deliberadamente simple: el caudal en cada uno de los siete días previos (características de rezago, q_lag1 = ayer, hasta q_lag7), más el ciclo anual codificado como el seno y el coseno del día del año. El caudal de hoy no es una característica — usarlo volvería trivial la tarea (un día de crecida es un día de caudal alto). Pronosticar de inmediato a partir del pasado mantiene honesto el problema y mantiene la etiqueta fuera de las características. El punto de esta lección no es el modelo — es lo que las particiones y el preprocesamiento le hacen a la evaluación.
feat = pd.DataFrame(index=df.index)
for lag in range(1, 8):
feat[f"q_lag{lag}"] = df["discharge_m3s"].shift(lag)
doy = df.index.dayofyear
feat["doy_sin"] = np.sin(2 * np.pi * doy / 365.25)
feat["doy_cos"] = np.cos(2 * np.pi * doy / 365.25)
feat["event"] = df["event"]
feat["event_id"] = df["event_id"]
feat["split"] = df["split"]
feat = feat.dropna()
X = feat[[c for c in feat.columns if c.startswith(("q_lag", "doy_"))]]
y = feat["event"]
X.shape, y.mean().round(4)((3645, 9), np.float64(0.0856))6. Agregar una covariable en malla: la unión ráster-estación¶
El registro de nuestra estación de aforo es una serie de tiempo puntual, pero los predictores que importan en los problemas ambientales reales suelen vivir en una malla: temperatura y precipitación de reanálisis, humedad del suelo satelital, temperatura superficial del mar, un modelo digital de elevación (DEM). Casi todo proyecto de ML ambiental termina, por lo tanto, realizando la misma operación — extraer los valores de la malla en las coordenadas de la estación y luego unirlos al eje temporal de la estación — y vale la pena hacerla una vez, con cuidado, nombrando cada decisión. Usamos el campo sintético del curso de temperatura mensual en malla (mlgeo_synth.climate_field), así que no hay ninguna descarga nueva y la estructura del campo es conocida.
Hay que resolver dos desajustes, y cada uno es una decisión:
- Espacio: el aforo es un punto; la malla es un conjunto de centros de celda. ¿Celda más cercana o interpolación?
- Tiempo: la malla es mensual; el aforo es diario. ¿Qué valor mensual recibe un día dado?
import xarray as xr
# A 10-year synthetic monthly temperature field, wrapped with labeled coordinates
field, field_truth = mlgeo_synth.climate_field(n_lat=40, n_lon=80, n_months=120, seed=2026)
grid_time = pd.date_range("2015-01-01", periods=field.shape[0], freq="MS")
temp_grid = xr.DataArray(
field,
coords={"time": grid_time, "lat": field_truth["lat"], "lon": field_truth["lon"]},
dims=("time", "lat", "lon"),
name="t_anom_c",
attrs={"units": "degC", "description": "synthetic monthly surface temperature anomaly"},
)
# Our (fictional) gauge location. NOTE the longitude convention: this grid runs
# 0-360; a gauge at 122.4 W is 237.6 E. Mixing 0-360 and +/-180 conventions is
# the single most common raster-join bug -- check the grid's coords first.
gauge_lat, gauge_lon = 47.6, 237.6
fig, ax = plt.subplots(figsize=(8, 4))
temp_grid.isel(time=6).plot(ax=ax, cmap="RdBu_r")
ax.plot(gauge_lon, gauge_lat, "k*", ms=16)
ax.set_title("Gridded product (one month); star = the gauge")
plt.tight_layout()
Espacio: .sel frente a .interp¶
xarray ofrece ambos estimadores en una línea cada uno: .sel(..., method="nearest") salta al centro de celda más cercano, .interp(...) interpola bilinealmente desde las cuatro celdas circundantes. Para un campo suave como la temperatura, la bilineal es la mejor estimación puntual; para un ráster categórico (cobertura del suelo, unidad geológica), la interpolación no tiene sentido — promediar «granito» y «basalto» produce un disparate — y la celda más cercana es la única opción correcta. Declare cuál usó: en esta malla gruesa (4.5°) las dos difieren, y en los problemas reales a escala de kilómetros la diferencia puede dominar.
t_nearest = temp_grid.sel(lat=gauge_lat, lon=gauge_lon, method="nearest")
t_bilinear = temp_grid.interp(lat=gauge_lat, lon=gauge_lon)
print(f"gauge at ({gauge_lat:.1f}N, {gauge_lon:.1f}E); "
f"nearest cell center at ({float(t_nearest.lat):.1f}N, {float(t_nearest.lon):.1f}E)")
print(f"RMS difference nearest vs bilinear: "
f"{float(np.sqrt(((t_nearest - t_bilinear) ** 2).mean())):.3f} degC")
fig, ax = plt.subplots(figsize=(10, 3.2))
ax.plot(grid_time, t_nearest, lw=0.8, label="nearest cell")
ax.plot(grid_time, t_bilinear, lw=0.8, label="bilinear interpolation")
ax.set_ylabel("temperature anomaly (°C)")
ax.legend()
ax.set_title("The gridded field extracted at the gauge, two ways")
plt.tight_layout()gauge at (47.6N, 237.6E); nearest cell center at (47.4N, 238.5E)
RMS difference nearest vs bilinear: 0.037 degC

Tiempo: una unión causal¶
La serie extraída es mensual; la tabla de características es diaria. La jugada tentadora es interpolar la temperatura a resolución diaria — pero la interpolación lineal entre centros de mes usa el valor del mes siguiente, que es información del futuro: una característica de pronóstico construida así fuga, exactamente en el sentido de la sección 7. La unión causal es pandas.merge_asof(..., direction="backward"): cada día recibe el valor mensual más reciente disponible y nada del futuro. Ese es el valor por defecto correcto para las características predictivas; la interpolación suave está bien para el análisis retrospectivo, y en cualquiera de los dos casos la decisión pertenece a la ficha de datos.
Unimos la covariable a una copia de la tabla de características y volvemos a correr el clasificador de crecidas sobre las características de la sección 5, con ella y sin ella, usando la partición cronológica incluida y el preprocesamiento ajustado solo con el entrenamiento.
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import average_precision_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
# Station-side table of the extracted covariate
t_station = (t_bilinear.drop_vars(["lat", "lon"]).to_dataframe().reset_index()
.rename(columns={"time": "date"}))
# Causal merge onto the daily feature table (both sorted by date)
feat_g = pd.merge_asof(feat.reset_index().sort_values("date"), t_station,
on="date", direction="backward").set_index("date")
print(feat_g[["q_lag1", "t_anom_c", "event", "split"]].head(3))
tr = feat_g["split"] == "train"
te = feat_g["split"] == "test"
base_cols = [c for c in feat_g.columns if c.startswith(("q_lag", "doy_"))]
for cols, label in [(base_cols, "discharge features only"),
(base_cols + ["t_anom_c"], "+ gridded temperature")]:
pipe = make_pipeline(StandardScaler(),
LogisticRegression(max_iter=2000, class_weight="balanced"))
pipe.fit(feat_g.loc[tr, cols], feat_g.loc[tr, "event"])
ap = average_precision_score(feat_g.loc[te, "event"],
pipe.predict_proba(feat_g.loc[te, cols])[:, 1])
print(f"average precision, {label}: {ap:.3f}") q_lag1 t_anom_c event split
date
2015-01-08 35.717825 -7.432165 0 train
2015-01-09 37.612688 -7.432165 0 train
2015-01-10 35.942248 -7.432165 0 train
average precision, discharge features only: 0.722
average precision, + gridded temperature: 0.710
El puntaje apenas se mueve — y ese es el resultado honesto: este campo sintético de temperatura no tiene ninguna conexión causal con las crecidas inyectadas, y una covariable correctamente unida que no carga información sobre el objetivo no debería cambiar la métrica. El punto de esta sección es la unión, que ahora es segura contra fugas, con unidades etiquetadas y reproducible; en un proyecto real, las mismas seis líneas traen la precipitación de reanálisis a una estación de aforo, la temperatura superficial del mar a una boya o la humedad del suelo a un pozo, y allí la covariable a menudo aporta la habilidad de pronóstico (skill).
Lo que la malla sintética nos ahorró, y una real no lo hará: reproyectar entre sistemas de referencia de coordenadas (rioxarray/rasterio para cualquier cosa en un CRS proyectado), la verificación de longitudes 0–360 frente a ±180 que señalamos arriba, el hecho de que el valor de una celda de malla es un promedio de área y no una medición puntual (un error de representatividad que crece con el relieve), y la semántica de las marcas de tiempo (¿«2015-01» estampa el inicio, el medio o el final del intervalo de promediado?). Cada uno de estos es una entrada de la ficha de datos: la procedencia de la columna unida — la malla de origen, el método de extracción, la regla de unión temporal — es ahora parte de la procedencia de su conjunto de datos (puntos 1 y 2 de la lista de verificación).
7. Fuga de datos por preprocesamiento¶
Punto 7 de la lista de verificación, primera parte. Cada estadístico que usted calcula a partir de los datos — una media, una desviación estándar, una base de PCA, un valor de imputación — es parte del modelo. Si lo calcula con el conjunto de datos completo y luego particiona, información del periodo de prueba ya fluyó hacia las características con las que el modelo entrena. Esto es fuga de datos por preprocesamiento.
Aquí estandarizamos las características de dos maneras:
- Mal: ajustar el
StandardScalercon todos los datos, y luego particionar y entrenar. - Bien: particionar primero, ajustar el escalador solo con los datos de entrenamiento y aplicarlo (congelado) a los datos de prueba.
Las crecidas de los años de prueba desplazan la media y la varianza del conjunto completo, así que la versión incorrecta le dice al modelo, en silencio, algo sobre el periodo de prueba. Calificamos con la precisión promedio (el área bajo la curva precisión-exhaustividad), la métrica apropiada para una clase positiva rara — la exactitud (accuracy) sería de ~97 % para un modelo que nunca predice una crecida.
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import average_precision_score
from sklearn.preprocessing import StandardScaler
train_mask = feat["split"] == "train"
test_mask = feat["split"] == "test"
X_train, y_train = X[train_mask], y[train_mask]
X_test, y_test = X[test_mask], y[test_mask]
# WRONG: scaler sees the test data
scaler_leaky = StandardScaler().fit(X)
clf = LogisticRegression(max_iter=2000, class_weight="balanced")
clf.fit(scaler_leaky.transform(X_train), y_train)
ap_leaky = average_precision_score(y_test, clf.predict_proba(scaler_leaky.transform(X_test))[:, 1])
# RIGHT: scaler sees only the training data
scaler_clean = StandardScaler().fit(X_train)
clf = LogisticRegression(max_iter=2000, class_weight="balanced")
clf.fit(scaler_clean.transform(X_train), y_train)
ap_clean = average_precision_score(y_test, clf.predict_proba(scaler_clean.transform(X_test))[:, 1])
print(f"average precision, scaler fit on ALL data (leaky): {ap_leaky:.3f}")
print(f"average precision, scaler fit on TRAIN only: {ap_clean:.3f}")
print("\nScaler means for q_lag1 differ because the test years contain floods:")
print(f" full-data mean: {scaler_leaky.mean_[0]:.3f} train-only mean: {scaler_clean.mean_[0]:.3f}")average precision, scaler fit on ALL data (leaky): 0.722
average precision, scaler fit on TRAIN only: 0.722
Scaler means for q_lag1 differ because the test years contain floods:
full-data mean: 26.002 train-only mean: 25.948
En este conjunto de datos los dos puntajes son esencialmente idénticos: la serie es larga, aproximadamente estacionaria, y un escalador carga solo dos números por característica, que una regresión logística puede absorber en sus pesos. No se consuele con eso. La brecha crece cuando los datos son cortos, cuando el periodo de prueba se aleja del periodo de entrenamiento o cuando el preprocesamiento es más rico que un escalador — las bases de PCA, los modelos de imputación, la selección de características y la normalización por percentiles globales fugan mucho más que una media y una desviación estándar. La disciplina no cuesta nada: particione primero y luego ajuste cada paso de preprocesamiento solo con la partición de entrenamiento. En sklearn, ponga el escalador y el modelo en un solo Pipeline para que la validación cruzada reajuste el preprocesamiento dentro de cada pliegue automáticamente.
Una fuga que destroza la métrica: sobremuestrear antes de particionar¶
Aquí hay una fuga de preprocesamiento con dientes, y uno de los errores más comunes con los eventos raros. Para combatir el desbalance de clases, los estudiantes a menudo sobremuestrean la clase rara — duplican los días de crecida hasta balancear las clases — y luego particionan al azar en entrenamiento y prueba. Las copias duplicadas del mismo día de crecida caen a ambos lados de la partición. El modelo se evalúa entonces sobre copias exactas de sus muestras de entrenamiento.
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
from sklearn.utils import resample
Xa, ya = X.to_numpy(), y.to_numpy()
# WRONG: balance the classes first, split second
X_pos_up, y_pos_up = resample(Xa[ya == 1], ya[ya == 1],
n_samples=int((ya == 0).sum()), random_state=0)
X_bal = np.vstack([Xa[ya == 0], X_pos_up])
y_bal = np.concatenate([ya[ya == 0], y_pos_up])
X_tr, X_te, y_tr, y_te = train_test_split(X_bal, y_bal, test_size=0.3,
random_state=0, stratify=y_bal)
rf = RandomForestClassifier(n_estimators=200, random_state=0).fit(X_tr, y_tr)
ap_wrong = average_precision_score(y_te, rf.predict_proba(X_te)[:, 1])
# RIGHT: split first, oversample the training set only
X_tr, X_te, y_tr, y_te = train_test_split(Xa, ya, test_size=0.3,
random_state=0, stratify=ya)
X_pos_up, y_pos_up = resample(X_tr[y_tr == 1], y_tr[y_tr == 1],
n_samples=int((y_tr == 0).sum()), random_state=0)
rf = RandomForestClassifier(n_estimators=200, random_state=0).fit(
np.vstack([X_tr[y_tr == 0], X_pos_up]),
np.concatenate([y_tr[y_tr == 0], y_pos_up]))
ap_right = average_precision_score(y_te, rf.predict_proba(X_te)[:, 1])
print(f"average precision, oversample BEFORE split (leaky): {ap_wrong:.3f}")
print(f"average precision, oversample train only: {ap_right:.3f}")average precision, oversample BEFORE split (leaky): 1.000
average precision, oversample train only: 0.848
La versión con fuga reporta un puntaje perfecto o casi perfecto — pura ficción, producida enteramente por las muestras duplicadas a caballo de la partición. Cualquier preprocesamiento que cree, duplique, seleccione o transforme muestras usando conocimiento del conjunto completo debe ocurrir dentro de la partición de entrenamiento, nunca antes.
8. Particiones para datos autocorrelacionados¶
Punto 7 de la lista de verificación, segunda parte, y la mayor fuente de puntajes inflados en el ML geocientífico. Nuestras características son ventanas de siete días de una serie suave: el día y el día comparten seis de sus siete valores de rezago. Una partición aleatoria dispersa los días de una misma crecida entre el entrenamiento y la prueba. El modelo entonces «predice» un día de prueba por haber memorizado a sus vecinos casi duplicados en el entrenamiento. El puntaje es real; la habilidad no, y no se transferirá a una crecida que el modelo nunca ha visto.
La solución es particionar a lo largo de la estructura de correlación:
TimeSeriesSplitentrena con el pasado y prueba con el futuro — el escenario de despliegue del pronóstico.GroupKFoldmantiene los grupos intactos: todos los días de una misma crecida quedan del mismo lado de la partición. Agrupamos los días de crecida porevent_idy los días de fondo en bloques mensuales.
Una decisión deliberada más: cambiamos de la regresión logística a un bosque aleatorio. Un modelo lineal casi no tiene capacidad de memorizar muestras individuales, así que esconde el problema de la partición. Un bosque aleatorio memoriza los casi duplicados con gusto — y esa capacidad es exactamente lo que una partición aleatoria premia. Corremos la cadena idéntica (escalador + clasificador, para que el preprocesamiento se reajuste dentro de cada pliegue) bajo los tres esquemas de validación cruzada.
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import GroupKFold, KFold, TimeSeriesSplit, cross_val_score
from sklearn.pipeline import make_pipeline
pipe = make_pipeline(StandardScaler(),
RandomForestClassifier(n_estimators=200, random_state=0,
class_weight="balanced"))
# Groups: one group per flood event; background days grouped by 30-day block
day_index = np.arange(len(feat))
groups = np.where(feat["event_id"] >= 0,
feat["event_id"],
1000 + day_index // 30)
cv_schemes = {
"KFold (random, shuffled)": (KFold(n_splits=5, shuffle=True, random_state=0), None),
"TimeSeriesSplit": (TimeSeriesSplit(n_splits=5), None),
"GroupKFold (by event)": (GroupKFold(n_splits=5), groups),
}
results = {}
for name, (cv, g) in cv_schemes.items():
scores = cross_val_score(pipe, X, y, cv=cv, groups=g,
scoring="average_precision")
results[name] = scores
print(f"{name:28s} AP = {scores.mean():.3f} +/- {scores.std():.3f}")KFold (random, shuffled) AP = 0.839 +/- 0.034
TimeSeriesSplit AP = 0.704 +/- 0.039
GroupKFold (by event) AP = 0.734 +/- 0.053
fig, ax = plt.subplots(figsize=(7, 3.5))
ax.boxplot([results[k] for k in cv_schemes], tick_labels=list(cv_schemes))
ax.set_ylabel("average precision")
ax.set_title("The same model, three ways of splitting the same data")
plt.tight_layout()
El KFold aleatorio reporta el puntaje más alto por un margen amplio, y es el menos confiable de los tres: sus pliegues de prueba están llenos de muestras que son casi copias de muestras de entrenamiento, así que el bosque se califica sobre crecidas que, en la práctica, ya vio. TimeSeriesSplit y GroupKFold responden la pregunta que de verdad le importa — ¿detecta el modelo crecidas que nunca ha visto? — y la responden con menos halagos. (TimeSeriesSplit también da el puntaje más bajo, en parte porque sus primeros pliegues entrenan con solo una fracción de los datos.)
Reglas prácticas:
- Series de tiempo → particione en el tiempo (
TimeSeriesSplit, o una retención cronológica como nuestra columnasplitincluida). - Datos por eventos (sismos, tormentas, crecidas) → particione por evento (
GroupKFoldsobre el identificador de evento). Nunca deje que las ventanas de un mismo evento queden a caballo de la partición. - Datos espaciales → particione por región, no por píxel; los píxeles cercanos son casi duplicados.
- En caso de duda, pregúntese: ¿podría una muestra del conjunto de prueba reconstruirse trivialmente a partir de muestras del conjunto de entrenamiento? Si la respuesta es sí, la partición está mal.
Por esto el punto 6 de la lista dice que las particiones viajan con los datos: quien construye el conjunto de datos conoce su estructura de correlación mejor que cualquier usuario posterior, y una partición almacenada hace comparables todos los puntajes publicados.
9. Conjuntos de entrenamiento, validación y prueba¶
Cuál es el papel de un conjunto de entrenamiento y de uno de prueba¶
Un conjunto de datos de entrenamiento es el cimiento de los modelos de aprendizaje automático. Proporciona los datos que el algoritmo usa para aprender los patrones, las relaciones y las representaciones necesarias para hacer predicciones o tomar decisiones. La meta principal del conjunto de entrenamiento es permitir que el modelo minimice el error o la función de pérdida optimizando sus parámetros.
Un conjunto de datos de prueba se usa para evaluar el desempeño del modelo sobre datos no vistos. Prepararlo implica:
- Partición de los datos: el conjunto de prueba es un subconjunto de los datos, separado del conjunto de entrenamiento, a menudo del 10-30 % del conjunto — elegido teniendo en mente la estructura de correlación, como arriba.
- Principio de retención: los datos de prueba nunca deben traslaparse con los de entrenamiento o validación, y deben tocarse una sola vez, al final. Cada vez que usted mira los puntajes de prueba y cambia el modelo en respuesta, el conjunto de prueba se degrada en un conjunto de validación.
- Representatividad del mundo real: los datos de prueba deben reflejar los escenarios donde se desplegará el modelo, para garantizar una evaluación robusta del desempeño.
Aprendizaje automático clásico (CML) frente a aprendizaje profundo (DL)¶
CML: los modelos son más simples, con menos hiperparámetros que ajustar (los hiperparámetros son las configuraciones que usted elige antes de entrenar — la profundidad del árbol, la tasa de aprendizaje — a diferencia de los parámetros que el modelo aprende de los datos). Un conjunto de entrenamiento se usa para ajustar el modelo, que luego se evalúa con el conjunto de prueba. La validación cruzada (como en la sección 8) evalúa el desempeño y reduce el sobreajuste, y a menudo elimina la necesidad de un conjunto de validación separado.
DL: los modelos son complejos, a menudo con millones de parámetros, y necesitan un conjunto de validación además de los conjuntos de entrenamiento y prueba. El conjunto de validación se usa durante el entrenamiento para ajustar los hiperparámetros (la tasa de aprendizaje, el número de capas) y para vigilar el sobreajuste (detención temprana cuando la pérdida de validación deja de mejorar).
Una partición típica para DL:
- Conjunto de entrenamiento: 70-80 % de los datos.
- Conjunto de validación: 10-20 % de los datos.
- Conjunto de prueba: 10-20 % de los datos.
El conjunto de validación protege al de prueba: el ajuste de hiperparámetros consume el conjunto de validación, así que el conjunto de prueba permanece genuinamente no visto.
10. Esta lista de verificación se califica¶
El conjunto de datos listo para la IA que usted construya para el hito de su proyecto se evaluará contra la lista de verificación de la sección 1: la procedencia y la licencia, una ficha de datos, formas ordenadas y unidades, una política de datos faltantes, particiones de referencia incluidas, controles contra fugas y un inventario de clases y eventos. Vea la tarea del capítulo y la descripción del proyecto final.
Si recuerda una sola oración de este pilar: la partición y el preprocesamiento son parte del conjunto de datos, no una ocurrencia tardía del modelo.