---
title: "Métricas de clasificación"
pagetitle: "Métricas de clasificación: matriz de confusión, umbral, ROC y Precision-Recall"
open-graph:
description: "Métricas para evaluar clasificadores: matriz de confusión, exactitud, sensibilidad, especificidad, precisión, F1, MCC, curvas ROC y Precision-Recall, umbral por costos, métricas multiclase y probabilísticas, con desarrollo manual, laboratorio interactivo y Python."
twitter-card:
description: "Métricas para evaluar clasificadores: matriz de confusión, exactitud, sensibilidad, especificidad, precisión, F1, MCC, curvas ROC y Precision-Recall, umbral por costos, métricas multiclase y probabilísticas, con desarrollo manual, laboratorio interactivo y Python."
description: "Métricas para evaluar clasificadores: matriz de confusión, exactitud, sensibilidad, especificidad, precisión, F1, MCC, curvas ROC y Precision-Recall, umbral por costos, métricas multiclase y probabilísticas, con desarrollo manual, laboratorio interactivo y Python."
author: "Diego Villalba"
lang: es
format:
html:
toc: true
toc-depth: 3
toc-title: "Contenido"
code-fold: true
code-tools: true
code-summary: "Mostrar código"
theme: cosmo
highlight-style: github
execute:
echo: true
warning: false
message: false
bibliography: [referencias_fundamentos_clasificacion.bib, referencias_clasificacion.bib, referencias_evaluacion_clasificacion.bib, ../../references.bib]
jupyter: python3
---
{{< include ../preprocesamiento/_labkit.qmd >}}
En el capítulo introductorio medimos a los clasificadores con un solo número, la exactitud. Basta un ejemplo para ver que no alcanza: si el 97 % de las transacciones son legítimas, el clasificador que **nunca** detecta fraude acierta el 97 % de las veces y es completamente inútil. Evaluar bien exige separar los **tipos de error**, reconocer que tienen **costos distintos** y distinguir entre la calidad del **puntaje** que produce un modelo y la calidad de la **decisión** que se toma al compararlo con un umbral [@provost2013; @fawcett2006].
Este capítulo construye todas las métricas a partir de un único ejemplo de veinte pacientes, calculado a mano, y después las generaliza. El siguiente capítulo usa estas métricas para responder otra pregunta: cómo estimarlas de forma honesta y cómo elegir modelos con ellas.
## Intuición: una prueba de detección
Un modelo asigna a cada paciente un **puntaje** entre 0 y 1: la probabilidad estimada de tener una enfermedad. De veinte pacientes, cinco están enfermos (la clase **positiva**) y quince sanos (la **negativa**); es decir, la **prevalencia** es $5/20 = 25\%$.
| | Puntajes |
|---|---|
| **Enfermos** (5) | 0.95, 0.85, 0.70, 0.55, 0.30 |
| **Sanos** (15) | 0.80, 0.60, 0.45, 0.40, 0.35, 0.25, 0.20, 0.18, 0.15, 0.12, 0.10, 0.08, 0.05, 0.04, 0.02 |
: Puntajes del modelo para los veinte pacientes. {#tbl-pacientes}
El modelo es bueno pero no perfecto: casi todos los enfermos tienen puntajes altos, pero un sano tiene 0.80 y un enfermo apenas 0.30. Para **decidir** hay que fijar un **umbral** $\theta$: se declara «enfermo» a quien tenga puntaje $\ge \theta$. Con el umbral usual $\theta = 0.5$, ¿qué tan bueno es el resultado? La respuesta depende de qué errores nos importen.
## Construcción paso a paso
### Paso 1. La matriz de confusión
Con $\theta = 0.5$ se declaran enfermos los puntajes 0.95, 0.85, 0.70 y 0.55 (cuatro enfermos reales) y 0.80 y 0.60 (dos sanos). El enfermo con 0.30 queda sin detectar. La **matriz de confusión** cruza la clase real con la predicha:
| | Predicho enfermo | Predicho sano | Total |
|---|---:|---:|---:|
| **Real enfermo** | VP = 4 | FN = 1 | 5 |
| **Real sano** | FP = 2 | VN = 13 | 15 |
| **Total** | 6 | 14 | 20 |
: Matriz de confusión con θ = 0.5. {#tbl-matriz-050}
- **Verdaderos positivos** (VP): enfermos detectados.
- **Falsos negativos** (FN): enfermos no detectados, el **error de tipo II**.
- **Falsos positivos** (FP): sanos señalados como enfermos, el **error de tipo I** o falsa alarma.
- **Verdaderos negativos** (VN): sanos correctamente descartados.
Todas las métricas de esta sección son cocientes entre celdas de esta tabla. La clave para no confundirlas es preguntarse **qué fila o qué columna** se usa como denominador.
### Paso 2. Métricas por fila: ¿qué le pasó a cada clase real?
Dividiendo entre el total de cada **fila** (la realidad):
$$
\text{Sensibilidad} = \frac{VP}{VP + FN} = \frac{4}{5} = 0.80, \qquad
\text{Especificidad} = \frac{VN}{VN + FP} = \frac{13}{15} \approx 0.867.
$$
La **sensibilidad** (*recall*, exhaustividad o tasa de verdaderos positivos, TPR) responde: de los enfermos, ¿cuántos detecta la prueba? La **especificidad** (tasa de verdaderos negativos) responde: de los sanos, ¿cuántos deja en paz? Sus complementos son la tasa de falsos negativos, $1 - 0.80 = 0.20$, y la **tasa de falsos positivos** (FPR), $2/15 \approx 0.133$.
### Paso 3. Métricas por columna: ¿cuánto vale cada predicción?
Dividiendo entre el total de cada **columna** (lo que dijo el modelo):
$$
\text{Precisión} = \frac{VP}{VP + FP} = \frac{4}{6} \approx 0.667, \qquad
\text{VPN} = \frac{VN}{VN + FN} = \frac{13}{14} \approx 0.929.
$$
La **precisión** (valor predictivo positivo, VPP) responde: si la prueba dice «enfermo», ¿qué tan probable es que lo esté? El **valor predictivo negativo** (VPN) responde lo mismo para el «sano». Son las métricas que le importan a quien recibe el resultado; las de fila, a quien diseña la prueba.
| Pregunta | Denominador | Métrica | Valor |
|---|---|---|---:|
| De los enfermos, ¿cuántos detecto? | fila positiva | Sensibilidad (recall, TPR) | 0.800 |
| De los sanos, ¿cuántos descarto? | fila negativa | Especificidad (TNR) | 0.867 |
| Si digo «enfermo», ¿acierto? | columna positiva | Precisión (VPP) | 0.667 |
| Si digo «sano», ¿acierto? | columna negativa | VPN | 0.929 |
: Las cuatro métricas básicas y su denominador. {#tbl-cuatro-metricas}
### Paso 4. Métricas globales y la paradoja de la exactitud
La **exactitud** usa toda la tabla: $(VP + VN)/n = 17/20 = 0.85$. Ahora comparemos con el clasificador trivial que declara **sanos a todos**: no tiene VP ni FP, acierta los 15 sanos y su exactitud es $15/20 = 0.75$. Solo diez puntos por debajo de nuestro modelo… sin detectar a un solo enfermo. Con una prevalencia del 1 %, el clasificador trivial tendría 0.99 de exactitud. Esta es la **paradoja de la exactitud**: con clases desbalanceadas, la exactitud premia ignorar a la clase minoritaria [@saito2015].
Tres métricas globales resisten mejor el desbalance:
- **Exactitud balanceada**: el promedio de sensibilidad y especificidad, $(0.80 + 0.867)/2 \approx 0.833$. El clasificador trivial obtiene $(0 + 1)/2 = 0.5$, como el azar.
- **F1**: la media armónica[^armonica] de precisión y sensibilidad,
$$F_1 = \frac{2 \cdot 0.667 \cdot 0.80}{0.667 + 0.80} = \frac{2\,VP}{2\,VP + FP + FN} = \frac{8}{11} \approx 0.727.$$
Ignora los VN, así que solo tiene sentido cuando la clase positiva es la de interés. El trivial obtiene 0.
- **Coeficiente de correlación de Matthews** (MCC) [@matthews1975comparison], la correlación entre la clase real y la predicha, que usa las cuatro celdas:
$$\text{MCC} = \frac{VP \cdot VN - FP \cdot FN}{\sqrt{(VP+FP)(VP+FN)(VN+FP)(VN+FN)}} = \frac{4 \cdot 13 - 2 \cdot 1}{\sqrt{6 \cdot 5 \cdot 15 \cdot 14}} = \frac{50}{79.4} \approx 0.630.$$
Vale 1 para un clasificador perfecto, 0 para uno que no aprende nada (incluido el trivial) y $-1$ para uno que se equivoca siempre. Es simétrico respecto a qué clase se llama positiva y suele recomendarse como resumen único en problemas binarios desbalanceados [@chicco2020advantages].
### Paso 5. Mover el umbral
Nada obliga a usar $\theta = 0.5$. Bajar el umbral detecta más enfermos a costa de más falsas alarmas; subirlo, lo contrario:
| Umbral $\theta$ | VP | FP | FN | Sensibilidad (TPR) | FPR | Precisión |
|---:|---:|---:|---:|---:|---:|---:|
| 0.90 | 1 | 0 | 4 | 0.20 | 0.000 | 1.000 |
| 0.75 | 2 | 1 | 3 | 0.40 | 0.067 | 0.667 |
| 0.50 | 4 | 2 | 1 | 0.80 | 0.133 | 0.667 |
| 0.30 | 5 | 5 | 0 | 1.00 | 0.333 | 0.500 |
| 0.10 | 5 | 11 | 0 | 1.00 | 0.733 | 0.313 |
: Métricas para distintos umbrales. {#tbl-umbrales}
Ningún umbral es «el correcto» en abstracto: la sensibilidad y la precisión se mueven en direcciones opuestas. Elegir el umbral es una **decisión de negocio**, no un detalle técnico.
### Paso 6. La curva ROC y el AUC
Si recorremos todos los umbrales posibles, de mayor a menor, y graficamos cada par (FPR, TPR), obtenemos la **curva ROC** (*receiver operating characteristic*). Con veinte pacientes es una escalera: cada vez que el umbral pasa por un enfermo, la curva sube $1/5$; cada vez que pasa por un sano, avanza $1/15$ a la derecha. Recorriendo los puntajes de mayor a menor (E = enfermo, S = sano):
$$
\underset{0.95}{E}\;\underset{0.85}{E}\;\underset{0.80}{S}\;\underset{0.70}{E}\;\underset{0.60}{S}\;\underset{0.55}{E}\;\underset{0.45}{S}\;\underset{0.40}{S}\;\underset{0.35}{S}\;\underset{0.30}{E}\;\underset{0.25}{S}\;\cdots\;\underset{0.02}{S}
$$
El **área bajo la curva** (AUC) resume la curva en un número. Tiene una interpretación muy útil: es la probabilidad de que un enfermo elegido al azar tenga un puntaje mayor que un sano elegido al azar [@bamber1975]. Con 5 enfermos y 15 sanos hay $5 \times 15 = 75$ parejas. Contamos, para cada enfermo, cuántos sanos tienen un puntaje menor:
| Enfermo | 0.95 | 0.85 | 0.70 | 0.55 | 0.30 | Total |
|---|---:|---:|---:|---:|---:|---:|
| Sanos con puntaje menor | 15 | 15 | 14 | 13 | 10 | 67 |
: Conteo de parejas bien ordenadas. {#tbl-auc-parejas}
$$
\text{AUC} = \frac{67}{75} \approx 0.893.
$$
Un AUC de 0.5 es el azar (la diagonal); 1 es una separación perfecta. Lo importante es que el AUC **no depende del umbral**: mide qué tan bien **ordena** el modelo a los pacientes, no qué tan buena es una decisión concreta.
### Paso 7. La curva Precision–Recall
La curva ROC usa tasas por fila, que no dependen de la prevalencia. Cuando los positivos son pocos, conviene mirar también la **curva Precision–Recall** (PR), que grafica la precisión contra la sensibilidad. Su resumen habitual es la **precisión promedio** (AP): el promedio de la precisión en los umbrales donde aparece un nuevo enfermo, ponderado por el aumento en sensibilidad ($1/5$ cada vez):
| Nuevo enfermo en | 0.95 | 0.85 | 0.70 | 0.55 | 0.30 |
|---|---:|---:|---:|---:|---:|
| Sensibilidad | 0.2 | 0.4 | 0.6 | 0.8 | 1.0 |
| Precisión | 1/1 = 1.000 | 2/2 = 1.000 | 3/4 = 0.750 | 4/6 = 0.667 | 5/10 = 0.500 |
: Puntos de la curva Precision–Recall donde aumenta la sensibilidad. {#tbl-ap}
$$
\text{AP} = 0.2\,(1 + 1 + 0.75 + 0.667 + 0.5) \approx 0.783.
$$
La referencia de la curva PR no es 0.5 sino la **prevalencia**: un clasificador al azar tiene precisión $5/20 = 0.25$ en todo el rango. Por eso un AP de 0.78 debe compararse con 0.25.
### Paso 8. El umbral según los costos
Supongamos que dejar sin diagnóstico a un enfermo cuesta **diez veces** más que una falsa alarma: $C_{FN} = 10$ y $C_{FP} = 1$. El costo total de cada umbral de la @tbl-umbrales es $10 \cdot FN + 1 \cdot FP$:
| $\theta$ | 0.90 | 0.75 | 0.50 | 0.30 | 0.10 |
|---|---:|---:|---:|---:|---:|
| Costo | 40 + 0 = 40 | 30 + 1 = 31 | 10 + 2 = **12** | 0 + 5 = **5** | 0 + 11 = 11 |
: Costo total según el umbral con C_FN = 10 y C_FP = 1. {#tbl-costos}
El mejor umbral es 0.30, no 0.50. La teoría de la decisión da una regla general: si el puntaje es una probabilidad **bien calibrada**[^calibrada], conviene declarar positivo cuando el costo esperado de hacerlo es menor que el de no hacerlo,
$$
(1 - p)\,C_{FP} < p\,C_{FN} \iff p > \theta^{*} = \frac{C_{FP}}{C_{FP} + C_{FN}} = \frac{1}{11} \approx 0.09.
$$ {#eq-umbral-costos}
En nuestro ejemplo los puntajes no están calibrados y con solo veinte pacientes la regla teórica (0.09) y el mejor umbral observado (0.30) no coinciden; con muchos datos y probabilidades calibradas, sí lo hacen. En cualquier caso el mensaje es el mismo: **cuando un error cuesta más que el otro, el umbral debe moverse hacia donde ese error se vuelve raro.**
## Formalización
### Matriz de confusión y métricas binarias
Sea $n_+ = VP + FN$ el número de positivos, $n_- = VN + FP$ el de negativos y $\pi = n_+/n$ la prevalencia. Las métricas anteriores se resumen así:
| Métrica | Fórmula | Rango | Depende de $\pi$ |
|---|---|---|---|
| Exactitud | $(VP + VN)/n$ | [0, 1] | sí |
| Sensibilidad, recall, TPR | $VP/(VP + FN)$ | [0, 1] | no |
| Especificidad, TNR | $VN/(VN + FP)$ | [0, 1] | no |
| FPR | $FP/(VN + FP) = 1 - \text{especificidad}$ | [0, 1] | no |
| Precisión, VPP | $VP/(VP + FP)$ | [0, 1] | sí |
| VPN | $VN/(VN + FN)$ | [0, 1] | sí |
| Exactitud balanceada | $(\text{TPR} + \text{TNR})/2$ | [0, 1] | no |
| $F_\beta$ | $(1+\beta^2)\dfrac{\text{prec}\cdot\text{rec}}{\beta^2\,\text{prec} + \text{rec}}$ | [0, 1] | sí |
| MCC | $\dfrac{VP\,VN - FP\,FN}{\sqrt{(VP+FP)(VP+FN)(VN+FP)(VN+FN)}}$ | [−1, 1] | débilmente |
: Métricas derivadas de la matriz de confusión binaria. {#tbl-metricas-formulas}
En $F_\beta$, el parámetro $\beta$ declara cuánto más importa la sensibilidad que la precisión: $F_2$ la pondera el doble (detección médica), $F_{0.5}$ la mitad (filtros donde las falsas alarmas son caras) y $F_1$ las trata por igual.
### Por qué la precisión depende de la prevalencia
La sensibilidad y la especificidad describen a la prueba; la precisión describe a la prueba **en una población**. Por el teorema de Bayes[^bayes-vpp],
$$
\text{VPP} = \frac{\text{TPR}\cdot\pi}{\text{TPR}\cdot\pi + \text{FPR}\cdot(1 - \pi)}.
$$ {#eq-vpp-prevalencia}
Con la sensibilidad (0.80) y la FPR (0.133) de nuestro ejemplo, la precisión es 0.667 cuando $\pi = 0.25$, pero baja a $\frac{0.8 \cdot 0.01}{0.8 \cdot 0.01 + 0.133 \cdot 0.99} \approx 0.057$ cuando $\pi = 0.01$: en una población con 1 % de enfermos, **diecisiete de cada dieciocho alarmas serían falsas**, con la misma prueba. Por eso las métricas de columna no se pueden trasladar de una población a otra, y por eso el laboratorio permite mover la prevalencia.
### Curvas ROC y PR
Sea $s(\mathbf{x})$ el puntaje y $\theta$ el umbral. Al variar $\theta$, la curva ROC recorre los puntos $(\text{FPR}(\theta), \text{TPR}(\theta))$ desde $(0, 0)$ hasta $(1, 1)$, y la curva PR recorre $(\text{TPR}(\theta), \text{VPP}(\theta))$. El AUC tiene la forma
$$
\text{AUC} = P\big(s(\mathbf{x}^{+}) > s(\mathbf{x}^{-})\big) + \tfrac{1}{2}P\big(s(\mathbf{x}^{+}) = s(\mathbf{x}^{-})\big),
$$ {#eq-auc}
donde $\mathbf{x}^{+}$ y $\mathbf{x}^{-}$ son un positivo y un negativo elegidos al azar; es el estadístico de Mann–Whitney normalizado. Dos propiedades prácticas:
- La curva ROC y el AUC **no cambian** si se transforma el puntaje con una función creciente: solo importa el orden. Por eso dos modelos con el mismo AUC pueden tener probabilidades muy distintas.
- La curva ROC **no cambia con la prevalencia**, pero la curva PR sí. Con clases muy desbalanceadas, un AUC alto puede convivir con una precisión pésima en el umbral de operación; la curva PR lo revela [@saito2015].
### Más de dos clases
Con $K$ clases la matriz de confusión es $K \times K$. Para cada clase $c$ se calculan precisión, sensibilidad y F1 tratándola como positiva y a las demás como negativas («una contra el resto»). Después se promedian de tres maneras:
- **Macro**: promedio simple sobre clases; todas pesan igual, también las raras.
- **Ponderado** (*weighted*): promedio ponderado por el número de casos de cada clase.
- **Micro**: se suman VP, FP y FN de todas las clases y se calcula una sola métrica; en clasificación multiclase de una etiqueta, el F1 micro coincide con la exactitud.
Un ejemplo con tres clases y 100 casos:
| | Predicho A | Predicho B | Predicho C | Sensibilidad |
|---|---:|---:|---:|---:|
| **Real A** (50) | **40** | 5 | 5 | 0.800 |
| **Real B** (30) | 10 | **15** | 5 | 0.500 |
| **Real C** (20) | 2 | 3 | **15** | 0.750 |
| **Precisión** | 40/52 = 0.769 | 15/23 = 0.652 | 15/25 = 0.600 | |
: Matriz de confusión con tres clases. {#tbl-multiclase}
Los F1 por clase son 0.784, 0.566 y 0.667. El F1 macro es su promedio, $0.672$; el ponderado, $0.5 \cdot 0.784 + 0.3 \cdot 0.566 + 0.2 \cdot 0.667 = 0.695$; el micro es igual a la exactitud, $70/100 = 0.700$. El macro es el más bajo porque da a la clase B, la peor, el mismo peso que a las demás: es el promedio adecuado cuando todas las clases importan por igual.
### Métricas para probabilidades
Las métricas anteriores evalúan **decisiones** o **rankings**. Cuando el producto del modelo es la probabilidad misma (un riesgo de impago que se usará para fijar una tasa, por ejemplo), hay que evaluar la probabilidad:
- **Pérdida logarítmica** (*log-loss*, entropía cruzada): $\;-\frac{1}{n}\sum_i \big[y_i \ln \hat{p}_i + (1 - y_i)\ln(1 - \hat{p}_i)\big]$. Castiga sin piedad la confianza equivocada: predecir 0.99 para un sano cuesta $-\ln 0.01 \approx 4.6$, cuarenta veces más que predecir 0.9 para un enfermo ($-\ln 0.9 \approx 0.105$).
- **Puntaje de Brier** [@brier1950verification]: $\frac{1}{n}\sum_i (\hat{p}_i - y_i)^2$, el error cuadrático de la probabilidad. Está acotado entre 0 y 1 y es más indulgente con los errores extremos.
Para los veinte pacientes, la log-loss es 0.387 y el Brier 0.124. Ambas son **reglas de puntuación propias**: se minimizan, en promedio, reportando la probabilidad verdadera, así que premian a los modelos **calibrados**. Su complemento gráfico, el diagrama de calibración, se estudia en el capítulo siguiente.
## Laboratorio: umbral, prevalencia y curvas
El laboratorio tiene dos modos. **Veinte pacientes** usa los puntajes de la @tbl-pacientes; el estado inicial ($\theta = 0.5$) reproduce la @tbl-matriz-050 y las métricas de los pasos 2 a 4. **Modelo binormal** supone que el puntaje latente de los sanos sigue una normal estándar y el de los enfermos una normal con media $d$; ese puntaje se convierte en una probabilidad calibrada según la prevalencia, y todo se calcula para una población de 1000 personas. Ahí se pueden mover la **separación** entre clases, la **prevalencia** y el **cociente de costos**.
::: {.content-visible when-format="html"}
```{=html}
<div class="plab" id="met-lab">
<p class="plab-eyebrow">Laboratorio · Métricas de clasificación</p>
<h3 class="plab-title">¿Dónde pongo el umbral?</h3>
<p class="plab-sub">Mueve el umbral y observa cómo cambian la matriz de confusión, las métricas y el punto sobre las curvas ROC y Precision–Recall.</p>
<div class="plab-controls">
<div class="plab-field">Datos
<div class="plab-seg" id="met-mode">
<button data-value="ej" aria-pressed="true">Veinte pacientes</button>
<button data-value="bin">Modelo binormal</button>
</div>
</div>
<label>Umbral θ: <b id="met-th-v"></b><input type="range" id="met-th" min="0.01" max="0.99" step="0.01" value="0.5"></label>
<label class="met-bin">Separación d: <b id="met-d-v"></b><input type="range" id="met-d" min="0" max="4" step="0.1" value="1.5"></label>
<label class="met-bin">Prevalencia π: <b id="met-pi-v"></b><input type="range" id="met-pi" min="0.01" max="0.5" step="0.01" value="0.1"></label>
<label>Costo FN / costo FP: <b id="met-c-v"></b><input type="range" id="met-c" min="1" max="50" step="1" value="10"></label>
<div class="plab-field met-bin">Umbral<button class="plab-btn" id="met-opt">Ir al umbral de mínimo costo</button></div>
</div>
<div class="plab-grid">
<div class="plab-panel">
<h4 id="met-dist-t">Puntajes por clase</h4>
<svg id="met-dist" role="img" aria-label="Distribución de puntajes de enfermos y sanos con la línea del umbral"></svg>
<div class="plab-legend"><span><i class="dot" style="background:var(--lab-s5)"></i>enfermos (positivos)</span><span><i class="dot" style="background:var(--lab-s1)"></i>sanos (negativos)</span><span><i style="background:var(--lab-text)"></i>umbral</span></div>
</div>
<div class="plab-panel">
<h4>Matriz de confusión</h4>
<div id="met-cm" class="plab-scroll"></div>
</div>
</div>
<div class="plab-stats" id="met-stats"></div>
<div class="plab-grid" style="margin-top:1rem">
<div class="plab-panel">
<h4>Curva ROC</h4>
<svg id="met-roc" role="img" aria-label="Curva ROC con el punto del umbral actual"></svg>
<div class="plab-legend"><span><i style="background:var(--lab-s4)"></i>curva ROC</span><span><i style="background:var(--lab-axis)"></i>azar</span><span><i class="dot" style="background:var(--lab-s2)"></i>umbral actual</span></div>
</div>
<div class="plab-panel">
<h4>Curva Precision–Recall</h4>
<svg id="met-pr" role="img" aria-label="Curva Precision-Recall con el punto del umbral actual"></svg>
<div class="plab-legend"><span><i style="background:var(--lab-s4)"></i>curva PR</span><span><i style="background:var(--lab-axis)"></i>prevalencia (azar)</span><span><i class="dot" style="background:var(--lab-s2)"></i>umbral actual</span></div>
</div>
</div>
<p class="plab-note" id="met-note" aria-live="polite"></p>
</div>
<script>
(() => {
const K = window.LabKit, root = document.getElementById('met-lab');
const $ = s => root.querySelector(s);
const POS = [0.95, 0.85, 0.70, 0.55, 0.30];
const NEG = [0.80, 0.60, 0.45, 0.40, 0.35, 0.25, 0.20, 0.18, 0.15, 0.12, 0.10, 0.08, 0.05, 0.04, 0.02];
// Función de distribución normal estándar (Abramowitz-Stegun 7.1.26).
const erf = x => { const s = Math.sign(x); x = Math.abs(x); const t = 1 / (1 + 0.3275911 * x);
return s * (1 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-x * x)); };
const Phi = z => 0.5 * (1 + erf(z / Math.SQRT2));
const phi = z => Math.exp(-z * z / 2) / Math.sqrt(2 * Math.PI);
const logit = p => Math.log(p / (1 - p));
const modeSeg = K.segmented($('#met-mode'), () => { sync(); render(); });
function sync() { root.querySelectorAll('.met-bin').forEach(e => e.style.display = modeSeg.get() === 'bin' ? '' : 'none'); }
// Conteos esperados para el modelo binormal: puntaje latente s, probabilidad calibrada p(s).
function binCounts(th, d, pi, N) {
// p(s) = σ(logit π + d s − d²/2) ≥ θ ⇔ s ≥ s*
const sStar = d > 1e-9 ? (logit(th) - logit(pi) + d * d / 2) / d : (th <= pi ? -Infinity : Infinity);
const tpr = 1 - Phi(sStar - d), fpr = 1 - Phi(sStar);
const P = pi * N, Nn = (1 - pi) * N;
return { tp: tpr * P, fn: (1 - tpr) * P, fp: fpr * Nn, tn: (1 - fpr) * Nn, sStar };
}
function exCounts(th) {
const tp = POS.filter(s => s >= th - 1e-12).length, fp = NEG.filter(s => s >= th - 1e-12).length;
return { tp, fn: POS.length - tp, fp, tn: NEG.length - fp };
}
function metrics(c) {
const P = c.tp + c.fn, Nn = c.fp + c.tn, n = P + Nn, pp = c.tp + c.fp, pn = c.fn + c.tn;
const sens = P ? c.tp / P : NaN, spec = Nn ? c.tn / Nn : NaN, prec = pp ? c.tp / pp : NaN, npv = pn ? c.tn / pn : NaN;
const f1 = (2 * c.tp + c.fp + c.fn) ? 2 * c.tp / (2 * c.tp + c.fp + c.fn) : NaN;
const den = Math.sqrt(pp * P * Nn * pn), mcc = den ? (c.tp * c.tn - c.fp * c.fn) / den : 0;
return { acc: (c.tp + c.tn) / n, sens, spec, prec, npv, f1, bal: (sens + spec) / 2, mcc };
}
$('#met-opt').addEventListener('click', () => { const r = +$('#met-c').value; $('#met-th').value = Math.max(0.01, Math.min(0.99, Math.round(100 / (1 + r)) / 100)); render(); });
function render() {
const mode = modeSeg.get(), th = +$('#met-th').value, d = +$('#met-d').value, pi = +$('#met-pi').value, r = +$('#met-c').value, N = 1000;
$('#met-th-v').textContent = K.fmt(th, 2); $('#met-d-v').textContent = K.fmt(d, 1);
$('#met-pi-v').textContent = K.fmt(pi, 2); $('#met-c-v').textContent = r;
const cnt = mode === 'ej' ? exCounts(th) : binCounts(th, d, pi, N), m = metrics(cnt);
const dec = mode === 'ej' ? 0 : 1, f = v => K.fmt(v, dec);
// Panel de puntajes.
if (mode === 'ej') {
$('#met-dist-t').textContent = 'Puntajes por clase (cada punto es un paciente)';
const c = K.chart($('#met-dist'), { width: 380, height: 200, x: [0, 1], y: [0, 3], margin: { left: 70, bottom: 38 } });
c.axes({ xTicks: 5, yTicks: 0, xLabel: 'puntaje del modelo', grid: false, xFmt: v => K.fmt(v, 1) });
c.text(0, 2.1, 'enfermos', { dx: -8, 'text-anchor': 'end', class: 'strong' });
c.text(0, 0.9, 'sanos', { dx: -8, 'text-anchor': 'end', class: 'strong' });
c.rect(th, 0, 1, 3, 'muted').style.fillOpacity = 0.08;
POS.forEach(s => c.dot(s, 2.1, 6.5, 's5 dot'));
NEG.forEach((s, i) => c.dot(s, 0.9 + (i % 2 ? 0.35 : 0), 6, 's1 dot'));
c.line(th, 0, th, 3, 'muted line'); c.text(th, 0.15, `θ = ${K.fmt(th, 2)}`, { dx: 4, class: 'strong' });
c.text(1, 2.85, 'se declara enfermo →', { 'text-anchor': 'end', dx: -2 });
} else {
$('#met-dist-t').textContent = 'Densidad del puntaje latente (escalada por la prevalencia)';
const lo = -3.5, hi = Math.max(3.5, d + 3.5), xs = []; for (let s = lo; s <= hi + 1e-9; s += 0.05) xs.push(s);
const ymax = Math.max(...xs.map(s => Math.max(pi * phi(s - d), (1 - pi) * phi(s)))) * 1.1;
const c = K.chart($('#met-dist'), { width: 380, height: 200, x: [lo, hi], y: [0, ymax], margin: { left: 44, bottom: 38 } });
c.axes({ xTicks: 6, yTicks: 3, xLabel: 'puntaje latente s', yLabel: 'densidad', xFmt: v => K.fmt(v, 0), yFmt: v => K.fmt(v, 2) });
const sS = K.clamp(cnt.sStar, lo, hi);
const area = (fn, from, to, cls) => { const pts = [[from, 0]]; for (let s = from; s <= to + 1e-9; s += 0.05) pts.push([s, fn(s)]); pts.push([to, 0]);
K.el('path', { d: 'M' + pts.map(p => `${c.X(p[0]).toFixed(1)},${c.Y(p[1]).toFixed(1)}`).join('L') + 'Z', class: cls }, c.g).style.stroke = 'none'; };
area(s => (1 - pi) * phi(s), sS, hi, 's1 soft'); area(s => pi * phi(s - d), lo, sS, 's5 soft');
c.path(xs.map(s => [s, (1 - pi) * phi(s)]), 's1 line'); c.path(xs.map(s => [s, pi * phi(s - d)]), 's5 line');
c.line(sS, 0, sS, ymax, 'muted line');
c.text(sS, ymax * 0.92, ` θ = ${K.fmt(th, 2)} ⇔ s ≥ ${K.fmt(cnt.sStar, 2)}`, { dx: 4, class: 'strong' });
}
// Matriz de confusión.
$('#met-cm').innerHTML = '<table class="plab-table"><thead><tr><th></th><th>Predicho enfermo</th><th>Predicho sano</th></tr></thead><tbody>' +
`<tr><td>Real enfermo</td><td class="hl">VP = ${f(cnt.tp)}</td><td>FN = ${f(cnt.fn)}</td></tr>` +
`<tr><td>Real sano</td><td>FP = ${f(cnt.fp)}</td><td class="hl">VN = ${f(cnt.tn)}</td></tr></tbody></table>` +
(mode === 'bin' ? '<p class="plab-sub" style="margin:.35rem 0 0">Conteos esperados en una población de 1000 personas.</p>' : '');
const cost = r * cnt.fn + cnt.fp;
const stats = [['Exactitud', m.acc], ['Sensibilidad', m.sens], ['Especificidad', m.spec], ['Precisión', m.prec], ['VPN', m.npv], ['F1', m.f1], ['Exact. balanceada', m.bal], ['MCC', m.mcc]];
$('#met-stats').innerHTML = stats.map(([k, v]) => `<div class="plab-stat"><span>${k}</span><strong>${K.fmt(v, 3)}</strong></div>`).join('') +
`<div class="plab-stat"><span>Costo (${r}·FN + FP)</span><strong>${K.fmt(cost, dec)}</strong></div>`;
// Curvas ROC y PR.
let roc = [], prc = [], auc, base;
if (mode === 'ej') {
const all = [...new Set([...POS, ...NEG])].sort((a, b) => b - a), ths = [1.01, ...all];
ths.forEach(t => { const k = exCounts(t), mm = metrics(k); roc.push([k.fp / 15, k.tp / 5]); if (k.tp + k.fp) prc.push([mm.sens, mm.prec]); });
auc = 0; POS.forEach(p => NEG.forEach(n => { auc += p > n ? 1 : p === n ? 0.5 : 0; })); auc /= 75; base = 0.25;
} else {
for (let t = 0.995; t >= 0.005; t -= 0.005) { const k = binCounts(t, d, pi, N), mm = metrics(k); roc.push([k.fp / ((1 - pi) * N), k.tp / (pi * N)]); if (k.tp + k.fp > 1e-9) prc.push([mm.sens, mm.prec]); }
roc = [[0, 0], ...roc, [1, 1]]; auc = Phi(d / Math.SQRT2); base = pi;
}
const cr = K.chart($('#met-roc'), { width: 300, height: 290, x: [0, 1], y: [0, 1], margin: { left: 44, bottom: 40, right: 10 } });
cr.axes({ xTicks: 5, yTicks: 5, xLabel: 'FPR (1 − especificidad)', yLabel: 'TPR (sensibilidad)', xFmt: v => K.fmt(v, 1), yFmt: v => K.fmt(v, 1) });
cr.line(0, 0, 1, 1, 'muted line dash thin');
cr.path(roc, 's4 line');
cr.dot(1 - m.spec, m.sens, 6.5, 's2 dot');
cr.text(0.55, 0.12, `AUC = ${K.fmt(auc, 3)}`, { class: 'strong' });
const cp = K.chart($('#met-pr'), { width: 300, height: 290, x: [0, 1], y: [0, 1.02], margin: { left: 44, bottom: 40, right: 10 } });
cp.axes({ xTicks: 5, yTicks: 5, xLabel: 'recall (sensibilidad)', yLabel: 'precisión', xFmt: v => K.fmt(v, 1), yFmt: v => K.fmt(v, 1) });
cp.line(0, base, 1, base, 'muted line dash thin');
cp.path(prc, 's4 line');
if (Number.isFinite(m.prec)) cp.dot(m.sens, m.prec, 6.5, 's2 dot');
// Retroalimentación.
const note = $('#met-note'), thOpt = 1 / (1 + r);
const trivialAcc = mode === 'ej' ? 0.75 : 1 - pi;
if (Number.isNaN(m.prec)) {
note.className = 'plab-note bad';
note.textContent = `Con θ = ${K.fmt(th, 2)} nadie se declara enfermo: la precisión no está definida y la sensibilidad es 0, aunque la exactitud sea ${K.fmt(m.acc, 3)}.`;
} else if (m.acc <= trivialAcc + 1e-9) {
note.className = 'plab-note warn';
note.textContent = `Exactitud ${K.fmt(m.acc, 3)}: no supera la del clasificador trivial que declara sanos a todos (${K.fmt(trivialAcc, 3)}), aunque este detectaría 0 enfermos. Mira la sensibilidad (${K.fmt(m.sens, 3)}) y el MCC (${K.fmt(m.mcc, 3)}).`;
} else {
note.className = 'plab-note good';
note.textContent = `De cada 100 enfermos se detectan ${K.fmt(100 * m.sens, 0)}; de cada 100 alarmas, ${K.fmt(100 * m.prec, 0)} son correctas. ` +
(mode === 'bin' ? `Con probabilidades calibradas, el umbral de mínimo costo es θ* = 1/(1 + ${r}) = ${K.fmt(thOpt, 3)}; el costo actual es ${K.fmt(cost, 1)} por cada 1000 personas.` :
`Con costo FN/FP = ${r}, el costo total en estos 20 pacientes es ${cost}.`);
}
}
root.querySelectorAll('input[type=range]').forEach(el => el.addEventListener('input', render));
sync();
render();
})();
</script>
```
:::
::: {.content-visible when-format="pdf"}
En la versión web, este laboratorio recalcula la matriz de confusión, las métricas y las curvas al mover el umbral. Valores de referencia con los veinte pacientes y $\theta = 0.5$: VP = 4, FN = 1, FP = 2, VN = 13; exactitud 0.850, sensibilidad 0.800, especificidad 0.867, precisión 0.667, VPN 0.929, F1 0.727, exactitud balanceada 0.833, MCC 0.630 y AUC 0.893. Con $\theta = 0.30$ la sensibilidad es 1 y la precisión 0.5. En el modelo binormal con separación $d = 1.5$ el AUC es $\Phi(d/\sqrt{2}) \approx 0.856$; con prevalencia 0.10 y cociente de costos 10, el umbral de mínimo costo es $1/11 \approx 0.09$.
:::
**Guía del laboratorio.**
1. Con *Veinte pacientes* y $\theta = 0.50$, comprueba la @tbl-matriz-050 y las métricas de los pasos 2 a 4. ¿Dónde está el punto naranja sobre la curva ROC?
2. Predice antes de mover: si bajas el umbral a 0.30, ¿qué celda de la matriz se vacía? ¿Qué le pasa a la precisión? Compruébalo con la @tbl-umbrales.
3. Recorre el umbral de 0.99 a 0.01 y observa cómo el punto recorre la escalera de la curva ROC. ¿Por qué el AUC no cambia?
4. Cambia a *Modelo binormal*. Con $d = 1.5$ y $\theta = 0.5$, baja la prevalencia de 0.10 a 0.01. ¿Qué curva cambia y cuál no? ¿Qué le pasa a la precisión en el umbral actual?
5. Con prevalencia 0.01, compara la exactitud del modelo con la del clasificador trivial. ¿Qué métrica te avisa del problema?
6. Pulsa *Ir al umbral de mínimo costo* con cocientes de costo 1, 10 y 50. Mueve el umbral un poco a cada lado y verifica que el costo sube.
7. Pon $d = 0$. ¿Qué forma tienen las curvas? ¿Qué AUC obtienes y por qué?
## Cómo elegir la métrica
La métrica se elige **antes** de entrenar, a partir del problema y no de los resultados:
| Situación | Métrica principal | Complemento |
|---|---|---|
| Clases balanceadas y errores con costo similar | Exactitud | Matriz de confusión |
| Positivo raro y lo importante es detectarlo (cribado médico, fraude) | Sensibilidad a una precisión mínima, $F_2$ | Curva PR, AP |
| Las falsas alarmas son caras (bloquear clientes, spam) | Precisión a una sensibilidad mínima, $F_{0.5}$ | Curva PR |
| Desbalance y se quiere un resumen único y simétrico | MCC o exactitud balanceada | Matriz de confusión |
| Comparar la capacidad de ordenar, sin fijar umbral | AUC-ROC | AP si el positivo es raro |
| Costos conocidos | Costo esperado en el umbral $\theta^{*}$ | Curva de costo vs. umbral |
| La probabilidad es el producto (riesgo, tarifas) | Log-loss o Brier | Diagrama de calibración |
| Multiclase con clases igualmente importantes | F1 macro | Matriz $K \times K$ y F1 por clase |
: Guía para elegir métricas. {#tbl-eleccion-metrica}
Errores frecuentes:
- **Reportar solo la exactitud** con clases desbalanceadas.
- **Elegir el umbral mirando el conjunto de prueba.** El umbral es un hiperparámetro: se elige con validación, como se explica en el capítulo siguiente.
- **Comparar precisiones entre poblaciones** con prevalencias distintas (@eq-vpp-prevalencia).
- **Confundir buen ranking con buenas probabilidades.** Un AUC alto no garantiza que «0.8» signifique 80 %.
- **Promediar sin mirar las clases.** Un F1 macro aceptable puede ocultar una clase con F1 cercano a cero; reporta siempre las métricas por clase.
## Implementación en Python
### El ejemplo manual con `scikit-learn`
El módulo `sklearn.metrics` tiene una función para cada métrica. Reproducimos los números de la sección anterior.
```{python}
#| label: met-manual
import numpy as np
import pandas as pd
from sklearn.metrics import (confusion_matrix, accuracy_score, recall_score, precision_score,
f1_score, balanced_accuracy_score, matthews_corrcoef, roc_auc_score,
average_precision_score, log_loss, brier_score_loss)
puntaje = np.array([0.95, 0.85, 0.70, 0.55, 0.30, # enfermos
0.80, 0.60, 0.45, 0.40, 0.35, 0.25, 0.20, 0.18, # sanos
0.15, 0.12, 0.10, 0.08, 0.05, 0.04, 0.02])
real = np.array([1] * 5 + [0] * 15)
pred = (puntaje >= 0.5).astype(int)
vn, fp, fn, vp = confusion_matrix(real, pred).ravel()
print(f"VP={vp} FN={fn} FP={fp} VN={vn}")
pd.Series({
"exactitud": accuracy_score(real, pred),
"sensibilidad": recall_score(real, pred),
"especificidad": recall_score(real, pred, pos_label=0),
"precisión": precision_score(real, pred),
"VPN": precision_score(real, pred, pos_label=0),
"F1": f1_score(real, pred),
"exactitud balanceada": balanced_accuracy_score(real, pred),
"MCC": matthews_corrcoef(real, pred),
"AUC": roc_auc_score(real, puntaje),
"AP": average_precision_score(real, puntaje),
"log-loss": log_loss(real, puntaje),
"Brier": brier_score_loss(real, puntaje),
}).round(3)
```
Observa que la especificidad y el VPN no tienen función propia: son la sensibilidad y la precisión de la clase negativa (`pos_label=0`). El costo por umbral de la @tbl-costos:
```{python}
#| label: met-costos
umbrales = [0.90, 0.75, 0.50, 0.30, 0.10]
filas = []
for t in umbrales:
vn_, fp_, fn_, vp_ = confusion_matrix(real, (puntaje >= t).astype(int)).ravel()
filas.append({"umbral": t, "VP": vp_, "FP": fp_, "FN": fn_, "costo (10·FN + FP)": 10 * fn_ + fp_})
pd.DataFrame(filas)
```
### Un caso realista: cribado con 8 % de prevalencia
Simulamos un problema de cribado médico con 3000 pacientes y una prevalencia cercana al 8 %, y comparamos una regresión logística con un bosque aleatorio. Separamos tres conjuntos: **entrenamiento** para ajustar los modelos, **validación** para elegir el umbral y **prueba** para la evaluación final.
```{python}
#| label: met-datos
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
X, y = make_classification(n_samples=3000, n_features=12, n_informative=6, n_redundant=2,
weights=[0.92, 0.08], flip_y=0.01, class_sep=1.0, random_state=11)
X_tmp, X_te, y_tmp, y_te = train_test_split(X, y, test_size=0.3, stratify=y, random_state=0)
X_tr, X_va, y_tr, y_va = train_test_split(X_tmp, y_tmp, test_size=0.3, stratify=y_tmp, random_state=0)
print(f"Entrenamiento: {len(y_tr)} (positivos {y_tr.mean():.1%}) · validación: {len(y_va)} · prueba: {len(y_te)}")
modelos = {
"Regresión logística": make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000)),
"Bosque aleatorio": RandomForestClassifier(n_estimators=300, min_samples_leaf=3, random_state=0),
}
proba_va, proba_te = {}, {}
for nombre, m in modelos.items():
m.fit(X_tr, y_tr)
proba_va[nombre], proba_te[nombre] = m.predict_proba(X_va)[:, 1], m.predict_proba(X_te)[:, 1]
```
Con el umbral por defecto (0.5), ambos modelos tienen exactitud alta y sensibilidad modesta, el síntoma clásico del desbalance:
```{python}
#| label: met-reporte
from sklearn.metrics import classification_report
for nombre, p in proba_te.items():
print(f"── {nombre} (θ = 0.5) ──")
print(classification_report(y_te, (p >= 0.5).astype(int), target_names=["sano", "enfermo"], digits=3))
print(f"Exactitud del clasificador trivial: {1 - y_te.mean():.3f}")
```
```{python}
#| label: fig-met-curvas
#| fig-cap: "Curvas ROC (izquierda) y Precision–Recall (derecha) en el conjunto de prueba. En ROC ambos modelos superan 0.9 de AUC; la curva PR, cuya referencia es la prevalencia (línea discontinua), muestra cuánto cuesta en precisión recuperar a los últimos enfermos."
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from sklearn.metrics import roc_curve, precision_recall_curve
fig = make_subplots(rows=1, cols=2, subplot_titles=("ROC", "Precision–Recall"))
colores = {"Regresión logística": "#2563eb", "Bosque aleatorio": "#16a34a"}
for nombre, p in proba_te.items():
fpr, tpr, _ = roc_curve(y_te, p)
pr, rc, _ = precision_recall_curve(y_te, p)
fig.add_trace(go.Scatter(x=fpr, y=tpr, name=f"{nombre} (AUC {roc_auc_score(y_te, p):.3f})",
line=dict(color=colores[nombre])), row=1, col=1)
fig.add_trace(go.Scatter(x=rc, y=pr, name=f"{nombre} (AP {average_precision_score(y_te, p):.3f})",
line=dict(color=colores[nombre], dash="dot")), row=1, col=2)
fig.add_trace(go.Scatter(x=[0, 1], y=[0, 1], line=dict(color="gray", dash="dash"), showlegend=False), row=1, col=1)
fig.add_trace(go.Scatter(x=[0, 1], y=[y_te.mean()] * 2, line=dict(color="gray", dash="dash"), showlegend=False), row=1, col=2)
fig.update_xaxes(title_text="FPR", row=1, col=1); fig.update_yaxes(title_text="TPR", row=1, col=1)
fig.update_xaxes(title_text="recall", row=1, col=2); fig.update_yaxes(title_text="precisión", row=1, col=2)
fig.update_layout(height=400, margin=dict(l=10, r=10, t=40, b=40), legend=dict(orientation="h", y=-0.25))
fig.show()
```
### Elegir el umbral con validación
Supongamos que un enfermo no detectado cuesta diez veces más que una falsa alarma. Elegimos el umbral que minimiza el costo **en validación** y lo aplicamos, sin tocarlo, en prueba:
```{python}
#| label: fig-met-umbral
#| fig-cap: "Costo total en el conjunto de validación (10·FN + FP) según el umbral, para la regresión logística. El mínimo está muy por debajo de 0.5; la línea punteada es el umbral teórico 1/11 para probabilidades calibradas."
ths = np.linspace(0.01, 0.9, 90)
nombre = "Regresión logística"
costo_va = [10 * ((proba_va[nombre] < t) & (y_va == 1)).sum() + ((proba_va[nombre] >= t) & (y_va == 0)).sum() for t in ths]
t_opt = ths[int(np.argmin(costo_va))]
fig = go.Figure(go.Scatter(x=ths, y=costo_va, line=dict(color="#7c3aed")))
fig.add_vline(x=t_opt, line=dict(color="#ea580c"), annotation_text=f"mínimo en validación: {t_opt:.2f}")
fig.add_vline(x=1 / 11, line=dict(color="gray", dash="dot"))
fig.add_vline(x=0.5, line=dict(color="gray", dash="dash"), annotation_text="0.5", annotation_position="bottom right")
fig.update_layout(xaxis_title="umbral θ", yaxis_title="costo en validación", height=360, margin=dict(l=10, r=10, t=20, b=40))
fig.show()
```
```{python}
#| label: met-umbral-prueba
def resumen(p, t):
pr = (p >= t).astype(int)
vn_, fp_, fn_, vp_ = confusion_matrix(y_te, pr).ravel()
return {"umbral": round(t, 2), "sensibilidad": recall_score(y_te, pr), "precisión": precision_score(y_te, pr),
"MCC": matthews_corrcoef(y_te, pr), "costo en prueba": 10 * fn_ + fp_}
pd.DataFrame([resumen(proba_te[nombre], 0.5), resumen(proba_te[nombre], t_opt)]).round(3)
```
El umbral elegido en validación reduce el costo en prueba alrededor de un 30 % respecto a 0.5, a cambio de una precisión menor: es exactamente el compromiso que se decidió al fijar los costos.
### Métricas multiclase
`classification_report` calcula precisión, sensibilidad y F1 por clase con sus promedios macro y ponderado. Reproducimos la @tbl-multiclase:
```{python}
#| label: met-multiclase
M = np.array([[40, 5, 5], [10, 15, 5], [2, 3, 15]])
y_real = np.repeat(np.repeat(["A", "B", "C"], 3), M.ravel())
y_pred = np.repeat(np.tile(["A", "B", "C"], 3), M.ravel())
print(classification_report(y_real, y_pred, digits=3))
print("F1 micro:", round(f1_score(y_real, y_pred, average="micro"), 3))
```
## Resumen
- La **matriz de confusión** es la base de todas las métricas; cada métrica es un cociente con un denominador distinto: por fila (sensibilidad, especificidad) o por columna (precisión, VPN).
- La **exactitud** engaña con clases desbalanceadas; la exactitud balanceada, F1 y sobre todo el **MCC** son resúmenes más robustos.
- La **precisión depende de la prevalencia**: la misma prueba produce muchas más falsas alarmas en una población donde el positivo es raro.
- Las curvas **ROC** y **PR** recorren todos los umbrales. El AUC mide la capacidad de **ordenar** y equivale a la probabilidad de que un positivo tenga mayor puntaje que un negativo; la curva PR revela problemas que la ROC oculta cuando el positivo es raro.
- El **umbral** es una decisión: con costos $C_{FP}$ y $C_{FN}$ y probabilidades calibradas, el óptimo es $C_{FP}/(C_{FP} + C_{FN})$. Se elige con validación, nunca con la prueba.
- Con varias clases, las métricas se promedian (macro, ponderado, micro); cuando la probabilidad es el producto, se evalúa con log-loss o Brier.
[^armonica]: La **media armónica** de $a$ y $b$ es $2ab/(a + b)$. Siempre es menor o igual que la media aritmética y se acerca al menor de los dos valores: con precisión 0.99 y sensibilidad 0.01, la media aritmética es 0.5 pero la armónica es 0.02. Así, F1 solo es alto si ambas métricas lo son.
[^calibrada]: Un puntaje está **calibrado** si, entre todos los casos con puntaje 0.3, alrededor del 30 % son positivos. La regresión logística suele estar razonablemente calibrada; los bosques aleatorios, Naive Bayes y las máquinas de soporte vectorial, no. El capítulo siguiente muestra cómo diagnosticarlo y corregirlo.
[^bayes-vpp]: El **teorema de Bayes** permite «invertir» una probabilidad condicional: $P(\text{enfermo} \mid +) = \frac{P(+ \mid \text{enfermo})\,P(\text{enfermo})}{P(+)}$. Aquí $P(+ \mid \text{enfermo})$ es la sensibilidad, $P(\text{enfermo}) = \pi$, y el denominador suma las dos maneras de obtener un positivo: ser enfermo y detectado, o ser sano y recibir una falsa alarma.
## Referencias {.unnumbered}
::: {#refs}
:::