Curso de ML EN

Capítulo 19 de 37 · intermedio

Ajuste de hiperparámetros

Qué cubre este capítulo

Todo modelo tiene dos tipos de números adentro. El primer tipo lo aprende de los datos: los pesos de un ajuste lineal, los puntos de corte de un árbol, los vectores de soporte de una SVM. El segundo tipo se lo tienes que dar tú antes de que pueda aprender nada: qué tan profundo puede crecer el árbol, a cuántos vecinos consultar, qué tan duro debe empujar su frontera una SVM. Esos son los hiperparámetros, y el modelo no puede descubrirlos por sí solo, porque son los ajustes que deciden qué significa "aprender" para él. Este capítulo trata de cómo elegirlos con honestidad.

La honestidad es todo el punto, y por eso este capítulo está donde está. Los dos capítulos anteriores construyeron las herramientas en las que se apoya: la validación cruzada, que te da un score estable para un modelo fijo, y la U de sesgo-varianza, que es la forma por la que todo hiperparámetro realmente te va guiando. El tuning es el ciclo que envuelve a la validación cruzada — prueba una configuración, califícala con k-fold, quédate con la ganadora — más la disciplina de no engañarte mientras lo haces. Construimos ese ciclo desde cero en NumPy: un particionador k-fold reutilizado directo del capítulo de validación cruzada, un score con validación cruzada para una configuración, luego grid search que califica cada combinación de una malla, random search que muestrea en lugar de recorrer, y la curva de validación que muestra la historia completa de un hiperparámetro de un vistazo. Después le entregamos el mismo trabajo a GridSearchCV, RandomizedSearchCV y validation_curve de scikit-learn y comprobamos que nuestra búsqueda selecciona la configuración idéntica con el score idéntico.

El vehículo es una SVM con kernel RBF sobre los datos de diabetes Pima: dos hiperparámetros, C y gamma, que se compensan de una forma que puedes ver. La pieza central es una animación de la búsqueda misma: mira a grid search arrastrarse por una malla de dos parámetros, llenando el score con validación cruzada de cada celda, mientras random search dispersa el mismo presupuesto de ajustes y encuentra un buen punto en una fracción de las evaluaciones. El número que cierra el capítulo es el honesto: lo que el tuning nos compró sobre datos que la búsqueda nunca tocó.

Un poco de historia

Grid search no tiene inventor, y eso ya te dice algo. Es lo que todo el mundo hacía porque es lo obvio: lista algunos valores para cada perilla, prueba cada combinación, quédate con la mejor. Aparece en estadística e ingeniería mucho antes de que el machine learning tuviera nombre, bajo etiquetas como "parameter sweep", y durante décadas fue menos un método que un reflejo. Si tenías dos hiperparámetros y una tarde libre, hacías una malla. El reflejo sobrevivió hasta la era de scikit-learn porque es trivial de escribir, trivial de paralelizar y fácil de explicarle a un revisor: cada punto de la malla recibió su prueba justa e idéntica.

El reflejo también era silenciosamente desperdiciador, y el paper que lo dijo en voz alta es "Random Search for Hyper-Parameter Optimization" de James Bergstra y Yoshua Bengio, de 2012, en el Journal of Machine Learning Research. Su argumento es afilado y un poco contraintuitivo: con el mismo presupuesto de pruebas, random search normalmente le gana a grid search, y la brecha se ensancha conforme agregas hiperparámetros. La razón es que no todos los hiperparámetros importan por igual. Si solo dos de tus diez perillas realmente mueven el score, una malla desperdicia casi todas sus pruebas re-evaluando las ocho que no — a las dos que importan las evalúa en apenas un puñado de valores distintos cada una, porque la resolución de la malla se gastó en las dimensiones inútiles. Random search, al muestrear todas las perillas de forma independiente en cada prueba, intenta un valor diferente de las importantes en cada ajuste, así que explora las dimensiones que importan con mucha más finura, gratis. La pulcra retícula de grid search, resulta, es exactamente la forma equivocada para un problema donde no sabes de antemano qué ejes cuentan. Ese paper es la razón por la que random search es hoy el default sensato, y por la que el campo siguió avanzando — hacia la optimización bayesiana, a la que llegaremos al final, donde la búsqueda aprende de sus propias pruebas en lugar de muestrear a ciegas.

La intuición

Aquí está la superficie que vamos a buscar. Dos hiperparámetros, C y gamma, dispuestos en una malla — cinco valores cada uno, o sea veinticinco combinaciones — y cada celda coloreada por su accuracy con validación cruzada de cinco folds. Esto es lo que de verdad calcula nuestro código, no un boceto. Léelo antes de que construyamos la búsqueda, porque la búsqueda no es más que una forma de descubrir esta imagen celda por celda sin poder verla primero.

Las dos perillas hacen trabajos distintos, y la superficie muestra ambos. Gamma fija hasta dónde llega la influencia de un solo punto de entrenamiento: demasiado pequeña y cada punto se embarra por todo el espacio, así que la frontera no puede doblarse — la banda oscura y plana de underfit en la parte de abajo, atorada cerca de 0.65. Demasiado grande y la influencia de cada punto se colapsa en una burbujita alrededor de sí mismo, así que el modelo memoriza el conjunto de entrenamiento y no generaliza nada — el oscurecimiento en la parte de arriba. C es la suavidad del margen: cuánto está dispuesta la SVM a clasificar mal un punto de entrenamiento con tal de mantener una frontera más simple. La cresta brillante que corre por el centro es donde ambas están en equilibrio, y la celda más brillante de todas — C en 10, gamma en 0.01 — es lo que la búsqueda anda cazando. Tú puedes verla aquí de un vistazo. La búsqueda no; tiene que ganársela.

La matemática

Una configuración es una elección de valores para cada hiperparámetro — llamémosla λ\lambda. Para nuestra SVM, λ=(C,γ)\lambda = (C, \gamma). El conjunto de configuraciones que la búsqueda considera es Λ\Lambda: para grid search es el producto cartesiano de las listas de valores por perilla; para random search es lo que sea que se muestree. Toda la tarea es una sola optimización sobre ese conjunto.

El objetivo es el score con validación cruzada del capítulo anterior, ahora leído como función de la configuración. Divide las filas de entrenamiento en k folds. Sea f^λ(j)\hat{f}_{\lambda}^{(-j)} el modelo entrenado con los ajustes λ\lambda en todos los folds excepto el fold j, y sea FjF_j el conjunto de filas apartadas del fold j. El score de validación cruzada de una configuración es el accuracy promedio de esas predicciones apartadas a lo largo de los k folds:

CV(λ)=1kj=1k1FjiFj1 ⁣[f^λ(j)(xi)=yi]\mathrm{CV}(\lambda) = \frac{1}{k} \sum_{j=1}^{k} \frac{1}{|F_j|} \sum_{i \in F_j} \mathbb{1}\!\left[\, \hat{f}_{\lambda}^{(-j)}(x_i) = y_i \,\right]

Lee la suma interna como "accuracy en el fold j" y la externa como "promedio sobre los folds" — es exactamente el CVk\mathrm{CV}_k del capítulo de validación cruzada, con los ajustes del modelo ahora nombrados explícitamente porque son lo que estamos variando. El tuning entonces es una línea. Elige la configuración de la malla que lo maximiza:

λ=argmaxλΛ  CV(λ)\lambda^{*} = \arg\max_{\lambda \in \Lambda} \; \mathrm{CV}(\lambda)

Ese es el objetivo completo. Grid search evalúa CV(λ)\mathrm{CV}(\lambda) en cada λ\lambda de la retícula y devuelve el argmax por fuerza bruta. Random search extrae λ1,,λn\lambda_1, \ldots, \lambda_n de forma independiente de una distribución sobre el espacio y devuelve la mejor de esas:

λrand=argmaxt{1,,n}  CV(λt),λtp(λ)\lambda^{*}_{\text{rand}} = \arg\max_{t \in \{1, \ldots, n\}} \; \mathrm{CV}(\lambda_t), \qquad \lambda_t \sim p(\lambda)

Mismo objetivo, distinto Λ\Lambda. La única decisión de modelado realmente enterrada aquí es la distribución p(λ)p(\lambda), y para parámetros de escala como C y gamma debe ser log-uniforme — muestrea el exponente de manera uniforme — porque lo que importa es el orden de magnitud, no el valor crudo. Una búsqueda que extrae gamma uniformemente en [0.0001,1][0.0001, 1] gasta el 90% de sus extracciones arriba de 0.1 y nunca prueba de verdad los valores pequeños donde vive el punto dulce.

En qué es bueno, en qué no

La fortaleza de ambas búsquedas es que no necesitan nada de ti más que honestidad y paciencia. No hay gradiente, no hay supuestos sobre el modelo, no hay requisito de que el score sea diferenciable o siquiera continuo — el objetivo es una caja negra que se come una configuración y devuelve un número, y la búsqueda solo tiene que comparar números. Por eso exactamente el mismo ciclo ajusta una SVM, un random forest y una red neuronal sin cambiar una línea: nunca mira dentro del modelo. Grid search suma una segunda virtud, la reproducibilidad. La retícula es fija, así que la búsqueda es determinista y cada revisor que la corra obtiene la misma ganadora — que es por lo que, para dos o tres hiperparámetros sobre un rango modesto, sigue siendo un default perfectamente bueno y el que yo elijo cuando quiero que el resultado sea aburrido y auditable.

La debilidad es el costo, y es exponencial de la peor manera. Cada hiperparámetro que agregas multiplica la malla: cinco valores de una perilla son cinco ajustes, pero cinco valores de cada una de cuatro perillas son 625, por k para la validación cruzada, y eso antes de que el modelo mismo sea lento. Esta es la maldición de la dimensionalidad con otro sombrero, y es exactamente el régimen donde muerde el punto de Bergstra y Bengio: la malla gasta su presupuesto en combinaciones que varían perillas que no importan. Random search esquiva la multiplicación al desacoplar el presupuesto de la dimensionalidad: tú eliges cuántos ajustes puedes pagar y él los gasta, ya sean dos hiperparámetros o veinte. Ninguna de las dos búsquedas es lista, eso sí. Ambas evalúan a ciegas, sin aprender nada de las pruebas que ya corrieron, que es la rendija por la que entra la optimización bayesiana.

Los datos

Los mismos pacientes que el capítulo de validación cruzada: el conjunto de diabetes Pima, 768 personas, ocho mediciones cada una — glucosa, IMC, edad, presión arterial y demás — y una etiqueta binaria, diabetes o no, positiva en alrededor del 35% de ellas. El modelo esta vez es una SVM con kernel RBF, a la que le importa la escala, así que las features se estandarizan; y como la estandarización es algo que se aprende de los datos, se reajusta dentro de cada fold solo con las filas de entrenamiento de ese fold, nunca con las apartadas. Es una pequeña honestidad fácil de saltarse que envenena silenciosamente un score si lo haces.

La honestidad grande es la partición. Antes de que ocurra cualquier tuning, aparto un cuarto de los datos — 192 pacientes — como conjunto de prueba y no lo vuelvo a tocar hasta el número final. Toda la búsqueda, toda la validación cruzada, todo el argmax, sucede sobre los otros 576 pacientes. Esta es la regla de oro del tuning, y vale la pena decirla sin rodeos: en el momento en que eliges un hiperparámetro por un score, ese score ya fue usado para ajustar y deja de ser una estimación insesgada de nada. La validación cruzada sobre el conjunto de entrenamiento te dice qué configuración elegir; no te dice qué tan bien le irá a la configuración elegida, porque la elegiste por calificar bien. La única estimación limpia de eso viene de datos que no jugaron ningún papel en la elección. Así que la estructura es anidada: un ciclo interno de validación cruzada selecciona la configuración, y un conjunto apartado externo — evaluado exactamente una vez, al final — reporta el número que de verdad citarías.

Constrúyelo, una función a la vez

La búsqueda es un ciclo pequeño alrededor de una función de scoring, y la función de scoring es la validación cruzada, así que empezamos por tomarla prestada. Primero la métrica que cada fold necesita:

def accuracy(y_true, y_pred):
    """Fraction of predictions that match the truth — unitless, 0 to 1."""
    y_true = np.asarray(y_true)
    y_pred = np.asarray(y_pred)
    return float((y_true == y_pred).mean())

Luego los folds. El tuning reutiliza la validación cruzada al mayoreo, así que reutiliza su particionador — este es el k-fold estratificado del capítulo anterior, insertado sin cambios para que los scores de la búsqueda cuadren exactamente con los de scikit-learn. Estratificado porque la etiqueta está desbalanceada y queremos que cada fold cargue el mismo balance de clases:

def stratified_kfold_indices(y, k):
    """The week-25 splitter: k folds, each keeping the whole set's class balance.

    Tuning reuses cross-validation wholesale, so it reuses cross-validation's
    folds. This is the same StratifiedKFold(shuffle=False) allocation built in
    the cross-validation chapter — split each class across the folds separately
    so a rare label can't clump — reproduced here so the tuning loop is
    self-contained and its scores line up with scikit-learn's to the digit.
    """
    y = np.asarray(y)
    classes, y_enc = np.unique(y, return_inverse=True)
    ordered = np.sort(y_enc)
    per_fold = np.array([np.bincount(ordered[i::k], minlength=len(classes))
                         for i in range(k)])
    fold_id = np.empty(len(y), dtype=int)
    for c in range(len(classes)):
        fold_id[y_enc == c] = np.repeat(np.arange(k), per_fold[:, c])
    return [(np.where(fold_id != f)[0], np.where(fold_id == f)[0]) for f in range(k)]

Ahora el objetivo: el score con validación cruzada de una configuración fija. Este es el número que la búsqueda maximiza. El estimador llega como un callable fit que devuelve una función predict, así que este ciclo trata al modelo como caja negra: ajusta sobre las filas de entrenamiento de cada fold, califica las filas apartadas y promedia:

def cv_score(fit, X, y, splits):
    """Cross-validated accuracy for one fixed configuration — the score to beat.

    `fit(X_train, y_train)` trains a fresh model and returns a `predict`
    callable. We rotate through the folds, fit on each fold's training rows,
    score the held-out rows, and return the mean across folds. That mean is the
    objective the search maximizes; the model is a black box to this function.
    """
    X, y = np.asarray(X), np.asarray(y)
    fold_acc = np.empty(len(splits))
    for j, (train, val) in enumerate(splits):
        predict = fit(X[train], y[train])
        fold_acc[j] = accuracy(y[val], predict(X[val]))
    return float(fold_acc.mean())

Con una configuración ya calificable, grid search es un ciclo sobre el producto cartesiano de las listas de valores. Registra el score de cada combinación — eso es lo que dibuja el heatmap — y devuelve el argmax:

def grid_search(make_fit, grid, X, y, splits):
    """Exhaustive search: CV-score every combination on the grid, keep the best.

    `grid` maps each hyperparameter name to a list of candidate values; the
    search is the Cartesian product of those lists. `make_fit(params)` returns a
    `fit` callable wired with those settings. Returns every combination's score
    (for the heatmap) and the argmax — the single best configuration found.
    """
    names = list(grid)
    results = []
    for combo in itertools.product(*(grid[n] for n in names)):
        params = dict(zip(names, combo))
        score = cv_score(make_fit(params), X, y, splits)
        results.append({"params": params, "score": score})
    best = max(results, key=lambda r: r["score"])
    return results, best

Random search tiene el mismo esqueleto con el cuerpo del ciclo intercambiado: en lugar de caminar una retícula, extrae cada configuración de un muestreador y gasta un presupuesto fijo de n_iter ajustes. Los muestreadores son funciones del RNG — una extracción log-uniforme para C y gamma — y sembrar el RNG hace que toda la corrida sea repetible:

def random_search(make_fit, samplers, n_iter, X, y, splits, rng):
    """Sample n_iter configurations, CV-score each, keep the best.

    `samplers` maps each hyperparameter to a function of the RNG that draws one
    value — a log-uniform draw for C and gamma, say. Unlike the grid, this never
    enumerates a lattice: it spends its whole budget on n_iter independent draws,
    which is why in high dimensions it finds a good point in far fewer fits.
    Seed the RNG and the run is exactly repeatable.
    """
    results = []
    for _ in range(n_iter):
        params = {name: sample(rng) for name, sample in samplers.items()}
        score = cv_score(make_fit(params), X, y, splits)
        results.append({"params": params, "score": score})
    best = max(results, key=lambda r: r["score"])
    return results, best

Al último, la curva de validación. Esto no es una búsqueda — es un diagnóstico. Fija todos los hiperparámetros menos uno, barre ese a lo largo de un rango, y registra dos scores en cada valor: accuracy sobre los folds de entrenamiento y accuracy sobre los folds apartados. La curva de entrenamiento te dice qué tan flexible se ha vuelto el modelo; la curva de validación te dice si esa flexibilidad está ayudando o estorbando; y la brecha entre ambas es el overfitting, dibujado como una distancia:

def validation_curve(make_fit, param_name, values, X, y, splits, fixed=None):
    """Train and validation CV accuracy as one hyperparameter sweeps a range.

    Hold every other setting fixed, vary `param_name` across `values`, and at
    each value record two numbers: the mean accuracy on the training folds and
    the mean on the held-out folds. The training curve keeps rising as the model
    grows more flexible; the validation curve rises, peaks, then falls. That peak
    is the sweet spot, and the gap between the two curves is the overfitting.
    """
    fixed = dict(fixed or {})
    X, y = np.asarray(X), np.asarray(y)
    train = np.empty(len(values))
    val = np.empty(len(values))
    for i, v in enumerate(values):
        fit = make_fit({**fixed, param_name: v})
        tr_acc = np.empty(len(splits))
        va_acc = np.empty(len(splits))
        for j, (trn, va) in enumerate(splits):
            predict = fit(X[trn], y[trn])
            tr_acc[j] = accuracy(y[trn], predict(X[trn]))
            va_acc[j] = accuracy(y[va], predict(X[va]))
        train[i] = tr_acc.mean()
        val[i] = va_acc.mean()
    return train, val

Seis piezas pequeñas: una métrica, un particionador, un calificador y tres maneras de apuntar el calificador al espacio de hiperparámetros. Nada de esto sabe que está ajustando una SVM.

Míralo trabajar

Esta es la búsqueda corriendo de verdad. Dos paneles, un presupuesto de ajustes cada uno. A la izquierda, grid search se arrastra por la malla de veinticinco celdas de C por gamma, llenando el accuracy con validación cruzada de una celda por cuadro, coloreándola por score, con el mejor acumulado marcado con una estrella. A la derecha, random search gasta los mismos ajustes — doce — como extracciones log-uniformes dispersas por el mismo espacio, con los cuadritos tenues mostrando dónde habría mirado la malla. El pie de imagen nombra la configuración y el accuracy con validación cruzada de la celda actual, el mejor de la malla hasta el momento y el mejor de random search hasta el momento. La métrica es accuracy — la fracción de pacientes apartados clasificados correctamente, sin unidades, de 0 a 1.

Dale play y mira cómo las dos estrategias difieren en carácter. Grid search es metódico y ciego: tiene que visitar las celdas oscuras de underfit de abajo y las celdas oscuras de overfit de arriba con la misma diligencia que las brillantes, porque no puede saber que están desperdiciadas hasta que las califica. Marcha por las veinticinco, y su estrella aterriza en C en 10, gamma en 0.01 con un accuracy con validación cruzada de 0.781. Random search, en cambio, ignora la retícula por completo. Para su cuarta extracción ya encontró una configuración que califica 0.778 — a 0.004 del mejor eventual de la malla — y termina sus doce ajustes en 0.778, habiendo gastado menos de la mitad del cómputo para aterrizar esencialmente en el mismo lugar. Con dos perillas la brecha es modesta; toda la fuerza de Bergstra y Bengio es que con diez perillas es un abismo.

Ahora la curva de validación, que es la otra manera de ver la misma superficie — una rebanada de ella, en detalle. Fija C en su mejor valor y barre gamma sola por un rango logarítmico fino. La línea cian es el accuracy de validación cruzada sobre los folds apartados; la naranja es el accuracy sobre los folds de entrenamiento; el punto marcado es el punto dulce donde la curva de validación alcanza su pico. Léela de izquierda a derecha y estás leyendo la U de sesgo-varianza del capítulo pasado, ahora indexada por un hiperparámetro en lugar de un grado polinomial.

Las dos curvas cuentan la historia completa de gamma. En el extremo izquierdo, gamma pequeña, ambas curvas se sientan bajas juntas cerca de 0.65 — el modelo es demasiado rígido para doblarse, sesgo alto, malo en entrenamiento y validación por igual. Conforme gamma crece, la curva de validación sube hasta su pico, accuracy con validación cruzada de 0.781 alrededor de gamma 0.003, y ese es el punto dulce que la búsqueda anda buscando. Luego las curvas se separan y la cosa se pone fea rápido: la curva de entrenamiento sigue subiendo hacia 1.0, un ajuste perfecto a los datos que puede ver, mientras la curva de validación da vuelta y cae. Para el borde derecho el accuracy de entrenamiento es exactamente 1.0 y el de validación se ha colapsado de regreso hasta 0.65 — el mismo lugar donde empezó, que no es más que la línea base de la clase mayoritaria que obtendrías prediciendo "sin diabetes" para todos. El modelo memorizó sus puntos de entrenamiento y no aprendió nada que transfiera. Esa brecha que se ensancha es el overfitting hecho visible, y el pico de la línea cian es exactamente el valor de gamma que querrías que la búsqueda devolviera.

La implementación completa

El archivo entero, de arriba abajo — la métrica, el particionador reutilizado, el calificador y las tres búsquedas. Sin scikit-learn en ninguna parte; el modelo se entrega desde afuera. Este es el código que de verdad corrieron los dos paneles de la animación y la curva de validación:

"""Hyperparameter tuning, built from scratch in pure NumPy.

The problem: a model has settings it cannot learn from the data on its own — an
SVM's C and gamma, a tree's depth, a neighbor count. You have to choose them,
and you want to choose them honestly: by how well the model does on data it did
not train on. Cross-validation (week 25) is the scoring engine that answers
"how good is *this* configuration"; tuning is the loop around it that asks the
question for many configurations and keeps the winner.

This file is only the search machinery — the k-fold splitter, the CV score for
one configuration, exhaustive grid search, random search, and the validation
curve. The estimator is handed in as a `fit` callable that returns a `predict`
function, so nothing here imports scikit-learn; the same loop tunes any model.
Every function appears in the chapter one step at a time (the `# region:`
markers are what the book's include directives pull in).
"""

import itertools

import numpy as np
import pandas as pd


# region: accuracy
def accuracy(y_true, y_pred):
    """Fraction of predictions that match the truth — unitless, 0 to 1."""
    y_true = np.asarray(y_true)
    y_pred = np.asarray(y_pred)
    return float((y_true == y_pred).mean())
# endregion


# region: stratified_kfold_indices
def stratified_kfold_indices(y, k):
    """The week-25 splitter: k folds, each keeping the whole set's class balance.

    Tuning reuses cross-validation wholesale, so it reuses cross-validation's
    folds. This is the same StratifiedKFold(shuffle=False) allocation built in
    the cross-validation chapter — split each class across the folds separately
    so a rare label can't clump — reproduced here so the tuning loop is
    self-contained and its scores line up with scikit-learn's to the digit.
    """
    y = np.asarray(y)
    classes, y_enc = np.unique(y, return_inverse=True)
    ordered = np.sort(y_enc)
    per_fold = np.array([np.bincount(ordered[i::k], minlength=len(classes))
                         for i in range(k)])
    fold_id = np.empty(len(y), dtype=int)
    for c in range(len(classes)):
        fold_id[y_enc == c] = np.repeat(np.arange(k), per_fold[:, c])
    return [(np.where(fold_id != f)[0], np.where(fold_id == f)[0]) for f in range(k)]
# endregion


# region: cv_score
def cv_score(fit, X, y, splits):
    """Cross-validated accuracy for one fixed configuration — the score to beat.

    `fit(X_train, y_train)` trains a fresh model and returns a `predict`
    callable. We rotate through the folds, fit on each fold's training rows,
    score the held-out rows, and return the mean across folds. That mean is the
    objective the search maximizes; the model is a black box to this function.
    """
    X, y = np.asarray(X), np.asarray(y)
    fold_acc = np.empty(len(splits))
    for j, (train, val) in enumerate(splits):
        predict = fit(X[train], y[train])
        fold_acc[j] = accuracy(y[val], predict(X[val]))
    return float(fold_acc.mean())
# endregion


# region: grid_search
def grid_search(make_fit, grid, X, y, splits):
    """Exhaustive search: CV-score every combination on the grid, keep the best.

    `grid` maps each hyperparameter name to a list of candidate values; the
    search is the Cartesian product of those lists. `make_fit(params)` returns a
    `fit` callable wired with those settings. Returns every combination's score
    (for the heatmap) and the argmax — the single best configuration found.
    """
    names = list(grid)
    results = []
    for combo in itertools.product(*(grid[n] for n in names)):
        params = dict(zip(names, combo))
        score = cv_score(make_fit(params), X, y, splits)
        results.append({"params": params, "score": score})
    best = max(results, key=lambda r: r["score"])
    return results, best
# endregion


# region: random_search
def random_search(make_fit, samplers, n_iter, X, y, splits, rng):
    """Sample n_iter configurations, CV-score each, keep the best.

    `samplers` maps each hyperparameter to a function of the RNG that draws one
    value — a log-uniform draw for C and gamma, say. Unlike the grid, this never
    enumerates a lattice: it spends its whole budget on n_iter independent draws,
    which is why in high dimensions it finds a good point in far fewer fits.
    Seed the RNG and the run is exactly repeatable.
    """
    results = []
    for _ in range(n_iter):
        params = {name: sample(rng) for name, sample in samplers.items()}
        score = cv_score(make_fit(params), X, y, splits)
        results.append({"params": params, "score": score})
    best = max(results, key=lambda r: r["score"])
    return results, best
# endregion


# region: validation_curve
def validation_curve(make_fit, param_name, values, X, y, splits, fixed=None):
    """Train and validation CV accuracy as one hyperparameter sweeps a range.

    Hold every other setting fixed, vary `param_name` across `values`, and at
    each value record two numbers: the mean accuracy on the training folds and
    the mean on the held-out folds. The training curve keeps rising as the model
    grows more flexible; the validation curve rises, peaks, then falls. That peak
    is the sweet spot, and the gap between the two curves is the overfitting.
    """
    fixed = dict(fixed or {})
    X, y = np.asarray(X), np.asarray(y)
    train = np.empty(len(values))
    val = np.empty(len(values))
    for i, v in enumerate(values):
        fit = make_fit({**fixed, param_name: v})
        tr_acc = np.empty(len(splits))
        va_acc = np.empty(len(splits))
        for j, (trn, va) in enumerate(splits):
            predict = fit(X[trn], y[trn])
            tr_acc[j] = accuracy(y[trn], predict(X[trn]))
            va_acc[j] = accuracy(y[va], predict(X[va]))
        train[i] = tr_acc.mean()
        val[i] = va_acc.mean()
    return train, val
# endregion


FEATURES = [
    "Pregnancies", "Glucose", "BloodPressure", "SkinThickness",
    "Insulin", "BMI", "DiabetesPedigreeFunction", "Age",
]


def load_xy(path="../data/diabetes.csv"):
    """Pima Indians Diabetes: 768 patients, 8 features, binary Outcome."""
    df = pd.read_csv(path)
    return df[FEATURES].to_numpy(float), df["Outcome"].to_numpy(int)

La versión de librería

Nadie escribe a mano un ciclo de tuning en producción una vez que ya lo escribió, y el módulo model_selection de scikit-learn tiene nuestras tres herramientas listas para usar. El estimador es un Pipeline para que la estandarización se reajuste por fold exactamente como lo hicimos a mano — el default C=1.0, gamma="scale" es la línea base sin ajustar:

def make_pipeline(C=1.0, gamma="scale"):
    """Standardize-then-SVM as one estimator, so scaling is refit per fold.

    StandardScaler subtracts the mean and divides by the (population, ddof=0)
    standard deviation — the same transform impl.py's `fit` applies by hand.
    Wrapping it with the SVM in a Pipeline means cross-validation refits the
    scaler on each fold's training rows, so nothing about the held-out rows
    leaks into the fit. The default C=1.0, gamma="scale" is the untuned model.
    """
    return Pipeline([
        ("scale", StandardScaler()),
        ("svc", SVC(kernel="rbf", C=C, gamma=gamma)),
    ])

GridSearchCV es la contraparte de grid_search. Entrégale el estimador, una malla como dict, un scorer y los mismos folds estratificados, y hace el barrido idéntico, exponiendo el score medio de validación cruzada de cada celda en cv_results_ y la ganadora en best_params_:

def sklearn_grid(C_values, gamma_values, X, y, k):
    """GridSearchCV over the same C x gamma lattice, same stratified folds.

    Scores accuracy on every combination and reports the best. `cv_results_`
    holds the per-combination mean CV score we check our grid against; the
    integer `cv=k` on a classifier would already stratify, but we pass the
    splitter object so the folds are provably the ones impl.py builds.
    """
    grid = {"svc__C": C_values, "svc__gamma": gamma_values}
    search = GridSearchCV(
        make_pipeline(), grid, scoring="accuracy",
        cv=StratifiedKFold(n_splits=k, shuffle=False),
    )
    search.fit(X, y)
    return search

RandomizedSearchCV es la contraparte de random_search, y aquí scipy.stats.loguniform de scikit-learn hace el muestreo en escala logarítmica que escribimos a mano. Usa un RNG distinto al nuestro, así que sus puntos muestreados y su ganadora difieren de corrida en corrida — el punto nunca fue que las dos búsquedas aleatorias coincidieran, solo que ambas gastan un presupuesto fijo y aterrizan cerca del mejor de la malla:

def sklearn_random(C_lo, C_hi, g_lo, g_hi, n_iter, X, y, k, seed):
    """RandomizedSearchCV: n_iter log-uniform draws over the same box.

    Same method as our random_search — draw configurations from a log-uniform
    distribution over C and gamma and CV-score each — but a different RNG, so
    the sampled points and the winner differ run to run. The point isn't that
    the two random searches match; it's that both spend a fixed budget of fits
    and land near the grid's best.
    """
    dists = {"svc__C": loguniform(C_lo, C_hi),
             "svc__gamma": loguniform(g_lo, g_hi)}
    search = RandomizedSearchCV(
        make_pipeline(), dists, n_iter=n_iter, scoring="accuracy",
        cv=StratifiedKFold(n_splits=k, shuffle=False), random_state=seed,
    )
    search.fit(X, y)
    return search

Y validation_curve es la contraparte de la nuestra, devolviendo los scores de entrenamiento y apartados para cada valor de un hiperparámetro barrido, una columna por fold:

def sklearn_validation_curve(param_name, values, C, X, y, k):
    """validation_curve for one hyperparameter, other settings fixed.

    Returns (train_scores, test_scores), each shaped (len(values), k). Averaging
    the last axis gives the two curves impl.py's validation_curve returns.
    """
    est = make_pipeline(C=C)
    train, test = validation_curve(
        est, X, y, param_name=param_name, param_range=values,
        scoring="accuracy", cv=StratifiedKFold(n_splits=k, shuffle=False),
    )
    return np.asarray(train), np.asarray(test)

Lo único que vale la pena señalar es una conveniencia que esconde la lección: pasar un entero pelón, cv=5, a cualquiera de estas sobre un clasificador te da folds estratificados en silencio. Es un buen default, pero significa que la estratificación que cuidamos tanto es invisible a menos que la busques. Nosotros pasamos el objeto particionador explícitamente en ambos lados para que la comparación sea, de forma demostrable, con los mismos folds.

Desde cero contra la librería

La afirmación es que nuestro grid search y el de scikit-learn son la misma búsqueda, así que deben seleccionar la misma configuración con el mismo score — no parecido, idéntico. Y así es. Aquí está cada una de las veinticinco celdas, nuestro score con validación cruzada contra el de GridSearchCV para la misma celda; cada punto se sienta exactamente sobre la diagonal porque los números subyacentes coinciden hasta el dígito:

El generador afirma esta igualdad en cada corrida — las veinticinco celdas y el argmax, más los scores de entrenamiento y apartados de la curva de validación, verificados contra scikit-learn con una diferencia absoluta máxima de cero — así que si las dos alguna vez se desviaran, los datos no compilarían. Igualar a la librería no es el logro; es la prueba de que la búsqueda desde cero es la cosa real y no una imitación.

Ahora el número para el que existe todo el capítulo. Ajustamos sobre los 576 pacientes de entrenamiento y nunca tocamos los 192 pacientes de prueba hasta este momento. Aquí está la SVM por default contra la ajustada por grid search, calificada de dos maneras: el score de validación cruzada sobre el conjunto de entrenamiento que el tuning optimizó, y el accuracy de prueba apartado que no:

El tuning levantó el score de validación cruzada de 0.774 a 0.781 — una ganancia real de como siete décimas de punto, robusta a lo largo de los folds, y exactamente lo que la búsqueda fue construida para maximizar. Sobre el conjunto de prueba apartado se movió de 0.734 a 0.776, un salto mayor. Sin embargo, quiero ser cuidadoso con ese número de prueba, porque el conjunto de prueba son solo 192 pacientes, así que un paciente vale medio punto y la estimación de prueba tiene dispersión real; la lectura honesta es que el tuning compró una mejora sólida y confiable en el score con validación cruzada, y el conjunto de prueba intocado está de acuerdo en lugar de en desacuerdo. Lo que no debes hacer es leer la barra de prueba como precisa al dígito. Es una sola extracción, que es exactamente por lo que ajustas con validación cruzada y solo miras el conjunto de prueba una vez, al final, como comprobación de cordura sobre la estimación en la que ya confías.

Finalmente, malla contra aleatorio, la comparación de Bergstra en miniatura. Ambos buscaron el mismo espacio; la malla gastó 25 ajustes, el aleatorio gastó 12:

El mejor accuracy con validación cruzada de grid search fue 0.781 con 25 ajustes; el de random search fue 0.778 con 12. El aleatorio aterrizó tres milésimas de punto por debajo del mejor de la malla usando menos de la mitad de las evaluaciones — en este problema pequeño de dos perillas, un intercambio modesto. La razón para que te importe es que el intercambio no se queda modesto. Agrega hiperparámetros y el conteo de ajustes de la malla explota mientras el de random search se queda en exactamente lo que presupuestaste, así que la misma brecha de tres milésimas de punto llega a una décima del costo, luego a una centésima. Ese es el argumento completo para agarrar primero random search, y es por lo que el propio RandomizedSearchCV de scikit-learn, corrido aquí con su RNG distinto, resultó aterrizar en la celda exacta del mejor de la malla, 0.781, en sus doce extracciones.

Conclusiones

Empieza con random search, no con la malla. Con uno o dos hiperparámetros sobre un rango pequeño los dos son intercambiables y el determinismo de la malla es una ventaja leve, pero ese es el único caso donde la malla gana, y es más raro de lo que se siente. En el momento en que tienes tres o más perillas — y la mayoría de los modelos reales las tienen — la malla está gastando la mayor parte de su presupuesto variando cosas que no importan, y random search te consigue un mejor resultado por el mismo cómputo, o el mismo resultado por una fracción. Fija tu presupuesto de ajustes en lo que puedas pagar y deja que random search lo gaste; ese es el default que escala.

Nunca ajustes sobre el conjunto de prueba, y tómalo en serio estructuralmente, no solo como eslogan. El score que optimizas queda contaminado en el instante en que lo optimizas — eso no es un riesgo, es una definición — así que la configuración que eliges por validación cruzada necesita un conjunto apartado limpio e intocado contra el cual medirse, evaluado exactamente una vez. Ajusta en el ciclo interno, reporta en el externo. Cada vez que he visto un modelo verse genial en desarrollo y decepcionar en producción, el primer lugar donde reviso es si el número "apartado" se usó calladamente para elegir algo, porque un conjunto de prueba que miraste dos veces es un conjunto de validación disfrazado.

Busca en escala logarítmica todo lo que sea una escala — C, gamma, learning rate, fuerza de regularización, el número de árboles. Lo que importa para estos es el orden de magnitud, y una malla lineal o un muestreador uniforme desperdicia casi todas sus pruebas en la parte alta del rango y nunca sondea los valores pequeños donde normalmente vive la respuesta. Muestrea el exponente, no el número.

Y mantén toda la empresa en proporción, porque esta es la conclusión por la que pelearía más duro. El tuning nos compró aquí siete décimas de punto de accuracy con validación cruzada, que es real y vale la pena tener, pero es un pulido, no una transformación. Un mejor modelo, una mejor feature, más datos o datos más limpios — cualquiera de esos mueve la aguja más de lo que jamás la moverán unos hiperparámetros perfectos, y la mueven de una forma que no se evapora cuando los datos cambian. El instinto junior es meterle grid search a un modelo mediocre hasta hundirlo; la jugada senior es tener una línea base decente funcionando de punta a punta, ajustarla una vez con random search para reclamar la ganancia fácil, y gastar el resto de tu tiempo en los datos y el planteamiento del problema, que es donde se esconde el accuracy de verdad. El siguiente paso después de la búsqueda ciega es la optimización bayesiana — herramientas como Optuna e Hyperopt que construyen un modelo de la superficie de scores con las pruebas que ya corrieron y muestrean donde el beneficio parece más probable, convirtiendo el tuning de exhaustivo en adaptativo. Es la herramienta correcta cuando cada ajuste es caro y el espacio es grande. Pero es el mismo objetivo que escribimos en una línea al inicio de este capítulo, solo buscado con más astucia, y sigue sin ser sustituto de un modelo que valiera la pena ajustar desde el principio.