Aller au contenu

Comparez deux groupes avec Mann-Whitney

Une équipe produit lance un test A/B : la moitié des visiteurs voit l’ancienne page d’accueil (version A), l’autre moitié une nouvelle (version B). On mesure la durée de chaque session. Les deux groupes sont formés de personnes différentes : impossible d’apparier, comme au chapitre précédent. Et les durées de session sont typiquement asymétriques, avec quelques sessions très longues.

Le test de Mann-Whitney est l’outil standard de cette situation. Vous verrez comment il se calcule, ce qu’il teste exactement (ce n’est pas tout à fait une égalité de moyennes), et pourquoi il est, mot pour mot, l’aire sous la courbe ROC que vous utilisez pour évaluer un classifieur.

Le fil rouge : un test A/B sur la durée des sessions

Section intitulée « Le fil rouge : un test A/B sur la durée des sessions »

Sur un petit pilote, on a relevé ces durées de session (en minutes) :

Version A (n1=6n_1 = 6)3,25,12,47,84,03,6
Version B (n2=7n_2 = 7)6,38,94,412,57,15,79,8

Les médianes valent 3,8 minutes pour A et 7,1 minutes pour B. La version B semble retenir les visiteurs plus longtemps. Mais avec 13 sessions, est-ce concluant ?

L’idée du test, proposée par Wilcoxon puis reformulée par Mann et Whitney, est la suivante : on mélange les deux échantillons, on classe les N=n1+n2N = n_1 + n_2 valeurs, puis on regarde comment les rangs se répartissent entre les groupes. Si les deux versions sont équivalentes, les rangs de A devraient être éparpillés au hasard parmi les rangs 1 à 13. Si A est systématiquement plus court, ses rangs seront concentrés en bas.

24681012durée de session (min)12345678910111213B : R₂ = 65A : R₁ = 26rangs de Brangs de A
Les sessions A occupent surtout les premiers rangs (1 à 4 et 6), les sessions B les derniers : c'est ce déséquilibre des sommes de rangs que mesure le test.

On transforme la somme des rangs en une statistique plus parlante :

U1=R1−n1(n1+1)2U_1 = R_1 - \frac{n_1(n_1 + 1)}{2}

où R1R_1 est la somme des rangs du groupe 1 et n1(n1+1)/2n_1(n_1+1)/2 la plus petite valeur possible de R1R_1 (quand le groupe 1 occupe les rangs 1 à n1n_1). On définit U2U_2 de même, et U1+U2=n1n2U_1 + U_2 = n_1 n_2.

Ici, U1=26−21=5U_1 = 26 - 21 = 5 et U2=65−28=37U_2 = 65 - 28 = 37, avec bien 5+37=42=6×75 + 37 = 42 = 6 \times 7.

Pourquoi cette transformation ? Parce que U1U_1 a une interprétation directe :

U1=#{(i,j):Xi>Yj}U_1 = \#\lbrace (i, j) : X_i > Y_j \rbrace

c’est le nombre de paires (une session A, une session B) dans lesquelles la session A est la plus longue (un ex aequo compte pour 1/2). Sur les 6×7=426 \times 7 = 42 paires possibles, seules 5 donnent l’avantage à A : par exemple, la session A de 7,8 minutes bat celles de B qui durent 4,4 ; 5,7 ; 6,3 et 7,1 minutes, et celle de 5,1 minutes bat celle de 4,4.

4,45,76,37,18,99,812,5session B (min)2,43,23,64,05,17,8session A (min)BBBBBBBBBBBBBBBBBBBBBBBBBBBBABBBBBBAAAABBBU₁ = 5 cases où A l’emporteU₂ = 37 cases où B l’emporte
Chacune des 42 paires (A, B) est un duel. A n'en gagne que 5 : U₁ = 5 et U₂ = 37.

Sous H0H_0 (les deux échantillons viennent de la même loi continue), toutes les façons de choisir les n1n_1 rangs de A parmi les NN sont équiprobables. Il y en a (136)=1716\binom{13}{6} = 1716. En les énumérant, on trouve 19 configurations avec U1≤5U_1 \le 5. La p-valeur bilatérale vaut donc :

p=2×191716≈0,022p = 2 \times \frac{19}{1716} \approx 0{,}022

On rejette H0H_0 au seuil de 5 % : les sessions B sont plus longues.

Pour de plus grands échantillons, on utilise, sous H0H_0 :

E(U)=n1n22,Var(U)=n1n2(n1+n2+1)12E(U) = \frac{n_1 n_2}{2}, \qquad \mathrm{Var}(U) = \frac{n_1 n_2 (n_1 + n_2 + 1)}{12}

Ici, E(U)=21E(U) = 21, Var(U)=49\mathrm{Var}(U) = 49, donc Z=(5−21)/7≈−2,29Z = (5 - 21)/7 \approx -2{,}29 et p≈0,022p \approx 0{,}022. En présence d’ex aequo, on corrige la variance ; les logiciels ajoutent aussi souvent une correction de continuité de 1/2.

0510152025303540U₁ (paires où A l’emporte)0,000,010,020,030,040,050,060,07probabilité sous H₀observé U₁ = 519 cas19 casp = 2 × 19/1 716 ≈ 0,022approximation N(21 ; 7²)
Sur les 1 716 façons de choisir les rangs de A, 19 donnent U₁ ≤ 5 et 19 donnent U₁ ≥ 37 : la valeur observée est dans une queue, d'où p ≈ 0,022.

On lit souvent que Mann-Whitney « compare les médianes ». C’est vrai seulement sous une hypothèse supplémentaire. Le test est sensible à la quantité :

θ=P(X>Y)+12P(X=Y)\theta = P(X > Y) + \tfrac{1}{2} P(X = Y)

où XX est une observation tirée du groupe 1 et YY une observation tirée du groupe 2. L’estimateur naturel de θ\theta est U1/(n1n2)U_1/(n_1 n_2). Sous H0H_0, θ=1/2\theta = 1/2 : une observation de A a autant de chances de battre une observation de B que l’inverse.

  • Si les deux lois ont la même forme et ne diffèrent que par un décalage (modèle de translation, location shift), alors θ≠1/2\theta \ne 1/2 équivaut à une différence de médianes, et le test compare bien les médianes.
  • Si les formes diffèrent (par exemple, des variances très différentes), le test peut rejeter alors que les médianes sont égales, ou l’inverse.
−2024x0,00,20,40,60,81,0densitémédianes 0 et 1P(B > A) ≈ 0,76pur décalage : même formeAB−2024xmédianes toutes deux 0mais P(B > A) ≈ 0,60formes différentes, même médiane
À gauche, un pur décalage : θ ≠ 1/2 équivaut à des médianes différentes. À droite, les médianes sont égales et pourtant P(B > A) ≈ 0,60 : Mann-Whitney peut rejeter sans qu'aucune médiane ne diffère.

Une p-valeur ne dit pas de combien B dépasse A. Sous le modèle de translation, l’estimateur de Hodges-Lehmann du décalage est la médiane des n1n2n_1 n_2 différences Yj−XiY_j - X_i. Ici, il vaut 3,2 minutes : la version B allonge les sessions d’environ 3 minutes. C’est l’estimation que fournit R avec conf.int = TRUE, accompagnée d’un intervalle de confiance.

import numpy as np
from scipy import stats
A = np.array([3.2, 5.1, 2.4, 7.8, 4.0, 3.6])
B = np.array([6.3, 8.9, 4.4, 12.5, 7.1, 5.7, 9.8])
res = stats.mannwhitneyu(A, B) # bilatéral ; loi exacte pour les petits échantillons sans ex aequo
print(res.statistic, res.pvalue) # 5.0 et environ 0.022
# Estimateur de Hodges-Lehmann du décalage B - A
print(np.median(B[:, None] - A[None, :])) # 3.2
# Test t de Welch, pour comparaison
print(stats.ttest_ind(B, A, equal_var=False).pvalue) # environ 0.023

scipy renvoie la statistique UU du premier échantillon passé en argument. En R, la même fonction que pour les données appariées sert, sans paired :

A <- c(3.2, 5.1, 2.4, 7.8, 4.0, 3.6)
B <- c(6.3, 8.9, 4.4, 12.5, 7.1, 5.7, 9.8)
wilcox.test(A, B) # W = 5, p = 0.02214
wilcox.test(B, A, conf.int = TRUE) # estimation du décalage (Hodges-Lehmann) et IC

R appelle la statistique WW, mais c’est bien U1U_1. D’où les deux noms du test : « Mann-Whitney U » et « test de la somme des rangs de Wilcoxon » (Wilcoxon rank-sum test).

Changeons de décor. Un classifieur attribue un score à des exemples positifs (des patients malades, des transactions frauduleuses) et à des exemples négatifs. Son AUC (aire sous la courbe ROC, présentée dans le cours d’Apprentissage automatique) admet une interprétation probabiliste : c’est la probabilité qu’un positif tiré au hasard reçoive un score plus élevé qu’un négatif tiré au hasard, les égalités comptant pour 1/2.

Vous reconnaissez θ\theta ! L’AUC est exactement U/(n+n−)U/(n_+ n_-), où UU est la statistique de Mann-Whitney des scores des positifs contre ceux des négatifs.

0,00,20,40,60,81,0taux de faux positifs0,00,20,40,60,81,0taux de vrais positifshasardAUC = 0,5AUC = 17/20 = 0,850,920,810,640,47
En parcourant les scores du plus grand au plus petit, chaque positif fait monter la courbe et chaque négatif la fait avancer. L'aire ombrée vaut 17/20 = 0,85 : exactement U/(n₊n₋).

Cette équivalence a trois conséquences pratiques :

  • l’AUC ne dépend que du classement des scores, pas de leurs valeurs : toute transformation croissante des scores la laisse inchangée ;
  • le calcul par les rangs coûte O(Nlog⁡N)O(N \log N), bien moins que l’énumération des n+n−n_+ n_- paires ;
  • tester « AUC = 0,5 » (le modèle ne fait pas mieux que le hasard), c’est faire un test de Mann-Whitney.

Pour notre test A/B, U2/(n1n2)=37/42≈0,88U_2/(n_1 n_2) = 37/42 \approx 0{,}88 : une session B tirée au hasard a 88 % de chances d’être plus longue qu’une session A. Cette « AUC » est une taille d’effet facile à expliquer, qu’on appelle aussi probabilité de supériorité.

  • Mann-Whitney compare deux groupes indépendants en classant toutes les observations ensemble.
  • U1=R1−n1(n1+1)/2U_1 = R_1 - n_1(n_1+1)/2 compte les paires où le groupe 1 l’emporte ; U1+U2=n1n2U_1 + U_2 = n_1 n_2 ; sous H0H_0, E(U)=n1n2/2E(U) = n_1 n_2/2 et Var(U)=n1n2(n1+n2+1)/12\mathrm{Var}(U) = n_1 n_2(n_1 + n_2 + 1)/12.
  • Le test porte sur θ=P(X>Y)+12P(X=Y)\theta = P(X > Y) + \frac{1}{2} P(X = Y) ; il compare les médianes seulement si les deux lois ne diffèrent que par un décalage.
  • L’estimateur de Hodges-Lehmann (médiane des différences croisées) chiffre le décalage.
  • L’AUC d’un classifieur vaut U/(n+n−)U/(n_+ n_-) : c’est une statistique de Mann-Whitney.
  • scipy.stats.mannwhitneyu ; en R, wilcox.test(x, y) sans paired.

Exercice · Calculer U à la main

facile

Deux méthodes de révision sont testées sur deux groupes d’étudiants. Notes du groupe X : 12, 15, 9, 20. Notes du groupe Y : 18, 25, 22, 30, 16.

  1. Calculez RXR_X, puis UXU_X, de deux façons (par les rangs et par les paires).
  2. Sous H0H_0, P(UX≤2)=4/126P(U_X \le 2) = 4/126. Donnez la p-valeur bilatérale et concluez au seuil de 5 %.
Voir la solution
  1. Classement des 9 notes : 9 (X), 12 (X), 15 (X), 16 (Y), 18 (Y), 20 (X), 22 (Y), 25 (Y), 30 (Y). Les rangs de X sont 1, 2, 3 et 6, donc RX=12R_X = 12 et UX=12−4×5/2=2U_X = 12 - 4 \times 5/2 = 2. Par les paires : seule la note 20 dépasse des notes de Y (16 et 18), soit 2 paires. Les deux méthodes concordent.
  2. p=2×4/126≈0,063p = 2 \times 4/126 \approx 0{,}063. On ne rejette pas H0H_0 au seuil de 5 %, même si le groupe Y semble meilleur. Avec 4 et 5 observations, il faudrait une séparation presque parfaite pour conclure.

En code

import numpy as np
from scipy import stats
X = np.array([12, 15, 9, 20])
Y = np.array([18, 25, 22, 30, 16])
rangs = stats.rankdata(np.r_[X, Y])
R_X = rangs[:len(X)].sum()
print(R_X, R_X - len(X) * (len(X) + 1) / 2) # R_X = 12.0, U_X = 2.0
print((X[:, None] > Y[None, :]).sum()) # par les paires : 2
res = stats.mannwhitneyu(X, Y) # loi exacte
print(res.statistic, res.pvalue) # 2.0 0.0635 (= 2 x 4/126)

Exercice · De U à l'AUC

moyen

Un modèle de détection de fraude est évalué sur 50 transactions frauduleuses et 200 transactions normales. La somme des rangs des scores des fraudes, parmi les 250 scores, vaut R+=8 475R_+ = 8\,475 (aucun ex aequo).

  1. Calculez UU et l’AUC.
  2. Que vaudrait l’AUC si l’on remplaçait chaque score ss par log⁡(s)\log(s) ?
  3. Sous l’hypothèse d’un modèle sans pouvoir discriminant, calculez E(U)E(U) et l’écart-type de UU. L’AUC observée est-elle compatible avec le hasard ?
Voir la solution
  1. U=R+−n+(n++1)/2=8 475−50×51/2=8 475−1 275=7 200U = R_+ - n_+(n_+ + 1)/2 = 8\,475 - 50 \times 51/2 = 8\,475 - 1\,275 = 7\,200. AUC =7 200/(50×200)=0,72= 7\,200/(50 \times 200) = 0{,}72.
  2. Toujours 0,72 : le logarithme est croissant, il conserve l’ordre des scores, donc les rangs et UU.
  3. E(U)=50×200/2=5 000E(U) = 50 \times 200/2 = 5\,000 et Var(U)=50×200×251/12≈209 167\mathrm{Var}(U) = 50 \times 200 \times 251/12 \approx 209\,167, soit un écart-type d’environ 457. Z=(7 200−5 000)/457≈4,8Z = (7\,200 - 5\,000)/457 \approx 4{,}8 : on est très loin de ce que produirait le hasard (pp de l’ordre de 10−610^{-6}). Le modèle discrimine réellement, même si une AUC de 0,72 reste modeste.

En code

import numpy as np
from scipy import stats
n_pos, n_neg, R_pos = 50, 200, 8475
U = R_pos - n_pos * (n_pos + 1) / 2
print(U, U / (n_pos * n_neg)) # 7200.0 0.72
# Sous H0 : moyenne et écart-type de U, puis score z
E_U = n_pos * n_neg / 2
sd_U = np.sqrt(n_pos * n_neg * (n_pos + n_neg + 1) / 12)
z = (U - E_U) / sd_U
print(E_U, sd_U, z, 2 * stats.norm.sf(z)) # 5000.0 457.3 4.81 1.5e-06

Exercice · Pourquoi U compte les paires

difficile

Montrez que U1=R1−n1(n1+1)/2U_1 = R_1 - n_1(n_1+1)/2 est égal au nombre de paires (Xi,Yj)(X_i, Y_j) telles que Xi>YjX_i > Y_j (on suppose qu’il n’y a pas d’ex aequo).

Indication : exprimez le rang de X(k)X_{(k)}, la kk-ième plus petite valeur du groupe 1, dans l’échantillon mélangé.

Voir la solution

Dans l’échantillon mélangé, le rang de X(k)X_{(k)} est égal au nombre de valeurs inférieures ou égales à X(k)X_{(k)}. Parmi elles, il y a exactement kk valeurs du groupe 1 (X(1),…,X(k)X_{(1)}, \dots, X_{(k)}), et un certain nombre ckc_k de valeurs du groupe 2, celles qui sont plus petites que X(k)X_{(k)}. Donc rang(X(k))=k+ck\text{rang}(X_{(k)}) = k + c_k.

En sommant sur k=1,…,n1k = 1, \dots, n_1 :

R1=∑k=1n1(k+ck)=n1(n1+1)2+∑k=1n1ckR_1 = \sum_{k=1}^{n_1} (k + c_k) = \frac{n_1(n_1+1)}{2} + \sum_{k=1}^{n_1} c_k

Or ∑kck\sum_k c_k compte, pour chaque XX, le nombre de YY qu’il dépasse : c’est exactement le nombre de paires avec Xi>YjX_i > Y_j. D’où U1=R1−n1(n1+1)/2=#{(i,j):Xi>Yj}U_1 = R_1 - n_1(n_1+1)/2 = \#\lbrace (i, j) : X_i > Y_j \rbrace. Ce résultat justifie à la fois l’interprétation de UU et le calcul rapide de l’AUC par les rangs.

En code

import numpy as np
from scipy import stats
# Vérification numérique sur des échantillons aléatoires sans ex aequo
rng = np.random.default_rng(0)
for n1, n2 in [(4, 5), (10, 7), (30, 40)]:
X, Y = rng.normal(size=n1), rng.normal(0.5, 1, size=n2)
R1 = stats.rankdata(np.r_[X, Y])[:n1].sum()
U_rangs = R1 - n1 * (n1 + 1) / 2
U_paires = (X[:, None] > Y[None, :]).sum()
print(n1, n2, U_rangs, U_paires) # les deux colonnes sont toujours égales