Saltar al contenido

# machine-learning-clasico-con-python

Machine Learning con scikit-learn y XGBoost

Volver a la portada del curso
Lectura (Markdown)

Ajuste de hiperparámetros con GridSearchCV

Sección 5 · Árboles, boosting con XGBoost y ajuste de hiperparámetros

Ajuste de parámetros

En la sección de ensambles, el bosque aleatorio de PyRitmo usaba 300 árboles y min_samples_leaf=3. ¿De dónde salieron esos números? Probarlos a mano, mirando el test cada vez, es la forma más rápida de engañarse. Esta lección muestra cómo buscarlos de forma ordenada con la validación cruzada estratificada del video anterior, y cómo dejar el test intacto hasta el final. Todo el código está en el notebook validacion-y-ajuste.

Parámetros e hiperparámetros

Un parámetro lo aprende el modelo al llamar fit. La regresión logística del curso aprende 14 coeficientes, uno por columna que sale de pre (9 numéricas y 5 géneros), más el intercepto. En scikit-learn, lo aprendido termina en guion bajo: coef_, intercept_.

Un hiperparámetro lo eliges tú antes de entrenar y se pasa al constructor. Ejemplos que ya usaste:

  • max_depth y min_samples_leaf de los árboles.
  • n_estimators de los ensambles (100 por omisión).
  • learning_rate del gradient boosting (0.1 por omisión).
  • C de la regresión logística (1.0 por omisión).

Ajustar hiperparámetros es entrenar muchas veces con valores distintos y quedarte con los que mejor generalizan, medidos siempre con validación cruzada sobre train.

Búsqueda en rejilla: GridSearchCV

Defines una lista de valores por hiperparámetro y GridSearchCV prueba todas las combinaciones. Como el modelo vive dentro de un Pipeline, cada nombre lleva el paso, dos guiones bajos y el hiperparámetro: rf__max_depth es el max_depth del paso llamado "rf". Si dudas, pipe.get_params().keys() lista todos los nombres válidos.

pipe = Pipeline([("pre", pre),
                 ("rf", RandomForestClassifier(random_state=42))])
grid = {"rf__n_estimators": [100, 300],
        "rf__max_depth": [None, 5, 10],
        "rf__min_samples_leaf": [1, 3, 10]}
cv = StratifiedKFold(5, shuffle=True, random_state=42)
gs = GridSearchCV(pipe, grid, cv=cv, scoring="roc_auc", n_jobs=-1)
gs.fit(X_tr, y_tr)
print(gs.best_params_)
print(round(gs.best_score_, 4))         # 0.8474

La rejilla tiene $2 \times 3 \times 3 = 18$ combinaciones. Cada una se evalúa con 5 pliegues: 90 ajustes, más uno final. El costo es el producto de todas las listas, así que crece muy rápido. n_jobs=-1 reparte el trabajo entre todos los núcleos.

La ganadora es max_depth=None, min_samples_leaf=3 y n_estimators=300, con AUC medio de validación cruzada 0.8474. Ese best_score_ es una cifra de selección, no de test.

Leer cv_results_: la meseta

cv_results_ guarda una fila por combinación, con la media y la desviación de los pliegues y su rango. En un DataFrame ordenado por rank_test_score se ve algo que best_params_ esconde: las 12 mejores combinaciones están entre 0.844 y 0.847. Todas las que caen al fondo usan max_depth=5 (entre 0.837 y 0.841).

La lección práctica: la búsqueda sirve sobre todo para evitar zonas malas. Dentro de la meseta, las diferencias son menores que la variación entre pliegues, y elegir la primera o la quinta da casi lo mismo.

Búsqueda aleatoria: RandomizedSearchCV

Cuando hay muchos hiperparámetros, la rejilla se vuelve impagable. RandomizedSearchCV sortea n_iter combinaciones a partir de distribuciones de scipy.stats:

dist = {"gb__learning_rate": loguniform(0.01, 0.3),
        "gb__n_estimators": randint(50, 400),
        "gb__max_depth": randint(1, 6),
        "gb__subsample": [0.6, 0.8, 1.0]}
pipe_gb = Pipeline([("pre", pre),
                    ("gb", GradientBoostingClassifier(random_state=42))])
rs = RandomizedSearchCV(pipe_gb, dist, n_iter=30, cv=cv,
                        scoring="roc_auc", random_state=42, n_jobs=-1)
rs.fit(X_tr, y_tr)
print(round(rs.best_score_, 4))         # 0.8551
  • loguniform(0.01, 0.3) sortea la tasa de aprendizaje en escala logarítmica: 0.01 y 0.03 tienen la misma oportunidad que 0.1 y 0.3.
  • randint(50, 400) sortea enteros de 50 a 399; el límite superior no se incluye.
  • Una lista, como la de subsample, se sortea con la misma probabilidad para cada valor.

Con 30 combinaciones y 5 pliegues son 150 ajustes. La mejor: learning_rate 0.0463, max_depth 2, n_estimators 138 y subsample 0.8, con AUC de validación cruzada 0.8551, contra 0.849 del gradient boosting con sus valores por omisión. Árboles más bajos y una tasa más lenta: muchos pasos pequeños.

El test, una sola vez

Con refit=True (el valor por omisión), al terminar la búsqueda se reentrena la mejor combinación con todo X_tr. Ahora, y solo ahora, se mira el test:

print(round(rs.score(X_te, y_te), 4))   # 0.8761 (AUC)
pred = rs.predict(X_te)
print(confusion_matrix(y_te, pred))     # [[255 17] [41 62]]

rs.score usa el scoring de la búsqueda, así que devuelve AUC, no exactitud. En test, el modelo ajustado da AUC 0.8761, exactitud 0.845, precision 0.785 y recall 0.602. El AUC de test salió más alto que el de validación cruzada: no es mérito del modelo, son 375 canciones con su propia suerte. Lo honesto es reportarlo tal cual y no volver a ajustar para mejorarlo.

No siempre hay mucho que ganar

Con la regresión logística y una rejilla de C (mientras más pequeño, más regularización), el AUC de validación cruzada va de 0.812 con C=0.001 a 0.8232 con 0.01, 0.8303 con 0.1 y 0.8302 con 1. Desde 0.1 la curva es plana (0.8298 con 100). El valor por omisión ya estaba en la meseta: una búsqueda que confirma el valor por omisión también es información.

Trampas comunes

  • Ajustar mirando el test. Si eliges con el test, su cifra deja de ser una estimación honesta.
  • Reportar best_score_ como resultado final. Es la mejor de muchas: sale optimista por construcción.
  • Nombres sin prefijo. max_depth sin rf__ lanza ValueError: Invalid parameter.
  • Rejillas enormes. 4 valores en 4 hiperparámetros ya son 256 combinaciones y 1280 ajustes; usa búsqueda aleatoria.
  • Rangos lineales para tasas. Para learning_rate o C, usa escala logarítmica.

Empieza con rejillas pequeñas y espaciadas, mira cv_results_ y afina solo alrededor de la meseta.

Recursos