| |
| """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 |
|
|
| |
|
|
| 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 |
|
|
| |
|
|
| |
| |
| |
| |
| |
| EPSILON_GRID = [e / 255 for e in [2, 4, 6, 8, 10, 12, 16]] |
|
|
| |
| |
| PGD_T_PLATEAU_EPS = 8 / 255 |
| PGD_T_GRID = [5, 10, 20, 40] |
|
|
| |
| |
| |
| |
| |
| |
| DEFAULT_N_IMAGES = 200 |
|
|
| |
| 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_ATTACKS: list[dict] = [ |
| { |
| "name": "FGSM", |
| "label": "FGSM (Goodfellow 2015)", |
| "ref": "ICLR 2015", |
| |
| }, |
| { |
| "name": "PGD", |
| "label": "PGD (Madry 2018)", |
| "ref": "ICLR 2018", |
| "steps": 10, |
| |
| "alpha_ratio": 0.1, |
| "random_start": False, |
| }, |
| { |
| "name": "MIM", |
| "label": "MIM (Dong 2018)", |
| "ref": "CVPR 2018", |
| "steps": 10, |
| |
| |
| |
| |
| "alpha_ratio": 0.1, |
| "decay": 1.0, |
| }, |
| { |
| "name": "TGR", |
| "label": "TGR (Zhang 2023)", |
| "ref": "CVPR 2023", |
| "steps": 10, |
| |
| |
| "decay": 1.0, |
| "k": 1, |
| "gamma_attn": 0.25, |
| "gamma_qkv": 0.75, |
| "gamma_mlp": 0.25, |
| }, |
| { |
| "name": "SAGA", |
| "label": "SAGA (Mahmood 2021)", |
| "ref": "ICCV 2021", |
| "steps": 10, |
| |
| }, |
| ] |
|
|
|
|
| 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"] |
| 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"] |
| 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"], |
| 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}") |
|
|
|
|
| |
|
|
| 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) |
|
|
|
|
| |
|
|
| 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, |
| } |
|
|
|
|
| |
|
|
| def _fmt(v) -> str: |
| if isinstance(v, float): |
| return "" if (v != v) else f"{v:.6f}" |
| 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 |
|
|
|
|
| |
|
|
| 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 |
|
|
| |
| 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 |
|
|
| 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) |
| |
| 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]}, |
| ) |
| |
| 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) |
|
|
| |
| _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), |
| ) |
|
|
| |
| _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), |
| ) |
|
|
| |
| _boxplot_eps_metric( |
| "lpips", ylabel="LPIPS (lower = more similar)", |
| title="Curva ε × LPIPS — descritiva", |
| fname="lpips_vs_eps_boxplot.png", |
| ) |
|
|
| |
| _boxplot_eps_metric( |
| "psnr", ylabel="PSNR (dB, higher = more similar)", |
| title="Curva ε × PSNR — descritiva", |
| fname="psnr_vs_eps_boxplot.png", |
| ) |
|
|
| |
| 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)", |
| "", |
| ] |
|
|
| |
| 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)") |
|
|
| |
| 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) |
|
|
| |
| 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}") |
|
|
|
|
| |
|
|
| 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 |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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: |
| |
| 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) |
|
|
| |
| |
| 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)) |
|
|
| 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 |
|
|
| |
| 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}") |
|
|
| |
| 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() |
|
|