Salta al contenuto principale

06 - Test and learn: esperimenti controllati e A/B test

Randomizzazione, test sulla differenza di due proporzioni, dimensione del campione e peeking misurato con simulazioni eseguite: come fare un A/B test che regga.

Cosa ne pensi di questo articolo?

06 - Test and learn: esperimenti controllati e A/B test
Machine Learning Engineerfedericocalo.dev

In Breve

Randomizzazione, test sulla differenza di due proporzioni, dimensione del campione e peeking misurato con simulazioni eseguite: come fare un A/B test che regga.

Corso Machine Learning Engineer · Modulo 00 · Mindset data-driven · Lezione 6

Obiettivo. Spiegare perché l'esperimento controllato è lo strumento che risolve il confondimento (lezione 05), e dare al lettore tutto il necessario per progettarne uno: test sulla differenza di due proporzioni (derivato per intero), calcolo della dimensione del campione (derivato), e la trappola più diffusa, il «controllare i risultati ogni giorno» (peeking), misurata con una simulazione.

Falso mito. «Appena il test mostra p < 0.05, si può fermare e dichiarare il vincitore.» Controllando dieci volte, il tasso di falsi positivi non è più 5%.

Introduzione

Nella lezione 05 abbiamo visto che il confronto tra due gruppi può ingannare quando i gruppi differiscono per qualcos'altro. Il rimedio più robusto si chiama randomizzazione: decidere con una moneta chi riceve cosa. Quando l'assegnazione è casuale, per costruzione nessuna caratteristica può essere correlata alla scelta, quindi qualunque differenza sistematica nei risultati è dovuta al trattamento (o al caso, che la statistica quantifica).

Roadmap. (1) Perché la moneta risolve il confondimento. (2) Il test: dalla differenza osservata al p-value, con tutti i passaggi. (3) Quanti utenti servono. (4) La simulazione del peeking. (5) Confronti multipli. (6) Come si lavora in pratica.

Cos'è, perché esiste, quando NON usarlo

Test and learn è il ciclo: formula un'ipotesi, progetta un esperimento, esegui, misura, decidi, impara, ripeti. Un A/B test è la sua forma più semplice: due versioni (A = controllo, B = variante), assegnazione casuale, una metrica.

Analogia: il confronto di due fertilizzanti. Se dai il fertilizzante B alle aiuole al sole e A a quelle in ombra, non sai se la differenza è dovuta al fertilizzante o al sole. Se assegni i fertilizzanti alle aiuole a sorte, il sole si distribuisce per caso equamente tra i due gruppi e può contribuire solo con rumore, non con una distorsione sistematica.

Quando NON fare un A/B test:

Situazione Perché Alternativa
Traffico troppo basso Servirebbero mesi per un campione sufficiente (vedi calcolo sotto) Interviste, test qualitativi, decisione di giudizio
Effetto atteso minuscolo Campione necessario irrealistico Accettare di non poterlo misurare
Gli utenti si influenzano tra loro (rete sociale, marketplace) Un gruppo contamina l'altro: violata l'indipendenza Randomizzare per cluster, o progetti di esperimento specifici
Costo etico alto (danno possibile) Non si espone una persona a un rischio per misurare Studi osservazionali con metodi causali, revisione etica
Decisione irreversibile unica Non c'è un «dopo» in cui imparare Analisi di scenario

Preparazione

pip install numpy

Useremo math per la funzione errore (la CDF della normale) e NumPy per le simulazioni.

Primo esempio: la randomizzazione bilancia ciò che non vedi

Riprendiamo il paradosso di Simpson: la «gravità» confondeva il confronto. Simuliamo 20.000 persone, il 30% con caso grave. Assegniamo il trattamento a caso.

Schema di un A/B testGli utenti vengono assegnati a caso a due gruppi, misurati con la stessa metrica e confrontati.Utentiche arrivanoAssegnazionecasuale (moneta)Gruppo Aversione attualeGruppo BvarianteConfrontometrica + testciò che non vedi si bilancia tra i gruppi
Figura: la randomizzazione è ciò che permette di attribuire la differenza alla variante.
import numpy as np

rng = np.random.default_rng(0)
n = 20_000
grave = rng.random(n) < 0.30                  # 30% di casi gravi
trattamento_B = rng.random(n) < 0.5           # moneta: nessuna relazione con "grave"

print("quota di casi gravi nel gruppo A:", round(float(grave[~trattamento_B].mean()), 3))
print("quota di casi gravi nel gruppo B:", round(float(grave[trattamento_B].mean()), 3))

# Output (Python 3.14.4, seme 0, due esecuzioni identiche):
# quota di casi gravi nel gruppo A: 0.299
# quota di casi gravi nel gruppo B: 0.286

Come leggere il codice. grave è un vettore di vero/falso: rng.random(n) < 0.30 è vero con probabilità 0.30, quindi circa il 30% degli utenti è «grave». trattamento_B è la moneta: un secondo vettore estratto indipendentemente, vero per circa la metà degli utenti. Poiché la moneta non guarda grave, la quota di gravi dovrebbe essere la stessa nei due gruppi. grave[~trattamento_B] seleziona i gravi tra chi non ha ricevuto B (l'operatore ~ nega il vettore booleano) e .mean() ne calcola la frazione (la media di vero/falso è la proporzione di veri). I due valori sono vicini a 0.30 a meno di piccole oscillazioni casuali: è questo bilanciamento, ottenuto anche per le variabili che non abbiamo misurato, il motivo per cui la randomizzazione funziona.

Le due quote escono 0.299 e 0.286 (seed 0): entrambe vicine a 0.30, con una differenza dovuta solo al rumore. Questo è il motivo per cui l'esperimento funziona: i pesi w_g della lezione 05 sono gli stessi nei due gruppi, quindi il paradosso non può comparire. Con la randomizzazione ottieni il bilanciamento anche per le variabili che non hai misurato o non conosci: è il suo vantaggio unico rispetto a «correggere a posteriori».

Trappola: la randomizzazione ha una unità (utente, sessione, negozio). Se randomizzi per sessione ma lo stesso utente compare in entrambi i gruppi, i gruppi non sono indipendenti e vede due versioni. Scegli l'unità che coincide con quella su cui calcoli la metrica.

Un secondo esempio, contrastante: senza moneta il confronto si inganna

Per vedere che cosa vale davvero la randomizzazione, costruiamo un caso in cui la variante non ha alcun effetto e confrontiamo due modi di formare i gruppi. Nel primo (autoselezione) gli utenti più attivi scelgono più spesso la nuova versione, come accade quando una funzione viene offerta come «provala se vuoi». Nel secondo (randomizzazione) decide la moneta.

import math
import numpy as np

def phi(z):
    return 0.5 * (1 + math.erf(z / math.sqrt(2)))

def z_test(x_a, n_a, x_b, n_b):
    p_a, p_b = x_a / n_a, x_b / n_b
    p = (x_a + x_b) / (n_a + n_b)
    se = math.sqrt(p * (1 - p) * (1 / n_a + 1 / n_b))
    z = (p_b - p_a) / se
    return p_b - p_a, z, 2 * (1 - phi(abs(z)))

rng = np.random.default_rng(2)
n = 40_000
impegno = rng.random(n)                               # quanto l'utente è già attivo, tra 0 e 1
conversione = rng.random(n) < 0.02 + 0.10 * impegno   # dipende SOLO dall'impegno: la variante non fa nulla

scelte = {
    "autoselezione (chi è attivo prova B)": rng.random(n) < 0.2 + 0.6 * impegno,
    "randomizzazione (moneta)": rng.random(n) < 0.5,
}
for nome, in_B in scelte.items():
    diff, z, p = z_test(int(conversione[~in_B].sum()), int((~in_B).sum()), int(conversione[in_B].sum()), int(in_B.sum()))
    print(f"{nome:<38}: B - A = {diff:+.4f} | z = {z:+6.2f} | p-value = {p:.4f}")

Output ottenuto:

autoselezione (chi è attivo prova B)  : B - A = +0.0167 | z =  +6.50 | p-value = 0.0000
randomizzazione (moneta)              : B - A = +0.0009 | z =  +0.35 | p-value = 0.7227

Come leggere il codice. phi è la funzione di ripartizione della normale standard (tramite erf), z_test implementa il test della sezione seguente (la formula è derivata per intero più avanti). La conversione di ogni utente dipende solo da impegno: non esiste nessun effetto della variante, per costruzione. Nel primo scenario gli utenti più impegnati entrano più spesso nel gruppo B, e siccome convertono di più, B «vince» con un p-value minuscolo: un risultato apparentemente solido e completamente falso. Nel secondo scenario la moneta distribuisce l'impegno in modo uguale nei due gruppi e la differenza è compatibile con il rumore. È il confondimento della lezione precedente, visto in azione: il test statistico non ha colpe, ha risposto correttamente a una domanda diversa («i due gruppi hanno tassi diversi?», sì, perché sono fatti da persone diverse) da quella che ci interessava («la variante cambia il tasso?»).

Teoria: dal dato alla decisione

Il modello

Il gruppo A ha n utenti, con x_A conversioni. Il gruppo B ha n utenti, con x_B conversioni. Stimiamo le probabilità di conversione:

p̂_A = x_A / n_A, p̂_B = x_B / n_B.

Vogliamo sapere se la differenza osservata D = p̂_B − p̂_A è un effetto reale o solo rumore del campionamento.

Passo 1: la distribuzione di una proporzione

Ogni utente converte con probabilità p, in modo indipendente. Sia Xᵢ ∈ {0, 1} il suo esito. Allora E[Xᵢ] = p e Var(Xᵢ) = p(1 − p) (la varianza di una Bernoulli: E[X²] − E[X]² = p − p², perché X² = X).

La proporzione campionaria è la media p̂ = (1/n) Σ Xᵢ. Per le proprietà della varianza di somme di variabili indipendenti,

Var(p̂) = (1/n²) · n · p(1 − p) = p(1 − p) / n.

Per il teorema del limite centrale, per n grande p̂ è approssimativamente normale con media p e varianza p(1 − p)/n.

Passo 2: la distribuzione della differenza

I due gruppi sono indipendenti, quindi le varianze si sommano:

Var(D) = p_A(1 − p_A)/n_A + p_B(1 − p_B)/n_B.

Passo 3: l'ipotesi nulla e l'errore standard

L'ipotesi nulla H₀ dice: non c'è differenza reale, p_A = p_B = p. Sotto H₀ la miglior stima di p usa tutti i dati insieme (stima combinata):

p̂ = (x_A + x_B) / (n_A + n_B),

e l'errore standard della differenza sotto H₀ è

SE = sqrt( p̂(1 − p̂) · (1/n_A + 1/n_B) ).

Passo 4: la statistica z e il p-value

z = (p̂_B − p̂_A) / SE.

Sotto H₀, z è approssimativamente normale standard. Il p-value è la probabilità, supponendo vera H₀, di osservare una differenza grande almeno quanto quella vista, in valore assoluto:

p-value = 2 · (1 − Φ(|z|))

dove Φ è la funzione di ripartizione della normale standard (qui il «2» perché il test è bilaterale: una differenza in un verso o nell'altro). Si rifiuta H₀ se il p-value è sotto la soglia α (tipicamente 0.05).

Cosa NON è il p-value. Non è la probabilità che H₀ sia vera, né la probabilità che il risultato sia dovuto al caso. È la probabilità del dato, dato H₀. Un p-value di 0.02 significa: «se non ci fosse alcuna differenza, vedere un risultato così estremo accadrebbe 2 volte su 100». Non dice quanto è grande o importante l'effetto.

Codice

import math

def phi(z):
    """CDF della normale standard, tramite la funzione errore."""
    return 0.5 * (1 + math.erf(z / math.sqrt(2)))

def z_test_proporzioni(x_a, n_a, x_b, n_b):
    p_a, p_b = x_a / n_a, x_b / n_b
    p_comb = (x_a + x_b) / (n_a + n_b)
    se = math.sqrt(p_comb * (1 - p_comb) * (1 / n_a + 1 / n_b))
    z = (p_b - p_a) / se
    p_value = 2 * (1 - phi(abs(z)))
    return z, p_value

# A: 200 conversioni su 4000 (5.0%). B: 248 su 4000 (6.2%).
z, p = z_test_proporzioni(200, 4000, 248, 4000)
print("z =", round(z, 3), "| p-value =", round(p, 4))

# Output:
# z = 2.334 | p-value = 0.0196

Output: z = 2.334, p-value = 0.0196. Passa la soglia 0.05. Ma attenzione: «significativo» non significa «grande» né «conveniente». L'effetto osservato è +1.2 punti percentuali, e se vale il costo della variante lo decide la lezione 04, non il p-value.

Che cosa significa «non significativo»

Il test che segue dice se la differenza osservata è «significativa». Prima di vederlo conviene fissare cosa significa non esserlo: il test non dimostra che le versioni sono uguali, dice solo che con questo campione non si riesce a distinguerle dal rumore. Per avere un test informativo anche in caso di risultato nullo serve la potenza, cioè la probabilità di accorgersi di un effetto reale di una certa dimensione: è ciò che si calcola nella sezione sul campione.

Quanti utenti servono: derivazione

Prima di far partire l'esperimento si decide il campione. Servono tre numeri:

  • α: probabilità accettata di un falso positivo (tipicamente 0.05).
  • Potenza 1 − β: probabilità di rilevare un effetto reale della dimensione che ti interessa (tipicamente 0.80).
  • δ: il minimo effetto che vale la pena rilevare (decisione di business!).

I primi due sono convenzioni statistiche, il terzo no: è l'unico numero che nessuna formula ti fornisce, perché dipende da quanto vale per te un miglioramento. Se non sai dire quanto deve migliorare la conversione perché la variante ripaghi il lavoro, non puoi dimensionare il test, e il calcolo che segue si ferma qui. Derivazione, assumendo p_A = p₀ e p_B = p₀ + δ, n per gruppo.

  1. Sotto la ipotesi alternativa, D = p̂_B − p̂_A è circa normale con media δ e varianza V/n, dove V = p₀(1−p₀) + p₁(1−p₁) e p₁ = p₀ + δ. Sia σ = sqrt(V/n).
  2. Rifiutiamo H₀ quando |D| > z_{α/2} · σ (approssimando l'errore standard sotto H₀ con σ).
  3. La probabilità di rifiutare quando l'effetto è δ (la potenza) è, trascurando la coda sbagliata, P(D > z_{α/2} σ) = P( Z > z_{α/2} − δ/σ ), con Z normale standard.
  4. Vogliamo che questa sia 1 − β, cioè z_{α/2} − δ/σ = −z_β.
  5. Quindi δ/σ = z_{α/2} + z_β, ovvero σ = δ / (z_{α/2} + z_β).
  6. Elevando al quadrato e sostituendo σ² = V/n:

I sei passi sono una sola idea ripetuta: la soglia di rifiuto dista z_{α/2} errori standard da zero, la distribuzione reale della differenza è centrata su δ, e la potenza è la quota di quella distribuzione che supera la soglia. Imporre che questa quota sia 80% fissa la distanza tra δ e la soglia, e da quella distanza esce n.

n = (z_{α/2} + z_β)² · [ p₀(1−p₀) + p₁(1−p₁) ] / δ²

per gruppo.

Leggila: n cresce col quadrato dell'inverso di δ. Dimezzare l'effetto che vuoi rilevare quadruplica gli utenti necessari. È la ragione per cui le micro-ottimizzazioni sono costosissime da misurare.

def z_quantile(q):
    """Inversa della CDF normale, per bisezione (nessuna libreria esterna)."""
    lo, hi = -10.0, 10.0
    for _ in range(100):
        mid = (lo + hi) / 2
        if phi(mid) < q:
            lo = mid
        else:
            hi = mid
    return (lo + hi) / 2

def n_per_gruppo(p0, delta, alpha=0.05, potenza=0.80):
    za = z_quantile(1 - alpha / 2)
    zb = z_quantile(potenza)
    p1 = p0 + delta
    varianza = p0 * (1 - p0) + p1 * (1 - p1)
    return math.ceil((za + zb) ** 2 * varianza / delta ** 2)

print("z_alpha/2 =", round(z_quantile(0.975), 3), "| z_beta =", round(z_quantile(0.80), 3))
print("n per gruppo, p0=5%, +1 punto:  ", n_per_gruppo(0.05, 0.01))
print("n per gruppo, p0=5%, +0.5 punti:", n_per_gruppo(0.05, 0.005))

# Output (eseguito di seguito ai blocchi precedenti, che definiscono phi e z_test):
# z_alpha/2 = 1.96 | z_beta = 0.842
# n per gruppo, p0=5%, +1 punto:   8155
# n per gruppo, p0=5%, +0.5 punti: 31231

Output: z_alpha/2 = 1.96, z_beta = 0.842; per rilevare da 5% a 6% servono 8155 utenti per gruppo; per rilevare +0.5 punti ne servono 31231, cioè 3.8 volte tanti (non esattamente 4× perché anche la varianza V cambia leggermente). La formula è un'approssimazione (normale, errore standard sotto H₀ sostituito): verifichiamola.

Esercizio 1

La regola del 16

La formula qui sopra è esatta ma non si fa a mente, e le decisioni sul dimensionamento si prendono in riunione. Con α = 0.05 bilaterale e potenza 0.80, (zα/2 + zβ)² = 7.849, e il fattore 2 dei due gruppi porta la costante a 15.698: arrotondata a 16, dà la regola spiccia n ≈ 16·p₀(1−p₀)/δ² per gruppo.

Il sito di un rifugio per animali (scenario simulato, numeri scelti perché le radici vengano esatte): oggi il 20% dei visitatori completa il modulo di adozione a distanza, e passano 800 visitatori al giorno, divisi a metà fra le due versioni. Carta e penna.

  1. Quanti visitatori per gruppo servono per riconoscere un miglioramento di 4, 2 e 1 punti percentuali?
  2. Quanti giorni dura il test nei tre casi?
  3. Quanto si paga per l'arrotondamento da 15.698 a 16?

Con la regola del 16 i tre conti diventano divisioni a mente. Il secondo punto converte il campione in tempo, che è la domanda che il committente fa davvero; il terzo ti chiede di quantificare l'errore di un'approssimazione prima di fidarti.

Mostra la soluzione

1. Con p₀ = 0.20 si ha p₀(1−p₀) = 0.16, quindi n = 16 × 0.16/δ² = 2.56/δ². Per δ = 0.04: 2.56/0.0016 = 1600. Per δ = 0.02: 6400. Per δ = 0.01: 25600. Numeri interi, perché 0.16 diviso 0.0004 fa 400 tondi.

2. 400 visitatori per gruppo al giorno, quindi 4, 16 e 64 giorni. Il rapporto conta più dei valori: dimezzare l'effetto da rilevare quadruplica il campione e i giorni. È la legge 1/√n vista dal lato dei costi, ed è il motivo per cui misurare effetti piccoli è un privilegio di chi ha molto traffico.

3. Poco: 16/15.698 = 1.0193, cioè l'1.93% di utenti in più di quanti ne servirebbero (6400 invece di 6279 per i 2 punti). È un errore dalla parte giusta — si sovrastima il campione, non lo si sottostima — al prezzo di zero calcolatrici in riunione.

Verifica per simulazione

import numpy as np

rng = np.random.default_rng(3)

def tasso_di_rifiuto(p0, p1, n, sims=2000):
    rifiuti = 0
    for _ in range(sims):
        xa = rng.binomial(n, p0)
        xb = rng.binomial(n, p1)
        _, pv = z_test_proporzioni(xa, n, xb, n)
        rifiuti += pv < 0.05
    return rifiuti / sims

n = n_per_gruppo(0.05, 0.01)
print("potenza simulata (effetto reale +1 punto):", tasso_di_rifiuto(0.05, 0.06, n))
print("falsi positivi simulati (A/A, nessun effetto):", tasso_di_rifiuto(0.05, 0.05, n))

# Output (seme 3, dopo il blocco della formula):
# potenza simulata (effetto reale +1 punto): 0.7925
# falsi positivi simulati (A/A, nessun effetto): 0.043

Come leggere il codice. tasso_di_rifiuto ripete 2000 esperimenti simulati: in ognuno estrae le conversioni dei due gruppi da due binomiali (rng.binomial(n, p) conta i successi su n prove con probabilità p) e applica il test; poi conta la frazione di esperimenti in cui il p-value è sotto 0.05. Se p0 ≠ p1 quella frazione è la potenza simulata; se p0 = p1 è il tasso di falsi positivi. Nel progetto di un esperimento si fanno spesso proprio queste simulazioni, prima di toccare il traffico reale.

Output ottenuto (seed 3): potenza simulata 0.7925 (obiettivo 0.80) e falsi positivi 0.043 (obiettivo ≤ 0.05; il valore fluttua per il numero finito di simulazioni, circa ±0.01). La formula regge.

Il problema inverso: quale effetto posso rilevare con il traffico che ho?

La formula del campione si può leggere al contrario. Spesso il vincolo non è «quanto voglio rilevare» ma «quanti utenti ho». Con un traffico fisso, quale δ minimo si riesce a distinguere (con potenza 80%)? Per δ piccolo, la varianza è circa V ≈ 2·p₀(1−p₀), e risolvendo la formula per δ: δ ≈ (z_{α/2} + z_β) · sqrt(2·p₀(1−p₀)/n).

import math

za, zb = 1.959964, 0.841621             # quantili della normale per α = 0.05 bilaterale e potenza 0.80
p0 = 0.05
print("utenti per gruppo | effetto minimo rilevabile (punti percentuali) | in termini relativi")
for n in (2_000, 8_000, 32_000, 128_000):
    delta = (za + zb) * math.sqrt(2 * p0 * (1 - p0) / n)
    print(f"{n:>17,} | {delta * 100:>45.2f} | {delta / p0:>18.1%}".replace(",", "."))

Output ottenuto:

utenti per gruppo | effetto minimo rilevabile (punti percentuali) | in termini relativi
            2.000 |                                          1.93 |              38.6%
            8.000 |                                          0.97 |              19.3%
           32.000 |                                          0.48 |               9.7%
          128.000 |                                          0.24 |               4.8%

Come leggere il codice. Per quattro dimensioni del campione (ogni volta il quadruplo della precedente) calcola l'effetto minimo rilevabile. Ogni quadruplicazione dei dati dimezza l'effetto rilevabile: è la legge 1/√n, la stessa che regola la precisione della media campionaria (lezione di probabilità). Con 2000 utenti per gruppo si vede solo un effetto relativo di quasi il 40% rispetto a una conversione del 5%; per distinguere effetti di qualche punto percentuale relativo servono centinaia di migliaia di utenti. Una tabella come questa, mostrata al committente prima dell'esperimento, evita il malinteso più comune: «abbiamo fatto il test e non è venuto fuori nulla», quando il test non poteva, per costruzione, vedere l'effetto che ci si aspettava.

Esercizio 2

Il test che non poteva vedere niente

Stesso rifugio simulato. Il consiglio direttivo può tenere il test al massimo due settimane (16 giorni) e, realisticamente, si aspetta un miglioramento di un punto percentuale. La stessa costante letta al contrario dà δ ≈ 4·√(p₀(1−p₀)/n), dove il 4 è l'arrotondamento di (zα/2 + zβ)·√2 = 3.962.

  1. Qual è l'effetto minimo rilevabile con il traffico di 4, 16 e 64 giorni? Esprimilo anche in termini relativi.
  2. Il test da due settimane va fatto?
  3. Se la risposta è no, quali sono le uscite oneste?

L'esercizio rovescia il precedente: invece di partire dall'effetto e chiedere il tempo, parte dal tempo e chiede l'effetto. È la versione della domanda che il traffico reale impone, e quella che di solito viene saltata.

Mostra la soluzione

1. Le radici vengono esatte: 0.16/1600 = 0.0001 → δ = 4 × 0.01 = 4 punti (+20% relativo); 0.16/6400 = 0.000025 → δ = 4 × 0.005 = 2 punti (+10%); 0.16/25600 = 0.00000625 → δ = 4 × 0.0025 = 1 punto (+5%).

2. No. In 16 giorni il test può distinguere al minimo 2 punti, e il consiglio ne spera 1. Un esperimento così è già deciso prima di partire: restituirà «non significativo» quasi comunque, e quella risposta verrà letta come «la pagina nuova non serve» — una conclusione che i dati non autorizzano. Non è un test, è un rituale.

3. Due. Allungare a 64 giorni, accettando che il rifugio resti quattro volte più a lungo con una delle due pagine che forse è peggiore. Oppure non farlo e decidere in altro modo — interviste a chi abbandona il modulo, giudizio, un cambiamento più grosso che punti a 4 punti invece che a 1. La terza strada, «lo facciamo lo stesso e vediamo», è quella che produce le decisioni peggiori, perché dà a un'assenza di potenza statistica l'aspetto di un'evidenza. Una tabella come quella del punto 1, mostrata prima di partire, è il modo più economico di evitarlo.

La trappola più diffusa: il peeking

Analogia: il fotofinish truccato. Se osservi una corsa a ogni metro e fermi tutto nell'istante in cui «il tuo cavallo è in testa», lo dichiarerai vincitore anche se la corsa, completata, lo vede ultimo. Non è la corsa a essere truccata: è la regola di arresto.

Il p-value è calcolato sotto l'ipotesi di guardare il risultato una sola volta, alla fine, con un campione fissato in anticipo. Se guardi più volte e ti fermi alla prima occasione in cui p < 0.05, stai dando al caso molte occasioni di sembrare un effetto. Il tasso di falsi positivi cresce con il numero di controlli (Armitage, McPherson e Rowe 1969 lo hanno quantificato per i test ripetuti).

Misuriamolo. Facciamo A/A test: i due gruppi hanno la stessa probabilità di conversione (5%), quindi qualunque «vittoria» è un falso positivo. Confrontiamo: un solo controllo alla fine contro dieci controlli equispaziati, fermandosi al primo p < 0.05.

def peeking(p, n_max=5000, controlli=10, sims=2000):
    falsi = 0
    punti = np.linspace(n_max / controlli, n_max, controlli).astype(int)
    for _ in range(sims):
        a = rng.random(n_max) < p
        b = rng.random(n_max) < p              # stessa p: nessun effetto reale
        ca, cb = np.cumsum(a), np.cumsum(b)
        for k in punti:
            _, pv = z_test_proporzioni(ca[k - 1], k, cb[k - 1], k)
            if pv < 0.05:
                falsi += 1
                break                           # "ho visto il significativo: mi fermo"
    return falsi / sims

print("un solo controllo   :", peeking(0.05, controlli=1))
print("dieci controlli     :", peeking(0.05, controlli=10))

# Output (stesso generatore, di seguito ai blocchi precedenti):
# un solo controllo   : 0.0565
# dieci controlli     : 0.1895

Output ottenuto (stesso generatore, eseguito di seguito al blocco precedente): un solo controllo 0.0565; dieci controlli 0.1895. Il livello nominale è 0.05, ma con dieci controlli circa un esperimento su cinque senza alcun effetto viene dichiarato vincente. (Con 2000 simulazioni ogni valore oscilla di circa ±0.01.)

Come leggere il codice del peeking. punti contiene i controlli equispaziati (ogni 500 utenti, 10 volte). Per ciascuna delle 2000 simulazioni genera due serie di 5000 utenti con la stessa probabilità, calcola le conversioni cumulate con np.cumsum (la somma cumulata dà, per ogni k, il numero di conversioni tra i primi k utenti) e applica il test ai punti di controllo. Appena un controllo dà p < 0.05, la simulazione «dichiara il vincitore» (falsi += 1) ed esce dal ciclo con break. La frazione di esperimenti dichiarati vincitori in assenza di qualsiasi effetto è il tasso reale di falsi positivi di quella procedura.

Rimedi.

  1. Fissare il campione in anticipo e guardare solo alla fine (il rimedio più semplice).
  2. Test sequenziali progettati per controlli ripetuti (la tradizione parte da Wald, 1945): correggono la soglia a ogni controllo.
  3. Soglie più severe per controlli intermedi.

Trade-off. Aspettare la fine ha un costo: se la variante è molto peggiore, la mostri a tanti utenti più a lungo. Un monitoraggio di guardia (errori, crash, reclami) con soglie di sicurezza è lecito e raccomandato; ciò che è scorretto è usare il test di significatività del beneficio per fermarsi presto sul successo.

Confronti multipli

Se testi 20 metriche o 20 varianti con soglia 0.05, ci si aspetta circa un risultato «significativo» per puro caso anche senza alcun effetto (con indipendenza, la probabilità di almeno uno è 1 − 0.95²⁰ ≈ 0.64: 0.95²⁰ = 0.358). Rimedio più semplice: correzione di Bonferroni: usa α/m per ciascun test su m confronti. Più conservativa di altre, ma facile da spiegare e da applicare. Meglio ancora: una sola metrica primaria dichiarata in anticipo.

Un controllo di salute: il rapporto di assegnazione

Uno degli errori più comuni, nei sistemi reali, è che la randomizzazione non produca davvero la quota prevista (un bug nel codice di assegnazione, un filtro che scarta più utenti di un gruppo). Il controllo è semplice: le numerosità osservate nei due gruppi devono essere compatibili con la quota attesa, per esempio 50/50. Sotto H₀ (assegnazione corretta) il numero di utenti in B è una binomiale con p = 0.5, quindi z = (osservati − attesi)/√(n·0.5·0.5).

import math

def srm(n_a, n_b, quota_a=0.5):
    n = n_a + n_b
    attesi_a = n * quota_a
    z = (n_a - attesi_a) / math.sqrt(n * quota_a * (1 - quota_a))
    p = 2 * (1 - 0.5 * (1 + math.erf(abs(z) / math.sqrt(2))))
    return z, p

for n_a, n_b in ((50_100, 49_900), (50_800, 49_200)):
    z, p = srm(n_a, n_b)
    print(f"A = {n_a}, B = {n_b} -> z = {z:+.2f}, p-value = {p:.4f}")

Output ottenuto:

A = 50100, B = 49900 -> z = +0.63, p-value = 0.5271
A = 50800, B = 49200 -> z = +5.06, p-value = 0.0000

Come leggere il codice. srm confronta la numerosità del gruppo A con quella attesa. Una differenza di 200 utenti su 100 000 (50 100 contro 49 900) è compatibile con il caso: un esperimento del genere va bene. Una differenza di 1600 utenti (50 800 contro 49 200, l'1.6% del totale) ha un p-value minuscolo: l'assegnazione non è 50/50, c'è un difetto nel sistema, e qualunque risultato di quell'esperimento va scartato prima ancora di guardare la metrica. Per questo i grandi sistemi di sperimentazione fanno di questo controllo il primo semaforo (si parla di sample ratio mismatch, vedi Kohavi e coll. nelle fonti).

Un vocabolario minimo

  • Randomizzazione: assegnazione dei trattamenti per sorteggio.
  • Ipotesi nulla H₀: assunzione di nessuna differenza reale.
  • p-value: probabilità, sotto H₀, di un risultato estremo almeno quanto quello osservato.
  • Potenza: probabilità di rilevare un effetto reale di dimensione δ.
  • Effetto minimo rilevabile: la più piccola differenza distinguibile dal rumore con un dato campione.
  • Peeking: controllare i risultati più volte e fermarsi al primo «significativo».
  • A/A test: esperimento con due gruppi identici, per verificare il sistema.
  • Sample ratio mismatch: quote osservate diverse da quelle previste.

Le otto voci si ordinano secondo il ciclo di un esperimento: prima si assegna (randomizzazione), poi si formula e si dimensiona (ipotesi nulla, potenza, effetto minimo), poi si osserva e si decide (p-value, peeking), e intanto si controllano gli strumenti (A/A, SRM). Se uno dei termini non ti è chiaro, risali al punto della lezione dove è comparso.

Errori tipici

Errore Sintomo Causa Prevenzione
Fermarsi al primo p < 0.05 Vittorie che spariscono alla ripetizione Peeking Campione fisso o test sequenziale
Campione non calcolato Test inconcludente dopo settimane Potenza insufficiente Formula n prima di partire
Troppe metriche Una «scoperta» ogni tanto Confronti multipli Una metrica primaria; Bonferroni per il resto
Rapporto di assegnazione sbilanciato per un bug Le quote reali non sono 50/50 Errore di implementazione (sample ratio mismatch) Controllare le quote osservate contro quelle attese: Kohavi et al. lo trattano come test di salute dell'esperimento
Durata troppo breve Effetto che dipende dal giorno della settimana Cicli settimanali Durata di almeno un ciclo completo
Effetto novità Boom iniziale che si spegne Curiosità per il nuovo Durata sufficiente, misura sugli utenti nuovi
Interpretare p come probabilità di H₀ Sovrastima della certezza Definizione fraintesa Rileggere la definizione del p-value

Best practice

  • Pre-registrazione: ipotesi, metrica primaria, δ, α, potenza, durata, scritti prima di partire.
  • Un'unica metrica primaria, una o due di guardia.
  • Randomizza sull'unità giusta, controlla il bilanciamento.
  • Calcola il campione, e se il traffico non basta, rinuncia o cambia disegno.
  • Documenta anche i risultati nulli: un esperimento che non trova nulla è informazione.
  • Riporta l'intervallo di confidenza, non solo il p-value: l'intervallo dice quanto grande può essere l'effetto.

Le prime due regole si applicano prima di vedere i dati e impediscono di scegliere la metrica a posteriori. Le altre due controllano che il disegno sia valido, le ultime due che il risultato sia comunicato bene. Se il tempo è poco, pre-registra: è la regola che costa una pagina e protegge da peeking e confronti multipli insieme.

Mini-progetto

Progetta un A/B test per un sito immaginario con conversione base 4%.

  1. Decidi δ (il minimo effetto che ripaga i costi) e calcola n per gruppo con n_per_gruppo.
  2. Dato un traffico di 1500 visitatori al giorno diviso in due, calcola i giorni necessari.
  3. Simula l'esperimento 1000 volte con effetto reale uguale a δ e verifica la potenza.
  4. Aggiungi al codice il calcolo dell'intervallo di confidenza al 95% per D: D ± 1.96 · sqrt(p̂_A(1−p̂_A)/n_A + p̂_B(1−p̂_B)/n_B).

I quattro passi riproducono un esperimento intero, dalla decisione di δ alla stima con incertezza. Il passo 3 è quello che ti dà fiducia nella formula, perché la sperimenti su un mondo di cui conosci la verità.

Challenge

  • Easy. Verifica a mano il calcolo di z per 200/4000 contro 248/4000, passo per passo.
  • Medium. Fai un grafico di n in funzione di δ per p₀ = 5%. Che forma ha? Perché?
  • Hard. Implementa la correzione di Bonferroni nel codice del peeking: usa 0.05/10 a ogni controllo. Il tasso di falsi positivi torna entro 0.05? Perde potenza?
  • Expert. Dimostra che la varianza di una Bernoulli è massima per p = 0.5 e discutine le conseguenze per n: perché le metriche con probabilità intorno a 0.5 richiedono più utenti a parità di δ?
  • Enterprise. Scrivi il modello di documento di pre-registrazione per la tua organizzazione (una pagina) e una regola scritta su chi può fermare un esperimento e con quali criteri.

Hard è la sfida che vale di più: ti fa verificare in prima persona che correggere il peeking riduce i falsi positivi ma ha un prezzo in potenza. Le altre cinque sfide (grafico, dimostrazione, documento) consolidano il metodo, ma questa è l'unica che lo sottopone a un risultato che non conosci in anticipo.

Codice di questa lezione

I due esercizi qui sopra, con il programma che deriva il 16 e il 4 dai quantili invece di darli per buoni, e confronta riga per riga la regola spiccia con la formula esatta. Scheletro con le parti da completare e, in fondo, le soluzioni commentate. Richiede solo Python 3.12+ (nessuna dipendenza esterna, nessuna simulazione: questi conti si devono poter fare su un foglio).

Eseguilo con python3 esercizi.py: gira anche con le parti non completate e ti dice quali mancano; il file è nella cartella di download del sito.

Recap

Mappa mentale. Moneta → bilancia i confondenti → differenza D → errore standard → z → p-value. Prima di partire: α, potenza, δ → n. Mai fermarsi al primo «significativo».

Cheat sheet.

Cosa Formula
Varianza di una proporzione p(1−p)/n
Errore standard della differenza (sotto H₀) sqrt( p̂(1−p̂)(1/n_A + 1/n_B) )
Statistica z = (p̂_B − p̂_A)/SE
p-value bilaterale 2(1 − Φ(|z|))
Campione per gruppo (z_{α/2}+z_β)² [p₀(1−p₀)+p₁(1−p₁)] / δ²
Bonferroni α/m

3 concetti: randomizzazione; p-value (che cosa è e che cosa no); potenza e campione. 3 errori: peeking; metriche multiple senza correzione; campione non calcolato. 3 best practice: pre-registrazione; una metrica primaria; intervallo di confidenza. 1 esercizio: il mini-progetto di questa lezione.

Prossima lezione: il cloud: dove vivono dati e modelli, e come si ragiona sui costi.

Nella stessa serie trovi anche: 05 - Cultura del dato (la lezione precedente), 07 - Cloud e intelligenza artificiale (la lezione successiva), 28 - Statistica bayesiana: prior, posterior, coniugati e perché la regolarizzazione è una credenza (che richiama questa lezione).

Fonti

  • NIST/SEMATECH e-Handbook of Statistical Methods, 7.3.3: Do two processes produce the same proportion of defectives? - il test z per la differenza di due proporzioni con campioni grandi (approssimazione normale della binomiale), consultata il 2026-11-12
  • Python: modulo math - la funzione erf con cui si costruisce la funzione di ripartizione della normale, consultata il 2026-11-12
  • J. P. A. Ioannidis, Why Most Published Research Findings Are False (PLoS Medicine 2(8), 2005) - contesto dei confronti multipli e dei risultati non replicabili; aperta la pagina dell'articolo, consultata il 2026-11-12
  • Non raggiunti online: R. Kohavi, D. Tang, Y. Xu, Trustworthy Online Controlled Experiments, Cambridge University Press, 2020 (sample ratio mismatch); R. A. Fisher, The Design of Experiments, 1935; A. Wald, Sequential Tests of Statistical Hypotheses, Annals of Mathematical Statistics 16(2), 1945; P. Armitage, C. K. McPherson, B. C. Rowe, Repeated Significance Tests on Accumulating Data, J. R. Stat. Soc. A 132(2), 1969 (l'affermazione che hanno quantificato l'effetto dei controlli ripetuti non è stata verificata sul testo).

I listati Python sono stati eseguiti due volte, di seguito nell'ordine in cui compaiono nell'articolo (alcuni usano le funzioni dei blocchi precedenti), con Python 3.14.4 e NumPy 2.3.5: le uscite coincidono. I valori delle simulazioni (2000 ripetizioni) oscillano di circa ±0.01 al variare del seme. Lo scenario del rifugio per animali è inventato.

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