1. La promesa del AutoML¶
Hacia 2018–2022, una ola de herramientas de AutoML prometió automatizar el tedioso tramo medio del aprendizaje automático: elegir la familia de modelos, ajustar los hiperparámetros, reportar al ganador. auto-sklearn, TPOT, H2O AutoML y pycaret ofrecían una experiencia compare_models() de una sola línea, y ediciones anteriores de este capítulo enseñaban pycaret sobre el mismo conjunto de datos de temperatura usado en la lección 3.7.
Esa ola se retiró. La mayoría de las herramientas académicas de AutoML ya no se mantiene, y pycaret está retirado de este curso. Dos cosas sobrevivieron y vale la pena enseñarlas:
- Las bibliotecas de optimización de hiperparámetros. La búsqueda automatizada sobre la configuración del modelo sigue siendo práctica estándar; Optuna es la biblioteca por defecto actual para ello.
- Buenos modelos tabulares por defecto. Los árboles con gradient boosting (potenciación de gradiente) — el
HistGradientBoostingRegressorde scikit-learn, LightGBM, XGBoost — ganan sobre las tablas de características con tal consistencia que la búsqueda de modelos rara vez es ya el cuello de botella. Elija un árbol potenciado, ajústelo un poco y gaste el tiempo ahorrado en la calidad de los datos y en la evaluación.
Esta lección cubre a los dos sobrevivientes y luego mira lo que reemplazó al AutoML en 2026: los agentes que escriben código, y las destrezas de verificación que le exigen a usted.
2. La búsqueda de hiperparámetros que sobrevivió: búsqueda en malla contra Optuna¶
Reutilizamos exactamente el conjunto de datos de la lección 3.7 — el mismo generador, las mismas características, la misma división —, de modo que los números son comparables entre las dos lecciones. El modelo es HistGradientBoostingRegressor, y la cantidad que optimizamos es el MAE con validación cruzada de 5 pliegues sobre el conjunto de entrenamiento.
import numpy as np
import pandas as pd
def make_daily_temps(start="2012-01-01", end="2019-12-31", seed=42):
"""Synthetic Seattle-like daily maximum temperature record (degrees F).
Seasonal climatology + a weak warming trend + AR(1) weather noise.
Generated in-notebook so the lesson does not depend on a remote file.
"""
rng = np.random.default_rng(seed)
dates = pd.date_range(start, end, freq="D")
day_of_year = dates.dayofyear.to_numpy()
climatology = 62.0 - 15.0 * np.cos(2 * np.pi * (day_of_year - 203) / 365.25)
trend = 0.05 * np.arange(len(dates)) / 365.25
noise = np.zeros(len(dates))
for i in range(1, len(dates)):
noise[i] = 0.65 * noise[i - 1] + rng.normal(0.0, 3.0)
df = pd.DataFrame(
{
"date": dates,
"average": np.round(climatology, 1), # historical average for that calendar day
"actual": np.round(climatology + trend + noise, 1),
}
)
df["temp_1"] = df["actual"].shift(1) # yesterday's max
df["temp_2"] = df["actual"].shift(2) # two days ago
df["month"] = df["date"].dt.month
df["day"] = df["date"].dt.day
df["doy_sin"] = np.sin(2 * np.pi * day_of_year / 365.25)
df["doy_cos"] = np.cos(2 * np.pi * day_of_year / 365.25)
return df.dropna().reset_index(drop=True)
df = make_daily_temps()
features = ["temp_1", "temp_2", "average", "month", "day", "doy_sin", "doy_cos"]
X = df[features]
y = df["actual"]
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=42)
print("Training features:", X_train.shape, " Testing features:", X_test.shape)Training features: (2190, 7) Testing features: (730, 7)
Búsqueda en malla¶
GridSearchCV prueba cada combinación de una retícula fija de valores. Con 3 profundidades, 3 tasas de aprendizaje y 2 límites de nodos hoja, son 18 candidatos, cada uno validado de forma cruzada 5 veces: 90 ajustes.
import time
from sklearn.ensemble import HistGradientBoostingRegressor
from sklearn.metrics import mean_absolute_error
from sklearn.model_selection import GridSearchCV, KFold, cross_val_score
cv = KFold(n_splits=5, shuffle=True, random_state=42)
param_grid = {
"max_depth": [2, 4, 8],
"learning_rate": [0.03, 0.1, 0.3],
"max_leaf_nodes": [15, 31],
}
grid = GridSearchCV(
HistGradientBoostingRegressor(random_state=42),
param_grid,
cv=cv,
scoring="neg_mean_absolute_error",
)
t0 = time.perf_counter()
grid.fit(X_train, y_train)
grid_time = time.perf_counter() - t0
n_candidates = len(grid.cv_results_["params"])
grid_fits = n_candidates * cv.get_n_splits()
grid_cv_mae = -grid.best_score_
grid_test_mae = mean_absolute_error(y_test, grid.best_estimator_.predict(X_test))
print("Best params:", grid.best_params_)
print(f"Best CV MAE: {grid_cv_mae:.3f} F")
print(f"Fits: {grid_fits} ({n_candidates} candidates x {cv.get_n_splits()} folds), wall time {grid_time:.1f} s")Best params: {'learning_rate': 0.1, 'max_depth': 2, 'max_leaf_nodes': 15}
Best CV MAE: 2.458 F
Fits: 90 (18 candidates x 5 folds), wall time 78.0 s
Optuna¶
Optuna muestrea el espacio de hiperparámetros en lugar de recorrer una retícula. Su muestreador TPE construye un modelo de qué regiones puntúan bien y concentra allí los ensayos posteriores. La función objetivo es cualquier cosa que usted pueda calcular: aquí, el mismo MAE de validación cruzada de 5 pliegues.
import optuna
optuna.logging.set_verbosity(optuna.logging.WARNING)
def objective(trial):
params = {
"max_depth": trial.suggest_int("max_depth", 2, 10),
"learning_rate": trial.suggest_float("learning_rate", 0.01, 0.4, log=True),
"max_leaf_nodes": trial.suggest_int("max_leaf_nodes", 10, 60),
}
model = HistGradientBoostingRegressor(random_state=42, **params)
scores = -cross_val_score(model, X_train, y_train, cv=cv, scoring="neg_mean_absolute_error")
return scores.mean()
study = optuna.create_study(direction="minimize", sampler=optuna.samplers.TPESampler(seed=42))
t0 = time.perf_counter()
study.optimize(objective, n_trials=30)
optuna_time = time.perf_counter() - t0
optuna_fits = 30 * cv.get_n_splits()
optuna_cv_mae = study.best_value
print("Best params:", study.best_params)
print(f"Best CV MAE: {optuna_cv_mae:.3f} F")
print(f"Fits: {optuna_fits} (30 trials x {cv.get_n_splits()} folds), wall time {optuna_time:.1f} s")Best params: {'max_depth': 3, 'learning_rate': 0.05958491008634781, 'max_leaf_nodes': 35}
Best CV MAE: 2.438 F
Fits: 150 (30 trials x 5 folds), wall time 145.8 s
# Refit each winner on the full training set, score once on the test set
optuna_model = HistGradientBoostingRegressor(random_state=42, **study.best_params)
optuna_model.fit(X_train, y_train)
optuna_test_mae = mean_absolute_error(y_test, optuna_model.predict(X_test))
comparison = pd.DataFrame(
{
"best CV MAE (F)": [grid_cv_mae, optuna_cv_mae],
"test MAE (F)": [grid_test_mae, optuna_test_mae],
"wall time (s)": [grid_time, optuna_time],
"fits": [grid_fits, optuna_fits],
},
index=["GridSearchCV", "Optuna (TPE)"],
)
comparison.round(3)La búsqueda en malla gasta su presupuesto en una retícula fija: cada punto se prueba, se haya visto ya mal o no su vecindario. Optuna muestrea el espacio continuo y se adapta a los ensayos pasados, así que puede aterrizar entre los puntos de la retícula y saltarse las regiones muertas. En un problema tan pequeño la diferencia son minutos; en una búsqueda de aprendizaje profundo con ajustes de una hora, son días. En cualquier caso, ambos métodos reportan un score validado de forma cruzada sobre datos de entrenamiento y tocan el conjunto de prueba exactamente una vez, al final.
3. 2026: los agentes escriben el pipeline, usted lo verifica¶
Hoy la exploración de modelos suele ocurrir de forma conversacional: usted le describe el conjunto de datos a un agente LLM, y este escribe el código de la cadena de modelado — el pipeline: la secuencia ordenada de preprocesamiento, ajuste y evaluación que va de los datos crudos al score —, lo corre y reporta un score. El problema de búsqueda que el AutoML intentó resolver se convirtió en un problema de verificación. El código escrito por máquinas falla de las mismas maneras que el código humano apurado — preprocesamiento con fuga de datos, clases descartadas, scores calculados sobre la división equivocada —, solo que con más fluidez, envuelto en comentarios prolijos y salidas confiadas.
Abajo hay un script de modelado del tipo que un asistente de IA producirá con gusto. Corre, imprime un score fuerte y está mal de tres maneras distintas. Encuéntrelas antes de abrir la solución.
# --- AI-generated modeling script: do NOT trust it yet ---
import mlgeo_synth
from sklearn.ensemble import HistGradientBoostingClassifier, RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
# Whole-rock geochemistry: oxide wt%, density, magnetic susceptibility -> rock type
df = mlgeo_synth.geochem_table(n=4000, seed=7)
df = df[df["label"].isin(["granite", "basalt"])] # remove sparse label noise
feature_cols = ["SIO2", "AL2O3", "FEO", "MGO", "CAO", "NA2O", "K2O", "density_g_cm3", "mag_susc_si"]
X = df[feature_cols].to_numpy()
y = df["label"].to_numpy()
scaler = StandardScaler() # normalize features
X = scaler.fit_transform(X)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=7)
models = {
"logistic_regression": LogisticRegression(max_iter=2000),
"random_forest": RandomForestClassifier(n_estimators=200, random_state=7),
"hist_gradient_boosting": HistGradientBoostingClassifier(random_state=7),
}
best_name, best_score = None, -1.0
for name, clf in models.items():
clf.fit(X_train, y_train)
score = clf.score(X_train, y_train) # evaluate each candidate
print(f"{name}: accuracy = {score:.3f}")
if score > best_score:
best_name, best_score = name, score
print(f"\nbest model: {best_name}, accuracy = {best_score:.3f}")logistic_regression: accuracy = 1.000
random_forest: accuracy = 1.000
hist_gradient_boosting: accuracy = 1.000
best model: logistic_regression, accuracy = 1.000
Solución
- La clase minoritaria se descarta en silencio.
df[df["label"].isin(["granite", "basalt"])]desecha todas las muestras de andesita bajo un comentario sobre «ruido de etiquetas». Un problema de 3 clases se vuelve uno más fácil de 2 clases, la exactitud reportada corresponde a una tarea distinta de la planteada y, en producción, esto sería un error científico silencioso: el modelo nunca puede predecir andesita. - El escalador se ajusta antes de la división.
StandardScaler().fit_transform(X)sobre la matriz completa calcula las medias y las varianzas usando las filas de prueba, y después ocurre la división. Las estadísticas del conjunto de prueba se fugan hacia las características de entrenamiento. El efecto aquí es pequeño, pero el patrón es exactamente la fuga de datos discutida en lecciones anteriores, y con otros preprocesadores (imputación, codificación por objetivo) puede ser grande. - La selección de modelos usa la exactitud sobre el conjunto de entrenamiento.
clf.score(X_train, y_train)premia la memorización, así que esta comparación favorece sistemáticamente al candidato más sobreajustado. Aquí todos los modelos puntúan ~1.0 sobre datos que ya vieron, así que la comparación no puede distinguirlos en absoluto. La «exactitud del mejor modelo» impresa no dice nada sobre la generalización.
El olor delator: un score casi perfecto que aparece sin modelo de referencia (baseline) y sin evaluación sobre datos apartados. Los resultados reales vienen con un modelo de referencia que superar y un conjunto de prueba tocado una sola vez.
El pipeline corregido¶
Las correcciones: conservar las tres clases; dividir primero, con estratificación para que la minoría de andesita aparezca en ambos subconjuntos; poner el escalador dentro de un Pipeline para que se ajuste solo sobre los pliegues de entrenamiento; reportar un modelo de referencia de clase mayoritaria antes de cualquier modelo; seleccionar entre los candidatos por el F1 macro validado de forma cruzada sobre el conjunto de entrenamiento (el F1 macro pondera por igual a la clase minoritaria); y tocar el conjunto de prueba una vez, al final, con scores por clase.
from sklearn.dummy import DummyClassifier
from sklearn.metrics import accuracy_score, classification_report, f1_score
from sklearn.model_selection import cross_val_score
from sklearn.pipeline import make_pipeline
df = mlgeo_synth.geochem_table(n=4000, seed=7) # all three classes kept
print(df["label"].value_counts(), "\n")
X = df[feature_cols]
y = df["label"]
# Split FIRST, stratified so class proportions match in train and test
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.25, random_state=7, stratify=y
)
# Baseline before any model
dummy = DummyClassifier(strategy="most_frequent").fit(X_train, y_train)
print(f"Majority-class baseline: accuracy = {dummy.score(X_test, y_test):.3f}, "
f"macro-F1 = {f1_score(y_test, dummy.predict(X_test), average='macro'):.3f}\n")
candidates = {
"logistic_regression": make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000)),
"random_forest": make_pipeline(StandardScaler(), RandomForestClassifier(n_estimators=200, random_state=7)),
"hist_gradient_boosting": make_pipeline(StandardScaler(), HistGradientBoostingClassifier(random_state=7)),
}
best_name, best_cv = None, -1.0
for name, pipe in candidates.items():
scores = cross_val_score(pipe, X_train, y_train, cv=5, scoring="f1_macro")
print(f"{name}: CV macro-F1 = {scores.mean():.3f} +/- {scores.std():.3f}")
if scores.mean() > best_cv:
best_name, best_cv = name, scores.mean()
# One look at the test set, for the selected model only
final = candidates[best_name].fit(X_train, y_train)
y_pred = final.predict(X_test)
print(f"\nSelected model: {best_name}")
print(f"Test accuracy = {accuracy_score(y_test, y_pred):.3f}, "
f"test macro-F1 = {f1_score(y_test, y_pred, average='macro'):.3f}\n")
print(classification_report(y_test, y_pred))label
granite 2205
basalt 1397
andesite 398
Name: count, dtype: int64
Majority-class baseline: accuracy = 0.551, macro-F1 = 0.237
logistic_regression: CV macro-F1 = 0.999 +/- 0.002
random_forest: CV macro-F1 = 0.999 +/- 0.003
hist_gradient_boosting: CV macro-F1 = 0.999 +/- 0.001
Selected model: hist_gradient_boosting
Test accuracy = 0.999, test macro-F1 = 0.998
precision recall f1-score support
andesite 1.00 0.99 0.99 100
basalt 1.00 1.00 1.00 349
granite 1.00 1.00 1.00 551
accuracy 1.00 1000
macro avg 1.00 1.00 1.00 1000
weighted avg 1.00 1.00 1.00 1000
El pipeline corregido también reporta un score alto — estos tipos de roca están genuinamente bien separados en el espacio de los óxidos —, pero el número hace ahora una afirmación distinta. Cubre las tres clases, incluida la andesita que el script de la IA nunca podría predecir; se mide sobre datos apartados en lugar de datos memorizados; y se sostiene contra un modelo de referencia mayoritario de 0.55. Los mismos dígitos, otro significado. En un conjunto de datos más difícil los dos flujos de trabajo divergen: el score de entrenamiento del script de la IA se queda cerca de 1.0 pase lo que pase, mientras que el número honesto baja para decírselo.
4. Qué conservar¶
La automatización se mudó. En 2020 vivía en los algoritmos de búsqueda — el AutoML iterando sobre modelos e hiperparámetros. En 2026 vive en los agentes que escriben código y producen el pipeline completo a pedido. La verificación no se mudó. Un modelo de referencia trivial, una división sin fuga de datos, la validación cruzada dentro del conjunto de entrenamiento y una sola mirada a un conjunto de prueba apartado atrapan los errores escritos por máquinas exactamente igual que atrapan los humanos. Automatice la búsqueda; nunca la verificación.