lucasddmc commited on
Commit
99483e9
·
1 Parent(s): 33e5b1a

git commit -m "feat: expand ViTViz with experiment pipeline, metrics module, model loading improvements and report UX" -m "- add experiment infrastructure with configs, sweep scripts, notebooks and Makefile tasks

Browse files

add metrics and seed utility modules and wire metrics into attack reporting
improve Hugging Face model loading to support timm wrapper checkpoints
update adversarial attacks for iterative artifact capture and robustness fixes
enhance UI/report with metric tooltips, metric selection control, and layout refinements
update docs, dependencies and repository ignore rules"

.gitignore CHANGED
@@ -205,3 +205,26 @@ cython_debug/
205
  marimo/_static/
206
  marimo/_lsp/
207
  __marimo__/
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
  marimo/_static/
206
  marimo/_lsp/
207
  __marimo__/
208
+
209
+ # Paper (editado no Overleaf, não versionado aqui)
210
+ paper/
211
+
212
+ # Gradio cache (gerado automaticamente)
213
+ .gradio/
214
+
215
+ # ViTViz experiment outputs
216
+ results/raw/
217
+ results/figures/
218
+ results/tables/
219
+ *.npy
220
+ data/sample_images/*.jpg
221
+ data/sample_images/*.jpeg
222
+ data/sample_images/*.png
223
+ data/sample_images/*.JPEG
224
+ !data/sample_images/.gitkeep
225
+
226
+ # Model files
227
+ models/*.pth
228
+ models/*.pt
229
+ models/*.safetensors
230
+ models/*.ckpt
Makefile ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .PHONY: install app experiment attention figures clean help
2
+
3
+ PYTHON ?= python
4
+ CONFIG ?= configs/default.yaml
5
+
6
+ help: ## Show this help
7
+ @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}'
8
+
9
+ install: ## Install all dependencies
10
+ pip install -r requirements.txt
11
+
12
+ download-samples: ## Download 10 sample ImageNet images (requer HF login)
13
+ $(PYTHON) data/download_samples.py
14
+
15
+ app: ## Run the Gradio web app
16
+ $(PYTHON) app.py
17
+
18
+ experiment: ## Run full attack sweep (CONFIG=configs/experiments/exp_quick_test.yaml)
19
+ $(PYTHON) experiments/run_attack_sweep.py --config $(CONFIG)
20
+
21
+ experiment-dry: ## Dry run: show combinations without running attacks
22
+ $(PYTHON) experiments/run_attack_sweep.py --config $(CONFIG) --dry-run
23
+
24
+ attention: ## Run attention analysis for all config combinations
25
+ $(PYTHON) experiments/run_attention_analysis.py --config $(CONFIG) --all
26
+
27
+ quick-test: ## Run quick experiment (single model, FGSM, 1 epsilon)
28
+ $(PYTHON) experiments/run_attack_sweep.py --config configs/experiments/exp_quick_test.yaml
29
+
30
+ clean-results: ## Remove all experiment outputs
31
+ rm -rf results/raw/ results/figures/ results/tables/
32
+
33
+ clean: clean-results ## Clean all generated files
34
+ find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
35
+ find . -name "*.pyc" -delete 2>/dev/null || true
Métricas para avaliação no ViT-Viz.txt ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Métricas para avaliação no ViT-Viz
2
+
3
+
4
+ 1) Métricas de qualidade de imagem
5
+ ℓ_∞ (perturbação máxima)
6
+ * O que mede: alteração máxima em qualquer pixel.
7
+ * Relevância: Essencial
8
+
9
+
10
+ ℓ_2 (distância euclidiana)
11
+ * O que mede: energia total da perturbação, muitos pixels pouco alterados vs poucos pixels muito alterados.
12
+ * Relevância: complementa ℓ_∞.
13
+
14
+
15
+ PSNR (Peak Signal-to-Noise Ratio)
16
+ * O que mede: similaridade a nível de pixel via MSE.
17
+ * Relevância: Alta, legal para medir fidelidade pixel-wise para um mesmo ASR por exemplo.
18
+
19
+
20
+ SSIM (Structural Similarity Index)
21
+ * O que mede: similaridade estrutural, padrões locais, mais alinhada à percepção humana que PSNR.
22
+ * Relevância: Alta, simples implementação e é interpretável
23
+
24
+
25
+ MS-SSIM (Multi-Scale SSIM)
26
+ * O que mede: similaridade estrutural, mas dessa vez focando em diferentes níveis de detalhe (macroestrutura vs microtextura)
27
+ * Relevância: Baixa, parece muito específico.
28
+
29
+
30
+ LPIPS (Learned Perceptual Image Patch Similarity)
31
+ * O que mede: diferênça semântica local aproximada
32
+ * Relevância: Essencial, acho que faz muito sentido quando estamos tentando ver perturbações na atenção.
33
+ NIQE/BRISQUE
34
+ * O que mede: naturalidade da imagem, sem usar referência, são baseadas em estatísticas de cenas naturais.
35
+ * Relevância: baixa, acho que pode ser mais exploratório.
36
+ * NIQE (Mittal et al., 2013).
37
+ * BRISQUE (Mittal et al., 2012).
38
+
39
+
40
+ FID (Fréchet Inception Distance)
41
+ * O que mede: é uma métrica mais global que analisa mudança do conjunto adversarial em relação ao conjunto limpo usando espaço de features do Inception
42
+ * Relevância: Baixa, mas acho que pode ser legal para uma análise global e é recente (Heusel et al. 2017.), mas é pesado computacionalmente.
43
+
44
+
45
+ 2) Métricas de sucesso de ataque
46
+ ASR
47
+ * O que mede: taxa de sucesso do ataque em mudar a decisão.
48
+ * Relevância: essencial.
49
+
50
+
51
+ Top-K accuracy drop’
52
+ * O que mede: se o ataque só tira a label certa do top-1 ou se ataca o top-k.
53
+ * Relevância: alta, é simples e informativa.
54
+
55
+
56
+ Confidence drop
57
+ * O que mede: queda de confiança do modelo em suas classificações.
58
+ * Relevância: Essencial.
59
+
60
+
61
+
62
+
63
+ 3) Outras métricas úteis
64
+ Logit margin
65
+ * O que mede: Quão perto o exemplo ficou da fronteira de decisão em espaço de logits.
66
+ * Relevância: baixa-média, acho complementar a outras métricas
67
+ 4) Métricas específicas de atenção
68
+
69
+
70
+ Divergências Kullback-Leibler e/ou Jensen-Shannon entre mapas de attention ou attention rollout
71
+ * O que mede: diferença distribucional entre os dois padrões de atenção (antes vs depois do ataque), ou seja, o quanto a “massa” de atenção foi redistribuída pelos tokens/patches.
72
+ * Relevância: Alta, permite quantificar bem a mudança de atenção se tratarmos os mapas como distribuições de probabilidade.
73
+ * Obs: JS lida melhor com valores próximos de zero, possivelmente descarta KL. Importante se ligar nas normalizações necessárias quando utilizar esse tipo de métrica.
74
+
75
+
76
+ Similaridade de cossenos
77
+ * O que mede: mudança do vetor de atenção.
78
+ * Relevância: Essencial, é mais simples e também quantifica bem a mudança nos attention maps.
README.md CHANGED
@@ -25,9 +25,10 @@ Any timm-compatible ViT architecture (`model.blocks[i].attn.qkv`), including:
25
 
26
  | Model | Architecture | Dataset |
27
  |-------|-------------|---------|
28
- | ViT-B/16 | Base, patch 16 | Stanford40 Actions |
29
  | ViT-B/32 | Base, patch 32, 384px | ImageNet-1k |
30
  | ViT-L/16 | Large, patch 16 | ImageNet-1k |
 
31
  | DeiT-S/16 | Small, patch 16 | ImageNet-1k |
32
  | Custom Upload | Any ViT | Any |
33
 
@@ -35,9 +36,77 @@ Any timm-compatible ViT architecture (`model.blocks[i].attn.qkv`), including:
35
 
36
  `.pth`, `.pt`, `.safetensors`, `.ckpt`
37
 
38
- ## Running Locally
39
 
40
  ```bash
41
- pip install -r requirements.txt
42
- python app.py
 
 
 
 
 
43
  ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
  | Model | Architecture | Dataset |
27
  |-------|-------------|---------|
28
+ | ViT-B/16 | Base, patch 16 | ImageNet-1k |
29
  | ViT-B/32 | Base, patch 32, 384px | ImageNet-1k |
30
  | ViT-L/16 | Large, patch 16 | ImageNet-1k |
31
+ | ViT-L/32 | Large, patch 32, 384px | ImageNet-1k |
32
  | DeiT-S/16 | Small, patch 16 | ImageNet-1k |
33
  | Custom Upload | Any ViT | Any |
34
 
 
36
 
37
  `.pth`, `.pt`, `.safetensors`, `.ckpt`
38
 
39
+ ## Quick Start
40
 
41
  ```bash
42
+ # 1. Clone e instale as dependências
43
+ git clone <repo-url>
44
+ cd ViTViz
45
+ make install
46
+
47
+ # 2. Suba o app Gradio
48
+ make app
49
  ```
50
+
51
+ ## Comandos Disponíveis (`make help`)
52
+
53
+ | Comando | Descrição |
54
+ |---|---|
55
+ | `make install` | Instala todas as dependências |
56
+ | `make app` | Roda o app Gradio (`localhost:7860`) |
57
+ | `make quick-test` | Teste rápido: DeiT-S + FGSM + 1 epsilon |
58
+ | `make experiment` | Sweep completo de ataques (config padrão) |
59
+ | `make experiment-dry` | Mostra combinações sem executar nada |
60
+ | `make attention` | Gera mapas de atenção para todas as combinações |
61
+ | `make clean` | Remove resultados e cache |
62
+
63
+ Para usar um config específico:
64
+
65
+ ```bash
66
+ make experiment CONFIG=configs/experiments/exp_all_attacks.yaml
67
+ ```
68
+
69
+ ## Pipeline de Experimentos
70
+
71
+ O projeto expõe um pipeline de linha de comando separado da UI, voltado para geração de resultados reproduzíveis para artigos científicos.
72
+
73
+ ### Estrutura de diretórios
74
+
75
+ ```
76
+ configs/ # Configurações YAML de experimentos
77
+ default.yaml # Config base (modelos, ataques, epsilons, seeds)
78
+ experiments/
79
+ exp_quick_test.yaml # Teste rápido (1 modelo, 1 ataque)
80
+ exp_all_attacks.yaml # Sweep completo
81
+ data/
82
+ sample_images/ # Imagens de entrada (.jpg/.png)
83
+ experiments/
84
+ run_attack_sweep.py # Sweep de ataques → CSV com métricas
85
+ run_attention_analysis.py # Salva rollouts de atenção (.npy + .png)
86
+ notebooks/
87
+ 01-metrics-analysis.ipynb # Gera tabelas LaTeX e gráficos de métricas
88
+ 02-attention-figures.ipynb # Gera figuras de comparação de atenção
89
+ results/
90
+ raw/ # Outputs brutos (CSV, .npy, imagens)
91
+ figures/ # Figuras geradas pelos notebooks
92
+ tables/ # Tabelas LaTeX
93
+ ```
94
+
95
+ ### Workflow para paper
96
+
97
+ ```bash
98
+ # 1. Adicione imagens em data/sample_images/
99
+ # 2. Rode os experimentos
100
+ make experiment
101
+
102
+ # 3. Gere os mapas de atenção
103
+ make attention
104
+
105
+ # 4. Abra os notebooks para gerar figuras e tabelas
106
+ cd notebooks && jupyter notebook
107
+ ```
108
+
109
+ ### Métricas calculadas automaticamente
110
+
111
+ - **Perturbação**: L∞, L₂, PSNR, SSIM, LPIPS, Modified Pixels
112
+ - **Ataque**: ASR, Confidence Drop, Top-k Overlap Drop
app.py CHANGED
@@ -1,5 +1,6 @@
1
  import gradio as gr
2
  import os
 
3
  import numpy as np
4
  import torch
5
  from PIL import Image
@@ -10,6 +11,10 @@ from utils.model_loader import load_model_and_labels, ViTConfig
10
  from utils.preprocessing import get_default_transform, preprocess_image
11
  from utils.inference import predict_topk
12
  from utils.attacks import PGDIterations, FGSM, SAGA, MIFGSM, TGR
 
 
 
 
13
  from utils.visualization import (
14
  extract_attention_maps,
15
  attention_rollout,
@@ -34,19 +39,31 @@ ICON_GEAR = '<i class="bi bi-gear-fill vitviz-bi" aria-hidden="true"></i>'
34
 
35
  # Backbone CNN opcional usado no modo "SAGA (with CNN gradient)".
36
  # Pode ser um caminho local (ex.: "models/resnet.pth") ou um checkpoint no Hugging Face Hub.
37
- RESNET_BACKBONE_SPEC = "hf://lucasddmc/resnet101-stanford40-actions/resnet.pth"
38
 
39
  # Modelos pré-configurados disponíveis para seleção
40
  # Diversas arquiteturas para demonstrar flexibilidade do projeto
41
  AVAILABLE_MODELS = {
42
- "ViT-B/16 · Stanford40 Actions": "hf-model://lucasddmc/vit-b16-stanford40-actions",
43
  "ViT-B/32 · ImageNet-1k (384px)": "hf-model://google/vit-base-patch32-384",
44
  "ViT-L/16 · ImageNet-1k": "hf-model://google/vit-large-patch16-224",
 
45
  "DeiT-S/16 · ImageNet-1k": "hf-model://facebook/deit-small-patch16-224",
46
  "Custom Upload": None, # Sinaliza que o usuário deve fazer upload
47
  }
48
  MODEL_CHOICES = list(AVAILABLE_MODELS.keys())
49
- DEFAULT_MODEL = MODEL_CHOICES[0] # Stanford40 por padrão
 
 
 
 
 
 
 
 
 
 
 
50
 
51
  def _get_model_path_from_selection(model_selection: str, model_file) -> str:
52
  """Retorna o path do modelo baseado na seleção do Radio."""
@@ -236,6 +253,7 @@ def run_attack(
236
  steps: int,
237
  decay: float,
238
  vit_weight: float,
 
239
  ) -> Tuple[List[Image.Image], str, List[List[torch.Tensor]]]:
240
  """
241
  Executa ataque adversarial (FGSM ou PGD) untargeted e extrai atenção.
@@ -249,6 +267,7 @@ def run_attack(
249
  alpha: step size (apenas PGD/MIM)
250
  steps: número de iterações (iterativos)
251
  decay: momentum decay (apenas MIM)
 
252
 
253
  Returns:
254
  (iteration_images, result_text, attention_overlays)
@@ -267,7 +286,7 @@ def run_attack(
267
  img_tensor = preprocess_image(image, transform=dynamic_transform).to(DEVICE)
268
 
269
  # Predição original (top-5 para comparação)
270
- top_prob_orig, top_idx_orig, num_classes, _ = predict_topk(model, img_tensor, top_k=5, device=DEVICE)
271
  orig_class = top_idx_orig[0].item()
272
  orig_prob = top_prob_orig[0].item()
273
 
@@ -303,22 +322,58 @@ def run_attack(
303
  cached_attns = getattr(attack, 'attentions_per_iter', None)
304
 
305
  # Predição adversarial (top-5 para comparação)
306
- top_prob_adv, top_idx_adv, _, _ = predict_topk(model, adv_tensor, top_k=5, device=DEVICE)
307
  adv_class = top_idx_adv[0].item()
308
  adv_prob = top_prob_adv[0].item()
 
 
 
 
 
 
 
 
 
309
 
310
- # Calcular métricas de perturbação no espaço [0,1] (desnormalizado)
311
- # Isso é importante porque epsilon é definido no espaço [0,1], não no espaço normalizado
312
- mean = torch.tensor([0.485, 0.456, 0.406]).view(1, 3, 1, 1).to(DEVICE)
313
- std = torch.tensor([0.229, 0.224, 0.225]).view(1, 3, 1, 1).to(DEVICE)
314
-
315
- # Desnormalizar ambos os tensores para espaço [0,1]
316
- img_denorm = img_tensor * std + mean
317
- adv_denorm = adv_tensor * std + mean
318
-
319
- delta = (adv_denorm - img_denorm).detach()
320
- l_inf = delta.abs().max().item()
321
- pixels_changed = (delta.abs() > 1e-6).float().mean().item() * 100
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
322
 
323
  # Format result com tabela comparativa
324
  result = f"## {attack_type} Attack Result (Untargeted)\n\n"
@@ -392,10 +447,17 @@ def run_attack(
392
  result += f" <div class=\"vitviz-panel\">\n"
393
  result += f" <div class=\"vitviz-panel__title\">{ICON_RULER} Perturbation Metrics</div>\n"
394
  result += " <table class=\"vitviz-table\">\n"
395
- result += " <thead><tr><th>Metric</th><th>Value</th><th>Description</th></tr></thead>\n"
396
  result += " <tbody>\n"
397
- result += f" <tr><td><b>L∞ Distance</b></td><td>{l_inf:.6f}</td><td>Maximum pixel change</td></tr>\n"
398
- result += f" <tr><td><b>Modified Pixels</b></td><td>{pixels_changed:.1f}%</td><td>Pixels with |δ| &gt; 1e-6</td></tr>\n"
 
 
 
 
 
 
 
399
  result += " </tbody>\n"
400
  result += " </table>\n"
401
  result += " </div>\n"
@@ -543,6 +605,7 @@ def create_app():
543
  border-spacing: 0;
544
  font-size: 13px;
545
  border: 1px solid rgba(255, 255, 255, 0.18);
 
546
  }
547
  .vitviz-table th,
548
  .vitviz-table td {
@@ -551,6 +614,7 @@ def create_app():
551
  border-right: 1px solid rgba(255, 255, 255, 0.10);
552
  vertical-align: top;
553
  text-align: left;
 
554
  }
555
  .vitviz-table th:last-child,
556
  .vitviz-table td:last-child {
@@ -563,6 +627,94 @@ def create_app():
563
  .vitviz-table tbody tr:last-child td {
564
  border-bottom: none;
565
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
566
  .vitviz-details {
567
  width: 100%;
568
  }
@@ -864,6 +1016,27 @@ def create_app():
864
  inputs=[attack_type],
865
  outputs=[alpha_group, steps_group, decay_group, vit_weight_slider]
866
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
867
 
868
  attack_btn = gr.Button("Execute Full Analysis", variant="primary", size="lg")
869
 
@@ -1039,7 +1212,8 @@ def create_app():
1039
  fn=run_attack,
1040
  inputs=[
1041
  model_upload_attack, model_select_attack, image_upload_attack,
1042
- attack_type, eps_input, alpha_input, steps_input, decay_input, vit_weight_slider
 
1043
  ],
1044
  outputs=[iteration_images_state, output_text_attack, cached_attentions_state]
1045
  ).then(
 
1
  import gradio as gr
2
  import os
3
+ import html
4
  import numpy as np
5
  import torch
6
  from PIL import Image
 
11
  from utils.preprocessing import get_default_transform, preprocess_image
12
  from utils.inference import predict_topk
13
  from utils.attacks import PGDIterations, FGSM, SAGA, MIFGSM, TGR
14
+ from utils.metrics import (
15
+ compute_linf, compute_psnr, compute_ssim, compute_lpips,
16
+ compute_modified_pixels, compute_confidence_drop, compute_topk_drop,
17
+ )
18
  from utils.visualization import (
19
  extract_attention_maps,
20
  attention_rollout,
 
39
 
40
  # Backbone CNN opcional usado no modo "SAGA (with CNN gradient)".
41
  # Pode ser um caminho local (ex.: "models/resnet.pth") ou um checkpoint no Hugging Face Hub.
42
+ RESNET_BACKBONE_SPEC = None
43
 
44
  # Modelos pré-configurados disponíveis para seleção
45
  # Diversas arquiteturas para demonstrar flexibilidade do projeto
46
  AVAILABLE_MODELS = {
47
+ "ViT-B/16 · ImageNet-1k": "hf-model://google/vit-base-patch16-224",
48
  "ViT-B/32 · ImageNet-1k (384px)": "hf-model://google/vit-base-patch32-384",
49
  "ViT-L/16 · ImageNet-1k": "hf-model://google/vit-large-patch16-224",
50
+ "ViT-L/32 · ImageNet-1k (384px)": "hf-model://google/vit-large-patch32-384",
51
  "DeiT-S/16 · ImageNet-1k": "hf-model://facebook/deit-small-patch16-224",
52
  "Custom Upload": None, # Sinaliza que o usuário deve fazer upload
53
  }
54
  MODEL_CHOICES = list(AVAILABLE_MODELS.keys())
55
+ DEFAULT_MODEL = MODEL_CHOICES[0] # ImageNet-1k por padrão
56
+
57
+ METRIC_OPTIONS = [
58
+ "L∞ Distance",
59
+ "PSNR",
60
+ "SSIM",
61
+ "LPIPS",
62
+ "Modified Pixels",
63
+ "Confidence Drop",
64
+ "Top-K Accuracy Drop",
65
+ ]
66
+ METRIC_DROPDOWN_OPTIONS = ["Select All", *METRIC_OPTIONS]
67
 
68
  def _get_model_path_from_selection(model_selection: str, model_file) -> str:
69
  """Retorna o path do modelo baseado na seleção do Radio."""
 
253
  steps: int,
254
  decay: float,
255
  vit_weight: float,
256
+ selected_metrics,
257
  ) -> Tuple[List[Image.Image], str, List[List[torch.Tensor]]]:
258
  """
259
  Executa ataque adversarial (FGSM ou PGD) untargeted e extrai atenção.
 
267
  alpha: step size (apenas PGD/MIM)
268
  steps: número de iterações (iterativos)
269
  decay: momentum decay (apenas MIM)
270
+ selected_metrics: métricas selecionadas no dropdown
271
 
272
  Returns:
273
  (iteration_images, result_text, attention_overlays)
 
286
  img_tensor = preprocess_image(image, transform=dynamic_transform).to(DEVICE)
287
 
288
  # Predição original (top-5 para comparação)
289
+ top_prob_orig, top_idx_orig, num_classes, orig_probs_full = predict_topk(model, img_tensor, top_k=5, device=DEVICE)
290
  orig_class = top_idx_orig[0].item()
291
  orig_prob = top_prob_orig[0].item()
292
 
 
322
  cached_attns = getattr(attack, 'attentions_per_iter', None)
323
 
324
  # Predição adversarial (top-5 para comparação)
325
+ top_prob_adv, top_idx_adv, _, adv_probs_full = predict_topk(model, adv_tensor, top_k=5, device=DEVICE)
326
  adv_class = top_idx_adv[0].item()
327
  adv_prob = top_prob_adv[0].item()
328
+
329
+ # Resolver seleção de métricas:
330
+ # - sem seleção ou com "Select All" => mostra todas
331
+ # - caso contrário, mostra apenas as escolhidas
332
+ requested_metrics = selected_metrics or []
333
+ if (not requested_metrics) or ("Select All" in requested_metrics):
334
+ requested_metrics = METRIC_OPTIONS.copy()
335
+ else:
336
+ requested_metrics = [m for m in METRIC_OPTIONS if m in requested_metrics]
337
 
338
+ # Calcular apenas métricas solicitadas
339
+ metric_values = {}
340
+ if "L∞ Distance" in requested_metrics:
341
+ metric_values["L∞ Distance"] = f"{compute_linf(img_tensor, adv_tensor):.6f}"
342
+ if "PSNR" in requested_metrics:
343
+ metric_values["PSNR"] = f"{compute_psnr(img_tensor, adv_tensor):.2f} dB"
344
+ if "SSIM" in requested_metrics:
345
+ metric_values["SSIM"] = f"{compute_ssim(img_tensor, adv_tensor):.4f}"
346
+ if "LPIPS" in requested_metrics:
347
+ metric_values["LPIPS"] = f"{compute_lpips(img_tensor, adv_tensor):.4f}"
348
+ if "Modified Pixels" in requested_metrics:
349
+ metric_values["Modified Pixels"] = f"{compute_modified_pixels(img_tensor, adv_tensor):.1f}%"
350
+ if "Confidence Drop" in requested_metrics:
351
+ metric_values["Confidence Drop"] = f"{compute_confidence_drop(orig_probs_full, adv_probs_full, orig_class):.4f}"
352
+ if "Top-K Accuracy Drop" in requested_metrics:
353
+ topk_drop = compute_topk_drop(orig_probs_full, adv_probs_full, orig_class, k=5)
354
+ metric_values["Top-K Accuracy Drop"] = "Yes" if topk_drop >= 0.5 else "No"
355
+
356
+ metric_tooltips = {
357
+ "L∞ Distance": "Maximum absolute per-pixel perturbation between original and adversarial images in [0,1] space. Lower is less visible.",
358
+ "PSNR": "Peak Signal-to-Noise Ratio computed from MSE and expressed in dB. Higher generally indicates less distortion.",
359
+ "SSIM": "Structural Similarity Index comparing luminance, contrast, and structure. Values near 1 mean images are structurally similar.",
360
+ "LPIPS": "Learned Perceptual Image Patch Similarity based on deep features. Lower values indicate greater perceptual similarity.",
361
+ "Modified Pixels": "Percentage of pixels where perturbation exceeds 1e-5 (channel max). It approximates a sparse L0/Hamming-like change count.",
362
+ "Confidence Drop": "Decrease in original-class probability after attack: p_orig(original class) - p_adv(original class). Higher means stronger reduction.",
363
+ "Top-K Accuracy Drop": "Yes means the original class left top-5 after attack. No means the original class is still in top-5.",
364
+ }
365
+
366
+ def metric_label_with_info(label: str, tooltip_text: str) -> str:
367
+ safe_tip = html.escape(tooltip_text, quote=True)
368
+ return (
369
+ '<span class="vitviz-metric-name">'
370
+ f"<b>{label}</b>"
371
+ '<span class="vitviz-tooltip-wrap">'
372
+ f'<span class="vitviz-info-icon" aria-label="{safe_tip}" tabindex="0">i</span>'
373
+ f'<span class="vitviz-tooltip" role="tooltip">{safe_tip}</span>'
374
+ '</span>'
375
+ "</span>"
376
+ )
377
 
378
  # Format result com tabela comparativa
379
  result = f"## {attack_type} Attack Result (Untargeted)\n\n"
 
447
  result += f" <div class=\"vitviz-panel\">\n"
448
  result += f" <div class=\"vitviz-panel__title\">{ICON_RULER} Perturbation Metrics</div>\n"
449
  result += " <table class=\"vitviz-table\">\n"
450
+ result += " <thead><tr><th>Metric</th><th>Value</th></tr></thead>\n"
451
  result += " <tbody>\n"
452
+ visible_metrics = [m for m in METRIC_OPTIONS if m in metric_values]
453
+ if visible_metrics:
454
+ for metric_name in visible_metrics:
455
+ result += (
456
+ f" <tr><td>{metric_label_with_info(metric_name, metric_tooltips[metric_name])}</td>"
457
+ f"<td>{metric_values[metric_name]}</td></tr>\n"
458
+ )
459
+ else:
460
+ result += " <tr><td colspan=\"2\"><i>No metrics selected.</i></td></tr>\n"
461
  result += " </tbody>\n"
462
  result += " </table>\n"
463
  result += " </div>\n"
 
605
  border-spacing: 0;
606
  font-size: 13px;
607
  border: 1px solid rgba(255, 255, 255, 0.18);
608
+ overflow: visible;
609
  }
610
  .vitviz-table th,
611
  .vitviz-table td {
 
614
  border-right: 1px solid rgba(255, 255, 255, 0.10);
615
  vertical-align: top;
616
  text-align: left;
617
+ overflow: visible;
618
  }
619
  .vitviz-table th:last-child,
620
  .vitviz-table td:last-child {
 
627
  .vitviz-table tbody tr:last-child td {
628
  border-bottom: none;
629
  }
630
+ .vitviz-metric-name {
631
+ display: inline-flex;
632
+ align-items: center;
633
+ gap: 8px;
634
+ }
635
+ .vitviz-tooltip-wrap {
636
+ position: relative;
637
+ display: inline-flex;
638
+ align-items: center;
639
+ }
640
+ .vitviz-info-icon {
641
+ display: inline-flex;
642
+ align-items: center;
643
+ justify-content: center;
644
+ width: 16px;
645
+ height: 16px;
646
+ border-radius: 999px;
647
+ border: 1px solid rgba(255, 255, 255, 0.40);
648
+ color: rgba(255, 255, 255, 0.82);
649
+ font-size: 11px;
650
+ font-weight: 700;
651
+ line-height: 1;
652
+ cursor: help;
653
+ user-select: none;
654
+ }
655
+ .vitviz-tooltip {
656
+ position: absolute;
657
+ left: calc(100% + 8px);
658
+ top: 50%;
659
+ transform: translateY(-50%);
660
+ background: #2a2a2a;
661
+ color: #ffffff;
662
+ border: 1px solid rgba(255, 255, 255, 0.14);
663
+ border-radius: 8px;
664
+ box-shadow: 0 8px 20px rgba(0, 0, 0, 0.35);
665
+ font-size: 12px;
666
+ line-height: 1.35;
667
+ font-weight: 450;
668
+ padding: 8px 10px;
669
+ width: max-content;
670
+ max-width: min(360px, 60vw);
671
+ white-space: normal;
672
+ opacity: 0;
673
+ visibility: hidden;
674
+ pointer-events: none;
675
+ z-index: 50;
676
+ transition: none;
677
+ }
678
+ .vitviz-tooltip::before {
679
+ content: "";
680
+ position: absolute;
681
+ left: -6px;
682
+ top: 50%;
683
+ transform: translateY(-50%);
684
+ width: 0;
685
+ height: 0;
686
+ border-top: 6px solid transparent;
687
+ border-bottom: 6px solid transparent;
688
+ border-right: 6px solid #2a2a2a;
689
+ }
690
+ .vitviz-tooltip-wrap:hover .vitviz-tooltip,
691
+ .vitviz-tooltip-wrap:focus-within .vitviz-tooltip {
692
+ opacity: 1;
693
+ visibility: visible;
694
+ }
695
+ .vitviz-info-icon:hover,
696
+ .vitviz-info-icon:focus-visible {
697
+ border-color: #84cbff;
698
+ color: #84cbff;
699
+ outline: none;
700
+ }
701
+ @media (max-width: 900px) {
702
+ .vitviz-tooltip {
703
+ left: 50%;
704
+ top: calc(100% + 8px);
705
+ transform: translateX(-50%);
706
+ max-width: min(320px, 82vw);
707
+ }
708
+ .vitviz-tooltip::before {
709
+ left: 50%;
710
+ top: -6px;
711
+ transform: translateX(-50%);
712
+ border-left: 6px solid transparent;
713
+ border-right: 6px solid transparent;
714
+ border-bottom: 6px solid #2a2a2a;
715
+ border-top: 0;
716
+ }
717
+ }
718
  .vitviz-details {
719
  width: 100%;
720
  }
 
1016
  inputs=[attack_type],
1017
  outputs=[alpha_group, steps_group, decay_group, vit_weight_slider]
1018
  )
1019
+
1020
+ metrics_selector = gr.Dropdown(
1021
+ choices=METRIC_DROPDOWN_OPTIONS,
1022
+ value=METRIC_OPTIONS,
1023
+ multiselect=True,
1024
+ label="Metrics in Report",
1025
+ info="Choose which perturbation metrics should be displayed. Select All enables all metrics."
1026
+ )
1027
+
1028
+ def update_metric_selection(selected_values):
1029
+ selected_values = selected_values or []
1030
+ if "Select All" in selected_values:
1031
+ return gr.update(value=METRIC_OPTIONS)
1032
+ filtered = [m for m in METRIC_OPTIONS if m in selected_values]
1033
+ return gr.update(value=filtered)
1034
+
1035
+ metrics_selector.change(
1036
+ fn=update_metric_selection,
1037
+ inputs=[metrics_selector],
1038
+ outputs=[metrics_selector]
1039
+ )
1040
 
1041
  attack_btn = gr.Button("Execute Full Analysis", variant="primary", size="lg")
1042
 
 
1212
  fn=run_attack,
1213
  inputs=[
1214
  model_upload_attack, model_select_attack, image_upload_attack,
1215
+ attack_type, eps_input, alpha_input, steps_input, decay_input, vit_weight_slider,
1216
+ metrics_selector
1217
  ],
1218
  outputs=[iteration_images_state, output_text_attack, cached_attentions_state]
1219
  ).then(
configs/default.yaml ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ViTViz Default Configuration
2
+ # =============================
3
+ # Base config for all experiments. Override in configs/experiments/*.yaml.
4
+
5
+ # Available models (same as app.py)
6
+ models:
7
+ - name: "ViT-B/16 · Stanford40"
8
+ path: "hf-model://lucasddmc/vit-b16-stanford40-actions"
9
+ type: "hf-vit"
10
+ dataset: "Stanford40 Actions"
11
+ img_size: 224
12
+ num_classes: 40
13
+
14
+ - name: "ViT-B/32 · ImageNet-1k"
15
+ path: "hf-model://google/vit-base-patch32-384"
16
+ type: "hf-vit"
17
+ dataset: "ImageNet-1k"
18
+ img_size: 384
19
+ num_classes: 1000
20
+
21
+ - name: "ViT-L/16 · ImageNet-1k"
22
+ path: "hf-model://google/vit-large-patch16-224"
23
+ type: "hf-vit"
24
+ dataset: "ImageNet-1k"
25
+ img_size: 224
26
+ num_classes: 1000
27
+
28
+ - name: "DeiT-S/16 · ImageNet-1k"
29
+ path: "hf-model://facebook/deit-small-patch16-224"
30
+ type: "hf-vit"
31
+ dataset: "ImageNet-1k"
32
+ img_size: 224
33
+ num_classes: 1000
34
+
35
+ # Attack methods with default hyperparameters
36
+ attacks:
37
+ - name: "FGSM"
38
+ params:
39
+ eps: null # set per evaluation.epsilons
40
+
41
+ - name: "PGD"
42
+ params:
43
+ eps: null
44
+ alpha_ratio: 0.25 # alpha = eps * alpha_ratio
45
+ steps: 10
46
+
47
+ - name: "MIM"
48
+ params:
49
+ eps: null
50
+ alpha_ratio: 0.25
51
+ steps: 10
52
+ decay: 1.0
53
+
54
+ - name: "TGR"
55
+ params:
56
+ eps: null
57
+ alpha_ratio: 0.25
58
+ steps: 10
59
+ gamma_attn: 0.25
60
+ gamma_qkv: 0.75
61
+ gamma_mlp: 0.5
62
+
63
+ - name: "SAGA"
64
+ params:
65
+ eps: null
66
+ alpha_ratio: 0.25
67
+ steps: 10
68
+ cnn_backbone: null # ViT-only SAGA (no blending)
69
+
70
+ # Evaluation setup
71
+ evaluation:
72
+ # Epsilon values in [0,1] space (will also compute eps/255 for convenience)
73
+ epsilons:
74
+ - 0.00784 # ~2/255
75
+
76
+ # Random seeds for reproducibility
77
+ seeds: [42, 123, 456, 789, 2026]
78
+
79
+ # Top-k for accuracy drop metric
80
+ topk: 5
81
+
82
+ # Whether to compute LPIPS (slower)
83
+ compute_lpips: true
84
+
85
+ # Attention analysis
86
+ attention:
87
+ discard_ratio: 0.9
88
+ head_fusion: "max"
89
+
90
+ # Output paths
91
+ output:
92
+ results_dir: "results/raw"
93
+ figures_dir: "results/figures"
94
+ tables_dir: "results/tables"
95
+
96
+ # Input data
97
+ data:
98
+ images_dir: "data/sample_images"
99
+
100
+ # Device
101
+ device: "auto" # "auto", "cuda", "cpu"
configs/experiments/exp_all_attacks.yaml ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Experiment: All Attacks × All Models
2
+ # ============================================================================
3
+ # Full sweep across all models, all attacks, all epsilons, 5 seeds.
4
+ # Estimated time: ~4-8h on single GPU (depends on model size).
5
+ #
6
+ # Usage:
7
+ # python experiments/run_attack_sweep.py --config configs/experiments/exp_all_attacks.yaml
8
+
9
+ # Inherit defaults from ../default.yaml (loaded programmatically)
10
+ # Only override what's different.
11
+
12
+ # Use all models from default.yaml
13
+ # models: (inherited)
14
+
15
+ # Use all attacks from default.yaml
16
+ # attacks: (inherited)
17
+
18
+ # Use all epsilons and seeds from default.yaml
19
+ # evaluation: (inherited)
20
+
21
+ # Output gets its own subdirectory
22
+ output:
23
+ results_dir: "results/raw/exp_all_attacks"
24
+ figures_dir: "results/figures/exp_all_attacks"
25
+ tables_dir: "results/tables/exp_all_attacks"
configs/experiments/exp_quick_test.yaml ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Experiment: Quick test (sanity check)
2
+ # ============================================================================
3
+ # Single model, single attack, single epsilon, single seed.
4
+ # Use to verify the pipeline works before running the full sweep.
5
+ #
6
+ # Usage:
7
+ # python experiments/run_attack_sweep.py --config configs/experiments/exp_quick_test.yaml
8
+
9
+ models:
10
+ - name: "DeiT-S/16 · ImageNet-1k"
11
+ path: "hf-model://facebook/deit-small-patch16-224"
12
+ type: "hf-vit"
13
+ dataset: "ImageNet-1k"
14
+ img_size: 224
15
+ num_classes: 1000
16
+
17
+ attacks:
18
+ - name: "FGSM"
19
+ params:
20
+ eps: null
21
+
22
+ evaluation:
23
+ epsilons:
24
+ - 0.03137 # 8/255
25
+ seeds: [42]
26
+ topk: 5
27
+ compute_lpips: false # faster
28
+
29
+ output:
30
+ results_dir: "results/raw/exp_quick_test"
31
+ figures_dir: "results/figures/exp_quick_test"
32
+ tables_dir: "results/tables/exp_quick_test"
data/download_samples.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Download sample ImageNet-1k images via HuggingFace Datasets (streaming).
3
+
4
+ Requirements:
5
+ 1. Accept the dataset agreement at: https://huggingface.co/datasets/imagenet-1k
6
+ 2. Login: huggingface-cli login (or set HF_TOKEN env var)
7
+ 3. pip install datasets
8
+
9
+ Usage:
10
+ python data/download_samples.py
11
+ python data/download_samples.py --n-per-class 2 --output data/sample_images
12
+ """
13
+
14
+ import argparse
15
+ import json
16
+ from pathlib import Path
17
+
18
+ # Classes desejadas: {class_id: filename_prefix}
19
+ TARGET_CLASSES = {
20
+ 281: "tabby_cat",
21
+ 207: "golden_retriever",
22
+ 985: "daisy",
23
+ 530: "digital_clock",
24
+ 817: "sports_car",
25
+ 291: "lion",
26
+ 949: "strawberry",
27
+ 388: "giant_panda",
28
+ 562: "fountain",
29
+ 717: "pickup_truck",
30
+ }
31
+
32
+
33
+ def download_samples(output_dir: Path, n_per_class: int = 1):
34
+ try:
35
+ from datasets import load_dataset
36
+ except ImportError:
37
+ print("ERROR: instale o pacote com: pip install datasets")
38
+ raise
39
+
40
+ output_dir.mkdir(parents=True, exist_ok=True)
41
+
42
+ print("Carregando ImageNet-1k via streaming (sem baixar o dataset completo)...")
43
+ print("Isso pode levar alguns minutos na primeira execução.")
44
+ print()
45
+
46
+ ds = load_dataset(
47
+ "imagenet-1k",
48
+ split="validation",
49
+ streaming=True,
50
+ )
51
+
52
+ # Rastrear quantas imagens já temos por classe
53
+ collected = {cls_id: 0 for cls_id in TARGET_CLASSES}
54
+ total_needed = len(TARGET_CLASSES) * n_per_class
55
+ total_saved = 0
56
+
57
+ for sample in ds:
58
+ cls_id = sample["label"]
59
+ if cls_id not in collected:
60
+ continue
61
+ if collected[cls_id] >= n_per_class:
62
+ continue
63
+
64
+ name = TARGET_CLASSES[cls_id]
65
+ idx = collected[cls_id]
66
+ suffix = f"_{idx+1}" if n_per_class > 1 else ""
67
+ filename = output_dir / f"{name}{suffix}.JPEG"
68
+
69
+ sample["image"].save(filename)
70
+ collected[cls_id] += 1
71
+ total_saved += 1
72
+ print(f" [{total_saved}/{total_needed}] {filename.name} (classe {cls_id})")
73
+
74
+ if total_saved >= total_needed:
75
+ break
76
+
77
+ # Verificar quais classes ficaram sem imagens
78
+ missing = [TARGET_CLASSES[k] for k, v in collected.items() if v < n_per_class]
79
+ if missing:
80
+ print(f"\nAVISO: não encontradas: {missing}")
81
+ else:
82
+ print(f"\nConcluído! {total_saved} imagens salvas em {output_dir}")
83
+
84
+ # Atualizar metadata.json com os arquivos baixados
85
+ meta_path = output_dir / "metadata.json"
86
+ if meta_path.exists():
87
+ with open(meta_path) as f:
88
+ meta = json.load(f)
89
+ meta["downloaded_files"] = [
90
+ f"{TARGET_CLASSES[k]}{'_' + str(i+1) if n_per_class > 1 else ''}.JPEG"
91
+ for k in TARGET_CLASSES
92
+ for i in range(n_per_class)
93
+ if (output_dir / f"{TARGET_CLASSES[k]}{'_' + str(i+1) if n_per_class > 1 else ''}.JPEG").exists()
94
+ ]
95
+ with open(meta_path, "w") as f:
96
+ json.dump(meta, f, indent=2)
97
+
98
+
99
+ if __name__ == "__main__":
100
+ parser = argparse.ArgumentParser(description="Download ImageNet sample images")
101
+ parser.add_argument("--n-per-class", type=int, default=1,
102
+ help="Imagens por classe (default: 1)")
103
+ parser.add_argument("--output", type=str, default="data/sample_images",
104
+ help="Diretório de saída")
105
+ args = parser.parse_args()
106
+
107
+ download_samples(Path(args.output), args.n_per_class)
data/sample_images/.gitkeep ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # This file ensures the directory is tracked by git.
2
+ # Add sample images (.jpg, .png) here for batch experiments.
3
+ # These images will be excluded from git by .gitignore.
data/sample_images/metadata.json ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "description": "Sample images for ViTViz experiments",
3
+ "instructions": "Add ImageNet validation images here. Recommended: 10-20 diverse images.",
4
+ "naming_convention": "ILSVRC2012_val_NNNNN.JPEG or descriptive names (e.g., cat.jpg, car.jpg)",
5
+ "suggested_classes": [
6
+ {
7
+ "imagenet_id": 281,
8
+ "name": "tabby cat"
9
+ },
10
+ {
11
+ "imagenet_id": 207,
12
+ "name": "golden retriever"
13
+ },
14
+ {
15
+ "imagenet_id": 985,
16
+ "name": "daisy"
17
+ },
18
+ {
19
+ "imagenet_id": 530,
20
+ "name": "digital clock"
21
+ },
22
+ {
23
+ "imagenet_id": 817,
24
+ "name": "sports car"
25
+ },
26
+ {
27
+ "imagenet_id": 291,
28
+ "name": "lion"
29
+ },
30
+ {
31
+ "imagenet_id": 949,
32
+ "name": "strawberry"
33
+ },
34
+ {
35
+ "imagenet_id": 388,
36
+ "name": "giant panda"
37
+ },
38
+ {
39
+ "imagenet_id": 562,
40
+ "name": "fountain"
41
+ },
42
+ {
43
+ "imagenet_id": 717,
44
+ "name": "pickup truck"
45
+ }
46
+ ],
47
+ "downloaded_files": [
48
+ "tabby_cat.JPEG",
49
+ "golden_retriever.JPEG",
50
+ "daisy.JPEG",
51
+ "digital_clock.JPEG",
52
+ "sports_car.JPEG",
53
+ "lion.JPEG",
54
+ "strawberry.JPEG",
55
+ "giant_panda.JPEG",
56
+ "fountain.JPEG",
57
+ "pickup_truck.JPEG"
58
+ ]
59
+ }
experiments/run_attack_sweep.py ADDED
@@ -0,0 +1,398 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Run adversarial attack sweep across models × attacks × epsilons × seeds.
3
+
4
+ Produces a CSV with all metrics for each combination and saves adversarial
5
+ images. Designed to run headless (no Gradio) on any machine.
6
+
7
+ Usage:
8
+ # Full sweep with default config
9
+ python experiments/run_attack_sweep.py
10
+
11
+ # Custom experiment config
12
+ python experiments/run_attack_sweep.py --config configs/experiments/exp_quick_test.yaml
13
+
14
+ # Dry run (list combinations without executing)
15
+ python experiments/run_attack_sweep.py --dry-run
16
+
17
+ # Resume interrupted run
18
+ python experiments/run_attack_sweep.py --resume
19
+ """
20
+
21
+ import argparse
22
+ import csv
23
+ import os
24
+ import sys
25
+ import time
26
+ from pathlib import Path
27
+
28
+ # Add project root to path
29
+ def _find_project_root() -> Path:
30
+ """Find project root by walking up the directory tree until requirements.txt is found."""
31
+ current = Path(__file__).resolve().parent
32
+ for parent in [current, *current.parents]:
33
+ if (parent / "requirements.txt").exists():
34
+ return parent
35
+ raise RuntimeError(f"Project root not found starting from {__file__}")
36
+
37
+ PROJECT_ROOT = _find_project_root()
38
+ sys.path.insert(0, str(PROJECT_ROOT))
39
+
40
+ import numpy as np
41
+ import torch
42
+ import yaml
43
+ from PIL import Image
44
+ from tqdm import tqdm
45
+
46
+ from utils.attacks import (
47
+ FGSM,
48
+ MIFGSM,
49
+ PGDIterations,
50
+ SAGA,
51
+ TGR,
52
+ )
53
+ from utils.inference import predict_topk
54
+ from utils.metrics import (
55
+ compute_all_attack_metrics,
56
+ compute_all_image_metrics,
57
+ )
58
+ from utils.model_loader import load_model_and_labels
59
+ from utils.preprocessing import get_default_transform, preprocess_image
60
+ from utils.seed import set_seed
61
+
62
+
63
+ # ═══════════════════════════════════════════════════════════════════════════
64
+ # Config loading
65
+ # ═══════════════════════════════════════════════════════════════════════════
66
+
67
+ def load_config(config_path: str) -> dict:
68
+ """Load experiment config with inheritance from default.yaml."""
69
+ default_path = PROJECT_ROOT / "configs" / "default.yaml"
70
+
71
+ with open(default_path) as f:
72
+ config = yaml.safe_load(f)
73
+
74
+ if config_path and Path(config_path).exists():
75
+ with open(config_path) as f:
76
+ overrides = yaml.safe_load(f) or {}
77
+ # Deep merge: override top-level keys
78
+ for key, value in overrides.items():
79
+ if isinstance(value, dict) and key in config and isinstance(config[key], dict):
80
+ config[key].update(value)
81
+ else:
82
+ config[key] = value
83
+
84
+ return config
85
+
86
+
87
+ def get_device(config: dict) -> torch.device:
88
+ """Resolve device from config."""
89
+ dev = config.get("device", "auto")
90
+ if dev == "auto":
91
+ return torch.device("cuda" if torch.cuda.is_available() else "cpu")
92
+ return torch.device(dev)
93
+
94
+
95
+ # ═══════════════════════════════════════════════════════════════════════════
96
+ # Attack factory
97
+ # ═══════════════════════════════════════════════════════════════════════════
98
+
99
+ def create_attack(model, attack_cfg: dict, eps: float):
100
+ """Instantiate an attack from config dict."""
101
+ name = attack_cfg["name"]
102
+ params = attack_cfg.get("params", {})
103
+
104
+ if name == "FGSM":
105
+ return FGSM(model, eps=eps, collect_images=False)
106
+
107
+ elif name == "PGD":
108
+ alpha = eps * params.get("alpha_ratio", 0.25)
109
+ steps = params.get("steps", 10)
110
+ return PGDIterations(model, eps=eps, alpha=alpha, steps=steps, random_start=False, collect_images=False)
111
+
112
+ elif name == "MIM":
113
+ alpha = eps * params.get("alpha_ratio", 0.25)
114
+ steps = params.get("steps", 10)
115
+ decay = params.get("decay", 1.0)
116
+ return MIFGSM(model, eps=eps, alpha=alpha, steps=steps, decay=decay, collect_images=False)
117
+
118
+ elif name == "TGR":
119
+ steps = params.get("steps", 10)
120
+ decay = params.get("decay", 1.0)
121
+ return TGR(model, eps=eps, steps=steps, decay=decay, collect_images=False)
122
+
123
+ elif name == "SAGA":
124
+ steps = params.get("steps", 10)
125
+ cnn = params.get("cnn_backbone")
126
+ return SAGA(model, eps=eps, steps=steps, use_resnet=bool(cnn), cnn_checkpoint_path=cnn, collect_images=False)
127
+
128
+ else:
129
+ raise ValueError(f"Unknown attack: {name}")
130
+
131
+
132
+ # ═══════════════════════════════════════════════════════════════════════════
133
+ # CSV columns
134
+ # ═══════════════════════════════════════════════════════════════════════════
135
+
136
+ CSV_COLUMNS = [
137
+ "model", "attack", "epsilon", "seed", "image",
138
+ "orig_pred", "orig_conf", "adv_pred", "adv_conf",
139
+ # Attack success
140
+ "asr", "confidence_drop", "topk_drop",
141
+ # Image quality
142
+ "linf", "l2", "psnr", "ssim", "lpips", "modified_pixels",
143
+ # Timing
144
+ "time_seconds",
145
+ ]
146
+
147
+
148
+ # ═══════════════════════════════════════════════════════════════════════════
149
+ # Main sweep
150
+ # ═══════════════════════════════════════════════════════════════════════════
151
+
152
+ def build_combinations(config: dict, image_dir: Path, allow_empty: bool = False) -> list:
153
+ """Generate all (model, attack, epsilon, seed, image) combinations."""
154
+ models = config["models"]
155
+ attacks = config["attacks"]
156
+ epsilons = config["evaluation"]["epsilons"]
157
+ seeds = config["evaluation"]["seeds"]
158
+
159
+ images = sorted(
160
+ p for p in image_dir.iterdir()
161
+ if p.suffix.lower() in {".jpg", ".jpeg", ".png", ".bmp", ".webp"}
162
+ ) if image_dir.exists() else []
163
+
164
+ if not images and not allow_empty:
165
+ print(f"ERROR: No images found in {image_dir}")
166
+ sys.exit(1)
167
+
168
+ if not images:
169
+ # Dry-run mode: show config summary without images
170
+ return [(m, a, e, s, Path("(no images)")) for m in models for a in attacks for e in epsilons for s in seeds]
171
+
172
+ combos = []
173
+ for model_cfg in models:
174
+ for attack_cfg in attacks:
175
+ for eps in epsilons:
176
+ for seed in seeds:
177
+ for img_path in images:
178
+ combos.append((model_cfg, attack_cfg, eps, seed, img_path))
179
+
180
+ return combos
181
+
182
+
183
+ def get_completed_keys(csv_path: Path) -> set:
184
+ """Read existing CSV to find completed runs (for --resume)."""
185
+ if not csv_path.exists():
186
+ return set()
187
+ keys = set()
188
+ with open(csv_path) as f:
189
+ reader = csv.DictReader(f)
190
+ for row in reader:
191
+ key = (row["model"], row["attack"], row["epsilon"], row["seed"], row["image"])
192
+ keys.add(key)
193
+ return keys
194
+
195
+
196
+ def run_single(
197
+ model, vit_config, class_names,
198
+ attack_cfg: dict, eps: float, seed: int,
199
+ img_path: Path, device: torch.device,
200
+ eval_cfg: dict, attn_cfg: dict,
201
+ ) -> dict:
202
+ """Run a single attack and compute all metrics. Returns a dict row."""
203
+ set_seed(seed)
204
+
205
+ # Preprocess
206
+ transform = get_default_transform(img_size=vit_config.img_size)
207
+ img_tensor = preprocess_image(str(img_path), transform=transform).to(device)
208
+
209
+ # Original prediction
210
+ model.eval()
211
+ with torch.no_grad():
212
+ orig_out = model(img_tensor)
213
+ if isinstance(orig_out, tuple):
214
+ orig_out = orig_out[0]
215
+ orig_logits = orig_out[0] # shape [num_classes]
216
+ orig_probs = torch.nn.functional.softmax(orig_logits, dim=0)
217
+ orig_pred = orig_probs.argmax().item()
218
+ orig_conf = orig_probs[orig_pred].item()
219
+
220
+ # Create and run attack
221
+ attack = create_attack(model, attack_cfg, eps)
222
+ original_label = torch.tensor([orig_pred], device=device)
223
+
224
+ t0 = time.time()
225
+ adv_tensor, _ = attack(img_tensor, original_label)
226
+ elapsed = time.time() - t0
227
+
228
+ # Adversarial prediction
229
+ with torch.no_grad():
230
+ adv_out = model(adv_tensor)
231
+ if isinstance(adv_out, tuple):
232
+ adv_out = adv_out[0]
233
+ adv_logits = adv_out[0]
234
+ adv_probs = torch.nn.functional.softmax(adv_logits, dim=0)
235
+ adv_pred = adv_probs.argmax().item()
236
+ adv_conf = adv_probs[adv_pred].item()
237
+
238
+ # Image quality metrics
239
+ use_lpips = eval_cfg.get("compute_lpips", True)
240
+ img_metrics = compute_all_image_metrics(img_tensor, adv_tensor, use_lpips=use_lpips)
241
+
242
+ # Attack success metrics
243
+ atk_metrics = compute_all_attack_metrics(
244
+ orig_probs, adv_probs, orig_pred, adv_pred,
245
+ k=eval_cfg.get("topk", 5),
246
+ orig_logits=orig_logits, adv_logits=adv_logits,
247
+ )
248
+
249
+ # Build row
250
+ row = {
251
+ "model": attack_cfg.get("_model_name", "unknown"),
252
+ "attack": attack_cfg["name"],
253
+ "epsilon": f"{eps:.5f}",
254
+ "seed": str(seed),
255
+ "image": img_path.name,
256
+ "orig_pred": str(orig_pred),
257
+ "orig_conf": f"{orig_conf:.6f}",
258
+ "adv_pred": str(adv_pred),
259
+ "adv_conf": f"{adv_conf:.6f}",
260
+ **{k: f"{v:.6f}" for k, v in atk_metrics.items()},
261
+ **{k: f"{v:.6f}" for k, v in img_metrics.items()},
262
+ "time_seconds": f"{elapsed:.2f}",
263
+ }
264
+
265
+ return row
266
+
267
+
268
+ def main():
269
+ parser = argparse.ArgumentParser(description="ViTViz Attack Sweep Experiment")
270
+ parser.add_argument(
271
+ "--config", type=str, default=None,
272
+ help="Experiment config YAML (inherits from configs/default.yaml)",
273
+ )
274
+ parser.add_argument("--dry-run", action="store_true", help="List combinations without executing")
275
+ parser.add_argument("--resume", action="store_true", help="Skip already-completed runs in CSV")
276
+ parser.add_argument("--device", type=str, default=None, help="Override device (cuda/cpu)")
277
+ parser.add_argument("--output", type=str, default=None, help="Override output CSV path")
278
+ args = parser.parse_args()
279
+
280
+ # Load config
281
+ config = load_config(args.config)
282
+ device = torch.device(args.device) if args.device else get_device(config)
283
+
284
+ print(f"Device: {device}")
285
+ print(f"Config: {args.config or 'default'}")
286
+
287
+ # Resolve paths
288
+ results_dir = Path(config["output"]["results_dir"])
289
+ results_dir.mkdir(parents=True, exist_ok=True)
290
+ images_dir = Path(config["data"]["images_dir"])
291
+
292
+ if not images_dir.exists():
293
+ images_dir.mkdir(parents=True, exist_ok=True)
294
+ if not args.dry_run:
295
+ print(f"ERROR: Images directory is empty: {images_dir}")
296
+ print(" → Add sample images to data/sample_images/")
297
+ sys.exit(1)
298
+
299
+ # Build combinations
300
+ combos = build_combinations(config, images_dir, allow_empty=args.dry_run)
301
+ print(f"Total combinations: {len(combos)}")
302
+ print(f" Models: {len(config['models'])}")
303
+ print(f" Attacks: {len(config['attacks'])}")
304
+ print(f" Epsilons: {len(config['evaluation']['epsilons'])}")
305
+ print(f" Seeds: {len(config['evaluation']['seeds'])}")
306
+ image_exts = {".jpg", ".jpeg", ".png", ".bmp", ".webp", ".JPEG"}
307
+ print(f" Images: {len([p for p in images_dir.iterdir() if p.suffix.lower() in image_exts])}")
308
+
309
+ if args.dry_run:
310
+ print("\n--- Dry Run: combinations ---")
311
+ for i, (m, a, e, s, img) in enumerate(combos[:20]):
312
+ print(f" [{i+1}] {m['name']} × {a['name']} × ε={e:.5f} × seed={s} × {img.name}")
313
+ if len(combos) > 20:
314
+ print(f" ... and {len(combos) - 20} more")
315
+ return
316
+
317
+ # Output CSV
318
+ csv_path = Path(args.output) if args.output else results_dir / "results.csv"
319
+ csv_path.parent.mkdir(parents=True, exist_ok=True)
320
+
321
+ # Resume support
322
+ completed = get_completed_keys(csv_path) if args.resume else set()
323
+ if completed: # if not an empty set
324
+ print(f"Resuming: {len(completed)} runs already completed")
325
+
326
+ # Write header if new file
327
+ write_header = not csv_path.exists() or not args.resume
328
+ if write_header:
329
+ with open(csv_path, "w", newline="") as f:
330
+ writer = csv.DictWriter(f, fieldnames=CSV_COLUMNS)
331
+ writer.writeheader()
332
+
333
+ # Cache loaded models to avoid reloading
334
+ model_cache = {} # TODO: add LRU eviction if too many models
335
+
336
+ # Run sweep
337
+ pbar = tqdm(combos, desc="Attack sweep", unit="run")
338
+ success_count = 0
339
+ error_count = 0
340
+
341
+ for model_cfg, attack_cfg, eps, seed, img_path in pbar:
342
+ model_name = model_cfg["name"]
343
+ attack_name = attack_cfg["name"]
344
+
345
+ # Check resume
346
+ key = (model_name, attack_name, f"{eps:.5f}", str(seed), img_path.name)
347
+ if key in completed:
348
+ continue
349
+
350
+ pbar.set_postfix_str(f"{model_name[:15]}|{attack_name}|ε={eps:.4f}|s={seed}")
351
+
352
+ # Load model (cached)
353
+ if model_name not in model_cache:
354
+ try:
355
+ print(f"\n Loading model: {model_name}...")
356
+ model, class_names, _, vit_config = load_model_and_labels(
357
+ model_cfg["path"], None, device=device
358
+ )
359
+ model_cache[model_name] = (model, class_names, vit_config)
360
+ except Exception as e:
361
+ print(f"\n ERROR loading model {model_name}: {e}")
362
+ error_count += 1
363
+ continue
364
+
365
+ model, class_names, vit_config = model_cache[model_name]
366
+
367
+ # Inject model name into attack config for CSV
368
+ attack_cfg_copy = dict(attack_cfg)
369
+ attack_cfg_copy["_model_name"] = model_name
370
+
371
+ try:
372
+ row = run_single(
373
+ model, vit_config, class_names,
374
+ attack_cfg_copy, eps, seed, img_path, device,
375
+ config["evaluation"], config.get("attention", {}),
376
+ )
377
+
378
+ # Append to CSV
379
+ with open(csv_path, "a", newline="") as f:
380
+ writer = csv.DictWriter(f, fieldnames=CSV_COLUMNS)
381
+ writer.writerow(row)
382
+
383
+ success_count += 1
384
+
385
+ except Exception as e:
386
+ print(f"\n ERROR: {model_name} × {attack_name} × ε={eps} × seed={seed} × {img_path.name}: {e}")
387
+ error_count += 1
388
+
389
+ print(f"\n{'='*60}")
390
+ print(f"Sweep complete!")
391
+ print(f" Successful: {success_count}")
392
+ print(f" Errors: {error_count}")
393
+ print(f" Results: {csv_path}")
394
+ print(f"{'='*60}")
395
+
396
+
397
+ if __name__ == "__main__":
398
+ main()
experiments/run_attention_analysis.py ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Capture detailed attention maps for visualization in the paper.
3
+
4
+ For a given model + image + attack, saves:
5
+ - Attention rollout maps (original and adversarial) as .npy and .png
6
+ - Per-layer attention heatmaps
7
+ - Overlay images
8
+ Usage:
9
+ python experiments/run_attention_analysis.py \
10
+ --model "hf-model://facebook/deit-small-patch16-224" \
11
+ --image data/sample_images/cat.jpg \
12
+ --attack FGSM --eps 0.03137
13
+
14
+ # Or with config (analyzes all combinations)
15
+ python experiments/run_attention_analysis.py --config configs/default.yaml --all
16
+ """
17
+
18
+ import argparse
19
+ import json
20
+ import os
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
25
+ sys.path.insert(0, str(PROJECT_ROOT))
26
+
27
+ import numpy as np
28
+ import torch
29
+ import yaml
30
+ from PIL import Image
31
+
32
+ from utils.attacks import FGSM, MIFGSM, PGDIterations, SAGA, TGR, capture_outputs_and_attentions
33
+ from utils.inference import predict_topk
34
+ from utils.model_loader import load_model_and_labels
35
+ from utils.preprocessing import get_default_transform, preprocess_image
36
+ from utils.seed import set_seed
37
+ from utils.visualization import attention_rollout, create_attention_overlay
38
+
39
+
40
+ def save_rollout_artifacts(
41
+ output_dir: Path,
42
+ prefix: str,
43
+ rollout_map: np.ndarray,
44
+ original_image: Image.Image,
45
+ alpha: float = 0.5,
46
+ ):
47
+ """Save rollout map as .npy and overlay .png."""
48
+ np.save(output_dir / f"{prefix}_rollout.npy", rollout_map)
49
+
50
+ overlay = create_attention_overlay(original_image, rollout_map, alpha=alpha)
51
+ overlay.save(output_dir / f"{prefix}_overlay.png")
52
+
53
+ # Save raw heatmap
54
+ import matplotlib.pyplot as plt
55
+ import matplotlib.cm as cm
56
+
57
+ fig, ax = plt.subplots(1, 1, figsize=(4, 4))
58
+ ax.imshow(rollout_map, cmap="jet", interpolation="nearest")
59
+ ax.set_title(prefix)
60
+ ax.axis("off")
61
+ fig.tight_layout()
62
+ fig.savefig(output_dir / f"{prefix}_heatmap.png", dpi=150, bbox_inches="tight")
63
+ plt.close(fig)
64
+
65
+
66
+ def analyze_single(
67
+ model_path: str,
68
+ img_path: str,
69
+ attack_name: str,
70
+ eps: float,
71
+ seed: int,
72
+ output_base: str,
73
+ device: torch.device,
74
+ discard_ratio: float = 0.9,
75
+ head_fusion: str = "max",
76
+ attack_params: dict = None,
77
+ ):
78
+ """Full attention analysis for one model × image × attack combination."""
79
+ set_seed(seed)
80
+
81
+ # Load model
82
+ model, class_names, _, vit_config = load_model_and_labels(model_path, None, device=device)
83
+ transform = get_default_transform(img_size=vit_config.img_size)
84
+
85
+ # Load image
86
+ pil_img = Image.open(img_path).convert("RGB")
87
+ img_tensor = preprocess_image(pil_img, transform=transform).to(device)
88
+
89
+ # Output directory
90
+ img_name = Path(img_path).stem
91
+ safe_model = model_path.split("/")[-1] if "/" in model_path else "custom"
92
+ dir_name = f"{safe_model}_{attack_name}_eps{eps:.4f}_{img_name}_seed{seed}"
93
+ output_dir = Path(output_base) / dir_name
94
+ output_dir.mkdir(parents=True, exist_ok=True)
95
+
96
+ print(f" Output: {output_dir}")
97
+
98
+ # Original prediction & attention
99
+ _, attns_orig = capture_outputs_and_attentions(model, img_tensor)
100
+ top_prob, top_idx, _, orig_probs = predict_topk(model, img_tensor, top_k=5, device=device)
101
+ orig_pred = top_idx[0].item()
102
+
103
+ rollout_orig = attention_rollout(attns_orig, discard_ratio=discard_ratio, head_fusion=head_fusion)
104
+
105
+ # Resize original image to match model input
106
+ resized = pil_img.resize(
107
+ (vit_config.img_size, vit_config.img_size), Image.LANCZOS
108
+ )
109
+ resized.save(output_dir / "original.png")
110
+ save_rollout_artifacts(output_dir, "original", rollout_orig, resized)
111
+
112
+ # Run attack
113
+ params = attack_params or {}
114
+ if attack_name == "FGSM":
115
+ attack = FGSM(model, eps=eps, collect_images=False)
116
+ elif attack_name == "PGD":
117
+ alpha = eps * params.get("alpha_ratio", 0.25)
118
+ attack = PGDIterations(model, eps=eps, alpha=alpha, steps=params.get("steps", 10), collect_images=False)
119
+ elif attack_name == "MIM":
120
+ alpha = eps * params.get("alpha_ratio", 0.25)
121
+ attack = MIFGSM(model, eps=eps, alpha=alpha, steps=params.get("steps", 10), collect_images=False)
122
+ elif attack_name == "TGR":
123
+ attack = TGR(model, eps=eps, steps=params.get("steps", 10), collect_images=False)
124
+ elif attack_name == "SAGA":
125
+ attack = SAGA(model, eps=eps, steps=params.get("steps", 10), collect_images=False)
126
+ else:
127
+ raise ValueError(f"Unknown attack: {attack_name}")
128
+
129
+ label_tensor = torch.tensor([orig_pred], device=device)
130
+ adv_tensor, _ = attack(img_tensor, label_tensor)
131
+
132
+ # Adversarial prediction & attention (reuse attentions captured during attack)
133
+ attns_adv = attack.attentions_per_iter[-1] if attack.attentions_per_iter else None
134
+ _, top_idx_adv, _, adv_probs = predict_topk(model, adv_tensor, top_k=5, device=device)
135
+ adv_pred = top_idx_adv[0].item()
136
+
137
+ if attns_adv is None:
138
+ _, attns_adv = capture_outputs_and_attentions(model, adv_tensor)
139
+
140
+ rollout_adv = attention_rollout(attns_adv, discard_ratio=discard_ratio, head_fusion=head_fusion)
141
+
142
+ # Save adversarial image
143
+ from utils.attacks import denormalize_imagenet, tensor_to_pil
144
+ adv_pil = tensor_to_pil(adv_tensor[0])
145
+ adv_pil.save(output_dir / "adversarial.png")
146
+ save_rollout_artifacts(output_dir, "adversarial", rollout_adv, adv_pil)
147
+
148
+ # Side-by-side comparison figure
149
+ import matplotlib.pyplot as plt
150
+
151
+ fig, axes = plt.subplots(1, 4, figsize=(16, 4))
152
+
153
+ axes[0].imshow(resized)
154
+ axes[0].set_title("Original")
155
+ axes[0].axis("off")
156
+
157
+ axes[1].imshow(rollout_orig, cmap="jet")
158
+ orig_label = class_names.get(orig_pred, f"Class {orig_pred}") if class_names else f"Class {orig_pred}"
159
+ axes[1].set_title(f"Attn: {orig_label}")
160
+ axes[1].axis("off")
161
+
162
+ axes[2].imshow(adv_pil)
163
+ axes[2].set_title("Adversarial")
164
+ axes[2].axis("off")
165
+
166
+ axes[3].imshow(rollout_adv, cmap="jet")
167
+ adv_label = class_names.get(adv_pred, f"Class {adv_pred}") if class_names else f"Class {adv_pred}"
168
+ axes[3].set_title(f"Attn: {adv_label}")
169
+ axes[3].axis("off")
170
+
171
+ fig.suptitle(f"{attack_name} (ε={eps:.4f})", fontsize=14)
172
+ fig.tight_layout()
173
+ fig.savefig(output_dir / "comparison.png", dpi=200)
174
+ plt.close(fig)
175
+
176
+ # Save summary JSON
177
+ summary = {
178
+ "model": model_path,
179
+ "image": str(img_path),
180
+ "attack": attack_name,
181
+ "epsilon": eps,
182
+ "seed": seed,
183
+ "orig_pred": orig_pred,
184
+ "orig_label": orig_label,
185
+ "adv_pred": adv_pred,
186
+ "adv_label": adv_label,
187
+ }
188
+ with open(output_dir / "summary.json", "w") as f:
189
+ json.dump(summary, f, indent=2)
190
+
191
+ print(f" Pred: {orig_label} → {adv_label}")
192
+
193
+ return summary
194
+
195
+
196
+ def main():
197
+ parser = argparse.ArgumentParser(description="ViTViz Attention Analysis")
198
+ parser.add_argument("--model", type=str, help="Model path or hf-model:// URI")
199
+ parser.add_argument("--image", type=str, help="Path to input image")
200
+ parser.add_argument("--attack", type=str, default="FGSM", help="Attack name")
201
+ parser.add_argument("--eps", type=float, default=0.03137, help="Epsilon (default: ~8/255)")
202
+ parser.add_argument("--seed", type=int, default=42)
203
+ parser.add_argument("--output", type=str, default="results/raw/attention")
204
+ parser.add_argument("--device", type=str, default=None)
205
+ parser.add_argument("--discard-ratio", type=float, default=0.9)
206
+ parser.add_argument("--head-fusion", type=str, default="max")
207
+
208
+ # Batch mode
209
+ parser.add_argument("--config", type=str, help="Config YAML for batch mode")
210
+ parser.add_argument("--all", action="store_true", help="Analyze all combos from config")
211
+
212
+ args = parser.parse_args()
213
+
214
+ device = torch.device(args.device) if args.device else torch.device(
215
+ "cuda" if torch.cuda.is_available() else "cpu"
216
+ )
217
+ print(f"Device: {device}")
218
+
219
+ if args.all and args.config:
220
+ # Batch mode: iterate over config
221
+ default_path = PROJECT_ROOT / "configs" / "default.yaml"
222
+ with open(default_path) as f:
223
+ config = yaml.safe_load(f)
224
+ if args.config:
225
+ with open(args.config) as f:
226
+ overrides = yaml.safe_load(f) or {}
227
+ for k, v in overrides.items():
228
+ if isinstance(v, dict) and k in config:
229
+ config[k].update(v)
230
+ else:
231
+ config[k] = v
232
+
233
+ images_dir = Path(config["data"]["images_dir"])
234
+ images = sorted(
235
+ p for p in images_dir.iterdir()
236
+ if p.suffix.lower() in {".jpg", ".jpeg", ".png", ".JPEG"}
237
+ )
238
+
239
+ for model_cfg in config["models"]:
240
+ for attack_cfg in config["attacks"]:
241
+ for eps in config["evaluation"]["epsilons"]:
242
+ for img_path in images:
243
+ print(f"\n--- {model_cfg['name']} × {attack_cfg['name']} × ε={eps} × {img_path.name} ---")
244
+ try:
245
+ analyze_single(
246
+ model_cfg["path"], str(img_path),
247
+ attack_cfg["name"], eps,
248
+ seed=config["evaluation"]["seeds"][0],
249
+ output_base=args.output, device=device,
250
+ discard_ratio=args.discard_ratio,
251
+ head_fusion=args.head_fusion,
252
+ attack_params=attack_cfg.get("params", {}),
253
+ )
254
+ except Exception as e:
255
+ print(f" ERROR: {e}")
256
+ else:
257
+ # Single mode
258
+ if not args.model or not args.image:
259
+ parser.error("--model and --image are required in single mode (or use --config + --all)")
260
+ analyze_single(
261
+ args.model, args.image, args.attack, args.eps,
262
+ args.seed, args.output, device,
263
+ args.discard_ratio, args.head_fusion,
264
+ )
265
+
266
+
267
+ if __name__ == "__main__":
268
+ main()
notebooks/01-metrics-analysis.ipynb ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "82a50f9e",
6
+ "metadata": {},
7
+ "source": [
8
+ "# ViTViz - Metrics Analysis\n",
9
+ "\n",
10
+ "Load experiment results from `results/raw/results.csv` and generate tables/figures for the paper."
11
+ ]
12
+ },
13
+ {
14
+ "cell_type": "code",
15
+ "execution_count": null,
16
+ "id": "6781cd0c",
17
+ "metadata": {},
18
+ "outputs": [],
19
+ "source": [
20
+ "import pandas as pd\n",
21
+ "import numpy as np\n",
22
+ "import matplotlib.pyplot as plt\n",
23
+ "import matplotlib\n",
24
+ "matplotlib.rcParams.update({'font.size': 11, 'font.family': 'serif'})\n",
25
+ "\n",
26
+ "from pathlib import Path\n",
27
+ "\n",
28
+ "RESULTS_CSV = Path('../results/raw/results.csv')\n",
29
+ "FIGURES_DIR = Path('../results/figures')\n",
30
+ "TABLES_DIR = Path('../results/tables')\n",
31
+ "FIGURES_DIR.mkdir(parents=True, exist_ok=True)\n",
32
+ "TABLES_DIR.mkdir(parents=True, exist_ok=True)"
33
+ ]
34
+ },
35
+ {
36
+ "cell_type": "code",
37
+ "execution_count": null,
38
+ "id": "03122534",
39
+ "metadata": {},
40
+ "outputs": [],
41
+ "source": [
42
+ "df = pd.read_csv(RESULTS_CSV)\n",
43
+ "print(f'Total runs: {len(df)}')\n",
44
+ "print(f'Models: {df.model.nunique()}')\n",
45
+ "print(f'Attacks: {df.attack.nunique()}')\n",
46
+ "df.head()"
47
+ ]
48
+ },
49
+ {
50
+ "cell_type": "markdown",
51
+ "id": "8beccbfc",
52
+ "metadata": {},
53
+ "source": [
54
+ "## Attack Success Rate (ASR) Table\n",
55
+ "Aggregated by model × attack, averaged over all epsilons, seeds, and images."
56
+ ]
57
+ },
58
+ {
59
+ "cell_type": "code",
60
+ "execution_count": null,
61
+ "id": "80764de3",
62
+ "metadata": {},
63
+ "outputs": [],
64
+ "source": [
65
+ "asr_table = df.groupby(['model', 'attack'])['asr'].mean().unstack(fill_value=0) * 100\n",
66
+ "asr_table = asr_table.round(1)\n",
67
+ "print(asr_table.to_string())\n",
68
+ "\n",
69
+ "# Save LaTeX\n",
70
+ "latex = asr_table.to_latex(caption='Attack Success Rate (\\\\%) by model and attack method.',\n",
71
+ " label='tab:asr', float_format='%.1f')\n",
72
+ "with open(TABLES_DIR / 'asr_table.tex', 'w') as f:\n",
73
+ " f.write(latex)\n",
74
+ "print('Saved to', TABLES_DIR / 'asr_table.tex')"
75
+ ]
76
+ },
77
+ {
78
+ "cell_type": "markdown",
79
+ "id": "fcb957ed",
80
+ "metadata": {},
81
+ "source": [
82
+ "## Image Quality Metrics vs. Epsilon"
83
+ ]
84
+ },
85
+ {
86
+ "cell_type": "code",
87
+ "execution_count": null,
88
+ "id": "37fe9b55",
89
+ "metadata": {},
90
+ "outputs": [],
91
+ "source": [
92
+ "metrics = ['ssim', 'psnr', 'l2', 'linf']\n",
93
+ "fig, axes = plt.subplots(1, len(metrics), figsize=(4*len(metrics), 3.5), sharey=False)\n",
94
+ "\n",
95
+ "for ax, metric in zip(axes, metrics):\n",
96
+ " pivot = df.groupby(['epsilon', 'attack'])[metric].mean().unstack()\n",
97
+ " pivot.plot(ax=ax, marker='o', linewidth=1.5)\n",
98
+ " ax.set_title(metric.upper())\n",
99
+ " ax.set_xlabel('Epsilon')\n",
100
+ " ax.legend(fontsize=8)\n",
101
+ " ax.grid(True, alpha=0.3)\n",
102
+ "\n",
103
+ "fig.tight_layout()\n",
104
+ "fig.savefig(FIGURES_DIR / 'quality_vs_epsilon.pdf', bbox_inches='tight')\n",
105
+ "fig.savefig(FIGURES_DIR / 'quality_vs_epsilon.png', dpi=200, bbox_inches='tight')\n",
106
+ "plt.show()"
107
+ ]
108
+ },
109
+ {
110
+ "cell_type": "markdown",
111
+ "id": "2497bb54",
112
+ "metadata": {},
113
+ "source": [
114
+ "## Attention Shift Metrics (JSD, Cosine Similarity)"
115
+ ]
116
+ },
117
+ {
118
+ "cell_type": "code",
119
+ "execution_count": null,
120
+ "id": "704c9f89",
121
+ "metadata": {},
122
+ "outputs": [],
123
+ "source": [
124
+ "attn_metrics = ['jsd', 'cosine_similarity']\n",
125
+ "fig, axes = plt.subplots(1, 2, figsize=(10, 4))\n",
126
+ "\n",
127
+ "for ax, metric in zip(axes, attn_metrics):\n",
128
+ " pivot = df.groupby(['epsilon', 'attack'])[metric].mean().unstack()\n",
129
+ " pivot.plot(ax=ax, marker='s', linewidth=1.5)\n",
130
+ " ax.set_title(f'Attention {metric.replace(\"_\", \" \").title()} vs. Epsilon')\n",
131
+ " ax.set_xlabel('Epsilon')\n",
132
+ " ax.legend(fontsize=8)\n",
133
+ " ax.grid(True, alpha=0.3)\n",
134
+ "\n",
135
+ "fig.tight_layout()\n",
136
+ "fig.savefig(FIGURES_DIR / 'attention_shift.pdf', bbox_inches='tight')\n",
137
+ "fig.savefig(FIGURES_DIR / 'attention_shift.png', dpi=200, bbox_inches='tight')\n",
138
+ "plt.show()"
139
+ ]
140
+ },
141
+ {
142
+ "cell_type": "markdown",
143
+ "id": "dd58cfdc",
144
+ "metadata": {},
145
+ "source": [
146
+ "## Comprehensive Summary Table (for paper)"
147
+ ]
148
+ },
149
+ {
150
+ "cell_type": "code",
151
+ "execution_count": null,
152
+ "id": "64583c52",
153
+ "metadata": {},
154
+ "outputs": [],
155
+ "source": [
156
+ "summary_cols = ['asr', 'confidence_drop', 'linf', 'l2', 'ssim', 'psnr', 'jsd', 'cosine_similarity']\n",
157
+ "summary = df.groupby(['model', 'attack'])[summary_cols].agg(['mean', 'std'])\n",
158
+ "\n",
159
+ "# Flatten multi-level columns\n",
160
+ "summary.columns = [f'{col}_{stat}' for col, stat in summary.columns]\n",
161
+ "summary = summary.round(4)\n",
162
+ "\n",
163
+ "# Save\n",
164
+ "summary.to_csv(TABLES_DIR / 'summary_stats.csv')\n",
165
+ "print('Saved summary to', TABLES_DIR / 'summary_stats.csv')\n",
166
+ "summary"
167
+ ]
168
+ }
169
+ ],
170
+ "metadata": {
171
+ "language_info": {
172
+ "name": "python"
173
+ }
174
+ },
175
+ "nbformat": 4,
176
+ "nbformat_minor": 5
177
+ }
notebooks/02-attention-figures.ipynb ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "54561e91",
6
+ "metadata": {},
7
+ "source": [
8
+ "# ViTViz - Attention Figures\n",
9
+ "\n",
10
+ "Load saved attention maps (`.npy`) from `results/raw/attention/` and create comparison figures for the paper."
11
+ ]
12
+ },
13
+ {
14
+ "cell_type": "code",
15
+ "execution_count": null,
16
+ "id": "1641faab",
17
+ "metadata": {},
18
+ "outputs": [],
19
+ "source": [
20
+ "import json\n",
21
+ "import numpy as np\n",
22
+ "import matplotlib.pyplot as plt\n",
23
+ "import matplotlib\n",
24
+ "matplotlib.rcParams.update({'font.size': 11, 'font.family': 'serif'})\n",
25
+ "\n",
26
+ "from pathlib import Path\n",
27
+ "from PIL import Image\n",
28
+ "\n",
29
+ "ATTENTION_DIR = Path('../results/raw/attention')\n",
30
+ "FIGURES_DIR = Path('../results/figures')\n",
31
+ "FIGURES_DIR.mkdir(parents=True, exist_ok=True)"
32
+ ]
33
+ },
34
+ {
35
+ "cell_type": "code",
36
+ "execution_count": null,
37
+ "id": "a95c211e",
38
+ "metadata": {},
39
+ "outputs": [],
40
+ "source": [
41
+ "# List all analysis directories\n",
42
+ "analysis_dirs = sorted([d for d in ATTENTION_DIR.iterdir() if d.is_dir()])\n",
43
+ "print(f'Found {len(analysis_dirs)} analyses')\n",
44
+ "for d in analysis_dirs[:10]:\n",
45
+ " summary = json.loads((d / 'summary.json').read_text())\n",
46
+ " print(f' {d.name}: {summary[\"orig_label\"]} → {summary[\"adv_label\"]}')"
47
+ ]
48
+ },
49
+ {
50
+ "cell_type": "markdown",
51
+ "id": "1b781fd5",
52
+ "metadata": {},
53
+ "source": [
54
+ "## Comparison Grid: Original vs. Adversarial Attention"
55
+ ]
56
+ },
57
+ {
58
+ "cell_type": "code",
59
+ "execution_count": null,
60
+ "id": "78fa078a",
61
+ "metadata": {},
62
+ "outputs": [],
63
+ "source": [
64
+ "def plot_comparison_grid(dirs, ncols=4, save_path=None):\n",
65
+ " \"\"\"Create a grid comparing original vs adversarial attention.\"\"\"\n",
66
+ " nrows = len(dirs)\n",
67
+ " fig, axes = plt.subplots(nrows, ncols, figsize=(ncols*3, nrows*3))\n",
68
+ " if nrows == 1:\n",
69
+ " axes = axes[np.newaxis, :]\n",
70
+ "\n",
71
+ " col_titles = ['Original', 'Attn (Original)', 'Adversarial', 'Attn (Adversarial)']\n",
72
+ "\n",
73
+ " for row, d in enumerate(dirs):\n",
74
+ " summary = json.loads((d / 'summary.json').read_text())\n",
75
+ "\n",
76
+ " orig = Image.open(d / 'original.png')\n",
77
+ " orig_rollout = np.load(d / 'original_rollout.npy')\n",
78
+ " adv = Image.open(d / 'adversarial.png')\n",
79
+ " adv_rollout = np.load(d / 'adversarial_rollout.npy')\n",
80
+ "\n",
81
+ " axes[row, 0].imshow(orig)\n",
82
+ " axes[row, 0].set_ylabel(f'{summary[\"attack\"]}\\nε={summary[\"epsilon\"]:.4f}', fontsize=9)\n",
83
+ "\n",
84
+ " axes[row, 1].imshow(orig_rollout, cmap='jet')\n",
85
+ " axes[row, 1].set_title(summary['orig_label'] if row == 0 else '', fontsize=9)\n",
86
+ "\n",
87
+ " axes[row, 2].imshow(adv)\n",
88
+ "\n",
89
+ " axes[row, 3].imshow(adv_rollout, cmap='jet')\n",
90
+ " axes[row, 3].set_title(summary['adv_label'] if row == 0 else '', fontsize=9)\n",
91
+ "\n",
92
+ " for c in range(ncols):\n",
93
+ " axes[row, c].axis('off')\n",
94
+ " if row == 0:\n",
95
+ " axes[row, c].set_title(col_titles[c], fontsize=10, fontweight='bold')\n",
96
+ "\n",
97
+ " fig.tight_layout()\n",
98
+ " if save_path:\n",
99
+ " fig.savefig(save_path, dpi=300, bbox_inches='tight')\n",
100
+ " plt.show()\n",
101
+ "\n",
102
+ "# Plot first 5 (or all if fewer)\n",
103
+ "plot_comparison_grid(\n",
104
+ " analysis_dirs[:5],\n",
105
+ " save_path=FIGURES_DIR / 'attention_comparison_grid.pdf'\n",
106
+ ")"
107
+ ]
108
+ }
109
+ ],
110
+ "metadata": {
111
+ "language_info": {
112
+ "name": "python"
113
+ }
114
+ },
115
+ "nbformat": 4,
116
+ "nbformat_minor": 5
117
+ }
requirements.txt CHANGED
@@ -9,4 +9,13 @@ matplotlib>=3.7.0
9
  Pillow>=10.0.0
10
  huggingface_hub>=0.20.0
11
  transformers>=4.36.0
12
- safetensors>=0.4.0
 
 
 
 
 
 
 
 
 
 
9
  Pillow>=10.0.0
10
  huggingface_hub>=0.20.0
11
  transformers>=4.36.0
12
+ safetensors>=0.4.0
13
+
14
+ # Metrics
15
+ pytorch-msssim>=1.0.0
16
+ lpips>=0.1.4
17
+
18
+ # Experiment infrastructure
19
+ pyyaml>=6.0
20
+ tqdm>=4.60.0
21
+ pandas>=2.0.0
utils/attacks.py CHANGED
@@ -23,6 +23,13 @@ try:
23
  except Exception: # pragma: no cover - optional dependency
24
  hf_hub_download = None
25
 
 
 
 
 
 
 
 
26
  def capture_outputs_and_attentions(model, x_norm: torch.Tensor):
27
  """Executa um forward único capturando atenções via hooks nas camadas de atenção do ViT.
28
  Retorna (outputs, attentions_list) onde attentions_list é lista de tensores [B,H,T,T] por camada.
@@ -106,8 +113,9 @@ class FGSM(torchattacks.FGSM):
106
 
107
  FGSM é um ataque de 1 única iteração (non-iterative).
108
  """
109
- def __init__(self, model, eps=0.03):
110
  super().__init__(model, eps=eps)
 
111
  self.iteration_images: List[Image.Image] = []
112
  self.iteration_tensors: List[torch.Tensor] = []
113
  # Atenções por iteração (iteração 0: original, iteração 1: adversarial)
@@ -135,14 +143,17 @@ class FGSM(torchattacks.FGSM):
135
  self.attentions_per_iter = []
136
 
137
  # Salvar imagem original
138
- pil_img_orig = tensor_to_pil(images_denorm[0], denormalize=False)
139
- self.iteration_images.append(pil_img_orig)
140
- self.iteration_tensors.append(images.clone().detach())
 
141
 
142
  # Calcular gradiente
143
  images.requires_grad = True
144
  # Capturar atenções e logits para imagem original
145
  outputs, attentions0 = capture_outputs_and_attentions(self.model, images)
 
 
146
  self.attentions_per_iter.append([att for att in attentions0])
147
 
148
  if self.targeted:
@@ -162,9 +173,10 @@ class FGSM(torchattacks.FGSM):
162
  adv_images = (adv_images_denorm - mean) / std
163
 
164
  # Salvar imagem adversarial
165
- pil_img_adv = tensor_to_pil(adv_images_denorm[0], denormalize=False)
166
- self.iteration_images.append(pil_img_adv)
167
- self.iteration_tensors.append(adv_images.clone().detach())
 
168
 
169
  # Capturar atenções para imagem adversarial final
170
  outputs_adv, attentions1 = capture_outputs_and_attentions(self.model, adv_images)
@@ -177,9 +189,10 @@ class PGDIterations(torchattacks.PGD):
177
  Extensão do ataque PGD padrão que captura e retorna
178
  as imagens adversariais de cada iteração como lista de PIL Images.
179
  """
180
- def __init__(self, model, eps=0.05, alpha=0.005, steps=10, random_start=True):
181
  # Inicializa PGD padrão com os parâmetros
182
  super().__init__(model, eps=eps, alpha=alpha, steps=steps, random_start=random_start)
 
183
  self.iteration_images: List[Image.Image] = []
184
  self.iteration_tensors: List[torch.Tensor] = []
185
  self.attentions_per_iter: List[List[torch.Tensor]] = []
@@ -220,9 +233,10 @@ class PGDIterations(torchattacks.PGD):
220
  self.attentions_per_iter = []
221
 
222
  # Salvar iteração 0 (imagem original)
223
- pil_img_orig = tensor_to_pil(images_denorm[0], denormalize=False)
224
- self.iteration_images.append(pil_img_orig)
225
- self.iteration_tensors.append(images.clone().detach())
 
226
  # Atenções da imagem original
227
  outputs0, attentions0 = capture_outputs_and_attentions(self.model, images)
228
  self.attentions_per_iter.append([att for att in attentions0])
@@ -232,6 +246,8 @@ class PGDIterations(torchattacks.PGD):
232
  adv_images = (adv_images_denorm - mean) / std
233
  adv_images.requires_grad = True
234
  outputs, attentions = capture_outputs_and_attentions(self.model, adv_images)
 
 
235
 
236
  # Calculate loss
237
  if self.targeted:
@@ -253,9 +269,10 @@ class PGDIterations(torchattacks.PGD):
253
  adv_images_normalized = (adv_images_denorm - mean) / std
254
 
255
  # Capturar imagem e tensor desta iteração
256
- pil_img = tensor_to_pil(adv_images_denorm[0], denormalize=False)
257
- self.iteration_images.append(pil_img)
258
- self.iteration_tensors.append(adv_images_normalized.clone().detach())
 
259
  # Atenções desta iteração
260
  self.attentions_per_iter.append([att for att in attentions])
261
 
@@ -277,7 +294,8 @@ class SAGA(torch.nn.Module):
277
 
278
  def __init__(self, model, eps=8/255, steps=10, discard_ratio: float = 0.0,
279
  head_fusion: str = "mean", use_resnet: bool = False,
280
- cnn_checkpoint_path: str = "resnet.pth", vit_weight=0.5):
 
281
  """Implementação correta do SAGA baseada no código original (SelfAttentionGradientAttack).
282
 
283
  Parâmetros:
@@ -288,9 +306,11 @@ class SAGA(torch.nn.Module):
288
  - head_fusion: estratégia de fusão de heads ('mean','max','min')
289
  - use_resnet: se True, acumula gradiente de um backbone CNN externo e o mistura ao gradiente ponderado pela atenção
290
  - cnn_checkpoint_path: caminho padrão do backbone CNN auxiliar (será carregado sob demanda)
 
291
  """
292
  super().__init__()
293
  self.model = model
 
294
  self.eps = eps
295
  self.steps = steps
296
  self.eps_step = self.eps / max(1, steps)
@@ -367,45 +387,6 @@ class SAGA(torch.nn.Module):
367
  # e serão passadas externamente; mantida para compatibilidade se necessário.
368
  raise RuntimeError("_attention_map should not be called directly; use integrated forward attention capture.")
369
 
370
- def _capture_outputs_and_attentions(self, x_norm: torch.Tensor):
371
- """Executa um forward único capturando atenções via hooks nas camadas de atenção do ViT.
372
- Retorna (outputs, attentions_list) onde attentions_list é lista de tensores [B,H,T,T] por camada.
373
- """
374
- attentions: List[torch.Tensor] = []
375
-
376
- def make_attention_hook():
377
- def hook(module, input, output):
378
- # input[0] é o embedding antes de atenção (B, N, C)
379
- x = input[0]
380
- B, N, C = x.shape
381
- if not (hasattr(module, 'qkv') and hasattr(module, 'num_heads')):
382
- return
383
- qkv = module.qkv(x).reshape(B, N, 3, module.num_heads, C // module.num_heads).permute(2, 0, 3, 1, 4)
384
- q, k, v = qkv.unbind(0)
385
- scale = (C // module.num_heads) ** -0.5
386
- attn = (q @ k.transpose(-2, -1)) * scale
387
- attn = attn.softmax(dim=-1)
388
- attentions.append(attn.detach())
389
- return hook
390
-
391
- hooks = []
392
- if not hasattr(self.model, 'blocks'):
393
- outputs = self.model(x_norm)
394
- return outputs, []
395
- for block in self.model.blocks:
396
- if hasattr(block, 'attn'):
397
- hooks.append(block.attn.register_forward_hook(make_attention_hook()))
398
-
399
- self.model.eval()
400
- outputs = self.model(x_norm)
401
-
402
- for h in hooks:
403
- h.remove()
404
-
405
- # mover atenções para CPU para cache leve
406
- attentions = [a.cpu() for a in attentions]
407
- return outputs, attentions
408
-
409
  def _load_cnn_backbone(self) -> Optional[torch.nn.Module]:
410
  """Carrega (lazy) o backbone CNN auxiliar usado quando use_resnet=True."""
411
  if not self.use_resnet:
@@ -518,15 +499,14 @@ class SAGA(torch.nn.Module):
518
  self.attentions_per_iter = []
519
 
520
  # Iteração 0 (imagem original)
521
- self.iteration_images.append(tensor_to_pil(images_denorm[0], denormalize=False))
522
- self.iteration_tensors.append(images.clone().detach())
 
523
  # Atenção da imagem original: captura integrada
524
- outputs0, attentions0 = self._capture_outputs_and_attentions(images)
525
  # Guardar atenções brutas
526
  self.attentions_per_iter.append([att for att in attentions0])
527
  # Gerar máscara de rollout para cache visual
528
- from utils.visualization import attention_rollout
529
- import cv2
530
  b, _, h, w = images.shape
531
  mask0 = attention_rollout(attentions0, discard_ratio=self.discard_ratio, head_fusion=self.head_fusion)
532
  mask0_resized = cv2.resize(mask0, (w, h))
@@ -536,7 +516,7 @@ class SAGA(torch.nn.Module):
536
  # Normalizar para forward
537
  adv_norm = (adv_denorm - mean) / std
538
  adv_norm.requires_grad = True
539
- outputs, attentions = self._capture_outputs_and_attentions(adv_norm)
540
  if isinstance(outputs, tuple): # compatibilidade com modelos que retornam extras
541
  outputs = outputs[0]
542
  loss = self.loss_fn(outputs, labels)
@@ -572,8 +552,9 @@ class SAGA(torch.nn.Module):
572
  adv_denorm = torch.clamp(images_denorm + delta, 0.0, 1.0).detach()
573
 
574
  # Salvar artefatos desta iteração
575
- self.iteration_images.append(tensor_to_pil(adv_denorm[0], denormalize=False))
576
- self.iteration_tensors.append(((adv_denorm - mean) / std).clone().detach())
 
577
 
578
  # Retorna tensor normalizado final
579
  adv_final = (adv_denorm - mean) / std
@@ -708,8 +689,9 @@ class MIFGSM(torchattacks.MIFGSM):
708
  Paper: "Boosting Adversarial Attacks with Momentum" (2017)
709
  https://arxiv.org/abs/1710.06081
710
  """
711
- def __init__(self, model, eps=8/255, alpha=2/255, steps=10, decay=1.0):
712
  super().__init__(model, eps=eps, alpha=alpha, steps=steps, decay=decay)
 
713
  self.iteration_images: List[Image.Image] = []
714
  self.iteration_tensors: List[torch.Tensor] = []
715
  self.attentions_per_iter: List[List[torch.Tensor]] = []
@@ -744,9 +726,10 @@ class MIFGSM(torchattacks.MIFGSM):
744
  self.attentions_per_iter = []
745
 
746
  # Salvar imagem original (iteração 0)
747
- pil_img_orig = tensor_to_pil(images_denorm[0], denormalize=False)
748
- self.iteration_images.append(pil_img_orig)
749
- self.iteration_tensors.append(images.clone().detach())
 
750
 
751
  # Atenções da imagem original
752
  outputs0, attentions0 = capture_outputs_and_attentions(self.model, images)
@@ -757,6 +740,8 @@ class MIFGSM(torchattacks.MIFGSM):
757
  adv_images = (adv_images_denorm - mean) / std
758
  adv_images.requires_grad = True
759
  outputs, attentions = capture_outputs_and_attentions(self.model, adv_images)
 
 
760
 
761
  # Calcular loss
762
  if self.targeted:
@@ -786,9 +771,10 @@ class MIFGSM(torchattacks.MIFGSM):
786
 
787
  # Normalizar e armazenar artefatos desta iteração
788
  adv_images_normalized = (adv_images_denorm - mean) / std
789
- self.iteration_tensors.append(adv_images_normalized.clone().detach())
790
- pil_iter = tensor_to_pil(adv_images_denorm[0], denormalize=False)
791
- self.iteration_images.append(pil_iter)
 
792
 
793
  adv_images = (adv_images_denorm - mean) / std
794
 
@@ -827,9 +813,11 @@ class TGR(torch.nn.Module):
827
  debug_stats: bool = False,
828
  protect_cls_token: bool = True,
829
  debug_progress: bool = False,
 
830
  ) -> None:
831
  super().__init__()
832
  self.model = model
 
833
  self.eps = float(eps)
834
  self.steps = int(steps)
835
  self.decay = float(decay)
@@ -1196,8 +1184,9 @@ class TGR(torch.nn.Module):
1196
  self.debug_progress_log = []
1197
 
1198
  # Iteração 0 (imagem original)
1199
- self.iteration_images.append(tensor_to_pil(images_denorm[0], denormalize=False))
1200
- self.iteration_tensors.append(images.clone().detach())
 
1201
 
1202
  # Garantir eval mode (evita dropout/ruído durante ataque)
1203
  was_training = self.model.training
@@ -1317,9 +1306,10 @@ class TGR(torch.nn.Module):
1317
  print(f"[TGR DEBUG] Iteration delta: {actual_delta:.6f} (eps={self.eps:.6f}, eps_step={self.eps_step:.6f})")
1318
 
1319
  # Salvar artefatos desta iteração
1320
- adv_denorm = torch.clamp(unnorm_inps + perts, 0.0, 1.0).detach()
1321
- self.iteration_images.append(tensor_to_pil(adv_denorm[0], denormalize=False))
1322
- self.iteration_tensors.append(((adv_denorm - mean) / std).clone().detach())
 
1323
 
1324
  finally:
1325
  for h in handles:
 
23
  except Exception: # pragma: no cover - optional dependency
24
  hf_hub_download = None
25
 
26
+ try:
27
+ import cv2
28
+ except Exception: # pragma: no cover - optional for SAGA rollout resizing
29
+ cv2 = None
30
+
31
+ from utils.visualization import attention_rollout
32
+
33
  def capture_outputs_and_attentions(model, x_norm: torch.Tensor):
34
  """Executa um forward único capturando atenções via hooks nas camadas de atenção do ViT.
35
  Retorna (outputs, attentions_list) onde attentions_list é lista de tensores [B,H,T,T] por camada.
 
113
 
114
  FGSM é um ataque de 1 única iteração (non-iterative).
115
  """
116
+ def __init__(self, model, eps=0.03, collect_images: bool = True):
117
  super().__init__(model, eps=eps)
118
+ self.collect_images = collect_images
119
  self.iteration_images: List[Image.Image] = []
120
  self.iteration_tensors: List[torch.Tensor] = []
121
  # Atenções por iteração (iteração 0: original, iteração 1: adversarial)
 
143
  self.attentions_per_iter = []
144
 
145
  # Salvar imagem original
146
+ if self.collect_images:
147
+ pil_img_orig = tensor_to_pil(images_denorm[0], denormalize=False)
148
+ self.iteration_images.append(pil_img_orig)
149
+ self.iteration_tensors.append(images.clone().detach())
150
 
151
  # Calcular gradiente
152
  images.requires_grad = True
153
  # Capturar atenções e logits para imagem original
154
  outputs, attentions0 = capture_outputs_and_attentions(self.model, images)
155
+ if isinstance(outputs, tuple):
156
+ outputs = outputs[0]
157
  self.attentions_per_iter.append([att for att in attentions0])
158
 
159
  if self.targeted:
 
173
  adv_images = (adv_images_denorm - mean) / std
174
 
175
  # Salvar imagem adversarial
176
+ if self.collect_images:
177
+ pil_img_adv = tensor_to_pil(adv_images_denorm[0], denormalize=False)
178
+ self.iteration_images.append(pil_img_adv)
179
+ self.iteration_tensors.append(adv_images.clone().detach())
180
 
181
  # Capturar atenções para imagem adversarial final
182
  outputs_adv, attentions1 = capture_outputs_and_attentions(self.model, adv_images)
 
189
  Extensão do ataque PGD padrão que captura e retorna
190
  as imagens adversariais de cada iteração como lista de PIL Images.
191
  """
192
+ def __init__(self, model, eps=0.05, alpha=0.005, steps=10, random_start=True, collect_images: bool = True):
193
  # Inicializa PGD padrão com os parâmetros
194
  super().__init__(model, eps=eps, alpha=alpha, steps=steps, random_start=random_start)
195
+ self.collect_images = collect_images
196
  self.iteration_images: List[Image.Image] = []
197
  self.iteration_tensors: List[torch.Tensor] = []
198
  self.attentions_per_iter: List[List[torch.Tensor]] = []
 
233
  self.attentions_per_iter = []
234
 
235
  # Salvar iteração 0 (imagem original)
236
+ if self.collect_images:
237
+ pil_img_orig = tensor_to_pil(images_denorm[0], denormalize=False)
238
+ self.iteration_images.append(pil_img_orig)
239
+ self.iteration_tensors.append(images.clone().detach())
240
  # Atenções da imagem original
241
  outputs0, attentions0 = capture_outputs_and_attentions(self.model, images)
242
  self.attentions_per_iter.append([att for att in attentions0])
 
246
  adv_images = (adv_images_denorm - mean) / std
247
  adv_images.requires_grad = True
248
  outputs, attentions = capture_outputs_and_attentions(self.model, adv_images)
249
+ if isinstance(outputs, tuple):
250
+ outputs = outputs[0]
251
 
252
  # Calculate loss
253
  if self.targeted:
 
269
  adv_images_normalized = (adv_images_denorm - mean) / std
270
 
271
  # Capturar imagem e tensor desta iteração
272
+ if self.collect_images:
273
+ pil_img = tensor_to_pil(adv_images_denorm[0], denormalize=False)
274
+ self.iteration_images.append(pil_img)
275
+ self.iteration_tensors.append(adv_images_normalized.clone().detach())
276
  # Atenções desta iteração
277
  self.attentions_per_iter.append([att for att in attentions])
278
 
 
294
 
295
  def __init__(self, model, eps=8/255, steps=10, discard_ratio: float = 0.0,
296
  head_fusion: str = "mean", use_resnet: bool = False,
297
+ cnn_checkpoint_path: str = "resnet.pth", vit_weight=0.5,
298
+ collect_images: bool = True):
299
  """Implementação correta do SAGA baseada no código original (SelfAttentionGradientAttack).
300
 
301
  Parâmetros:
 
306
  - head_fusion: estratégia de fusão de heads ('mean','max','min')
307
  - use_resnet: se True, acumula gradiente de um backbone CNN externo e o mistura ao gradiente ponderado pela atenção
308
  - cnn_checkpoint_path: caminho padrão do backbone CNN auxiliar (será carregado sob demanda)
309
+ - collect_images: se True, salva imagens PIL e tensores intermediários (necessário para UI)
310
  """
311
  super().__init__()
312
  self.model = model
313
+ self.collect_images = collect_images
314
  self.eps = eps
315
  self.steps = steps
316
  self.eps_step = self.eps / max(1, steps)
 
387
  # e serão passadas externamente; mantida para compatibilidade se necessário.
388
  raise RuntimeError("_attention_map should not be called directly; use integrated forward attention capture.")
389
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
390
  def _load_cnn_backbone(self) -> Optional[torch.nn.Module]:
391
  """Carrega (lazy) o backbone CNN auxiliar usado quando use_resnet=True."""
392
  if not self.use_resnet:
 
499
  self.attentions_per_iter = []
500
 
501
  # Iteração 0 (imagem original)
502
+ if self.collect_images:
503
+ self.iteration_images.append(tensor_to_pil(images_denorm[0], denormalize=False))
504
+ self.iteration_tensors.append(images.clone().detach())
505
  # Atenção da imagem original: captura integrada
506
+ outputs0, attentions0 = capture_outputs_and_attentions(self.model, images)
507
  # Guardar atenções brutas
508
  self.attentions_per_iter.append([att for att in attentions0])
509
  # Gerar máscara de rollout para cache visual
 
 
510
  b, _, h, w = images.shape
511
  mask0 = attention_rollout(attentions0, discard_ratio=self.discard_ratio, head_fusion=self.head_fusion)
512
  mask0_resized = cv2.resize(mask0, (w, h))
 
516
  # Normalizar para forward
517
  adv_norm = (adv_denorm - mean) / std
518
  adv_norm.requires_grad = True
519
+ outputs, attentions = capture_outputs_and_attentions(self.model, adv_norm)
520
  if isinstance(outputs, tuple): # compatibilidade com modelos que retornam extras
521
  outputs = outputs[0]
522
  loss = self.loss_fn(outputs, labels)
 
552
  adv_denorm = torch.clamp(images_denorm + delta, 0.0, 1.0).detach()
553
 
554
  # Salvar artefatos desta iteração
555
+ if self.collect_images:
556
+ self.iteration_images.append(tensor_to_pil(adv_denorm[0], denormalize=False))
557
+ self.iteration_tensors.append(((adv_denorm - mean) / std).clone().detach())
558
 
559
  # Retorna tensor normalizado final
560
  adv_final = (adv_denorm - mean) / std
 
689
  Paper: "Boosting Adversarial Attacks with Momentum" (2017)
690
  https://arxiv.org/abs/1710.06081
691
  """
692
+ def __init__(self, model, eps=8/255, alpha=2/255, steps=10, decay=1.0, collect_images: bool = True):
693
  super().__init__(model, eps=eps, alpha=alpha, steps=steps, decay=decay)
694
+ self.collect_images = collect_images
695
  self.iteration_images: List[Image.Image] = []
696
  self.iteration_tensors: List[torch.Tensor] = []
697
  self.attentions_per_iter: List[List[torch.Tensor]] = []
 
726
  self.attentions_per_iter = []
727
 
728
  # Salvar imagem original (iteração 0)
729
+ if self.collect_images:
730
+ pil_img_orig = tensor_to_pil(images_denorm[0], denormalize=False)
731
+ self.iteration_images.append(pil_img_orig)
732
+ self.iteration_tensors.append(images.clone().detach())
733
 
734
  # Atenções da imagem original
735
  outputs0, attentions0 = capture_outputs_and_attentions(self.model, images)
 
740
  adv_images = (adv_images_denorm - mean) / std
741
  adv_images.requires_grad = True
742
  outputs, attentions = capture_outputs_and_attentions(self.model, adv_images)
743
+ if isinstance(outputs, tuple):
744
+ outputs = outputs[0]
745
 
746
  # Calcular loss
747
  if self.targeted:
 
771
 
772
  # Normalizar e armazenar artefatos desta iteração
773
  adv_images_normalized = (adv_images_denorm - mean) / std
774
+ if self.collect_images:
775
+ self.iteration_tensors.append(adv_images_normalized.clone().detach())
776
+ pil_iter = tensor_to_pil(adv_images_denorm[0], denormalize=False)
777
+ self.iteration_images.append(pil_iter)
778
 
779
  adv_images = (adv_images_denorm - mean) / std
780
 
 
813
  debug_stats: bool = False,
814
  protect_cls_token: bool = True,
815
  debug_progress: bool = False,
816
+ collect_images: bool = True,
817
  ) -> None:
818
  super().__init__()
819
  self.model = model
820
+ self.collect_images = collect_images
821
  self.eps = float(eps)
822
  self.steps = int(steps)
823
  self.decay = float(decay)
 
1184
  self.debug_progress_log = []
1185
 
1186
  # Iteração 0 (imagem original)
1187
+ if self.collect_images:
1188
+ self.iteration_images.append(tensor_to_pil(images_denorm[0], denormalize=False))
1189
+ self.iteration_tensors.append(images.clone().detach())
1190
 
1191
  # Garantir eval mode (evita dropout/ruído durante ataque)
1192
  was_training = self.model.training
 
1306
  print(f"[TGR DEBUG] Iteration delta: {actual_delta:.6f} (eps={self.eps:.6f}, eps_step={self.eps_step:.6f})")
1307
 
1308
  # Salvar artefatos desta iteração
1309
+ if self.collect_images:
1310
+ adv_denorm = torch.clamp(unnorm_inps + perts, 0.0, 1.0).detach()
1311
+ self.iteration_images.append(tensor_to_pil(adv_denorm[0], denormalize=False))
1312
+ self.iteration_tensors.append(((adv_denorm - mean) / std).clone().detach())
1313
 
1314
  finally:
1315
  for h in handles:
utils/metrics.py ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Evaluation metrics for adversarial attack analysis.
2
+
3
+ All functions accept pure tensors and return Python floats.
4
+ No UI or Gradio dependency.
5
+
6
+ Metric groups:
7
+ - Image quality: SSIM, PSNR, LPIPS, L2, L-inf
8
+ - Attack success: ASR, confidence drop, top-k accuracy drop
9
+
10
+ Usage:
11
+ from utils.metrics import compute_all_image_metrics, compute_all_attack_metrics
12
+ """
13
+
14
+ import math
15
+ from typing import Dict, Optional, Tuple
16
+
17
+ import numpy as np
18
+ import torch
19
+ import torch.nn.functional as F
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # ImageNet denormalization (shared constant)
24
+ # ---------------------------------------------------------------------------
25
+ IMAGENET_MEAN = torch.tensor([0.485, 0.456, 0.406]).view(1, 3, 1, 1)
26
+ IMAGENET_STD = torch.tensor([0.229, 0.224, 0.225]).view(1, 3, 1, 1)
27
+
28
+
29
+ def _to_01(x: torch.Tensor) -> torch.Tensor:
30
+ """Denormalize ImageNet tensor to [0, 1] range.
31
+
32
+ If already in [0,1] (min>=0, max<=1) returns as-is.
33
+ """
34
+ if x.min() < -0.1: # heuristic: normalized images have negative values
35
+ mean = IMAGENET_MEAN.to(x.device)
36
+ std = IMAGENET_STD.to(x.device)
37
+ x = x * std + mean
38
+ return x.clamp(0, 1)
39
+
40
+
41
+ # ═══════════════════════════════════════════════════════════════════════════
42
+ # Image Quality Metrics
43
+ # ═══════════════════════════════════════════════════════════════════════════
44
+
45
+ def compute_linf(orig: torch.Tensor, adv: torch.Tensor) -> float:
46
+ """L-infinity distance in [0,1] space.
47
+
48
+ Args:
49
+ orig: Original image tensor (BxCxHxW or CxHxW), normalized or [0,1].
50
+ adv: Adversarial image tensor, same shape.
51
+
52
+ Returns:
53
+ Maximum absolute pixel difference in [0,1] space.
54
+ """
55
+ o = _to_01(orig)
56
+ a = _to_01(adv)
57
+ return (o - a).abs().max().item()
58
+
59
+
60
+ def compute_psnr(orig: torch.Tensor, adv: torch.Tensor) -> float:
61
+ """Peak Signal-to-Noise Ratio (dB) in [0,1] space.
62
+
63
+ Higher = less distortion. Returns inf if images are identical.
64
+ """
65
+ o = _to_01(orig)
66
+ a = _to_01(adv)
67
+ mse = F.mse_loss(o, a).item()
68
+ if mse < 1e-10:
69
+ return float("inf")
70
+ return 10.0 * math.log10(1.0 / mse)
71
+
72
+
73
+ def compute_ssim(orig: torch.Tensor, adv: torch.Tensor) -> float:
74
+ """Structural Similarity Index (SSIM).
75
+
76
+ Requires pytorch-msssim. Returns value in [-1, 1], higher = more similar.
77
+ """
78
+ try:
79
+ from pytorch_msssim import ssim
80
+ except ImportError:
81
+ raise ImportError(
82
+ "pytorch-msssim is required for SSIM. "
83
+ "Install: pip install pytorch-msssim"
84
+ )
85
+ o = _to_01(orig)
86
+ a = _to_01(adv)
87
+ # Ensure 4D (BxCxHxW)
88
+ if o.dim() == 3:
89
+ o = o.unsqueeze(0)
90
+ if a.dim() == 3:
91
+ a = a.unsqueeze(0)
92
+ return ssim(o, a, data_range=1.0, size_average=True).item()
93
+
94
+
95
+ def compute_lpips(orig: torch.Tensor, adv: torch.Tensor,
96
+ net: str = "alex",
97
+ _lpips_cache: dict = {}) -> float:
98
+ """Learned Perceptual Image Patch Similarity (LPIPS).
99
+
100
+ Lower = more similar. Uses AlexNet by default (fastest).
101
+ The LPIPS model is cached across calls.
102
+
103
+ Args:
104
+ orig: Original image tensor.
105
+ adv: Adversarial image tensor.
106
+ net: Backbone network ('alex', 'vgg', 'squeeze').
107
+
108
+ Returns:
109
+ LPIPS distance (lower = more perceptually similar).
110
+ """
111
+ try:
112
+ import lpips as lpips_lib
113
+ except ImportError:
114
+ raise ImportError(
115
+ "lpips is required for LPIPS metric. "
116
+ "Install: pip install lpips"
117
+ )
118
+
119
+ cache_key = net
120
+ if cache_key not in _lpips_cache:
121
+ _lpips_cache[cache_key] = lpips_lib.LPIPS(net=net, verbose=False)
122
+
123
+ loss_fn = _lpips_cache[cache_key]
124
+
125
+ o = _to_01(orig)
126
+ a = _to_01(adv)
127
+
128
+ # LPIPS expects [-1, 1] range
129
+ o_scaled = o * 2.0 - 1.0
130
+ a_scaled = a * 2.0 - 1.0
131
+
132
+ if o_scaled.dim() == 3:
133
+ o_scaled = o_scaled.unsqueeze(0)
134
+ if a_scaled.dim() == 3:
135
+ a_scaled = a_scaled.unsqueeze(0)
136
+
137
+ device = o_scaled.device
138
+ loss_fn = loss_fn.to(device)
139
+
140
+ with torch.no_grad():
141
+ dist = loss_fn(o_scaled, a_scaled)
142
+
143
+ return dist.item()
144
+
145
+
146
+ def compute_modified_pixels(orig: torch.Tensor, adv: torch.Tensor,
147
+ threshold: float = 1e-5) -> float:
148
+ """Fraction of pixels modified by the attack (0 to 1)."""
149
+ o = _to_01(orig)
150
+ a = _to_01(adv)
151
+ diff = (o - a).abs().max(dim=-3)[0] # max over channels
152
+ return (diff > threshold).float().mean().item()
153
+
154
+
155
+ # ═══════════════════════════════════════════════════════════════════════════
156
+ # Attack Success Metrics
157
+ # ═══════════════════════════════════════════════════════════════════════════
158
+
159
+ def compute_asr(orig_pred: int, adv_pred: int) -> float:
160
+ """Attack Success Rate for a single sample.
161
+
162
+ Returns 1.0 if attack changed the prediction, 0.0 otherwise.
163
+ For batch ASR, average across samples.
164
+ """
165
+ return 1.0 if orig_pred != adv_pred else 0.0
166
+
167
+
168
+ def compute_confidence_drop(orig_probs: torch.Tensor,
169
+ adv_probs: torch.Tensor,
170
+ orig_label: int) -> float:
171
+ """Confidence drop on the original class.
172
+
173
+ Args:
174
+ orig_probs: Softmax probabilities for original image (1D tensor).
175
+ adv_probs: Softmax probabilities for adversarial image (1D tensor).
176
+ orig_label: Original predicted class index.
177
+
178
+ Returns:
179
+ Drop in confidence (positive = confidence decreased).
180
+ """
181
+ return (orig_probs[orig_label] - adv_probs[orig_label]).item()
182
+
183
+
184
+ def compute_topk_drop(orig_probs: torch.Tensor,
185
+ adv_probs: torch.Tensor,
186
+ orig_label: int,
187
+ k: int = 5) -> float:
188
+ """Top-k accuracy drop.
189
+
190
+ Checks if original class remains in top-k predictions after attack.
191
+ Returns 1.0 if original class dropped out of top-k, 0.0 if still in.
192
+ """
193
+ _, adv_topk = torch.topk(adv_probs, min(k, len(adv_probs)))
194
+ return 0.0 if orig_label in adv_topk.tolist() else 1.0
195
+
196
+
197
+
198
+
199
+ # ═══════════════════════════════════════════════════════════════════════════
200
+ # Aggregate Helper
201
+ # ═══════════════════════════════════════════════════════════════════════════
202
+
203
+ def compute_all_image_metrics(
204
+ orig: torch.Tensor,
205
+ adv: torch.Tensor,
206
+ use_lpips: bool = True,
207
+ ) -> Dict[str, float]:
208
+ """Compute all image quality metrics at once.
209
+
210
+ Args:
211
+ orig: Original image tensor (BxCxHxW or CxHxW).
212
+ adv: Adversarial image tensor.
213
+ use_lpips: Whether to compute LPIPS (requires lpips package).
214
+
215
+ Returns:
216
+ Dictionary with keys: linf, psnr, ssim, lpips, modified_pixels.
217
+ """
218
+ results = {
219
+ "linf": compute_linf(orig, adv),
220
+ "psnr": compute_psnr(orig, adv),
221
+ "ssim": compute_ssim(orig, adv),
222
+ "modified_pixels": compute_modified_pixels(orig, adv),
223
+ }
224
+
225
+ if use_lpips:
226
+ try:
227
+ results["lpips"] = compute_lpips(orig, adv)
228
+ except ImportError:
229
+ results["lpips"] = float("nan")
230
+ else:
231
+ results["lpips"] = float("nan")
232
+
233
+ return results
234
+
235
+
236
+ def compute_all_attack_metrics(
237
+ orig_probs: torch.Tensor,
238
+ adv_probs: torch.Tensor,
239
+ orig_label: int,
240
+ adv_label: int,
241
+ k: int = 5,
242
+ ) -> Dict[str, float]:
243
+ """Compute all attack success metrics at once.
244
+
245
+ Returns:
246
+ Dictionary with keys: asr, confidence_drop, topk_drop.
247
+ """
248
+ results = {
249
+ "asr": compute_asr(orig_label, adv_label),
250
+ "confidence_drop": compute_confidence_drop(orig_probs, adv_probs, orig_label),
251
+ "topk_drop": compute_topk_drop(orig_probs, adv_probs, orig_label, k=k),
252
+ }
253
+
254
+ return results
255
+
utils/model_loader.py CHANGED
@@ -338,6 +338,26 @@ def _convert_hf_vit_to_timm_state_dict(hf_sd: Dict[str, torch.Tensor], num_layer
338
  return out
339
 
340
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
341
  def load_vit_from_huggingface(model_id: str, device: Optional[torch.device] = None) -> Tuple[torch.nn.Module, Optional[Dict[int, str]], ViTConfig]:
342
  """Carrega ViT do Hugging Face Hub e retorna um modelo timm equivalente.
343
 
@@ -351,37 +371,27 @@ def load_vit_from_huggingface(model_id: str, device: Optional[torch.device] = No
351
  hf_model = AutoModelForImageClassification.from_pretrained(model_id)
352
  hf_model.eval()
353
  cfg = getattr(hf_model, "config", None)
354
- num_labels = int(getattr(cfg, "num_labels", 1000)) if cfg is not None else 1000
355
- num_layers = int(getattr(cfg, "num_hidden_layers", 12)) if cfg is not None else 12
356
- hidden_size = int(getattr(cfg, "hidden_size", 768)) if cfg is not None else 768
357
- num_heads = int(getattr(cfg, "num_attention_heads", 12)) if cfg is not None else 12
358
- patch_size = int(getattr(cfg, "patch_size", 16)) if cfg is not None else 16
359
- img_size = int(getattr(cfg, "image_size", 224)) if cfg is not None else 224
360
- intermediate_size = int(getattr(cfg, "intermediate_size", hidden_size * 4)) if cfg is not None else hidden_size * 4
361
- qkv_bias = bool(getattr(cfg, "qkv_bias", True)) if cfg is not None else True
362
  class_names = _hf_id2label_to_class_names(getattr(cfg, "id2label", None)) if cfg is not None else None
363
 
364
- # Criar config dinâmico
365
- vit_config = ViTConfig(
366
- embed_dim=hidden_size,
367
- num_heads=num_heads,
368
- num_layers=num_layers,
369
- patch_size=patch_size,
370
- img_size=img_size,
371
- num_classes=num_labels,
372
- mlp_ratio=intermediate_size / hidden_size,
373
- qkv_bias=qkv_bias
374
- )
375
-
 
 
376
  print(f"[ViTViz] Carregando do HuggingFace: {vit_config.timm_model_name} "
377
  f"(embed_dim={vit_config.embed_dim}, heads={vit_config.num_heads}, "
378
- f"layers={vit_config.num_layers})")
379
-
380
- # Criar modelo com arquitetura customizada diretamente
381
  timm_model = create_vit_from_config(vit_config, device=device)
382
-
383
- # Converter e carregar state_dict
384
- timm_sd = _convert_hf_vit_to_timm_state_dict(hf_model.state_dict(), num_layers=num_layers)
385
  timm_model.load_state_dict(timm_sd, strict=False)
386
  timm_model.eval()
387
 
 
338
  return out
339
 
340
 
341
+ def _convert_hf_timm_wrapper_to_timm_state_dict(hf_sd: Dict[str, torch.Tensor]) -> Dict[str, torch.Tensor]:
342
+ """Converte state_dict de TimmWrapper (Transformers) para formato timm ViT.
343
+
344
+ Exemplo de origem: chaves com prefixo ``timm_model.``.
345
+ """
346
+ out: Dict[str, torch.Tensor] = {}
347
+
348
+ for key, value in hf_sd.items():
349
+ if key.startswith("timm_model."):
350
+ out[key[len("timm_model."):]] = value
351
+ elif key.startswith("classifier."):
352
+ # Alguns wrappers usam head separado como classifier.
353
+ out[f"head.{key[len('classifier.'):]}"] = value
354
+
355
+ if not out:
356
+ raise ValueError("State_dict de TimmWrapper sem chaves reconhecidas (timm_model.* / classifier.*).")
357
+
358
+ return out
359
+
360
+
361
  def load_vit_from_huggingface(model_id: str, device: Optional[torch.device] = None) -> Tuple[torch.nn.Module, Optional[Dict[int, str]], ViTConfig]:
362
  """Carrega ViT do Hugging Face Hub e retorna um modelo timm equivalente.
363
 
 
371
  hf_model = AutoModelForImageClassification.from_pretrained(model_id)
372
  hf_model.eval()
373
  cfg = getattr(hf_model, "config", None)
 
 
 
 
 
 
 
 
374
  class_names = _hf_id2label_to_class_names(getattr(cfg, "id2label", None)) if cfg is not None else None
375
 
376
+ hf_sd = hf_model.state_dict()
377
+ if any(key.startswith("timm_model.") for key in hf_sd.keys()):
378
+ timm_sd = _convert_hf_timm_wrapper_to_timm_state_dict(hf_sd)
379
+ else:
380
+ num_layers = int(getattr(cfg, "num_hidden_layers", 12)) if cfg is not None else 12
381
+ timm_sd = _convert_hf_vit_to_timm_state_dict(hf_sd, num_layers=num_layers)
382
+
383
+ vit_config = infer_config_from_state_dict(timm_sd)
384
+ if cfg is not None and hasattr(cfg, "num_labels"):
385
+ try:
386
+ vit_config.num_classes = int(getattr(cfg, "num_labels"))
387
+ except Exception:
388
+ pass
389
+
390
  print(f"[ViTViz] Carregando do HuggingFace: {vit_config.timm_model_name} "
391
  f"(embed_dim={vit_config.embed_dim}, heads={vit_config.num_heads}, "
392
+ f"layers={vit_config.num_layers}, patch={vit_config.patch_size}, img={vit_config.img_size})")
393
+
 
394
  timm_model = create_vit_from_config(vit_config, device=device)
 
 
 
395
  timm_model.load_state_dict(timm_sd, strict=False)
396
  timm_model.eval()
397
 
utils/seed.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Seed management for reproducible experiments.
2
+
3
+ Usage:
4
+ from utils.seed import set_seed
5
+ set_seed(42)
6
+ """
7
+
8
+ import os
9
+ import random
10
+
11
+ import numpy as np
12
+ import torch
13
+
14
+
15
+ def set_seed(seed: int = 42) -> None:
16
+ """Set all random seeds for reproducibility.
17
+
18
+ Fixes: random, numpy, torch (CPU + CUDA), cuDNN determinism,
19
+ and Python hash seed.
20
+
21
+ Note: even with all seeds set, GPU non-determinism may cause
22
+ small numerical differences across runs. See:
23
+ https://pytorch.org/docs/stable/notes/randomness.html
24
+
25
+ Args:
26
+ seed: Integer seed value.
27
+ """
28
+ random.seed(seed)
29
+ np.random.seed(seed)
30
+ torch.manual_seed(seed)
31
+ os.environ["PYTHONHASHSEED"] = str(seed)
32
+
33
+ if torch.cuda.is_available():
34
+ torch.cuda.manual_seed(seed)
35
+ torch.cuda.manual_seed_all(seed)
36
+ torch.backends.cudnn.deterministic = True
37
+ torch.backends.cudnn.benchmark = False