4.2 - Clasificador Naive Bayes para el triaje de urgencias¶
El segundo algoritmo que estudia el equipo de Minermont para el triaje es el clasificador Naive Bayes, un método probabilístico basado en el teorema de Bayes. Las notas de Marta (sección 4.5) desarrollan sus matemáticas; en este cuaderno lo implementamos a mano, lo comparamos con scikit-learn y vemos dónde ayuda y dónde perjudica su suposición "ingenua":
- Importar las bibliotecas.
- El teorema de Bayes y la suposición ingenua.
- Los datos de triaje.
- Una predicción a mano, paso a paso.
- Naive Bayes gaussiano, y por qué falla aquí.
- Contar frecuencias: Naive Bayes con categorías.
- Cuando la suposición ingenua se rompe.
- Evaluación final.
Paso 1: Importar las bibliotecas¶
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.naive_bayes import GaussianNB, CategoricalNB
from sklearn.metrics import accuracy_score, recall_score, classification_report, ConfusionMatrixDisplay
Paso 2: El teorema de Bayes y la suposición ingenua¶
Para un paciente con características $X = (x_1, \dots, x_d)$ queremos la probabilidad de cada nivel de urgencia $C_k$. El teorema de Bayes nos la da:
$$P(C_k \mid X) = \frac{P(X \mid C_k)\, P(C_k)}{P(X)} \qquad\Longrightarrow\qquad P(C_k \mid X) \propto P(X \mid C_k)\, P(C_k)$$
- $P(C_k)$ es la probabilidad a priori: lo frecuente que es el nivel antes de mirar al paciente.
- $P(X \mid C_k)$ es la verosimilitud: lo probables que son estas características en un paciente de ese nivel.
- $P(X)$ es igual para todos los niveles, así que para elegir el nivel podemos ignorarla.
Estimar $P(x_1, \dots, x_d \mid C_k)$ para cada combinación de valores necesitaría una cantidad enorme de datos. La suposición ingenua es que, una vez conocido el nivel, las características son independientes entre sí, de modo que la verosimilitud se convierte en un producto:
$$P(X \mid C_k) = \prod_{j=1}^{d} P(x_j \mid C_k) \qquad\qquad \text{Nivel predicho} = \arg\max_k \; P(C_k) \prod_{j=1}^{d} P(x_j \mid C_k)$$
Paso 3: 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.
| 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) |
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,
})
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
Paso 4: Una predicción a mano¶
Antes de usar todas las características, hagamos el cálculo con solo dos síntomas. Llega un paciente con dolor torácico y dificultad para respirar. Con los datos de entrenamiento estimamos, para cada nivel, la probabilidad a priori y las dos verosimilitudes, tal como lo describe el libro: el número de pacientes del nivel con el síntoma, dividido por el número de pacientes del nivel.
prior = y_train.value_counts(normalize=True).sort_index()
p_chest = X_train.groupby(y_train)['dolor_toracico'].mean() # P(dolor torácico | nivel)
p_dysp = X_train.groupby(y_train)['disnea'].mean() # P(disnea | nivel)
product = prior * p_chest * p_dysp # numerador del teorema de Bayes
example = pd.DataFrame({'P(nivel)': prior, 'P(dolor torácico | nivel)': p_chest, 'P(disnea | nivel)': p_dysp,
'producto': product, 'P(nivel | síntomas)': product / product.sum()})
print(example.set_axis(level_names).round(3).to_string())
P(nivel) P(dolor torácico | nivel) P(disnea | nivel) producto P(nivel | síntomas) 1 Rojo 0.039 0.280 0.432 0.005 0.116 2 Naranja 0.133 0.761 0.271 0.027 0.671 3 Amarillo 0.195 0.202 0.212 0.008 0.204 4 Verde 0.418 0.006 0.154 0.000 0.009 5 Azul 0.215 0.000 0.088 0.000 0.000
El nivel 2 (naranja) es con diferencia el más probable (0,671): el dolor torácico es muy frecuente entre los pacientes de nivel 2. El nivel 1 obtiene solo 0,116, aunque la dificultad para respirar es allí más frecuente que en ningún otro nivel, porque es un nivel raro (su probabilidad a priori es 0,039). Y fíjate en el nivel 5: ningún paciente de nivel 5 del conjunto de entrenamiento tenía dolor torácico, así que su producto es exactamente cero y el nivel queda descartado por completo. Volveremos sobre estos ceros.
Paso 5: Naive Bayes gaussiano¶
Para las características numéricas, como la frecuencia cardiaca, el libro explica que se suele suponer una distribución normal (gaussiana) dentro de cada nivel: estimamos su media $\mu$ y su varianza $\sigma^2$, y la verosimilitud de un valor es la altura de la campana,
$$P(x_j \mid C_k) = \frac{1}{\sqrt{2\pi\sigma_{kj}^2}} \exp\left(-\frac{(x_j - \mu_{kj})^2}{2\sigma_{kj}^2}\right)$$
Multiplicar 13 números pequeños da números tan diminutos que el ordenador los redondea a cero, así que en la práctica sumamos logaritmos en lugar de multiplicar probabilidades. El máximo no cambia. Para volver a convertir las puntuaciones en probabilidades dividimos por su suma, que es el denominador $P(X)$ del teorema de Bayes.
levels = np.sort(y_train.unique())
Xtr = X_train.to_numpy(dtype=float)
ytr = y_train.to_numpy()
log_prior = np.log([np.mean(ytr == c) for c in levels])
means = np.array([Xtr[ytr == c].mean(axis=0) for c in levels]) # niveles x características
epsilon = 1e-9 * Xtr.var(axis=0).max() # el pequeño margen de seguridad que añade scikit-learn
variances = np.array([Xtr[ytr == c].var(axis=0) for c in levels]) + epsilon
def gaussian_log_likelihood(X_new, columns):
j = [feature_names.index(c) for c in columns]
x = np.asarray(X_new[columns], dtype=float)[:, None, :] # pacientes x 1 x características
ll = -0.5 * np.log(2 * np.pi * variances[:, j]) - (x - means[:, j]) ** 2 / (2 * variances[:, j])
return ll.sum(axis=2) # pacientes x niveles
def to_probabilities(log_scores):
p = np.exp(log_scores - log_scores.max(axis=1, keepdims=True))
return p / p.sum(axis=1, keepdims=True) # dividir por P(X)
gauss_scores = log_prior + gaussian_log_likelihood(X_test, feature_names)
pred_gauss = levels[gauss_scores.argmax(axis=1)]
sk_gauss = GaussianNB().fit(X_train, y_train)
print(f"Misma predicción que GaussianNB de scikit-learn: {np.sum(pred_gauss == sk_gauss.predict(X_test))} de {len(y_test)}")
print(f"Exactitud en prueba: {accuracy_score(y_test, pred_gauss):.3f}")
print("Recall por nivel:", dict(zip(level_names, recall_score(y_test, pred_gauss, average=None).round(2).tolist())))
Misma predicción que GaussianNB de scikit-learn: 1000 de 1000
Exactitud en prueba: 0.477
Recall por nivel: {'1 Rojo': 0.65, '2 Naranja': 0.67, '3 Amarillo': 0.25, '4 Verde': 0.25, '5 Azul': 0.98}
Las dos implementaciones coinciden en todos los pacientes, pero el resultado es pobre: una exactitud de 0,477, muy por debajo de KNN. El recall por nivel muestra el problema: se encuentra a casi todos los pacientes de nivel 5 (0,98), pero solo a una cuarta parte de los de nivel 3 y nivel 4. ¿Por qué? Mira lo que ha aprendido el modelo sobre el dolor torácico:
j = feature_names.index('dolor_toracico')
print(pd.DataFrame({'media = P(dolor torácico)': means[:, j], 'varianza': variances[:, j]}, index=level_names).to_string(float_format='{:.3g}'.format))
media = P(dolor torácico) varianza 1 Rojo 0.28 0.201 2 Naranja 0.761 0.182 3 Amarillo 0.202 0.161 4 Verde 0.00559 0.00556 5 Azul 0 5.36e-07
El dolor torácico es una columna de sí o no, y ningún paciente de nivel 5 lo tiene. La gaussiana del nivel 5 tiene por tanto media 0 y una varianza prácticamente nula: un pico altísimo en el 0. Todos los pacientes sin dolor torácico, que son la mayoría, reciben por esta sola columna una verosimilitud enorme para el nivel 5, que pesa más que todo lo demás. El problema no es el teorema de Bayes, sino cómo se describe la característica: una campana no es un modelo razonable para un síntoma que está o no está.
Paso 6: Contar frecuencias¶
Para las características categóricas, el libro estima la verosimilitud contando: el número de pacientes del nivel con ese valor, dividido por el número de pacientes del nivel. Podemos usar la misma idea con los signos vitales si antes cortamos cada uno en intervalos. Usamos 6 intervalos por medida, con el mismo número de pacientes de entrenamiento en cada uno (los puntos de corte se calculan solo con el conjunto de entrenamiento).
Un detalle: si un valor no aparece nunca en un nivel, su frecuencia es 0, y un solo cero en el producto descartaría el nivel por completo. El suavizado de Laplace añade un paciente imaginario a cada recuento ($\alpha = 1$), de modo que ninguna probabilidad es exactamente cero:
$$P(x_j = v \mid C_k) = \frac{\text{pacientes del nivel } k \text{ con } x_j = v \;+\; \alpha}{\text{pacientes del nivel } k \;+\; \alpha \cdot (\text{número de valores posibles de } x_j)}$$
continuous = ['edad', 'presion_sistolica', 'frec_cardiaca', 'frec_respiratoria', 'spo2', 'temperatura']
categorical = [c for c in feature_names if c not in continuous]
cut_points = {c: np.unique(np.quantile(X_train[c], np.linspace(0, 1, 7)[1:-1])) for c in continuous}
def to_intervals(X_):
B = X_.copy()
for c in continuous:
B[c] = np.digitize(X_[c], cut_points[c]) # 0, 1, 2, ... = número de intervalo
return B.astype(int)
B_train, B_test = to_intervals(X_train), to_intervals(X_test)
n_values = {c: len(cut_points[c]) + 1 for c in continuous}
n_values.update({'hombre': 2, 'consciencia': 4, 'dolor': 11, 'dolor_toracico': 2, 'disnea': 2, 'traumatismo': 2, 'vomitos': 2})
def count_tables(B, y, alpha=1):
"""P(valor | nivel) para cada característica, contando, con suavizado de Laplace."""
tables = {}
for col in B.columns:
t = np.zeros((len(levels), n_values[col]))
for k, c in enumerate(levels):
counts = np.bincount(B[col][y == c], minlength=n_values[col])
t[k] = (counts + alpha) / (counts.sum() + alpha * n_values[col])
tables[col] = t
return tables
tables = count_tables(B_train, ytr)
def count_log_likelihood(B, columns):
return sum(np.log(tables[col][:, B[col].to_numpy()]).T for col in columns) # pacientes x niveles
print("Intervalos de frecuencia cardiaca (lpm):", cut_points['frec_cardiaca'])
print(pd.DataFrame(tables['frec_cardiaca'], index=level_names,
columns=[f'intervalo {i}' for i in range(n_values['frec_cardiaca'])]).round(3).to_string())
Intervalos de frecuencia cardiaca (lpm): [72. 78. 84. 90. 98.]
intervalo 0 intervalo 1 intervalo 2 intervalo 3 intervalo 4 intervalo 5
1 Rojo 0.089 0.032 0.065 0.081 0.153 0.581
2 Naranja 0.141 0.121 0.178 0.151 0.158 0.250
3 Amarillo 0.112 0.110 0.161 0.159 0.210 0.249
4 Verde 0.147 0.151 0.177 0.172 0.215 0.137
5 Azul 0.209 0.253 0.255 0.213 0.049 0.021
La tabla muestra lo que una campana no puede: la probabilidad de cada intervalo de frecuencia cardiaca dentro de cada nivel, sin suponer ninguna forma. La mayoría de los pacientes de nivel 1 están en el intervalo más rápido (0,581), pero para ellos el intervalo más lento (0,089) es más frecuente que cualquiera de los intermedios: algunos pacientes críticos tienen el pulso demasiado lento. Los pacientes de nivel 5, en cambio, se concentran por debajo de 90 lpm.
Comparamos tres versiones: todo gaussiano; la receta del libro (gaussiana para las seis medidas y recuento para las características categóricas); y recuento para todo, con las medidas cortadas en intervalos. También comprobamos la última frente a CategoricalNB de scikit-learn.
versions = {
'todo gaussiano': gauss_scores,
'gaussiano + recuento (libro)': log_prior + gaussian_log_likelihood(X_test, continuous) + count_log_likelihood(X_test, categorical),
'recuento con intervalos': log_prior + count_log_likelihood(B_test, feature_names),
}
rows = []
for name, scores in versions.items():
pred = levels[scores.argmax(axis=1)]
rows.append({'versión': name, 'exactitud': accuracy_score(y_test, pred),
**dict(zip([f'recall {n}' for n in level_names], recall_score(y_test, pred, average=None)))})
print(pd.DataFrame(rows).set_index('versión').round(3).to_string())
pred_counts = levels[versions['recuento con intervalos'].argmax(axis=1)]
sk_cat = CategoricalNB(alpha=1, min_categories=[n_values[c] for c in feature_names]).fit(B_train, y_train)
print(f"\nMisma predicción que CategoricalNB de scikit-learn: {np.sum(pred_counts == sk_cat.predict(B_test))} de {len(y_test)}")
exactitud recall 1 Rojo recall 2 Naranja recall 3 Amarillo recall 4 Verde recall 5 Azul versión todo gaussiano 0.477 0.65 0.667 0.246 0.249 0.981 gaussiano + recuento (libro) 0.742 0.65 0.697 0.605 0.801 0.795 recuento con intervalos 0.761 0.65 0.667 0.513 0.904 0.786 Misma predicción que CategoricalNB de scikit-learn: 1000 de 1000
La receta del libro, una gaussiana solo para las seis medidas y recuento para las características categóricas, salta de 0,477 a 0,742. Contar todo con intervalos llega algo más lejos, a 0,761, porque los intervalos captan formas que una campana no puede, como el pulso lento de algunos pacientes críticos. Nuestra implementación y CategoricalNB de scikit-learn dan la misma predicción para los 1000 pacientes de prueba.
Paso 7: Cuando la suposición ingenua se rompe¶
Carmen objeta en el libro que los síntomas no son independientes. En nuestros pacientes, la frecuencia respiratoria, la saturación de oxígeno y la dificultad para respirar vienen del mismo problema de fondo. Dentro de un mismo nivel están claramente correlacionadas:
print("Correlaciones entre los pacientes de nivel 2 (conjunto de entrenamiento):")
print(X_train[y_train == 2][['frec_respiratoria', 'spo2', 'disnea', 'frec_cardiaca']].corr().round(2).to_string())
Correlaciones entre los pacientes de nivel 2 (conjunto de entrenamiento):
frec_respiratoria spo2 disnea frec_cardiaca
frec_respiratoria 1.00 -0.68 0.59 0.37
spo2 -0.68 1.00 -0.61 -0.28
disnea 0.59 -0.61 1.00 0.22
frec_cardiaca 0.37 -0.28 0.22 1.00
Naive Bayes multiplica sus verosimilitudes como si fueran tres pruebas distintas, cuando en buena medida cuentan la misma historia. El efecto se ve mejor con un caso extremo: le damos al modelo la misma columna de saturación de oxígeno varias veces. Una copia no añade ninguna información, pero Naive Bayes la vuelve a contar. Contar la misma prueba $n$ veces equivale a sumar su log-verosimilitud $n$ veces:
base_scores = versions['recuento con intervalos']
spo2_term = count_log_likelihood(B_test, ['spo2'])
for copies in [1, 3, 5]:
scores = base_scores + (copies - 1) * spo2_term
pred = levels[scores.argmax(axis=1)]
confidence = to_probabilities(scores).max(axis=1).mean()
print(f"SpO2 contada x{copies}: exactitud {accuracy_score(y_test, pred):.3f}, "
f"probabilidad media del nivel elegido {confidence:.3f}")
i = np.where((y_test.to_numpy() == 2) & (B_test['spo2'].to_numpy() == 0))[0][0]
print("\nUn paciente de nivel 2 con saturación baja:")
print(X_test.iloc[[i]].to_string(index=False))
for copies in [1, 2, 3, 5]:
p = to_probabilities(base_scores[[i]] + (copies - 1) * spo2_term[[i]])[0]
print(f" SpO2 contada x{copies}: " + ", ".join(f"{n} {v:.3f}" for n, v in zip(level_names, p)))
SpO2 contada x1: exactitud 0.761, probabilidad media del nivel elegido 0.710 SpO2 contada x3: exactitud 0.725, probabilidad media del nivel elegido 0.737 SpO2 contada x5: exactitud 0.701, probabilidad media del nivel elegido 0.759 Un paciente de nivel 2 con saturación baja: edad hombre presion_sistolica frec_cardiaca frec_respiratoria spo2 temperatura consciencia dolor dolor_toracico disnea traumatismo vomitos 85 1 144 39 23 94 36.1 0 2 0 1 0 1 SpO2 contada x1: 1 Rojo 0.320, 2 Naranja 0.179, 3 Amarillo 0.204, 4 Verde 0.286, 5 Azul 0.011 SpO2 contada x2: 1 Rojo 0.644, 2 Naranja 0.150, 3 Amarillo 0.136, 4 Verde 0.070, 5 Azul 0.000 SpO2 contada x3: 1 Rojo 0.847, 2 Naranja 0.082, 3 Amarillo 0.059, 4 Verde 0.011, 5 Azul 0.000 SpO2 contada x5: 1 Rojo 0.976, 2 Naranja 0.016, 3 Amarillo 0.008, 4 Verde 0.000, 5 Azul 0.000
Cada copia extra hace el modelo más seguro y menos exacto: la exactitud baja de 0,761 a 0,701 mientras la probabilidad media del nivel elegido sube de 0,710 a 0,759. Para el paciente de nivel 2, la probabilidad del nivel 1 pasa de 0,320 a 0,976 solo por repetir la misma medida. Los signos vitales correlacionados de los pacientes reales producen una versión más suave del mismo efecto: Naive Bayes puede estar muy seguro y aun así equivocarse.
Paso 8: Evaluación final¶
La mejor versión es la que cuenta frecuencias con intervalos. Esta es su evaluación detallada en el conjunto de prueba:
print(classification_report(y_test, pred_counts, target_names=level_names, digits=3))
ConfusionMatrixDisplay.from_predictions(y_test, pred_counts, display_labels=level_names, cmap='Blues', xticks_rotation=30)
plt.xlabel('Nivel predicho')
plt.ylabel('Nivel real')
plt.title('Naive Bayes (recuento con intervalos) en el conjunto de prueba')
plt.show()
precision recall f1-score support
1 Rojo 0.406 0.650 0.500 40
2 Naranja 0.693 0.667 0.680 132
3 Amarillo 0.714 0.513 0.597 195
4 Verde 0.789 0.904 0.843 418
5 Azul 0.889 0.786 0.835 215
accuracy 0.761 1000
macro avg 0.698 0.704 0.691 1000
weighted avg 0.768 0.761 0.758 1000
Naive Bayes alcanza 0,761 en el conjunto de prueba, mejor que KNN, con un modelo que se entrena en una fracción de segundo. Pero fíjate en el nivel rojo: encuentra a 26 de los 40 pacientes de nivel 1 (recall de 0,650) y, cuando dice nivel 1, acierta menos de la mitad de las veces (precisión de 0,406).
Conclusión¶
- Naive Bayes combina la probabilidad a priori de cada nivel con la verosimilitud de cada característica, usando el teorema de Bayes y la suposición de que las características son independientes dentro de cada nivel.
- Entrenarlo es solo calcular frecuencias, medias y varianzas: es muy rápido y necesita pocos datos. Sumamos logaritmos en lugar de multiplicar, y el suavizado de Laplace evita las probabilidades cero.
- Cómo modelamos cada característica importa mucho: una gaussiana para un síntoma de sí o no, o para un signo vital que es peligroso tanto si es demasiado bajo como demasiado alto, es una mala descripción, y contar frecuencias funciona mucho mejor.
- Cuando varias características cuentan la misma historia, Naive Bayes la cuenta varias veces y se vuelve demasiado seguro. Es justo la objeción de Carmen, y el motivo por el que, como concluye Víctor en el libro, probablemente no es el modelo más adecuado para la complejidad del triaje. El siguiente cuaderno estudia los árboles de decisión.