Plans d’expérience et A/B testing#

Le A/B testing est devenu la méthode de référence pour prendre des décisions basées sur les données dans le monde du produit numérique : quelle version d’une page d’accueil génère plus d’inscriptions ? Quelle formulation d’un bouton augmente le taux de clic ? Derrière ces questions pratiques se cachent des fondements statistiques rigoureux — ceux des plans d’expérience — dont la maîtrise est indispensable pour éviter de fausses conclusions coûteuses.

Hide code cell source

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
import seaborn as sns
from scipy import stats
from scipy.stats import norm, binom
import warnings
warnings.filterwarnings('ignore')

plt.rcParams.update({
    'figure.dpi': 110,
    'axes.spines.top': False,
    'axes.spines.right': False,
    'font.size': 11,
})
rng = np.random.default_rng(42)

Principes des plans d’expérience#

Un plan d’expérience est un protocole rigoureux pour comparer des conditions expérimentales tout en contrôlant les sources de variabilité. Quatre principes fondamentaux, formalisés par R.A. Fisher dans les années 1920, restent au cœur de tout design expérimental :

Les quatre principes de Fisher#

1. Randomisation L’affectation des sujets aux conditions (traitement A ou B) doit être aléatoire. La randomisation équilibre en moyenne toutes les variables confondantes — connues et inconnues — entre les groupes. Sans elle, tout effet observé peut être dû à un biais de sélection.

2. Contrôle Un groupe contrôle (ou placebo) permet d’isoler l’effet du traitement de l’effet temporel, du régression vers la moyenne, de l’effet Hawthorne, etc.

3. Réplication Répéter les mesures augmente la puissance statistique (capacité à détecter un effet réel) et permet d’estimer la variabilité naturelle du phénomène.

4. Blocage Regrouper des sujets similaires dans des blocs avant de randomiser réduit la variabilité résiduelle. Exemple : bloquer par plateforme (mobile/desktop) avant d’assigner les utilisateurs à A ou B.


A/B testing : définition opérationnelle#

Les étapes d’un A/B test#

Hide code cell source

etapes = {
    '1. Définir la métrique\nprimaire': 'Taux de conversion, revenu,\ntaux de rétention, NPS...',
    '2. Formuler les hypothèses\nstatistiques': 'H₀ : p_B = p_A\nH₁ : p_B > p_A (unilatéral)',
    '3. Calculer la taille\nd\'échantillon': 'Puissance, taille d\'effet,\nα, β fixés a priori',
    '4. Randomiser\nles utilisateurs': 'Hash(user_id) % 2\nou randomisation par bloc',
    '5. Collecter les données\npendant T jours': 'Durée fixée a priori,\nne pas regarder avant !',
    '6. Analyser et\nconclure': 'Test z, IC, taille d\'effet,\npuissance a posteriori',
}

fig, ax = plt.subplots(figsize=(14, 3))
ax.axis('off')
x_positions = np.linspace(0.05, 0.95, len(etapes))
for i, (titre, details) in enumerate(etapes.items()):
    x = x_positions[i]
    ax.text(x, 0.7, titre, ha='center', va='center', fontsize=8.5,
            bbox=dict(boxstyle='round,pad=0.4', facecolor='steelblue', alpha=0.2, edgecolor='steelblue'),
            fontweight='bold')
    ax.text(x, 0.25, details, ha='center', va='center', fontsize=7.5, color='#555555')
    if i < len(etapes) - 1:
        ax.annotate('', xy=(x + 0.08, 0.7), xytext=(x + 0.02, 0.7),
                    arrowprops=dict(arrowstyle='->', color='steelblue', lw=1.5))

ax.set_title('Étapes d\'un A/B test rigoureux', fontsize=12, pad=10)
plt.tight_layout()
plt.show()
_images/b4d52de610f134d7b2f9a8866f0ccaefba9bd8e4fad94767eeae11ca160452ab.png

Métrique primaire vs métriques secondaires#

Choisir une seule métrique primaire avant de lancer le test est crucial. Analyser simultanément de nombreuses métriques après le test revient à faire des comparaisons multiples — le taux de faux positifs explose (voir chapitre 8). Les métriques secondaires servent à comprendre le mécanisme, pas à décider.


Calcul de la taille d’échantillon#

Formule pour deux proportions#

Pour un test bilatéral comparant deux taux de conversion \(p_A\) et \(p_B\) :

\[n = \frac{(z_{\alpha/2} + z_\beta)^2 \left[p_A(1-p_A) + p_B(1-p_B)\right]}{(p_B - p_A)^2}\]

où :

  • \(z_{\alpha/2}\) : quantile normal pour le niveau de risque α (typiquement 1.96 pour α = 5%)

  • \(z_\beta\) : quantile normal pour la puissance souhaitée (0.84 pour 80%, 1.28 pour 90%)

  • \(p_A\) : taux de conversion du groupe contrôle (baseline)

  • \(p_B\) : taux de conversion attendu sous H₁ (= \(p_A\) + effet minimal détectable)

Hide code cell source

def taille_echantillon_proportions(p_a, p_b, alpha=0.05, puissance=0.80, bilateral=True):
    """Calcule la taille d'échantillon par groupe pour comparer deux proportions."""
    z_alpha = norm.ppf(1 - alpha / (2 if bilateral else 1))
    z_beta  = norm.ppf(puissance)
    numerateur = (z_alpha + z_beta)**2 * (p_a*(1-p_a) + p_b*(1-p_b))
    denominateur = (p_b - p_a)**2
    return int(np.ceil(numerateur / denominateur))

# Exemple : baseline 10%, amélioration attendue de 2 points
p_baseline = 0.10
ameliorations = np.arange(0.005, 0.06, 0.005)
puissances = [0.70, 0.80, 0.90]

fig, axes = plt.subplots(1, 2, figsize=(13, 5))

# Courbe taille vs taille d'effet
ax = axes[0]
for pwr in puissances:
    n_vals = [taille_echantillon_proportions(p_baseline, p_baseline + d, puissance=pwr)
              for d in ameliorations]
    ax.plot(ameliorations * 100, n_vals, 'o-', label=f'Puissance = {int(pwr*100)}%', linewidth=2)

ax.set_xlabel('Amélioration absolue attendue (points de %)')
ax.set_ylabel('Taille d\'échantillon par groupe')
ax.set_title(f'Taille d\'échantillon nécessaire\n(baseline = {p_baseline*100}%, α = 5%)')
ax.legend()
ax.set_ylim(0)

# Courbe de puissance en fonction du n
ax = axes[1]
n_range = np.linspace(100, 5000, 200)
for d in [0.01, 0.02, 0.03, 0.05]:
    p_b = p_baseline + d
    z_alpha = norm.ppf(0.975)
    # Puissance approximée
    sigma_H1 = np.sqrt((p_baseline*(1-p_baseline) + p_b*(1-p_b)) / n_range)
    z_stat   = d / sigma_H1
    puissance_vals = norm.cdf(z_stat - z_alpha) + norm.cdf(-z_stat - z_alpha)
    ax.plot(n_range, puissance_vals * 100, linewidth=2, label=f'Δ = {d*100:.0f}%')

ax.axhline(80, color='gray', linestyle='--', linewidth=1, label='80%')
ax.axhline(90, color='gray', linestyle=':', linewidth=1, label='90%')
ax.set_xlabel('Taille d\'échantillon par groupe (n)')
ax.set_ylabel('Puissance (%)')
ax.set_title(f'Courbe de puissance\n(baseline = {p_baseline*100}%, α = 5%)')
ax.legend(fontsize=9)
ax.set_ylim(0, 100)

plt.suptitle('Planification de la taille d\'échantillon', fontsize=13, y=1.01)
plt.tight_layout()
plt.show()

# Table de référence rapide
print("Table : taille par groupe (α=5%, puissance=80%, baseline=10%)")
print(f"{'Δ absolu':>10} | {'n/groupe':>10} | {'n total':>10}")
print("-" * 36)
for d in [0.01, 0.02, 0.03, 0.05, 0.10]:
    n = taille_echantillon_proportions(p_baseline, p_baseline + d)
    print(f"{'+'+ str(int(d*100))+'%':>10} | {n:>10,} | {2*n:>10,}")
_images/c46eb24819b88c8bec69be12f9865317862c434709672f1944a6d1b307a97241.png
Table : taille par groupe (α=5%, puissance=80%, baseline=10%)
  Δ absolu |   n/groupe |    n total
------------------------------------
       +1% |     14,749 |     29,498
       +2% |      3,839 |      7,678
       +3% |      1,772 |      3,544
       +5% |        683 |      1,366
      +10% |        197 |        394

Règle pratique

Pour un A/B test sur un taux de conversion de 10%, détecter une amélioration de 2 points (20% d’amélioration relative) à puissance 80% requiert environ 3 800 utilisateurs par groupe, soit 7 600 au total. Si votre trafic est de 500 visiteurs/jour, il faudra au moins 15 jours — et c’est sans regarder les résultats avant la fin !


Le problème du « peeking » (regarder en cours de route)#

Pourquoi c’est un problème#

L’intuition dit qu’il serait utile d’arrêter le test dès que \(p < 0.05\). C’est l’une des erreurs les plus répandues en A/B testing. En regardant les résultats à plusieurs reprises et en s’autorisant à arrêter si \(p < \alpha\), le taux de faux positifs réel explose bien au-delà de α.

Hide code cell source

def simuler_pvalue_evolution(n_max, p_a, p_b, graine=0):
    """Simule l'évolution de la p-valeur d'un test z au fil du temps."""
    rng_local = np.random.default_rng(graine)
    resultats_a = rng_local.binomial(1, p_a, n_max)
    resultats_b = rng_local.binomial(1, p_b, n_max)
    pvaleurs = []
    for n in range(10, n_max + 1, 5):
        conv_a = resultats_a[:n].mean()
        conv_b = resultats_b[:n].mean()
        p_pool = (resultats_a[:n].sum() + resultats_b[:n].sum()) / (2*n)
        se = np.sqrt(2 * p_pool * (1-p_pool) / n)
        if se == 0:
            pvaleurs.append(1.0)
            continue
        z = (conv_b - conv_a) / se
        pvaleurs.append(2 * norm.cdf(-abs(z)))
    return np.arange(10, n_max + 1, 5), np.array(pvaleurs)

# Sous H0 (p_A = p_B = 10%) : combien de fois peeking donne un faux positif ?
N_SIMULATIONS = 500
N_MAX = 1000
alpha = 0.05

faux_positifs_standard = 0
faux_positifs_peeking   = 0

for graine in range(N_SIMULATIONS):
    ns, pvals = simuler_pvalue_evolution(N_MAX, 0.10, 0.10, graine)
    # Test standard : regarder seulement à la fin
    if pvals[-1] < alpha:
        faux_positifs_standard += 1
    # Peeking : arrêt dès que p < alpha
    if np.any(pvals < alpha):
        faux_positifs_peeking += 1

print(f"Taux de faux positifs — test standard (n fixé)  : {faux_positifs_standard/N_SIMULATIONS*100:.1f}%")
print(f"Taux de faux positifs — peeking (arrêt si p<5%) : {faux_positifs_peeking/N_SIMULATIONS*100:.1f}%")

# Visualisation de quelques trajectoires de p-valeur
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

ax = axes[0]
ax.axhline(0.05, color='orangered', linewidth=2, linestyle='--', label='α = 0.05')
for graine in range(20):
    ns, pvals = simuler_pvalue_evolution(N_MAX, 0.10, 0.10, graine)
    ax.plot(ns, pvals, alpha=0.35, linewidth=0.8, color='steelblue')

# Trajectoire "fausse alarme" : la première qui franchit 0.05
for graine in range(N_SIMULATIONS):
    ns, pvals = simuler_pvalue_evolution(N_MAX, 0.10, 0.10, graine)
    if np.any(pvals < alpha) and pvals[-1] >= alpha:
        ax.plot(ns, pvals, color='orangered', linewidth=1.5, alpha=0.8, zorder=5)
        break

ax.set_xlabel('Nombre d\'observations par groupe')
ax.set_ylabel('p-valeur')
ax.set_title('Évolution de la p-valeur sous H₀\n(20 simulations + 1 fausse alarme en rouge)')
ax.legend()
ax.set_ylim(0, 1)

# Sous H1 (effet réel)
ax = axes[1]
ax.axhline(0.05, color='orangered', linewidth=2, linestyle='--', label='α = 0.05')
n_requis = taille_echantillon_proportions(0.10, 0.13)  # effet réel +3%
ax.axvline(n_requis, color='seagreen', linewidth=1.5, linestyle='--', label=f'n requis = {n_requis}')

for graine in range(15):
    ns, pvals = simuler_pvalue_evolution(N_MAX, 0.10, 0.13, graine)
    ax.plot(ns, pvals, alpha=0.5, linewidth=1, color='steelblue')

ax.set_xlabel('Nombre d\'observations par groupe')
ax.set_ylabel('p-valeur')
ax.set_title('Évolution de la p-valeur sous H₁\n(p_B = 13%, effet réel de +3%)')
ax.legend()
ax.set_ylim(0, 1)

plt.suptitle('Le problème du peeking', fontsize=13, y=1.01)
plt.tight_layout()
plt.show()
Taux de faux positifs — test standard (n fixé)  : 4.4%
Taux de faux positifs — peeking (arrêt si p<5%) : 40.8%
_images/6216d7f39127c4d0aefbad8c5cf8887d38c88b0af575c58498ab44cff18f067d.png

Alpha spending et tests séquentiels#

Pour les situations où il est opérationnellement nécessaire de regarder les résultats en cours de route (tests longs, décisions urgentes), des méthodes d”alpha spending permettent de distribuer le budget d’erreur de type I entre les analyses intermédiaires.

Procédure de O’Brien-Fleming : les seuils critiques sont très conservateurs au début (ex : \(\alpha_1 = 0.001\), \(\alpha_2 = 0.005\)) et s’assouplissent à mesure que l’on approche du terme prévu, de sorte que le budget α total reste 5%.

SPRT (Sequential Probability Ratio Test, Wald 1945) : à chaque observation, on calcule le rapport de vraisemblance \(\Lambda_n\) et on arrête si \(\Lambda_n \geq B\) (rejeter H₀) ou \(\Lambda_n \leq A\) (accepter H₀). Les bornes \(A = \beta/(1-\alpha)\) et \(B = (1-\beta)/\alpha\) garantissent les erreurs α et β désirées.

Always-valid p-values (méthode d’anytime-valid inference) : des variantes récentes utilisent des e-valeurs (e-values) pour produire des p-valeurs valides à tout moment de l’observation.

Hide code cell source

# Simulation de l'alpha spending — comparaison peeking naïf vs O'Brien-Fleming
def obrien_fleming_seuil(t, alpha=0.05, k=5):
    """Seuil d'arrêt O'Brien-Fleming au temps t ∈ [0,1], k analyses prévues."""
    # Approximation : z_critique ≈ z_α/2 / sqrt(t)
    z_alpha = norm.ppf(1 - alpha/2)
    return z_alpha / np.sqrt(t)

t_analyses = np.array([0.2, 0.4, 0.6, 0.8, 1.0])
seuils_obf  = obrien_fleming_seuil(t_analyses)

print("Seuils O'Brien-Fleming (α=5%, 5 analyses)")
print(f"{'Temps relatif':>15} | {'|z| critique':>14} | {'p équivalent':>14}")
print("-" * 48)
for t, z in zip(t_analyses, seuils_obf):
    p_eq = 2 * norm.cdf(-z)
    print(f"{t:>15.1f} | {z:>14.3f} | {p_eq:>14.4f}")

fig, ax = plt.subplots(figsize=(8, 4))
t_continu = np.linspace(0.05, 1.0, 200)
ax.plot(t_continu * 100, obrien_fleming_seuil(t_continu), 'steelblue', linewidth=2,
        label="O'Brien-Fleming |z| critique")
ax.axhline(norm.ppf(0.975), color='orangered', linestyle='--', linewidth=1.5,
           label=f'Test fixe |z| = {norm.ppf(0.975):.2f}')
ax.scatter(t_analyses * 100, seuils_obf, s=80, zorder=5, color='steelblue')
ax.fill_between(t_continu * 100, obrien_fleming_seuil(t_continu), 10,
                alpha=0.08, color='steelblue', label='Zone de rejet OBF')
ax.set_xlabel('Avancement du test (%)')
ax.set_ylabel('Valeur critique de |z|')
ax.set_ylim(1, 6)
ax.set_title("Seuils O'Brien-Fleming : très conservateurs au début, identiques au test fixe à la fin")
ax.legend(fontsize=9)
plt.tight_layout()
plt.show()
Seuils O'Brien-Fleming (α=5%, 5 analyses)
  Temps relatif |   |z| critique |   p équivalent
------------------------------------------------
            0.2 |          4.383 |         0.0000
            0.4 |          3.099 |         0.0019
            0.6 |          2.530 |         0.0114
            0.8 |          2.191 |         0.0284
            1.0 |          1.960 |         0.0500
_images/5378e5ce8ec820302d94b89595983b0bf5f8f8bddcf1c6cdfd6f809b42cbd6bd.png

Biais courants en A/B testing#

Effet de nouveauté (novelty effect)#

Les utilisateurs réagissent à la nouveauté d’une interface, indépendamment de sa qualité réelle. Un bouton de couleur différente peut générer plus de clics simplement parce qu’il attire l’œil, mais l’effet disparaît après quelques jours d’exposition. Solution : s’assurer que le test dure suffisamment longtemps (au moins 2 semaines) et vérifier la cohérence de l’effet entre utilisateurs nouveaux et habitués.

Paradoxe de Simpson#

# Illustration du paradoxe de Simpson en A/B testing
data = {
    'Segment'   : ['Mobile', 'Mobile', 'Desktop', 'Desktop', 'Total', 'Total'],
    'Variante'  : ['A', 'B'] * 3,
    'Visiteurs' : [8000, 2000, 2000, 8000, 10000, 10000],
    'Conversions': [400, 80, 200, 640, 600, 720],
}
df_simpson = pd.DataFrame(data)
df_simpson['Taux'] = df_simpson['Conversions'] / df_simpson['Visiteurs']

print("Paradoxe de Simpson — A/B test segmenté par appareil\n")
print(f"{'Segment':>10} | {'Variante':>10} | {'Visiteurs':>10} | {'Conversions':>12} | {'Taux':>8}")
print("-" * 58)
for _, row in df_simpson.iterrows():
    print(f"{row['Segment']:>10} | {row['Variante']:>10} | {row['Visiteurs']:>10,} | "
          f"{row['Conversions']:>12,} | {row['Taux']:>8.1%}")

print("\n→ Sur mobile   : A=5.0% > B=4.0%  (A gagne)")
print("→ Sur desktop  : A=10.0% < B=8.0% (A gagne... non, B=8% < A=10%)")
print("→ En agrégé    : A=6.0% < B=7.2%  (B semble gagner)")
print("\nCause : la variante B reçoit plus de visiteurs desktop (taux naturellement plus élevé).")
Paradoxe de Simpson — A/B test segmenté par appareil

   Segment |   Variante |  Visiteurs |  Conversions |     Taux
----------------------------------------------------------
    Mobile |          A |      8,000 |          400 |     5.0%
    Mobile |          B |      2,000 |           80 |     4.0%
   Desktop |          A |      2,000 |          200 |    10.0%
   Desktop |          B |      8,000 |          640 |     8.0%
     Total |          A |     10,000 |          600 |     6.0%
     Total |          B |     10,000 |          720 |     7.2%

→ Sur mobile   : A=5.0% > B=4.0%  (A gagne)
→ Sur desktop  : A=10.0% < B=8.0% (A gagne... non, B=8% < A=10%)
→ En agrégé    : A=6.0% < B=7.2%  (B semble gagner)

Cause : la variante B reçoit plus de visiteurs desktop (taux naturellement plus élevé).

Contamination entre groupes#

Lorsque les utilisateurs peuvent se parler (réseau social) ou partager un compte, l”effet de contamination (spillover) invalide la comparaison. Solution : randomiser par unité d’expérimentation adaptée (cluster randomization : par ménage, par ville, par entreprise).

Biais de survie#

Si certains utilisateurs abandonnent le test avant la fin (notamment dans le groupe contrôle ou parce qu’ils sont frustrés), l’échantillon final n’est plus représentatif. Les statistiques d’attrition doivent être analysées avant les statistiques de performance.


Plans factoriels et tests multivariés (MVT)#

Plans factoriels#

Un plan factoriel teste plusieurs facteurs simultanément. Un design \(2^k\) teste \(k\) facteurs binaires en combinant toutes les combinaisons possibles, permettant d’estimer :

  • Les effets principaux de chaque facteur

  • Les effets d’interaction entre facteurs

Exemple : tester simultanément la couleur du bouton (rouge/bleu) et le texte (« Acheter maintenant » / « Découvrir ») génère 4 conditions au lieu de lancer 2 tests séquentiels, avec la possibilité de détecter une interaction (ex : le texte court fonctionne mieux avec le rouge).

Tests multivariés (MVT)#

Le MVT (Multivariate Testing) permet de tester plusieurs éléments d’une page simultanément. La différence avec le plan factoriel est surtout terminologique dans le monde du produit :

  • A/B test : 1 facteur, 2 niveaux

  • A/B/n test : 1 facteur, n niveaux

  • MVT : plusieurs facteurs, tous les niveaux combinés

L’attribution des effets requiert une analyse de la variance multifactorielle (ANOVA à plusieurs facteurs ou régression avec interactions).


Analyse des résultats#

Simulation complète d’un A/B test#

Hide code cell source

# Simulation d'un A/B test complet
rng_test = np.random.default_rng(2024)

P_A = 0.10       # taux de conversion contrôle
P_B = 0.128      # taux de conversion variante (+2.8 points)
N   = taille_echantillon_proportions(P_A, P_B + 0.002, puissance=0.80)

# Collecte des données
conversions_a = rng_test.binomial(1, P_A, N)
conversions_b = rng_test.binomial(1, P_B, N)

n_a = len(conversions_a)
n_b = len(conversions_b)
conv_a = conversions_a.mean()
conv_b = conversions_b.mean()
delta  = conv_b - conv_a

# Test z pour deux proportions
p_pool = (conversions_a.sum() + conversions_b.sum()) / (n_a + n_b)
se_pool = np.sqrt(p_pool * (1 - p_pool) * (1/n_a + 1/n_b))
z_stat = delta / se_pool
p_value = 2 * norm.cdf(-abs(z_stat))

# Intervalle de confiance
se_diff = np.sqrt(conv_a*(1-conv_a)/n_a + conv_b*(1-conv_b)/n_b)
ic_low  = delta - 1.96 * se_diff
ic_high = delta + 1.96 * se_diff

# Taille d'effet relative
effet_relatif = delta / conv_a

print("=" * 55)
print("     RAPPORT D'A/B TEST")
print("=" * 55)
print(f"  Groupe A (contrôle) : n={n_a:,}, conv={conv_a:.3f} ({conv_a*100:.2f}%)")
print(f"  Groupe B (variante) : n={n_b:,}, conv={conv_b:.3f} ({conv_b*100:.2f}%)")
print(f"  Δ absolu            : {delta:+.4f} ({delta*100:+.2f}%)")
print(f"  Δ relatif           : {effet_relatif:+.1%}")
print(f"  IC 95%              : [{ic_low*100:.2f}%, {ic_high*100:.2f}%]")
print(f"  z-stat              : {z_stat:.3f}")
print(f"  p-valeur            : {p_value:.4f}")
print(f"  Décision (α=5%)     : {'Rejeter H₀ ✓' if p_value < 0.05 else 'Ne pas rejeter H₀'}")
print("=" * 55)

# Visualisation des résultats
fig, axes = plt.subplots(1, 3, figsize=(15, 4))

# Distribution des taux observés
ax = axes[0]
x_range = np.linspace(0.05, 0.20, 300)
for (taux, n, nom, col) in [(conv_a, n_a, 'A (contrôle)', 'steelblue'),
                              (conv_b, n_b, 'B (variante)', 'orangered')]:
    se = np.sqrt(taux*(1-taux)/n)
    ax.plot(x_range, norm.pdf(x_range, taux, se), linewidth=2, color=col, label=f'{nom}: {taux*100:.2f}%')
    ax.fill_between(x_range, norm.pdf(x_range, taux, se), alpha=0.15, color=col)
ax.set_xlabel('Taux de conversion')
ax.set_ylabel('Densité')
ax.set_title('Distribution des estimateurs')
ax.legend()

# IC sur la différence
ax = axes[1]
ax.barh(['Δ = conv_B − conv_A'], [delta*100], xerr=[[abs(ic_low - delta)*100], [(ic_high - delta)*100]],
        color='steelblue', alpha=0.7, capsize=8, height=0.4)
ax.axvline(0, color='gray', linewidth=1.5, linestyle='--', label='H₀ : Δ = 0')
ax.set_xlabel('Différence absolue (%)')
ax.set_title(f'Intervalle de confiance 95%\n[{ic_low*100:.2f}%, {ic_high*100:.2f}%]')
ax.legend()

# Analyse par segment (post-hoc)
segments = ['Mobile', 'Desktop', 'Nouveau', 'Habitué']
delta_segs = rng_test.normal(delta, 0.015, 4)
ic_segs    = rng_test.uniform(0.008, 0.018, 4)

ax = axes[2]
couleurs_seg = ['steelblue' if abs(d) > ic else 'lightgray'
                for d, ic in zip(delta_segs, ic_segs)]
ax.barh(segments, delta_segs * 100, xerr=ic_segs * 100 * 1.96,
        color=couleurs_seg, alpha=0.8, capsize=5, height=0.5)
ax.axvline(0, color='gray', linewidth=1.5, linestyle='--')
ax.axvline(delta*100, color='orangered', linewidth=1, linestyle=':', label=f'Effet global = {delta*100:.2f}%')
ax.set_xlabel('Δ taux de conversion (%)')
ax.set_title('Segmentation post-hoc\n(interpréter avec prudence)')
ax.legend(fontsize=8)

plt.suptitle('Résultats complets de l\'A/B test', fontsize=13, y=1.01)
plt.tight_layout()
plt.show()
=======================================================
     RAPPORT D'A/B TEST
=======================================================
  Groupe A (contrôle) : n=1,772, conv=0.108 (10.78%)
  Groupe B (variante) : n=1,772, conv=0.130 (12.98%)
  Δ absolu            : +0.0220 (+2.20%)
  Δ relatif           : +20.4%
  IC 95%              : [0.07%, 4.33%]
  z-stat              : 2.025
  p-valeur            : 0.0429
  Décision (α=5%)     : Rejeter H₀ ✓
=======================================================
_images/9a174b73bf7541f1438960070431cdabf4531c6f021347d6dc843d82d4f3698c.png

Segmentation post-hoc : prudence !

L’analyse par segment après le test constitue des comparaisons multiples non planifiées. Si vous analysez 10 segments, vous attendez statistiquement 0.5 faux positif sous H₀. Les résultats de segmentation post-hoc doivent être considérés comme exploratoires et validés par un test préenregistré.


Récapitulatif et checklist#

Hide code cell source

checklist = [
    ("Pré-test", [
        "Métrique primaire définie a priori",
        "Hypothèse directionnelle ou bilatérale spécifiée",
        "Taille d'échantillon calculée (puissance ≥ 80%)",
        "Durée du test fixée (au moins 1 cycle hebdomadaire complet)",
        "Randomisation vérifiée (SRM test)",
    ]),
    ("Pendant le test", [
        "Ne pas regarder les résultats avant la fin prévue",
        "Si urgence : utiliser alpha spending ou SPRT",
        "Surveiller uniquement les métriques de santé (bugs, crashs)",
        "Vérifier l'absence de contamination",
    ]),
    ("Post-test", [
        "Analyser la métrique primaire en premier",
        "Calculer l'IC et la taille d'effet (pas seulement la p-valeur)",
        "Vérifier la puissance a posteriori si résultat non significatif",
        "Segmentation post-hoc = exploratoire uniquement",
        "Documenter le résultat, même s'il est négatif",
    ]),
]

fig, ax = plt.subplots(figsize=(12, 6))
ax.axis('off')
y = 0.98
couleurs_phase = ['#2980b9', '#27ae60', '#8e44ad']
for (phase, items), col in zip(checklist, couleurs_phase):
    ax.text(0.01, y, f'■ {phase}', fontsize=11, fontweight='bold', color=col,
            transform=ax.transAxes, va='top')
    y -= 0.06
    for item in items:
        ax.text(0.04, y, f'☐  {item}', fontsize=9.5, color='#333333',
                transform=ax.transAxes, va='top')
        y -= 0.055
    y -= 0.02

ax.set_title('Checklist A/B testing', fontsize=13, pad=15)
plt.tight_layout()
plt.show()
_images/8ca4378ed75fb6f9ffe22a469d058d4d72a882ac213eae97b8603f4636aeba2b.png