augmentation add

This commit is contained in:
pikaliov
2026-07-09 21:55:10 +03:00
parent 76dfca5e06
commit 557a78d600
4 changed files with 825 additions and 38 deletions

View File

@@ -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): нужен для crosssame 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 прогонов нет, поэтому crosssame 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/<version>/<seed>/<exp_dir>/history.json.
Ищет history.json рекурсивно под outputs/<version>/, поэтому находит
прогоны обоих протоколов (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()
# ---------------------------------------------------------------------------
# crosssame 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 (samecross)':>17}")
sep = "" * len(header)
print(sep)
print(f" {version.upper()} — crosssame 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)
# crosssame 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}")

320
compare.py Normal file
View 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
View 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"Вклад текста (fullzero): {-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()

View File

@@ -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 (для crosssame 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