"""SamplingTAR: Training-free Concept Localization for Typographic Attack Defense. Interactive demo of the training-free defense from "Towards Robustness against Typographic Attack with Training-free Concept Localization". The method mines attention heads responsible for reading text in images using randomly-initialised Sparse Autoencoders (SAEs), then ablates those heads at inference to recover the true object prediction. We use the HuggingFace transformers CLIP model (openai/clip-vit-large-patch14) for a stable, well-defined attention interface. """ import os os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True") import spaces # MUST come before torch / any CUDA-touching import import math import random from contextlib import contextmanager from functools import partial import numpy as np import torch import torch.nn as nn import torch.nn.functional as F import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import matplotlib.font_manager from PIL import Image, ImageDraw, ImageFont from torchvision import transforms import gradio as gr from transformers import CLIPModel, CLIPProcessor # --------------------------------------------------------------------------- # Model loading at module scope (ZeroGPU rule: load eagerly, .to("cuda")) # --------------------------------------------------------------------------- MODEL_ID = "openai/clip-vit-large-patch14" print(f"Loading CLIP model from {MODEL_ID}…") _clip_model = CLIPModel.from_pretrained(MODEL_ID, torch_dtype=torch.float32) _processor = CLIPProcessor.from_pretrained(MODEL_ID) _vision = _clip_model.vision_model _vision.eval() _vision = _vision.to("cuda") # The projection to the shared embedding space _visual_projection = _clip_model.visual_projection _visual_projection.eval() _visual_projection = _visual_projection.to("cuda") # Text encoder _text_model = _clip_model.text_model _text_projection = _clip_model.text_projection _text_model.eval() _text_projection.eval() _text_model = _text_model.to("cuda") _text_projection = _text_projection.to("cuda") # Architecture constants PATCH_SIZE = _vision.config.patch_size # 14 IMAGE_SIZE = _vision.config.image_size # 224 NUM_PATCHES = (IMAGE_SIZE // PATCH_SIZE) ** 2 # 256 NUM_HEADS = _vision.config.num_attention_heads # 16 HIDDEN_DIM = _vision.config.hidden_size # 1024 HEAD_DIM = HIDDEN_DIM // NUM_HEADS # 64 NUM_LAYERS = _vision.config.num_hidden_layers # 24 # The paper uses the top ~20% of layers for circuit mining. MINE_LAYERS = [x for x in range(NUM_LAYERS) if x >= NUM_LAYERS * 0.8] print(f"CLIP loaded. image_size={IMAGE_SIZE} patch={PATCH_SIZE} " f"patches={NUM_PATCHES} heads={NUM_HEADS} layers={NUM_LAYERS} " f"mine_layers={MINE_LAYERS}") # CLIP normalisation constants CLIP_MEAN = [0.48145466, 0.4578275, 0.40821073] CLIP_STD = [0.26862954, 0.26130258, 0.27577711] # --------------------------------------------------------------------------- # Minimal SAE (faithful to model_training/sae.py — only what circuit mining needs) # --------------------------------------------------------------------------- class TopKSAE(nn.Module): """Minimal TopK SAE — only the pieces circuit mining touches.""" def __init__(self, n_heads, head_dim, hidden_dim, k, device="cuda"): super().__init__() self.n_heads = n_heads self.head_dim = head_dim self.hidden_dim = hidden_dim self.k = k self.per_head_recon = False self.encoders = nn.ModuleList() for _ in range(n_heads): enc = nn.ModuleList([ nn.Linear(head_dim, hidden_dim // n_heads, bias=True), ]) self.encoders.append(enc) self.to(device) def build_random_sae_list(n_heads, head_dim, hidden_dim, k, layers, device): """Build per-layer randomly-initialised SAEs (as the repo does).""" sae_list = {} for layer in layers: sae = TopKSAE(n_heads, head_dim, hidden_dim, k, device=device) for i, encoder in enumerate(sae.encoders): encoder[0].weight.data = torch.randn_like(encoder[0].weight.data).to(device) encoder[0].bias.data = torch.randn_like(encoder[0].bias.data).to(device) encoder[0].weight.data = F.normalize(encoder[0].weight.data, dim=1, p=2) sae.per_head_recon = False sae_list[layer] = sae return sae_list # --------------------------------------------------------------------------- # Preprocessing # --------------------------------------------------------------------------- _preprocess = transforms.Compose([ transforms.Resize(IMAGE_SIZE, interpolation=transforms.InterpolationMode.BICUBIC), transforms.CenterCrop(IMAGE_SIZE), transforms.ToTensor(), transforms.Normalize(mean=CLIP_MEAN, std=CLIP_STD), ]) # --------------------------------------------------------------------------- # Core attribution functions (ported from eval_utils.py, adapted for transformers) # --------------------------------------------------------------------------- def get_attention_scores(vision_model, input_tensor): """Extract Q, K, V and attention scores from all transformer layers. For transformers CLIPVisionModel, each layer's attention module is CLIPAttention with q_proj, k_proj, v_proj, out_proj. We hook on the encoder layer (not self_attn) because CLIPAttention.forward() takes only kwargs, so forward hooks on it receive empty input tuples. """ qs, ks, vs, xs = {}, {}, {}, {} def get_qkv_hook(i, module, input, output): # module is CLIPEncoderLayer, input[0] is hidden_states hidden = input[0] xs[i] = hidden B, L, C = hidden.shape # Apply layer norm before attention (as the layer does internally) x = module.layer_norm1(hidden) attn = module.self_attn q = attn.q_proj(x) k = attn.k_proj(x) v = attn.v_proj(x) q = q.view(B, L, attn.num_heads, attn.head_dim).transpose(1, 2) k = k.view(B, L, attn.num_heads, attn.head_dim).transpose(1, 2) v = v.view(B, L, attn.num_heads, attn.head_dim).transpose(1, 2) qs[i], ks[i], vs[i] = q, k, v return output hooks = [] for i, layer in enumerate(vision_model.encoder.layers): hook = layer.register_forward_hook(partial(get_qkv_hook, i)) hooks.append(hook) try: with torch.no_grad(): _ = vision_model(pixel_values=input_tensor) finally: for hook in hooks: hook.remove() attention_scores = {} for i in qs: d_k = qs[i].size(-1) scores = torch.matmul(qs[i], ks[i].transpose(-2, -1)) / np.sqrt(d_k) attention_scores[i] = scores return xs, qs, ks, vs, attention_scores def get_attribution_map(pre_softmax_attention, vs, sae_w, sae_b, layer, head): """Compute the attribution map for one (layer, head) — from the repo.""" cls_token_index = 0 original_scores_head = pre_softmax_attention[layer][:, head] values_head = vs[layer][:, head] sae_neuron_weight = sae_w.T cls_scores = original_scores_head[:, cls_token_index, :] cls_attention_softmax = F.softmax(cls_scores, dim=-1) value_contributions = torch.matmul(values_head, sae_neuron_weight) avg_value_contribution = torch.sum( cls_attention_softmax.unsqueeze(-1) * value_contributions, dim=1, keepdim=True ) analytical_gradient = cls_attention_softmax.unsqueeze(-1) * ( value_contributions - avg_value_contribution ) return cls_attention_softmax, analytical_gradient def create_masks(token_shape, text_depth=2): """Binary masks for the four text-border regions (top/bottom/left/right).""" d = int(math.floor(math.sqrt(token_shape))) masks = [] for i in range(4): mask = torch.zeros(d, d) if i == 0: # Top mask[:text_depth, :] = 1 elif i == 1: # Bottom mask[-text_depth:, :] = 1 elif i == 2: # Left mask[:, :text_depth] = 1 elif i == 3: # Right mask[:, -text_depth:] = 1 masks.append(mask.flatten()) return torch.stack(masks) def calculate_score(text_loc, score_maps, all_masks): """Score how strongly a head localises to the text border vs. the object.""" masks = all_masks[text_loc] # (token_shape,) score_maps = score_maps[:, 1:] # remove cls token -> (B, T, C) score_maps = torch.clamp(score_maps, min=0.0, max=1.0) score_maps = score_maps.permute(0, 2, 1) # (B, C, T) pos_scores = (score_maps * masks).sum(-1) # (B, C, T) * (T,) -> (B, C) neg_scores = (score_maps * (1 - masks)).sum(-1) final_scores = pos_scores / (pos_scores + neg_scores + 1e-6) return final_scores # (B, C) # --------------------------------------------------------------------------- # Head ablation context manager (adapted for transformers CLIPAttention) # --------------------------------------------------------------------------- @contextmanager def fix_attn_head_list(vision_model, layer_spec, alpha=1.0): """Ablate the CLS->text attention in the specified heads (training-free defense). Registers a forward hook on each targeted CLIPEncoderLayer that recomputes the attention output with the specified heads' CLS-token attention to non-CLS patches neutralised, then passes through the MLP normally. """ hooks = [] def hook_fn(module, input, output, layer_idx, heads): # module is CLIPEncoderLayer, input[0] is hidden_states hidden = input[0] B, L, C = hidden.shape attn = module.self_attn # Apply layer norm before attention (as the layer does internally) x = module.layer_norm1(hidden) q = attn.q_proj(x).view(B, L, attn.num_heads, attn.head_dim).transpose(1, 2) k = attn.k_proj(x).view(B, L, attn.num_heads, attn.head_dim).transpose(1, 2) v = attn.v_proj(x).view(B, L, attn.num_heads, attn.head_dim).transpose(1, 2) att = q @ k.transpose(-2, -1) / math.sqrt(attn.head_dim) att = att.softmax(dim=-1) # Redistribute CLS-token attention: neutralise text-patch contribution. factors = att[:, :, :1, 1:].sum(dim=-1, keepdim=True) for head in heads: att[:, head, :1, 0] = alpha att[:, head, :1, 1:] = att[:, head, :1, 1:] * (1 - alpha) / ( factors[:, head, :1, :] + 1e-6 ) # Recompute attention output v_out = att @ v # (B, heads, L, head_dim) ctx = v_out.transpose(1, 2).reshape(B, L, C) attn_out = attn.out_proj(ctx) # Residual + MLP (as the original layer would do) hidden_out = hidden + attn_out hidden_out = hidden_out + module.mlp(module.layer_norm2(hidden_out)) return hidden_out for layer in layer_spec: heads = layer_spec[layer] hook = vision_model.encoder.layers[layer].register_forward_hook( partial(hook_fn, layer_idx=layer, heads=heads) ) hooks.append(hook) try: yield finally: for hook in hooks: hook.remove() # --------------------------------------------------------------------------- # Text border transform (for circuit mining) # --------------------------------------------------------------------------- def draw_text_border(img, text, edge_idx=0, border_ratio=0.2, target_size=(224, 224)): """Draw a text strip along one edge of the image.""" target_w, target_h = target_size border_px_h = int(target_h * border_ratio) border_px_w = int(target_w * border_ratio) canvas = Image.new("RGB", (target_w, target_h), (255, 255, 255)) color = (random.randint(0, 150), random.randint(0, 150), random.randint(0, 150)) if edge_idx == 0: # Top img_resized = img.resize((target_w, target_h - border_px_h), Image.Resampling.LANCZOS) canvas.paste(img_resized, (0, border_px_h)) elif edge_idx == 1: # Bottom img_resized = img.resize((target_w, target_h - border_px_h), Image.Resampling.LANCZOS) canvas.paste(img_resized, (0, 0)) elif edge_idx == 2: # Left img_resized = img.resize((target_w - border_px_w, target_h), Image.Resampling.LANCZOS) canvas.paste(img_resized, (border_px_w, 0)) elif edge_idx == 3: # Right img_resized = img.resize((target_w - border_px_w, target_h), Image.Resampling.LANCZOS) canvas.paste(img_resized, (0, 0)) draw = ImageDraw.Draw(canvas) font_paths = matplotlib.font_manager.findSystemFonts(fontpaths=None, fontext="ttf") font = ImageFont.truetype(random.choice(font_paths), max(8, int(border_px_h * 0.7))) if font_paths else ImageFont.load_default() text_repeated = (text + " ") * 5 if edge_idx in (0, 1): draw.text((10, border_px_h // 4 if edge_idx == 0 else target_h - border_px_h + border_px_h // 4), text_repeated, fill=color, font=font) else: strip = Image.new("RGBA", (border_px_w, target_h), (0, 0, 0, 0)) strip_draw = ImageDraw.Draw(strip) strip_draw.text((border_px_w // 4, 10), text_repeated, fill=color, font=font) strip = strip.rotate(90, expand=True, resample=Image.BICUBIC) if edge_idx == 2: canvas.paste(strip, (0, 0), strip) else: canvas.paste(strip, (target_w - border_px_w, 0), strip) return canvas, edge_idx # --------------------------------------------------------------------------- # Circuit mining # --------------------------------------------------------------------------- def mine_head_scores(pil_img, class_labels, n_samples=8): """Mine head scores for a single image by creating text-bordered variants.""" head_latent_dim = 64 # hidden_dim // n_heads for the SAE sae_hidden_dim = head_latent_dim * NUM_HEADS sae_list = build_random_sae_list( n_heads=NUM_HEADS, head_dim=HEAD_DIM, hidden_dim=sae_hidden_dim, k=128, layers=MINE_LAYERS, device="cuda", ) all_masks = create_masks(NUM_PATCHES, text_depth=int(np.ceil(IMAGE_SIZE * 0.2 / PATCH_SIZE))).to("cuda") head_scores = {} for layer in MINE_LAYERS: for head in range(NUM_HEADS): head_scores[(layer, head)] = torch.zeros(head_latent_dim).to("cuda") for s in range(n_samples): edge = s % 4 label = random.choice(class_labels) if class_labels else "object" bordered_img, text_loc = draw_text_border( pil_img, label, edge_idx=edge, border_ratio=0.2, target_size=(IMAGE_SIZE, IMAGE_SIZE) ) input_tensor = _preprocess(bordered_img).unsqueeze(0).to("cuda") with torch.inference_mode(): _, _, _, vs, attention_scores = get_attention_scores(_vision, input_tensor) for layer in MINE_LAYERS: sae = sae_list[layer] for head in range(NUM_HEADS): sae_w = sae.encoders[head][0].weight.data sae_b = sae.encoders[head][0].bias.data cls_attn, analytical_grad = get_attribution_map( attention_scores, vs, sae_w, sae_b, layer=layer, head=head ) final_scores = calculate_score(text_loc, analytical_grad, all_masks).sum(0) head_scores[(layer, head)] += final_scores for layer in MINE_LAYERS: for head in range(NUM_HEADS): head_scores[(layer, head)] = head_scores[(layer, head)].mean().item() / n_samples return head_scores def build_layer_spec(head_scores, layers, n_heads, sigma): """Select heads with score > layer_mean + sigma * layer_std.""" layer_spec = {} for layer in layers: layer_spec[layer] = [] layer_scores = np.array([head_scores[(layer, head)] for head in range(n_heads)]) layer_mean, layer_std = layer_scores.mean(), layer_scores.std() for head in range(n_heads): if head_scores[(layer, head)] > layer_mean + layer_std * sigma: layer_spec[layer].append(head) return layer_spec # --------------------------------------------------------------------------- # CLIP zero-shot classification + attention heatmap # --------------------------------------------------------------------------- def encode_text_prompts(labels): """Encode 'a photo of a