Capítulo 6 de 37 · básico
Evaluar un clasificador: más allá del accuracy
Qué cubre este capítulo
El accuracy es el primer número que todo mundo busca y el primero que les miente. Un modelo que predice "sin diabetes" para los 231 pacientes de nuestro test set logra 65% de accuracy sin aprender nada, porque el 65% de esos pacientes no tiene diabetes. Reporta ese 65% con cara seria y habrás dicho técnicamente la verdad mientras engañas por completo a toda la sala.
Así que este capítulo trata de todos los demás números — los que te dicen no solo qué tan seguido acierta el modelo, sino en qué dirección se equivoca. Construimos toda la familia a mano en NumPy: la matriz de confusión, precision, recall, especificidad, F1 y la curva ROC con su área debajo. Después dejamos que scikit-learn calcule lo mismo y verificamos que cada dígito coincida. Este no es un capítulo de algoritmos. Aquí nada entrena un modelo. Una regresión logística ya produjo los scores; nuestro trabajo es juzgarlos, y saber qué mide cada número antes de confiar en él.
La pieza central es una animación. Deslizamos el umbral de decisión de 0 a 1 y vemos cómo la matriz de confusión, las cuatro métricas y un punto sobre la curva ROC se mueven juntos, para que veas suceder el trade-off entre precision y recall en lugar de leer sobre él. Es la secuela directa del barrido de la matriz de confusión del capítulo de umbrales — la misma idea, con más instrumentos en el tablero.
Un poco de historia
La curva ROC es una pieza de hardware de la Segunda Guerra Mundial que terminó metida en la estadística. ROC significa receiver operating characteristic, y el receptor en cuestión era un operador de radar mirando una pantalla, decidiendo si un punto era un bombardero enemigo o una parvada de gansos. Sube la sensibilidad y atrapas cada avión pero también saltas con cada ganso; bájala y las falsas alarmas se detienen pero se te escapan objetivos reales. Los ingenieros graficaron ese trade-off como una curva — aciertos contra falsas alarmas conforme se movía el umbral de detección — y la curva sobrevivió a la guerra. La teoría de detección de señales la llevó a la psicología en los años cincuenta, la radiología la adoptó para calificar pruebas diagnósticas, y machine learning la heredó completa. Cuando hoy calculas un AUC estás usando una herramienta construida para afinar radares.
Precision y recall vienen de otro mundo: la recuperación de información. En los años cincuenta y sesenta, quienes construían los primeros sistemas de búsqueda de documentos necesitaban decir qué tan buena era una búsqueda, y el "accuracy" no significa nada cuando la respuesta correcta son 12 documentos de entre un millón. Los experimentos de Cranfield de Cyril Cleverdon, a finales de los cincuenta, fijaron el par que seguimos usando — recall para cuántos de los documentos relevantes encontraste, precision para cuántos de los que devolviste eran realmente relevantes. Cada motor de búsqueda, cada recomendador, cada sistema de recuperación desde entonces se califica con algún descendiente de esos dos números. F1, la media armónica que los junta en uno, apareció después como una conveniencia para cuando quieres una sola perilla con la cual ordenar sistemas.
La intuición
Un clasificador como la regresión logística no te entrega una etiqueta. Te entrega un score — una probabilidad estimada de que el paciente sea diabético — y tú decides dónde cortar. Todo lo que implica juzgar el modelo empieza con una imagen: los scores que les dio a los pacientes que de verdad eran diabéticos, contra los scores que les dio a los que no.
Las dos jorobas se inclinan hacia lados opuestos — el modelo sí empuja a los diabéticos reales hacia scores más altos — pero se traslapan en el medio, y ese traslape es toda la historia. Donde sea que pongas la línea del umbral, algo de azul (sanos) queda a su derecha y algo de morado (diabéticos) queda a su izquierda. Esos son tus dos tipos de error, y ninguna línea hace desaparecer ambos. Desliza la línea a la izquierda y recoges a casi todos los diabéticos, junto con una multitud de gente sana. Deslízala a la derecha y la multitud sana se despeja, pero los diabéticos empiezan a colarse por debajo. El accuracy colapsa todo ese trade-off en un solo número y tira la forma a la basura. Las métricas de este capítulo son maneras de conservar la forma.
Las matemáticas
Toda métrica se construye a partir de cuatro conteos. Fija un umbral, etiqueta a cada paciente, y clasifica a cada uno en una de cuatro casillas comparando la predicción con la verdad. Llama 1 a la clase positiva (diabético):
TP es un caso detectado, TN uno correctamente descartado; FP es una falsa alarma, FN un caso perdido. Esos cuatro enteros acomodados en una cuadrícula de 2×2 son la matriz de confusión, y cada número de abajo es un cociente entre ellos.
El accuracy es la diagonal sobre el total — la fracción de todas las decisiones que fueron correctas:
Precision pregunta: de todos los que marcamos, cuántos realmente eran positivos. Recall pregunta: de todos los que realmente eran positivos, cuántos atrapamos. Dividen el mismo TP entre denominadores distintos, y esa diferencia lo es todo:
La especificidad es el espejo del recall en la otra clase — de todos los que realmente eran negativos, cuántos descartamos correctamente:
F1 junta precision y recall en un solo score usando la media armónica, que se queda cerca del menor de los dos para que no puedas hacer trampa dominando uno y hundiendo el otro:
Todas esas son métricas puntuales — juzgan un solo umbral. La curva ROC juzga al modelo en todos los umbrales a la vez. Barre el corte de alto a bajo y grafica la tasa de verdaderos positivos contra la tasa de falsos positivos:
El área bajo esa curva es el AUC, que obtenemos por integración trapezoidal sobre los puntos de la curva:
Una nota de unidades de la que depende todo el capítulo: accuracy, precision, recall, especificidad y F1 son fracciones sin unidades en [0, 1] — cada una es un conteo dividido entre un conteo. El AUC también es adimensional en [0, 1], pero no es una fracción de los datos; es una probabilidad, la probabilidad de que un positivo al azar reciba un score más alto que un negativo al azar. La mitad de usar bien estas métricas es recordar qué conteo va en cada denominador.
Qué mide cada métrica, y qué esconde
El accuracy es la opción honesta exactamente cuando las clases están balanceadas y los dos errores cuestan lo mismo — y casi en ningún otro caso. En nuestro test set el 65% de los pacientes está sano, así que el modelo que no hace nada y descarta a todos ya logra 65%. Cualquier métrica que un tabique puede aprobar es una mala vara para medir un modelo. El accuracy tampoco distingue un modelo cauteloso de uno que dispara a todo: dos clasificadores con el mismo accuracy pueden tener tasas de falsas alarmas radicalmente distintas, y el accuracy no te muestra nada de eso.
Precision y recall abren ese punto ciego, uno por cada tipo de error. Precision es lo que te importa cuando un falso positivo sale caro — un filtro de spam que tira correo real, una alerta de fraude que congela a un cliente legítimo. Recall es lo que te importa cuando el desastre es un caso perdido — un tamizaje de cáncer, una prueba de diabetes, cualquier cosa donde el costo de pasar por alto un caso real empequeñece el costo de una segunda revisión. Puedes intercambiar uno por el otro libremente moviendo el umbral, así que ninguno significa nada sin el otro fijado; citar una precision sin su recall es un truco de magia, no una medición. F1 los amarra juntos cuando genuinamente no tienes razón para favorecer a ninguno, pero también entierra el trade-off sobre el que promedió, así que yo lo trato como una conveniencia para rankear, no como un objetivo. El AUC se sale por completo del juego del umbral y califica el ordenamiento: ¿el modelo tiende a darles scores más altos a los positivos que a los negativos, cortes donde cortes? Es el resumen correcto cuando todavía no eliges un punto de operación, y uno engañoso en datos fuertemente desbalanceados, donde una pila gorda de verdaderos negativos fáciles puede inflar el número. Ese es el fallo alrededor del cual está construido el capítulo de clases desbalanceadas.
Los datos
Los scores vienen de una regresión logística ajustada sobre el dataset Pima Indians Diabetes — los mismos 768 pacientes y ocho mediciones que el capítulo de umbrales, divididos 70/30 en 537 pacientes de entrenamiento y 231 de prueba. De los 231 pacientes apartados, 80 eran diabéticos, así que la clase positiva es el 34.6% del test set: lo bastante desbalanceado para que el accuracy y el resto se separen visiblemente, que es toda la razón para usar este dataset aquí.
El modelo en sí apenas importa para este capítulo — podríamos juzgar los scores
de cualquier clasificador de la misma manera. Lo que importa es que
predict_proba da una probabilidad real por paciente, guardada en
data/scores.json junto a la etiqueta verdadera, y cada número de esta página
se rastrea hasta esos 231 pares. La distribución de scores que viste arriba es
una imagen suavizada de exactamente ese archivo.
Constrúyelo, una función a la vez
Todo empieza con los cuatro conteos. Convierte los scores en predicciones 0/1 con el umbral, y luego acomoda a cada paciente en una de las cuatro casillas:
def confusion_counts(y_true, y_pred):
"""The four counts a binary confusion matrix is made of.
Compares hard 0/1 predictions against 0/1 truth and returns
(tp, fp, fn, tn):
TP true positive predicted 1, truly 1 (caught a real case)
FP false positive predicted 1, truly 0 (a false alarm)
FN false negative predicted 0, truly 1 (a missed case)
TN true negative predicted 0, truly 0 (correctly cleared)
Every metric below is some ratio of these four integers.
"""
y_true = np.asarray(y_true).astype(int)
y_pred = np.asarray(y_pred).astype(int)
tp = int(np.sum((y_pred == 1) & (y_true == 1)))
fp = int(np.sum((y_pred == 1) & (y_true == 0)))
fn = int(np.sum((y_pred == 0) & (y_true == 1)))
tn = int(np.sum((y_pred == 0) & (y_true == 0)))
return tp, fp, fn, tn
Ese paso de aplicar el umbral es su propio one-liner, porque estamos a punto de llamarlo con cincuenta cortes distintos y quiero la regla de decisión en exactamente un lugar:
def predict_at(y_score, threshold):
"""Turn continuous scores into 0/1 labels at a decision threshold.
Predict the positive class when the score is at or above the threshold.
Everything downstream is a function of where you put this line.
"""
return (np.asarray(y_score) >= threshold).astype(int)
Con los cuatro conteos en mano, cada métrica puntual es una división corta. El accuracy es la diagonal sobre el total:
def accuracy(tp, fp, fn, tn):
"""Fraction of all predictions that were correct: the diagonal / total.
Unitless, in [0, 1]. Reads well, lies on imbalanced data: predict the
majority class for everyone and this number can still look high.
"""
total = tp + fp + fn + tn
return (tp + tn) / total if total else 0.0
Precision divide los verdaderos positivos entre todo lo que marcamos; recall divide los mismos verdaderos positivos entre todo lo que realmente era positivo. Mismo numerador, distinto denominador — ese único cambio es toda la diferencia entre los dos números más confundidos de machine learning:
def precision(tp, fp):
"""Of everything we FLAGGED as positive, what fraction really was.
precision = TP / (TP + FP). Unitless, in [0, 1]. The cost of a false
alarm lives here — low precision means you're crying wolf. Undefined
when nothing is flagged; we return 0 to match sklearn's convention.
"""
return tp / (tp + fp) if (tp + fp) else 0.0
def recall(tp, fn):
"""Of everything that truly WAS positive, what fraction we caught.
recall = TP / (TP + FN), a.k.a. sensitivity or the true positive rate.
Unitless, in [0, 1]. The cost of a miss lives here — low recall means
real cases slipped through. Undefined when there are no positives.
"""
return tp / (tp + fn) if (tp + fn) else 0.0
La especificidad es el recall leído sobre la clase negativa, y es la pieza que la curva ROC necesita — su tasa de falsos positivos es simplemente uno menos esto:
def specificity(tn, fp):
"""Of everything that truly was NEGATIVE, what fraction we cleared.
specificity = TN / (TN + FP), the true negative rate. Unitless, in
[0, 1]. Recall's mirror image on the other class; 1 - specificity is
the false positive rate, the x-axis of the ROC curve.
"""
return tn / (tn + fp) if (tn + fp) else 0.0
F1 toma la media armónica de precision y recall. La media armónica, no el promedio simple, a propósito: se arrastra hacia el que sea menor de los dos, así que un modelo no puede comprarse un buen F1 maximizando uno y abandonando el otro:
def f1(precision_val, recall_val):
"""The harmonic mean of precision and recall — one number for both.
f1 = 2 * P * R / (P + R). Unitless, in [0, 1]. Harmonic, not
arithmetic, on purpose: it stays near the smaller of the two, so you
can't win F1 by acing precision while recall collapses. Zero when
either is zero.
"""
denom = precision_val + recall_val
return 2 * precision_val * recall_val / denom if denom else 0.0
La animación necesita una foto completa en cada umbral, así que un wrapper calcula todo de una vez y regresa un dict — incluyendo el TPR y el FPR sobre los que viaja el punto de la ROC:
def metrics_at(y_true, y_score, threshold):
"""Every scalar metric at one decision threshold, in one call.
Threshold the scores, count the four cells, and derive the ratios.
Returns a dict so the animation can read a full metric snapshot per
frame without recomputing anything.
"""
y_pred = predict_at(y_score, threshold)
tp, fp, fn, tn = confusion_counts(y_true, y_pred)
p = precision(tp, fp)
r = recall(tp, fn)
return {
"threshold": float(threshold),
"tp": tp, "fp": fp, "fn": fn, "tn": tn,
"accuracy": accuracy(tp, fp, fn, tn),
"precision": p,
"recall": r,
"specificity": specificity(tn, fp),
"f1": f1(p, r),
"tpr": r, # recall == true positive rate
"fpr": 1.0 - specificity(tn, fp),
}
Ahora las curvas. El barrido ROC recorre cada score distinto de alto a bajo. La única sutileza es agrupar los scores empatados en un solo escalón, que es lo que hace que el área trapezoidal salga como el AUC exacto y no como una aproximación:
def roc_curve(y_true, y_score):
"""Sweep every threshold and trace (FPR, TPR) — the ROC curve.
The thresholds that matter are the distinct score values: between two
adjacent scores nothing changes. Walk them from high to low, so the
curve starts at (0, 0) (threshold above every score, nothing flagged)
and ends at (1, 1) (threshold below every score, everything flagged).
Tied scores are grouped into a single step, which is what makes the
trapezoidal area equal the true AUC. Returns (fpr, tpr, thresholds).
"""
y_true = np.asarray(y_true).astype(int)
y_score = np.asarray(y_score, dtype=float)
P = int(np.sum(y_true == 1))
N = int(np.sum(y_true == 0))
thresholds = np.sort(np.unique(y_score))[::-1] # high -> low
fpr = [0.0]
tpr = [0.0]
thr = [np.inf]
for t in thresholds:
y_pred = (y_score >= t).astype(int)
tp = int(np.sum((y_pred == 1) & (y_true == 1)))
fp = int(np.sum((y_pred == 1) & (y_true == 0)))
tpr.append(tp / P if P else 0.0)
fpr.append(fp / N if N else 0.0)
thr.append(float(t))
return np.array(fpr), np.array(tpr), np.array(thr)
El área en sí es una regla del trapecio sobre esos puntos:
def auc(fpr, tpr):
"""Area under the ROC curve by the trapezoidal rule.
Integrate TPR with respect to FPR. Unitless, in [0, 1], and it has a
clean meaning: the probability that a random positive is scored above a
random negative. 0.5 is a coin flip, 1.0 is a perfect ranking. Because
the curve is monotone in FPR, trapezoids give the exact area.
"""
return float(np.trapezoid(tpr, fpr))
Y la curva precision-recall es el mismo barrido leído en otros ejes — precision contra recall en lugar de TPR contra FPR:
def pr_curve(y_true, y_score):
"""The precision-recall curve: precision vs recall as the threshold sweeps.
Same threshold sweep as the ROC, read on different axes. On imbalanced
data this curve is the honest one — it ignores the huge true-negative
count that flatters the ROC. Returns (recall_points, precision_points).
"""
y_true = np.asarray(y_true).astype(int)
y_score = np.asarray(y_score, dtype=float)
thresholds = np.sort(np.unique(y_score))[::-1]
recalls, precisions = [], []
for t in thresholds:
y_pred = (y_score >= t).astype(int)
tp, fp, fn, tn = confusion_counts(y_true, y_pred)
recalls.append(recall(tp, fn))
precisions.append(precision(tp, fp))
return np.array(recalls), np.array(precisions)
Míralo funcionar
Aquí está el punto central del capítulo en una sola animación. Barremos el umbral de decisión de 0 a 1 en pasos de 0.02 sobre los 231 pacientes reales de prueba. Cada frame es un umbral, calculado en vivo: la matriz de confusión a la izquierda, las cuatro métricas como barras en el centro, y el punto de operación trazando la curva ROC abajo. El texto nombra el umbral y lee cada métrica con su significado.
Dale play y observa el trade-off del que no puedes escapar. Empieza a la izquierda, con el umbral cerca de 0: el modelo marca a todos, así que el recall es un 1.0 perfecto — ningún diabético se pierde — pero la precision se hunde hasta la tasa base, porque la mayoría de la gente que marcaste está sana. El punto ROC se sienta en la esquina superior derecha, (1, 1), puros aciertos y puras falsas alarmas. Ahora sube el umbral. Las falsas alarmas se drenan de la celda inferior izquierda, la precision sube, y el punto camina curva abajo hacia el origen — pero el recall cae todo el trayecto conforme los diabéticos empiezan a aterrizar en la celda de casos perdidos. El accuracy hace joroba a la mitad y luego se desploma. No hay frame donde todas las barras estén altas a la vez. Dónde detienes el barrido es una decisión sobre qué error prefieres cometer, y ese es el trade-off de la matriz de confusión del capítulo de umbrales, ahora con las métricas que lo nombran conectadas al mismo control.
Vale la pena ver completa la curva sobre la que viaja el punto. Aquí está la ROC completa con su área sombreada — el AUC — y junto a ella la curva precision-recall para el mismo modelo sobre los mismos scores.
La diagonal punteada es el azar — la ROC de un volado, AUC 0.5. Nuestra curva se arquea por encima hasta un AUC de 0.83, lo que significa que si escoges un diabético y un paciente sano al azar, el modelo le da al diabético el score más alto cerca del 83% de las veces. Esa es una cualidad de ordenamiento, independiente de cualquier umbral, que es exactamente por qué el AUC es el número que se cita cuando todavía no eliges un punto de operación.
La curva precision-recall cuenta la misma historia desde el lado práctico. Su línea punteada es la tasa base, 0.346 — la precision que lograría alguien adivinando al azar — y un modelo útil vive por encima de ella. Léela de derecha a izquierda: empuja el recall hacia 1 y la precision decae hacia la tasa base; retrocede hacia precision alta y sacrificas recall. En problemas desbalanceados esta curva es la más honesta de las dos, porque nunca deja que la pila gigante de verdaderos negativos fáciles adule el score como el eje de tasa de falsos positivos de la ROC calladamente sí puede.
La implementación completa
Toda la librería de métricas, sin dependencias más allá de NumPy, de arriba a abajo. Este es el archivo que la animación y ambas curvas realmente ejecutaron:
"""Classification metrics, built from scratch.
Given true labels and a classifier's predicted scores, every number you'd
ever use to judge it: the confusion matrix, accuracy, precision, recall,
specificity, F1, the ROC curve and its AUC, and the precision-recall curve.
Pure NumPy — no metrics library anywhere in this file.
None of this trains a model. A model already produced the scores; this file
is only about scoring the scoring. Every function below appears in the
chapter one step at a time (the `# region:` markers are what the book's
include directives pull in).
Convention throughout: label 1 is the positive class (the thing we're trying
to catch — a diabetic patient), label 0 is the negative class. A "score" is
the model's estimated P(y = 1); a decision needs a threshold applied to it.
"""
import numpy as np
# region: confusion
def confusion_counts(y_true, y_pred):
"""The four counts a binary confusion matrix is made of.
Compares hard 0/1 predictions against 0/1 truth and returns
(tp, fp, fn, tn):
TP true positive predicted 1, truly 1 (caught a real case)
FP false positive predicted 1, truly 0 (a false alarm)
FN false negative predicted 0, truly 1 (a missed case)
TN true negative predicted 0, truly 0 (correctly cleared)
Every metric below is some ratio of these four integers.
"""
y_true = np.asarray(y_true).astype(int)
y_pred = np.asarray(y_pred).astype(int)
tp = int(np.sum((y_pred == 1) & (y_true == 1)))
fp = int(np.sum((y_pred == 1) & (y_true == 0)))
fn = int(np.sum((y_pred == 0) & (y_true == 1)))
tn = int(np.sum((y_pred == 0) & (y_true == 0)))
return tp, fp, fn, tn
# endregion
# region: predict_at
def predict_at(y_score, threshold):
"""Turn continuous scores into 0/1 labels at a decision threshold.
Predict the positive class when the score is at or above the threshold.
Everything downstream is a function of where you put this line.
"""
return (np.asarray(y_score) >= threshold).astype(int)
# endregion
# region: accuracy
def accuracy(tp, fp, fn, tn):
"""Fraction of all predictions that were correct: the diagonal / total.
Unitless, in [0, 1]. Reads well, lies on imbalanced data: predict the
majority class for everyone and this number can still look high.
"""
total = tp + fp + fn + tn
return (tp + tn) / total if total else 0.0
# endregion
# region: precision
def precision(tp, fp):
"""Of everything we FLAGGED as positive, what fraction really was.
precision = TP / (TP + FP). Unitless, in [0, 1]. The cost of a false
alarm lives here — low precision means you're crying wolf. Undefined
when nothing is flagged; we return 0 to match sklearn's convention.
"""
return tp / (tp + fp) if (tp + fp) else 0.0
# endregion
# region: recall
def recall(tp, fn):
"""Of everything that truly WAS positive, what fraction we caught.
recall = TP / (TP + FN), a.k.a. sensitivity or the true positive rate.
Unitless, in [0, 1]. The cost of a miss lives here — low recall means
real cases slipped through. Undefined when there are no positives.
"""
return tp / (tp + fn) if (tp + fn) else 0.0
# endregion
# region: specificity
def specificity(tn, fp):
"""Of everything that truly was NEGATIVE, what fraction we cleared.
specificity = TN / (TN + FP), the true negative rate. Unitless, in
[0, 1]. Recall's mirror image on the other class; 1 - specificity is
the false positive rate, the x-axis of the ROC curve.
"""
return tn / (tn + fp) if (tn + fp) else 0.0
# endregion
# region: f1
def f1(precision_val, recall_val):
"""The harmonic mean of precision and recall — one number for both.
f1 = 2 * P * R / (P + R). Unitless, in [0, 1]. Harmonic, not
arithmetic, on purpose: it stays near the smaller of the two, so you
can't win F1 by acing precision while recall collapses. Zero when
either is zero.
"""
denom = precision_val + recall_val
return 2 * precision_val * recall_val / denom if denom else 0.0
# endregion
# region: metrics_at
def metrics_at(y_true, y_score, threshold):
"""Every scalar metric at one decision threshold, in one call.
Threshold the scores, count the four cells, and derive the ratios.
Returns a dict so the animation can read a full metric snapshot per
frame without recomputing anything.
"""
y_pred = predict_at(y_score, threshold)
tp, fp, fn, tn = confusion_counts(y_true, y_pred)
p = precision(tp, fp)
r = recall(tp, fn)
return {
"threshold": float(threshold),
"tp": tp, "fp": fp, "fn": fn, "tn": tn,
"accuracy": accuracy(tp, fp, fn, tn),
"precision": p,
"recall": r,
"specificity": specificity(tn, fp),
"f1": f1(p, r),
"tpr": r, # recall == true positive rate
"fpr": 1.0 - specificity(tn, fp),
}
# endregion
# region: roc_curve
def roc_curve(y_true, y_score):
"""Sweep every threshold and trace (FPR, TPR) — the ROC curve.
The thresholds that matter are the distinct score values: between two
adjacent scores nothing changes. Walk them from high to low, so the
curve starts at (0, 0) (threshold above every score, nothing flagged)
and ends at (1, 1) (threshold below every score, everything flagged).
Tied scores are grouped into a single step, which is what makes the
trapezoidal area equal the true AUC. Returns (fpr, tpr, thresholds).
"""
y_true = np.asarray(y_true).astype(int)
y_score = np.asarray(y_score, dtype=float)
P = int(np.sum(y_true == 1))
N = int(np.sum(y_true == 0))
thresholds = np.sort(np.unique(y_score))[::-1] # high -> low
fpr = [0.0]
tpr = [0.0]
thr = [np.inf]
for t in thresholds:
y_pred = (y_score >= t).astype(int)
tp = int(np.sum((y_pred == 1) & (y_true == 1)))
fp = int(np.sum((y_pred == 1) & (y_true == 0)))
tpr.append(tp / P if P else 0.0)
fpr.append(fp / N if N else 0.0)
thr.append(float(t))
return np.array(fpr), np.array(tpr), np.array(thr)
# endregion
# region: auc
def auc(fpr, tpr):
"""Area under the ROC curve by the trapezoidal rule.
Integrate TPR with respect to FPR. Unitless, in [0, 1], and it has a
clean meaning: the probability that a random positive is scored above a
random negative. 0.5 is a coin flip, 1.0 is a perfect ranking. Because
the curve is monotone in FPR, trapezoids give the exact area.
"""
return float(np.trapezoid(tpr, fpr))
# endregion
# region: pr_curve
def pr_curve(y_true, y_score):
"""The precision-recall curve: precision vs recall as the threshold sweeps.
Same threshold sweep as the ROC, read on different axes. On imbalanced
data this curve is the honest one — it ignores the huge true-negative
count that flatters the ROC. Returns (recall_points, precision_points).
"""
y_true = np.asarray(y_true).astype(int)
y_score = np.asarray(y_score, dtype=float)
thresholds = np.sort(np.unique(y_score))[::-1]
recalls, precisions = [], []
for t in thresholds:
y_pred = (y_score >= t).astype(int)
tp, fp, fn, tn = confusion_counts(y_true, y_pred)
recalls.append(recall(tp, fn))
precisions.append(precision(tp, fp))
return np.array(recalls), np.array(precisions)
# endregion
La versión de librería
Nadie escribe estas a mano en producción, y tú tampoco deberías una vez que
sabes qué significan. sklearn.metrics tiene todas y cada una, y el punto de
escribir las nuestras fue ganarnos el derecho de confiar en las de la librería.
Aquí está calculando los mismos escalares en el umbral de 0.5, más el AUC libre
de umbral:
def sklearn_metrics(y_true, y_score, threshold=0.5):
"""Every scalar metric at one threshold, from sklearn.metrics.
sklearn's confusion_matrix lays cells out as [[TN, FP], [FP-row...]] —
rows are truth, columns are prediction, labels sorted [0, 1] — so we
unpack it as [[TN, FP], [FN, TP]]. AUC is threshold-free, computed from
the raw scores.
"""
y_pred = (y_score >= threshold).astype(int)
tn, fp, fn, tp = confusion_matrix(y_true, y_pred, labels=[0, 1]).ravel()
return {
"tp": int(tp), "fp": int(fp), "fn": int(fn), "tn": int(tn),
"accuracy": float(accuracy_score(y_true, y_pred)),
"precision": float(precision_score(y_true, y_pred, zero_division=0)),
"recall": float(recall_score(y_true, y_pred, zero_division=0)),
"f1": float(f1_score(y_true, y_pred, zero_division=0)),
"auc": float(roc_auc_score(y_true, y_score)),
}
Un detalle de layout que vale la pena señalar: el confusion_matrix de sklearn
pone la verdad en las filas y las predicciones en las columnas con las etiquetas
ordenadas [0, 1], así que el orden aplanado es TN, FP, FN, TP — no el orden que
adivinarías, y una fuente común de precision y recall silenciosamente
intercambiados. La ROC sale de roc_curve, y el área de roc_auc_score:
def sklearn_roc(y_true, y_score):
"""sklearn's ROC curve and AUC from the raw scores."""
fpr, tpr, thr = roc_curve(y_true, y_score)
return fpr, tpr, thr, float(roc_auc_score(y_true, y_score))
Los scores que estamos juzgando vienen de una regresión logística simple — el
modelo no es el tema aquí, así que lo mantenemos aburrido: estandarizar con las
estadísticas de entrenamiento, ajustar, leer predict_proba.
def train_and_score(train_df, test_df):
"""Fit logistic regression on the train split, score the test split.
The model is not the point of this chapter — the metrics are — so we
keep it plain: standardize the eight features on the training stats,
fit, then read off predict_proba for the positive class on the held-out
patients. Returns (y_true, y_score) for the test set.
"""
Xtr = train_df[FEATURES].to_numpy(float)
Xte = test_df[FEATURES].to_numpy(float)
mean, std = Xtr.mean(axis=0), Xtr.std(axis=0)
std = np.where(std == 0, 1.0, std)
Xtr, Xte = (Xtr - mean) / std, (Xte - mean) / std
clf = LogisticRegression(max_iter=1000, random_state=0)
clf.fit(Xtr, train_df["Outcome"].to_numpy(int))
y_true = test_df["Outcome"].to_numpy(int)
y_score = clf.predict_proba(Xte)[:, 1] # P(y = 1) column
return y_true, y_score
Desde cero contra librería
Si nuestras fórmulas están bien, cada número hecho desde cero debería caer exactamente sobre el de sklearn. Aquí está el careo entre las cinco métricas, cada una adimensional en una escala 0–1 — accuracy, precision, recall y F1 son fracciones de un conteo en el umbral de 0.5, y el AUC es la probabilidad de ordenamiento libre de umbral:
Los pares son idénticos hasta el último decimal — accuracy 0.77, precision 0.68,
recall 0.61, F1 0.64, AUC 0.83 — que es la confirmación que buscábamos:
generate_traces.py verifica la igualdad con un assert en cada corrida, así que
si una fórmula algún día se desvía, el build falla en lugar de publicar un
número equivocado. Las cifras de las que se alimenta la gráfica viven en
results.json, regenerado cada vez que el código cambia.
Ahora lee esos cinco números como grupo, porque esa es la verdadera lección. El accuracy de este modelo es 0.77, que suena respetable hasta que recuerdas que la línea base de la clase mayoritaria es 0.65 — así que el modelo te está comprando doce puntos por encima de adivinar, no setenta y siete. Su recall es 0.61: en el umbral por defecto se le escapan 31 de los 80 diabéticos del test set. Para una prueba de tamizaje ese número es alarmante y el accuracy lo escondió por completo. Mismo modelo, mismos datos, y la historia cambia de "bastante bueno" a "se le van dos de cada cinco casos" dependiendo únicamente de qué métrica leas. Esa brecha es el argumento completo de este capítulo.
Conclusiones
Elige la métrica que corresponda al costo de tus errores, y elígela antes de entrenar, no después de ver los números. Si un caso perdido es el error caro — enfermedad, fraude, seguridad — optimizas recall y aceptas las falsas alarmas. Si la falsa alarma es la cara — marcar clientes buenos, bloquear correo legítimo — optimizas precision y aceptas los casos perdidos. Cuando ambos cuestan más o menos lo mismo y solo necesitas un número para rankear modelos, F1. Cuando no te has comprometido con un umbral y quieres saber qué tan bueno es el ordenamiento, AUC. El accuracy se gana un lugar en esa lista solo cuando las clases están balanceadas y los dos errores cuestan lo mismo, que en mi experiencia casi nunca es el caso que alguien realmente tiene.
El hábito que te salva es reportar siempre la matriz de confusión junto a cualquier número estelar que cites. Cada métrica de este capítulo es un resumen con pérdida de esos cuatro conteos, y distintos resúmenes esconden distintos desastres — pero los cuatro conteos no esconden nada. Un revisor que ve TP, FP, FN y TN puede calcular cualquier métrica que le interese y atrapar la que convenientemente no mencionaste. Y trata cualquier cifra de accuracy suelta sobre datos desbalanceados como un foco rojo hasta que la hayas comparado contra la línea base mayoritaria; la nuestra se veía bien en 0.77 y calladamente se le escapaba el 39% de los casos que existía para encontrar. Ese modo de fallo empeora entre más rara se vuelve la clase positiva, que es donde el siguiente capítulo sobre clases desbalanceadas retoma el hilo — porque una vez que los positivos son el 1% de los datos, el accuracy no solo engaña: miente de plano.