Salta al contenuto principale

18 - Probabilità: ragionare sull'incertezza

Dalle regole di Kolmogorov al teorema di Bayes: perché un test positivo al 90% può valere solo il 15% di probabilità, con Monty Hall e simulazioni.

Cosa ne pensi di questo articolo?

18 - Probabilità: ragionare sull'incertezza
Machine Learning Engineerfedericocalo.dev

In Breve

Dalle regole di Kolmogorov al teorema di Bayes: perché un test positivo al 90% può valere solo il 15% di probabilità, con Monty Hall e simulazioni.

Corso Machine Learning Engineer · Modulo 02 · Matematica e statistica · Lezione 3

Obiettivo. Dare alla parola «probabilità» una definizione che si può calcolare, derivarne le regole da tre assiomi, e usarle per due compiti che il machine learning chiede di continuo: aggiornare una credenza quando arriva un'informazione nuova (teorema di Bayes) e capire quando due eventi sono davvero indipendenti. Alla fine saprai spiegare perché un test «accurato al 90%» che risulta positivo può significare solo il 15% di probabilità di essere malati.

Falso mito da sfatare. «Dopo cinque teste di fila, è più probabile croce: la moneta deve compensare.» La moneta non ha memoria. Questa convinzione (la fallacia del giocatore) è la più diffusa e la più costosa; la smonteremo con una simulazione.

Introduzione

Un modello di machine learning non dà quasi mai una risposta certa: dà una probabilità («questa email è spam con probabilità 0.93»). Per leggerla, per fidarsi di lei e per capire quando sbaglia, serve il linguaggio della probabilità. Lo costruiamo dal basso, con le analogie al primo posto e le formule derivate per intero. Riprendiamo gli insiemi (lezione 1) e i conteggi (lezione 2): sono i mattoni.

Il percorso va dall'esperimento aleatorio agli assiomi di Kolmogorov, poi alla probabilità condizionata, all'indipendenza e al teorema di Bayes, e termina con un classificatore giocattolo, con il problema di Monty Hall e con gli errori di ragionamento più comuni. Per i conteggi rimando a Calcolo combinatorio, per il linguaggio degli eventi a Insiemi e logica.

Esperimento, spazio campionario, eventi

L'analogia: la roulette con le caselle numerate

Un esperimento aleatorio è un processo di cui conosci tutti i possibili risultati ma non quale uscirà: lancio un dado, estraggo una carta, un cliente apre o no una email. Ogni singolo risultato è un esito. L'insieme di tutti gli esiti è lo spazio campionario, indicato con Ω («omega»). Un evento è un sottoinsieme di Ω: un insieme di esiti che ci interessano. È esattamente il linguaggio della lezione sugli insiemi: «esce un numero pari» è l'insieme {2, 4, 6} ⊆ Ω = {1, 2, 3, 4, 5, 6}; «e» diventa intersezione, «o» unione, «non» complemento.

Probabilità con esiti equiprobabili

Se gli |Ω| esiti sono ugualmente possibili (un dado onesto), la probabilità di un evento A è la proporzione di esiti favorevoli:

P(A) = |A| / |Ω|.

Qui entra la combinatoria: per calcolare |A| e |Ω| si conta.

from itertools import product
from fractions import Fraction

omega = set(product(range(1, 7), repeat=2))     # spazio campionario di due dadi: 36 coppie
def P(evento):
    return Fraction(len(evento), len(omega))

somma_7 = {(a, b) for (a, b) in omega if a + b == 7}
doppio = {(a, b) for (a, b) in omega if a == b}
print("|Ω| =", len(omega))
print("P(somma = 7) =", P(somma_7))
print("P(doppio)    =", P(doppio))
print("P(somma = 7 o doppio) =", P(somma_7 | doppio), "| P(somma = 7 e doppio) =", P(somma_7 & doppio))

Output ottenuto:

|Ω| = 36
P(somma = 7) = 1/6
P(doppio)    = 1/6
P(somma = 7 o doppio) = 1/3 | P(somma = 7 e doppio) = 0

Cosa fa il codice. product(range(1, 7), repeat=2) genera le 36 coppie (dado1, dado2); gli eventi sono insiemi di coppie costruiti con una comprehension; la funzione P divide le cardinalità. Usiamo Fraction per ottenere frazioni esatte (6/36 viene stampato come 1/6) invece di decimali approssimati. L'ultima riga mostra l'unione («o») e l'intersezione («e») tra eventi: tra somma = 7 e doppio non c'è nessun esito comune (la somma 7 è dispari, un doppio ha somma pari), quindi l'intersezione è vuota.

Gli assiomi di Kolmogorov

Se gli esiti non sono equiprobabili (una moneta truccata) la regola |A|/|Ω| non vale più. Andrey Kolmogorov (1933) ha fissato tre regole minime che qualunque probabilità deve rispettare, e da cui tutto il resto si deriva:

  1. Non negatività: P(A) ≥ 0 per ogni evento.
  2. Normalizzazione: P(Ω) = 1 (qualcosa deve accadere).
  3. Additività: se A e B sono disgiunti (A ∩ B = ∅), allora P(A ∪ B) = P(A) + P(B).

Sono pochissime regole, e basta romperne una per ottenere una «probabilità» che non si comporta come tale: con un valore negativo si potrebbero comprare scommesse a costo opposto, con P(Ω) ≠ 1 le percentuali non sommerebbero a cento, senza additività due eventi esclusivi potrebbero valere più della loro somma. Per gli spazi infiniti l'assioma 3 si estende a infiniti eventi disgiunti numerabili; nei nostri esempi finiti la versione a due eventi è sufficiente.

Cosa si deriva, passo per passo

Il complementare. A e Aᶜ sono disgiunti e la loro unione è Ω. Per l'assioma 3: P(A) + P(Aᶜ) = P(Ω) = 1 (assioma 2). Quindi P(Aᶜ) = 1 − P(A). È la formula che abbiamo usato per i compleanni.

L'evento impossibile. Ω e ∅ sono disgiunti e Ω ∪ ∅ = Ω: P(Ω) + P(∅) = P(Ω), da cui P(∅) = 0.

Monotonia e limite superiore. Se A ⊆ B, scrivi B = A ∪ (B ∖ A) (disgiunti): P(B) = P(A) + P(B∖A) ≥ P(A) per l'assioma 1. In particolare P(A) ≤ P(Ω) = 1: nessuna probabilità supera 1.

La regola dell'unione. Per due eventi qualunque (anche non disgiunti): A ∪ B si spezza nelle tre parti disgiunte A∖B, A∩B, B∖A. Con P(A) = P(A∖B) + P(A∩B) e P(B) = P(B∖A) + P(A∩B) e sommando le tre parti si ottiene

P(A ∪ B) = P(A) + P(B) − P(A ∩ B)

(l'inclusione-esclusione della lezione 1, ora per probabilità). La stessa dimostrazione, con le parole «numero di elementi» al posto di «probabilità».

# verifica delle conseguenze sui due dadi
A, B = somma_7, {(a, b) for (a, b) in omega if a == 3 or b == 3}      # B: almeno un 3
print("P(A) =", P(A), "| P(B) =", P(B), "| P(A∩B) =", P(A & B))
print("regola dell'unione:", P(A | B) == P(A) + P(B) - P(A & B))
print("complementare     :", P(omega - A) == 1 - P(A))
print("monotonia (A∩B ⊆ A):", P(A & B) <= P(A))

Output ottenuto:

P(A) = 1/6 | P(B) = 11/36 | P(A∩B) = 1/18
regola dell'unione: True
complementare     : True
monotonia (A∩B ⊆ A): True

Cosa fa il codice. Prende due eventi sovrapposti (somma 7; almeno un 3) e verifica con numeri esatti tre delle conseguenze appena dimostrate. Se una delle tre risultasse False, avremmo sbagliato un passaggio: il computer funge da controllo, non da dimostrazione.

La probabilità come frequenza: la simulazione

Che cosa significa «la probabilità di testa è 0.5»? Una lettura operativa: se ripeti l'esperimento molte volte, la frazione di teste si avvicina a 0.5. È la legge dei grandi numeri, che dimostreremo in forma elementare nella lezione sulle variabili aleatorie; qui la osserviamo.

import numpy as np

rng = np.random.default_rng(42)
lanci = rng.integers(0, 2, size=1_000_000)       # 0 = croce, 1 = testa

for n in (10, 100, 1_000, 10_000, 100_000, 1_000_000):
    freq = lanci[:n].mean()
    print(f"n = {n:>9,}: frequenza di testa = {freq:.4f}   scarto da 0.5 = {abs(freq - 0.5):.4f}".replace(",", "."))

Output ottenuto:

n =        10: frequenza di testa = 0.4000   scarto da 0.5 = 0.1000
n =       100: frequenza di testa = 0.5200   scarto da 0.5 = 0.0200
n =     1.000: frequenza di testa = 0.5090   scarto da 0.5 = 0.0090
n =    10.000: frequenza di testa = 0.4939   scarto da 0.5 = 0.0061
n =   100.000: frequenza di testa = 0.5009   scarto da 0.5 = 0.0009
n = 1.000.000: frequenza di testa = 0.4998   scarto da 0.5 = 0.0002

Cosa fa il codice. Genera un milione di lanci simulati (zero e uno), poi guarda i primi n per n crescente e stampa la frazione di uni. La media di un array di 0 e 1 è esattamente la frequenza relativa degli uni. Lo scarto da 0.5 tende a ridursi, anche se non in modo regolare: a 10 lanci può essere grande, a un milione è centinaia di volte più piccolo. Una cosa importante: lo scarto assoluto nel numero di teste non si annulla (può crescere), si annulla la frazione. È il motivo per cui la fallacia del giocatore è tale: il futuro non «ripaga» il passato, lo diluisce.

Probabilità condizionata

L'analogia: restringere la stanza

Sei in una stanza con 100 persone: 40 portano gli occhiali, 20 sono studenti, 10 sono studenti con gli occhiali. Qual è la probabilità che una persona scelta a caso porti gli occhiali? 40/100. Ora ti dicono: «la persona scelta è studente». La stanza si è ristretta ai 20 studenti: in quella stanza, 10 hanno gli occhiali. La probabilità diventa 10/20 = 0.5. Hai cambiato l'universo di riferimento.

Probabilità condizionata come restringere la stanzaDue pannelli: lo spazio intero con A e B, poi solo B, dove conta la quota occupata da A∩B.Prima: tutto lo spazio ΩABA∩BDopo aver saputo B: solo BBA∩BP(A|B) = P(A∩B) / P(B): la parte verde dentro B
Figura: sapere che B è accaduto cambia l'universo di riferimento.

Definizione. La probabilità di A sapendo che B è accaduto è

P(A | B) = P(A ∩ B) / P(B), con P(B) > 0.

(Il numeratore è la quota di B occupata anche da A; il denominatore è la dimensione della nuova stanza.) Riscritta, è la regola del prodotto: P(A ∩ B) = P(A | B) · P(B): per avere sia B sia A, prima ci vuole B, poi A dato B.

def P_cond(A, B):
    """P(A | B) con la definizione: P(A ∩ B) / P(B)."""
    return P(A & B) / P(B)

somma_8 = {(a, b) for (a, b) in omega if a + b == 8}
primo_pari = {(a, b) for (a, b) in omega if a % 2 == 0}
print("P(somma = 8)                    =", P(somma_8))
print("P(somma = 8 | primo dado pari)  =", P_cond(somma_8, primo_pari))
print("verifica contando nella stanza ristretta:",
      Fraction(len(somma_8 & primo_pari), len(primo_pari)))

Output ottenuto:

P(somma = 8)                    = 5/36
P(somma = 8 | primo dado pari)  = 1/6
verifica contando nella stanza ristretta: 1/6

Cosa fa il codice. P_cond è la definizione tradotta alla lettera. L'ultima riga conta direttamente dentro la «stanza» ristretta (primo_pari) e ottiene lo stesso numero: è l'analogia diventata codice. Sapere che il primo dado è pari cambia la probabilità della somma 8 (da 5/36 a 1/6, perché alcune coppie con somma 8 escono dalla stanza).

Attenzione alla direzione. P(A | B) e P(B | A) sono in generale diverse. «La probabilità che un colpevole abbia un certo gruppo sanguigno» non è «la probabilità che chi ha quel gruppo sia colpevole». Confonderle è la fallacia del procuratore e ha mandato in tribunale conclusioni sbagliate.

Indipendenza

Due eventi sono indipendenti se saperne uno non cambia la probabilità dell'altro: P(A | B) = P(A). Con la definizione di condizionata è equivalente a

P(A ∩ B) = P(A) · P(B).

Due lanci di moneta sono indipendenti: la moneta non ricorda. Ma attenzione a una confusione molto comune: disgiunti non vuol dire indipendenti. Due eventi disgiunti con probabilità positive sono addirittura dipendenti al massimo: se uno accade, l'altro non può accadere (P(A | B) = 0 ≠ P(A)).

primo_6 = {(a, b) for (a, b) in omega if a == 6}
somma_pari = {(a, b) for (a, b) in omega if (a + b) % 2 == 0}
secondo_6 = {(a, b) for (a, b) in omega if b == 6}

def indipendenti(A, B):
    return P(A & B) == P(A) * P(B)

print("primo = 6  vs secondo = 6   indipendenti:", indipendenti(primo_6, secondo_6))
print("primo = 6  vs somma pari    indipendenti:", indipendenti(primo_6, somma_pari))
print("somma = 7  vs somma = 8 (disgiunti) indipendenti:", indipendenti(somma_7, somma_8))
print("somma = 7  vs primo = 6              indipendenti:", indipendenti(somma_7, primo_6))

Output ottenuto:

primo = 6  vs secondo = 6   indipendenti: True
primo = 6  vs somma pari    indipendenti: True
somma = 7  vs somma = 8 (disgiunti) indipendenti: False
somma = 7  vs primo = 6              indipendenti: True

Cosa fa il codice. Verifica la definizione P(A∩B) = P(A)P(B) con frazioni esatte, quindi senza errori di arrotondamento (lezione sui float). Il terzo caso mostra la trappola: somma = 7 e somma = 8 sono disgiunti ma non indipendenti. Il quarto è un caso istruttivo: sapere che il primo dado è 6 non cambia la probabilità di ottenere somma 7 (qualunque sia il primo dado, esiste esattamente un valore del secondo che dà 7), e infatti sono indipendenti anche se «sembrano» legati.

Nel machine learning. L'ipotesi di indipendenza tra le caratteristiche (dato l'esito) è la semplificazione del classificatore Naive Bayes; l'ipotesi di indipendenza tra gli esempi (campioni indipendenti) è alla base di quasi tutta la statistica. Quando è falsa (serie storiche, più righe dello stesso cliente) i risultati diventano troppo ottimisti.

Esercizio 1

Il varco della zona a traffico limitato

Un varco simulato registra 200 veicoli in un'ora e li classifica in due modi: autorizzato o no, targa pulita o sporca. I conteggi sono questi.

targa pulitatarga sporca
autorizzato12040
non autorizzato3010
  1. Calcola P(N), probabilità che un veicolo scelto a caso sia non autorizzato, e P(S), che abbia la targa sporca.
  2. Calcola P(N | S) e P(S | N). I due numeri sono diversi: che domanda risponde ciascuno?
  3. I due eventi sono indipendenti? Verificalo con la definizione, e poi di' che cosa questa risposta non autorizza a concludere.

Costruisci prima le due marginali dai totali della tabella; poi, per ogni condizionata, scrivi per esteso a quale sottoinsieme di veicoli ti stai limitando, perché è il denominatore a cambiare e non il numeratore.

Mostra la soluzione

1. I non autorizzati sono 30 + 10 = 40 su 200, quindi P(N) = 40/200 = 1/5. Le targhe sporche sono 40 + 10 = 50 su 200, quindi P(S) = 50/200 = 1/4. Sono i totali di riga e di colonna divisi per il totale generale: le marginali, che si chiamano così perché stanno ai margini della tabella.

2. Condizionare vuol dire cambiare il denominatore. P(N | S) = 10/50 = 1/5: dentro le 50 targhe sporche, 10 veicoli non sono autorizzati. P(S | N) = 10/40 = 1/4: dentro i 40 non autorizzati, 10 hanno la targa sporca. La prima risponde «fra le targhe sporche, quanti irregolari?», la seconda «fra gli irregolari, quante targhe sporche?». Scambiarle è l'errore di inversione della condizionata, lo stesso che confonde la sensibilità di un test con la sua precisione.

3. Sì: P(N ∩ S) = 10/200 = 1/20 e P(N)·P(S) = (1/5)·(1/4) = 1/20. Coincidono, quindi gli eventi sono indipendenti — e si vede anche dal fatto che P(N | S) = 1/5 = P(N): sapere che la targa è sporca non cambia nulla. Ciò che non si può concludere: che lo stato della targa sia irrilevante per il sistema. L'indipendenza riguarda queste due caratteristiche dei veicoli, non la qualità della lettura; una targa sporca può far sbagliare il lettore molto più spesso, e quella è una probabilità condizionata a un altro evento, che questa tabella non misura.

Probabilità totale e teorema di Bayes

La legge della probabilità totale

Se gli eventi A₁, A₂, …, A_k formano una partizione di Ω (disgiunti, con unione Ω), allora ogni evento B si spezza nei pezzi B ∩ Aᵢ disgiunti, e per la regola del prodotto

P(B) = Σᵢ P(B | Aᵢ) · P(Aᵢ).

È una media pesata: la probabilità di B è la media delle sue probabilità «in ciascun caso», pesate con quanto è probabile ogni caso.

Il teorema di Bayes

Dalla regola del prodotto scritta in due modi, P(A ∩ B) = P(B | A) P(A) = P(A | B) P(B), dividendo per P(B):

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

È il teorema di Thomas Bayes (pubblicato postumo nel 1763) nella forma resa celebre da Laplace. Si legge così: la nuova credenza in A dopo aver visto B (posteriore) è la credenza di partenza (prior, P(A)) corretta dal rapporto tra quanto B è atteso se A è vero e quanto B è atteso in generale (la verosimiglianza P(B | A) normalizzata da P(B), calcolata con la probabilità totale).

L'esempio che inganna quasi tutti: il test diagnostico

Parametri inventati e dichiarati: una malattia riguarda l'1% della popolazione; un test la riconosce nel 90% dei malati (sensibilità); dà un falso allarme nel 5% dei sani (tasso di falsi positivi). Un paziente risulta positivo. Quanto è probabile che sia malato?

L'intuizione risponde «circa 90%». Calcoliamo.

prevalenza = 0.01           # P(malato)
sensibilita = 0.90          # P(positivo | malato)
falsi_positivi = 0.05       # P(positivo | sano)

p_pos = sensibilita * prevalenza + falsi_positivi * (1 - prevalenza)      # probabilità totale
p_malato_dato_pos = sensibilita * prevalenza / p_pos                      # Bayes
print(f"P(positivo)            = {p_pos:.4f}")
print(f"P(malato | positivo)   = {p_malato_dato_pos:.4f}")

# la stessa cosa contando persone, su 10.000
n = 10_000
malati = n * prevalenza
veri_pos = malati * sensibilita
falsi_pos = (n - malati) * falsi_positivi
print(f"su {n}: malati = {malati:.0f}, positivi veri = {veri_pos:.0f}, falsi positivi = {falsi_pos:.0f}")
print(f"tra i positivi, i malati sono {veri_pos:.0f} su {veri_pos + falsi_pos:.0f} = {veri_pos / (veri_pos + falsi_pos):.4f}")

Output ottenuto:

P(positivo)            = 0.0585
P(malato | positivo)   = 0.1538
su 10000: malati = 100, positivi veri = 90, falsi positivi = 495
tra i positivi, i malati sono 90 su 585 = 0.1538

Cosa fa il codice. La prima parte applica le due formule (probabilità totale al denominatore, poi Bayes). La seconda ripete il calcolo contando persone su 10 000: è il metodo delle frequenze naturali, che gli studi di Gigerenzer e Hoffrage (1995) hanno mostrato rendere il ragionamento bayesiano molto più facile da seguire. Risultato: il 15.4% circa.

Teorema di Bayes con le frequenze naturaliAlbero con 10 000 persone: 100 malati di cui 90 positivi, 9 900 sani di cui 495 positivi; i malati tra i positivi sono 90 su 585.10 000 persone100 malati (1 %)9 900 sani (99 %)90 positivi(sensibilità 90 %)10 negativi495 positivi(falsi positivi 5 %)9 405 negativitra i positivi: 90 malati su 90 + 495 = 585 → 15,4 %
Figura: parametri inventati, dichiarati nel testo.

Perché così basso? Perché la malattia è rara: su 10 000 persone i malati sono 100, i sani 9 900. Il 5% di falsi allarmi sui sani produce 495 positivi sbagliati, molti più dei 90 positivi veri. La frequenza di base (il prior) pesa quanto la precisione del test. Trascurarla è la fallacia della frequenza di base.

Aggiornare di nuovo. Dopo un secondo test positivo (assumendo, ipotesi dichiarata, che i due test siano indipendenti dato lo stato di salute), il posteriore del primo diventa il prior del secondo:

def aggiorna(prior, sens=0.90, fp=0.05):
    pos = sens * prior + fp * (1 - prior)
    return sens * prior / pos

p = prevalenza
for i in range(1, 4):
    p = aggiorna(p)
    print(f"dopo {i} test positivi: P(malato) = {p:.4f}")

Output ottenuto:

dopo 1 test positivi: P(malato) = 0.1538
dopo 2 test positivi: P(malato) = 0.7660
dopo 3 test positivi: P(malato) = 0.9833

Cosa fa il codice. Applica aggiorna ripetutamente, riusando l'uscita come ingresso: il posteriore diventa il prior successivo. Con tre test positivi la probabilità passa da 1% a circa il 98%. È l'idea alla base dei filtri bayesiani e dell'apprendimento incrementale, e mostra che l'evidenza si accumula.

Il legame con le metriche (lezione 3 del modulo 00). La probabilità di essere malato dato un positivo è la precisione di un classificatore, e la sensibilità è il richiamo. Lo stesso test, applicato a una popolazione con una prevalenza diversa, ha precisione diversa: la qualità di un modello non si esprime con un numero solo, ma con il numero e la popolazione in cui è usato.

Un classificatore bayesiano giocattolo

Applichiamo Bayes a un problema di testi, con un mini-corpus inventato: 8 messaggi classificati come spam o normali, e le parole che contengono.

corpus = [
    ({"gratis", "vinci"}, "spam"), ({"gratis", "offerta"}, "spam"), ({"vinci", "offerta", "clicca"}, "spam"),
    ({"clicca", "gratis"}, "spam"), ({"riunione", "domani"}, "normale"), ({"domani", "offerta"}, "normale"),
    ({"riunione", "progetto"}, "normale"), ({"progetto", "gratis"}, "normale"),
]
vocabolario = {p for parole, _ in corpus for p in parole}
classi = ("spam", "normale")

prior = {c: sum(1 for _, y in corpus if y == c) / len(corpus) for c in classi}

def p_parola_dato_classe(parola, classe, alpha=1.0):
    """P(parola nel messaggio | classe) con lisciamento di Laplace."""
    messaggi = [parole for parole, y in corpus if y == classe]
    con = sum(1 for parole in messaggi if parola in parole)
    return (con + alpha) / (len(messaggi) + 2 * alpha)

def posteriore(parole_osservate):
    punteggio = {}
    for c in classi:
        p = prior[c]
        for w in vocabolario:                         # naive: parole indipendenti dato la classe
            pw = p_parola_dato_classe(w, c)
            p *= pw if w in parole_osservate else (1 - pw)
        punteggio[c] = p
    tot = sum(punteggio.values())
    return {c: v / tot for c, v in punteggio.items()}

print("prior:", prior)
print("{'gratis','clicca'}   ->", {c: round(v, 3) for c, v in posteriore({'gratis', 'clicca'}).items()})
print("{'riunione','domani'} ->", {c: round(v, 3) for c, v in posteriore({'riunione', 'domani'}).items()})

Output ottenuto:

prior: {'spam': 0.5, 'normale': 0.5}
{'gratis','clicca'}   -> {'spam': 0.926, 'normale': 0.074}
{'riunione','domani'} -> {'spam': 0.024, 'normale': 0.976}

Cosa fa il codice, riga per riga. prior è la frazione di messaggi per classe. p_parola_dato_classe stima P(parola presente | classe) come frazione di messaggi di quella classe che contengono la parola, con lisciamento di Laplace (+alpha): senza di esso una parola mai vista in una classe darebbe probabilità 0 e annullerebbe tutto il prodotto (un solo zero cancella ogni altra evidenza). posteriore applica Bayes: moltiplica il prior per la verosimiglianza di ogni parola (presente o assente), assumendo indipendenza condizionata delle parole dato la classe (l'ipotesi «naive»), e poi normalizza dividendo per la somma (il denominatore P(B) della probabilità totale). I messaggi con «gratis» e «clicca» risultano molto più probabilmente spam; quelli con «riunione» e «domani» molto più probabilmente normali.

Trade-off. L'ipotesi di indipendenza è quasi sempre falsa (le parole di un testo sono correlate), eppure il Naive Bayes funziona sorprendentemente bene perché per classificare serve l'ordine dei posteriori, non il loro valore esatto. Ma le probabilità che restituisce sono spesso mal calibrate: non vanno lette come percentuali vere.

Esercizio 2

Quanti dei segnalati sono davvero irregolari

Lo stesso varco simulato, ora con i parametri del lettore. Un veicolo su 50 in ingresso non è autorizzato. Il lettore segnala un veicolo non autorizzato con probabilità 24/25 e segnala per errore un veicolo autorizzato con probabilità 1/25. Carta e penna, frazioni esatte.

  1. Il lettore segnala un veicolo. Qual è la probabilità che sia davvero non autorizzato?
  2. Rifai il conto ragionando su un lotto di 1250 veicoli, e di' da dove arriva la differenza rispetto al 96% del lettore.
  3. Di notte passano meno veicoli e la quota di irregolari sale a 1/5. Quanto diventa la probabilità del punto 1, con lo stesso lettore?

Il dato nascosto nel testo è la frequenza di base: un veicolo su 50. Prova a rispondere a mente con la tua intuizione prima di calcolare, annota il numero, e confrontalo con quello che esce dal conto: lo scarto è la misura di quanto la frequenza di base sia stata trascurata.

Mostra la soluzione

1. Bayes, con il denominatore scritto per probabilità totale: P(N|seg) = (24/25 · 1/50) / (24/25 · 1/50 + 1/25 · 49/50). Riducendo tutto a cinquantesimi di venticinquesimi: numeratore 24/1250, secondo addendo 49/1250, denominatore 73/1250. Quindi P(N|seg) = 24/73 ≈ 0,3288, cioè meno di un terzo.

2. Su 1250 veicoli: 25 non autorizzati e 1225 autorizzati. Il lettore segnala 25 · 24/25 = 24 dei primi e 1225 · 1/25 = 49 dei secondi, in tutto 73 segnalazioni di cui 24 giuste: 24/73, lo stesso numero. La differenza rispetto al 96% è tutta qui: il 96% risponde a «se è irregolare, lo prendo?», non a «se l'ho preso, è irregolare?». Il 4% di un gruppo da 1225 produce più segnalazioni (49) del 96% di un gruppo da 25 (24). Una quota piccola di un gruppo grande batte una quota grande di un gruppo piccolo.

3. Con prevalenza 1/5: numeratore 24/25 · 1/5 = 24/125, falsi positivi 1/25 · 4/5 = 4/125, denominatore 28/125, quindi 24/28 = 6/7 ≈ 0,857. Lo stesso lettore, senza modifiche, passa dal 33% all'86% di precisione: non è migliorato, è cambiato il mondo in cui lavora. È la ragione per cui un modello valutato su dati bilanciati delude sempre in produzione, dove la classe positiva è rara.

Monty Hall

Tre porte: dietro una c'è un'auto, dietro le altre due una capra. Scegli la porta 1. Il conduttore, che sa dove sta l'auto, apre una delle altre due porte che nasconde una capra e ti chiede se vuoi cambiare. Conviene cambiare?

L'intuizione dice «50 e 50». L'analisi per casi (con la scelta iniziale fissata, porta 1) dice altro:

Il problema di Monty HallTre scenari ugualmente probabili: cambiando porta si vince in due su tre.Hai scelto la porta 1. Il conduttore apre una porta con una capra.Auto dietro 1probabilità 1/3autocapracapratuacambi → perdiAuto dietro 2probabilità 1/3capraautocapratuacambi → vinciAuto dietro 3probabilità 1/3capracapraautotuacambi → vincicambiare vince in 2 casi su 3: P(vincere cambiando) = 2/3
Figura: l'enumerazione dei tre casi.
  • L'auto è dietro la 1 (probabilità 1/3): cambiando perdi.
  • L'auto è dietro la 2 (1/3): il conduttore deve aprire la 3, cambiando vinci.
  • L'auto è dietro la 3 (1/3): il conduttore deve aprire la 2, cambiando vinci.

Cambiare vince in 2 casi su 3. La chiave è che l'apertura della porta non è casuale: il conduttore non può aprire quella con l'auto, quindi il suo gesto porta informazione. (Il problema fu discusso da Selvin nel 1975; la versione con le ipotesi esplicite sul comportamento del conduttore è essenziale.)

rng = np.random.default_rng(1)

def gioca(cambia, prove=100_000):
    auto = rng.integers(0, 3, prove)
    scelta = np.zeros(prove, dtype=int)                 # scegli sempre la porta 0
    # il conduttore apre una porta diversa da `scelta` e senza auto
    apre = np.empty(prove, dtype=int)
    for i in range(prove):
        candidate = [d for d in range(3) if d != scelta[i] and d != auto[i]]
        apre[i] = candidate[rng.integers(len(candidate))]
    if cambia:
        scelta_finale = 3 - scelta - apre               # l'unica porta rimasta (0+1+2 = 3)
    else:
        scelta_finale = scelta
    return float((scelta_finale == auto).mean())

print("restando   :", round(gioca(False), 4))
print("cambiando  :", round(gioca(True), 4))

Output ottenuto:

restando   : 0.334
cambiando  : 0.6649

Cosa fa il codice. Simula 100 000 partite. Per ciascuna estrae la porta con l'auto, fissa la scelta iniziale sulla porta 0 (non perde generalità) e fa aprire al conduttore una delle porte diverse dalla tua e senza auto (se ne ha due a disposizione, ne sceglie una a caso). Se cambi, la porta finale è l'unica rimasta: poiché le porte sono 0, 1, 2 e la somma è 3, 3 − scelta − apre la identifica. Il risultato conferma 1/3 contro 2/3. Una simulazione non sostituisce l'analisi, ma ti dà il coraggio di fidarti di un risultato contro-intuitivo.

Errori di ragionamento (sintomo, causa, rimedio)

  • Fallacia del giocatore. «Dopo 5 teste, più probabile croce.» Mostriamolo con la simulazione qui sotto: la frequenza di testa dopo cinque teste di fila resta 0.5.
  • Fallacia del procuratore. Confondere P(A | B) con P(B | A). Rimedio: scrivere in esplicito quale è il dato e quale l'ipotesi.
  • Fallacia della frequenza di base. Ignorare il prior. Rimedio: frequenze naturali (albero con 10 000 persone).
  • Disgiunti = indipendenti. Falso: disgiunti con probabilità positiva sono dipendenti.
  • Fallacia della congiunzione. Giudicare più probabile «A e B» di «A». Impossibile, perché A ∩ B ⊆ A e quindi P(A ∩ B) ≤ P(A) (monotonia): lo hanno documentato Tversky e Kahneman (1983).
  • Moltiplicare probabilità non indipendenti. Rimedio: usare la regola del prodotto con le condizionate.
  • Campioni selezionati. Se condizioni su un sottoinsieme scelto (solo chi risponde al sondaggio) stai calcolando una condizionata diversa da quella che volevi.

Sei errori su sette sono la stessa distrazione: rispondere a una domanda diversa da quella posta. Scrivere per esteso «probabilità di che cosa, sapendo che cosa» prima di calcolare costringe a vedere quale dato è l'ipotesi e quale l'osservazione, e basta da solo a evitare le fallacie del procuratore, della frequenza di base e dei campioni selezionati. La simulazione che segue affronta invece la prima, quella del giocatore.

lanci = rng.integers(0, 2, size=2_000_000)
# posizioni dove gli ultimi 5 lanci sono tutti teste (1)
cinque_teste = (lanci[:-5] + lanci[1:-4] + lanci[2:-3] + lanci[3:-2] + lanci[4:-1]) == 5
successivo = lanci[5:][cinque_teste]
print("casi con 5 teste di fila:", int(cinque_teste.sum()))
print("frequenza di testa al lancio successivo:", round(float(successivo.mean()), 4))

Output ottenuto:

casi con 5 teste di fila: 63267
frequenza di testa al lancio successivo: 0.5022

Cosa fa il codice. lanci[:-5] + lanci[1:-4] + … somma cinque finestre scorrevoli di lanci: la somma è 5 solo se tutti e cinque sono teste. lanci[5:][cinque_teste] seleziona il lancio subito dopo ciascuna serie. La media di questi è la frequenza di testa dopo cinque teste: vale 0.5, come senza condizione.

Come lavorare nella pratica

Dichiara sempre lo spazio campionario e l'evento prima di calcolare. Per piccoli problemi discreti usa le frazioni esatte (Fraction), per quelli grandi simula e confronta. Per Bayes parti dall'albero con le frequenze e solo dopo scrivi la formula; verifica con un test automatico le conseguenze degli assiomi (somma pari a 1, complementare, monotonia); e dichiara le ipotesi di indipendenza chiedendoti se reggono nei tuoi dati.

Il lessico essenziale: lo spazio campionario Ω è l'insieme degli esiti possibili e un evento è un suo sottoinsieme. Due eventi sono disgiunti se non hanno esiti comuni e indipendenti se saperne uno non cambia la probabilità dell'altro. La condizionata P(A|B) è la probabilità di A nell'universo ristretto a B; prior e posteriore sono la credenza prima e dopo un dato, e la verosimiglianza misura quanto il dato era atteso se l'ipotesi è vera. Per un classificatore, la sensibilità è il richiamo e la precisione è la quota di veri positivi fra i positivi; la frequenza di base è quanto è comune il fenomeno nella popolazione.

Mini-progetto

  1. Con Fraction, calcola per due dadi P(somma ≥ 10), P(somma ≥ 10 | almeno un 6) e dimostra, con il codice, che i due eventi non sono indipendenti.
  2. Riprendi il test diagnostico e produci una tabella con la precisione (P(malato | positivo)) per prevalenze di 0.1%, 1%, 10%, 30%, 50%, con sensibilità 90% e falsi positivi 5%. Commenta la progressione.
  3. Estendi il Naive Bayes giocattolo aggiungendo 4 messaggi tuoi: cambia il posteriore del messaggio {'offerta'}? Perché?

Il primo punto allena a riconoscere la dipendenza anche quando i due eventi «sembrano» scollegati, il secondo anticipa la sfida svolta qui sotto, il terzo ti fa osservare, misurandolo tu, quanto pesi il lisciamento di Laplace quando il corpus è piccolo come questo (otto messaggi).

Una sfida svolta: la precisione del test al variare della prevalenza

Il mini-progetto chiedeva una tabella della precisione P(malato | positivo) per prevalenze diverse. Eccola, con la formula di Bayes in una funzione e con i numeri a confronto.

def precisione(prevalenza, sensibilita=0.90, falsi_positivi=0.05):
    positivi_veri = sensibilita * prevalenza
    positivi_falsi = falsi_positivi * (1 - prevalenza)
    return positivi_veri / (positivi_veri + positivi_falsi)

print(f"{'prevalenza':>11} {'precisione':>11} {'su 10.000 test positivi, malati veri':>38}")
for prev in (0.001, 0.01, 0.10, 0.30, 0.50, 0.80):
    p = precisione(prev)
    print(f"{prev:>11.3f} {p:>11.3f} {10_000 * p:>38.0f}")

Output ottenuto:

 prevalenza  precisione   su 10.000 test positivi, malati veri
      0.001       0.018                                    177
      0.010       0.154                                   1538
      0.100       0.667                                   6667
      0.300       0.885                                   8852
      0.500       0.947                                   9474
      0.800       0.986                                   9863

Come leggere il codice. La funzione precisione è il teorema di Bayes in forma di conteggio: al numeratore i positivi che arrivano dai malati (sensibilità × prevalenza), al denominatore tutti i positivi (veri più falsi). Variando solo la prevalenza, tenendo fisse sensibilità e falsi positivi, la precisione passa da circa 1,8% (su 1000 persone una sola è malata, e i falsi allarmi dei sani sommergono i veri positivi) a oltre il 90% (con prevalenza dell'80% quasi ogni positivo è un malato: 98,6% nell'uscita qui sopra). Ecco la lezione: un test non ha «la» sua precisione, ha una precisione per ogni popolazione in cui lo si usa. Un test di screening applicato a tutta la popolazione (prevalenza bassa) produce molti falsi allarmi; lo stesso test applicato a persone già sospette (prevalenza alta) è molto più affidabile. È anche il motivo per cui un classificatore valutato in laboratorio su dati bilanciati può deludere in produzione, dove la classe di interesse è rara.

Challenge

  • Easy. Dimostra P(A ∪ B ∪ C) per tre eventi, in analogia con l'inclusione-esclusione, e verificala sui dadi.
  • Medium. Dimostra che, se A e B sono indipendenti, lo sono anche A e Bᶜ. (Suggerimento: P(A ∩ Bᶜ) = P(A) − P(A ∩ B).)
  • Hard. Un'urna contiene due monete: una onesta e una con due teste. Ne estrai una a caso e la lanci tre volte ottenendo tre teste. Calcola con Bayes la probabilità che sia quella truccata e verifica per simulazione.
  • Expert. Nel Monty Hall, supponi che il conduttore apra una porta a caso tra le due rimaste (e per fortuna trova una capra). Mostra con il teorema di Bayes che ora cambiare e restare sono equivalenti e simula entrambi i casi condizionando agli scenari con capra.
  • Enterprise. Per un classificatore reale della tua organizzazione, stima sensibilità e tasso di falsi positivi, la frequenza di base nel traffico reale, e calcola la precisione attesa in produzione. Confrontala con quella misurata in laboratorio.

Le prime due sfide sono dimostrazioni brevi, la terza e la quarta applicano Bayes a un caso con dati nascosti e vanno verificate per simulazione come nella lezione. Se hai tempo per una sola, scegli la Enterprise: è l'unica che produce un numero utile al tuo lavoro, e se la classe positiva è rara in produzione, la precisione attesa sarà inferiore a quella misurata su dati bilanciati.

Per esercitarti sul varco simulato trovi lo scheletro da completare, con le soluzioni commentate in fondo, in esercizi.py: legge condizionate e indipendenza da una tabella di contingenza con Fraction, calcola Bayes al variare della prevalenza e stima la stessa quantità per simulazione con il seme fisso. Richiede solo Python 3.12 o successivo e si lancia con python3 esercizi.py; il file non è stato eseguito per questo articolo.

Che cosa sai fare adesso

Sai tradurre un problema in uno spazio campionario e in eventi, derivare le regole del complementare e dell'unione dai tre assiomi, calcolare una condizionata restringendo la stanza e distinguere disgiunti da indipendenti. Sai usare il teorema di Bayes con le frequenze naturali, e sai spiegare perché la precisione di un test dipende dalla popolazione in cui lo usi. Il prossimo passo sono le variabili aleatorie, che trasformano gli esiti in numeri; la statistica farà il percorso inverso, stimando probabilità sconosciute dai dati, e il classificatore bayesiano sarà ripreso in Naive Bayes.

Nella stessa serie trovi anche: 28 - Statistica bayesiana: prior, posterior, coniugati e perché la regolarizzazione è una credenza (che richiama questa lezione).

Fonti

  • A. N. Kolmogorov, Grundbegriffe der Wahrscheinlichkeitsrechnung, Springer, 1933 - i tre assiomi (libro a stampa, non consultato online)
  • T. Bayes, An Essay towards solving a Problem in the Doctrine of Chances, Philosophical Transactions 53, 1763 - origine del teorema; P.-S. Laplace, Théorie analytique des probabilités, 1812 - la forma moderna (citazioni bibliografiche, non consultate online)
  • G. Gigerenzer, U. Hoffrage, How to improve Bayesian reasoning without instruction: Frequency formats, Psychological Review 102(4), 1995 - le frequenze naturali; A. Tversky, D. Kahneman, Extensional versus intuitive reasoning, Psychological Review 90(4), 1983 - la fallacia della congiunzione; S. Selvin, A problem in probability, The American Statistician 29(1), 1975 - Monty Hall (citazioni bibliografiche: gli editori bloccano l'accesso automatico, gli articoli non sono stati letti per intero)
  • Python 3: modulo fractions - la classe Fraction e l'aritmetica razionale esatta usata in tutti i calcoli discreti, consultata il 2026-11-12
  • NumPy: Random Generator - default_rng con seme, integers e choice usati nelle simulazioni, consultata il 2026-11-12

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