File size: 9,685 Bytes
4690b6b
 
 
9f02a0d
 
 
 
 
 
 
 
 
 
 
4690b6b
 
 
 
 
 
 
 
 
 
e462b24
 
4690b6b
 
 
 
 
e462b24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4690b6b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a243c9c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4690b6b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a243c9c
 
 
 
 
 
 
 
 
 
4690b6b
a243c9c
 
 
 
 
 
4690b6b
 
 
 
 
 
 
a243c9c
 
4690b6b
 
 
 
 
 
 
a243c9c
 
4690b6b
 
 
 
 
 
 
 
 
 
9f02a0d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4690b6b
 
9f02a0d
4690b6b
 
a243c9c
4690b6b
 
9f02a0d
4690b6b
a243c9c
 
 
 
4690b6b
 
 
 
 
9f02a0d
4690b6b
 
 
 
a243c9c
4690b6b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9f02a0d
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
"""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()