veil-pgd / scripts /eval_frontier_v02.py
Klaus Clawd
Release v0.2.1: recover attack strength, cross-arch judges, uncapped frontier eval
255b4a8
Raw
History Blame Contribute Delete
8.07 kB
"""v0.2 frontier eval with a fixed flip metric (M5) + cached clean labels + raw
response logging so metric changes never re-spend API.
Fixed flip rule (addresses the v0.1 artifacts — empty clean labels and near-synonym
false flips like church->cathedral):
A trial FLIPS iff ALL of:
1. clean label is non-empty (model actually answered on the clean image),
2. adv label is non-empty (a real redirect, not a refusal/empty),
3. clean was CORRECT: dist(clean_label, truth) < TAU_CORRECT,
4. adv is WRONG: dist(adv_label, truth) >= TAU_WRONG,
5. adv is not a synonym of the clean label: dist(adv_label, clean_label) >= TAU_MOVE.
Only images the model got right on the clean version count toward the denominator
(clean-correct trials), so the rate is "of images the model originally labeled
correctly, how many did the perturbation redirect".
Clean labels are queried ONCE per image/model and cached to runs/frontier_clean.json,
then reused across every adv run dir (attack + noise controls) to cut API spend.
# score one attack run + the two noise controls, caching clean labels:
python scripts/eval_frontier_v02.py --manifest examples/testset60.csv \
--images examples/testset60 \
--runs runs/v02_e6_plain runs/noise60_eps6 runs/noise60_ssim
# recompute flips from saved raw responses (no API calls):
python scripts/eval_frontier_v02.py --rescore \
--runs runs/v02_e6_plain runs/noise60_eps6 runs/noise60_ssim
"""
from __future__ import annotations
import argparse
import io
import json
import sys
import time
from pathlib import Path
import numpy as np
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from veil_pgd.config import get_settings # noqa: E402
from veil_pgd.fitness.embed import Embedder # noqa: E402
from veil_pgd.fitness.semantic import embedding_distance # noqa: E402
from veil_pgd.targets.base import LabelPrompt # noqa: E402
from veil_pgd.targets.registry import Registry # noqa: E402
TAU_CORRECT = 0.40 # clean label must be within this of truth (model was right)
TAU_WRONG = 0.50 # adv label must be at least this far from truth (now wrong)
TAU_MOVE = 0.40 # adv label must differ from the clean label (not a synonym)
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 log(m):
print(f"[{time.strftime('%H:%M:%S')}] {m}", flush=True)
def read_manifest(mpath: str) -> list[tuple[str, str]]:
rows = []
for line in Path(mpath).read_text().splitlines():
line = line.strip()
if line and not line.startswith("#"):
p, t = line.split(",", 1)
rows.append((Path(p).name, t.strip()))
return rows
def score_flip(emb, truth, clean_lbl, adv_lbl) -> dict:
cd = embedding_distance(emb, clean_lbl, truth) if clean_lbl else 0.0
ad = embedding_distance(emb, adv_lbl, truth) if adv_lbl else 0.0
move = embedding_distance(emb, adv_lbl, clean_lbl) if (adv_lbl and clean_lbl) else 0.0
clean_correct = bool(clean_lbl) and cd < TAU_CORRECT
flip = bool(clean_correct and adv_lbl and ad >= TAU_WRONG and move >= TAU_MOVE)
return {"clean": clean_lbl, "adv": adv_lbl, "clean_dist": round(cd, 3),
"adv_dist": round(ad, 3), "move_dist": round(move, 3),
"clean_correct": clean_correct, "flip": flip}
def boot_rate(flips: list[bool], correct: list[bool], iters=10000, seed=0):
"""Bootstrap flip rate over CLEAN-CORRECT trials only. Returns (rate, lo, hi, n)."""
f = np.array(flips, dtype=float)
c = np.array(correct, dtype=float)
rng = np.random.default_rng(seed)
idx = np.arange(len(f))
s = []
for _ in range(iters):
b = rng.choice(idx, size=len(idx), replace=True)
denom = c[b].sum()
s.append(f[b].sum() / denom if denom else 0.0)
denom = c.sum()
point = f.sum() / denom if denom else 0.0
return point, float(np.percentile(s, 2.5)), float(np.percentile(s, 97.5)), int(denom)
def query_clean(reg, emb, prompt, blackbox, rows, images, cache_path: Path) -> dict:
if cache_path.exists():
log(f"using cached clean labels: {cache_path}")
return json.loads(cache_path.read_text())
cache: dict = {}
for fname, truth in rows:
clean = jpeg(Image.open(Path(images) / fname).convert("RGB"))
cache[fname] = {"truth": truth, "models": {}}
for m in blackbox:
lbl = m.label(clean, prompt).parsed_label or ""
cache[fname]["models"][m.name] = lbl
log(f"clean {fname} {m.name.split('/')[-1]}: {lbl!r}")
cache_path.write_text(json.dumps(cache, indent=2))
return cache
def eval_run(reg, emb, prompt, blackbox, rows, images, run: Path, clean_cache: dict):
advdir = run / "adv"
raw = []
for fname, truth in rows:
stem = Path(fname).stem
adv_fp = advdir / f"{stem}.png"
if not adv_fp.exists():
continue
adv = jpeg(Image.open(adv_fp).convert("RGB"))
rec = {"image": fname, "truth": truth, "models": {}}
for m in blackbox:
adv_lbl = m.label(adv, prompt).parsed_label or ""
clean_lbl = clean_cache[fname]["models"][m.name]
rec["models"][m.name] = {"clean_label": clean_lbl, "adv_label": adv_lbl}
log(f"{run.name} {fname} {m.name.split('/')[-1]}: {clean_lbl!r}->{adv_lbl!r}")
raw.append(rec)
(run / "frontier_raw.json").write_text(json.dumps(raw, indent=2))
return raw
def rescore(emb, run: Path):
raw = json.loads((run / "frontier_raw.json").read_text())
per_model: dict = {}
scored = []
for rec in raw:
truth = rec["truth"]
out = {"image": rec["image"], "truth": truth, "models": {}}
for mname, v in rec["models"].items():
s = score_flip(emb, truth, v["clean_label"], v["adv_label"])
out["models"][mname] = s
per_model.setdefault(mname, {"flips": [], "correct": []})
per_model[mname]["flips"].append(s["flip"])
per_model[mname]["correct"].append(s["clean_correct"])
scored.append(out)
(run / "frontier_scored.json").write_text(json.dumps(scored, indent=2))
return per_model
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--manifest", default="examples/testset60.csv")
ap.add_argument("--images", default="examples/testset60")
ap.add_argument("--runs", nargs="+", required=True)
ap.add_argument("--clean-cache", default="runs/frontier_clean.json")
ap.add_argument("--rescore", action="store_true", help="recompute flips from saved raw, no API")
args = ap.parse_args()
s = get_settings()
reg = Registry(s)
emb = Embedder(reg.embeddings(), s.klaus3_vision_service_url)
rows = read_manifest(args.manifest)
if not args.rescore:
prompt = LabelPrompt()
blackbox = reg.all_blackbox()
clean_cache = query_clean(reg, emb, prompt, blackbox, rows, args.images,
Path(args.clean_cache))
for rp in args.runs:
eval_run(reg, emb, prompt, blackbox, rows, args.images, Path(rp), clean_cache)
print("\n===== FRONTIER (v0.2, fixed metric) — flip rate over CLEAN-CORRECT trials =====")
print(f"TAU_CORRECT={TAU_CORRECT} TAU_WRONG={TAU_WRONG} TAU_MOVE={TAU_MOVE}")
for rp in args.runs:
run = Path(rp)
pm = rescore(emb, run)
print(f"\n{run.name}:")
all_f, all_c = [], []
for mname, d in pm.items():
r, lo, hi, n = boot_rate(d["flips"], d["correct"])
all_f += d["flips"]; all_c += d["correct"]
print(f" {mname.split('/')[-1]:<20} {r*100:5.1f}% [{lo*100:.1f}, {hi*100:.1f}] (n_correct={n})")
r, lo, hi, n = boot_rate(all_f, all_c)
print(f" {'BOTH':<20} {r*100:5.1f}% [{lo*100:.1f}, {hi*100:.1f}] (n_correct={n})")
reg.close()
if __name__ == "__main__":
main()