"""Drives eval-unlearn's registry directly (bypassing SingleBenchmarkRunner / MultiBenchmarkRunner) so that: 1. The free_run diffusion pipeline is loaded once per submission and reused across all metrics (load_technique + run_one_metric), rather than reloaded per metric. On this Space's dedicated T4 hardware, reloading it 6x per submission churned enough GPU memory across CUDA allocator fragmentation to cause an out-of-memory crash a few metrics in — a hard crash, not a catchable Python exception, which killed the whole process and looked like the app "randomly refreshing" mid-submission. (This traded off against ZeroGPU's earlier per-call duration ceiling, which favored short-lived, independent calls — if this Space ever moves back to ZeroGPU, that tradeoff needs revisiting: a technique object loaded in one @spaces.GPU call may not be valid to reuse in another.) 2. Generated images are never written to disk — technique.generate() -> metric.update() -> metric.compute() happens in memory and the images are discarded, so no i2p-prompted imagery is ever persisted or published. "cheap" tier caps (num_inference_steps and each metric's `limit`) trade score precision for speed; the eval-unlearn CLI run with full dataset sizes remains the source of truth for publication-quality numbers. """ import os import sys import types from pathlib import Path from typing import Any, Dict, List, Optional import spaces def _stub_missing_technique_packages() -> None: """Work around a bug in eval-unlearn's own packaging. eval_unlearn/techniques/__init__.py unconditionally imports the uce, concept_steerers and mace wrapper modules (unlike esd/saeuron/safree/etc, which are correctly wrapped in try/except there). Each of those three unconditionally does `from uce import UCEPipeline` for an external package that was never published alongside eval-unlearn on PyPI. Since Python must run that __init__.py before importing *any* technique submodule, one missing unrelated package breaks free_run's registration too. We never use uce/concept_steerers/mace, so a harmless stub module is enough to let the parent package finish initializing. """ stub_attrs = { "uce": "UCEPipeline", "concept_steerers": "ConceptSteeringPipeline", "mace": "MACEPipeline", } for name, attr in stub_attrs.items(): if name in sys.modules: continue try: __import__(name) except ImportError: stub = types.ModuleType(name) setattr(stub, attr, object) sys.modules[name] = stub _stub_missing_technique_packages() DATA_DIR = Path(__file__).resolve().parent.parent.parent / "data" / "ua_ira" # The 7 concepts I2P has prompt categories for (datasets/_i2p_categories.py in # eval-unlearn). ASR-I2P is only ever non-N/A for these; "other" always N/A's. I2P_CONCEPTS = [ "nudity", "harassment", "hate", "illegal activity", "self-harm", "shocking", "violence", ] CONCEPTS = I2P_CONCEPTS + ["other"] # Metrics offered on the online "cheap" tier, in run order (cheapest/most # broadly-applicable first so a submitter sees partial progress quickly). METRIC_NAMES = ["clip_score", "asr_i2p", "ua_ira", "err", "tifa", "fid"] DEFAULT_NUM_INFERENCE_STEPS = 25 # Concepts eval-unlearn ships curated UA/IRA target/retain CSVs for. UA/IRA # can't be derived from a concept name alone (it needs a curated retain set), # so it's only offered for concepts we actually have CSVs for. UA_IRA_CONCEPTS = {"nudity", "violence"} # What the bundled retain CSV for each concept actually depicts, used as the # CLIP retain-concept label (see examples/data/{concept}_retain_prompts.csv). _UA_IRA_RETAIN_LABELS = {"nudity": "clothed person", "violence": "peaceful scene"} # Default number of images/prompts each metric uses on the "cheap" tier, and # the largest size the Submit tab's sliders allow. Sizes above the default # trade speed for precision — going too high risks exceeding a single # ZeroGPU call's time budget (run_one_metric's duration=180s). DEFAULT_SIZES: Dict[str, int] = { "clip_score": 25, "fid": 50, "tifa": 20, "asr_i2p": 15, "err": 15, # applied to each of target/retain/adversarial "ua_ira": 11, # the bundled curated CSVs only have 11 rows each } MAX_SIZES: Dict[str, int] = { "clip_score": 100, "fid": 150, "tifa": 60, "asr_i2p": 60, "err": 40, "ua_ira": 11, } def available_metrics_for_concept(concept: str) -> List[str]: """Which of METRIC_NAMES can actually run for this concept. err's forgetting check is hardcoded to nudity I2P prompts regardless of the technique's real concept (eval_unlearn.datasets.err_composite), so we only offer it for nudity submissions to avoid a misleading score. ua_ira needs curated retain prompts we only have for nudity/violence. """ metrics = list(METRIC_NAMES) if concept != "nudity": metrics.remove("err") if concept not in UA_IRA_CONCEPTS: metrics.remove("ua_ira") return metrics def _build_metric_config( metric_name: str, concept: str, size: Optional[int] = None ) -> Dict[str, Any]: """Build a metric's config dict, applying either its default size or a caller-supplied override (clamped to MAX_SIZES so the Submit tab's sliders can't push a metric past what a single ZeroGPU call can finish). """ n = size or DEFAULT_SIZES.get(metric_name) if n is not None: n = max(1, min(n, MAX_SIZES.get(metric_name, n))) config: Dict[str, Any] = {} if metric_name in ("clip_score", "fid", "tifa"): config["limit"] = n elif metric_name == "asr_i2p": config["limit"] = n config["concept_name"] = concept # asr_i2p's "auto" detector picks NudeNet for nudity and Q16 for # everything else — Q16 is an unpublished local package this Space # doesn't install. Force NudeNet for nudity (installed via the `asr` # extra) and fall back to the CLIP-similarity detector (needs only # `transformers`, already a dependency) for the other 6 concepts. config["detector"] = "nudenet" if concept == "nudity" else "clip" elif metric_name == "err": config.update({"target_limit": n, "retain_limit": n, "adversarial_limit": n}) elif metric_name == "ua_ira": config.update( { "target_prompts_path": str(DATA_DIR / f"{concept}_target_prompts.csv"), "retain_prompts_path": str(DATA_DIR / f"{concept}_retain_prompts.csv"), "target_concept": concept, "retain_concept": _UA_IRA_RETAIN_LABELS.get(concept, "retain"), "target_prompt_limit": n, "retain_prompt_limit": n, } ) return config def _metric_result_to_dict(result) -> Dict[str, Any]: return {"name": result.name, "value": result.value, "details": result.details} @spaces.GPU(duration=60) def load_technique(model_id: str, num_inference_steps: int = DEFAULT_NUM_INFERENCE_STEPS): """Load the free_run diffusion pipeline once for the whole submission. Raises on failure (bad model_id, load error) — the caller should abort the submission rather than retrying this once per metric. """ from eval_unlearn.registry import get_technique from eval_unlearn.registry.entrypoints import load_entrypoints load_entrypoints() technique_factory = get_technique("free_run") return technique_factory(model_id=model_id, num_inference_steps=num_inference_steps) def free_gpu_memory() -> None: """Call after the caller has `del`'d its own references (e.g. the technique) to actually release that memory back to the CUDA allocator. """ try: import torch torch.cuda.empty_cache() except Exception: pass @spaces.GPU(duration=180) def run_one_metric( technique, metric_name: str, concept: str, size: Optional[int] = None, seed: Optional[int] = 0, ) -> Dict[str, Any]: """Run a single metric against an already-loaded free_run technique. `size` overrides that metric's default number of images/prompts (see DEFAULT_SIZES/MAX_SIZES); leave it None to use the default "cheap" tier size. Returns a plain dict (not MetricResult) — always, even on failure, with an "error" key set instead of raising, so a caller looping over several metrics can record partial results instead of aborting the whole submission when one metric fails. """ from eval_unlearn.registry import get_metric from eval_unlearn.registry.entrypoints import load_entrypoints load_entrypoints() metric_config = _build_metric_config(metric_name, concept, size) metric = None try: metric_factory = get_metric(metric_name) metric = metric_factory(**metric_config) loader = metric.load_dataset() total = 0 for batch in loader: images = technique.generate(prompts=batch.prompts, seed=seed) metric.update(images, batch.prompts, batch.metadata) total += len(images) del images result = metric.compute() out = _metric_result_to_dict(result) out["details"] = {**out.get("details", {}), "images_generated": total} return out except Exception as e: # noqa: BLE001 - reported to the caller, not raised return {"name": metric_name, "value": None, "details": {}, "error": str(e)} finally: del metric free_gpu_memory()