Capítulo 17 de 37 · intermedio
Validación cruzada
Qué cubre este capítulo
Un solo split train/test te da un número de accuracy, y ese número es en parte suerte. Vuelve a barajar el split y se mueve. En un dataset pequeño puede moverse mucho — lo suficiente para que un modelo peor se vea mejor, que es justo el error que intentas evitar cuando te pones a medir. La validación cruzada es el remedio: en lugar de apartar una rebanada y confiar en ella, aparta cada rebanada por turnos, califica cada una y promedia. Usas todos tus datos para probar y todos para entrenar, solo que nunca al mismo tiempo.
La construimos desde cero en NumPy — la contabilidad de los folds, el loop de
puntuación, la media y la desviación estándar — y luego le pasamos exactamente
los mismos splits a scikit-learn y comprobamos que nuestras puntuaciones por
fold coinciden con su cross_val_score hasta el último dígito. En el camino
ves rotar los folds, caer las puntuaciones de cada fold y apretarse la media
acumulada con una barra de error que te dice cuánto confiar en ella. El número
final es todo el argumento: la estimación de un solo split vaga cuatro puntos
de accuracy entre semillas; la estimación de cinco folds apenas se mueve.
Los datos son de nuevo el conjunto Pima Indians Diabetes — 768 pacientes, ocho mediciones cada uno, un diagnóstico binario — con un árbol de decisión poco profundo como clasificador. El árbol es lo de menos aquí. La validación cruzada es lo que construimos, y funciona igual alrededor de cualquier modelo que le pongas dentro.
Un poco de historia
La idea de reservar datos para verificarte a ti mismo es vieja y obvia, pero la versión que usamos tiene dos padres concretos, ambos de mediados de los setenta. El paper de 1974 de Mervyn Stone en el Journal of the Royal Statistical Society — "Cross-validatory choice and assessment of statistical predictions" — es el que le dio el nombre y la teoría. Stone planteó la rotación que seguimos ejecutando hoy: deja fuera un punto (o un grupo), predícelo con el resto, hazlo con cada punto y usa el agregado tanto para elegir un modelo como para estimar qué tan bien le irá. Al año siguiente Seymour Geisser publicó el método de reutilización predictiva de muestras en el Journal of the American Statistical Association, llegando a la misma idea desde un ángulo predictivo y generalizándola. Entre los dos, la validación cruzada pasó de folclor a procedimiento con nombre y justificación.
Lo interesante del momento es que iban contra la corriente. La cultura
estadística de la época se apoyaba en criterios de forma cerrada — medidas
ajustadas, criterios de información — que estiman el error fuera de muestra con
una fórmula sobre el ajuste de entrenamiento. Stone y Geisser dijeron: no lo
estimes, mídelo, prediciendo de verdad datos que el modelo no vio. Esa es una
respuesta de computadora, y solo se volvió más atractiva conforme las
computadoras se abarataron. Hoy la validación cruzada es la forma estándar en
que cualquiera reporta el accuracy de un modelo sobre datos que no son enormes,
y cada llamada a cross_val_score está ejecutando la rotación de Stone.
La intuición
Este es el truco. Corta los datos en k partes iguales — llámalas folds. Ahora corre k rondas. En cada ronda, un fold se queda fuera como conjunto de validación y los otros k-1 folds son el conjunto de entrenamiento; ajustas un modelo nuevo sobre los folds de entrenamiento y lo calificas sobre el fold apartado. Rota de modo que cada fold sea el apartado exactamente una vez. Eso te da k puntuaciones, una por ronda, cada una medida sobre datos que el modelo de esa ronda nunca tocó. Promédialas y tienes tu estimación.
El dibujo es toda la idea. Cada fila de abajo es una ronda; la celda resaltada es el fold apartado en esa ronda; las celdas grises son con lo que entrenó el modelo. Recorre la diagonal y verás al fold apartado marchar a través de los datos hasta que cada parte ha sido el conjunto de prueba una vez.
Cada paciente es un caso de prueba exactamente una vez y un caso de entrenamiento k-1 veces. Nadie se desperdicia, y ninguna puntuación se mide jamás sobre datos con los que el modelo entrenó. Ese es el truco que hace la estimación honesta y estable a la vez.
La matemática
Divide las N filas en k folds disjuntos. Sea el conjunto de índices de fila en el fold j, y sea la predicción para la fila i del modelo entrenado con todos los folds excepto el que contiene a i. La puntuación del fold j es simplemente el accuracy sobre ese fold:
La estimación de la validación cruzada es el promedio de las k puntuaciones:
Ese promedio es el número que reportas. Pero un promedio esconde el desacuerdo, y el desacuerdo es información — te dice cuánto se tambalearía la estimación si los folds hubieran caído distinto. Así que reporta también la dispersión, la desviación estándar entre los folds:
Dos números, no uno. La media es tu mejor apuesta del accuracy fuera de muestra; la desviación estándar es qué tan fuerte discutieron los folds al respecto. Un modelo que puntúa y uno que puntúa no son el mismo resultado, aunque su titular sea idéntico, y el punto central de este capítulo es que no puedes ver la diferencia sin el segundo número.
Para qué sirve y para qué no
Lo bueno es que dejas de tirar datos. En un conjunto pequeño — y 768 filas es pequeño — un split de prueba del 20% significa que estás estimando tu accuracy con 150 pacientes y entrenando con los otros 600, y que ambos números sean pequeños duele. La validación cruzada entrena con el 80% y prueba con el 100% a lo largo de las rondas, así que cada fila hace doble trabajo. Obtienes una estimación de menor varianza y un modelo con todos los datos, y la dispersión te sale gratis, que es la parte que la gente se salta y luego se pregunta por qué su leaderboard es puro ruido. Para seleccionar modelos con datos limitados no es realmente opcional; es como distingues una mejora real de un split con suerte.
El costo es cómputo, y es exactamente k veces. La validación cruzada de cinco folds ajusta tu modelo cinco veces; leave-one-out lo ajusta N veces, una por fila. Con un árbol poco profundo sobre 768 filas eso no es nada, pero con cualquier cosa que tarde minutos en entrenar el multiplicador es real, y por eso en la práctica ves tres y cinco folds mucho más que diez. La otra trampa es más sutil: si afinas cualquier cosa mirando la puntuación de validación cruzada — la profundidad del árbol, un umbral de decisión, qué columnas te quedas — entonces esa puntuación ya vio los datos de validación y vuelve a ser optimista. El remedio es la validación cruzada anidada, y es hacia donde apunta este capítulo al final.
Los datos
Los mismos pacientes del capítulo uno: 768 personas, ocho mediciones cada una — glucosa, IMC, edad, presión arterial y demás — y una etiqueta que dice si cada una fue diagnosticada con diabetes. Lo que importa aquí no son las features, es el balance de la etiqueta. Solo alrededor del 35% de los pacientes son positivos, así que las clases están desbalanceadas, y ese desbalance es lo que en un minuto va a morder a un split de folds ingenuo.
El clasificador es un árbol de decisión limitado a profundidad tres — poco profundo a propósito. No necesita escalar features, entrena al instante, y con una semilla aleatoria fija es determinista, así que lo único que puede mover la puntuación de un fold son los datos de ese fold. Eso es lo que nos permite probar que nuestros folds coinciden exactamente con los de scikit-learn: mismos datos, mismo modelo, mismo número.
Constrúyelo, una función a la vez
La validación cruzada es contabilidad alrededor de un loop de puntuación, y lo más fácil es construirla de adentro hacia afuera. Empieza con lo que cada fold necesita — el 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())
Ahora los folds. El k-fold simple solo corta los índices de fila en k bloques contiguos. El único detalle es el residuo cuando N no es divisible entre k: los primeros folds reciben una fila extra cada uno. Es la misma regla que sigue scikit-learn, y replicarla es lo que hace que las puntuaciones cuadren después.
def kfold_indices(n, k):
"""Split range(n) into k contiguous folds; return (train, val) index pairs.
No shuffling: fold 0 is the first block of rows, fold 1 the next, and so
on. When n isn't divisible by k the first n % k folds get one extra row —
the same rule scikit-learn's KFold(shuffle=False) uses, so the splits line
up exactly.
"""
sizes = np.full(k, n // k, dtype=int)
sizes[: n % k] += 1 # spread the remainder over the first folds
fold_id = np.repeat(np.arange(k), sizes) # which fold each row belongs to
return [(np.where(fold_id != f)[0], np.where(fold_id == f)[0]) for f in range(k)]
Sin embargo, los bloques contiguos tienen un problema con datos desbalanceados. Si las filas no están barajadas con respecto a la etiqueta — y nunca puedes asumir que lo están — un bloque puede terminar con demasiados positivos o muy pocos. El k-fold estratificado lo arregla dividiendo cada clase por separado, de modo que cada fold hereda el balance de clases del conjunto completo:
def stratified_kfold_indices(y, k):
"""Like kfold_indices, but each fold keeps the class balance of the whole.
Assign folds within each class separately so a fold can't end up with too
few of a rare label. This reproduces scikit-learn's StratifiedKFold
(shuffle=False): for every class the counts are spread across folds as
evenly as possible, then handed out in the order the rows appear.
"""
y = np.asarray(y)
classes, y_enc = np.unique(y, return_inverse=True)
# how many of each class land in each fold, balanced by the k-way stride
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)]
La línea de asignación es todo el truco: para cada clase, reparte sus miembros
entre los folds tan parejo como los conteos lo permitan, y luego entrégalos en
orden. Reproduce exactamente el StratifiedKFold de scikit-learn, en el que
nos apoyaremos cuando verifiquemos los números.
Leave-one-out es el k-fold llevado a su límite — una fila por fold, N folds. No necesita código nuevo; es simplemente k = N:
def loo_indices(n):
"""Leave-one-out: the k = n extreme. Each row is its own validation fold."""
return kfold_indices(n, n)
Con los folds en mano, el driver es un loop corto: para cada split, ajusta un modelo nuevo sobre las filas de entrenamiento, predice las filas de validación, registra el accuracy. El estimador entra como un callable, así que a este loop no le importa si envuelve un árbol, una regresión logística o una red neuronal.
def cross_val_score(fit_predict, X, y, splits):
"""Train and score once per fold; return the k raw scores.
`fit_predict(X_train, y_train, X_val)` fits a fresh model on the training
rows and returns predictions for the validation rows — so this loop is
estimator-agnostic. A new model is fit for every fold; nothing leaks across
them.
"""
X, y = np.asarray(X), np.asarray(y)
scores = np.empty(len(splits))
for j, (train, val) in enumerate(splits):
y_pred = fit_predict(X[train], y[train], X[val])
scores[j] = accuracy(y[val], y_pred)
return scores
Por último, colapsa las k puntuaciones en los dos números que de verdad reportas — la media y su dispersión:
def cv_summary(scores):
"""The headline number and its uncertainty: mean and std of the fold scores.
The std here is the population std over the k folds (ddof=0) — the same
thing `scores.std()` gives. Report both: the mean is your estimate, the std
is how far the folds disagreed about it.
"""
scores = np.asarray(scores)
return float(scores.mean()), float(scores.std())
Míralo funcionar
Esto es validación cruzada de cinco folds corriendo de verdad sobre los 768 pacientes, un cuadro por ronda. La franja superior es el split de esa ronda — cuatro folds en gris entrenando el modelo, uno en naranja apartado. El panel inferior es donde van cayendo las puntuaciones de los folds conforme llegan: cada punto es el accuracy de un fold, la línea punteada es la media acumulada, y la banda sombreada es una desviación estándar a cada lado. La métrica es accuracy — la fracción de pacientes apartados que el árbol clasifica correctamente, sin unidades, de 0 a 1.
Dale play. Mira al fold apartado marchar de izquierda a derecha mientras la media acumulada se asienta y la banda se forma. Reinicia y córrelo cuantas veces quieras — esto es k-fold simple sin barajar, así que es completamente determinista; los mismos folds caen igual cada vez.
Ahora mira cuánto discrepan esos cinco puntos. El fold cuatro puntúa 0.830 y el fold dos puntúa 0.669 — dieciséis puntos de accuracy de distancia, con los mismos datos, el mismo modelo, solo una rebanada distinta apartada. La media cae en 0.750 pero la banda es ancha, , y esa anchura es la señal honesta de que una sola rebanada de estos datos es algo frágil en lo que confiar. Si hubieras corrido un solo split train/test y te hubiera tocado el fold cuatro como conjunto de prueba, habrías reportado 0.83 y te lo habrías creído.
Aquí están las cinco puntuaciones de los folds estratificados por su cuenta, con la línea de la media y la banda de desviación estándar dibujadas. Esta es la versión que de verdad reportarías:
Nota que la banda es mucho más apretada que la de la corrida simple — en vez de . Eso es la estratificación ganándose su lugar. Recuerda que la etiqueta es solo 35% positiva; cuando cortas las filas en bloques contiguos simples, la tasa de positivos por fold oscila entre 0.255 y 0.416, así que algunos folds son mucho más fáciles o difíciles que otros y las puntuaciones se dispersan. Los folds estratificados mantienen la tasa de positivos cerca de 0.35 en cada fold, así que los folds son comparables y la estimación se estabiliza. Aquí están los mismos cinco folds de las dos formas:
Los puntos estratificados se agrupan; los simples se riegan por todo el eje. Mismos datos, mismo modelo, misma k — la única diferencia es si los folds conservaron el balance de clases. Con datos desbalanceados, estratifica. No cuesta nada y es la diferencia entre una estimación que puedes citar y una que es mayormente ruido.
La implementación completa
Todo el asunto, de arriba abajo — seis funciones pequeñas y un loader, sin scikit-learn en ninguna parte del archivo. Esto es lo que de verdad corrió la animación de arriba:
"""Cross-validation, built from scratch in pure NumPy.
The idea: instead of trusting one train/test split, cut the data into k folds,
train on k-1 of them and score on the one you left out, and rotate until every
fold has been the held-out set exactly once. Average the k scores for a stable
estimate, and keep their spread so you know how much to trust it.
This file is only the CV machinery — the fold bookkeeping, the score loop, the
mean/std. The estimator is handed in as a `fit_predict` callable, so nothing
here imports scikit-learn. Every function appears in the chapter one step at a
time (the `# region:` markers are what the book's include directives pull in).
"""
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: kfold_indices
def kfold_indices(n, k):
"""Split range(n) into k contiguous folds; return (train, val) index pairs.
No shuffling: fold 0 is the first block of rows, fold 1 the next, and so
on. When n isn't divisible by k the first n % k folds get one extra row —
the same rule scikit-learn's KFold(shuffle=False) uses, so the splits line
up exactly.
"""
sizes = np.full(k, n // k, dtype=int)
sizes[: n % k] += 1 # spread the remainder over the first folds
fold_id = np.repeat(np.arange(k), sizes) # which fold each row belongs to
return [(np.where(fold_id != f)[0], np.where(fold_id == f)[0]) for f in range(k)]
# endregion
# region: stratified_kfold_indices
def stratified_kfold_indices(y, k):
"""Like kfold_indices, but each fold keeps the class balance of the whole.
Assign folds within each class separately so a fold can't end up with too
few of a rare label. This reproduces scikit-learn's StratifiedKFold
(shuffle=False): for every class the counts are spread across folds as
evenly as possible, then handed out in the order the rows appear.
"""
y = np.asarray(y)
classes, y_enc = np.unique(y, return_inverse=True)
# how many of each class land in each fold, balanced by the k-way stride
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: loo_indices
def loo_indices(n):
"""Leave-one-out: the k = n extreme. Each row is its own validation fold."""
return kfold_indices(n, n)
# endregion
# region: cross_val_score
def cross_val_score(fit_predict, X, y, splits):
"""Train and score once per fold; return the k raw scores.
`fit_predict(X_train, y_train, X_val)` fits a fresh model on the training
rows and returns predictions for the validation rows — so this loop is
estimator-agnostic. A new model is fit for every fold; nothing leaks across
them.
"""
X, y = np.asarray(X), np.asarray(y)
scores = np.empty(len(splits))
for j, (train, val) in enumerate(splits):
y_pred = fit_predict(X[train], y[train], X[val])
scores[j] = accuracy(y[val], y_pred)
return scores
# endregion
# region: cv_summary
def cv_summary(scores):
"""The headline number and its uncertainty: mean and std of the fold scores.
The std here is the population std over the k folds (ddof=0) — the same
thing `scores.std()` gives. Report both: the mean is your estimate, the std
is how far the folds disagreed about it.
"""
scores = np.asarray(scores)
return float(scores.mean()), float(scores.std())
# 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 su propio loop de folds en producción, y una vez que lo has
escrito una vez, tú tampoco deberías. El módulo model_selection de
scikit-learn tiene los splitters y el scorer, y son las contrapartes exactas de
lo que construimos: KFold para kfold_indices, StratifiedKFold para
stratified_kfold_indices, LeaveOneOut para el caso k = N, y
cross_val_score para el loop del driver.
def make_model():
"""A shallow, deterministic classifier: depth-3 tree, fixed seed.
Shallow so it needs no feature scaling and trains in milliseconds, seeded
so the only thing that moves a fold's score is the data in that fold — not
the model. That's what makes the scratch-vs-library scores match to the
digit.
"""
return DecisionTreeClassifier(max_depth=3, random_state=0)
def sklearn_kfold(X, y, k):
"""cross_val_score over a plain k-fold split (no shuffle)."""
return cross_val_score(make_model(), X, y, cv=KFold(n_splits=k, shuffle=False))
def sklearn_stratified(X, y, k):
"""cross_val_score over a stratified k-fold split (no shuffle)."""
return cross_val_score(make_model(), X, y,
cv=StratifiedKFold(n_splits=k, shuffle=False))
def sklearn_loo(X, y):
"""cross_val_score with leave-one-out (k = n folds)."""
return cross_val_score(make_model(), X, y, cv=LeaveOneOut())
Una cosa que vale la pena señalar, porque hace tropezar a la gente: si llamas
cross_val_score(model, X, y, cv=5) con un entero pelón sobre un clasificador,
scikit-learn te da folds estratificados en silencio, no simples. Es un buen
default, pero significa que la diferencia simple-vs-estratificado que acabamos
de ver es invisible a menos que pases el objeto splitter tú mismo. Nosotros lo
pasamos explícitamente en ambos casos para que la comparación sea real.
Desde cero contra librería
La afirmación es que nuestros folds desde cero y los de scikit-learn son el mismo split, así que las puntuaciones por fold deberían ser idénticas, no meramente parecidas. Lo son. Aquí está la media de las cinco puntuaciones, la nuestra contra la de la librería, tanto para el split simple como para el estratificado — las barras caen exactamente una sobre otra porque los números de fondo coinciden hasta el último dígito:
El generador verifica esta igualdad en cada corrida — simple, estratificado y leave-one-out coinciden con scikit-learn fold por fold — así que si los dos llegaran a divergir, los datos no compilarían. Igualar a la librería no es el punto en sí; es la prueba de que la versión desde cero es el algoritmo real y no una imitación.
Ahora el número que justifica todo el capítulo. Corrí dos estimadores del accuracy del árbol treinta veces cada uno, con treinta semillas distintas. El primero es un solo split train/test 80/20 — una rebanada apartada, un número. El segundo es la media de la validación cruzada de cinco folds. Mismos datos, mismo modelo, mismas treinta semillas; la única diferencia es que una estimación mira una rebanada y la otra promedia cinco. Aquí está cada corrida, de las dos formas:
Ambos estimadores se centran en el mismo lugar — 0.7385 para el split único, 0.7386 para la media de validación cruzada, así que la validación cruzada no está sesgada, no infla ni desinfla el accuracy con trampa. Lo que cambia es la dispersión. El split único va de 0.649 a 0.799 entre semillas, una desviación estándar de 0.040 — saca un conjunto de prueba aleatorio distinto y tu accuracy reportado oscila catorce puntos entre la semilla desafortunada y la afortunada. La media de validación cruzada va de 0.718 a 0.763, una desviación estándar de 0.010 — la cuarta parte del bamboleo. Ese es el intercambio en una sola imagen: la misma respuesta en promedio, cuatro veces más estable. El número del split único no está mal, solo es impreciso, y en un dataset pequeño impreciso es como terminas enviando el modelo peor porque le tocó el split más amable.
Conclusiones
Usa validación cruzada para cualquier número de accuracy sobre el que vayas a tomar una decisión, y toma la decisión sobre la media citando la desviación estándar junto a ella. La media es tu estimación; la dispersión es tu confianza, y reportar una sin la otra es como las comparaciones de modelos se convierten en volados. Si te llevas un hábito de este capítulo, que sea ese: dos números, siempre. He visto equipos elegir el modelo A sobre el modelo B por una brecha de 0.02 que era menor que la dispersión fold a fold de cualquiera de los dos modelos, lo que significa que eligieron al azar y lo disfrazaron de progreso.
Recurre a ella con más fuerza justo cuando los datos escasean, porque es cuando un solo split es menos confiable y la validación cruzada te rinde más. Con etiquetas desbalanceadas, estratifica — es gratis y es la diferencia que vimos entre una estimación apretada y una ruidosa. Cinco folds es el default sensato; sube a diez cuando el modelo es barato y quieres un poco más de precisión, y a leave-one-out solo en conjuntos genuinamente diminutos, donde su costo de N veces es pagable y no puedes prescindir de una sola fila para un fold de prueba. El costo que siempre estás pagando es cómputo, k veces, así que tenlo presente cuando el modelo se vuelva caro.
Lo único que la validación cruzada no puede hacer por sí sola es proteger una puntuación contra la que has afinado. En el momento en que eliges un hiperparámetro, un umbral o un conjunto de features por su puntuación de validación cruzada, esa puntuación ya se asomó a los datos de validación y se volvió optimista — el remedio honesto es envolver el afinado en un loop externo de validación cruzada, que es la validación cruzada anidada. Ese es el siguiente paso natural, y es exactamente donde arranca el afinado de hiperparámetros: ahora que puedes medir un modelo de forma confiable, puedes empezar a buscar el mejor sin engañarte a ti mismo.