Download core/evalset.py from FineEnvs/DesignGym: direct link, hf CLI and curl.
- Browser
- Download file 14 kB
-
https://huggingface.co/spaces/FineEnvs/DesignGym/resolve/main/core/evalset.py
- Command line
-
hf download hf://spaces/FineEnvs/DesignGym/core/evalset.py
-
curl -L -o evalset.py https://huggingface.co/spaces/FineEnvs/DesignGym/resolve/main/core/evalset.py
14 kB
| """A frozen, diverse, difficulty-ranked evaluation set over the Crello test split. | |
| Random sampling is a poor eval set here: the test split is 40 appearance | |
| clusters of very uneven size (1 to 227 designs), so a random 300 is dominated | |
| by a handful of looks. Selection is instead: | |
| 1. **Stratified** by difficulty tier x Crello use group (social post, cover, | |
| marketing, ad, event, blog), with each tier held to an equal share. | |
| 2. **Spread**: at most `cluster_cap` tasks per appearance cluster, and within a | |
| stratum the next pick is the candidate whose category, format and canvas | |
| shape are least represented so far. | |
| 3. **Covering**: until each hard feature (rotation, transparency, mixed-style | |
| text, dense layouts, long copy, tiny text, reused assets, no photo) has its | |
| quota, candidates that add an under-covered feature go first. | |
| 4. **Seeded**: every tie breaks on a hash of (seed, snapshot, task id), so the | |
| same corpus and seed give the same set, and nothing depends on file order. | |
| The lite subset is chosen from the full set by the same rules, so lite scores | |
| are a smaller sample of the same distribution rather than a different test. | |
| `build(validate=True)` fetches every selected design and checks its ground | |
| truth scores exactly 1.0 against itself. A task that fails is recorded, never | |
| swapped for another; a set with failures refuses to load. | |
| """ | |
| from __future__ import annotations | |
| import hashlib | |
| import json | |
| import math | |
| from collections import Counter | |
| from pathlib import Path | |
| from .difficulty import TIERS | |
| EVALSET_VERSION = 1 | |
| DEFAULT_SIZE = 300 | |
| DEFAULT_LITE = 60 | |
| DEFAULT_SPLIT = "test" | |
| # Hard features the set must contain, with how to detect each from index features. | |
| FLAGS = { | |
| "rotated": lambda f: f["rotated"] > 0, | |
| "transparent": lambda f: f["semi"] > 0, | |
| "mixed_style_text": lambda f: f["styled"] > 0, | |
| "dense": lambda f: f["n_elements"] >= 20, | |
| "long_copy": lambda f: f["chars"] >= 175, | |
| "tiny_text": lambda f: f["tiny_text"] > 0, | |
| "reused_asset": lambda f: f["reuse"] > 0, | |
| "three_fonts": lambda f: f["n_fonts"] >= 3, | |
| "no_photo": lambda f: f["n_images"] == 0, | |
| } | |
| def canonical(obj) -> str: | |
| return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False) | |
| def _h(seed, snapshot, *parts) -> str: | |
| return hashlib.sha256(canonical(["design-canvas-evalset-v1", seed, snapshot, *parts]).encode()).hexdigest() | |
| def flags_of(row: dict) -> list[str]: | |
| if "flags" in row: # a frozen entry carries its flags; index rows carry the raw features | |
| return list(row["flags"]) | |
| return [name for name, test in FLAGS.items() if test(row)] | |
| def _allocate(pool: list[dict], size: int) -> dict[tuple[str, str], int]: | |
| """Per-(tier, group) quotas: tiers equal, groups equal within a tier, capped by supply.""" | |
| groups = sorted({r["group"] for r in pool}) | |
| supply = Counter((r["tier"], r["group"]) for r in pool) | |
| quotas = {} | |
| per_tier, extra = divmod(size, len(TIERS)) | |
| for ti, tier in enumerate(TIERS): | |
| want = per_tier + (ti < extra) | |
| need = {g: 0 for g in groups} | |
| # Water-fill: hand out one at a time to the group with the fewest so far that has supply left. | |
| for _ in range(want): | |
| open_ = [g for g in groups if need[g] < supply[tier, g]] | |
| if not open_: | |
| raise ValueError(f"the {tier} tier cannot supply {want} tasks") | |
| g = min(open_, key=lambda g: (need[g], g)) | |
| need[g] += 1 | |
| for g in groups: | |
| quotas[tier, g] = need[g] | |
| return quotas | |
| def select(pool: list[dict], size: int, *, seed: int, snapshot: str, cluster_cap: int | None = None, | |
| flag_quota: int | None = None) -> list[dict]: | |
| """Choose `size` rows from `pool` (index rows with features, tier, group, cluster).""" | |
| if size > len(pool): | |
| raise ValueError(f"asked for {size} tasks from a pool of {len(pool)}") | |
| n_clusters = len({r["cluster"] for r in pool}) | |
| cap = cluster_cap or max(1, math.ceil(2 * size / n_clusters)) | |
| fq = flag_quota if flag_quota is not None else max(3, size // 20) | |
| quotas = _allocate(pool, size) | |
| cells: dict[tuple, list[dict]] = {} | |
| for r in sorted(pool, key=lambda r: _h(seed, snapshot, r["task_id"])): | |
| cells.setdefault((r["tier"], r["group"]), []).append(r) | |
| counts = {k: Counter() for k in ("cluster", "category", "format", "shape", "flag")} | |
| chosen, taken = [], Counter() | |
| def pick(cell): | |
| best, best_key = None, None | |
| for r in cells[cell]: | |
| if counts["cluster"][r["cluster"]] >= cap: | |
| continue | |
| gain = sum(1 for f in flags_of(r) if counts["flag"][f] < fq) | |
| key = (-gain, counts["category"][r["category"]], counts["format"][r["format"]], | |
| counts["shape"][r["shape"]]) | |
| if best_key is None or key < best_key: | |
| best, best_key = r, key | |
| return best | |
| order = [c for c in sorted(quotas) if quotas[c]] | |
| while len(chosen) < size: | |
| progressed = False | |
| for cell in order: # round robin, so balancing counters evolve evenly across strata | |
| if taken[cell] >= quotas[cell]: | |
| continue | |
| r = pick(cell) | |
| if r is None: | |
| continue | |
| cells[cell].remove(r) | |
| taken[cell] += 1 | |
| chosen.append(r) | |
| progressed = True | |
| counts["cluster"][r["cluster"]] += 1 | |
| for k in ("category", "format", "shape"): | |
| counts[k][r[k]] += 1 | |
| for f in flags_of(r): | |
| counts["flag"][f] += 1 | |
| if not progressed: | |
| short = {f"{t}/{g}": quotas[t, g] - taken[t, g] for t, g in order if taken[t, g] < quotas[t, g]} | |
| raise ValueError(f"cluster cap {cap} leaves strata short: {short}; raise cluster_cap") | |
| return chosen | |
| ENTRY_KEYS = ("task_id", "crello_id", "tier", "score", "group", "shape", "cluster", "category", | |
| "format", "n_elements", "n_text", "chars", "doc_sha256") | |
| def _entry(r: dict) -> dict: | |
| return {**{k: r[k] for k in ENTRY_KEYS}, "flags": flags_of(r)} | |
| def _identity(snapshot: str, split: str, seed: int, ids: list[str], lite: list[str]) -> str: | |
| return hashlib.sha256(canonical([EVALSET_VERSION, snapshot, split, seed, sorted(ids), | |
| sorted(lite)]).encode()).hexdigest() | |
| def build(pack, *, split: str = DEFAULT_SPLIT, size: int = DEFAULT_SIZE, lite: int = DEFAULT_LITE, | |
| seed: int = 42, validate: bool = True, log=print) -> dict: | |
| """Select, validate, and return the frozen record. `pack` is a CorpusPack.""" | |
| pool = pack.rows(split) | |
| snapshot = pack.snapshot_id | |
| full = select(pool, size, seed=seed, snapshot=snapshot) | |
| lite_rows = select(full, lite, seed=seed, snapshot=snapshot, flag_quota=max(2, lite // 30)) | |
| tasks = sorted((_entry(r) for r in full), key=lambda e: (TIERS.index(e["tier"]), e["task_id"])) | |
| lite_ids = sorted(r["task_id"] for r in lite_rows) | |
| failures = [] | |
| if validate: | |
| pack.prefetch([pack.get(e["task_id"]) for e in tasks]) | |
| for i, e in enumerate(tasks, 1): | |
| try: | |
| task = pack.get(e["task_id"]) | |
| grade = pack.grade(task, pack.gt_doc(task)) | |
| if abs(grade.total - 1.0) > 1e-6: | |
| raise ValueError(f"ground truth scores {grade.total:.4f}, not 1.0") | |
| if not task.get("description"): | |
| raise ValueError("no written brief for description mode") | |
| except Exception as err: # recorded, never substituted | |
| failures.append({"task_id": e["task_id"], "error": f"{type(err).__name__}: {err}"}) | |
| if i % 25 == 0: | |
| log(f" validated {i}/{len(tasks)} ({len(failures)} failures)") | |
| record = { | |
| "evalset_version": EVALSET_VERSION, | |
| "name": f"crello-{split}-{size}", | |
| "snapshot_id": snapshot, | |
| "difficulty": pack.manifest["difficulty"], | |
| "split": split, | |
| "seed": seed, | |
| "size": len(tasks), | |
| "validated": validate, | |
| "failures": failures, | |
| "subsets": {"lite": lite_ids}, | |
| "tasks": tasks, | |
| } | |
| record["evalset_id"] = _identity(snapshot, split, seed, [e["task_id"] for e in tasks], lite_ids) | |
| record["summary"] = summarize(record) | |
| return record | |
| def summarize(record: dict, subset: str | None = None) -> dict: | |
| tasks = subset_tasks(record, subset) | |
| c = lambda key: dict(sorted(Counter(e[key] for e in tasks).items())) # noqa: E731 | |
| flags = Counter(f for e in tasks for f in e["flags"]) | |
| return { | |
| "size": len(tasks), | |
| "tiers": {t: sum(e["tier"] == t for e in tasks) for t in TIERS}, | |
| "groups": c("group"), | |
| "shapes": c("shape"), | |
| "clusters": len({e["cluster"] for e in tasks}), | |
| "max_per_cluster": max(Counter(e["cluster"] for e in tasks).values()), | |
| "categories": len({e["category"] for e in tasks}), | |
| "formats": len({e["format"] for e in tasks}), | |
| "flags": {f: flags[f] for f in FLAGS}, | |
| "median_elements_by_tier": { | |
| t: sorted(e["n_elements"] for e in tasks if e["tier"] == t)[ | |
| len([e for e in tasks if e["tier"] == t]) // 2] for t in TIERS | |
| if any(e["tier"] == t for e in tasks)}, | |
| } | |
| def add_subset(record: dict, name: str, size: int, within: str = "lite") -> list[str]: | |
| """Nest a smaller subset inside an existing one (by the same selection rules), so its scores are a smaller | |
| sample of the same distribution and a larger run later extends them. The set's identity is unchanged.""" | |
| if name in ("full", "lite"): | |
| raise ValueError(f"{name} is built with the set; pick another name") | |
| pool = subset_tasks(record, within) | |
| rows = select(pool, size, seed=record["seed"], snapshot=record["snapshot_id"], flag_quota=max(2, size // 30)) | |
| ids = sorted(r["task_id"] for r in rows) | |
| record["subsets"][name] = ids | |
| return ids | |
| def subset_tasks(record: dict, subset: str | None = None) -> list[dict]: | |
| if not subset or subset == "full": | |
| return list(record["tasks"]) | |
| ids = set(record["subsets"][subset]) | |
| return [e for e in record["tasks"] if e["task_id"] in ids] | |
| def save(record: dict, path: str | Path) -> Path: | |
| path = Path(path) | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| path.write_text(json.dumps(record, indent=1, ensure_ascii=False) + "\n") | |
| return path | |
| def load(path: str | Path, snapshot_id: str | None = None) -> dict: | |
| """Read a frozen set and refuse one that is inconsistent or built for another corpus.""" | |
| record = json.loads(Path(path).read_text()) | |
| if record.get("evalset_version") != EVALSET_VERSION: | |
| raise ValueError("rebuild this evaluation set for the current evalset version") | |
| if snapshot_id and record["snapshot_id"] not in (snapshot_id, record.get("corpus_snapshot_id")): | |
| raise ValueError("evaluation set was built for a different corpus snapshot; rebuild it") | |
| if record.get("failures"): | |
| raise ValueError(f"{len(record['failures'])} task(s) failed validation; fix them before scoring") | |
| ids = [e["task_id"] for e in record["tasks"]] | |
| if len(set(ids)) != len(ids) or len(ids) != record["size"]: | |
| raise ValueError("evaluation set size or task uniqueness is inconsistent") | |
| for name, members in record["subsets"].items(): | |
| if not set(members) <= set(ids): | |
| raise ValueError(f"{name} subset is not inside the full set") | |
| snap = record.get("corpus_snapshot_id", record["snapshot_id"]) | |
| if _identity(snap, record["split"], record["seed"], ids, record["subsets"]["lite"]) != record["evalset_id"]: | |
| raise ValueError("evaluation set identity does not match its task ids") | |
| return record | |
| def check_against(record: dict, pack) -> None: | |
| """Every task still resolves to the same ground truth in the served catalog.""" | |
| for e in record["tasks"]: | |
| task = pack.get(e["task_id"]) | |
| if task["crello_id"] != e["crello_id"]: | |
| raise ValueError(f"{e['task_id']} is design {task['crello_id']} here, not {e['crello_id']}") | |
| from .corpus import doc_digest | |
| if doc_digest(task["doc"]) != e["doc_sha256"]: | |
| raise ValueError(f"{e['task_id']}: ground truth differs from the frozen set") | |
| def export_pack(pack, record: dict, out: str | Path) -> dict: | |
| """Write the set as an ordinary offline task pack (no bucket, no network needed). | |
| Task ids are kept, so `reset(task_id=...)` and results line up with runs | |
| against the full corpus. Positions in the pack follow the frozen order. | |
| """ | |
| import shutil | |
| if record.get("failures"): | |
| raise ValueError("refusing to export a set with recorded failures") | |
| out = Path(out) | |
| (out / "tasks").mkdir(parents=True, exist_ok=True) | |
| if not (out / "fonts").exists(): | |
| shutil.copytree(pack.fonts.root, out / "fonts") | |
| lines = [] | |
| for pos, e in enumerate(record["tasks"]): | |
| task = dict(pack.get(e["task_id"])) | |
| src = pack.materialize(task) | |
| pack.preview_path(task) | |
| dest = out / "designs" / task["crello_id"] | |
| if not dest.exists(): | |
| shutil.copytree(src, dest, ignore=shutil.ignore_patterns(".ok")) | |
| task["index"] = pos # position in this pack; task_id keeps the corpus identity | |
| lines.append(json.dumps(task, separators=(",", ":"))) | |
| (out / "tasks" / f"{record['split']}.jsonl").write_text("\n".join(lines) + "\n") | |
| manifest = { | |
| "version": 1, | |
| "created": record.get("created"), | |
| "source": pack.manifest["source"], | |
| "splits": {record["split"]: len(lines)}, | |
| "corpus_snapshot_id": record["snapshot_id"], | |
| "evalset_id": record["evalset_id"], | |
| "max_elements": pack.manifest["config"]["max_elements"], | |
| } | |
| (out / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n") | |
| save({**record, "corpus_snapshot_id": record["snapshot_id"]}, out / "evalset.json") | |
| return manifest | |