Salta al contenuto principale

32 - Naive Bayes: il teorema di Bayes con un'ipotesi ingenua

Applichi il teorema di Bayes alla classificazione con l'ipotesi di indipendenza, scrivi Naive Bayes a mano con logaritmi e smoothing e ne misuri i limiti.

Cosa ne pensi di questo articolo?

32 - Naive Bayes: il teorema di Bayes con un'ipotesi ingenua
Machine Learning Engineerfedericocalo.dev

In Breve

Applichi il teorema di Bayes alla classificazione con l'ipotesi di indipendenza, scrivi Naive Bayes a mano con logaritmi e smoothing e ne misuri i limiti.

Un medico che riceve un paziente con febbre, tosse e mal di gola non ragiona su un sintomo alla volta: parte da quanto è frequente ogni malattia in generale, poi aggiorna la sua convinzione a ogni indizio. Un filtro antispam fa lo stesso con le parole di un messaggio: «gratis» sposta il giudizio verso spam, «riunione» verso messaggio legittimo, e la decisione finale è la somma di tutti gli indizi. Naive Bayes è questo: un classificatore che applica il teorema di Bayes e, per rendere i conti fattibili, fa un'ipotesi volutamente semplificata (da qui «naive», ingenuo) sulla relazione tra gli indizi.

È uno degli algoritmi più semplici del machine learning e proprio per questo è un ottimo punto di partenza: lo implementerai a mano in poche righe, vedrai che coincide con la libreria, e capirai in quali casi quell'ipotesi ingenua si paga cara.

Un'intuizione prima delle formule

Immagina di ricevere un messaggio che contiene la parola «gratis». Quanto sei convinto che sia spam? Dipende da due cose. Primo, da quanto spam arriva in generale nella tua casella (se il 90% della posta è spam, parti già sospettoso). Secondo, da quanto la parola «gratis» è tipica dello spam rispetto ai messaggi legittimi. Il teorema di Bayes è la regola che combina questi due ingredienti in modo coerente. Un esempio numerico, con cifre inventate solo per illustrare il meccanismo: il 10% dei messaggi è spam; un filtro che cerca «gratis» lo trova nel 95% dello spam e nel 2% dei messaggi legittimi. Se il filtro scatta, quanto è probabile lo spam?

spam e filtro scatta:      0,10 × 0,95 = 0,095
legittimo e filtro scatta: 0,90 × 0,02 = 0,018
P(spam | scatta) = 0,095 / (0,095 + 0,018) = 0,84

Il 95% di «sensibilità» del filtro non significa 95% di probabilità di spam: la probabilità finale è 84%, perché i messaggi legittimi sono molti e anche un 2% di falsi allarmi pesa. Sbagliare questo passaggio (confondere P(scatta | spam) con P(spam | scatta)) è uno degli errori di ragionamento più comuni, e Bayes è esattamente la formula che li collega.

Il teorema di Bayes, derivato in tre righe

La probabilità condizionata di A dato B è la frazione dei casi in cui accade B in cui accade anche A:

P(A | B) = P(A ∩ B) / P(B)

Lo stesso evento congiunto si può scrivere in due modi, P(A ∩ B) = P(B | A) · P(A). Sostituendo nella formula precedente si ottiene il teorema di Bayes:

P(A | B) = P(B | A) · P(A) / P(B)

Applicato alla classificazione, A è la classe c (per esempio «spam») e B sono le feature osservate x = (x₁, ..., x_n). Le tre quantità hanno nomi precisi:

P(c | x)  =  P(x | c) · P(c)  /  P(x)
 posteriori   verosimiglianza · a priori / evidenza
  • A priori P(c): quanto è frequente la classe prima di vedere il messaggio (la «percentuale base» di spam).
  • Verosimiglianza P(x | c): quanto sarebbe tipico vedere queste feature se la classe fosse c.
  • Posteriori P(c | x): quello che vogliamo, la probabilità della classe dopo aver visto i dati.

Le tre quantità hanno ruoli diversi e solo due si stimano dai dati: l'a priori si conta sulle etichette, la verosimiglianza si modella con l'ipotesi ingenua, la posteriori è ciò che si calcola. La confusione più frequente è scambiare verosimiglianza e posteriori, cioè P(scatta | spam) con P(spam | scatta), ed è l'errore che l'esempio numerico del filtro ha appena mostrato.

Teorema di Bayes come aggiornamentoL'a priori viene aggiornato dalle evidenze per dare l'a posterioriA priori P(c)quanto è frequente la classe×Verosimiglianza P(x|c)quanto è tipico vedere queste paroleA posteriori P(c|x)la tua convinzione aggiornata
L'a priori viene aggiornato dalle evidenze per dare l'a posteriori

Una notazione prima di proseguire: argmax. Scrivere argmax_c f(c) significa «il valore di c per cui f(c) è massimo», non il massimo stesso. Qui è la classe con il punteggio più alto.

Per scegliere la classe non serve calcolare P(x): è uguale per tutte le classi. Basta confrontare i numeratori, quindi la regola di decisione è

ŷ = argmax_c  P(c) · P(x₁, ..., x_n | c)

Esercizio 1

Il test che suona, e il tasso di base

Un'officina gestisce una flotta di stampanti 3D. Il 2% ha il cuscinetto dell'asse Y consumato. Un test di vibrazione suona nel 90% dei casi in cui il cuscinetto è davvero consumato, e nell'8% dei casi in cui è sano. Il test ha suonato.

  1. Scrivi la tua stima a occhio, poi calcola la probabilità esatta con il teorema di Bayes.
  2. Rifai lo stesso conto ragionando su 1000 stampanti invece che su percentuali. I due risultati coincidono?
  3. Ripeti con prior 0,10 e 0,50. Il test non cambia: che cosa cambia?

Le tre domande mostrano che il tasso di base governa il risultato più del test: la prima confronta l'intuizione con il calcolo, la seconda lo riscrive in conteggi su 1000 stampanti, la terza fa variare solo il prior e lascia fisso il test. Se la stima a occhio era molto più alta del risultato esatto, hai appena ripetuto l'errore del tasso di base.

Mostra la soluzione

1. P(guasto | suona) = 0,02 · 0,90 / (0,02 · 0,90 + 0,98 · 0,08) = 0,0180 / (0,0180 + 0,0784) = 0,0180 / 0,0964 = 0,1867. Diciotto per cento, non novanta. Il denominatore è la somma dei pesi di tutti i rami che portano a un test positivo, non solo di quello che ci interessa.

2. Su 1000 stampanti: 20 hanno il guasto e il test suona per 18 di quelle; 980 sono sane e il test suona per 78 di quelle. Allarmi totali 96, di cui veri 18: 18/96 = 0,1875. La minima differenza con 0,1867 è solo il tondeggiamento a conteggi interi. Questo modo di contare è il più sicuro: l'8% di falsi allarmi sembra poco, ma pesca da una popolazione quaranta volte più grande.

3. Con prior 0,10 la posteriore è 0,09 / (0,09 + 0,072) = 0,5556; con prior 0,50 è 0,45 / (0,45 + 0,04) = 0,9184. Lo strumento è identico in tutti e tre i casi: cambia soltanto quanto era plausibile il guasto prima di misurare. È il motivo per cui una sensibilità, da sola, non è mai una diagnosi — e il motivo per cui in Naive Bayes il prior non è un dettaglio da mettere a 0,5 per comodità.

L'ipotesi ingenua

Il problema è P(x₁, ..., x_n | c): stimare la probabilità congiunta di tutte le feature insieme richiederebbe, con n feature binarie, circa 2ⁿ parametri per classe, e dati che non avrai mai. L'ipotesi «naive» è assumere che, conoscendo la classe, le feature siano condizionatamente indipendenti:

P(x₁, ..., x_n | c) = P(x₁ | c) · P(x₂ | c) · ... · P(x_n | c)

Da 2ⁿ parametri si passa a circa n per classe. La regola finale è:

ŷ = argmax_c  P(c) · Π_i P(x_i | c)

L'ipotesi è quasi sempre falsa: in un testo, «premio» e «vinci» compaiono spesso insieme anche nello spam. Eppure il classificatore funziona sorprendentemente bene, perché per classificare non serve che le probabilità siano giuste, basta che la classe vincente sia quella giusta. Quando l'ipotesi è troppo sbagliata, però, le probabilità risultanti diventano inaffidabili: lo vedremo con un esperimento.

Due trucchi pratici: logaritmi e smoothing

Logaritmi. Moltiplicare cento probabilità minori di 1 produce numeri così piccoli che il computer li arrotonda a zero (underflow). Poiché il logaritmo è crescente, l'argmax non cambia se lavori sui logaritmi, dove i prodotti diventano somme:

ŷ = argmax_c  [ log P(c) + Σ_i log P(x_i | c) ]

Smoothing di Laplace. Se una parola non è mai comparsa nello spam del training, P(parola | spam) = 0, e quel singolo zero annulla l'intero prodotto: un solo termine sconosciuto cancella il peso di tutte le altre parole. Si corregge «fingendo» di aver visto ogni parola α volte in più (di solito α = 1): visto da vicino è un prior, e la lezione sulla statistica bayesiana lo deriva come media di una posterior Dirichlet. Per il modello multinomiale (conteggio di parole):

P(w | c) = ( conteggio(w, c) + α ) / ( N_c + α · |V| )

dove conteggio(w, c) è quante volte w compare nei messaggi di classe c, N_c è il totale delle parole in quella classe e |V| la dimensione del vocabolario. Il denominatore somma α·|V| perché abbiamo aggiunto α a ciascuna delle |V| parole: così le probabilità sommano ancora a 1.

Implementazione a mano su un mini-spam filter

Usiamo dieci messaggi inventati per l'occasione, abbastanza piccoli da poter seguire i numeri.

import numpy as np

spam = ["vinci un premio gratis adesso", "offerta gratis clicca adesso",
        "premio vinci denaro subito", "clicca qui per vincere denaro gratis",
        "offerta esclusiva premio gratis"]
ham  = ["riunione domani alle dieci", "ti mando il report della riunione",
        "pranzo domani con il team", "il report è pronto per domani",
        "ci vediamo a pranzo dopo la riunione"]

docs = spam + ham
y = np.array([1] * len(spam) + [0] * len(ham))        # 1 = spam
vocab = sorted({w for d in docs for w in d.split()})
idx = {w: i for i, w in enumerate(vocab)}

def conta(testo):
    v = np.zeros(len(vocab))
    for w in testo.split():
        if w in idx:                 # parole fuori vocabolario: ignorate
            v[idx[w]] += 1
    return v

X = np.array([conta(d) for d in docs])
print("documenti x parole:", X.shape)

alpha = 1.0
prior = np.array([(y == c).mean() for c in (0, 1)])           # P(c)
cont = np.array([X[y == c].sum(axis=0) for c in (0, 1)])      # conteggio(w, c)
verosim = (cont + alpha) / (cont.sum(axis=1, keepdims=True) + alpha * len(vocab))

def log_punteggi(testo):
    x = conta(testo)
    return np.log(prior) + x @ np.log(verosim).T      # log P(c) + sum_i x_i log P(w_i|c)

def posteriori(testo):
    s = log_punteggi(testo)
    e = np.exp(s - s.max())                           # softmax stabile
    return e / e.sum()

for t in ["gratis premio domani", "report della riunione", "clicca per vincere denaro"]:
    p = posteriori(t)
    print("%-28s P(ham)=%.3f  P(spam)=%.3f" % (t, p[0], p[1]))
documenti x parole: (10, 32)
gratis premio domani         P(ham)=0.133  P(spam)=0.867
report della riunione        P(ham)=0.949  P(spam)=0.051
clicca per vincere denaro    P(ham)=0.038  P(spam)=0.962

Cosa fa il codice, a pezzi. Vocabolario: l'elenco ordinato di tutte le parole presenti nei messaggi di training (32 parole); idx assegna a ciascuna una colonna. conta trasforma un messaggio in un vettore di 32 numeri, uno per parola, con quante volte compare: è la rappresentazione «bag of words», che butta via l'ordine e tiene solo i conteggi. X è la matrice (10 messaggi × 32 parole). prior contiene P(c) per le due classi, entrambe 0,5. cont somma i conteggi delle parole separatamente per classe, e verosim applica la formula con smoothing: la somma di ogni riga è 1. log_punteggi calcola log P(c) + Σ x_i log P(w_i|c) per le due classi; posteriori li riconverte in probabilità normalizzate.

Nota la riga x @ np.log(verosim).T: moltiplicare per il vettore dei conteggi equivale a sommare log P(w|c) una volta per ogni occorrenza della parola. La conversione finale da punteggi logaritmici a probabilità usa il trucco di sottrarre il massimo prima di esponenziare, per evitare overflow.

Prendi «gratis premio domani»: due parole tipiche dello spam e una tipica dei messaggi legittimi. Il modello assegna 0,867 allo spam: non un semplice conteggio 2 contro 1, ma il risultato delle frequenze apprese, del prior (5 messaggi su 10 sono spam) e dello smoothing.

Ora la libreria, e verifica che coincida

from sklearn.naive_bayes import MultinomialNB

nb = MultinomialNB(alpha=1.0).fit(X, y)
for t in ["gratis premio domani", "report della riunione", "clicca per vincere denaro"]:
    print("%-28s sklearn P(spam)=%.3f   a mano P(spam)=%.3f" % (
        t, nb.predict_proba(conta(t).reshape(1, -1))[0, 1], posteriori(t)[1]))
gratis premio domani         sklearn P(spam)=0.867   a mano P(spam)=0.867
report della riunione        sklearn P(spam)=0.051   a mano P(spam)=0.051
clicca per vincere denaro    sklearn P(spam)=0.962   a mano P(spam)=0.962

Cosa fa il codice: MultinomialNB(alpha=1.0) è la stessa cosa che abbiamo scritto, con lo stesso smoothing. fit(X, y) impara prior e verosimiglianze; predict_proba restituisce le probabilità per le due classi, e prendiamo la colonna 1 (spam). Il reshape(1, -1) trasforma il vettore in una matrice a una riga, perché la libreria si aspetta sempre una matrice (n campioni × d feature).

I numeri coincidono: la libreria non fa niente di più di quello che hai scritto, e questo vale la pena di ricordarlo. Quando un modello ti sembra una scatola nera, spesso è un'idea semplice con un'ingegnerizzazione attenta.

Esercizio 2

Quanto conta davvero lo smoothing

Venti rapporti di officina, dieci per guasto, su un vocabolario di cinque parole. Questi sono i conteggi sommati per classe.

parola            estrusore_intasato   piano_non_livellato
sottoestrusione           6                     1
clic                      4                     0
primo_strato              1                     7
distacco                  0                     5
temperatura               3                     1
TOTALE                   14                    14

Arriva un rapporto nuovo: «primo_strato distacco». I prior sono 0,5 e 0,5.

  1. Con smoothing di Laplace e alpha = 1, calcola le quattro probabilità che servono e il rapporto di verosimiglianza fra le due classi.
  2. Rifai il conto con alpha = 0,1. Il modello diventa più sicuro o meno sicuro?
  3. Che cosa succede con alpha = 0, e perché il risultato è inaccettabile anche se è matematicamente corretto?

Le tre domande fanno variare un solo parametro e chiedono di leggerne l'effetto: α = 1 è il caso di riferimento, α = 0,1 rende il modello più fiducioso nei conteggi osservati, α = 0 lo rende fragile alla prima parola mai vista. Il punto da portare via è che lo smoothing non è un dettaglio numerico ma un'assunzione sul prior.

Mostra la soluzione

1. Con alpha = 1 e vocabolario di cinque parole il denominatore è 14 + 5 = 19 per entrambe le classi. Quindi P(primo_strato | intasato) = 2/19, P(distacco | intasato) = 1/19, P(primo_strato | livellamento) = 8/19, P(distacco | livellamento) = 6/19. I prior sono uguali e si cancellano, quindi il rapporto a favore di «piano non livellato» è (8 · 6) / (2 · 1) = 48/2 = 24. In logaritmo, la differenza dei punteggi vale ln 24 = 3,178.

2. Con alpha = 0,1 il denominatore diventa 14 + 0,5 = 14,5 e i numeratori sono 7,1 · 5,1 contro 1,1 · 0,1. Il rapporto è 36,21 / 0,11 = 329. Il modello diventa molto più sicuro. La direzione sorprende finché non la si legge così: lo smoothing non aggiunge rumore, aggiunge prudenza. Toglierlo toglie prudenza.

3. Con alpha = 0 il conteggio di «distacco» per «estrusore intasato» è zero, quindi P(distacco | intasato) = 0, il punteggio logaritmico diventa meno infinito e il rapporto è infinito. Il modello dichiara impossibile una delle due classi sulla base di una parola che semplicemente non era ancora comparsa in dieci rapporti. È formalmente corretto e praticamente insostenibile: un'assenza di prove è stata trasformata in una prova di assenza. Per questo alpha è un iperparametro, e come tutti gli iperparametri si sceglie in validazione, non a occhio.

Feature continue: Gaussian Naive Bayes

Con feature numeriche (altezze, misure) non si contano parole: si assume che dentro ogni classe ogni feature segua una gaussiana, di cui stimi media μ e varianza σ² dai dati di quella classe:

P(x_i | c) = 1 / sqrt(2π σ²_ic) · exp( −(x_i − μ_ic)² / (2 σ²_ic) )

log P(x_i | c) = −½ [ log(2π σ²_ic) + (x_i − μ_ic)² / σ²_ic ]

Implementiamolo in forma vettoriale sul dataset Iris (tre specie di fiori, quattro misure), con una divisione stratificata come nella lezione precedente. Il codice scrive la classe da zero in forma vettoriale: per ogni classe tiene il prior, la media e la varianza di ogni misura, e valuta il logaritmo della gaussiana su tutti i campioni e tutte le classi in un colpo solo, per poi confrontarla con quella di scikit-learn sulle stesse previsioni:

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

class GaussianNBDaZero:
    def fit(self, X, y):
        self.classi = np.unique(y)
        self.prior = np.array([(y == c).mean() for c in self.classi])
        self.mu = np.array([X[y == c].mean(axis=0) for c in self.classi])
        self.var = np.array([X[y == c].var(axis=0) for c in self.classi]) + 1e-9
        return self

    def log_congiunta(self, X):
        # X: (n, d)  mu, var: (k, d)  -> risultato (n, k)
        diff2 = (X[:, None, :] - self.mu[None, :, :]) ** 2
        ll = -0.5 * (np.log(2 * np.pi * self.var)[None] + diff2 / self.var[None]).sum(axis=2)
        return ll + np.log(self.prior)

    def predict(self, X):
        return self.classi[self.log_congiunta(X).argmax(axis=1)]

Xi, yi = load_iris(return_X_y=True)
Xi_tr, Xi_te, yi_tr, yi_te = train_test_split(Xi, yi, test_size=0.3, stratify=yi, random_state=0)

mio = GaussianNBDaZero().fit(Xi_tr, yi_tr)
lib = GaussianNB().fit(Xi_tr, yi_tr)
print("accuratezza a mano:  %.3f" % (mio.predict(Xi_te) == yi_te).mean())
print("accuratezza sklearn: %.3f" % lib.score(Xi_te, yi_te))
print("previsioni identiche:", bool((mio.predict(Xi_te) == lib.predict(Xi_te)).all()))
print("shape di mu:", mio.mu.shape, "-> (classi, feature)")
accuratezza a mano:  0.978
accuratezza sklearn: 0.978
previsioni identiche: True
shape di mu: (3, 4) -> (classi, feature)

Cosa fa il codice: fit calcola, per ogni classe, il prior, la media di ogni feature e la varianza di ogni feature (il + 1e-9 evita divisioni per zero quando una varianza è nulla). log_congiunta valuta la formula del logaritmo della gaussiana per ogni campione, classe e feature, e le somma sulle feature. Il risultato (n, k) contiene un punteggio per coppia campione-classe e predict prende l'argmax per riga. Iris ha 3 classi e 4 feature, quindi mu ha forma (3, 4). Il fatto che le due implementazioni facciano le stesse 45 previsioni su 45 è la conferma che la libreria non nasconde nulla di più.

La forma dei tensori è la parte che più spesso si sbaglia: X[:, None, :] ha forma (n, 1, d), self.mu[None, :, :] ha forma (1, k, d), il broadcasting produce (n, k, d) e la somma sull'ultimo asse dà (n, k): un punteggio per ogni coppia (campione, classe).

La trappola: feature correlate e probabilità troppo sicure

Quando due feature sono quasi la stessa informazione, il modello la conta due volte, e l'evidenza viene gonfiata. Per vederlo in modo estremo, duplichiamo ripetutamente una colonna di Iris e osserviamo come cambia la probabilità assegnata a un punto ambiguo:

punto = Xi_te[[np.argmax(lib.predict_proba(Xi_te).max(axis=1) < 0.9)]]   # un punto non banale
print("probabilità con 4 feature originali:", np.round(lib.predict_proba(punto)[0], 3))

for copie in (3, 10):
    Xd = np.hstack([Xi_tr] + [Xi_tr[:, [2]]] * copie)     # ripeto la colonna 2 (lunghezza petalo)
    pd_ = np.hstack([punto] + [punto[:, [2]]] * copie)
    g = GaussianNB().fit(Xd, yi_tr)
    print("con %2d copie extra della colonna 2:" % copie, np.round(g.predict_proba(pd_)[0], 3))
probabilità con 4 feature originali: [0.   0.78 0.22]
con  3 copie extra della colonna 2: [0.    0.981 0.019]
con 10 copie extra della colonna 2: [0. 1. 0.]

Cosa fa il codice. La prima riga sceglie un punto «non banale» del test: predict_proba(...).max(axis=1) < 0.9 è un vettore di vero/falso (il modello è poco sicuro), argmax prende il primo vero; le doppie parentesi quadre mantengono la forma a matrice. Nel ciclo, np.hstack affianca alle 4 colonne originali copie copie della colonna 2 (lunghezza del petalo), sia nel training sia sul punto; poi addestra un nuovo GaussianNB e stampa le probabilità del punto per le tre specie.

Il punto parte da (0, 0,78, 0,22). Con tre copie extra della stessa colonna diventa (0, 0,981, 0,019); con dieci, (0, 1, 0). Le feature duplicate non portano informazione nuova, ma il modello le tratta come indizi indipendenti e sposta le probabilità verso gli estremi. L'accuratezza spesso non cambia di molto (la classe vincente può restare la stessa), ma le probabilità prodotte non sono più calibrate: un «99%» non significa che nel 99% dei casi simili la previsione è giusta. Se ti servono probabilità affidabili (per esempio per ordinare per rischio o per decidere una soglia) devi calibrarle a parte, oppure usare un altro modello.

Le tre varianti, in breve

  • MultinomialNB: feature = conteggi (parole, frequenze). Il classico dei testi.
  • BernoulliNB: feature binarie (la parola c'è / non c'è). Penalizza anche l'assenza di una parola, a differenza del multinomiale.
  • GaussianNB: feature continue, una gaussiana per feature e classe.

Le tre varianti si scelgono in base al tipo di feature, non alla preferenza: conteggi per il multinomiale, presenza o assenza per il Bernoulli, misure continue per il gaussiano. Scegliere la variante sbagliata dà errore (valori negativi nel multinomiale) oppure, peggio, un modello che gira ma ignora l'informazione che conta, come mostra BernoulliNB sui testi lunghi.

Trade-off

  • Pro: addestramento immediato (basta contare), pochi dati bastano, scala a migliaia di feature, facile da interpretare e da aggiornare.
  • Contro: l'ipotesi di indipendenza condizionata è raramente vera; le probabilità sono mal calibrate; non cattura interazioni tra feature (la presenza congiunta di due parole significa qualcosa di diverso dalla somma).
  • Quando usarlo: baseline veloce per classificazione di testo, pochi dati, necessità di un modello leggerissimo. Quando no: feature molto correlate e probabilità che devono essere affidabili.

Il bilancio si legge in un modo solo: Naive Bayes è un ottimo punto di partenza e un cattivo stimatore di probabilità. Si adotta quando contano velocità, semplicità e pochi dati, si abbandona quando le feature sono molto correlate o quando le probabilità devono essere affidabili. La riga più importante è l'ultima, perché dice quando smettere di usarlo.

Trappole reali

  • Data leakage nel vocabolario: se costruisci il vocabolario (o CountVectorizer/TfidfVectorizer) su tutto il dataset prima di dividere, il test set ha già influenzato il training. Il vettorizzatore va dentro una Pipeline e si addestra solo sul train.
  • Zero senza smoothing: con α = 0 una parola mai vista in una classe azzera la probabilità. Tieni α > 0.
  • Classi sbilanciate: il prior P(c) pesa tanto. Con l'1% di spam, il modello tende a dire «ham» finché l'evidenza non è schiacciante. Guarda precision e recall, non l'accuratezza (lezione precedente).
  • Shape mismatch: predict_proba vuole una matrice (n, d); passare un singolo vettore (d,) dà errore. Per un campione serve reshape(1, -1).
  • Feature sbagliate per la variante sbagliata: dare valori negativi (per esempio feature standardizzate) a MultinomialNB genera errore, perché i conteggi non possono essere negativi.

Le cinque trappole sono tutte errori di preparazione, non di algoritmo: il vocabolario costruito sul dataset intero, lo zero senza smoothing, il prior ignorato, la forma sbagliata dell'input, la variante sbagliata per il tipo di feature. Quattro danno un errore esplicito o si vedono nei numeri, la prima no: è quella da verificare a mano con una pipeline.

Un test su testi veri: 20 newsgroups

Il mini-filtro antispam con dieci messaggi era didattico, ma non dice nulla sul comportamento di Naive Bayes su testi reali. Usiamo il corpus classico «20 newsgroups»: messaggi pubblicati su gruppi di discussione, ciascuno etichettato con il gruppo. Prendiamo quattro argomenti (sci.space, rec.autos, talk.politics.guns, comp.graphics), 2 317 messaggi per l'addestramento e 1 543 per il test. Alla prima esecuzione scikit-learn scarica il corpus (qualche secondo). Il messaggio contiene, oltre al testo, intestazioni (headers), firme (footers) e citazioni di messaggi precedenti (quotes): informazioni che nella realtà potresti non avere, o che identificano il gruppo senza che il modello debba capire il testo. Misuriamo quanto pesano.

from sklearn.datasets import fetch_20newsgroups
from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer
from sklearn.naive_bayes import MultinomialNB, BernoulliNB, ComplementNB
from sklearn.pipeline import make_pipeline
from sklearn.linear_model import LogisticRegression

categories = ["sci.space", "rec.autos", "talk.politics.guns", "comp.graphics"]

def load(subset, clean):
    extra = dict(remove=("headers", "footers", "quotes")) if clean else {}
    return fetch_20newsgroups(subset=subset, categories=categories, random_state=0, **extra)

for clean in (False, True):
    train, test = load("train", clean), load("test", clean)
    pipe = make_pipeline(CountVectorizer(min_df=2), MultinomialNB(alpha=1.0)).fit(train.data, train.target)
    print("metadati rimossi: %-5s   esempi training %d, test %d   vocabolario %d parole   accuratezza sul test %.3f" % (
        clean, len(train.data), len(test.data), len(pipe[0].vocabulary_), pipe.score(test.data, test.target)))
metadati rimossi: False   esempi training 2317, test 1543   vocabolario 19001 parole   accuratezza sul test 0.962
metadati rimossi: True    esempi training 2317, test 1543   vocabolario 13134 parole   accuratezza sul test 0.866

Cosa fa il codice. CountVectorizer trasforma ogni testo in un vettore di conteggi di parole (la rappresentazione «bag of words» della lezione), scartando le parole che compaiono in meno di due documenti (min_df=2). Il vettorizzatore e il classificatore stanno nella stessa Pipeline: il vocabolario viene imparato solo sul training, e il test viene trasformato con quel vocabolario (nessun leakage dal vocabolario). La differenza tra le due righe è la parte istruttiva: con intestazioni, firme e citazioni l'accuratezza sul test è 0,962; togliendole scende a 0,866. Quasi dieci punti non sono «capacità di capire il testo»: sono metadati (indirizzi e-mail, nomi di organizzazioni, righe «Subject:», nomi di gruppo nelle firme) che identificano il gruppo senza bisogno di leggere. Un modello valutato così darebbe un'accuratezza gonfiata, e in produzione, dove quei metadati non ci sono, crollerebbe: è il leakage nella forma più concreta. Da qui in avanti usiamo la versione pulita, che è la stima onesta.

Quale variante, quale smoothing?

train, test = load("train", True), load("test", True)

print("smoothing alpha  ->  accuratezza (Multinomial Naive Bayes)")
for alpha in (0.001, 0.01, 0.1, 0.5, 1.0, 3.0):
    p = make_pipeline(CountVectorizer(min_df=2), MultinomialNB(alpha=alpha)).fit(train.data, train.target)
    print("   %-6g  ->  %.3f" % (alpha, p.score(test.data, test.target)))

print("\nvarianti e confronto con un modello discriminativo")
candidates = {
    "MultinomialNB (conteggi)": make_pipeline(CountVectorizer(min_df=2), MultinomialNB(alpha=0.1)),
    "BernoulliNB (presenza/assenza)": make_pipeline(CountVectorizer(min_df=2, binary=True), BernoulliNB()),
    "ComplementNB": make_pipeline(CountVectorizer(min_df=2), ComplementNB()),
    "regressione logistica su TF-IDF": make_pipeline(TfidfVectorizer(min_df=2), LogisticRegression(max_iter=2000)),
}
for name, p in candidates.items():
    p.fit(train.data, train.target)
    print("%-48s  accuratezza %.3f" % (name, p.score(test.data, test.target)))
smoothing alpha  ->  accuratezza (Multinomial Naive Bayes)
   0.001   ->  0.865
   0.01    ->  0.867
   0.1     ->  0.865
   0.5     ->  0.866
   1       ->  0.866
   3       ->  0.854

varianti e confronto con un modello discriminativo
MultinomialNB (conteggi)                          accuratezza 0.865
BernoulliNB (presenza/assenza)                    accuratezza 0.684
ComplementNB                                      accuratezza 0.870
regressione logistica su TF-IDF                   accuratezza 0.839

Cosa fa il codice. Il primo ciclo varia lo smoothing α su cinque ordini di grandezza: l'accuratezza resta tra 0,865 e 0,867 per α da 0,001 a 1 e cala a 0,854 solo con α = 3. Qui il valore di α conta poco, perché il corpus è grande e il vocabolario molto ricco; l'effetto dello smoothing si fa sentire con pochi dati o con parole rare.

Il secondo confronto è più interessante. BernoulliNB, che modella la presenza o assenza di ciascuna parola, ottiene solo 0,684: nei testi lunghi quasi tutte le parole del vocabolario sono assenti, e il modello, che penalizza anche l'assenza, viene dominato da migliaia di «non c'è» poco informativi. ComplementNB (una variante pensata per testi e classi sbilanciate, che stima i parametri usando i documenti di tutte le altre classi) fa 0,870, appena sopra il multinomiale (0,865). La regressione logistica su TF-IDF fa 0,839: più bassa, in questo caso.

Nessuna vittoria schiacciante di un approccio: su testi brevi con tanto vocabolario e pochi esempi, Naive Bayes è una baseline difficile da battere e si addestra in una frazione di secondo. Le differenze di mezzo punto (0,865 contro 0,870) non vanno commentate: sono sotto il rumore di campionamento di un test di 1 543 messaggi (circa ±0,9 punti).

Cosa ha imparato il modello? Le parole più indicative

Un vantaggio di Naive Bayes è la leggibilità: i parametri sono le log-probabilità P(parola | classe), e si possono ispezionare. Per ciascuna classe cerchiamo le parole che più la distinguono dalle altre, cioè con la massima differenza tra la log-probabilità nella classe e la media nelle altre.

pipe = make_pipeline(CountVectorizer(min_df=2), MultinomialNB(alpha=0.1)).fit(train.data, train.target)
vectorizer, nb = pipe[0], pipe[1]
vocabulary = np.array(vectorizer.get_feature_names_out())
log_prob = nb.feature_log_prob_                                       # (classi, parole): log P(parola | classe)

for k, name in enumerate(train.target_names):
    others = np.delete(log_prob, k, axis=0).mean(axis=0)
    top = np.argsort(-(log_prob[k] - others))[:8]
    print("%-20s %s" % (name, ", ".join(vocabulary[top])))
comp.graphics        jpeg, tiff, 3d, animation, visualization, polygon, vga, viewer
rec.autos            tires, brake, toyota, honda, sho, brakes, mustang, car
sci.space            lunar, spacecraft, orbital, orbit, moon, orbiter, titan, launches
talk.politics.guns   firearms, firearm, militia, amendment, handgun, assault, nra, rkba

Cosa fa il codice. feature_log_prob_ è la matrice (4 classi × parole del vocabolario) dei logaritmi di P(parola | classe), con smoothing. Per ogni classe k si sottrae la media delle altre tre e si prendono le otto parole con la differenza più alta. L'elenco è perfettamente leggibile: per la grafica compaiono «jpeg», «tiff», «3d», «polygon»; per le auto «tires», «brake», «toyota», «mustang»; per lo spazio «lunar», «spacecraft», «orbit», «moon»; per le armi «firearms», «militia», «amendment», «nra». Questa trasparenza è un vero vantaggio pratico: vedi subito se il modello si appoggia a parole sensate o a artefatti (nomi propri, codici, parole che dipendono dalla raccolta dei dati). Se tra le prime parole trovassi indirizzi e-mail o nomi di università, sapresti di avere un leakage, come nel confronto precedente.

Il difetto più noto: le probabilità sono troppo sicure

Abbiamo già visto con le feature duplicate che l'ipotesi di indipendenza gonfia le probabilità. Misuriamolo sul testo reale con una tabella di affidabilità (reliability table): raggruppiamo le previsioni del test per probabilità massima prevista e confrontiamo, in ciascun gruppo, la confidenza media del modello con la frazione di previsioni effettivamente corrette. Un modello calibrato ha le due colonne uguali.

proba = pipe.predict_proba(test.data)
confidence = proba.max(axis=1)
correct = proba.argmax(axis=1) == test.target

print("confidenza prevista     previsioni   confidenza media   frazione corretta")
edges = [0, 0.5, 0.7, 0.9, 0.99, 0.999, 1.0001]
for lo, hi in zip(edges[:-1], edges[1:]):
    in_bin = (confidence >= lo) & (confidence < hi)
    print("   [%.3f, %.3f)      %8d        %.3f              %.3f" % (lo, min(hi, 1.0), in_bin.sum(), confidence[in_bin].mean(), correct[in_bin].mean()))

document = vectorizer.transform([test.data[0]])
n_tokens = int(document.sum())
log_likelihood = (document.toarray()[0] * log_prob[test.target[0]]).sum()
print("\nprimo documento di test: %d parole; log-verosimiglianza %.1f; come probabilità, in virgola mobile: %s" % (n_tokens, log_likelihood, np.exp(log_likelihood)))
confidenza prevista     previsioni   confidenza media   frazione corretta
   [0.000, 0.500)            77        0.300              0.338
   [0.500, 0.700)            39        0.589              0.436
   [0.700, 0.900)            58        0.816              0.517
   [0.900, 0.990)            80        0.958              0.600
   [0.990, 0.999)            68        0.995              0.676
   [0.999, 1.000)          1221        1.000              0.956

primo documento di test: 165 parole; log-verosimiglianza -1132.1; come probabilità, in virgola mobile: 0.0

Cosa fa il codice. Si raggruppano le 1 543 previsioni in sei intervalli di confidenza massima. Il risultato è un esempio da manuale di sovra-confidenza. Nell'intervallo 0,99-0,999, dove il modello si dice sicuro al 99,5%, ha ragione solo nel 67,6% dei casi. In quello 0,9-0,99 (confidenza media 0,958) l'accuratezza è 0,60. Solo nell'intervallo estremo, oltre 0,999, che contiene la gran parte delle previsioni (1 221 su 1 543), la frazione corretta è 0,956: alta ma ancora sotto il 100% che il modello dichiara.

Il problema è la stessa ipotesi di indipendenza: le parole di un testo sono fortemente correlate (se c'è «orbit» è molto probabile che ci sia «spacecraft»), ma Naive Bayes le conta come indizi indipendenti e moltiplica le verosimiglianze, accumulando evidenza in eccesso.

Conseguenza pratica: non usare le probabilità di Naive Bayes come stime di rischio (per esempio per ordinare o per applicare soglie di costo come nella lezione sulle metriche). Se servono probabilità affidabili, si ricalibrano (CalibratedClassifierCV) oppure si passa a un modello discriminativo come la regressione logistica.

L'ultima riga mostra anche perché si lavora con i logaritmi: un messaggio di 165 parole ha una log-verosimiglianza di −1132, e exp(−1132) in virgola mobile vale esattamente 0,0, perché sotto circa 10⁻³²⁴ il numero non è rappresentabile. Senza i logaritmi, tutte le classi avrebbero probabilità zero e il classificatore non saprebbe scegliere.

Quando ha senso usare Naive Bayes oggi

Dopo questi esperimenti la valutazione può essere precisa. Naive Bayes è una baseline eccellente per la classificazione di testi: si addestra con un solo passaggio sui dati (basta contare), regge bene con poco materiale, e si legge. È anche una buona scelta quando devi aggiornare il modello in modo incrementale (si aggiungono conteggi, senza riaddestrare) o quando le risorse di calcolo sono minime. Non è invece la scelta giusta quando servono probabilità affidabili (si veda la tabella di affidabilità), quando le feature sono molto correlate e la loro interazione è decisiva (testi brevi in cui conta l'ordine delle parole, o dati tabellari con relazioni complesse), o quando cerchi l'ultimo punto di accuratezza. Il flusso di lavoro consigliato è semplice: parti da Naive Bayes come riferimento, e adotta un modello più sofisticato solo se la validazione, con più semi e con una stima dell'incertezza, mostra un vantaggio che giustifica la complessità in più.

Tre esercizi

Esercizio 1. Prova TfidfVectorizer con MultinomialNB al posto di CountVectorizer: TF-IDF riduce il peso delle parole molto comuni; cambia l'accuratezza? Esercizio 2. Aggiungi stop_words="english" al vettorizzatore (toglie «the», «and»...): e l'elenco delle parole indicative? Esercizio 3. Applica CalibratedClassifierCV(pipe, method="isotonic", cv=3) e rifai la tabella di affidabilità: le due colonne si avvicinano?

Gli esercizi di codice di questa lezione sono in un solo file, esercizi.py (circa 15 KB). Tre esercizi su un dominio nuovo, la diagnosi dei guasti di una stampante 3D dai registri di officina: Bayes con il tasso di base, un Naive Bayes multinomiale scritto da zero con lo smoothing parametrico, e la misura di quanto una colonna duplicata gonfia la sicurezza del modello. Richiede Python 3.12+ e NumPy 2.3. Eseguilo con python3 esercizi.py: gira anche con le parti da completare lasciate vuote e ti dice quali mancano.

Nella stessa serie rimandano a questa lezione anche: Probabilità: ragionare sull'incertezza, Random Forest: perché cento alberi sbagliano meno di uno, Clustering: K-Means e DBSCAN senza etichette.

Fonti

  • scikit-learn, Naive Bayes (guida utente) - le varianti multinomiale, di Bernoulli, gaussiana e complementare, lo smoothing di Laplace per α = 1 e l'avvertenza che le probabilità di predict_proba non vanno prese troppo sul serio, consultata il 2026-11-12
  • scikit-learn, fetch_20newsgroups - il corpus dei newsgroup e l'opzione remove che toglie intestazioni, firme e citazioni, consultata il 2026-11-12

Riferimenti non raggiunti online: T. Bayes, An Essay towards solving a Problem in the Doctrine of Chances, Philosophical Transactions of the Royal Society, 1763, pubblicato postumo; H. Zhang, The optimality of Naive Bayes, Proc. FLAIRS, 2004, indicato dalla stessa guida di scikit-learn come riferimento sul perché Naive Bayes classifica bene nonostante l'ipotesi di indipendenza. Il corpus 20 newsgroups non è stato scaricato per questa revisione: era già nella cache locale di scikit-learn. Tutti i listati sono stati eseguiti due volte con Python 3.14.4, NumPy 2.3.5 e scikit-learn 1.8.0: le uscite riportate coincidono con quelle reali.

Nella prossima lezione cambiamo filosofia: invece di stimare probabilità, cercheremo il confine che separa le classi con il margine più ampio possibile.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti