Capítulo 10 de 37 · básico
Naive Bayes
Qué cubre este capítulo
En el capítulo pasado trazamos una línea sobre un solo feature e ignoramos los otros siete. En este capítulo dejamos de ignorarlos. Naive Bayes mira cada medición de un paciente, le pregunta a cada una "¿qué tanto te sorprende ver este valor en una persona diabética contra una sana?" y suma la sorpresa. La clase con la menor sorpresa gana. Ese es todo el modelo, y está construido sobre una regla de probabilidad que le lleva dos siglos de ventaja a cualquier otro algoritmo de este curso.
Lo construimos a mano en NumPy — priors, gaussianas por clase, un
log-posterior, un argmax — y luego dejamos que el GaussianNB de scikit-learn
construya lo mismo, para ver cómo los dos llegan a la predicción idéntica en
los 154 pacientes reservados. En el camino verás el modelo hecho literal: las
campanas de Gauss ajustadas que carga consigo, y una animación donde la
probabilidad se inclina visiblemente de "sano" a "diabético" mientras paseamos
un punto de consulta a través de los datos.
Los datos son el mismo conjunto Pima Indians Diabetes de la semana 1: 768 pacientes, ocho mediciones cada uno, una etiqueta de diabetes. Esta vez las ocho columnas entran en juego.
Un poco de historia
La regla al centro de todo esto es el teorema de Bayes, y viene de un ministro presbiteriano. Thomas Bayes resolvió cómo actualizar una creencia a la luz de la evidencia en algún momento de la década de 1740, nunca lo publicó, y murió en 1761. Su amigo Richard Price encontró el ensayo entre sus papeles y lo leyó ante la Royal Society en 1763. Pierre-Simon Laplace, trabajando por su cuenta, puso el teorema sobre bases sólidas unos años después y lo usó para hacer ciencia de verdad. Así que la matemática en la que estamos a punto de apoyarnos es anterior a las computadoras, a la estadística como disciplina y a Darwin.
La parte "naive" es mucho más joven y mucho más pragmática. En algún punto de los años sesenta, la gente que construía clasificadores automáticos de documentos notó que si finges que cada palabra de un documento es independiente de las demás — obviamente falso, "machine" y "learning" viajan juntas — el cómputo de Bayes colapsa en algo que sí puedes ejecutar: multiplicar un montón de probabilidades por palabra y quedarte con la más grande. Estaba mal y aun así funcionó. Para los noventa, naive Bayes era el caballo de batalla detrás de los filtros de spam, y el ensayo de Paul Graham de 2002, "A Plan for Spam", puso un filtro naive-Bayes frente a toda una generación de programadores. Sigue siendo lo primero que agarras cuando necesitas un clasificador de texto y un resultado antes de la comida.
La intuición
Olvida el teorema por un segundo. Esto ya lo haces. Llega un paciente con una lectura de glucosa muy alta y un BMI alto. Tu instinto dice diabetes, y la razón por la que lo dice es que ya has visto las distribuciones: la glucosa alta es común entre diabéticos y rara entre gente sana, así que una lectura alta es evidencia, y dos piezas de evidencia apuntando en la misma dirección son más fuertes que una.
Naive Bayes escribe esa corazonada como aritmética. Para cada clase guarda un pequeño retrato estadístico de cómo se ve lo "normal" — la glucosa promedio de un diabético, cuánto varía, lo mismo para el BMI, para la edad, para los ocho features. Un paciente nuevo se califica contra ambos retratos: qué tan bien encaja en el perfil diabético, qué tan bien en el sano. El truco, la parte naive, es que califica cada feature por su cuenta y luego simplemente suma las calificaciones, como si la glucosa y el BMI cargaran información completamente separada. No es así — están correlacionados — pero fingir que son separados es lo que hace que todo esto sean unas cuantas líneas de NumPy en lugar de una matriz de covarianza que tienes que invertir.
Aquí están los dos features sobre los que lo veremos trabajar, glucosa contra BMI, cada paciente coloreado según si tuvo diabetes:
Las dos nubes se traslapan mucho — estos no son datos limpios y separables — pero la nube morada de diabéticos se recarga hacia arriba y a la derecha. Esa inclinación es toda la señal, y Naive Bayes la convierte en un par de probabilidades para cada punto.
La matemática
Empieza con el teorema de Bayes. Queremos la probabilidad de una clase dadas las mediciones de un paciente :
Léelo de derecha a izquierda. es el prior — la frecuencia de la clase antes de mirar al paciente. es la verosimilitud — qué tan probable es este conjunto exacto de mediciones si el paciente pertenece a la clase . es igual para todas las clases, así que no puede cambiar cuál gana; lo tiramos y nos quedamos con una proporcionalidad:
El término difícil es — la probabilidad conjunta de los ocho features a la vez, que en general no puedes estimar sin una montaña de datos. Aquí es donde "naive" se gana su nombre. Asume que los features son condicionalmente independientes dada la clase, y la conjunta se factoriza en un producto de términos de un solo feature:
Ese es todo el truco. Cada feature aporta su propia verosimilitud, y las multiplicamos. Para features continuos como la glucosa necesitamos una forma para , y la elección por defecto — la que convierte esto en Gaussian Naive Bayes — es una distribución normal ajustada por feature por clase:
donde y son simplemente la media y la varianza del feature entre los pacientes de entrenamiento de la clase . Multiplicar ocho densidades pequeñas entre sí se desborda hacia cero muy rápido, así que tomamos logaritmos y convertimos el producto en una suma:
La predicción es la clase que maximiza esto:
Cada símbolo de aquí se convierte en una línea de código, y la suma es la parte que no debes perder de vista — ahí es donde se combinan las ocho piezas de evidencia.
En qué es bueno, en qué no
El lado bueno es la velocidad y la frugalidad. Entrenar es una sola pasada para calcular una media y una varianza por feature por clase — sin iteración, sin gradiente, nada que converger. Necesita muy pocos datos para estimar ese puñado de números, así que aguanta cuando tienes veinte ejemplos por clase en lugar de veinte mil. En texto, donde cada palabra es un feature y hay decenas de miles, sigue siendo uno de los clasificadores útiles más rápidos que puedes entrenar, y sigue siendo el baseline que un filtro de spam o un etiquetador de temas tiene que superar. Échale mano cuando quieras una respuesta real de inmediato y un piso debajo de lo que sea que construyas después.
El lado malo es la propia suposición naive. Los features casi nunca son independientes — la glucosa y el BMI se mueven juntos, y Naive Bayes cuenta doble la evidencia correlacionada como si fueran dos testigos separados cuando en realidad es un testigo hablando dos veces. Eso hace que sus estimaciones de probabilidad estén mal calibradas: tiende a empujar el posterior hacia un confiado 0.99 cuando la respuesta honesta es 0.7. El ranking normalmente sobrevive a esto (la clase correcta suele quedar arriba de todos modos, que es por lo que la accuracy se sostiene), pero no leas una probabilidad de Naive Bayes como una probabilidad real. Y el sabor gaussiano en específico asume que cada feature tiene forma de campana dentro de una clase; en un feature muy sesgado esa suposición es una mentira y el ajuste sufre.
Los datos
El conjunto Pima tiene ocho features: embarazos, glucosa, presión arterial, grosor del pliegue cutáneo, insulina, BMI, un puntaje de pedigrí de diabetes y edad. Los ocho entran al modelo principal. Para las imágenes bidimensionales de abajo uso glucosa y BMI, descartando el puñado de filas donde cualquiera de los dos está registrado como cero (el sustituto del dataset para "faltante"), porque un cero arrastraría la media y la varianza de una gaussiana a un lugar falso. El scatter de arriba es esa rebanada limpia de dos features; el duelo de accuracy al final usa los ocho features, en crudo.
Constrúyelo, una función a la vez
Siete funciones cortas, sin librería. Este es el orden en el que de verdad las teclearía: primero los priors, luego las estadísticas por clase, luego la gaussiana, luego el posterior que las amarra todas.
El prior es lo más fácil de todo el curso — qué fracción de los pacientes de entrenamiento pertenece a cada clase:
def class_priors(y, classes):
"""P(y = c) for each class — just its share of the training rows.
The prior is what you'd guess with no features at all: if 35% of
patients are diabetic, the prior leans 65/35 before we look at a single
measurement.
"""
return np.array([(y == c).mean() for c in classes])
Luego el modelo en sí. Para cada clase, recorta sus filas y toma la media y la varianza de cada columna. Ese par de arrays es todo el modelo ajustado — no se guarda nada más:
def class_stats(X, y, classes):
"""Per-class, per-feature Gaussian mean and variance — the whole model.
For each class we slice out its rows and take the column means and
variances. means[c, j] and vars[c, j] are the center and spread of
feature j *within* class c. That pair of numbers per feature per class is
everything Naive Bayes ever stores.
"""
means = np.array([X[y == c].mean(axis=0) for c in classes]) # (C, D)
variances = np.array([X[y == c].var(axis=0) for c in classes]) # (C, D)
return means, variances
Ahora la gaussiana, en forma logarítmica porque es la única forma en la que la usaremos. Dado un valor, una media y una varianza, devuelve la log-densidad, y hace broadcast, así que una sola llamada califica una matriz completa contra una clase completa de una vez:
def gaussian_logpdf(x, mean, var):
"""Log of the Gaussian density N(x; mean, var), elementwise.
We never want the raw density — a product of densities underflows fast.
We want its log, because in log-space the product of per-feature
likelihoods becomes a sum we can add up safely.
"""
return -0.5 * np.log(2.0 * np.pi * var) - 0.5 * (x - mean) ** 2 / var
Aquí está el corazón del asunto. Para cada clase, suma el log del prior a las
log-verosimilitudes por feature ya sumadas. Ese .sum(axis=1) es la
suposición naive de independencia hecha concreta — ocho log-verosimilitudes
separadas sumadas como si fueran evidencia independiente:
def log_posterior(X, priors, means, variances):
"""Unnormalized log posterior log P(y=c) + sum_j log P(x_j | y=c).
X is (N, D). For each class we add the log prior to the summed
per-feature log-likelihoods — that sum IS the naive independence
assumption, treating the features as if they carry separate evidence.
Returns an (N, C) grid: a score per point per class.
"""
N, C = X.shape[0], len(priors)
out = np.zeros((N, C))
for c in range(C):
ll = gaussian_logpdf(X, means[c], variances[c]).sum(axis=1) # (N,)
out[:, c] = np.log(priors[c]) + ll
return out
Con un puntaje por clase en mano, la predicción es un argmax a lo largo del eje de clases. Nunca dividimos entre porque es idéntico para todas las clases y no puede cambiar al ganador:
def predict(X, priors, means, variances, classes):
"""Label each row of X by the class with the highest log posterior.
argmax over the (N, C) score grid. The normalizing constant P(x) is the
same for every class, so it can't change which one wins — we skip it.
"""
scores = log_posterior(X, priors, means, variances)
return classes[np.argmax(scores, axis=1)]
La accuracy, el mismo one-liner de siempre:
def accuracy(y_true, y_pred):
"""Fraction of predictions that match the truth."""
return float((np.asarray(y_true) == np.asarray(y_pred)).mean())
Y un wrapper delgado que estima el modelo completo de una vez. La única sutileza es un piso de varianza — un feature con varianza cero dentro de una clase haría que la gaussiana dividiera entre cero, así que agregamos un epsilon diminuto, exactamente como lo hace scikit-learn, que es lo que permite que nuestros números coincidan con los suyos hasta el decimal:
def fit(X, y, var_smoothing=1e-9):
"""Estimate the whole model: classes, priors, means, variances.
The only wrinkle is var_smoothing. A feature that never varies inside a
class has zero variance, and the Gaussian divides by it. So — exactly as
scikit-learn does — we add a floor: var_smoothing times the largest
feature variance in the data, added to every variance. It keeps the math
finite and matches the library's numbers to the decimal.
"""
classes = np.unique(y)
priors = class_priors(y, classes)
means, variances = class_stats(X, y, classes)
epsilon = var_smoothing * X.var(axis=0).max()
return classes, priors, means, variances + epsilon
Un helper extra, usado solo para los subtítulos de la animación de abajo: convertir los log-puntajes en probabilidades reales con un softmax numéricamente seguro. La predicción nunca necesita esto — al argmax no le importa la constante de normalización — pero está bonito poder decir en voz alta "P(diabetes) = 0.73":
def posterior_proba(X, priors, means, variances):
"""Turn the log-posterior scores into real probabilities per class.
A softmax over the log scores (subtract the row max first — the standard
log-sum-exp trick — so exp never overflows). Only needed for reporting,
e.g. "P(diabetes) = 0.82" in a caption; the prediction never needs it.
"""
scores = log_posterior(X, priors, means, variances)
scores = scores - scores.max(axis=1, keepdims=True)
p = np.exp(scores)
return p / p.sum(axis=1, keepdims=True)
Míralo trabajar
Primero, el modelo hecho visible. Estas son las gaussianas ajustadas — las campanas reales que el clasificador almacena, una por clase por feature, para glucosa y BMI. Esto es el modelo; no hay nada más bajo el cofre:
Las dos curvas de glucosa están claramente desplazadas — la campana diabética queda bien a la derecha, así que la glucosa es un testigo fuerte. Las dos curvas de BMI se traslapan mucho más, así que el BMI es un testigo más débil que aun así empuja el voto. Naive Bayes escucha a ambos y los pondera automáticamente según qué tan separadas están sus curvas: un feature bien separado sacude fuerte la log-verosimilitud, uno traslapado apenas la mueve.
Ahora mira a las dos curvas pelearse por pacientes individuales. Cada cuadro deja caer un paciente reservado sobre el plano glucosa-BMI. Las cruces son los dos centros de clase, los anillos son la gaussiana de cada clase a una y dos desviaciones estándar, y la barra de abajo es el posterior — el veredicto combinado. El punto está coloreado según la clase predicha y recibe un anillo rojo cuando el posterior se inclinó hacia el lado equivocado. El subtítulo muestra ambas probabilidades.
Reprodúcela y observa la barra. Abajo, en la esquina de glucosa baja, el posterior está clavado cerca de 0.95 sano — todos los features están de acuerdo, sin competencia. Conforme la consulta sube hacia glucosa alta y BMI alto, la barra se desliza a la derecha, y en algún punto del traslape del medio cruza 0.5 y el punto cambia de azul a morado. Los anillos rojos se agrupan exactamente ahí, en la costura donde las dos gaussianas tienen densidad casi igual, porque ahí es donde la evidencia está genuinamente mezclada y un modelo probabilístico es lo bastante honesto para dudar. Esto es lo que hay que llevarse: la predicción no es una línea dura, es un jaloneo entre dos campanas, y la frontera está donde jalan por igual.
La implementación completa
El archivo completo, de arriba a abajo — las siete funciones sobre las que corrieron tanto la animación como el duelo, más los cargadores de datos:
"""Gaussian Naive Bayes, built from scratch.
Model every feature, for every class, as a 1-D Gaussian. To label a new
point, multiply each class prior by the per-feature likelihoods (the "naive"
assumption that the features are independent given the class), and pick the
class with the biggest product. We do the multiplying in log-space so a
handful of tiny densities never underflows to zero.
Pure NumPy — no ML library anywhere in this file. Every function below
appears in the chapter one step at a time (the `# region:` markers are what
the book's include directives pull in).
"""
import numpy as np
import pandas as pd
# region: class_priors
def class_priors(y, classes):
"""P(y = c) for each class — just its share of the training rows.
The prior is what you'd guess with no features at all: if 35% of
patients are diabetic, the prior leans 65/35 before we look at a single
measurement.
"""
return np.array([(y == c).mean() for c in classes])
# endregion
# region: class_stats
def class_stats(X, y, classes):
"""Per-class, per-feature Gaussian mean and variance — the whole model.
For each class we slice out its rows and take the column means and
variances. means[c, j] and vars[c, j] are the center and spread of
feature j *within* class c. That pair of numbers per feature per class is
everything Naive Bayes ever stores.
"""
means = np.array([X[y == c].mean(axis=0) for c in classes]) # (C, D)
variances = np.array([X[y == c].var(axis=0) for c in classes]) # (C, D)
return means, variances
# endregion
# region: gaussian_logpdf
def gaussian_logpdf(x, mean, var):
"""Log of the Gaussian density N(x; mean, var), elementwise.
We never want the raw density — a product of densities underflows fast.
We want its log, because in log-space the product of per-feature
likelihoods becomes a sum we can add up safely.
"""
return -0.5 * np.log(2.0 * np.pi * var) - 0.5 * (x - mean) ** 2 / var
# endregion
# region: log_posterior
def log_posterior(X, priors, means, variances):
"""Unnormalized log posterior log P(y=c) + sum_j log P(x_j | y=c).
X is (N, D). For each class we add the log prior to the summed
per-feature log-likelihoods — that sum IS the naive independence
assumption, treating the features as if they carry separate evidence.
Returns an (N, C) grid: a score per point per class.
"""
N, C = X.shape[0], len(priors)
out = np.zeros((N, C))
for c in range(C):
ll = gaussian_logpdf(X, means[c], variances[c]).sum(axis=1) # (N,)
out[:, c] = np.log(priors[c]) + ll
return out
# endregion
# region: predict
def predict(X, priors, means, variances, classes):
"""Label each row of X by the class with the highest log posterior.
argmax over the (N, C) score grid. The normalizing constant P(x) is the
same for every class, so it can't change which one wins — we skip it.
"""
scores = log_posterior(X, priors, means, variances)
return classes[np.argmax(scores, axis=1)]
# endregion
# region: accuracy
def accuracy(y_true, y_pred):
"""Fraction of predictions that match the truth."""
return float((np.asarray(y_true) == np.asarray(y_pred)).mean())
# endregion
# region: fit
def fit(X, y, var_smoothing=1e-9):
"""Estimate the whole model: classes, priors, means, variances.
The only wrinkle is var_smoothing. A feature that never varies inside a
class has zero variance, and the Gaussian divides by it. So — exactly as
scikit-learn does — we add a floor: var_smoothing times the largest
feature variance in the data, added to every variance. It keeps the math
finite and matches the library's numbers to the decimal.
"""
classes = np.unique(y)
priors = class_priors(y, classes)
means, variances = class_stats(X, y, classes)
epsilon = var_smoothing * X.var(axis=0).max()
return classes, priors, means, variances + epsilon
# endregion
# region: posterior_proba
def posterior_proba(X, priors, means, variances):
"""Turn the log-posterior scores into real probabilities per class.
A softmax over the log scores (subtract the row max first — the standard
log-sum-exp trick — so exp never overflows). Only needed for reporting,
e.g. "P(diabetes) = 0.82" in a caption; the prediction never needs it.
"""
scores = log_posterior(X, priors, means, variances)
scores = scores - scores.max(axis=1, keepdims=True)
p = np.exp(scores)
return p / p.sum(axis=1, keepdims=True)
# endregion
FEATURES = [
"Pregnancies", "Glucose", "BloodPressure", "SkinThickness",
"Insulin", "BMI", "DiabetesPedigreeFunction", "Age",
]
def load_data(path="../data/diabetes.csv"):
"""Pima Indians Diabetes dataset: 768 patients, 8 features, Outcome.
Returns (X, y): X is (N, 8) float, y is (N,) int (0 = no diabetes,
1 = diabetes).
"""
df = pd.read_csv(path)
X = df[FEATURES].to_numpy(float)
y = df["Outcome"].to_numpy(int)
return X, y
def load_two_features(path="../data/diabetes.csv", cols=("Glucose", "BMI")):
"""Two clean features for the 2-D concept views.
Glucose and BMI both use 0 to mean "not recorded"; those rows would drag
a Gaussian's mean and variance around, so we drop them here. The headline
accuracy still uses all eight raw features — this cleaning is only so the
bell curves and the animation read clearly.
"""
df = pd.read_csv(path)
keep = (df[list(cols)] > 0).all(axis=1)
df = df[keep]
X = df[list(cols)].to_numpy(float)
y = df["Outcome"].to_numpy(int)
return X, y, list(cols)
La versión de librería
Nadie arma esto a mano en producción. El GaussianNB de scikit-learn es el
mismo modelo — mismos priors, mismas gaussianas por clase, mismo argmax del
log-posterior, mismo piso de varianza — con los casos límite ya resueltos:
def gnb_sklearn(X_train, y_train, X_test):
"""Fit a Gaussian Naive Bayes classifier and predict the test rows.
The defaults match our scratch version: one Gaussian per feature per
class, priors read off the training class frequencies, var_smoothing=1e-9.
"""
clf = GaussianNB()
clf.fit(X_train, y_train)
return clf.predict(X_test)
La única razón para mirar adentro es confirmar que de verdad es el mismo
modelo. Ajústalo y expone exactamente los tres arrays que calcula nuestro
fit — class_prior_, theta_ (las medias) y var_ (las varianzas, con el
mismo epsilon ya incorporado):
def gnb_fit(X_train, y_train):
"""Return the fitted model so we can read its estimated parameters.
clf.class_prior_ is the priors, clf.theta_ the per-class feature means,
clf.var_ the per-class feature variances (with the smoothing floor
already folded in). These are the exact numbers our fit() reproduces.
"""
clf = GaussianNB()
clf.fit(X_train, y_train)
return clf
El generador de trazas verifica que nuestros priors, medias y varianzas coinciden con los de sklearn dentro de la tolerancia de punto flotante, y que los dos están de acuerdo en absolutamente todas las predicciones de prueba. Cuando una construcción desde cero reproduce exactamente los parámetros ajustados de la librería, sabes que entiendes la librería — no hay ningún paso oculto que ella haga y tú no.
Desde cero contra librería
Split estratificado ochenta/veinte, con semilla, ajustado sobre los 614 pacientes de entrenamiento, evaluado sobre los 154 reservados. Ambos modelos, los ocho features:
Ambos sacan 0.7727 en el conjunto reservado, y no solo empatan en el promedio — toman la decisión idéntica en los 154 pacientes. Ese es el resultado que quieres de una construcción desde cero: no "cercano a" la librería sino el mismo modelo bit por bit, que es la prueba de que las cuatro ecuaciones de arriba son todo el algoritmo y sklearn no esconde nada.
Dos advertencias honestas sobre ese 0.7727. Primero, el piso es alto: adivinar "no diabetes" para todos saca cerca de 65% aquí, porque solo alrededor del 35% de los pacientes son diabéticos, así que el modelo de ocho features te está comprando más o menos doce puntos sobre la adivinanza más tonta posible — real, pero no milagroso. Segundo, y esta es la nota al pie de Naive Bayes que nunca debes olvidar, esa accuracy viene de que el ranking está bien, no de que las probabilidades sean confiables. El modelo te va a entregar un 0.9 que se ve seguro y al que no deberías apostarle dinero. Ordena bien a los pacientes; no les pone bien el precio.
Conclusiones
Naive Bayes es el clasificador al que recurro cuando quiero una respuesta antes de terminarme el café. Entrena en una pasada, se conforma con unas cuantas docenas de ejemplos por clase, y en texto — donde los features son palabras y hay decenas de miles — sigue siendo el baseline que todo lo demás tiene que superar. Si estás levantando un filtro de spam, un detector de idioma, un etiquetador de temas de primera pasada, empieza aquí y haz que el modelo elegante justifique reemplazarlo.
La suposición de independencia está mal y ese es el punto. La glucosa y el BMI están correlacionados; el modelo finge que no, cuenta doble el traslape, y aun así ordena a los pacientes lo bastante bien para superar por una docena de puntos al baseline de clase mayoritaria. Equivocado pero útil es una categoría real en machine learning, y este es su ejemplo más limpio. Lo que no obtienes gratis es la calibración — el posterior que imprime es una llave de ordenamiento decente y una probabilidad mala, así que si una decisión río abajo de verdad necesita "¿qué tan seguros estamos?", lo calibras o te mueves a un modelo que estime probabilidades con honestidad. Esa es la grieta que esto abre hacia el siguiente tramo del curso: la regresión logística, que viene más adelante, conserva el enfoque probabilístico pero suelta la suposición naive y te da un número en el que puedes confiar. Naive Bayes es donde aprendes a pensar en priors y verosimilitudes. Los modelos que le siguen son donde aprendes a dejar de ser naive.