ViTViz / experiments /run_imperceptibility_probe.py
lucasddmc's picture
feat(ui): descrição, créditos e agradecimento de financiamento; corrige caixa de upload presa ao voltar para modelo default
b0e01a5
Raw
History Blame Contribute Delete
34.9 kB
#!/usr/bin/env python3
"""Sweep B-probe: curva ε × {ASR, SSIM, LPIPS, PSNR} + PGD T-plateau check.
Updated 2026-05-08:
- Dataset: data/in1k_hybrid_1k/ (1 imagem por classe IN-1k, com flag
has_mask via metadata.json). Amostragem estratificada via metadata.
- Modelos: 4 do TCC — **S/16, S/32, B/16, B/32** (drop ViT-L). Passar via
--models. Tabela 2×2 (size × patch).
- ε grid: 7 pontos {2, 4, 6, 8, 10, 12, 16}/255 (era 5).
- Plots: boxplots ε × métrica (era linhas mean±IC) — orientação Maynara
2026-05-05: transição "boxplot largo→fino" é achado científico.
- Plots novos: psnr_vs_eps_boxplot.png + asr_vs_eps_boxplot.png.
Satisfaz duas recomendações de Carlini et al. (2019):
- §4.8: verifica que PGD converge por T=10 (plot ASR vs T, espera plateau)
- §5.3: gera curva accuracy-vs-perturbation (figura central do paper)
Coleta também stats descritivos de qualidade (SSIM/LPIPS/PSNR/L∞) em cada ε
pra reportar como evidência na Discussion (com caveat Sen 2020 + Liu 2025).
Statistical basis for N=100
───────────────────────────
- ASR binomial CI at N=100, p=0.5: ±0.098 — sufficient to distinguish 0% vs 30%
- SSIM mean CI at N=100, σ=0.05: ±0.0098 — sufficient for descriptive curves
- Cost ~15-20 min local (4 modelos × 5 ataques × 7 ε + PGD T-sweep)
Attack hyperparameters (paper-exact, see protocolo-avaliacao-imagenet.md)
──────────────────────────────────────────────────────────────────────────
FGSM T=1 α=ε (Goodfellow et al., ICLR 2015 — single step)
PGD T=10 α=ε/T random_start=False (Madry 2018, with random_start=False
for reproducibility of attention maps)
MIM T=10 α=ε/T μ=1.0 (Dong et al., CVPR 2018 — Sec. 4 ImageNet)
TGR T=10 α=ε/T μ=1.0 (Zhang et al., CVPR 2023 — k=1, γ_attn=0.25,
γ_QKV=0.75, γ_MLP=0.25 (paper Tab. 3))
SAGA T=10 α=ε/T (Mahmood et al., ICCV 2021 — white-box ensemble)
Note on MIM alpha: Dong 2018 Sec. 4 ImageNet uses α = ε/T = 1.6/255 at ε=16/255
(proportional, NOT fixed at 2/255 as previously thought — corrected 2026-05-02).
Note on TGR gamma_mlp: paper Tab. 3 ablation → s_MLP=0.25 (code default is 0.5;
overridden here for paper-exact reproduction).
Usage
─────
# TCC scope: 4 modelos S/B × 5 ataques × 7 ε no dataset híbrido 1K
python experiments/run_imperceptibility_probe.py \\
--models "ViT-S/16,ViT-S/32,ViT-B/16,ViT-B/32"
# Custom images directory
python experiments/run_imperceptibility_probe.py --images-dir /path/to/imgs
# Skip PGD T-plateau check
python experiments/run_imperceptibility_probe.py --no-t-sweep
# Regenerate plots from existing CSV (no re-run)
python experiments/run_imperceptibility_probe.py --plot-only
# Resume an interrupted run
python experiments/run_imperceptibility_probe.py --resume
Outputs (under results/imperceptibility_probe/)
────────────────────────────────────────────────
probe_results.csv — raw rows: ... + has_mask (1=Guillaumin GT, 0=outros)
asr_vs_eps_boxplot.png — Carlini §5.3 figura central — boxplots ε × ASR
ssim_vs_eps_boxplot.png — boxplot ε × SSIM (descritiva, Sen 2020/Liu 2025)
lpips_vs_eps_boxplot.png — boxplot ε × LPIPS
psnr_vs_eps_boxplot.png — boxplot ε × PSNR
pgd_t_plateau.png — Carlini §4.8 sanity — ASR(T) para PGD a ε=8/255
summary.md — T-plateau verdict + descriptive stats + Mahmood/TGR
comparison at ε=16/255
"""
from __future__ import annotations
import argparse
import csv
import json
import random
import sys
import time
from pathlib import Path
# ─── project root ────────────────────────────────────────────────────────────
def _project_root() -> Path:
current = Path(__file__).resolve().parent
for p in [current, *current.parents]:
if (p / "requirements.txt").exists():
return p
raise RuntimeError("Project root not found")
ROOT = _project_root()
sys.path.insert(0, str(ROOT))
import numpy as np
import torch
import yaml
from tqdm import tqdm
from utils.attacks import FGSM, MIFGSM, PGDIterations, SAGA, TGR
from utils.metrics import compute_all_image_metrics, compute_all_attack_metrics
from utils.model_loader import load_model_and_labels
from utils.preprocessing import get_default_transform, preprocess_image
from utils.seed import set_seed
# ─── constants ───────────────────────────────────────────────────────────────
#: ε grid (L∞, in [0,1] space). 7 points covering the range from imperceptible
#: (2/255, Goodfellow 2015 panda demo) to perceptible (16/255, Mahmood/TGR).
#: Inclui {6, 10}/255 para visualizar transição "boxplot largo → fino" entre
#: regimes (Maynara 2026-05-05). Usado para gerar curvas ε × {ASR, SSIM, LPIPS,
#: PSNR} centrais ao paper (Carlini §5.3 + decisão protocolo 2026-05-05).
EPSILON_GRID = [e / 255 for e in [2, 4, 6, 8, 10, 12, 16]]
#: T-sweep for PGD plateau check (Carlini §4.8). Run at fixed ε=8/255 (Sweep B
#: principal). Plateau verdict: ASR(T=40) − ASR(T=20) < 1pp.
PGD_T_PLATEAU_EPS = 8 / 255
PGD_T_GRID = [5, 10, 20, 40]
#: Default number of images. N=200 gives ASR CI ±0.069 at p=0.5 (apertado pra
#: regime distinction) e SSIM CI ±0.0069 (descritiva). Sampleadas
#: estratificadamente do data/in1k_hybrid_1k/ via metadata.json (todos os 95
#: com Guillaumin GT mask + 105 sem mask sorteados).
#: Cost: ~30-40 min local em MPS / ~15-20 min A100 (4 models × 5 attacks × 7 ε
#: × 200 imgs = 28k combos main + 4×4×200 = 3.2k T-sweep PGD).
DEFAULT_N_IMAGES = 200
#: Reference epsilon for Mahmood/TGR comparison (paper validation).
MAHMOOD_TGR_EPS = 16 / 255
OUTPUT_DIR = ROOT / "results" / "imperceptibility_probe"
CSV_COLUMNS = [
"model", "attack", "epsilon", "seed", "image", "has_mask",
"orig_pred", "orig_conf", "adv_pred", "adv_conf",
"asr", "confidence_drop", "topk_drop",
"linf", "psnr", "ssim", "lpips", "modified_pixels",
"time_seconds",
]
# ─── paper-exact attack hyperparameters ──────────────────────────────────────
#: All non-ε hyperparameters fixed to paper originals. Only ε is swept.
PAPER_ATTACKS: list[dict] = [
{
"name": "FGSM",
"label": "FGSM (Goodfellow 2015)",
"ref": "ICLR 2015",
# T=1, α=ε — single-step sign gradient (no additional params needed)
},
{
"name": "PGD",
"label": "PGD (Madry 2018)",
"ref": "ICLR 2018",
"steps": 10,
# α = ε/T — Madry formulation; for T=10 → alpha_ratio=0.1
"alpha_ratio": 0.1,
"random_start": False,
},
{
"name": "MIM",
"label": "MIM (Dong 2018)",
"ref": "CVPR 2018",
"steps": 10,
# CORRECTED 2026-05-02: Dong 2018 Sec. 4 ImageNet uses α = ε/T,
# NOT α=2/255 fixed as previously documented. With ε=16, T=10 → α=1.6.
# Verification: 1.6 = 16/10 = ε/T (proportional, not fixed).
# alpha_ratio=0.1 reproduces paper-exact α at any ε.
"alpha_ratio": 0.1,
"decay": 1.0, # μ = 1.0 (momentum decay factor)
},
{
"name": "TGR",
"label": "TGR (Zhang 2023)",
"ref": "CVPR 2023",
"steps": 10,
# α = ε/T internally (eps_step = eps/steps in TGR.__init__)
# At paper's ε=16/255, T=10 → α=1.6/255 ✓
"decay": 1.0, # μ = 1.0
"k": 1, # paper Table 3: k=1
"gamma_attn": 0.25, # s_attn = 0.25 (paper Table 3)
"gamma_qkv": 0.75, # s_QKV = 0.75 (paper Table 3)
"gamma_mlp": 0.25, # s_MLP = 0.25 (paper Table 3; code default=0.5, OVERRIDDEN)
},
{
"name": "SAGA",
"label": "SAGA (Mahmood 2021)",
"ref": "ICCV 2021",
"steps": 10,
# α = ε/T internally (eps_step = eps/steps in SAGA.__init__)
},
]
def build_attack(model: torch.nn.Module, attack_def: dict, eps: float):
"""Instantiate an attack with exact paper hyperparameters.
Only ε is variable. All other params come from PAPER_ATTACKS definitions.
"""
name = attack_def["name"]
if name == "FGSM":
return FGSM(model, eps=eps, collect_images=False)
elif name == "PGD":
alpha = eps * attack_def["alpha_ratio"] # ε/T
return PGDIterations(
model,
eps=eps,
alpha=alpha,
steps=attack_def["steps"],
random_start=attack_def["random_start"],
collect_images=False,
)
elif name == "MIM":
alpha = eps * attack_def["alpha_ratio"] # α = ε/T = 0.1·ε (Dong 2018 Sec. 4)
return MIFGSM(
model,
eps=eps,
alpha=alpha,
steps=attack_def["steps"],
decay=attack_def["decay"],
collect_images=False,
)
elif name == "TGR":
return TGR(
model,
eps=eps,
steps=attack_def["steps"],
decay=attack_def["decay"],
k=attack_def["k"],
gamma_attn=attack_def["gamma_attn"],
gamma_qkv=attack_def["gamma_qkv"],
gamma_mlp=attack_def["gamma_mlp"], # 0.25, not code default 0.5
collect_images=False,
)
elif name == "SAGA":
return SAGA(model, eps=eps, steps=attack_def["steps"], collect_images=False)
else:
raise ValueError(f"Unknown attack: {name}")
# ─── config + model loading ───────────────────────────────────────────────────
def load_config(config_path: str | None) -> dict:
default = ROOT / "configs" / "default.yaml"
with open(default) as f:
cfg = yaml.safe_load(f)
if config_path and Path(config_path).exists():
with open(config_path) as f:
overrides = yaml.safe_load(f) or {}
for k, v in overrides.items():
if isinstance(v, dict) and k in cfg and isinstance(cfg[k], dict):
cfg[k].update(v)
else:
cfg[k] = v
return cfg
def get_device(cfg: dict) -> torch.device:
dev = cfg.get("device", "auto")
if dev == "auto":
return torch.device("cuda" if torch.cuda.is_available() else "cpu")
return torch.device(dev)
# ─── single-run logic ─────────────────────────────────────────────────────────
def run_single(
model, vit_config, attack_def: dict, eps: float,
img_path: Path, device: torch.device, seed: int,
) -> dict:
set_seed(seed)
transform = get_default_transform(img_size=vit_config.img_size)
img_tensor = preprocess_image(str(img_path), transform=transform).to(device)
model.eval()
with torch.no_grad():
out = model(img_tensor)
logits = (out[0] if isinstance(out, tuple) else out)[0]
probs = torch.nn.functional.softmax(logits, dim=0)
orig_pred = probs.argmax().item()
orig_conf = probs[orig_pred].item()
attack = build_attack(model, attack_def, eps)
label = torch.tensor([orig_pred], device=device)
t0 = time.time()
adv_tensor, _ = attack(img_tensor, label)
elapsed = time.time() - t0
with torch.no_grad():
out_adv = model(adv_tensor)
logits_adv = (out_adv[0] if isinstance(out_adv, tuple) else out_adv)[0]
probs_adv = torch.nn.functional.softmax(logits_adv, dim=0)
adv_pred = probs_adv.argmax().item()
adv_conf = probs_adv[adv_pred].item()
img_m = compute_all_image_metrics(img_tensor, adv_tensor, use_lpips=True)
atk_m = compute_all_attack_metrics(probs, probs_adv, orig_pred, adv_pred)
return {
"orig_pred": orig_pred,
"orig_conf": orig_conf,
"adv_pred": adv_pred,
"adv_conf": adv_conf,
"elapsed": elapsed,
**img_m,
**atk_m,
}
# ─── CSV helpers ──────────────────────────────────────────────────────────────
def _fmt(v) -> str:
if isinstance(v, float):
return "" if (v != v) else f"{v:.6f}" # NaN → empty
return str(v)
def completed_keys(csv_path: Path) -> set:
if not csv_path.exists():
return set()
keys: set = set()
with open(csv_path) as f:
for row in csv.DictReader(f):
keys.add((row["model"], row["attack"], row["epsilon"], row["seed"], row["image"]))
return keys
# ─── plotting ─────────────────────────────────────────────────────────────────
def generate_plots(csv_path: Path, out_dir: Path) -> None:
"""Read probe CSV and produce boxplots ε × {ASR, SSIM, LPIPS, PSNR} + PGD T-plateau.
Boxplots ao invés de linhas mean±IC (orientação Maynara 2026-05-05): a
transição "boxplot largo → fino" conforme ε cresce é achado científico em si
— em ε baixo, atacabilidade depende da imagem (heterogeneidade); em ε alto,
satura universalmente. ε=8/255 cai na transição (sweet spot).
"""
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import pandas as pd
except ImportError:
print("[plots] matplotlib/pandas not available — skipping plots.")
return
out_dir.mkdir(parents=True, exist_ok=True)
df = pd.read_csv(csv_path)
df["epsilon_255"] = (df["epsilon"].astype(float) * 255).round(0).astype(int)
attack_order = ["FGSM", "PGD", "MIM", "TGR", "SAGA"]
palette = {"FGSM": "#e41a1c", "PGD": "#377eb8", "MIM": "#4daf4a",
"TGR": "#984ea3", "SAGA": "#ff7f00"}
main_df = df[df["t_sweep"].isna() | (df["t_sweep"] == "")] if "t_sweep" in df.columns else df
# ε ticks (ordenados, todos os valores presentes)
eps_values = sorted(main_df["epsilon_255"].unique())
eps_positions = {e: i for i, e in enumerate(eps_values)}
n_attacks = len(attack_order)
box_width = 0.8 / n_attacks # largura individual de cada box dentro do grupo ε
def _boxplot_eps_metric(metric: str, ylabel: str, title: str, fname: str,
ylim: tuple | None = None,
higher_is_better: bool = True) -> None:
fig, ax = plt.subplots(figsize=(11, 5.5))
legend_handles = []
for j, atk in enumerate(attack_order):
sub = main_df[main_df["attack"] == atk]
if sub.empty:
continue
data = []
positions = []
for e in eps_values:
vals = sub.loc[sub["epsilon_255"] == e, metric].dropna().values
if len(vals) == 0:
continue
data.append(vals)
# posição: centro do grupo ε + offset por ataque
offset = (j - (n_attacks - 1) / 2) * box_width
positions.append(eps_positions[e] + offset)
if not data:
continue
bp = ax.boxplot(
data, positions=positions, widths=box_width * 0.85,
patch_artist=True, showfliers=False,
medianprops={"color": "black", "linewidth": 1.5},
boxprops={"facecolor": palette[atk], "alpha": 0.7,
"edgecolor": palette[atk]},
whiskerprops={"color": palette[atk]},
capprops={"color": palette[atk]},
)
# Manter handle pra legenda
legend_handles.append(plt.Rectangle((0, 0), 1, 1, fc=palette[atk],
alpha=0.7, label=atk))
ax.set_xticks(list(eps_positions.values()))
ax.set_xticklabels([f"{e}/255" for e in eps_values])
ax.set_xlabel("ε∞ (perturbation budget)")
ax.set_ylabel(ylabel)
ax.set_title(title)
if ylim:
ax.set_ylim(*ylim)
ax.grid(axis="y", alpha=0.3)
ax.legend(handles=legend_handles, loc="best", fontsize=9, ncol=n_attacks)
fig.tight_layout()
fig.savefig(out_dir / fname, dpi=150)
plt.close(fig)
# ── ASR vs ε (figura central — Carlini §5.3) ──────────────────────────────
_boxplot_eps_metric(
"asr", ylabel="ASR (per image, 0=fail, 1=success)",
title="Curva ε × ASR — distribuição por ataque (boxplots)",
fname="asr_vs_eps_boxplot.png", ylim=(-0.05, 1.05),
)
# ── SSIM vs ε (descritiva — Sen 2020/Liu 2025) ────────────────────────────
_boxplot_eps_metric(
"ssim", ylabel="SSIM (higher = more similar)",
title="Curva ε × SSIM — descritiva (Wang 2004 não fornece threshold)",
fname="ssim_vs_eps_boxplot.png", ylim=(0.4, 1.02),
)
# ── LPIPS vs ε ────────────────────────────────────────────────────────────
_boxplot_eps_metric(
"lpips", ylabel="LPIPS (lower = more similar)",
title="Curva ε × LPIPS — descritiva",
fname="lpips_vs_eps_boxplot.png",
)
# ── PSNR vs ε ─────────────────────────────────────────────────────────────
_boxplot_eps_metric(
"psnr", ylabel="PSNR (dB, higher = more similar)",
title="Curva ε × PSNR — descritiva",
fname="psnr_vs_eps_boxplot.png",
)
# ── PGD T-plateau (Carlini §4.8) ──
if "t_sweep" in df.columns:
t_df = df[df["t_sweep"].notna() & (df["t_sweep"] != "")]
if not t_df.empty:
t_df = t_df.copy()
t_df["steps"] = t_df["t_sweep"].astype(int)
fig, ax = plt.subplots(figsize=(6, 4))
grp = t_df.groupby("steps")["asr"].mean().reset_index()
ax.plot(grp["steps"], grp["asr"], marker="o", color=colors["PGD"],
linewidth=2, markersize=7)
ax.set_xlabel("PGD iterations (T)")
ax.set_ylabel("ASR (mean over models)")
ax.set_title(f"Carlini §4.8 — PGD T-plateau at ε={PGD_T_PLATEAU_EPS*255:.0f}/255")
ax.set_xticks(PGD_T_GRID)
ax.grid(alpha=0.3)
fig.tight_layout()
fig.savefig(out_dir / "pgd_t_plateau.png", dpi=150)
plt.close(fig)
print(f"[plots] saved to {out_dir}/")
def generate_summary(csv_path: Path, out_dir: Path) -> None:
"""Print and save a Markdown summary with T-plateau verdict, descriptive
stats at ε=8/255 and ε=16/255, and Mahmood/TGR comparison.
"""
try:
import pandas as pd
except ImportError:
print("[summary] pandas not available — skipping summary.")
return
df = pd.read_csv(csv_path)
df["epsilon_255"] = (df["epsilon"].astype(float) * 255).round(1)
main_df = df[df["t_sweep"].isna() | (df["t_sweep"] == "")] if "t_sweep" in df.columns else df
lines = [
"# Sweep B-probe — Summary",
"",
f"**N images**: {main_df['image'].nunique()} ",
f"**Models**: {main_df['model'].nunique()} ",
f"**Attacks**: {sorted(main_df['attack'].unique())} ",
f"**ε grid**: {sorted(main_df['epsilon_255'].unique())} (×1/255) ",
"",
"## 1. PGD T-plateau verdict (Carlini 2019 §4.8)",
"",
]
# T-plateau check
if "t_sweep" in df.columns:
t_df = df[df["t_sweep"].notna() & (df["t_sweep"] != "")].copy()
if not t_df.empty:
t_df["steps"] = t_df["t_sweep"].astype(int)
grp = t_df.groupby("steps")["asr"].mean().reset_index().sort_values("steps")
lines.append("| T | ASR (mean over models) |")
lines.append("|---|---|")
for _, r in grp.iterrows():
lines.append(f"| {int(r['steps']):2d} | {r['asr']:.4f} |")
asr_by_t = dict(zip(grp["steps"], grp["asr"]))
if 20 in asr_by_t and 40 in asr_by_t:
delta = abs(asr_by_t[40] - asr_by_t[20])
verdict = "✅ PASS" if delta < 0.01 else "⚠️ CHECK"
lines.append("")
lines.append(f"|ASR(T=40) − ASR(T=20)| = {delta:.4f}{verdict} "
f"(threshold: <0.01 = <1pp)")
if delta < 0.01:
lines.append("Plateau confirmed: T=10 is sufficient for the main sweep.")
else:
lines.append("Plateau NOT confirmed: consider raising T to 20.")
else:
lines.append("(No T-sweep data — use --no-t-sweep flag was set or sweep skipped)")
else:
lines.append("(No T-sweep column in CSV)")
# Descriptive stats at ε=8/255 and ε=16/255
for ref_eps in [8.0, 16.0]:
sub = main_df[main_df["epsilon_255"] == ref_eps]
if sub.empty:
continue
lines += [
"",
f"## 2. Descriptive stats at ε={int(ref_eps)}/255 (mean over images × models)",
"",
"| Attack | ASR | SSIM | LPIPS | PSNR | L∞ |",
"|---|---|---|---|---|---|",
]
for atk in ["FGSM", "PGD", "MIM", "TGR", "SAGA"]:
atk_sub = sub[sub["attack"] == atk]
if atk_sub.empty:
lines.append(f"| {atk} | — | — | — | — | — |")
continue
row = (
f"| {atk:<5} | "
f"{atk_sub['asr'].mean():.3f} | "
f"{atk_sub['ssim'].mean():.3f} | "
f"{atk_sub['lpips'].mean():.3f} | "
f"{atk_sub['psnr'].mean():5.2f} | "
f"{atk_sub['linf'].mean():.4f} |"
)
lines.append(row)
# Mahmood/TGR validation — only at ε=16/255
sub16 = main_df[main_df["epsilon_255"] == 16.0]
if not sub16.empty:
lines += [
"",
"## 3. Mahmood/TGR comparison at ε=16/255 (implementation validation)",
"",
"Expected (from published baselines):",
" - Mahmood 2021 Tab. 1 (ImageNet, ε=0.062): ViT-B/16 PGD ≈ 0% Acc, MIM ≈ 0% Acc, FGSM ≈ 24% Acc",
" - Zhang 2023 (TGR) Tab. 1: TGR > MIM > PGD > FGSM in transfer; for white-box should be saturated",
"",
"Observed (mean Adv-Acc = 1 - ASR over models):",
"",
"| Attack | Adv-Acc | ASR | Note |",
"|---|---|---|---|",
]
for atk in ["FGSM", "PGD", "MIM", "TGR", "SAGA"]:
atk_sub = sub16[sub16["attack"] == atk]
if atk_sub.empty:
lines.append(f"| {atk} | — | — | no data |")
continue
asr = atk_sub["asr"].mean()
adv_acc = 1.0 - asr
note = "saturated (expected)" if asr > 0.95 else "not saturated"
lines.append(f"| {atk} | {adv_acc:.3f} | {asr:.3f} | {note} |")
lines += [
"",
"## References",
"- Carlini et al. (2019) — On Evaluating Adversarial Robustness. arXiv 1902.06705 §4.8 §5.3",
"- Goodfellow et al. (2015) — FGSM. ICLR 2015.",
"- Madry et al. (2018) — PGD. ICLR 2018.",
"- Dong et al. (2018) — MIM. CVPR 2018. (α=ε/T per Sec. 4)",
"- Zhang et al. (2023) — TGR. CVPR 2023. (γ_MLP=0.25 per Tab. 3)",
"- Mahmood et al. (2021) — SAGA. ICCV 2021.",
"- Sen et al. (2020) — Imperceptibility measures far from human perception. GameSec 2020.",
"- Liu et al. (2025) — Stealthiness Assessment of Adversarial Perturbation. IEEE TIFS.",
]
md = "\n".join(lines)
summary_path = out_dir / "summary.md"
summary_path.write_text(md)
print(md)
print(f"\n[summary] saved to {summary_path}")
# ─── main ─────────────────────────────────────────────────────────────────────
def main() -> None:
parser = argparse.ArgumentParser(description="Sweep B-probe: imperceptibility calibration")
parser.add_argument("--config", default=None, help="Experiment YAML (for model paths)")
parser.add_argument("--models", default=None,
help='Comma-separated model name prefixes to filter (e.g. '
'"ViT-S/16,ViT-S/32,ViT-B/16,ViT-B/32" para TCC scope). '
"Default: usa todos do config (default.yaml = 6 modelos).")
parser.add_argument("--images-dir", default=None,
help="Directory with probe images (default: data/in1k_hybrid_1k/)")
parser.add_argument("--n-images", type=int, default=DEFAULT_N_IMAGES,
help=f"Number of images to use (default: {DEFAULT_N_IMAGES}, min: 100)")
parser.add_argument("--seed", type=int, default=42)
parser.add_argument("--device", default=None)
parser.add_argument("--output-dir", default=str(OUTPUT_DIR))
parser.add_argument("--no-t-sweep", action="store_true",
help="Skip PGD T-plateau check (Carlini §4.8)")
parser.add_argument("--plot-only", action="store_true",
help="Skip sweep, regenerate plots from existing CSV")
parser.add_argument("--resume", action="store_true", help="Skip already-computed rows")
parser.add_argument("--dry-run", action="store_true", help="List combinations without running")
args = parser.parse_args()
out_dir = Path(args.output_dir)
out_dir.mkdir(parents=True, exist_ok=True)
csv_path = out_dir / "probe_results.csv"
if args.plot_only:
if not csv_path.exists():
print(f"ERROR: {csv_path} not found. Run without --plot-only first.")
sys.exit(1)
generate_plots(csv_path, out_dir)
generate_summary(csv_path, out_dir)
return
# Load config + resolve paths
cfg = load_config(args.config)
device = torch.device(args.device) if args.device else get_device(cfg)
images_dir = Path(args.images_dir) if args.images_dir else ROOT / "data" / "in1k_hybrid_1k"
if not images_dir.exists():
print(f"ERROR: images directory not found: {images_dir}")
print(" → run `python scripts/build_in1k_hybrid_1k.py --seed 42` first")
sys.exit(1)
# Filtrar modelos por nome se --models foi passado
if args.models:
wanted_names = {m.strip() for m in args.models.split(",")}
cfg["models"] = [m for m in cfg["models"]
if m["name"].split(" ·")[0].strip() in wanted_names
or m["name"].strip() in wanted_names]
if not cfg["models"]:
print(f"ERROR: no models matched filter --models={args.models}")
print(f" Available: {[m['name'].split(' ·')[0] for m in load_config(args.config)['models']]}")
sys.exit(1)
# Amostragem estratificada via metadata.json (prioriza has_mask=True)
metadata_path = images_dir / "metadata.json"
has_mask_lookup: dict[str, bool] = {}
if metadata_path.exists():
meta = json.loads(metadata_path.read_text())
samples = meta.get("samples") or []
if samples:
with_mask = [s["filename"] for s in samples if s.get("has_mask")]
without_mask = [s["filename"] for s in samples if not s.get("has_mask")]
has_mask_lookup = {s["filename"]: bool(s.get("has_mask"))
for s in samples}
rng = random.Random(args.seed)
n = min(args.n_images, len(samples))
if n <= len(with_mask):
chosen_names = rng.sample(with_mask, n)
else:
chosen_names = with_mask + rng.sample(
without_mask, n - len(with_mask)
)
chosen_names = sorted(chosen_names)
images = [images_dir / nm for nm in chosen_names if (images_dir / nm).exists()]
print(f"Stratified sample: {n} imgs ({sum(1 for p in images if has_mask_lookup.get(p.name)):d} com mask)")
else:
images = []
else:
# Fallback: sem metadata, iterar arquivos lexicograficamente
exts = {".jpg", ".jpeg", ".png", ".bmp", ".webp"}
all_images = sorted(p for p in images_dir.iterdir() if p.suffix.lower() in exts)
n = min(args.n_images, len(all_images))
images = all_images[:n]
print(f"WARN: metadata.json não encontrado em {images_dir}; usando primeiras {n} imagens lexicograficamente")
if len(images) < 100:
print(f"WARNING: only {len(images)} images available (recommended ≥100 for reliable CI)")
n = len(images)
# Build combinations: (model_cfg, attack_def, eps, image)
# T-sweep for PGD adds extra combinations at fixed ε
combos: list[tuple] = []
for model_cfg in cfg["models"]:
for attack_def in PAPER_ATTACKS:
for eps in EPSILON_GRID:
for img in images:
combos.append((model_cfg, attack_def, eps, img, None)) # None = not t-sweep
if not args.no_t_sweep:
pgd_def = next(a for a in PAPER_ATTACKS if a["name"] == "PGD")
for model_cfg in cfg["models"]:
for t in PGD_T_GRID:
pgd_t = dict(pgd_def, steps=t, alpha_ratio=1 / t)
for img in images:
combos.append((model_cfg, pgd_t, PGD_T_PLATEAU_EPS, img, t))
n_models = len(cfg["models"])
n_eps = len(EPSILON_GRID)
n_attacks = len(PAPER_ATTACKS)
t_extra = 0 if args.no_t_sweep else n_models * len(PGD_T_GRID) * n
print(
f"\nSweep B-probe configuration"
f"\n Device: {device}"
f"\n Images: {n} (from {images_dir})"
f"\n Models: {n_models}"
f"\n Attacks: {n_attacks} × {n_eps} ε values + "
f"{'PGD T-sweep (' + str(len(PGD_T_GRID)) + ' points)' if not args.no_t_sweep else 'no T-sweep'}"
f"\n Total combos: {len(combos)} ({n * n_attacks * n_eps * n_models} main + {t_extra} T-sweep)"
f"\n Output: {csv_path}"
f"\n"
f"\n Statistical basis: N={n} → SSIM CI ≈ ±{1.96*0.05/n**0.5:.3f} (σ=0.05, 95%)"
f"\n"
)
if args.dry_run:
for i, (m, a, e, img, t) in enumerate(combos[:10]):
tag = f" [T-sweep T={t}]" if t else ""
print(f" [{i+1}] {m['name']} × {a['name']} × ε={e*255:.0f}/255{tag} × {img.name}")
if len(combos) > 10:
print(f" ... and {len(combos)-10} more")
return
# Write CSV header if new file
done = completed_keys(csv_path) if args.resume else set()
if done:
print(f" Resuming: {len(done)} rows already completed\n")
if not csv_path.exists() or not args.resume:
with open(csv_path, "w", newline="") as f:
extra_cols = ["t_sweep"] if not args.no_t_sweep else []
writer = csv.DictWriter(f, fieldnames=CSV_COLUMNS + extra_cols)
writer.writeheader()
model_cache: dict = {}
extra_cols = ["t_sweep"] if not args.no_t_sweep else []
all_cols = CSV_COLUMNS + extra_cols
pbar = tqdm(combos, desc="B-probe", unit="run")
for model_cfg, attack_def, eps, img_path, t_val in pbar:
model_name = model_cfg["name"]
attack_name = attack_def["name"]
tag = f" T={t_val}" if t_val else ""
key = (model_name, attack_name, f"{eps:.5f}", str(args.seed), img_path.name)
if key in done:
continue
pbar.set_postfix_str(f"{model_name[:12]}|{attack_name}|ε={eps*255:.0f}/255{tag}")
# Load model (cached per name)
if model_name not in model_cache:
try:
model, _, _, vit_cfg = load_model_and_labels(model_cfg["path"], None, device=device)
model_cache[model_name] = (model, vit_cfg)
except Exception as e:
tqdm.write(f" ERROR loading {model_name}: {e}")
continue
model, vit_cfg = model_cache[model_name]
try:
res = run_single(model, vit_cfg, attack_def, eps, img_path, device, args.seed)
except Exception as e:
tqdm.write(f" ERROR: {model_name} × {attack_name} × ε={eps*255:.0f} × {img_path.name}: {e}")
continue
row = {
"model": model_name,
"attack": attack_name,
"epsilon": f"{eps:.5f}",
"seed": str(args.seed),
"image": img_path.name,
"has_mask": "1" if has_mask_lookup.get(img_path.name, False) else "0",
"orig_pred": res["orig_pred"],
"orig_conf": f"{res['orig_conf']:.6f}",
"adv_pred": res["adv_pred"],
"adv_conf": f"{res['adv_conf']:.6f}",
"asr": _fmt(res["asr"]),
"confidence_drop": _fmt(res["confidence_drop"]),
"topk_drop": _fmt(res.get("topk_drop", float("nan"))),
"linf": _fmt(res["linf"]),
"psnr": _fmt(res["psnr"]),
"ssim": _fmt(res["ssim"]),
"lpips": _fmt(res["lpips"]),
"modified_pixels": _fmt(res["modified_pixels"]),
"time_seconds": f"{res['elapsed']:.2f}",
}
if not args.no_t_sweep:
row["t_sweep"] = str(t_val) if t_val else ""
with open(csv_path, "a", newline="") as f:
writer = csv.DictWriter(f, fieldnames=all_cols)
writer.writerow(row)
print(f"\nSweep complete → {csv_path}")
print("\nGenerating plots and summary...")
generate_plots(csv_path, out_dir)
generate_summary(csv_path, out_dir)
if __name__ == "__main__":
main()