DesignGym / core /evalset.py
AdithyaSK's picture
AdithyaSK HF Staff
Serve Crello corpus aafdb0e86816 from the mounted bucket
df84e45 verified
Raw History Blame Contribute Delete
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