augmentation add
This commit is contained in:
@@ -47,15 +47,25 @@ def load_experiment(exp_dir: Path) -> dict | None:
|
|||||||
best = max(history, key=lambda r: r.get("eval_recall@1", 0))
|
best = max(history, key=lambda r: r.get("eval_recall@1", 0))
|
||||||
latest = history[-1]
|
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 {
|
return {
|
||||||
"dir": exp_dir.name,
|
"dir": exp_dir.name,
|
||||||
|
"protocol": protocol,
|
||||||
"seed": config.get("seed", "?"),
|
"seed": config.get("seed", "?"),
|
||||||
"text_levels": " + ".join(config.get("text_levels", ["?"])),
|
"text_levels": " + ".join(config.get("text_levels", ["?"])),
|
||||||
"epochs_done": latest["epoch"],
|
"epochs_done": latest["epoch"],
|
||||||
"epochs_total": config.get("epochs", "?"),
|
"epochs_total": config.get("epochs", "?"),
|
||||||
"best_epoch": best["epoch"],
|
"best_epoch": best["epoch"],
|
||||||
# q2g (primary); split — только official cross-area (§5.3),
|
# q2g (primary). gap = R@1(same-area) − R@1(cross-area) считается в
|
||||||
# same-area прогонов нет, поэтому cross−same gap не считается.
|
# compute_gaps(), если для варианта есть прогоны ОБОИХ протоколов.
|
||||||
"best_R@1": best.get("eval_recall@1", 0),
|
"best_R@1": best.get("eval_recall@1", 0),
|
||||||
"best_R@5": best.get("eval_recall@5", 0),
|
"best_R@5": best.get("eval_recall@5", 0),
|
||||||
"best_R@10": best.get("eval_recall@10", 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]:
|
def collect_version(version: str) -> list[dict]:
|
||||||
"""Собрать результаты одной версии (v1 или v2).
|
"""Собрать результаты одной версии (v1 или v2).
|
||||||
|
|
||||||
Структура: outputs/<version>/<seed>/<exp_dir>/history.json.
|
Ищет history.json рекурсивно под outputs/<version>/, поэтому находит
|
||||||
|
прогоны обоих протоколов (same/cross), даже если они лежат на разной
|
||||||
|
глубине (напр. seed/exp или seed/protocol/exp).
|
||||||
"""
|
"""
|
||||||
version_dir = OUTPUTS_DIR / version
|
version_dir = OUTPUTS_DIR / version
|
||||||
if not version_dir.exists():
|
if not version_dir.exists():
|
||||||
@@ -87,9 +99,8 @@ def collect_version(version: str) -> list[dict]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
for exp_dir in sorted(version_dir.glob("*/*")):
|
for hist_path in sorted(version_dir.rglob("history.json")):
|
||||||
if exp_dir.is_dir() and (exp_dir / "history.json").exists():
|
data = load_experiment(hist_path.parent)
|
||||||
data = load_experiment(exp_dir)
|
|
||||||
if data:
|
if data:
|
||||||
data["version"] = version
|
data["version"] = version
|
||||||
results.append(data)
|
results.append(data)
|
||||||
@@ -108,7 +119,7 @@ def print_table(results: list[dict], version: str) -> None:
|
|||||||
results.sort(key=lambda r: -r["best_R@1"])
|
results.sort(key=lambda r: -r["best_R@1"])
|
||||||
|
|
||||||
header = (
|
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"{'R@1':>7} {'R@5':>7} {'R@10':>7} {'R@1%':>7} {'mAP':>7} "
|
||||||
f"{'medR':>6} {'g2q@1':>7} {'Loss':>8} {'Time':>6}"
|
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:
|
for r in results:
|
||||||
prog = f"{r['epochs_done']}/{r['epochs_total']}"
|
prog = f"{r['epochs_done']}/{r['epochs_total']}"
|
||||||
print(
|
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_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_R@1%']:>7.4f} {r['best_mAP']:>7.4f} {r['best_median_rank']:>6.0f} "
|
||||||
f"{r['best_g2q_R@1']:>7.4f} "
|
f"{r['best_g2q_R@1']:>7.4f} "
|
||||||
@@ -135,13 +147,92 @@ def print_table(results: list[dict], version: str) -> None:
|
|||||||
print()
|
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
|
# CSV
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def save_csv(results: list[dict], version: str) -> None:
|
def save_csv(results: list[dict], version: str) -> None:
|
||||||
fields = [
|
fields = [
|
||||||
"version", "seed", "text_levels", "epochs_done", "epochs_total",
|
"version", "protocol", "seed", "text_levels", "epochs_done", "epochs_total",
|
||||||
"best_epoch",
|
"best_epoch",
|
||||||
"best_R@1", "best_R@5", "best_R@10", "best_R@1%", "best_mAP",
|
"best_R@1", "best_R@5", "best_R@10", "best_R@1%", "best_mAP",
|
||||||
"best_median_rank", "best_mean_rank",
|
"best_median_rank", "best_mean_rank",
|
||||||
@@ -271,6 +362,52 @@ def save_plots(results: list[dict], version: str) -> None:
|
|||||||
plt.close()
|
plt.close()
|
||||||
print(f" PNG: {path}")
|
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
|
# Main
|
||||||
@@ -295,9 +432,14 @@ def main():
|
|||||||
# Таблица в консоль
|
# Таблица в консоль
|
||||||
print_table(results, version)
|
print_table(results, version)
|
||||||
|
|
||||||
|
# cross−same gap (диагностика text-shortcut) — если есть пары протоколов
|
||||||
|
gaps = compute_gaps(results)
|
||||||
|
print_gap_table(gaps, version)
|
||||||
|
|
||||||
if results:
|
if results:
|
||||||
# CSV и графики в results/
|
# CSV и графики в results/
|
||||||
save_csv(results, version)
|
save_csv(results, version)
|
||||||
|
save_gap_csv(gaps, version)
|
||||||
save_plots(results, version)
|
save_plots(results, version)
|
||||||
|
|
||||||
print(f"\n📁 Все результаты: {RESULTS_DIR}")
|
print(f"\n📁 Все результаты: {RESULTS_DIR}")
|
||||||
|
|||||||
320
compare.py
Normal file
320
compare.py
Normal file
@@ -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()
|
||||||
288
eval_text_destruction.py
Normal file
288
eval_text_destruction.py
Normal file
@@ -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()
|
||||||
@@ -17,16 +17,24 @@
|
|||||||
# bash scripts/run_experiments.sh v2 # только v2
|
# bash scripts/run_experiments.sh v2 # только v2
|
||||||
# bash scripts/run_experiments.sh v1 3 # v1, эксперимент 3
|
# bash scripts/run_experiments.sh v1 3 # v1, эксперимент 3
|
||||||
# bash scripts/run_experiments.sh v2 1 4 # v2, эксперименты 1 и 4
|
# 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 # Остановиться при ошибке
|
set -e # Остановиться при ошибке
|
||||||
|
|
||||||
# ====================== НАСТРОЙКИ ===========================
|
# ====================== НАСТРОЙКИ ===========================
|
||||||
DATA_ROOT="/media/servml/SSD_2_2TB/datasets/cvgl_datasets/GTA-UAV-LR"
|
# База на внешнем SSD (нестабильная точка монтирования SSD_2_2TB ↔ SSD_2_2TB1).
|
||||||
DESCRIPTIONS_v1="/media/servml/SSD_2_2TB/datasets/cvgl_datasets/GTA-UAV-LR-captions_ v1"
|
# При ремаунте переопредели: DATA_BASE=/media/servml/SSD_2_2TB bash scripts/run_experiments.sh
|
||||||
DESCRIPTIONS_v2="/media/servml/SSD_2_2TB/datasets/cvgl_datasets/GTA-UAV-LR-captions_v2"
|
DATA_BASE="${DATA_BASE:-/media/servml/SSD_2_2TB1}"
|
||||||
DGTRS_CKPT="/media/servml/SSD_2_2TB/weights/LRSCLIP/DGTRS-CLIP-ViT-B-16.pt"
|
DATA_ROOT="${DATA_BASE}/datasets/cvgl_datasets/GTA-UAV-LR"
|
||||||
STRIPNET_CKPT="/media/servml/SSD_2_2TB/weights/stripnet_small.pth"
|
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
|
EPOCHS=50
|
||||||
@@ -39,6 +47,27 @@ OUTPUT="outputs"
|
|||||||
# 123 и 456). Переопределяется из окружения, дефолт остаётся 42:
|
# 123 и 456). Переопределяется из окружения, дефолт остаётся 42:
|
||||||
# SEED=123 bash scripts/run_experiments.sh v2 2
|
# SEED=123 bash scripts/run_experiments.sh v2 2
|
||||||
SEED="${SEED:-42}"
|
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=("$@")
|
EXP_NUMS=("$@")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
TOTAL=$(( ${#VERSIONS[@]} * ${#EXP_NUMS[@]} ))
|
TOTAL=$(( ${#VERSIONS[@]} * ${#EXP_NUMS[@]} * ${#SPLITS[@]} ))
|
||||||
|
|
||||||
echo "========================================"
|
echo "========================================"
|
||||||
echo " CVGL Experiments on GTA-UAV"
|
echo " CVGL Experiments on GTA-UAV"
|
||||||
echo " Epochs: $EPOCHS | Batch: $BATCH_SIZE | Seed: $SEED"
|
echo " Epochs: $EPOCHS | Batch: $BATCH_SIZE | Seed: $SEED"
|
||||||
echo " Versions: ${VERSIONS[*]}"
|
echo " Versions: ${VERSIONS[*]}"
|
||||||
echo " Experiments: ${EXP_NUMS[*]}"
|
echo " Experiments: ${EXP_NUMS[*]}"
|
||||||
|
echo " Splits: ${SPLITS[*]}"
|
||||||
echo " Total runs: $TOTAL"
|
echo " Total runs: $TOTAL"
|
||||||
echo "========================================"
|
echo "========================================"
|
||||||
|
|
||||||
@@ -113,18 +143,24 @@ for VERSION in "${VERSIONS[@]}"; do
|
|||||||
|
|
||||||
for EXP_NUM in "${EXP_NUMS[@]}"; do
|
for EXP_NUM in "${EXP_NUMS[@]}"; do
|
||||||
LEVELS="${EXPERIMENTS[$EXP_NUM]}"
|
LEVELS="${EXPERIMENTS[$EXP_NUM]}"
|
||||||
|
|
||||||
|
for SPLIT_NAME in "${SPLITS[@]}"; do
|
||||||
|
read -r TRAIN_META TEST_META <<< "$(meta_for_split "$SPLIT_NAME")"
|
||||||
RUN_IDX=$((RUN_IDX + 1))
|
RUN_IDX=$((RUN_IDX + 1))
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
echo " [$RUN_IDX/$TOTAL] ${VERSION} / Experiment $EXP_NUM: levels=[$LEVELS]"
|
echo " [$RUN_IDX/$TOTAL] ${VERSION} / Exp $EXP_NUM / ${SPLIT_NAME}-area: levels=[$LEVELS]"
|
||||||
echo " Descriptions: $DESCRIPTIONS"
|
echo " Descriptions: $DESCRIPTIONS"
|
||||||
|
echo " Split: train=$TRAIN_META test=$TEST_META"
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
|
||||||
python train.py \
|
python train.py \
|
||||||
--data_root "$DATA_ROOT" \
|
--data_root "$DATA_ROOT" \
|
||||||
--descriptions_path "$DESCRIPTIONS" \
|
--descriptions_path "$DESCRIPTIONS" \
|
||||||
--text_levels $LEVELS \
|
--text_levels $LEVELS \
|
||||||
|
--train_meta "$TRAIN_META" \
|
||||||
|
--test_meta "$TEST_META" \
|
||||||
--dgtrs_checkpoint "$DGTRS_CKPT" \
|
--dgtrs_checkpoint "$DGTRS_CKPT" \
|
||||||
--stripnet_checkpoint "$STRIPNET_CKPT" \
|
--stripnet_checkpoint "$STRIPNET_CKPT" \
|
||||||
--epochs $EPOCHS \
|
--epochs $EPOCHS \
|
||||||
@@ -135,7 +171,8 @@ for VERSION in "${VERSIONS[@]}"; do
|
|||||||
--output_dir "${OUTPUT}/${VERSION}/seed${SEED}" \
|
--output_dir "${OUTPUT}/${VERSION}/seed${SEED}" \
|
||||||
--inject_mona
|
--inject_mona
|
||||||
|
|
||||||
echo "✅ [$RUN_IDX/$TOTAL] ${VERSION} / Experiment $EXP_NUM complete"
|
echo "✅ [$RUN_IDX/$TOTAL] ${VERSION} / Exp $EXP_NUM / ${SPLIT_NAME}-area complete"
|
||||||
|
done
|
||||||
done
|
done
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user