diff --git a/collect_results.py b/collect_results.py index 9c0c459..15dbd24 100644 --- a/collect_results.py +++ b/collect_results.py @@ -47,15 +47,25 @@ def load_experiment(exp_dir: Path) -> dict | None: best = max(history, key=lambda r: r.get("eval_recall@1", 0)) latest = history[-1] + # Протокол сплита определяем из train_meta (§5.2): нужен для cross−same gap. + train_meta = config.get("train_meta", "") + if "same-area" in train_meta: + protocol = "same-area" + elif "cross-area" in train_meta: + protocol = "cross-area" + else: + protocol = "?" + return { "dir": exp_dir.name, + "protocol": protocol, "seed": config.get("seed", "?"), "text_levels": " + ".join(config.get("text_levels", ["?"])), "epochs_done": latest["epoch"], "epochs_total": config.get("epochs", "?"), "best_epoch": best["epoch"], - # q2g (primary); split — только official cross-area (§5.3), - # same-area прогонов нет, поэтому cross−same gap не считается. + # q2g (primary). gap = R@1(same-area) − R@1(cross-area) считается в + # compute_gaps(), если для варианта есть прогоны ОБОИХ протоколов. "best_R@1": best.get("eval_recall@1", 0), "best_R@5": best.get("eval_recall@5", 0), "best_R@10": best.get("eval_recall@10", 0), @@ -79,7 +89,9 @@ def load_experiment(exp_dir: Path) -> dict | None: def collect_version(version: str) -> list[dict]: """Собрать результаты одной версии (v1 или v2). - Структура: outputs////history.json. + Ищет history.json рекурсивно под outputs//, поэтому находит + прогоны обоих протоколов (same/cross), даже если они лежат на разной + глубине (напр. seed/exp или seed/protocol/exp). """ version_dir = OUTPUTS_DIR / version if not version_dir.exists(): @@ -87,12 +99,11 @@ def collect_version(version: str) -> list[dict]: return [] results = [] - for exp_dir in sorted(version_dir.glob("*/*")): - if exp_dir.is_dir() and (exp_dir / "history.json").exists(): - data = load_experiment(exp_dir) - if data: - data["version"] = version - results.append(data) + for hist_path in sorted(version_dir.rglob("history.json")): + data = load_experiment(hist_path.parent) + if data: + data["version"] = version + results.append(data) return results @@ -108,7 +119,7 @@ def print_table(results: list[dict], version: str) -> None: results.sort(key=lambda r: -r["best_R@1"]) header = ( - f"{'Levels':<24} {'Seed':>5} {'Prog':<8} {'BestEp':>6} " + f"{'Levels':<24} {'Proto':>10} {'Seed':>5} {'Prog':<8} {'BestEp':>6} " f"{'R@1':>7} {'R@5':>7} {'R@10':>7} {'R@1%':>7} {'mAP':>7} " f"{'medR':>6} {'g2q@1':>7} {'Loss':>8} {'Time':>6}" ) @@ -123,7 +134,8 @@ def print_table(results: list[dict], version: str) -> None: for r in results: prog = f"{r['epochs_done']}/{r['epochs_total']}" print( - f"{r['text_levels']:<24} {str(r['seed']):>5} {prog:<8} {r['best_epoch']:>6} " + f"{r['text_levels']:<24} {r.get('protocol','?'):>10} {str(r['seed']):>5} " + f"{prog:<8} {r['best_epoch']:>6} " f"{r['best_R@1']:>7.4f} {r['best_R@5']:>7.4f} {r['best_R@10']:>7.4f} " f"{r['best_R@1%']:>7.4f} {r['best_mAP']:>7.4f} {r['best_median_rank']:>6.0f} " f"{r['best_g2q_R@1']:>7.4f} " @@ -135,13 +147,92 @@ def print_table(results: list[dict], version: str) -> None: print() +# --------------------------------------------------------------------------- +# cross−same gap (§3.5, §5.3): диагностика text-shortcut +# --------------------------------------------------------------------------- + +def compute_gaps(results: list[dict]) -> list[dict]: + """Спарить same-area и cross-area прогоны и посчитать gap по R@1. + + gap = R@1(same-area) − R@1(cross-area). Большой gap = модель опирается на + area-specific shortcut (в т.ч. текстовый шаблон), не переносящийся на + невиданную область. Пары ищутся по (version, seed, text_levels): это ОДИН + вариант, обученный под двумя протоколами. same-area R@1 честен только при + отдельном обучении на same-area-train (§5.3), поэтому его нельзя заменить + доп. eval'ом cross-модели. + """ + from collections import defaultdict + + groups: dict[tuple, dict[str, dict]] = defaultdict(dict) + for r in results: + key = (r["version"], r["seed"], r["text_levels"]) + groups[key][r["protocol"]] = r + + gaps = [] + for (version, seed, levels), by_proto in groups.items(): + same = by_proto.get("same-area") + cross = by_proto.get("cross-area") + if not (same and cross): + continue + gaps.append({ + "version": version, + "seed": seed, + "text_levels": levels, + "same_R@1": same["best_R@1"], + "cross_R@1": cross["best_R@1"], + "gap_R@1": same["best_R@1"] - cross["best_R@1"], + "same_mAP": same["best_mAP"], + "cross_mAP": cross["best_mAP"], + }) + gaps.sort(key=lambda g: -g["gap_R@1"]) + return gaps + + +def print_gap_table(gaps: list[dict], version: str) -> None: + if not gaps: + print(f" {version}: gap не посчитан — нужны прогоны ОБОИХ протоколов " + f"(same-area И cross-area) для одного варианта (§5.3).") + print() + return + + header = (f"{'Levels':<24} {'Seed':>5} {'R@1 same':>9} {'R@1 cross':>10} " + f"{'gap (same−cross)':>17}") + sep = "─" * len(header) + print(sep) + print(f" {version.upper()} — cross−same gap (диагностика text-shortcut; " + f"меньше gap = лучше генерализация)") + print(sep) + print(header) + print(sep) + for g in gaps: + print(f"{g['text_levels']:<24} {str(g['seed']):>5} " + f"{g['same_R@1']:>9.4f} {g['cross_R@1']:>10.4f} " + f"{g['gap_R@1']*100:>15.2f} п.п.") + print(sep) + print() + + +def save_gap_csv(gaps: list[dict], version: str) -> None: + if not gaps: + return + fields = ["version", "seed", "text_levels", "same_R@1", "cross_R@1", + "gap_R@1", "same_mAP", "cross_mAP"] + path = RESULTS_DIR / f"gap_{version}.csv" + with open(path, "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore") + writer.writeheader() + for g in gaps: + writer.writerow(g) + print(f" CSV: {path}") + + # --------------------------------------------------------------------------- # CSV # --------------------------------------------------------------------------- def save_csv(results: list[dict], version: str) -> None: fields = [ - "version", "seed", "text_levels", "epochs_done", "epochs_total", + "version", "protocol", "seed", "text_levels", "epochs_done", "epochs_total", "best_epoch", "best_R@1", "best_R@5", "best_R@10", "best_R@1%", "best_mAP", "best_median_rank", "best_mean_rank", @@ -271,6 +362,52 @@ def save_plots(results: list[dict], version: str) -> None: plt.close() print(f" PNG: {path}") + # --- 6. gate σ(α)-траектории (§9.2 modality-collapse) --- + # Есть только у новых прогонов (train_drone_gate/train_satellite_gate). + has_gate = any( + any("train_drone_gate" in e for e in r["_history"]) for r in results + ) + if has_gate: + fig, ax = plt.subplots(figsize=(8, 5)) + for r, c in zip(results, colors): + h = r["_history"] + eps = [e["epoch"] for e in h if "train_drone_gate" in e] + gd = [e["train_drone_gate"] for e in h if "train_drone_gate" in e] + gs = [e.get("train_satellite_gate") for e in h if "train_drone_gate" in e] + if not eps: + continue + ax.plot(eps, gd, color=c, linewidth=1.5, label=f"{r['text_levels']} (drone)") + ax.plot(eps, gs, color=c, linewidth=1.0, linestyle="--", alpha=0.7) + ax.axhline(0.5, color="gray", linewidth=0.8, alpha=0.5) + ax.set_ylim(0, 1) + ax.set_xlabel("Epoch") + ax.set_ylabel("gate σ(α) — вес картинки") + ax.set_title(f"{version.upper()} — gate σ(α) (0/1 = modality collapse)") + ax.legend(fontsize=7) + ax.grid(True, alpha=0.3) + plt.tight_layout() + path = RESULTS_DIR / f"gate_{version}.png" + plt.savefig(path, dpi=150) + plt.close() + print(f" PNG: {path}") + + # --- 7. Pareto: R@1 vs стоимость (avg epoch time) --- + fig, ax = plt.subplots(figsize=(8, 5)) + for r, c in zip(results, colors): + ax.scatter(r["avg_epoch_time"], r["best_R@1"], color=c, s=60, + label=f"{r['text_levels']} ({r.get('protocol','?')})") + ax.annotate(r["text_levels"], (r["avg_epoch_time"], r["best_R@1"]), + fontsize=7, xytext=(4, 4), textcoords="offset points") + ax.set_xlabel("Средн. время эпохи, с (прокси стоимости)") + ax.set_ylabel("Best R@1") + ax.set_title(f"{version.upper()} — Pareto: R@1 vs cost") + ax.grid(True, alpha=0.3) + plt.tight_layout() + path = RESULTS_DIR / f"pareto_{version}.png" + plt.savefig(path, dpi=150) + plt.close() + print(f" PNG: {path}") + # --------------------------------------------------------------------------- # Main @@ -295,9 +432,14 @@ def main(): # Таблица в консоль print_table(results, version) + # cross−same gap (диагностика text-shortcut) — если есть пары протоколов + gaps = compute_gaps(results) + print_gap_table(gaps, version) + if results: # CSV и графики в results/ save_csv(results, version) + save_gap_csv(gaps, version) save_plots(results, version) print(f"\n📁 Все результаты: {RESULTS_DIR}") diff --git a/compare.py b/compare.py new file mode 100644 index 0000000..90c4ede --- /dev/null +++ b/compare.py @@ -0,0 +1,320 @@ +"""Статистическое сравнение вариантов по 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() diff --git a/eval_text_destruction.py b/eval_text_destruction.py new file mode 100644 index 0000000..793ca5a --- /dev/null +++ b/eval_text_destruction.py @@ -0,0 +1,288 @@ +"""Тест разрушения текста — диагностика shortcut / устойчивости к missing-modality. + +Берёт ОБУЧЕННЫЙ чекпоинт и прогоняет тот же multi-positive retrieval-eval +(протокол §6.2), но портит текстовый вход, измеряя падение R@1. Отвечает на +вопрос «модель реально использует текст или это shortcut/бутафория» (§3.5, §8.2). + +Режимы порчи (текст меняется СИММЕТРИЧНО на обеих сторонах — query и gallery, +иначе слитые векторы несопоставимы): + full — без изменений (референс); + zero — текст убран (пустые токены) → чистый image-retrieval. + Вклад текста = R@1(full) − R@1(zero). Для H-TA-2: малая разница + при обучении с text-dropout = модель устойчива к отсутствию текста. + shuffle — каждому объекту подставлен ЧУЖОЙ валидный caption (глобальная + перестановка). Если R@1 не падает — текст не несёт геометрию + (модель цепляется за лексический шаблон = косвенный shortcut). + +Интерпретация (§8.2): малое падение при высоком train R@1 → shortcut / текст не +используется; большое падение → текст реально нужен. + +Скрипт НЕ трогает даталоадер/обучение — только читает чекпоинт и гоняет eval. + +Запуск (проще всего — по каталогу прогона с config.json + best_model.pth): + python eval_text_destruction.py --run_dir outputs/text_aug/B0/seed42/exp_level1-level2-level3_ep10_bs64 + +Либо явно: + python eval_text_destruction.py \ + --checkpoint outputs/.../best_model.pth \ + --data_root /media/.../GTA-UAV-LR \ + --descriptions_path /media/.../GTA-UAV-LR-captions_v2 \ + --text_levels level1 level2 level3 +""" +from __future__ import annotations + +import argparse +import json +import logging +import sys +from contextlib import nullcontext +from pathlib import Path + +import torch + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from src.models.dual_encoder import build_dual_encoder +from src.models.dgtrs.model import tokenize_dgtrs +from src.metrics import compute_bidirectional_metrics +from src.data.gta_uav_eval import build_multipos_eval + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s") +LOGGER = logging.getLogger("cvgl.text_destruction") + +MODES = ("full", "zero", "shuffle") + + +# --------------------------------------------------------------------------- +# Кодирование loader'а с подменой токенов (pool задаёт токены в порядке loader) +# --------------------------------------------------------------------------- + +@torch.no_grad() +def collect_tokens(loader) -> torch.Tensor: + """Собрать все токены loader'а в один тензор [N, L] (порядок loader'а).""" + return torch.cat([batch["tokens"] for batch in loader], dim=0) + + +@torch.no_grad() +def encode(model, loader, encode_fn, device, amp_ctx, + pool: torch.Tensor | None = None) -> torch.Tensor: + """Прогнать loader через encode_fn; если pool задан — берём токены из него. + + pool[N, L] — токены в порядке loader'а (для zero/shuffle). None — исходные + токены из батча (режим full). + """ + embs = [] + off = 0 + for batch in loader: + images = batch["image"].to(device) + bs = images.shape[0] + tokens = batch["tokens"] if pool is None else pool[off:off + bs] + tokens = tokens.to(device) + with amp_ctx: + embs.append(encode_fn(images, tokens).float().cpu()) + off += bs + return torch.cat(embs, dim=0) + + +def make_pool(mode: str, orig: torch.Tensor, empty_row: torch.Tensor, + generator: torch.Generator) -> torch.Tensor | None: + """Построить pool токенов для режима порчи. + + Args: + mode: full / zero / shuffle. + orig: исходные токены [N, L] (в порядке loader'а). + empty_row: токены пустого текста [L] (SOT+EOT+паддинг). + generator: для воспроизводимой перестановки (shuffle). + """ + n = orig.shape[0] + if mode == "full": + return None + if mode == "zero": + return empty_row.unsqueeze(0).expand(n, -1).clone() + if mode == "shuffle": + perm = torch.randperm(n, generator=generator) + return orig[perm].clone() + raise ValueError(f"Unknown mode: {mode}") + + +# --------------------------------------------------------------------------- +# Загрузка обученной модели из чекпоинта + config +# --------------------------------------------------------------------------- + +def resolve_paths(args) -> tuple[Path, dict]: + """Найти чекпоинт и config: либо из --run_dir, либо из явных аргументов.""" + if args.run_dir: + run_dir = Path(args.run_dir) + ckpt_path = Path(args.checkpoint) if args.checkpoint else run_dir / "best_model.pth" + cfg_path = run_dir / "config.json" + config = json.loads(cfg_path.read_text()) if cfg_path.exists() else {} + else: + if not args.checkpoint: + raise SystemExit("Нужен либо --run_dir, либо --checkpoint") + ckpt_path = Path(args.checkpoint) + config = {} + # аргументы командной строки перекрывают config + for key in ("data_root", "descriptions_path", "test_meta", "image_size", + "fused_dim", "shared_dim", "mona_bottleneck", + "dgtrs_checkpoint", "stripnet_checkpoint"): + val = getattr(args, key, None) + if val is not None: + config[key] = val + if args.text_levels is not None: + config["text_levels"] = args.text_levels + return ckpt_path, config + + +def load_model(ckpt_path: Path, config: dict, device: torch.device): + """Собрать DualEncoder по config и загрузить веса из чекпоинта.""" + model = build_dual_encoder( + dgtrs_checkpoint=config["dgtrs_checkpoint"], + stripnet_checkpoint=config["stripnet_checkpoint"], + fused_dim=config.get("fused_dim", 512), + shared_dim=config.get("shared_dim", 512), + freeze_text=True, + freeze_image_backbone=True, + inject_mona=config.get("inject_mona", True), + mona_bottleneck=config.get("mona_bottleneck", 64), + device=str(device), + ) + ckpt = torch.load(ckpt_path, map_location=device) + state = ckpt.get("model_state_dict", ckpt) + missing, unexpected = model.load_state_dict(state, strict=False) + if missing: + LOGGER.warning("Отсутствуют ключи при загрузке: %d (напр. %s)", len(missing), missing[:3]) + if unexpected: + LOGGER.warning("Лишние ключи при загрузке: %d (напр. %s)", len(unexpected), unexpected[:3]) + model.eval() + LOGGER.info("✅ Модель загружена: %s (epoch=%s)", ckpt_path, ckpt.get("epoch", "?")) + return model + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main(): + args = parse_args() + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + ckpt_path, config = resolve_paths(args) + + context_length = config.get("context_length", 248) + text_levels = config.get("text_levels", ["level1"]) + LOGGER.info("🔎 text_levels=%s | test_meta=%s", text_levels, + config.get("test_meta", "cross-area-drone2sate-test.json")) + + model = load_model(ckpt_path, config, device) + + amp_ctx = (torch.autocast(device_type="cuda", dtype=torch.bfloat16) + if device.type == "cuda" else nullcontext()) + + eval_data = build_multipos_eval( + data_root=config["data_root"], + test_meta=config.get("test_meta", "cross-area-drone2sate-test.json"), + descriptions_path=config["descriptions_path"], + text_levels=text_levels, + image_size=config.get("image_size", 256), + batch_size=args.batch_size, + num_workers=args.num_workers, + context_length=context_length, + ) + + # Пустой текст (токены SOT+EOT+паддинг) — общий для zero-режима. + empty_row = tokenize_dgtrs("", context_length=context_length, truncate=True).squeeze(0) + + # Исходные токены обеих сторон (в порядке loader'ов) — нужны для shuffle. + drone_orig = collect_tokens(eval_data["drone_loader"]) + gallery_orig = collect_tokens(eval_data["gallery_loader"]) + LOGGER.info("📊 queries=%d, gallery=%d", drone_orig.shape[0], gallery_orig.shape[0]) + + gen = torch.Generator().manual_seed(args.seed) + + results: dict[str, dict] = {} + for mode in MODES: + drone_pool = make_pool(mode, drone_orig, empty_row, gen) + gallery_pool = make_pool(mode, gallery_orig, empty_row, gen) + + q = encode(model, eval_data["drone_loader"], model.encode_drone, + device, amp_ctx, pool=drone_pool) + g = encode(model, eval_data["gallery_loader"], model.encode_satellite, + device, amp_ctx, pool=gallery_pool) + + m = compute_bidirectional_metrics( + q, g, ks=(1, 5, 10), + positives_q2g=eval_data["positives_q2g"], + positives_g2q=eval_data["positives_g2q"], + ) + results[mode] = { + "q2g_recall@1": m["q2g_recall@1"], + "q2g_recall@5": m["q2g_recall@5"], + "q2g_recall@10": m["q2g_recall@10"], + "q2g_mAP": m["q2g_mAP"], + "g2q_recall@1": m["g2q_recall@1"], + } + LOGGER.info("[%s] q2g R@1=%.4f R@5=%.4f R@10=%.4f | mAP=%.4f", + mode, m["q2g_recall@1"], m["q2g_recall@5"], + m["q2g_recall@10"], m["q2g_mAP"]) + + _print_report(results) + + out_path = Path(args.output) if args.output else ( + (Path(args.run_dir) / "text_destruction.json") if args.run_dir + else Path("text_destruction.json")) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(results, indent=2, ensure_ascii=False)) + LOGGER.info("💾 Сохранено: %s", out_path) + + +def _print_report(results: dict[str, dict]) -> None: + """Таблица режимов + падение R@1 относительно full.""" + full_r1 = results["full"]["q2g_recall@1"] + print("\n" + "=" * 60) + print(" ТЕСТ РАЗРУШЕНИЯ ТЕКСТА (q2g, cross-area)") + print("=" * 60) + print(f"{'Режим':<10} {'R@1':>8} {'R@5':>8} {'R@10':>8} {'ΔR@1 vs full':>14}") + print("-" * 60) + for mode in MODES: + r = results[mode] + drop = r["q2g_recall@1"] - full_r1 + drop_str = "—" if mode == "full" else f"{drop*100:+.2f} п.п." + print(f"{mode:<10} {r['q2g_recall@1']*100:>7.2f}% {r['q2g_recall@5']*100:>7.2f}% " + f"{r['q2g_recall@10']*100:>7.2f}% {drop_str:>14}") + print("-" * 60) + zero_drop = (results["zero"]["q2g_recall@1"] - full_r1) * 100 + shuf_drop = (results["shuffle"]["q2g_recall@1"] - full_r1) * 100 + print(f"Вклад текста (full−zero): {-zero_drop:+.2f} п.п.") + print(f"Падение при подмене (shuffle): {shuf_drop:+.2f} п.п.") + print("Интерпретация: малое падение при высоком train R@1 → shortcut/текст " + "не используется;\n большое падение → текст реально нужен (§8.2).") + print("=" * 60) + + +def parse_args(): + p = argparse.ArgumentParser(description="Тест разрушения текста (H-TA-2 / shortcut-диагностика)") + p.add_argument("--run_dir", type=str, default=None, + help="Каталог прогона с config.json и best_model.pth") + p.add_argument("--checkpoint", type=str, default=None, + help="Путь к .pth (по умолчанию run_dir/best_model.pth)") + # переопределения config (если run_dir не задан или нужно поменять) + p.add_argument("--data_root", type=str, default=None) + p.add_argument("--descriptions_path", type=str, default=None) + p.add_argument("--test_meta", type=str, default=None) + p.add_argument("--text_levels", nargs="+", default=None) + p.add_argument("--image_size", type=int, default=None) + p.add_argument("--fused_dim", type=int, default=None) + p.add_argument("--shared_dim", type=int, default=None) + p.add_argument("--mona_bottleneck", type=int, default=None) + p.add_argument("--dgtrs_checkpoint", type=str, default=None) + p.add_argument("--stripnet_checkpoint", type=str, default=None) + # прогон + p.add_argument("--batch_size", type=int, default=64) + p.add_argument("--num_workers", type=int, default=8) + p.add_argument("--seed", type=int, default=42) + p.add_argument("--output", type=str, default=None, + help="Куда сохранить JSON (по умолчанию run_dir/text_destruction.json)") + return p.parse_args() + + +if __name__ == "__main__": + main() diff --git a/scripts/run_experiments.sh b/scripts/run_experiments.sh index c04e353..7911e31 100644 --- a/scripts/run_experiments.sh +++ b/scripts/run_experiments.sh @@ -17,16 +17,24 @@ # bash scripts/run_experiments.sh v2 # только v2 # bash scripts/run_experiments.sh v1 3 # v1, эксперимент 3 # bash scripts/run_experiments.sh v2 1 4 # v2, эксперименты 1 и 4 +# +# Сплит выбирается переменной окружения SPLIT (cross | same | both): +# SPLIT=same bash scripts/run_experiments.sh v2 5 # только same-area +# SPLIT=both bash scripts/run_experiments.sh v2 5 # cross И same (для gap) +# (по умолчанию SPLIT=cross — primary-метрика, §5.3) # ============================================================ set -e # Остановиться при ошибке # ====================== НАСТРОЙКИ =========================== -DATA_ROOT="/media/servml/SSD_2_2TB/datasets/cvgl_datasets/GTA-UAV-LR" -DESCRIPTIONS_v1="/media/servml/SSD_2_2TB/datasets/cvgl_datasets/GTA-UAV-LR-captions_ v1" -DESCRIPTIONS_v2="/media/servml/SSD_2_2TB/datasets/cvgl_datasets/GTA-UAV-LR-captions_v2" -DGTRS_CKPT="/media/servml/SSD_2_2TB/weights/LRSCLIP/DGTRS-CLIP-ViT-B-16.pt" -STRIPNET_CKPT="/media/servml/SSD_2_2TB/weights/stripnet_small.pth" +# База на внешнем SSD (нестабильная точка монтирования SSD_2_2TB ↔ SSD_2_2TB1). +# При ремаунте переопредели: DATA_BASE=/media/servml/SSD_2_2TB bash scripts/run_experiments.sh +DATA_BASE="${DATA_BASE:-/media/servml/SSD_2_2TB1}" +DATA_ROOT="${DATA_BASE}/datasets/cvgl_datasets/GTA-UAV-LR" +DESCRIPTIONS_v1="${DATA_BASE}/datasets/cvgl_datasets/GTA-UAV-LR-captions_ v1" +DESCRIPTIONS_v2="${DATA_BASE}/datasets/cvgl_datasets/GTA-UAV-LR-captions_v2" +DGTRS_CKPT="${DATA_BASE}/weights/LRSCLIP/DGTRS-CLIP-ViT-B-16.pt" +STRIPNET_CKPT="${DATA_BASE}/weights/stripnet_small.pth" # Общие гиперпараметры EPOCHS=50 @@ -39,6 +47,27 @@ OUTPUT="outputs" # 123 и 456). Переопределяется из окружения, дефолт остаётся 42: # SEED=123 bash scripts/run_experiments.sh v2 2 SEED="${SEED:-42}" + +# Протокол сплита (§5.2): cross (primary) | same (для cross−same gap) | both. +# Переопределяется из окружения: +# SPLIT=same bash scripts/run_experiments.sh v2 5 +# SPLIT=both bash scripts/run_experiments.sh v2 5 +SPLIT="${SPLIT:-cross}" +case "$SPLIT" in + cross) SPLITS=(cross) ;; + same) SPLITS=(same) ;; + both) SPLITS=(cross same) ;; + *) echo "❌ SPLIT должен быть cross|same|both (задано: $SPLIT)"; exit 1 ;; +esac + +# Сплит → (train_meta test_meta). same-area честен только при обучении на +# same-area-train (§5.3), поэтому это отдельный прогон, а не доп. eval. +meta_for_split() { + case "$1" in + cross) echo "cross-area-drone2sate-train.json cross-area-drone2sate-test.json" ;; + same) echo "same-area-drone2sate-train.json same-area-drone2sate-test.json" ;; + esac +} # ============================================================ # Массив экспериментов: номер → уровни текста @@ -74,13 +103,14 @@ else EXP_NUMS=("$@") fi -TOTAL=$(( ${#VERSIONS[@]} * ${#EXP_NUMS[@]} )) +TOTAL=$(( ${#VERSIONS[@]} * ${#EXP_NUMS[@]} * ${#SPLITS[@]} )) echo "========================================" echo " CVGL Experiments on GTA-UAV" echo " Epochs: $EPOCHS | Batch: $BATCH_SIZE | Seed: $SEED" echo " Versions: ${VERSIONS[*]}" echo " Experiments: ${EXP_NUMS[*]}" +echo " Splits: ${SPLITS[*]}" echo " Total runs: $TOTAL" echo "========================================" @@ -113,29 +143,36 @@ for VERSION in "${VERSIONS[@]}"; do for EXP_NUM in "${EXP_NUMS[@]}"; do LEVELS="${EXPERIMENTS[$EXP_NUM]}" - RUN_IDX=$((RUN_IDX + 1)) - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " [$RUN_IDX/$TOTAL] ${VERSION} / Experiment $EXP_NUM: levels=[$LEVELS]" - echo " Descriptions: $DESCRIPTIONS" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + for SPLIT_NAME in "${SPLITS[@]}"; do + read -r TRAIN_META TEST_META <<< "$(meta_for_split "$SPLIT_NAME")" + RUN_IDX=$((RUN_IDX + 1)) - python train.py \ - --data_root "$DATA_ROOT" \ - --descriptions_path "$DESCRIPTIONS" \ - --text_levels $LEVELS \ - --dgtrs_checkpoint "$DGTRS_CKPT" \ - --stripnet_checkpoint "$STRIPNET_CKPT" \ - --epochs $EPOCHS \ - --batch_size $BATCH_SIZE \ - --micro_batch_size $MICRO_BATCH \ - --lr $LR \ - --seed $SEED \ - --output_dir "${OUTPUT}/${VERSION}/seed${SEED}" \ - --inject_mona + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " [$RUN_IDX/$TOTAL] ${VERSION} / Exp $EXP_NUM / ${SPLIT_NAME}-area: levels=[$LEVELS]" + echo " Descriptions: $DESCRIPTIONS" + echo " Split: train=$TRAIN_META test=$TEST_META" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "✅ [$RUN_IDX/$TOTAL] ${VERSION} / Experiment $EXP_NUM complete" + python train.py \ + --data_root "$DATA_ROOT" \ + --descriptions_path "$DESCRIPTIONS" \ + --text_levels $LEVELS \ + --train_meta "$TRAIN_META" \ + --test_meta "$TEST_META" \ + --dgtrs_checkpoint "$DGTRS_CKPT" \ + --stripnet_checkpoint "$STRIPNET_CKPT" \ + --epochs $EPOCHS \ + --batch_size $BATCH_SIZE \ + --micro_batch_size $MICRO_BATCH \ + --lr $LR \ + --seed $SEED \ + --output_dir "${OUTPUT}/${VERSION}/seed${SEED}" \ + --inject_mona + + echo "✅ [$RUN_IDX/$TOTAL] ${VERSION} / Exp $EXP_NUM / ${SPLIT_NAME}-area complete" + done done done