Saltar a contenido

Naive Bayes

1 Un problema real: reseñas de restaurantes

Imagina que tienes 12 reseñas de restaurantes, cada una etiquetada como positiva o negativa. Quieres construir un clasificador que, dada una reseña nueva, prediga si el cliente quedó satisfecho o no.

# Reseña (preprocesada) Sentimiento
1 “comida excelente servicio rápido” Pos
2 “comida mala servicio lento” Neg
3 “lugar muy bueno excelente comida” Pos
4 “nunca más vuelvo pésimo servicio” Neg
5 “comida rica lugar acogedor” Pos
6 “caro lento mala experiencia” Neg
7 “excelente relación calidad precio” Pos
8 “servicio terrible comida fría” Neg
9 “volveré sin duda excelente lugar” Pos
10 “decepcionante nada recomendable” Neg
11 “comida buena servicio amable” Pos
12 “lugar encantador volveré pronto” Pos

Nota

Preprocesamiento. Las reseñas ya están procesadas: minúsculas, sin puntuación, sin stop words, con stemming. Consulta el capítulo de preprocesamiento de texto para el pipeline completo.

2 Probabilidades previas

De las 12 reseñas, 7 son positivas y 5 negativas. En la práctica, las reseñas positivas suelen ser más frecuentes que las negativas: la gente tiende a recomendar más que a quejarse.

\[ P(\text{Pos}) = \frac{7}{12} = 0.583 \qquad P(\text{Neg}) = \frac{5}{12} = 0.417 \]

La razón de previos (prior odds) es la razón entre ambas probabilidades:

\[ \text{Odds} = \frac{P(\text{Pos})}{P(\text{Neg})} = \frac{7/12}{5/12} = \frac{7}{5} = 1.4 \]

Esto significa que, sin saber nada del contenido de la reseña, una reseña es 1.4 veces más probable que sea positiva que negativa. Es lo que sabemos antes de leer una sola palabra.

Para la fórmula de puntuación, usamos el logaritmo de esa razón:

\[ \log \frac{P(\text{Pos})}{P(\text{Neg})} = \log 1.4 = 0.336 \]

¿Por qué importa? Porque en la fórmula de puntuación:

\[ \text{score} = \underbrace{\log \frac{P(\text{Pos})}{P(\text{Neg})}}_{\text{previo}} + \sum \lambda(w_i) \]

el previo actúa como un punto de partida: si las positivas son más frecuentes en el corpus, el modelo “empieza” inclinándose hacia positivo (+0.336). Si las clases fueran 50/50, el logaritmo sería \(\log(1) = 0\) y la decisión dependería solo de las palabras.

3 Frecuencias por clase

Contamos cuántas veces aparece cada palabra en reseñas positivas y negativas:

corpus = [
    ("comida excelente servicio rápido", "pos"),
    ("comida mala servicio lento", "neg"),
    ("lugar muy bueno excelente comida", "pos"),
    ("nunca más vuelvo pésimo servicio", "neg"),
    ("comida rica lugar acogedor", "pos"),
    ("caro lento mala experiencia", "neg"),
    ("excelente relación calidad precio", "pos"),
    ("servicio terrible comida fría", "neg"),
    ("volveré sin duda excelente lugar", "pos"),
    ("decepcionante nada recomendable", "neg"),
    ("comida buena servicio amable", "pos"),
    ("lugar encantador volveré pronto", "pos"),
]

# Contar frecuencias
freq_pos, freq_neg = {}, {}
n_pos, n_neg = 0, 0
vocabulario = set()

for texto, clase in corpus:
    palabras = texto.split()
    for p in palabras:
        vocabulario.add(p)
        if clase == "pos":
            freq_pos[p] = freq_pos.get(p, 0) + 1
            n_pos += 1
        else:
            freq_neg[p] = freq_neg.get(p, 0) + 1
            n_neg += 1

vocabulario = sorted(vocabulario)
V = len(vocabulario)
print(f"Vocabulario: {V} palabras únicas")
print(f"Total palabras en reseñas positivas: {n_pos}")
print(f"Total palabras en reseñas negativas: {n_neg}")
Palabra Pos Neg Palabra Pos Neg
acogedor 1 0 amable 1 0
bueno 1 0 buena 1 0
caro 0 1 calidad 1 0
comida 4 2 decepcionante 0 1
duda 1 0 encantador 1 0
experiencia 0 1 excelente 4 0
fría 0 1 larga 0 1
lento 0 2 lugar 3 0
mala 0 2 más 0 1
nada 0 1 nunca 0 1
pésimo 0 1 pronto 1 0
precio 1 0 rápido 1 0
rica 1 0 recomendable 0 1
relación 1 0 servicio 2 2
sin 1 0 terrible 0 1
vuelvo 0 1 Total 31 18

Observa algo interesante: “comida” y “servicio” aparecen en ambas clases, pero con diferente peso. “Comida” aparece 4 veces en positivas y solo 2 en negativas, por lo que tiene una señal ligeramente positiva. “Servicio” aparece 2 veces en cada clase, por lo que es más neutral. En cambio, “excelente” solo aparece en positivas y “terrible” solo en negativas: estas son las palabras claramente discriminantes.

4 Probabilidades condicionales

La probabilidad de que una palabra aparezca dado el sentimiento:

\[ P(w \mid \text{Pos}) = \frac{\text{frecuencia de } w \text{ en reseñas positivas}}{N_{\text{Pos}}} \]

Donde \(N_{\text{Pos}} = 31\) es el total de palabras en la clase positiva.

Veamos el cálculo concreto para dos palabras:

Ejemplo con “excelente”: Aparece 4 veces en reseñas positivas y 0 en negativas.

\[ P(\text{excelente} \mid \text{Pos}) = \frac{4}{31} = 0.129 \qquad P(\text{excelente} \mid \text{Neg}) = \frac{0}{18} = 0.000 \]

Ejemplo con “comida”: Aparece 4 veces en positivas y 2 en negativas.

\[ P(\text{comida} \mid \text{Pos}) = \frac{4}{31} = 0.129 \qquad P(\text{comida} \mid \text{Neg}) = \frac{2}{18} = 0.111 \]

Observa la diferencia: “excelente” tiene una señal clara (existe solo en positivas), mientras que “comida” es más ambigua (aparece en ambas clases, aunque ligeramente más en positivas).

La siguiente tabla muestra una selección de las palabras más representativas. La tabla completa, con las 29 palabras del vocabulario, se genera con el código:

# Probabilidades crudas (sin suavizar)
for w in vocabulario:
    p_pos = freq_pos.get(w, 0) / n_pos
    p_neg = freq_neg.get(w, 0) / n_neg
    print(f"{w:18s}  P(w|Pos)={p_pos:.3f}  P(w|Neg)={p_neg:.3f}")
Palabra \(P(w \mid \text{Pos})\) \(P(w \mid \text{Neg})\)
excelente 0.129 0.000
comida 0.129 0.111
lugar 0.097 0.000
servicio 0.065 0.111
buena 0.032 0.000
amable 0.032 0.000
rica 0.032 0.000
lento 0.000 0.111
mala 0.000 0.111
terrible 0.000 0.056

El problema es inmediatamente visible: “excelente” tiene probabilidad cero en la clase negativa. Si una reseña negativa contiene la palabra “excelente” (por ejemplo, “no fue excelente”), el producto completo será cero, anulando toda la evidencia restante.

5 Suavización de Laplace

La suavización de Laplace resuelve el problema de las probabilidades cero añadiendo 1 al numerador y el tamaño del vocabulario \(V\) al denominador:

\[ \boxed{P(w \mid \text{clase}) = \frac{\text{freq}(w, \text{clase}) + 1}{N_{\text{clase}} + V}} \]

Veamos el cálculo concreto para las mismas dos palabras, con \(V = 29\):

Ejemplo con “excelente” (freq Pos = 4, freq Neg = 0):

\[ P(\text{excelente} \mid \text{Pos}) = \frac{4 + 1}{31 + 29} = \frac{5}{60} = 0.083 \]
\[ P(\text{excelente} \mid \text{Neg}) = \frac{0 + 1}{18 + 29} = \frac{1}{47} = 0.021 \]

Ejemplo con “comida” (freq Pos = 4, freq Neg = 2):

\[ P(\text{comida} \mid \text{Pos}) = \frac{4 + 1}{31 + 29} = \frac{5}{60} = 0.083 \]
\[ P(\text{comida} \mid \text{Neg}) = \frac{2 + 1}{18 + 29} = \frac{3}{47} = 0.064 \]

Observa cómo el +1 en el numerador elimina las probabilidades cero: “excelente” ya no tiene \(P(\text{excelente} \mid \text{Neg}) = 0\), sino 0.021. El +V en el denominador compensa ese aumento para que todas las probabilidades sigan sumando 1.

La siguiente tabla muestra una selección de las palabras más representativas. La tabla completa se genera con el código:

V = len(vocabulario)  # 29 palabras únicas

print("Con suavización de Laplace:")
for w in vocabulario:
    p_pos = (freq_pos.get(w, 0) + 1) / (n_pos + V)
    p_neg = (freq_neg.get(w, 0) + 1) / (n_neg + V)
    print(f"{w:18s}  P(w|Pos)={p_pos:.3f}  P(w|Neg)={p_neg:.3f}")
Palabra \(P(w \mid \text{Pos})\) \(P(w \mid \text{Neg})\)
excelente 0.098 0.025
comida 0.098 0.075
lugar 0.073 0.025
servicio 0.049 0.050
buena 0.024 0.025
amable 0.024 0.025
rica 0.024 0.025
lento 0.024 0.050
mala 0.024 0.050
terrible 0.024 0.037
Suma 1.000 1.000

Tip

Propiedad clave. Con Laplace, “excelente” ya no tiene probabilidad cero en la clase negativa (0.026 en vez de 0). Las sumas por clase siguen siendo exactamente 1.

6 Verosimilitud logarítmica

En la práctica, multiplicar muchas probabilidades pequeñas produce resultados con desbordamiento numérico. La solución: trabajar con logaritmos, convirtiendo productos en sumas.

6.1 Lambda: la señal de cada palabra

Definimos Lambda (\(\lambda\)) como el logaritmo de la razón de verosimilitudes:

\[ \boxed{\lambda(w) = \log \frac{P(w \mid \text{Pos})}{P(w \mid \text{Neg})}} \]
\(\lambda\) Significado
\(> 0\) Palabra positiva (señal a favor de Pos)
\(= 0\) Palabra neutral (no discrimina)
\(< 0\) Palabra negativa (señal a favor de Neg)

La siguiente tabla muestra las palabras más representativas. La tabla completa se genera con el código:

import numpy as np

print("Lambda por palabra:")
for w in vocabulario:
    p_pos = (freq_pos.get(w, 0) + 1) / (n_pos + V)
    p_neg = (freq_neg.get(w, 0) + 1) / (n_neg + V)
    lam = np.log(p_pos / p_neg)
    print(f"  {w:18s}  λ={lam:+.3f}")
Palabra \(\lambda\) Tipo
excelente +1.366 positiva
lugar +1.072 positiva
comida +0.268 positiva
rica −0.041 neutral
servicio −0.020 neutral
amable −0.041 neutral
buena −0.041 neutral
lento −0.734 negativa
mala −0.734 negativa
terrible −0.432 negativa

Nota

¿Por qué “comida” no es neutral? Aunque “comida” aparece en ambas clases, aparece el doble de veces en reseñas positivas (4 de 31) que en negativas (2 de 18). Esa proporción desbalanceada le da un λ de +0.268, una señal positiva modesta. “Servicio”, en cambio, aparece 2 veces en cada clase y es mucho más neutral (λ = −0.020).

6.2 Puntuación de un tweet

La puntuación completa es:

\[ \boxed{\text{score} = \log \frac{P(\text{Pos})}{P(\text{Neg})} + \sum_{i=1}^{m} \lambda(w_i)} \]
score Decisión
\(> 0\) Positivo
\(< 0\) Negativo
\(= 0\) Indeciso

7 Ejemplo: clasificar una reseña nueva

Reseña: “comida excelente pero lenta”

7.1 Paso 1: Preprocesar

Tokens: [comida, excelente, pero, lenta]

“pero” no está en nuestro vocabulario (no apareció en entrenamiento), se ignora.

7.2 Paso 2: Sumar Lambdas

Palabra \(\lambda\)
comida +0.268
excelente +1.366
lenta −0.734 (usando “lento”)
Suma +0.900

7.3 Paso 3: Añadir log-previo

\[ \log \frac{P(\text{Pos})}{P(\text{Neg})} = \log \frac{7/12}{5/12} = \log 1.4 = 0.336 \]

7.4 Paso 4: Decisión

\[ \text{score} = 0.336 + 0.900 = \mathbf{1.236} > 0 \quad \Rightarrow \quad \textbf{Positivo} \]

La reseña se clasifica como positiva. Aunque “lenta” es negativa, “excelente” tiene un peso mucho mayor (λ = +1.366 vs. λ = −0.734), y el previo ya inclina la balanza hacia positivo.

Reseña 2: “comida mala servicio lento”

Palabra \(\lambda\)
comida +0.268
mala −0.734
servicio −0.020
lento −0.734
Suma −1.220
\[ \text{score} = 0.336 + (-1.220) = \mathbf{-0.884} < 0 \quad \Rightarrow \quad \textbf{Negativo} \]

Reseña 3: “lugar acogedor comida rica”

Palabra \(\lambda\)
lugar +1.072
acogedor +1.366
comida +0.268
rica −0.041
Suma +2.665
\[ \text{score} = 0.336 + 2.665 = \mathbf{3.001} \gg 0 \quad \Rightarrow \quad \textbf{Positivo} \]

Nota

Efecto del previo. Con clases equilibradas (50/50), el previo sería 0 y la decisión dependería solo de las palabras. Aquí, el previo de 0.336 da una “ventaja” de +0.336 a la clase positiva antes de mirar cualquier palabra. Esto es realista: en reseñas de restaurantes, las positivas suelen ser más frecuentes.

8 Variantes de Naive Bayes

Existen tres variantes principales, cada una adecuada a un tipo de dato diferente:

Variante Tipo de dato Distribución Ejemplo
Multinomial Frecuencias (conteos) Multinomial Clasificación de texto (BOW)
Bernoulli Presencia/ausencia (0/1) Bernoulli Spambinario, clasificación de documentos
Gaussiano Variables continuas Normal (gaussiana) Clasificación con features numéricas

La Multinomial es la más usada para texto y es exactamente la que hemos implementado: cuenta frecuencias y estima probabilidades condicionales.

# scikit-learn ofrece las tres variantes
from sklearn.naive_bayes import MultinomialNB, BernoulliNB, GaussianNB

# Multinomial: para frecuencias
modelo_multi = MultinomialNB()

# Bernoulli: para presencia/ausencia
modelo_bern = BernoulliNB()

# Gaussiano: para datos continuos
modelo_gauss = GaussianNB()

8.1 Naive Bayes Gaussiano en detalle

Mientras el Multinomial cuenta frecuencias (apariciones de palabras), el Gaussiano estima la densidad de probabilidad de cada feature asumiendo una distribución normal.

¿Cuándo se usa? Cuando los datos son numéricos continuos: medidas de flores (largo del pétalo, ancho del sépalo), datos médicos (presión, temperatura, edad), financieros (ingresos, gastos)…

Cómo “entrena”: Para cada clase, estima dos parámetros de cada feature:

\[ \mu_c = \frac{1}{N_c} \sum_{i \in c} x_i \qquad \sigma_c^2 = \frac{1}{N_c} \sum_{i \in c} (x_i - \mu_c)^2 \]

Es decir, la media y la varianza de cada feature dentro de cada clase.

Cómo predice: La probabilidad de que un dato \(x_i\) pertenezca a la clase \(c\) se calcula con la fórmula de la gaussiana:

\[ P(x_i \mid c) = \frac{1}{\sqrt{2\pi\sigma_c^2}} \exp\left(-\frac{(x_i - \mu_c)^2}{2\sigma_c^2}\right) \]

Y la clasificación:

\[ \text{clase} = \arg\max_c \left[ \log P(c) + \sum_{i=1}^{d} \log P(x_i \mid c) \right] \]

8.1.1 Ejemplo paso a paso

Clasificar flores como Setosa o Versicolor según el largo del pétalo.

Lo que sabemos (entrenamiento):

Clase \(\mu\) (media) \(\sigma^2\) (varianza)
Setosa 1.5 cm 0.005
Versicolor 4.6 cm 0.008

Pregunta: ¿A qué clase pertenece un pétalo de 3.0 cm?

Paso 1: Calcular \(P(3.0 \mid \text{Setosa})\)

La gaussiana dibuja una campana centrada en la media. Cuanto más lejos estés de la media, más bajo es el valor. Calculemos la distancia de 3.0 a la media de Setosa:

  • Distancia al cuadrado: \((3.0 - 1.5)^2 = 2.25\)
  • Dos veces la varianza: \(2 \cdot 0.005 = 0.01\)
  • Número de “varianzas de distancia”: \(\frac{2.25}{0.01} = 225\)
  • \(e^{-225} \approx 0\)

Resultado: \(P(3.0 \mid \text{Setosa}) \approx 0\)

¿Por qué? Porque 3.0 cm está a 1.5 cm de la media de Setosa (1.5), pero la varianza es tiny (0.005). Los pétalos de Setosa siempre están muy cerca de 1.5 cm. Un valor de 3.0 es “imposible” para Setosa.

Paso 2: Calcular \(P(3.0 \mid \text{Versicolor})\)

  • Distancia al cuadrado: \((3.0 - 4.6)^2 = 2.56\)
  • Dos veces la varianza: \(2 \cdot 0.008 = 0.016\)
  • Número de “varianzas de distancia”: \(\frac{2.56}{0.016} = 160\)
  • \(e^{-160} \approx 0\)

Resultado: \(P(3.0 \mid \text{Versicolor}) > P(3.0 \mid \text{Setosa})\)

Aunque ambas probabilidades son extremadamente pequeñas, \(P(3.0 \mid \text{Versicolor})\) es muchísimo mayor que \(P(3.0 \mid \text{Setosa})\):

\[ P(3.0 \mid \text{Setosa}) \approx e^{-225} \approx 10^{-98} \qquad P(3.0 \mid \text{Versicolor}) \approx e^{-160} \approx 10^{-70} \]

Ambos son números absurdamente pequeños. Pero \(10^{-70}\) es \(10^{28}\) veces mayor que \(10^{-98}\). Es como comparar el tamaño de un átomo con el del universo observable: los dos son pequeñísimos, pero uno es inmensamente mayor que el otro.

Naive Bayes no necesita probabilidades “grandes”; solo necesita que la razón entre ellas sea clara. Y aquí \(10^{28}\) es una ventaja abrumadora a favor de Versicolor.

8.1.2 Ejemplo 2: un pétalo más cercano a Setosa

Ahora clasifiquemos un pétalo de 1.7 cm, mucho más cerca de la media de Setosa (1.5 cm):

Paso 1: \(P(1.7 \mid \text{Setosa})\)

  • \((1.7 - 1.5)^2 = 0.04\)
  • \(\frac{0.04}{2 \cdot 0.005} = 4\)
  • \(e^{-4} = 0.018\)

Paso 2: \(P(1.7 \mid \text{Versicolor})\)

  • \((1.7 - 4.6)^2 = 8.41\)
  • \(\frac{8.41}{2 \cdot 0.008} = 525.6\)
  • \(e^{-525.6} \approx 0\)

Resultado:

\[ \frac{P(1.7 \mid \text{Setosa})}{P(1.7 \mid \text{Versicolor})} = \frac{0.018}{\approx 0} \to \infty \]

Setosa gana por goleada. La razón es infinita (o al menos enorme). Un pétalo de 1.7 cm es típico de Setosa y prácticamente imposible de Versicolor.

Comparación visual:

Setosa (μ=1.5):          Versicolor (μ=4.6):

    │  ╱╲                    │  ╱╲
    │ ╱  ╲                   │ ╱  ╲
    │╱    ╲                  │╱    ╲
    ┼──●───╲──────→          ┼──────╲──●──→
   1.0 1.5 2.0              4.0  4.6 5.2
       ↑                          ↑
    1.7 cm                    1.7 cm
    (cerca del centro,        (muy lejos,
     P = 0.018)               P ≈ 0)

Lo que nos enseñan los dos ejemplos:

Pétalo \(P(\cdot \mid \text{Setosa})\) \(P(\cdot \mid \text{Versicolor})\) Clase
3.0 cm \(10^{-98}\) \(10^{-70}\) Versicolor
1.7 cm \(0.018\) \(\approx 0\) Setosa

La gaussiana es sensible a la distancia a la media: cuanto más cerca estés del centro de una clase, más alta es la probabilidad. Cuanto más lejos, más la “castiga”.

La intuición visual:

Setosa (μ=1.5):          Versicolor (μ=4.6):

    │  ╱╲                    │  ╱╲
    │ ╱  ╲                   │ ╱  ╲
    │╱    ╲                  │╱    ╲
    ┼──────╲──────→          ┼──────╲──────→
   1.0  1.5  2.0            4.0  4.6  5.2

    ↑                        ↑
  3.0 cm está aquí         3.0 cm está aquí
  (muy lejos, P≈0)         (lejos pero menos, P>bajo)

3.0 cm está “en la cola” de ambas campanas, pero más cerca del centro de Versicolor que del centro de Setosa.

from sklearn.datasets import load_iris
from sklearn.naive_bayes import GaussianNB
from sklearn.model_selection import cross_val_score

iris = load_iris()
gnb = GaussianNB()
scores = cross_val_score(gnb, iris.data, iris.target, cv=5)
print(f"Accuracy: {scores.mean():.3f} ± {scores.std():.3f}")

9 Aplicaciones más allá del sentimiento

9.1 Filtrado de spam

Dado un correo, clasificarlo como spam o ham:

\[ P(\text{spam} \mid \text{palabras}) \propto P(\text{spam}) \prod_{i=1}^{m} P(\text{palabra}_i \mid \text{spam}) \]

Palabras como “gratis”, “oferta”, “urgente” tienen alta verosimilitud en la clase spam. Naive Bayes fue uno de los primeros filtros de spam exitosos y sigue siendo un baseline sólido.

9.2 Clasificación de temas

Dado un artículo de periódico, determinar si es de deportes, política o economía:

\[ P(\text{deportes} \mid \text{palabras}) \propto P(\text{deportes}) \prod_{i} P(\text{palabra}_i \mid \text{deportes}) \]

Se calcula la probabilidad para cada tema y se elige el máximo.

9.3 Filtrado de contenido

Dada una página web, decidir si es apropiada para menores. Las palabras del vocabulario se asocian con categorías de contenido, y Naive Bayes estima la probabilidad de cada categoría.

10 Limitaciones

10.1 La suposición de independencia

Naive Bayes asume que las palabras son independientes dado la clase. Esto es falso en el lenguaje natural:

  • “comida” y “excelente” tienden a aparecer juntas en reseñas positivas
  • “no” y “bueno” juntos cambian el sentido por completo
  • El orden importa: “no fue bueno” ≠ “fue bueno”

A pesar de esto, Naive Bayes funciona bien porque la clasificación solo necesita que el orden de las probabilidades sea correcto, no que los valores sean exactos.

10.2 Sensibilidad al preprocesamiento

  • Eliminar “no” como stop word puede cambiar el sentimiento
  • El stemming puede unir palabras con sentidos diferentes
  • Los signos de exclamación son señales útiles que se pierden

10.3 Desbalance de clases

Si el corpus de entrenamiento tiene 90% positivas y 10% negativas, pero la realidad es 50/50, las previas serán incorrectas y el modelo será sistemáticamente optimista.

10.4 Ataques adversarios

El sarcasmo y la ironía confunden a Naive Bayes:

“Qué lugar tan ‘excepcional’, 3 horas de espera y la comida fría”

Las comillas y el tono indican sarcasmo, pero para Naive Bayes “excepcional” es una palabra positiva.

Tip

Cuándo usar Naive Bayes. Es ideal como baseline rápido: entrenar toma segundos y da un punto de referencia sólido. Si un modelo más complejo no supera significativamente a Naive Bayes, probablemente no merece la complejidad adicional. En clasificación de texto, Naive Bayes a menudo iguala o supera a modelos mucho más sofisticados.