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:
const frauds = fraud.reduce((sum, y) => sum + y, 0);// «siempre normal»: nunca bloquea, así que acierta en todos los pagos honestosconst accuracy = 100 * (4000 - frauds) / 4000;console.log(`pagos: 4000, fraudes: ${frauds}`);console.log(`«siempre normal»: exactitud ${accuracy.toFixed(2)}%, fraudes detectados: 0 de ${frauds}`);pagos: 4000, fraudes: 40«siempre normal»: exactitud 99.00%, fraudes detectados: 0 de 40Este 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 lenguajeclass Lcg { constructor(seed) { this.state = seed; }
next() { this.state = (this.state * 1664525 + 1013904223) % 4294967296; return this.state / 4294967296; }}// un mes de pagos: aproximadamente 1 de cada 100 es fraudeconst rng = new Lcg(319);const ratios = [];const fraud = [];for (let i = 0; i < 4000; i++) { const isFraud = rng.next() < 0.01; if (isFraud) // fraude: normalmente de 3× a 12×, a veces una prueba pequeña ratios.push(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.push(rng.next() < 0.03 ? 2.5 + 3.5 * rng.next() : 0.2 + rng.next() + rng.next()); fraud.push(isFraud ? 1 : 0);}state * 1664525 es menor que 2⁵³, así que el número (un double) es exacto, sin pérdida. El generador da los mismos números 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 generadorconst order = Array.from({ length: 4000 }, (_, i) => i);for (let i = 3999; i > 0; i--) { const j = Math.floor(rng.next() * (i + 1)); [order[i], order[j]] = [order[j], order[i]];}const train = order.slice(0, 2800);const test = order.slice(2800);console.log(`entrenamiento: ${train.length} pagos, ${countFrauds(fraud, train)} fraudes`);console.log(`prueba: ${test.length} pagos, ${countFrauds(fraud, test)} fraudes`);// recuerda los importes de los fraudes que ha vistoconst seen = new Set(train.filter((i) => fraud[i] === 1).map((i) => ratios[i]));report("memorizar en los datos de entrenamiento", seen, ratios, fraud, train);report("memorizar en los datos de prueba", seen, ratios, fraud, test);entrenamiento: 2800 pagos, 24 fraudesprueba: 1200 pagos, 16 fraudesmemorizar en los datos de entrenamiento: exactitud 100.00%, detectados 24 de 24memorizar en los datos de prueba: exactitud 98.67%, detectados 0 de 16En 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 rowsfunction trainLogistic(xs, ys, rows, rate, epochs) { let w = 0, b = 0; const n = rows.length; for (let epoch = 0; epoch < epochs; epoch++) { let gw = 0, gb = 0; for (const i of rows) { const 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 { 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.
function confusion(ratios, fraud, rows, w, b, threshold) { let tp = 0, fp = 0, fn = 0, tn = 0; for (const i of rows) { const 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 { tp, fp, fn, tn };}const { w, b } = trainLogistic(ratios, fraud, train, 1, 1000);const { tp, fp, fn, tn } = confusion(ratios, fraud, test, w, b, 0.5);const accuracy = 100 * (tp + tn) / test.length;const precision = 100 * tp / (tp + fp);const recall = 100 * tp / (tp + fn);console.log(`w = ${w.toFixed(4)}, b = ${b.toFixed(4)}`);console.log(`fraudes bloqueados (TP): ${tp}`);console.log(`honestos bloqueados (FP): ${fp}`);console.log(`fraudes no detectados (FN): ${fn}`);console.log(`honestos permitidos (TN): ${tn}`);console.log(`exactitud ${accuracy.toFixed(2)}%, precision ${precision.toFixed(2)}%, recall ${recall.toFixed(2)}%`);w = 1.0702, b = -7.1054fraudes bloqueados (TP): 7honestos bloqueados (FP): 0fraudes no detectados (FN): 9honestos permitidos (TN): 1184exactitud 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
function confusion(ratios, fraud, rows, w, b, threshold) { let tp = 0, fp = 0, fn = 0, tn = 0; for (const i of rows) { const 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 { tp, fp, fn, tn };}const { tp, fp, fn, tn } = confusion(ratios, fraud, test, w, b, 0.5);const accuracy = 100 * (tp + tn) / test.length;const precision = 100 * tp / (tp + fp);const recall = 100 * tp / (tp + fn);Paso a paso, en palabras
- 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.
- Primero los 16 fraudes, uno por uno: 7 bloqueados (TP), 9 no detectados (FN).
- Luego los honestos: 0 bloqueados (FP), 1184 permitidos (TN). Casi todo acaba en una esquina.
- La exactitud es la diagonal: (7 + 1184) / 1200 = 99,25 %. «Siempre normal» obtiene 98,67 % en la misma prueba.
- La precisión es la columna «bloquear»: de los pagos bloqueados, ¿cuántos eran fraude? 7 / 7 = 100,00 %.
- La exhaustividad es la fila «fraude»: de los fraudes, ¿cuántos detectamos? 7 / 16 = 43,75 %.
- 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?
const precision = 100 * tp / (tp + fp);
const recall = 100 * tp / (tp + );La exhaustividad es TP / (TP + FN): los fraudes detectados divididos entre todos los fraudes, detectados y no detectados. La precisión mira la columna «bloquear» de la matriz, y la exhaustividad mira la fila «fraude».
Pruébalo
Predice la salida
Diez pagos: la verdad (1 = fraude) y lo que el modelo bloqueó (1 = bloqueado). ¿Qué imprimirá?
const actual = [1, 0, 0, 1, 0, 0, 0, 1, 0, 0];const blocked = [1, 1, 0, 0, 0, 1, 0, 1, 0, 0];let tp = 0, fp = 0, fn = 0;for (let 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++;}console.log(`precision ${(100 * tp / (tp + fp)).toFixed(2)}%, recall ${(100 * tp / (tp + fn)).toFixed(2)}%`);Se bloquean 4 pagos, 2 de ellos son fraudes (TP = 2, FP = 2): la precisión es 2 / 4 = 50 %. Hay 3 fraudes y se detectan 2 (FN = 1): la exhaustividad es 2 / 3 ≈ 66,67 %. La exactitud (7 de 10) es un tercer número que no dice nada sobre los fraudes.
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 (const threshold of [0.5, 0.3, 0.1, 0.02]) { const { tp, fp, fn } = confusion(ratios, fraud, test, w, b, threshold); const precision = 100 * tp / (tp + fp); const recall = 100 * tp / (tp + fn); console.log(`umbral ${threshold.toFixed(2)}: TP ${tp}, FP ${fp}, FN ${fn} → precision ${precision.toFixed(2)}%, recall ${recall.toFixed(2)}%`);}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 bloqueadoconst missedCost = 250, blockedCost = 10;let best = -1;let bestCost = Infinity;for (const threshold of [0.5, 0.3, 0.1, 0.02]) { const { fp, fn } = confusion(ratios, fraud, test, w, b, threshold); const cost = fn * missedCost + fp * blockedCost; console.log(`umbral ${threshold.toFixed(2)}: ${fn} × ${missedCost} € + ${fp} × ${blockedCost} € = ${cost} €`); if (cost < bestCost) { bestCost = cost; best = threshold; }}console.log(`umbral más barato: ${best.toFixed(2)}`);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.02Con 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 prueba toma las primeras 1200 filas, y esas también están en los datos de entrenamiento (las primeras 2800). El modelo se comprueba con pagos que ha visto, así que los números son demasiado buenos. La prueba tiene que ser el resto: de 2800 a 4000. Esto es una fuga de datos (data leakage): la prueba sabe algo que los pagos de mañana no sabrán.
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.
Set guarda números como claves: seen.has(3.7) es verdadero solo para exactamente el mismo número.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.
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.30Ahora 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
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Map y Set · Qué es el aprendizaje automático · Descenso del gradiente · Clasificación: fraude o no
- Continúa en: Sobreajuste: el modelo que memoriza · El entrenamiento de principio a fin · Los dígitos del recibo · Evaluación: un conjunto de pruebas para el asistente
- Ejercicios sobre el tema: Cómo aprende un modelo