Claude
docs(sprint-H.8): cleanup obsolete legacy/shim language in production docstrings
e407ec0 unverified
Raw
History Blame
8.39 kB
"""Test de Wilcoxon signé-rangé + tests pairwise (Sprint 7).
Test non-paramétrique pour comparer 2 séries appariées (mêmes
documents, deux moteurs différents). Utilise scipy si disponible
(méthode exacte n ≤ 25), sinon approximation normale native (n ≥ 10)
ou table critique simplifiée pour très petits n.
"""
from __future__ import annotations
import math
# Import optionnel de scipy — utilisé pour le test de Wilcoxon si disponible
# (méthode exacte pour n ≤ 25, approximation normale pour n > 25).
# En son absence, l'implémentation native (approximation normale pour n ≥ 10)
# est utilisée automatiquement.
try:
from scipy.stats import wilcoxon as _scipy_wilcoxon # type: ignore[import-untyped]
_SCIPY_AVAILABLE = True
except ImportError:
_SCIPY_AVAILABLE = False
def wilcoxon_test(
a: list[float],
b: list[float],
zero_method: str = "wilcox",
) -> dict:
"""Test de Wilcoxon signé-rangé entre deux séries de CER appariées.
Retourne un dict avec :
- statistic : W = min(W⁺, W⁻)
- p_value : p-value bilatérale
- significant : bool (p < 0.05)
- interpretation : phrase lisible
- n_pairs : nombre de paires utilisées (après retrait des zéros)
- W_plus : somme des rangs des différences positives
- W_minus : somme des rangs des différences négatives
Hypothèses et limites
---------------------
* Les observations sont appariées (même corpus, deux moteurs différents).
* Le test est non-paramétrique : aucune hypothèse de normalité des CER.
* ``zero_method="wilcox"`` (défaut) : les paires sans différence (aᵢ = bᵢ)
sont simplement exclues. Les autres méthodes (``"pratt"``, ``"zsplit"``)
nécessitent scipy.
* **Approximation normale** (implémentation native, n ≥ 10) :
L'approximation est raisonnable pour n ≥ 10 et converge vers la
distribution exacte. Pour n < 10, une table critique simplifiée est
utilisée (p ∈ {0.04, 0.20}) — résultat **conservateur**.
* **scipy** (si installé) : ``scipy.stats.wilcoxon`` est utilisé à la place
de l'approximation native. scipy utilise la méthode exacte pour n ≤ 25
et l'approximation normale pour n > 25, ce qui est plus précis.
* **Validité** : le test suppose la symétrie de la distribution des
différences. Avec de très petits n (< 5), les résultats sont peu fiables
quelle que soit la méthode.
Parameters
----------
a, b : séries de CER (même longueur, même ordre de documents)
zero_method : gestion des paires nulles (défaut : ``"wilcox"``)
"""
if len(a) != len(b):
raise ValueError("Les deux listes doivent avoir la même longueur")
diffs = [x - y for x, y in zip(a, b)]
# Retirer les zéros (méthode "wilcox")
if zero_method == "wilcox":
diffs = [d for d in diffs if d != 0.0]
n = len(diffs)
if n == 0:
return {
"statistic": 0.0,
"p_value": 1.0,
"significant": False,
"interpretation": "Aucune différence entre les deux concurrents.",
"n_pairs": 0,
}
# Rangs des valeurs absolues
abs_diffs = [abs(d) for d in diffs]
indexed = sorted(enumerate(abs_diffs), key=lambda x: x[1])
# Gestion des ex-aequo : rang moyen
ranks = [0.0] * n
i = 0
while i < n:
j = i
while j < n and abs_diffs[indexed[j][0]] == abs_diffs[indexed[i][0]]:
j += 1
avg_rank = (i + j + 1) / 2.0 # rang moyen (1-based)
for k in range(i, j):
ranks[indexed[k][0]] = avg_rank
i = j
W_plus = sum(ranks[k] for k in range(n) if diffs[k] > 0)
W_minus = sum(ranks[k] for k in range(n) if diffs[k] < 0)
W = min(W_plus, W_minus)
# Calcul de la p-value : scipy si disponible, sinon approximation native
if _SCIPY_AVAILABLE:
try:
scipy_res = _scipy_wilcoxon(diffs, zero_method=zero_method)
p_value = float(scipy_res.pvalue)
except Exception: # noqa: BLE001 — fallback gracieux
# Repli sur l'implémentation native en cas d'erreur scipy
p_value = _native_p_value(n, W)
else:
p_value = _native_p_value(n, W)
significant = p_value < 0.05
if significant:
better = "premier" if W_plus < W_minus else "second"
interpretation = (
f"Différence statistiquement significative (p = {p_value:.4f} < 0.05). "
f"Le {better} concurrent obtient de meilleurs scores."
)
else:
interpretation = (
f"Différence non significative (p = {p_value:.4f} ≥ 0.05). "
"On ne peut pas conclure que l'un surpasse l'autre."
)
return {
"statistic": round(W, 4),
"p_value": round(p_value, 6),
"significant": significant,
"interpretation": interpretation,
"n_pairs": n,
"W_plus": round(W_plus, 4),
"W_minus": round(W_minus, 4),
}
def _normal_sf(z: float) -> float:
"""Survival function de la loi normale standard (1 - CDF).
Approximation Abramowitz & Stegun 26.2.17. Utilisée par cette
famille pour Wilcoxon ET par friedman_nemenyi pour le fallback
Wilson-Hilferty quand scipy n'est pas disponible.
"""
t = 1.0 / (1.0 + 0.2316419 * abs(z))
poly = t * (0.319381530 + t * (-0.356563782 + t * (1.781477937
+ t * (-1.821255978 + t * 1.330274429))))
phi_z = math.exp(-0.5 * z * z) / math.sqrt(2.0 * math.pi)
p = phi_z * poly
return p if z >= 0 else 1.0 - p
# Table des valeurs critiques de W pour α=0.05 bilatéral (test exact, source : tables de Wilcoxon)
_W_CRITICAL = {1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 2, 8: 3, 9: 5}
def _wilcoxon_exact_p(n: int, w: float) -> float:
"""P-value approximée pour petits n (< 10) via table critique simplifiée.
Note : résultat **conservateur** — seules deux valeurs sont retournées :
0.04 (significatif à 5 %) ou 0.20 (non significatif).
Préférer scipy pour des p-values exactes.
"""
critical = _W_CRITICAL.get(n, 0)
if w <= critical:
return 0.04 # significatif à 5 %
return 0.20 # non significatif (approximation conservative)
def _native_p_value(n: int, W: float) -> float:
"""Calcule la p-value via l'approximation normale (n ≥ 10) ou la table exacte (n < 10)."""
if n >= 10:
mu = n * (n + 1) / 4.0
sigma2 = n * (n + 1) * (2 * n + 1) / 24.0
if sigma2 <= 0:
return 1.0
z = abs((W + 0.5) - mu) / math.sqrt(sigma2) # correction de continuité
return 2.0 * _normal_sf(z) # test bilatéral
return _wilcoxon_exact_p(n, W)
def compute_pairwise_stats(
engine_cer_map: dict[str, list[float]],
) -> list[dict]:
"""Calcule les tests de Wilcoxon entre toutes les paires de concurrents.
Parameters
----------
engine_cer_map : dict {engine_name → [cer_doc1, cer_doc2, ...]}
Returns
-------
Liste de dicts, un par paire :
- engine_a, engine_b, statistic, p_value, significant, interpretation
"""
names = list(engine_cer_map.keys())
results = []
for i in range(len(names)):
for j in range(i + 1, len(names)):
a_name, b_name = names[i], names[j]
a_vals = engine_cer_map[a_name]
b_vals = engine_cer_map[b_name]
# Aligner les longueurs
min_len = min(len(a_vals), len(b_vals))
if min_len < 2:
continue
res = wilcoxon_test(a_vals[:min_len], b_vals[:min_len])
results.append({
"engine_a": a_name,
"engine_b": b_name,
**res,
})
return results
__all__ = [
# Symboles publics : signature stable, consommés directement par les
# tests via le ré-export de ``picarones.evaluation.statistics``.
"compute_pairwise_stats",
"wilcoxon_test",
# Symboles privés ré-exportés (consommés par certains tests) :
# ``_SCIPY_AVAILABLE`` est utilisé pour skip les tests scipy quand
# la dépendance n'est pas installée. ``_normal_sf`` est par ailleurs
# importée par :mod:`friedman_nemenyi` comme utilité math pure.
"_SCIPY_AVAILABLE",
"_normal_sf",
]