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 filesadd 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 +23 -0
- Makefile +35 -0
- Métricas para avaliação no ViT-Viz.txt +78 -0
- README.md +73 -4
- app.py +195 -21
- configs/default.yaml +101 -0
- configs/experiments/exp_all_attacks.yaml +25 -0
- configs/experiments/exp_quick_test.yaml +32 -0
- data/download_samples.py +107 -0
- data/sample_images/.gitkeep +3 -0
- data/sample_images/metadata.json +59 -0
- experiments/run_attack_sweep.py +398 -0
- experiments/run_attention_analysis.py +268 -0
- notebooks/01-metrics-analysis.ipynb +177 -0
- notebooks/02-attention-figures.ipynb +117 -0
- requirements.txt +10 -1
- utils/attacks.py +64 -74
- utils/metrics.py +255 -0
- utils/model_loader.py +36 -26
- utils/seed.py +37 -0
.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 |
|
| 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 |
-
##
|
| 39 |
|
| 40 |
```bash
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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 ·
|
| 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] #
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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, _,
|
| 307 |
adv_class = top_idx_adv[0].item()
|
| 308 |
adv_prob = top_prob_adv[0].item()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
|
| 310 |
-
# Calcular métricas
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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><
|
| 396 |
result += " <tbody>\n"
|
| 397 |
-
|
| 398 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 139 |
-
|
| 140 |
-
|
|
|
|
| 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 |
-
|
| 166 |
-
|
| 167 |
-
|
|
|
|
| 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 |
-
|
| 224 |
-
|
| 225 |
-
|
|
|
|
| 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 |
-
|
| 257 |
-
|
| 258 |
-
|
|
|
|
| 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.
|
| 522 |
-
|
|
|
|
| 523 |
# Atenção da imagem original: captura integrada
|
| 524 |
-
outputs0, attentions0 = self.
|
| 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.
|
| 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.
|
| 576 |
-
|
|
|
|
| 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 |
-
|
| 748 |
-
|
| 749 |
-
|
|
|
|
| 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.
|
| 790 |
-
|
| 791 |
-
|
|
|
|
| 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.
|
| 1200 |
-
|
|
|
|
| 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 |
-
|
| 1321 |
-
|
| 1322 |
-
|
|
|
|
| 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 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
num_layers=
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 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
|