Klaus Clawd
Release v0.2.1: recover attack strength, cross-arch judges, uncapped frontier eval
255b4a8 | """Spark-side runner: attack each image against the TRAIN encoder ensemble, then | |
| score decoy-truth margin (clean vs adv, adv-after-JPEG) on BOTH train and HELD-OUT | |
| encoders to measure in-ensemble effect and cross-architecture transfer. | |
| Saves adversarial PNGs (for later frontier eval on the Mac) + results.json. | |
| python -m ensemble.run_attack --manifest testset.csv --images testset \ | |
| --out runs/demo --steps 200 --eps 12 --subset 4 [--limit 8] [--smoke] | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import io | |
| import json | |
| import time | |
| from pathlib import Path | |
| import numpy as np | |
| import torch | |
| from PIL import Image | |
| from ensemble.attack import AttackCfg, attack_image | |
| from ensemble.encoders import (ABLATION_TRAIN, DEFAULT_HELDOUT, DEFAULT_TRAIN, | |
| FULL_TRAIN, V02_HELDOUT, V02_TRAIN, | |
| V021_ATTACK, V021_ATTACK_BASE, V021_ATTACK_GIANTS, | |
| V021_HELDOUT, load_encoder) | |
| # v0.1 (non-Imagenette) decoys — kept only for reference / --train full reproduction. | |
| DECOYS_V01 = { | |
| "cassette player": "jellyfish", "tench": "steam locomotive", "church": "jellyfish", | |
| "chainsaw": "peacock", "english springer": "cassette player", | |
| "french horn": "jellyfish", "garbage truck": "sunflower", "gas pump": "banana", | |
| "golf ball": "volcano", "parachute": "octopus", | |
| } | |
| # v0.2 decoys are WITHIN Imagenette (reciprocal far pairs) so feature towers get an | |
| # image-feature centroid target (M4) from the exemplar pool, and contrastive text + | |
| # feature steering pull the SAME decoy direction. Semantically far within the 10 classes. | |
| DECOYS = { | |
| "tench": "church", "church": "tench", | |
| "english springer": "gas pump", "gas pump": "english springer", | |
| "cassette player": "parachute", "parachute": "cassette player", | |
| "chain saw": "golf ball", "golf ball": "chain saw", | |
| "french horn": "garbage truck", "garbage truck": "french horn", | |
| } | |
| def log(m): | |
| print(f"[{time.strftime('%H:%M:%S')}] {m}", flush=True) | |
| def to_tensor(img: Image.Image) -> torch.Tensor: | |
| a = np.asarray(img.convert("RGB"), dtype=np.float32) / 255.0 | |
| return torch.from_numpy(a).permute(2, 0, 1).unsqueeze(0).cuda() | |
| def jpeg(img: Image.Image, q=85) -> Image.Image: | |
| buf = io.BytesIO() | |
| img.convert("RGB").save(buf, format="JPEG", quality=q) | |
| buf.seek(0) | |
| return Image.open(buf).convert("RGB") | |
| def tensor_to_pil(x: torch.Tensor) -> Image.Image: | |
| a = (x.squeeze(0).clamp(0, 1).permute(1, 2, 0).cpu().float().numpy() * 255).round().astype(np.uint8) | |
| return Image.fromarray(a) | |
| def margin(enc, img: Image.Image, truth: str, decoy: str, centroids=None) -> float: | |
| """Decoy-minus-truth similarity margin (higher = more fooled toward decoy). | |
| Contrastive encoders use text-tower label embeddings; feature towers use the | |
| decoy/truth image-feature centroids (M4) when available. | |
| """ | |
| x = to_tensor(img) | |
| f = enc.image_feat(x).float() | |
| if enc.kind == "contrastive": | |
| t = enc.text_feat([truth, decoy]).float() | |
| return float(((f @ t[1:2].T) - (f @ t[0:1].T)).item()) | |
| cts = centroids.get(enc.name) if centroids else None | |
| if not cts or decoy not in cts or truth not in cts: | |
| return float("nan") | |
| c_d, c_t = cts[decoy].float(), cts[truth].float() | |
| return float(((f @ c_d.T) - (f @ c_t.T)).item()) | |
| def main(): | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--manifest", required=True) | |
| ap.add_argument("--images", required=True, help="dir holding the image files") | |
| ap.add_argument("--out", default="runs/demo") | |
| ap.add_argument("--steps", type=int, default=200) | |
| ap.add_argument("--eps", type=float, default=12.0, help="L-inf in /255") | |
| ap.add_argument("--subset", type=int, default=4) | |
| ap.add_argument("--limit", type=int, default=0) | |
| ap.add_argument("--train", default="default", | |
| choices=["default", "ablation", "full", "v0.2", "v0.2.1", | |
| "v0.2.1-base", "v0.2.1-giants"], | |
| help="'ablation' adds 2 timm VLM towers; 'full' also adds the 3 " | |
| "modern-LLM towers; 'v0.2' cross-arch split w/ M4 centroids; " | |
| "'v0.2.1' ADOPTED = full 13-tower v0.1 ensemble + M4 + never-trained " | |
| "cross-arch judges (C-RADIO, Qwen3-VL); 'v0.2.1-giants' adds the " | |
| "ablation-rejected SigLIP2-giant + MetaCLIP2-H") | |
| ap.add_argument("--exemplars", default="examples/exemplars", | |
| help="dir of per-class exemplar images for M4 feature-tower targets") | |
| ap.add_argument("--no-feature-target", action="store_true", | |
| help="ablate M4: feature towers use repel-only (still SCORED via centroids)") | |
| ap.add_argument("--decoys", default="v0.2", choices=["v0.1", "v0.2"], | |
| help="v0.2 = reciprocal Imagenette; v0.1 = original jellyfish-style decoys") | |
| # transfer/stealth levers (defaults off = plain momentum-PGD) | |
| ap.add_argument("--grad-norm", action="store_true", help="per-encoder unit-L2 grad agg") | |
| ap.add_argument("--vmi-n", type=int, default=0, help="VMI neighbor samples (0=off)") | |
| ap.add_argument("--vmi-beta", type=float, default=1.5) | |
| ap.add_argument("--max-per-family", type=int, default=99) | |
| ap.add_argument("--min-feature", type=int, default=0) | |
| ap.add_argument("--lpips-weight", type=float, default=0.0) | |
| ap.add_argument("--lpips-tau", type=float, default=0.0) | |
| ap.add_argument("--dct-keep", type=float, default=1.0) | |
| ap.add_argument("--metrics", action="store_true", help="record PSNR/SSIM/ΔE/LPIPS") | |
| ap.add_argument("--smoke", action="store_true") | |
| args = ap.parse_args() | |
| if args.smoke: | |
| args.steps, args.limit = 30, 2 | |
| out = Path(args.out) | |
| (out / "adv").mkdir(parents=True, exist_ok=True) | |
| imgdir = Path(args.images) | |
| rows = [] | |
| for line in Path(args.manifest).read_text().splitlines(): | |
| line = line.strip() | |
| if line and not line.startswith("#"): | |
| p, t = line.split(",", 1) | |
| rows.append((Path(p).name, t.strip().lower())) | |
| if args.limit: | |
| step = max(1, len(rows) // args.limit) | |
| rows = rows[::step][:args.limit] | |
| train_names = {"full": FULL_TRAIN, "ablation": ABLATION_TRAIN, | |
| "v0.2": V02_TRAIN, "v0.2.1": V021_ATTACK, | |
| "v0.2.1-base": V021_ATTACK_BASE, | |
| "v0.2.1-giants": V021_ATTACK_GIANTS}.get(args.train, DEFAULT_TRAIN) | |
| m4_trains = ("v0.2", "v0.2.1", "v0.2.1-base", "v0.2.1-giants") | |
| held_names = (V021_HELDOUT if args.train in ("v0.2.1", "v0.2.1-base", "v0.2.1-giants") | |
| else V02_HELDOUT if args.train == "v0.2" else DEFAULT_HELDOUT) | |
| log(f"loading TRAIN encoders ({args.train}): {train_names}") | |
| train = [load_encoder(n) for n in train_names] | |
| log(f"loading HELD-OUT encoders: {held_names}") | |
| held = [load_encoder(n) for n in held_names] | |
| torch.cuda.synchronize() | |
| log(f"VRAM after load: {torch.cuda.memory_allocated()/1e9:.1f} GB") | |
| # M4: per-encoder class feature centroids for feature-tower decoy steering + scoring. | |
| centroids = None | |
| has_feature = any(e.kind != "contrastive" for e in train + held) | |
| if args.train in m4_trains and has_feature: | |
| from ensemble.targets import compute_centroids | |
| log(f"computing feature-tower centroids from exemplars: {args.exemplars}") | |
| centroids = compute_centroids(train + held, args.exemplars, log=log) | |
| # centroids drive SCORING always; M4 ablation only removes them from the attack. | |
| attack_centroids = None if args.no_feature_target else centroids | |
| cfg = AttackCfg( | |
| eps=args.eps / 255, steps=args.steps, subset=args.subset, | |
| grad_norm=args.grad_norm, vmi_n=args.vmi_n, vmi_beta=args.vmi_beta, | |
| max_per_family=args.max_per_family, min_feature=args.min_feature, | |
| lpips_weight=args.lpips_weight, lpips_tau=args.lpips_tau, dct_keep=args.dct_keep, | |
| ) | |
| lpips_fn = None | |
| if args.lpips_weight > 0 or args.lpips_tau > 0 or args.metrics: | |
| from ensemble.perceptual import LPIPSPenalty | |
| lpips_fn = LPIPSPenalty(net="alex", device="cuda") | |
| log("LPIPS (alex) loaded") | |
| results = [] | |
| for i, (fname, truth) in enumerate(rows): | |
| fp = imgdir / fname | |
| if not fp.exists(): | |
| log(f"skip missing {fp}") | |
| continue | |
| img = Image.open(fp).convert("RGB") | |
| decoy_map = DECOYS_V01 if args.decoys == "v0.1" else DECOYS | |
| decoy = decoy_map.get(truth, "jellyfish") | |
| t0 = time.time() | |
| x_clean = to_tensor(img) | |
| adv = attack_image(x_clean, truth, decoy, train, cfg, | |
| lpips_fn=lpips_fn, centroids=attack_centroids) | |
| adv_img = tensor_to_pil(adv) | |
| adv_img.save(out / "adv" / f"{fp.stem}.png") | |
| adv_jpeg = jpeg(adv_img, 85) | |
| rec = {"image": fname, "truth": truth, "decoy": decoy, "train": {}, "heldout": {}} | |
| if args.metrics: | |
| from ensemble.perceptual import stealth_metrics | |
| rec["stealth"] = stealth_metrics(adv, x_clean, lpips_fn) | |
| for grp, encs in (("train", train), ("heldout", held)): | |
| for enc in encs: | |
| # feature towers score via M4 centroids; skip only if no centroid target | |
| if enc.kind != "contrastive" and centroids is None: | |
| continue | |
| mc = round(margin(enc, img, truth, decoy, centroids), 3) | |
| ma = round(margin(enc, adv_img, truth, decoy, centroids), 3) | |
| mj = round(margin(enc, adv_jpeg, truth, decoy, centroids), 3) | |
| if mc != mc or ma != ma or mj != mj: # NaN -> no target, skip | |
| continue | |
| rec[grp][enc.name] = {"clean": mc, "adv": ma, "adv_jpeg": mj} | |
| def flips(grp): # margin crosses 0 (decoy now beats truth) after JPEG | |
| v = rec[grp] | |
| return sum(d["adv_jpeg"] > 0 and d["clean"] < 0 for d in v.values()), len(v) | |
| tf, tn = flips("train"); hf, hn = flips("heldout") | |
| rec["train_flip_jpeg"] = f"{tf}/{tn}" | |
| rec["heldout_flip_jpeg"] = f"{hf}/{hn}" | |
| results.append(rec) | |
| log(f"[{i+1}/{len(rows)}] {fname} {truth!r}->{decoy!r} " | |
| f"train_flip(jpeg)={tf}/{tn} heldout_flip(jpeg)={hf}/{hn} ({time.time()-t0:.0f}s)") | |
| (out / "results.json").write_text(json.dumps(results, indent=2)) | |
| def agg(grp, key): | |
| vals = [d[key] for r in results for d in r[grp].values()] | |
| return sum(vals) / len(vals) if vals else 0.0 | |
| def flip_total(grp): | |
| f = n = 0 | |
| for r in results: | |
| for d in r[grp].values(): | |
| n += 1; f += (d["adv_jpeg"] > 0 and d["clean"] < 0) | |
| return f, n | |
| print("\n============ ENSEMBLE ATTACK SUMMARY ============") | |
| print(f"images: {len(results)} eps={args.eps}/255 steps={args.steps} subset={args.subset}") | |
| print(f"levers: grad_norm={args.grad_norm} vmi_n={args.vmi_n} " | |
| f"max_per_family={args.max_per_family} min_feature={args.min_feature} " | |
| f"lpips_w={args.lpips_weight} lpips_tau={args.lpips_tau} dct_keep={args.dct_keep}") | |
| for grp in ("train", "heldout"): | |
| f, n = flip_total(grp) | |
| print(f"{grp:>7}: mean clean margin {agg(grp,'clean'):+.3f} -> adv {agg(grp,'adv'):+.3f} " | |
| f"-> adv+JPEG {agg(grp,'adv_jpeg'):+.3f} | decoy-beats-truth after JPEG {f}/{n}") | |
| if args.metrics and results: | |
| keys = [k for k in ("psnr", "ssim", "deltaE_p95", "lpips") if k in results[0].get("stealth", {})] | |
| ms = {k: sum(r["stealth"][k] for r in results) / len(results) for k in keys} | |
| print("stealth (mean): " + " ".join(f"{k}={v:.3f}" for k, v in ms.items())) | |
| print("====================================================") | |
| if __name__ == "__main__": | |
| main() | |