4.1 - One-vs-Rest, One-vs-One y softmax para el triaje de urgencias¶
En el capítulo 4, el equipo de Minermont quiere sugerir el nivel de urgencia de cada paciente que llega a urgencias, del 1 (rojo, inmediato) al 5 (azul, no urgente). Las herramientas que ya tienen, la regresión logística y las SVM, responden sí o no. Alma explica las dos formas habituales de construir un clasificador de cinco clases a partir de clasificadores binarios, One-vs-Rest (OvR) y One-vs-One (OvO), y el equipo plantea sus puntos débiles: el desbalanceo de "Nivel 1 frente al resto", la ambigüedad cuando ningún clasificador está seguro y los empates en la votación. Carmen concluye que deberían buscar un enfoque que considere todas las clases a la vez. Para los modelos lineales, ese enfoque es la función softmax, la generalización de la sigmoide a varias clases.
En este cuaderno construimos los tres desde cero, con NumPy, y los comparamos con los mismos pacientes:
- Importar las bibliotecas.
- Los datos de triaje.
- La pieza básica: la regresión logística binaria.
- One-vs-Rest: cinco clasificadores.
- La ambigüedad de One-vs-Rest.
- One-vs-One: diez clasificadores y una votación.
- Regresión softmax: un solo modelo para los cinco niveles.
- Comparación: un paciente, tres respuestas.
Paso 1: Importar las bibliotecas¶
Los algoritmos se escriben con NumPy. scikit-learn solo se usa para dividir los datos y, al final del paso 7, para comprobar nuestra regresión softmax con su LogisticRegression.
import itertools
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import ConfusionMatrixDisplay
Paso 2: Los datos de triaje¶
Los registros de urgencias de un hospital real son confidenciales, así que generamos pacientes sintéticos que se comportan como los del libro. La siguiente celda contiene el generador. No hace falta seguirlo línea a línea: lo importante es que el nivel de urgencia sale de un protocolo oculto (una puntuación de alerta temprana basada en los signos vitales, más algunas señales de alarma clínicas) y del criterio de la propia enfermera, que a veces mueve el nivel un escalón. Los modelos nunca ven esa regla: tienen que descubrirla a partir de los ejemplos.
def generate_patients(n, seed):
"""Pacientes sintéticos de urgencias de Minermont: una fila por paciente, con el nivel de urgencia asignado en el triaje."""
rng = np.random.default_rng(seed)
age = np.clip(np.round(np.where(rng.random(n) < 0.12, rng.uniform(1, 17, n), rng.normal(52, 20, n))), 1, 97)
male = rng.binomial(1, 0.5, n)
s = rng.gamma(0.9, 0.55, n) # gravedad oculta del episodio (el modelo nunca la ve)
infection = rng.binomial(1, 0.12 + 0.1 * (s > 1), n)
trauma = rng.binomial(1, 0.2, n)
chest_pain = rng.binomial(1, np.clip(0.04 + 0.0025 * age, 0, 0.35), n)
dyspnea = rng.binomial(1, np.clip(0.06 + 0.25 * s, 0, 0.9), n)
vomiting = rng.binomial(1, 0.12, n)
shock = (rng.random(n) < 0.35) & (s > 1.2)
hr = 76 + 16 * s + 12 * infection + rng.normal(0, 10, n)
brady = (rng.random(n) < 0.08) & (s > 1.3) # algunos pacientes graves tienen el pulso muy lento
hr = np.where(brady, rng.normal(42, 6, n), hr)
rr = 15 + 4.5 * s + 3 * dyspnea + rng.normal(0, 2.2, n)
spo2 = np.clip(98.2 - 2.2 * s * (0.5 + dyspnea) + rng.normal(0, 1.1, n), 70, 100)
sbp = np.clip(128 + 0.25 * (age - 50) + rng.normal(0, 16, n) - 28 * s * shock, 60, 230)
temp = 36.8 + rng.normal(0, 0.35, n) + infection * rng.normal(1.6, 0.6, n)
p_consc = np.clip(0.02 * s ** 2.2, 0, 0.9)
consc = np.where(rng.random(n) < p_consc, rng.choice([1, 2, 3], n, p=[0.55, 0.3, 0.15]), 0)
pain = np.clip(np.round(rng.normal(2.5 + 3.5 * trauma + 3 * chest_pain + 0.8 * s, 1.8, n)), 0, 10).astype(int)
hr, rr, sbp = np.round(hr).astype(int), np.round(rr).astype(int), np.round(sbp).astype(int)
temp, spo2 = np.round(temp, 1), np.round(spo2).astype(int)
# El protocolo oculto detrás de las etiquetas, que solo usamos para generar los datos:
# una puntuación de alerta temprana en la que un signo vital suma puntos si es demasiado bajo O demasiado alto...
score = (np.select([rr <= 8, rr <= 11, rr <= 20, rr <= 24], [3, 1, 0, 2], 3)
+ np.select([spo2 <= 91, spo2 <= 93, spo2 <= 95], [3, 2, 1], 0)
+ np.select([sbp <= 90, sbp <= 100, sbp <= 110, sbp <= 219], [3, 2, 1, 0], 3)
+ np.select([hr <= 40, hr <= 50, hr <= 90, hr <= 110, hr <= 130], [3, 1, 0, 1, 2], 3)
+ np.where(consc > 0, 3, 0)
+ np.select([temp <= 35, temp <= 36, temp <= 38, temp <= 39], [3, 1, 0, 1], 2))
# ...más discriminadores clínicos, del nivel menos urgente al más urgente
level = np.full(n, 5)
level = np.where((score >= 1) | (pain >= 4) | (trauma == 1), 4, level)
level = np.where((score >= 4) | (pain >= 7) | (temp >= 39) | ((chest_pain == 1) & (age < 45)), 3, level)
level = np.where((score >= 6) | ((chest_pain == 1) & (age >= 45)) | (spo2 <= 91), 2, level)
level = np.where((score >= 9) | (consc >= 2) | (sbp <= 80), 1, level)
# y el criterio de la propia enfermera, que a veces mueve el nivel un escalón
level = np.clip(level + rng.choice([-1, 0, 1], n, p=[0.05, 0.9, 0.05]), 1, 5)
return pd.DataFrame({
'edad': age.astype(int), 'hombre': male, 'presion_sistolica': sbp, 'frec_cardiaca': hr, 'frec_respiratoria': rr,
'spo2': spo2, 'temperatura': temp, 'consciencia': consc, 'dolor': pain, 'dolor_toracico': chest_pain,
'disnea': dyspnea, 'traumatismo': trauma, 'vomitos': vomiting, 'nivel': level,
})
| Columna | Significado |
|---|---|
edad |
Edad en años (1–97; aproximadamente uno de cada ocho pacientes es un niño) |
hombre |
1 si el paciente es hombre, 0 si es mujer |
presion_sistolica |
Presión arterial sistólica (mmHg) |
frec_cardiaca |
Frecuencia cardiaca (latidos por minuto) |
frec_respiratoria |
Frecuencia respiratoria (respiraciones por minuto) |
spo2 |
Saturación de oxígeno (%) |
temperatura |
Temperatura (°C) |
consciencia |
Nivel de consciencia: 0 alerta, 1 responde a la voz, 2 responde solo al dolor, 3 no responde |
dolor |
Dolor referido por el paciente (0–10) |
dolor_toracico, disnea, traumatismo, vomitos |
Síntomas de consulta: dolor torácico, dificultad para respirar, traumatismo, vómitos (1 = sí) |
nivel |
Objetivo: nivel de urgencia, de 1 (rojo, inmediato) a 5 (azul, no urgente) |
Usamos los mismos 4.000 pacientes y la misma división que los demás cuadernos de este capítulo. Como en el capítulo 3, las características se estandarizan con la media y la desviación típica del conjunto de entrenamiento.
df = generate_patients(4000, seed=42)
feature_names = [c for c in df.columns if c != 'nivel']
level_names = ['1 Rojo', '2 Naranja', '3 Amarillo', '4 Verde', '5 Azul']
X = df[feature_names]
y = df['nivel']
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=42, stratify=y)
print(f"Entrenamiento: {len(y_train)} pacientes, prueba: {len(y_test)} pacientes\n")
print("Pacientes por nivel de urgencia:")
print(pd.DataFrame({'pacientes': y.value_counts().sort_index(),
'proporción': y.value_counts(normalize=True).sort_index().round(3)},).set_axis(level_names).to_string())
Entrenamiento: 3000 pacientes, prueba: 1000 pacientes
Pacientes por nivel de urgencia:
pacientes proporción
1 Rojo 158 0.040
2 Naranja 530 0.132
3 Amarillo 780 0.195
4 Verde 1671 0.418
5 Azul 861 0.215
mean, std = X_train.mean(), X_train.std()
Z_train = ((X_train - mean) / std).to_numpy()
Z_test = ((X_test - mean) / std).to_numpy()
y_tr, y_te = y_train.to_numpy(), y_test.to_numpy()
levels = np.arange(1, 6)
print(f"Características: {Z_train.shape[1]}, niveles: {len(levels)}")
Características: 13, niveles: 5
Paso 3: La pieza básica: la regresión logística binaria¶
OvR y OvO reutilizan un clasificador binario. Tomamos la regresión logística del capítulo 3: una puntuación lineal $z = \mathbf{w} \cdot \mathbf{x} + b$, la sigmoide $\sigma(z) = \frac{1}{1 + e^{-z}}$ para convertirla en una probabilidad y el descenso del gradiente sobre la pérdida logarítmica. También escribimos una pequeña función que mide, para cada nivel, la proporción de sus pacientes que el modelo asigna al nivel correcto (la sensibilidad o recall de cada clase).
def sigmoid(z):
return 1 / (1 + np.exp(-z))
def train_logistic(Z, t, learning_rate=1.0, iterations=5000):
"""Regresión logística binaria por descenso del gradiente. t vale 1 para la clase positiva y 0 para la negativa."""
w, b = np.zeros(Z.shape[1]), 0.0
for _ in range(iterations):
error = sigmoid(Z @ w + b) - t
w -= learning_rate * Z.T @ error / len(t)
b -= learning_rate * error.mean()
return w, b
def recall_per_level(y_true, y_pred):
return pd.Series([np.mean(y_pred[y_true == k] == k) for k in levels], index=level_names)
Paso 4: One-vs-Rest: cinco clasificadores¶
Como resume Marta, con cinco niveles OvR entrena cinco modelos: Nivel 1 frente a (2, 3, 4, 5), Nivel 2 frente a (1, 3, 4, 5), y así sucesivamente. Cada uno usa todos los pacientes de entrenamiento, etiquetados con 1 si pertenecen a su nivel y con 0 si no. Para predecir, los cinco clasificadores puntúan al paciente y gana el nivel cuyo clasificador da la puntuación más alta.
ovr_models = {}
for k in levels:
t = (y_tr == k).astype(float) # nivel k = 1, cualquier otro nivel = 0
ovr_models[k] = train_logistic(Z_train, t)
print(f"{level_names[k - 1]:<10} frente al resto: {t.mean():5.1%} de los {len(t)} pacientes de entrenamiento son positivos")
ovr_scores = np.column_stack([sigmoid(Z_test @ w + b) for w, b in ovr_models.values()])
ovr_pred = levels[ovr_scores.argmax(axis=1)] # el nivel cuyo clasificador está más seguro
print(f"\nExactitud de OvR en el conjunto de prueba: {np.mean(ovr_pred == y_te):.3f}")
print("Sensibilidad por nivel:")
print(recall_per_level(y_te, ovr_pred).round(3).to_string())
1 Rojo frente al resto: 3.9% de los 3000 pacientes de entrenamiento son positivos
2 Naranja frente al resto: 13.3% de los 3000 pacientes de entrenamiento son positivos
3 Amarillo frente al resto: 19.5% de los 3000 pacientes de entrenamiento son positivos
4 Verde frente al resto: 41.8% de los 3000 pacientes de entrenamiento son positivos
5 Azul frente al resto: 21.5% de los 3000 pacientes de entrenamiento son positivos Exactitud de OvR en el conjunto de prueba: 0.625 Sensibilidad por nivel: 1 Rojo 0.700 2 Naranja 0.735 3 Amarillo 0.221 4 Verde 0.813 5 Azul 0.544
La aguja en el pajar de Víctor está ahí: solo el 3,9% de los pacientes son positivos para "Nivel 1 frente al resto". Aun así, OvR encuentra el 0,700 de los pacientes rojos. La verdadera víctima es el nivel 3: solo el 0,221 de los pacientes amarillos reciben su nivel. Una puntuación lineal puede separar "más urgente" de "menos urgente", pero "Nivel 3 frente al resto" le pide separar una franja del medio de pacientes situados a ambos lados, más urgentes (niveles 1 y 2) y menos urgentes (4 y 5). El clasificador del nivel 3 rara vez gana, y la matriz de confusión del paso 8 muestra adónde van sus pacientes: 108 de los 195 pacientes amarillos del conjunto de prueba acaban en verde. La exactitud global es 0,625.
Paso 5: La ambigüedad de One-vs-Rest¶
La duda de Javier: "¿Qué hacemos si ningún clasificador da una confianza alta, o si dos la dan muy similar?". Los cinco clasificadores se entrenan por separado, así que nada hace que sus puntuaciones sumen 1 ni que sean comparables entre sí. Miramos las puntuaciones de los cinco primeros pacientes de prueba y contamos los casos dudosos.
first = pd.DataFrame(ovr_scores[:5], columns=level_names).round(3)
first['suma'] = ovr_scores[:5].sum(axis=1).round(3)
first['nivel real'] = y_te[:5]
print(first.to_string(), "\n")
best, second = np.sort(ovr_scores, axis=1)[:, ::-1][:, :2].T
print(f"Suma de las cinco puntuaciones: de {ovr_scores.sum(axis=1).min():.3f} a {ovr_scores.sum(axis=1).max():.3f}")
print(f"Pacientes para los que ningún clasificador llega a 0,5: {np.sum(best < 0.5)} de {len(y_te)}")
print(f"Pacientes cuyas dos puntuaciones más altas se diferencian en menos de 0,1: {np.sum(best - second < 0.1)}")
print("Puntuación más alta que da cada clasificador a algún paciente de prueba:")
print(pd.Series(ovr_scores.max(axis=0), index=level_names).round(3).to_string())
1 Rojo 2 Naranja 3 Amarillo 4 Verde 5 Azul suma nivel real 0 0.002 0.222 0.028 0.224 0.780 1.256 2 1 0.015 0.005 0.059 0.459 0.648 1.186 4 2 0.005 0.011 0.074 0.506 0.502 1.098 5 3 0.112 0.799 0.166 0.011 0.000 1.088 2 4 0.004 0.006 0.243 0.739 0.037 1.028 3 Suma de las cinco puntuaciones: de 0.352 a 2.417 Pacientes para los que ningún clasificador llega a 0,5: 338 de 1000 Pacientes cuyas dos puntuaciones más altas se diferencian en menos de 0,1: 211 Puntuación más alta que da cada clasificador a algún paciente de prueba: 1 Rojo 1.000 2 Naranja 0.996 3 Amarillo 0.886 4 Verde 0.801 5 Azul 0.963
Las cinco puntuaciones de un paciente suman cualquier cosa entre 0,352 y 2,417: son cinco opiniones separadas, no una distribución de probabilidad. Para 338 de los 1.000 pacientes de prueba ningún clasificador llega a 0,5, y para 211 las dos puntuaciones más altas se diferencian en menos de 0,1, como el tercer paciente de la tabla, 0,506 para verde y 0,502 para azul (el nivel real es azul). Los niveles del medio son además los menos seguros: la puntuación más alta que da el clasificador del nivel 3 a algún paciente es 0,886, y el del nivel 4, 0,801, mientras que los clasificadores rojo y naranja llegan a 1,000 y 0,996. La puntuación del quinto paciente, que es amarillo, es 0,243 frente a 0,739 para verde. Es lo que el libro llama una calibración inconsistente entre clasificadores, y la razón por la que a Javier no le convence: en triaje la respuesta tiene que ser clara.
Paso 6: One-vs-One: diez clasificadores y una votación¶
OvO entrena un clasificador para cada par de niveles, $\frac{K(K-1)}{2} = \frac{5 \times 4}{2} = 10$, cada uno solo con los pacientes de sus dos niveles. Para predecir, cada clasificador vota por uno de sus dos niveles y gana el nivel con más votos. Luis pregunta qué ocurre si hay un empate. La regla del libro para la regresión logística: elegir, entre los niveles empatados, el que tenga la mayor confianza media en sus victorias.
ovo_models = {}
for i, j in itertools.combinations(levels, 2):
in_pair = (y_tr == i) | (y_tr == j) # solo los pacientes de estos dos niveles
ovo_models[(i, j)] = train_logistic(Z_train[in_pair], (y_tr[in_pair] == i).astype(float))
smaller = min(np.sum(y_tr == i), np.sum(y_tr == j)) / in_pair.sum()
print(f"Nivel {i} frente a nivel {j}: {in_pair.sum():>4} pacientes, el nivel menos frecuente es el {smaller:5.1%}")
votes = np.zeros((len(y_te), 5))
confidence = np.zeros((len(y_te), 5)) # confianza sumada de las victorias de cada nivel
for (i, j), (w, b) in ovo_models.items():
p = sigmoid(Z_test @ w + b) # probabilidad del nivel i frente al nivel j
i_wins = p >= 0.5
votes[i_wins, i - 1] += 1
votes[~i_wins, j - 1] += 1
confidence[i_wins, i - 1] += p[i_wins]
confidence[~i_wins, j - 1] += 1 - p[~i_wins]
most_votes = votes == votes.max(axis=1, keepdims=True)
tied = most_votes.sum(axis=1) > 1
average_confidence = confidence / np.maximum(votes, 1)
ovo_pred = levels[np.where(most_votes, average_confidence, -1).argmax(axis=1)] # regla de desempate del libro
print(f"\nPacientes con un empate en el máximo de votos: {tied.sum()} de {len(y_te)}")
print(f"Exactitud de OvO en el conjunto de prueba: {np.mean(ovo_pred == y_te):.3f}")
print("Sensibilidad por nivel:")
print(recall_per_level(y_te, ovo_pred).round(3).to_string())
Nivel 1 frente a nivel 2: 516 pacientes, el nivel menos frecuente es el 22.9% Nivel 1 frente a nivel 3: 703 pacientes, el nivel menos frecuente es el 16.8%
Nivel 1 frente a nivel 4: 1371 pacientes, el nivel menos frecuente es el 8.6%
Nivel 1 frente a nivel 5: 764 pacientes, el nivel menos frecuente es el 15.4% Nivel 2 frente a nivel 3: 983 pacientes, el nivel menos frecuente es el 40.5%
Nivel 2 frente a nivel 4: 1651 pacientes, el nivel menos frecuente es el 24.1% Nivel 2 frente a nivel 5: 1044 pacientes, el nivel menos frecuente es el 38.1%
Nivel 3 frente a nivel 4: 1838 pacientes, el nivel menos frecuente es el 31.8% Nivel 3 frente a nivel 5: 1231 pacientes, el nivel menos frecuente es el 47.5%
Nivel 4 frente a nivel 5: 1899 pacientes, el nivel menos frecuente es el 34.0% Pacientes con un empate en el máximo de votos: 5 de 1000 Exactitud de OvO en el conjunto de prueba: 0.660 Sensibilidad por nivel: 1 Rojo 0.725 2 Naranja 0.720 3 Amarillo 0.472 4 Verde 0.763 5 Azul 0.581
Cada par usa menos pacientes, de 516 a 1.899, y la mayoría de los pares están más equilibrados que "Nivel 1 frente al resto": en "Nivel 1 frente a nivel 2" los pacientes rojos son el 22,9% en lugar del 3,9%. El par más desbalanceado, 1 frente a 4, todavía tiene un 8,6%. Los empates son raros, 5 de los 1.000 pacientes de prueba, así que aquí la pregunta de Luis tiene una respuesta sencilla. OvO sube la exactitud a 0,660 y, sobre todo, la sensibilidad del nivel 3 de 0,221 a 0,472: "Nivel 2 frente a nivel 3" y "Nivel 3 frente a nivel 4" son problemas que una puntuación lineal sí puede resolver, porque cada uno separa dos niveles vecinos.
Paso 7: Regresión softmax: un solo modelo para los cinco niveles¶
En lugar de cinco o diez clasificadores separados, un único modelo lineal da a cada paciente cinco puntuaciones a la vez, $z_k = \mathbf{w}_k \cdot \mathbf{x} + b_k$, una por nivel, y la función softmax las convierte en una distribución de probabilidad:
$$\text{Softmax}(z_k) = \frac{e^{z_k}}{\sum_{j=1}^{5} e^{z_j}}$$
Las cinco probabilidades son positivas y suman 1, y los niveles compiten entre sí: subir una puntuación baja la probabilidad de todos los demás. Se entrena, como la capa de salida de las redes neuronales del libro, minimizando la entropía cruzada categórica, el logaritmo negativo de la probabilidad asignada al nivel real:
$$L = -\frac{1}{m} \sum_{i=1}^{m} \log \hat{y}_{i,\,\text{nivel real}}$$
Su gradiente respecto a las puntuaciones tiene la misma forma que en el caso binario: probabilidad menos etiqueta, $\hat{\mathbf{y}} - \mathbf{y}$, donde $\mathbf{y}$ es el nivel real escrito como un vector one-hot (el nivel 3 es $[0, 0, 1, 0, 0]$). Con dos niveles, la regresión softmax es exactamente la regresión logística.
def softmax(scores):
scores = scores - scores.max(axis=1, keepdims=True) # las mismas probabilidades, sin desbordar exp
e = np.exp(scores)
return e / e.sum(axis=1, keepdims=True)
def cross_entropy(P, y):
return -np.mean(np.log(P[np.arange(len(y)), y - 1]))
Y_train = np.eye(5)[y_tr - 1] # one-hot: nivel 3 -> [0, 0, 1, 0, 0]
W, b = np.zeros((Z_train.shape[1], 5)), np.zeros(5) # una columna de pesos por nivel
history = []
for iteration in range(5000):
P = softmax(Z_train @ W + b)
error = P - Y_train # gradiente de la entropía cruzada respecto a las puntuaciones
W -= 1.0 * Z_train.T @ error / len(y_tr)
b -= 1.0 * error.mean(axis=0)
history.append(cross_entropy(P, y_tr))
softmax_probs = softmax(Z_test @ W + b)
softmax_pred = levels[softmax_probs.argmax(axis=1)]
print(f"Entropía cruzada: {history[0]:.4f} al principio, {history[-1]:.5f} tras {len(history)} iteraciones")
print(f"Suma de las cinco probabilidades: de {softmax_probs.sum(axis=1).min():.6f} a {softmax_probs.sum(axis=1).max():.6f}")
print(f"Exactitud de softmax en el conjunto de prueba: {np.mean(softmax_pred == y_te):.3f}")
print("Sensibilidad por nivel:")
print(recall_per_level(y_te, softmax_pred).round(3).to_string())
# Comprobación: LogisticRegression de scikit-learn usa softmax con varias clases (C muy grande = sin regularización)
reference = LogisticRegression(C=1e6, max_iter=5000).fit(Z_train, y_tr)
print(f"\nscikit-learn: entropía cruzada {cross_entropy(reference.predict_proba(Z_train), y_tr):.5f}, "
f"exactitud {reference.score(Z_test, y_te):.3f}, "
f"mayor diferencia en una probabilidad {np.abs(reference.predict_proba(Z_test) - softmax_probs).max():.4f}")
plt.figure(figsize=(8, 3.5))
plt.plot(np.arange(1, len(history) + 1), history)
plt.xscale('log'); plt.xlabel('iteración'); plt.ylabel('entropía cruzada (entrenamiento)'); plt.grid(True)
plt.title('Entrenamiento de la regresión softmax'); plt.show()
Entropía cruzada: 1.6094 al principio, 0.79455 tras 5000 iteraciones Suma de las cinco probabilidades: de 1.000000 a 1.000000 Exactitud de softmax en el conjunto de prueba: 0.657 Sensibilidad por nivel: 1 Rojo 0.750 2 Naranja 0.735 3 Amarillo 0.446 4 Verde 0.768 5 Azul 0.567 scikit-learn: entropía cruzada 0.79452, exactitud 0.657, mayor diferencia en una probabilidad 0.0142
La entropía cruzada empieza en 1,6094, que es $\ln 5$: con todos los pesos a cero, el modelo da 0,2 a cada nivel. Baja hasta 0,79455. Las cinco probabilidades de cada paciente de prueba suman exactamente 1. Con un solo modelo, softmax llega a una exactitud de 0,657, cerca del 0,660 de OvO, y a una sensibilidad del nivel 3 de 0,446. LogisticRegression de scikit-learn, que también usa softmax cuando hay varias clases, llega a una entropía cruzada de 0,79452 y a la misma exactitud, 0,657. Sus probabilidades y las nuestras se diferencian como mucho en 0,0142, porque nuestro descenso del gradiente se detiene justo antes del mínimo.
Paso 8: Comparación: un paciente, tres respuestas¶
Primero, las tres estrategias una junto a otra, con su número de clasificadores y de parámetros (13 pesos y un sesgo por cada puntuación lineal), y sus matrices de confusión en el conjunto de prueba:
n_params = Z_train.shape[1] + 1
summary = pd.DataFrame({
'clasificadores': [len(ovr_models), len(ovo_models), 1],
'parámetros': [len(ovr_models) * n_params, len(ovo_models) * n_params, W.size + b.size],
'exactitud': [np.mean(p == y_te) for p in (ovr_pred, ovo_pred, softmax_pred)],
'error de 2+ niveles': [np.sum(np.abs(p - y_te) >= 2) for p in (ovr_pred, ovo_pred, softmax_pred)],
}, index=['One-vs-Rest', 'One-vs-One', 'Softmax'])
recalls = pd.DataFrame([recall_per_level(y_te, p) for p in (ovr_pred, ovo_pred, softmax_pred)], index=summary.index)
print(pd.concat([summary, recalls.add_prefix('sens. ')], axis=1).round(3).to_string())
fig, axes = plt.subplots(1, 3, figsize=(16, 4.5))
for ax, (name, pred) in zip(axes, [('One-vs-Rest', ovr_pred), ('One-vs-One', ovo_pred), ('Softmax', softmax_pred)]):
ConfusionMatrixDisplay.from_predictions(y_te, pred, display_labels=level_names, ax=ax, colorbar=False, cmap='Blues')
ax.set_title(name); ax.set_xlabel('nivel predicho'); ax.set_ylabel('nivel real')
ax.tick_params(axis='x', labelrotation=45)
plt.tight_layout(); plt.show()
clasificadores parámetros exactitud error de 2+ niveles sens. 1 Rojo sens. 2 Naranja sens. 3 Amarillo sens. 4 Verde sens. 5 Azul One-vs-Rest 5 70 0.625 30 0.700 0.735 0.221 0.813 0.544 One-vs-One 10 140 0.660 18 0.725 0.720 0.472 0.763 0.581 Softmax 1 70 0.657 18 0.750 0.735 0.446 0.768 0.567
OvO y softmax están prácticamente empatados, 0,660 y 0,657, y los dos por delante de OvR, 0,625, que pierde a los pacientes amarillos. OvO necesita 10 clasificadores y 140 parámetros, el doble que softmax, que hace el mismo trabajo con un modelo y 70. La mayoría de los errores son de un nivel: OvO y softmax cometen 18 errores de dos niveles o más en 1.000 pacientes, y OvR 30. La diagonal flojea entre niveles vecinos, el 3 y el 4, y el 4 y el 5, donde una puntuación lineal lo tiene más difícil.
Por último, el primer paciente de prueba para el que ningún clasificador OvR llega a 0,5, y lo que responde cada estrategia:
patient = np.flatnonzero(best < 0.5)[0]
print(X_test.iloc[patient].to_string())
print(f"Nivel real: {y_te[patient]}\n")
print(pd.DataFrame({'puntuación OvR': ovr_scores[patient], 'votos OvO': votes[patient].astype(int),
'probabilidad softmax': softmax_probs[patient]}, index=level_names).round(3).to_string())
print(f"\nPredicción: OvR {ovr_pred[patient]}, OvO {ovo_pred[patient]}, softmax {softmax_pred[patient]}")
edad 52.0
hombre 0.0
presion_sistolica 117.0
frec_cardiaca 71.0
frec_respiratoria 16.0
spo2 97.0
temperatura 37.2
consciencia 0.0
dolor 2.0
dolor_toracico 0.0
disnea 0.0
traumatismo 0.0
vomitos 0.0
Nivel real: 5
puntuación OvR votos OvO probabilidad softmax
1 Rojo 0.010 0 0.002
2 Naranja 0.018 1 0.006
3 Amarillo 0.069 2 0.042
4 Verde 0.429 3 0.477
5 Azul 0.457 4 0.474
Predicción: OvR 5, OvO 5, softmax 4
Una mujer de 52 años con signos vitales normales, dolor 2 y sin síntomas: su nivel real es el 5. OvR le da 0,429 para verde y 0,457 para azul, ninguno por encima de 0,5, y elige azul. OvO da al azul sus 4 votos y también elige azul. Softmax da 0,477 a verde y 0,474 a azul y elige verde, esta vez equivocándose. Los tres dudan entre los mismos dos niveles; la diferencia está en cómo lo muestran. Las probabilidades de softmax lo dicen con claridad: casi toda la probabilidad se reparte en dos mitades iguales entre verde y azul. Para un paciente en la frontera, esa es la respuesta honesta, y la útil para una enfermera de triaje: un caso para revisar más que un nivel claro.
Conclusión¶
- One-vs-Rest construye un clasificador de $K$ clases a partir de $K$ clasificadores binarios. Es sencillo, pero cada clasificador se enfrenta a un problema desbalanceado, sus puntuaciones no suman 1 y una clase situada entre las demás puede quedar aplastada.
- One-vs-One entrena $K(K-1)/2$ clasificadores con pares más pequeños y más equilibrados, y los deja votar. Aquí necesita el doble de parámetros, y una regla para los empates.
- La regresión softmax considera todas las clases a la vez, como quería Carmen: un solo modelo, probabilidades que suman 1 y la entropía cruzada como coste. Es la versión multiclase de la regresión logística, y es lo que hace la capa de salida de una red neuronal.
- Los tres son lineales, y el protocolo de triaje no lo es: un signo vital suma puntos cuando es demasiado bajo o demasiado alto. Por eso el equipo pasó a algoritmos multiclase por naturaleza y no lineales, como los árboles de decisión y los bosques aleatorios, que construyen los siguientes cuadernos de este capítulo.