veil-pgd / ensemble /attack.py
Klaus Clawd
Release v0.2.1: recover attack strength, cross-arch judges, uncapped frontier eval
255b4a8
Raw
History Blame Contribute Delete
5.69 kB
"""EOT-hardened, ensemble, momentum-PGD image attack.
Four transfer/stealth levers are individually toggleable (all off = plain
momentum-PGD, which the defaults reproduce):
- per-encoder gradient L2-normalization (loss.ensemble_grad; cfg.grad_norm)
- stratified family-aware subset sampling (sampling.stratified_sample)
- VMI variance-tuned momentum (loss.vmi_variance; cfg.vmi_n>0)
- perceptual budget: LPIPS penalty + DCT low-pass (perceptual.*)
Plain behavior = grad_norm off, vmi_n=0, lpips_weight=0, dct_keep=1.0,
max_per_family huge, min_feature=0.
Loss per contrastive encoder:
w_t*[cos(f,e_decoy) - cos(f,e_truth)] + w_r*[-cos(f, f_clean)]
Feature towers contribute only the untargeted repel term.
"""
from __future__ import annotations
import random
from dataclasses import dataclass
import torch
from ensemble.eot import apply_eot
from ensemble.loss import encoder_loss, ensemble_grad, vmi_variance
from ensemble.sampling import stratified_sample
@dataclass
class AttackCfg:
eps: float = 12 / 255 # L-inf budget
steps: int = 200
step_size: float = 1.5 / 255
subset: int = 4 # encoders sampled per step
eot_samples: int = 1 # EOT draws averaged per step
w_target: float = 1.0
w_repel: float = 0.5
momentum: float = 0.9
seed: int = 0
# --- transfer/stealth levers (defaults off = plain momentum-PGD) ---
grad_norm: bool = False # per-encoder unit-L2 gradient aggregation
vmi_n: int = 0 # VMI neighbor samples (0 = plain MI-FGSM)
vmi_beta: float = 1.5 # VMI neighborhood radius (x eps)
max_per_family: int = 99 # per-step cap of same-architecture encoders
min_feature: int = 0 # min feature towers guaranteed per step
lpips_weight: float = 0.0 # soft LPIPS penalty weight
lpips_tau: float = 0.0 # hard LPIPS budget (0 = off); project delta down
dct_keep: float = 1.0 # DCT low-pass keep-fraction (1.0 = off)
def _legacy_grad(delta, x0, chosen, truth, decoy, clean_feat, cfg, rng, centroids=None):
"""Plain aggregation: sum scalar losses, one backward, no per-encoder norm."""
d = delta.detach().requires_grad_(True)
total = torch.zeros((), device=x0.device)
for _ in range(cfg.eot_samples):
x_adv = (x0 + d).clamp(0, 1)
x_t = apply_eot(x_adv, rng, strong=True)
for enc in chosen:
total = total + encoder_loss(enc, x_t, truth, decoy, clean_feat, cfg, centroids)
total = total / (len(chosen) * cfg.eot_samples)
return torch.autograd.grad(total, d)[0].float()
def _perceptual_grad(delta, x0, lpips_fn):
"""Gradient of LPIPS(adv, clean) wrt delta (to be descended)."""
d = delta.detach().requires_grad_(True)
adv = (x0 + d).clamp(0, 1)
dist = lpips_fn.distance(adv, x0)
return torch.autograd.grad(dist, d)[0].float(), float(dist.item())
def _unit(g):
return g / (g.flatten(1).norm(dim=1).view(-1, 1, 1, 1) + 1e-12)
@torch.enable_grad()
def attack_image(x0: torch.Tensor, truth: str, decoy: str, encoders: list,
cfg: AttackCfg, lpips_fn=None, centroids=None) -> torch.Tensor:
"""x0: (1,3,H,W) in [0,1] on cuda. Returns adversarial image in [0,1].
centroids (optional, M4): dict[enc.name][class_word] -> feature centroid, used
to give feature (no-text) towers a targeted decoy/truth steering objective.
"""
from ensemble.perceptual import dct_lowpass
rng = random.Random(cfg.seed)
clean_feat = {}
with torch.no_grad():
for enc in encoders:
clean_feat[enc.name] = enc.image_feat(x0).detach()
delta = torch.zeros_like(x0)
grad_mom = torch.zeros_like(x0)
for _ in range(cfg.steps):
if cfg.max_per_family < len(encoders) or cfg.min_feature > 0:
chosen = stratified_sample(encoders, cfg.subset, rng,
cfg.max_per_family, cfg.min_feature)
else:
chosen = encoders if len(encoders) <= cfg.subset \
else rng.sample(encoders, cfg.subset)
def grad_fn(dl):
if cfg.grad_norm:
return ensemble_grad(dl, x0, chosen, truth, decoy, clean_feat,
cfg, rng, lambda x, r: apply_eot(x, r, strong=True),
centroids=centroids)
return _legacy_grad(dl, x0, chosen, truth, decoy, clean_feat, cfg, rng,
centroids=centroids)
adv_grad = grad_fn(delta)
if cfg.vmi_n > 0:
adv_grad = adv_grad + vmi_variance(delta, adv_grad, cfg.eps, cfg, grad_fn)
combined = _unit(adv_grad)
if cfg.lpips_weight > 0.0 and lpips_fn is not None:
lp_grad, _ = _perceptual_grad(delta, x0, lpips_fn)
combined = combined - cfg.lpips_weight * _unit(lp_grad)
with torch.no_grad():
g = combined / (combined.abs().mean() + 1e-12) # MI-FGSM normalize
grad_mom = cfg.momentum * grad_mom + g
delta = (delta + cfg.step_size * grad_mom.sign()).clamp(-cfg.eps, cfg.eps)
if cfg.dct_keep < 1.0:
delta = dct_lowpass(delta, cfg.dct_keep).clamp(-cfg.eps, cfg.eps)
delta = (x0 + delta).clamp(0, 1) - x0 # keep image in range
if cfg.lpips_tau > 0.0 and lpips_fn is not None:
cur = float(lpips_fn.distance((x0 + delta).clamp(0, 1), x0).item())
if cur > cfg.lpips_tau:
delta = delta * (cfg.lpips_tau / cur) # project onto budget
delta = delta.detach()
return (x0 + delta).clamp(0, 1).detach()