File size: 8,390 Bytes
b0b1bdf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e407ec0
b0b1bdf
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
"""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",
]