Ir al contenido
wannacode

Evaluación: la exactitud miente

En resumen: Un modelo se evalúa con datos que no ha visto: la matriz de confusión cuenta los cuatro resultados, y la precisión y la exhaustividad dicen más que la exactitud.

Problema

99 % de exactitud, cero fraudes detectados

Un compañero te enseña su modelo de fraude: «¡99 % de exactitud!». Suena estupendo. Aquí hay un modelo que consigue lo mismo sin hacer nada:

int frauds = 0;
for (int y : fraud) frauds += y;
// «siempre normal»: nunca bloquea, así que acierta en todos los pagos honestos
double accuracy = 100.0 * (4000 - frauds) / 4000;
System.out.printf("pagos: 4000, fraudes: %d%n", frauds);
System.out.printf("«siempre normal»: exactitud %.2f%%, fraudes detectados: 0 de %d%n", accuracy, frauds);
Salida
pagos: 4000, fraudes: 40
«siempre normal»: exactitud 99.00%, fraudes detectados: 0 de 40

Este es un mes con 4000 pagos, y 40 de ellos son fraude: uno de cada cien. El modelo «siempre normal» no bloquea nada. Con los 3960 pagos honestos acierta, con los 40 fraudes se equivoca. Exactitud 99,00 %, fraudes detectados: cero.

La exactitud (accuracy) es la proporción de respuestas correctas. Cuando una clase es el 99 %, el modelo que siempre dice esa clase tiene un 99 % de exactitud. El número es verdadero, pero no responde a la pregunta de la tienda: «¿Cuántos fraudes detectamos y a cuántos clientes honestos molestamos?».

Míralo

El mes en código

Los pagos reales vienen de la base de datos de la tienda. Aquí el mes se genera con una regla, para que sea pequeño e igual en todos los lenguajes. Cada pago es fraude con probabilidad del 1 %. Los fraudes suelen ser de 3× a 12× el importe habitual, y a veces una prueba pequeña (de 0,3× a 1,3×). Los pagos honestos suelen ser de 0,2× a 2,2×, y a veces una compra grande de hasta 6×.

Los números aleatorios vienen de un pequeño generador con semilla (un LCG): la misma semilla da los mismos números en todos los lenguajes y en cada ejecución.

// un generador con semilla: los mismos números en cada lenguaje
class Lcg {
private long state;
Lcg(long seed) {
state = seed;
}
double next() {
state = (state * 1664525 + 1013904223) % 4294967296L;
return state / 4294967296.0;
}
}
// un mes de pagos: aproximadamente 1 de cada 100 es fraude
Lcg rng = new Lcg(319);
double[] ratios = new double[4000];
int[] fraud = new int[4000];
for (int i = 0; i < 4000; i++) {
boolean isFraud = rng.next() < 0.01;
if (isFraud) // fraude: normalmente de 3× a 12×, a veces una prueba pequeña
ratios[i] = rng.next() < 0.85 ? 3 + 9 * rng.next() : 0.3 + rng.next();
else // honesto: normalmente de 0.2× a 2.2×, a veces una compra grande
ratios[i] = rng.next() < 0.03 ? 2.5 + 3.5 * rng.next() : 0.2 + rng.next() + rng.next();
fraud[i] = isFraud ? 1 : 0;
}
4294967296L es 2³². La multiplicación se hace en long, así que el resultado es el mismo que en los otros lenguajes.

La característica es la de la lección de clasificación: el importe comparado con el habitual del cliente.

La idea

Entrenamiento y prueba

Antes de medir nada, hay una pregunta más importante: ¿con qué datos medimos? Este es el «modelo» más simple: recuerda los importes de los fraudes que ha visto y bloquea solo esos. El mes se mezcla y se divide: el 70 % para entrenar, el 30 % para comprobar.

// mezcla (Fisher–Yates) con el mismo generador
int[] order = new int[4000];
for (int i = 0; i < 4000; i++) order[i] = i;
for (int i = 3999; i > 0; i--) {
int j = (int) (rng.next() * (i + 1));
int t = order[i];
order[i] = order[j];
order[j] = t;
}
int[] train = Arrays.copyOfRange(order, 0, 2800);
int[] test = Arrays.copyOfRange(order, 2800, 4000);
System.out.printf("entrenamiento: %d pagos, %d fraudes%n", train.length, countFrauds(fraud, train));
System.out.printf("prueba: %d pagos, %d fraudes%n", test.length, countFrauds(fraud, test));
// recuerda los importes de los fraudes que ha visto
Set<Double> seen = new HashSet<>();
for (int i : train) if (fraud[i] == 1) seen.add(ratios[i]);
report("memorizar en los datos de entrenamiento", seen, ratios, fraud, train);
report("memorizar en los datos de prueba", seen, ratios, fraud, test);
Salida
entrenamiento: 2800 pagos, 24 fraudes
prueba: 1200 pagos, 16 fraudes
memorizar en los datos de entrenamiento: exactitud 100.00%, detectados 24 de 24
memorizar en los datos de prueba: exactitud 98.67%, detectados 0 de 16
Arrays.copyOfRange(order, 0, 2800) crea un arreglo nuevo con las filas de la 0 a la 2799. HashSet y Set son de java.util.

En los pagos de los que aprendió, el modelo es perfecto: 100 %, 24 de 24. En los nuevos no detecta ni un fraude, porque un fraude nuevo nunca tiene exactamente el mismo importe que uno viejo. El modelo no ha aprendido nada sobre fraudes, solo los ha recordado.

Por eso los datos se dividen en dos antes de entrenar:

  • datos de entrenamiento (training set): el modelo aprende de ellos;
  • datos de prueba (test set): se usan para medir al final, y el modelo no los ve hasta entonces.

La prueba hace de los pagos de mañana. Mezclar antes de dividir importa: si el mes está ordenado por fecha, la prueba serán solo los últimos días.

Míralo

La matriz de confusión

Ahora el modelo de verdad: la regresión logística de la lección de clasificación. La función es la misma, salvo que además recibe rows: qué filas usar. Aprende solo de los datos de entrenamiento:

// aprende solo de las filas de rows
static double[] trainLogistic(double[] xs, int[] ys, int[] rows, double rate, int epochs) {
double w = 0, b = 0;
int n = rows.length;
for (int epoch = 0; epoch < epochs; epoch++) {
double gw = 0, gb = 0;
for (int i : rows) {
double p = sigmoid(w * xs[i] + b);
gw += (p - ys[i]) * xs[i];
gb += p - ys[i];
}
w -= rate * gw / n;
b -= rate * gb / n;
}
return new double[] {w, b};
}

Un pago de prueba puede acabar de cuatro maneras. El modelo bloquea o deja pasar, y el pago es fraude u honesto:

el modelo bloquea el modelo deja pasar
fraude fraude bloqueado (TP) fraude no detectado (FN)
honesto honesto bloqueado (FP) honesto permitido (TN)

Esta es la matriz de confusión (confusion matrix). Las letras significan verdadero/falso positivo/negativo. «Positivo» es la clase que buscamos: el fraude.

static int[] confusion(double[] ratios, int[] fraud,
int[] rows, double w, double b,
double threshold) {
int tp = 0, fp = 0, fn = 0, tn = 0;
for (int i : rows) {
boolean stop = block(ratios[i], w, b, threshold);
if (stop && fraud[i] == 1) tp++; // fraude bloqueado
else if (stop) fp++; // cliente honesto bloqueado
else if (fraud[i] == 1) fn++; // fraude no detectado
else tn++; // cliente honesto permitido
}
return new int[] {tp, fp, fn, tn};
}
double[] m = trainLogistic(ratios, fraud, train, 1, 1000);
double w = m[0], b = m[1];
int[] c = confusion(ratios, fraud, test, w, b, 0.5);
int tp = c[0], fp = c[1], fn = c[2], tn = c[3];
double accuracy = 100.0 * (tp + tn) / test.length;
double precision = 100.0 * tp / (tp + fp);
double recall = 100.0 * tp / (tp + fn);
System.out.printf("w = %.4f, b = %.4f%n", w, b);
System.out.println("fraudes bloqueados (TP): " + tp);
System.out.println("honestos bloqueados (FP): " + fp);
System.out.println("fraudes no detectados (FN): " + fn);
System.out.println("honestos permitidos (TN): " + tn);
System.out.printf("exactitud %.2f%%, precision %.2f%%, recall %.2f%%%n", accuracy, precision, recall);
Salida
w = 1.0702, b = -7.1054
fraudes bloqueados (TP): 7
honestos bloqueados (FP): 0
fraudes no detectados (FN): 9
honestos permitidos (TN): 1184
exactitud 99.25%, precision 100.00%, recall 43.75%

La exactitud es del 99,25 %, un poco por encima del 98,67 % de «siempre normal» en la misma prueba. Sin embargo, el modelo detecta 7 de 16 fraudes y no bloquea a ningún cliente honesto. Las dos exactitudes son casi iguales, y los modelos son completamente distintos. Necesitamos números que miren a los fraudes.

Míralo

La matriz se llena

static int[] confusion(double[] ratios, int[] fraud,
int[] rows, double w, double b,
double threshold) {
int tp = 0, fp = 0, fn = 0, tn = 0;
for (int i : rows) {
boolean stop = block(ratios[i], w, b, threshold);
if (stop && fraud[i] == 1) tp++; // fraude bloqueado
else if (stop) fp++; // cliente honesto bloqueado
else if (fraud[i] == 1) fn++; // fraude no detectado
else tn++; // cliente honesto permitido
}
return new int[] {tp, fp, fn, tn};
}
Paso a paso, en palabras
  1. 1200 pagos de prueba que el modelo nunca ha visto, umbral 0,5. La fila es la verdad, la columna es lo que dijo el modelo.
  2. Primero los 16 fraudes, uno por uno: 7 bloqueados (TP), 9 no detectados (FN).
  3. Luego los honestos: 0 bloqueados (FP), 1184 permitidos (TN). Casi todo acaba en una esquina.
  4. La exactitud es la diagonal: (7 + 1184) / 1200 = 99,25 %. «Siempre normal» obtiene 98,67 % en la misma prueba.
  5. La precisión es la columna «bloquear»: de los pagos bloqueados, ¿cuántos eran fraude? 7 / 7 = 100,00 %.
  6. La exhaustividad es la fila «fraude»: de los fraudes, ¿cuántos detectamos? 7 / 16 = 43,75 %.
  7. La exactitud mira todo y es alta. La precisión y la exhaustividad miran los fraudes: cada bloqueo es un fraude, pero detectamos menos de la mitad.

1184 de los 1200 pagos están en la esquina inferior derecha: honestos permitidos. Por eso la exactitud es alta para cualquier modelo que deje pasar los pagos honestos. Lo interesante está en la fila de arriba.

La idea

Precisión y exhaustividad con las palabras de la tienda

Dos preguntas, dos números:

  • Precisión (precision): de los pagos bloqueados, ¿cuántos son realmente fraude? TP / (TP + FP). Una precisión baja significa muchos clientes honestos molestos.
  • Exhaustividad (recall): de todos los fraudes, ¿cuántos detectamos? TP / (TP + FN). Una exhaustividad baja significa fraudes no detectados y dinero perdido.

Con el umbral 0,5 el modelo tiene una precisión del 100 % y una exhaustividad del 43,75 %. Cada bloqueo es un fraude, pero detecta menos de la mitad. Para «siempre normal» la exhaustividad es del 0 %, y lo ves enseguida, sin engañarte con el 99 %.

Los dos tiran en sentidos opuestos. Para detectar más fraudes (exhaustividad), bloqueas más pagos sospechosos, y algunos de ellos son honestos (la precisión baja).

Pruébalo

Rellena el hueco

La precisión divide entre todos los pagos bloqueados. ¿Entre qué divide la exhaustividad?

double precision = 100.0 * tp / (tp + fp);
double recall = 100.0 * tp / (tp + );

Pruébalo

Predice la salida

Diez pagos: la verdad (1 = fraude) y lo que el modelo bloqueó (1 = bloqueado). ¿Qué imprimirá?

int[] actual = {1, 0, 0, 1, 0, 0, 0, 1, 0, 0};
int[] blocked = {1, 1, 0, 0, 0, 1, 0, 1, 0, 0};
int tp = 0, fp = 0, fn = 0;
for (int i = 0; i < actual.length; i++) {
if (blocked[i] == 1 && actual[i] == 1) tp++;
else if (blocked[i] == 1) fp++;
else if (actual[i] == 1) fn++;
}
System.out.printf("precision %.2f%%, recall %.2f%%%n",
100.0 * tp / (tp + fp), 100.0 * tp / (tp + fn));
Respuesta

La idea

El umbral y el coste en euros

El umbral de la lección de clasificación ahora tiene números. El mismo modelo en la misma prueba, con cuatro umbrales:

for (double threshold : new double[] {0.5, 0.3, 0.1, 0.02}) {
int[] c = confusion(ratios, fraud, test, w, b, threshold);
int tp = c[0], fp = c[1], fn = c[2];
System.out.printf("umbral %.2f: TP %d, FP %d, FN %d → precision %.2f%%, recall %.2f%%%n",
threshold, tp, fp, fn, 100.0 * tp / (tp + fp), 100.0 * tp / (tp + fn));
}
Salida
umbral 0.50: TP 7, FP 0, FN 9 → precision 100.00%, recall 43.75%
umbral 0.30: TP 10, FP 1, FN 6 → precision 90.91%, recall 62.50%
umbral 0.10: TP 11, FP 13, FN 5 → precision 45.83%, recall 68.75%
umbral 0.02: TP 14, FP 28, FN 2 → precision 33.33%, recall 87.50%

Cuanto más bajo el umbral, más fraudes detectamos (la exhaustividad sube del 43,75 % al 87,5 %) y más clientes honestos bloqueamos (la precisión baja del 100 % al 33,33 %). ¿Qué es mejor? La respuesta está en euros. Digamos que un fraude no detectado cuesta 250 € (la mercancía y la comisión de la devolución del cargo), y un cliente honesto bloqueado cuesta 10 € (una llamada a soporte y tiempo perdido). Los precios son un ejemplo:

// precios de ejemplo: un fraude no detectado y un cliente honesto bloqueado
int missedCost = 250, blockedCost = 10;
double best = -1;
int bestCost = Integer.MAX_VALUE;
for (double threshold : new double[] {0.5, 0.3, 0.1, 0.02}) {
int[] c = confusion(ratios, fraud, test, w, b, threshold);
int cost = c[2] * missedCost + c[1] * blockedCost;
System.out.printf("umbral %.2f: %d × %d € + %d × %d € = %d €%n",
threshold, c[2], missedCost, c[1], blockedCost, cost);
if (cost < bestCost) {
bestCost = cost;
best = threshold;
}
}
System.out.printf("umbral más barato: %.2f%n", best);
Salida
umbral 0.50: 9 × 250 € + 0 × 10 € = 2250 €
umbral 0.30: 6 × 250 € + 1 × 10 € = 1510 €
umbral 0.10: 5 × 250 € + 13 × 10 € = 1380 €
umbral 0.02: 2 × 250 € + 28 × 10 € = 780 €
umbral más barato: 0.02

Con estos precios el umbral más estricto es el más barato: 0,02, con 780 € en lugar de 2250 € con 0,5. Un fraude no detectado cuesta lo mismo que 25 clientes bloqueados, así que conviene bloquear más a menudo. Si un cliente bloqueado se va para siempre y cuesta mucho más, la respuesta es otra. Ese es el ejercicio del final.

Una advertencia. Aquí el umbral se elige con la prueba. Así que la prueba ya ha participado en una decisión, y los 780 € son un poco más optimistas que la verdad. En un proyecto real el umbral se elige con una tercera parte de los datos, los datos de validación (validation set), y la prueba se ejecuta una sola vez, al final. Cómo, está en la próxima lección.

Pruébalo

Encuentra el error

Un compañero divide los datos así. Sus números de prueba son excelentes, y en la tienda el modelo detecta mucho menos. ¿En qué línea está el error?

Elige la línea con el error.

La idea

Cuándo duele

  • Exactitud con clases desiguales. Fraude, enfermedad, averías, spam: cuando lo que buscas es raro, la exactitud casi no sirve. Mira la matriz, la precisión y la exhaustividad.
  • Una prueba que se parece más a los datos de entrenamiento que al futuro. Los defraudadores cambian sus trucos. Si la prueba se mezcla con meses pasados, es más fácil que el mes que viene. Para estos datos la prueba es el período más reciente: aprendes hasta el 30 de septiembre y mides octubre.
  • Pocos ejemplos de la clase rara. La prueba tiene 16 fraudes. Un fraude más o menos mueve la exhaustividad un 6,25 %. Los números de aquí son para la lección, y los equipos reales reúnen miles de fraudes antes de confiar en la segunda cifra.
  • Un número para todo. La precisión y la exhaustividad son dos porque hay dos errores y cuestan distinto. Si los aplastas en uno, pierdes la decisión sobre el coste.
  • Una prueba tocada muchas veces. Cada vez que cambias el modelo porque la prueba dijo «mal», la prueba se convierte un poco en entrenamiento.
Más precisamente, para curiosos

F1. La media armónica de la precisión y la exhaustividad: 2 · P · R / (P + R). Un número que baja si cualquiera de los dos es bajo. Es útil para comparar, pero no conoce el coste de los errores.

La curva. Si mueves el umbral de 1 a 0 y dibujas la precisión frente a la exhaustividad, obtienes la curva PR. El área bajo ella (PR AUC) mide el modelo sin un umbral concreto. La curva ROC relacionada (proporción de fraudes detectados frente a proporción de honestos bloqueados) es más conocida, pero con 1 de cada 100 parece mejor de lo que es, porque hay tantos pagos honestos.

El umbral a partir del coste. Si las probabilidades del modelo son correctas, el umbral más barato es el coste de un cliente bloqueado dividido entre la suma de los dos costes: 10 / (10 + 250) ≈ 0,04. El programa encontró 0,02 entre los cuatro umbrales que probó. El modelo de aquí se ha aprendido con un 1 % de fraude, así que sus probabilidades se acercan a las reales. Un modelo aprendido con un 40 % de fraude (como en la lección de clasificación) ve fraude demasiado a menudo, y su umbral tiene que ser más alto.

División por clases. Con 40 fraudes una división aleatoria puede mandar 30 al entrenamiento y 10 a la prueba, o al revés. Una división estratificada mantiene la misma proporción de fraudes en las dos partes.

En el código real. En scikit-learn son train_test_split (con stratify= para las clases), confusion_matrix, precision_score, recall_score y precision_recall_curve. En ML.NET TrainTestSplit y BinaryClassificationMetrics dan lo mismo. Vigilar el modelo en producción (si la exhaustividad baja mes a mes) forma parte de MLOps y se enseña en ZeroToOps.

printf con %% escribe el propio signo %. El formato depende de la Locale: con una española verás una coma.

Ejercicio

El cliente honesto caro

Resuelve esto en tu editor.

La tienda vende suscripciones. Un cliente bloqueado por error suele irse a la competencia, así que un cliente honesto bloqueado cuesta 60 €, y un fraude no detectado sigue costando 250 €. Ejecuta el programa del coste con 60 € y encuentra el umbral más barato.

Salida
umbral 0.50: 9 × 250 € + 0 × 60 € = 2250 €
umbral 0.30: 6 × 250 € + 1 × 60 € = 1560 €
umbral 0.10: 5 × 250 € + 13 × 60 € = 2030 €
umbral 0.02: 2 × 250 € + 28 × 60 € = 2180 €
umbral más barato: 0.30

Ahora el más barato es 0,3: bloquea 10 de 16 fraudes y solo 1 cliente honesto. El modelo es el mismo, los datos son los mismos, y el umbral es distinto, porque los errores cuestan distinto.

Más tareas

  • Una rejilla de umbrales más fina. Prueba los umbrales de 0,02 a 0,5 en pasos de 0,02. ¿Hay alguno más barato que 0,3?
  • Prueba por tiempo. En lugar de mezclar, toma los primeros 2800 pagos para entrenar y los últimos 1200 para la prueba. ¿Cuántos fraudes hay ahora en la prueba, y cómo cambió la exhaustividad con 0,3?

Test

Ponte a prueba

1. Los fraudes son 1 de cada 100. Un modelo dice «normal» para cada pago. ¿Qué exactitud tiene?
2. ¿Por qué se comprueba un modelo con datos que no ha visto durante el entrenamiento?
3. La precisión es del 90 %. ¿Qué significa para la tienda?
4. Un fraude no detectado cuesta 250 €, y un cliente honesto bloqueado cuesta 10 €. ¿En qué dirección debería moverse el umbral?
5. Un compañero elige el umbral con el coste más bajo en la prueba e informa de ese coste. ¿Qué está mal?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·