Files
cvgl_experiments/compare.py
2026-07-09 21:55:10 +03:00

321 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Статистическое сравнение вариантов по 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()