321 lines
14 KiB
Python
321 lines
14 KiB
Python
"""Статистическое сравнение вариантов по per-query рангам (протокол §8.3, §9.2).
|
||
|
||
Работает с сохранёнными `best_ranks_{q2g,g2q}.npy` (0-indexed ранг первого
|
||
попадания на запрос; M, если попадания нет). Из рангов выводится hit@k =
|
||
(ranks < k) для любого k — этого достаточно для:
|
||
* ΔR@k (parированное улучшение вариант − baseline),
|
||
* bootstrap CI парной дельты (ресэмпл запросов),
|
||
* McNemar exact (парный тест на discordant-запросах: miss↔hit),
|
||
* Wilcoxon signed-rank по разнице рангов (если установлен scipy),
|
||
* применения decision-rule (§9.2): ΔR@1 ≥ порог, p<α, CI не в минусе,
|
||
улучшение на ≥2/3 seeds.
|
||
|
||
Пары запросов валидны, т.к. eval детерминирован (shuffle=False, один
|
||
test_meta) → массивы рангов baseline и варианта совпадают по длине и порядку.
|
||
|
||
Многосидовый режим: передай несколько прогонов на baseline и variant (по
|
||
одному на seed, парно по порядку) — скрипт покажет per-seed дельты + пул.
|
||
|
||
Примеры:
|
||
# один seed: B0 vs H-TA-3 (каталоги прогонов с best_ranks_q2g.npy)
|
||
python compare.py \
|
||
--baseline outputs/text_aug/B0/seed42/exp_..._cross-area \
|
||
--variant outputs/text_aug/H-TA-3/seed42/exp_..._cross-area \
|
||
--label-baseline B0 --label-variant H-TA-3
|
||
|
||
# три seeds (парно по порядку 42/123/456)
|
||
python compare.py \
|
||
--baseline B0_s42_dir B0_s123_dir B0_s456_dir \
|
||
--variant HTA3_s42_dir HTA3_s123_dir HTA3_s456_dir
|
||
|
||
# прямые пути к .npy и другое направление/k
|
||
python compare.py --baseline a/best_ranks_q2g.npy --variant b/best_ranks_q2g.npy --k 5
|
||
|
||
Примечание про intra/inter cos (опровержение H-TA-5): это метрика на
|
||
ЭМБЕДДИНГАХ, а не на рангах, поэтому здесь не считается (эмбеддинги в
|
||
артефактах не сохраняются). Её место — отдельный проход энкодера
|
||
(ср. eval_text_destruction.py), не этот скрипт.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
from math import comb
|
||
from pathlib import Path
|
||
|
||
import numpy as np
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Загрузка рангов
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def load_ranks(path: str, direction: str, which: str = "best") -> np.ndarray:
|
||
"""Загрузить массив рангов из .npy-файла или каталога прогона.
|
||
|
||
Args:
|
||
path: путь к .npy ЛИБО к каталогу прогона (ищем {which}_ranks_{dir}.npy).
|
||
direction: q2g (primary) или g2q.
|
||
which: best (по лучшей эпохе) или last (последний eval).
|
||
"""
|
||
p = Path(path)
|
||
if p.is_dir():
|
||
cand = p / f"{which}_ranks_{direction}.npy"
|
||
if not cand.exists():
|
||
# fallback: если нет best — берём last, и наоборот
|
||
alt = p / f"{'last' if which == 'best' else 'best'}_ranks_{direction}.npy"
|
||
if alt.exists():
|
||
cand = alt
|
||
else:
|
||
raise FileNotFoundError(
|
||
f"Нет ranks-файла в {p} (искали {which}_ranks_{direction}.npy)")
|
||
p = cand
|
||
return np.load(p).astype(np.int64)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Статистика
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def bootstrap_ci_delta(
|
||
base_hits: np.ndarray,
|
||
var_hits: np.ndarray,
|
||
n_boot: int = 10000,
|
||
alpha: float = 0.05,
|
||
seed: int = 42,
|
||
) -> tuple[float, float, float]:
|
||
"""Bootstrap 95% CI парной дельты R@k (ресэмпл запросов с возвратом).
|
||
|
||
Returns:
|
||
(mean_delta, ci_lo, ci_hi) — дельта = mean(var_hits − base_hits).
|
||
"""
|
||
diff = var_hits.astype(np.float64) - base_hits.astype(np.float64)
|
||
n = len(diff)
|
||
rng = np.random.default_rng(seed)
|
||
idx = rng.integers(0, n, size=(n_boot, n))
|
||
boot = diff[idx].mean(axis=1)
|
||
lo, hi = np.percentile(boot, [100 * alpha / 2, 100 * (1 - alpha / 2)])
|
||
return float(diff.mean()), float(lo), float(hi)
|
||
|
||
|
||
def mcnemar_test(base_hits: np.ndarray, var_hits: np.ndarray) -> tuple[int, int, float]:
|
||
"""McNemar на discordant-запросах (двусторонний).
|
||
|
||
b = baseline попал, variant промахнулся; c = наоборот. Под H0 discordant
|
||
делятся 50/50. c > b → variant улучшает.
|
||
|
||
Малое n (≤1000) — точный биномиальный (целочисленное деление, без
|
||
переполнения float); большое n — нормальная аппроксимация χ² с поправкой
|
||
на непрерывность (стандартный McNemar), т.к. точный comb(n,·) переполняет
|
||
и медленный.
|
||
|
||
Returns:
|
||
(b, c, p_value).
|
||
"""
|
||
from math import erfc, sqrt
|
||
|
||
bh = base_hits.astype(bool)
|
||
vh = var_hits.astype(bool)
|
||
b = int(np.sum(bh & ~vh))
|
||
c = int(np.sum(~bh & vh))
|
||
n = b + c
|
||
if n == 0:
|
||
return b, c, 1.0
|
||
if n <= 1000:
|
||
k = min(b, c)
|
||
tail = sum(comb(n, i) for i in range(k + 1))
|
||
# int/int → корректный float без конверсии огромного int (2*, не 2.0*)
|
||
p = min(1.0, (2 * tail) / (2 ** n))
|
||
else:
|
||
chi = (abs(b - c) - 1) ** 2 / n # χ² с поправкой на непрерывность, 1 df
|
||
p = float(erfc(sqrt(chi / 2.0))) # p = 2·(1−Φ(√χ²)) для 1 df
|
||
return b, c, p
|
||
|
||
|
||
def wilcoxon_ranks(base_ranks: np.ndarray, var_ranks: np.ndarray) -> float | None:
|
||
"""Wilcoxon signed-rank по разнице рангов (нужен scipy). None, если нет scipy
|
||
или все разницы нулевые."""
|
||
try:
|
||
from scipy.stats import wilcoxon
|
||
except Exception:
|
||
return None
|
||
# меньший ранг = лучше; d>0 → variant лучше на этом запросе
|
||
d = base_ranks.astype(np.float64) - var_ranks.astype(np.float64)
|
||
if np.all(d == 0):
|
||
return None
|
||
try:
|
||
return float(wilcoxon(d, zero_method="wilcox", alternative="two-sided").pvalue)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Сравнение одной пары / агрегация
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def compare_pair(
|
||
base_ranks: np.ndarray,
|
||
var_ranks: np.ndarray,
|
||
k: int,
|
||
n_boot: int,
|
||
alpha: float,
|
||
seed: int,
|
||
) -> dict:
|
||
"""Полное сравнение baseline vs variant на одном наборе запросов."""
|
||
if base_ranks.shape != var_ranks.shape:
|
||
raise ValueError(
|
||
f"Разная длина рангов: baseline {base_ranks.shape} vs variant "
|
||
f"{var_ranks.shape}. Нужен один и тот же test-split/direction.")
|
||
base_hits = (base_ranks < k)
|
||
var_hits = (var_ranks < k)
|
||
|
||
r_base = float(base_hits.mean())
|
||
r_var = float(var_hits.mean())
|
||
mean_d, ci_lo, ci_hi = bootstrap_ci_delta(base_hits, var_hits, n_boot, alpha, seed)
|
||
b, c, p_mcnemar = mcnemar_test(base_hits, var_hits)
|
||
p_wilcoxon = wilcoxon_ranks(base_ranks, var_ranks)
|
||
|
||
return {
|
||
"n_queries": int(base_ranks.shape[0]),
|
||
f"R@{k}_baseline": r_base,
|
||
f"R@{k}_variant": r_var,
|
||
"delta": mean_d, # доля (умножь на 100 для п.п.)
|
||
"ci_lo": ci_lo,
|
||
"ci_hi": ci_hi,
|
||
"mcnemar_b": b, # base hit, var miss
|
||
"mcnemar_c": c, # base miss, var hit (улучшения)
|
||
"p_mcnemar": p_mcnemar,
|
||
"p_wilcoxon": p_wilcoxon,
|
||
}
|
||
|
||
|
||
def decide(delta: float, ci_lo: float, p: float,
|
||
threshold_pp: float, alpha: float,
|
||
seeds_improved: int, seeds_total: int) -> tuple[bool, str]:
|
||
"""Применить decision-rule §9.2. Возвращает (promote, причина)."""
|
||
reasons = []
|
||
ok_delta = delta * 100 >= threshold_pp
|
||
ok_ci = ci_lo > 0.0
|
||
ok_p = (p is not None) and (p < alpha)
|
||
ok_seeds = seeds_improved >= (2 * seeds_total + 2) // 3 if seeds_total > 1 else True
|
||
|
||
if not ok_delta:
|
||
reasons.append(f"ΔR@1={delta*100:+.2f}пп < порог {threshold_pp}")
|
||
if not ok_ci:
|
||
reasons.append(f"CI_lo={ci_lo*100:+.2f}пп ≤ 0 (пересекает минус)")
|
||
if not ok_p:
|
||
reasons.append(f"p={p:.4f} ≥ α={alpha}" if p is not None else "нет p")
|
||
if seeds_total > 1 and not ok_seeds:
|
||
reasons.append(f"улучшение лишь на {seeds_improved}/{seeds_total} seeds")
|
||
|
||
promote = ok_delta and ok_ci and ok_p and ok_seeds
|
||
return promote, ("все критерии выполнены" if promote else "; ".join(reasons))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Main
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def main():
|
||
args = parse_args()
|
||
if len(args.baseline) != len(args.variant):
|
||
raise SystemExit("Число --baseline и --variant должно совпадать (пары по seed)")
|
||
|
||
print("=" * 72)
|
||
print(f" СРАВНЕНИЕ: {args.label_variant} vs {args.label_baseline} "
|
||
f"| direction={args.direction} | k={args.k}")
|
||
print("=" * 72)
|
||
|
||
per_seed = []
|
||
pooled_base, pooled_var = [], []
|
||
for i, (b_path, v_path) in enumerate(zip(args.baseline, args.variant)):
|
||
base_ranks = load_ranks(b_path, args.direction, args.which)
|
||
var_ranks = load_ranks(v_path, args.direction, args.which)
|
||
res = compare_pair(base_ranks, var_ranks, args.k, args.n_boot, args.alpha, args.seed)
|
||
res["seed_idx"] = i
|
||
per_seed.append(res)
|
||
pooled_base.append((base_ranks < args.k))
|
||
pooled_var.append((var_ranks < args.k))
|
||
|
||
# ---- per-seed таблица ----
|
||
print(f"\n{'seed#':>5} {'N':>7} {'R@'+str(args.k)+' base':>10} "
|
||
f"{'R@'+str(args.k)+' var':>10} {'ΔR@'+str(args.k):>10} "
|
||
f"{'95% CI (пп)':>18} {'p(McN)':>9} {'p(Wilc)':>9}")
|
||
print("-" * 88)
|
||
for r in per_seed:
|
||
pw = f"{r['p_wilcoxon']:.4f}" if r["p_wilcoxon"] is not None else "—"
|
||
print(f"{r['seed_idx']:>5} {r['n_queries']:>7} "
|
||
f"{r[f'R@{args.k}_baseline']*100:>9.2f}% {r[f'R@{args.k}_variant']*100:>9.2f}% "
|
||
f"{r['delta']*100:>+9.2f} "
|
||
f"[{r['ci_lo']*100:>+6.2f},{r['ci_hi']*100:>+6.2f}] "
|
||
f"{r['p_mcnemar']:>9.4f} {pw:>9}")
|
||
print("-" * 88)
|
||
|
||
# ---- пул по всем seeds (общий CI/тест) ----
|
||
pooled_base = np.concatenate(pooled_base)
|
||
pooled_var = np.concatenate(pooled_var)
|
||
mean_d, ci_lo, ci_hi = bootstrap_ci_delta(
|
||
pooled_base, pooled_var, args.n_boot, args.alpha, args.seed)
|
||
b, c, p_mcn = mcnemar_test(pooled_base, pooled_var)
|
||
seeds_improved = sum(1 for r in per_seed if r["delta"] > 0)
|
||
n_seeds = len(per_seed)
|
||
|
||
print(f"\nПул ({n_seeds} seed(s), N={len(pooled_base)}): "
|
||
f"ΔR@{args.k}={mean_d*100:+.2f} пп | "
|
||
f"95% CI [{ci_lo*100:+.2f}, {ci_hi*100:+.2f}] пп | "
|
||
f"McNemar p={p_mcn:.4f} (b={b}, c={c}) | "
|
||
f"улучшение на {seeds_improved}/{n_seeds} seeds")
|
||
|
||
promote, reason = decide(mean_d, ci_lo, p_mcn, args.threshold, args.alpha,
|
||
seeds_improved, n_seeds)
|
||
verdict = "✅ PROMOTE" if promote else "❌ NOT PROMOTE"
|
||
print("\n" + "=" * 72)
|
||
print(f" DECISION (§9.2): {verdict}")
|
||
print(f" Причина: {reason}")
|
||
print(f" Правило: ΔR@1 ≥ {args.threshold} пп, p<{args.alpha}, "
|
||
f"CI_lo>0, ≥2/3 seeds, без modality-collapse (gate — проверь отдельно)")
|
||
print("=" * 72)
|
||
|
||
if args.output:
|
||
out = {
|
||
"baseline": args.label_baseline,
|
||
"variant": args.label_variant,
|
||
"direction": args.direction,
|
||
"k": args.k,
|
||
"per_seed": per_seed,
|
||
"pooled": {
|
||
"delta": mean_d, "ci_lo": ci_lo, "ci_hi": ci_hi,
|
||
"p_mcnemar": p_mcn, "mcnemar_b": b, "mcnemar_c": c,
|
||
"seeds_improved": seeds_improved, "n_seeds": n_seeds,
|
||
},
|
||
"promote": promote, "reason": reason,
|
||
}
|
||
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
|
||
Path(args.output).write_text(json.dumps(out, indent=2, ensure_ascii=False))
|
||
print(f"\n💾 Сохранено: {args.output}")
|
||
|
||
|
||
def parse_args():
|
||
p = argparse.ArgumentParser(description="Парное статсравнение вариантов по рангам (§9.2)")
|
||
p.add_argument("--baseline", nargs="+", required=True,
|
||
help="Каталог(и) прогона B0 или .npy (по одному на seed)")
|
||
p.add_argument("--variant", nargs="+", required=True,
|
||
help="Каталог(и) прогона варианта или .npy (парно к baseline)")
|
||
p.add_argument("--direction", choices=["q2g", "g2q"], default="q2g",
|
||
help="q2g (primary, drone→sat) или g2q")
|
||
p.add_argument("--which", choices=["best", "last"], default="best")
|
||
p.add_argument("--k", type=int, default=1, help="k для R@k (по умолчанию 1)")
|
||
p.add_argument("--n_boot", type=int, default=10000)
|
||
p.add_argument("--alpha", type=float, default=0.05)
|
||
p.add_argument("--threshold", type=float, default=1.0,
|
||
help="Порог ΔR@1 в п.п. для promote (§9.2, дефолт 1.0)")
|
||
p.add_argument("--seed", type=int, default=42, help="Seed бутстрапа")
|
||
p.add_argument("--label-baseline", default="baseline")
|
||
p.add_argument("--label-variant", default="variant")
|
||
p.add_argument("--output", default=None, help="Куда сохранить JSON-итог")
|
||
return p.parse_args()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|