Spaces:
Sleeping
Sleeping
Claude
docs(sprint-H.8): cleanup obsolete legacy/shim language in production docstrings
e407ec0 unverified | """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", | |
| ] | |