Spaces:
Running
Running
File size: 18,576 Bytes
4d6fb4f 9a668d8 4d6fb4f 9a668d8 4d6fb4f 11a28fa 4d6fb4f 9a668d8 4d6fb4f 9a668d8 4d6fb4f 9a668d8 4d6fb4f 9a668d8 4d6fb4f 9a668d8 4d6fb4f 9a668d8 4d6fb4f 9a668d8 4d6fb4f 9a668d8 4d6fb4f 9a668d8 4d6fb4f 218b520 11a28fa 4d6fb4f 9a668d8 4d6fb4f 11a28fa 4d6fb4f 96e822b 4d6fb4f 9a668d8 4d6fb4f 9a668d8 4d6fb4f 11a28fa 9a668d8 11a28fa | 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 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 | """Data loading and subset configuration for the TabArena leaderboard.
This module owns everything about *where* leaderboard artifacts live and *how*
they are read. Layout (Gradio components) lives in ``views.py`` and ``pages.py``;
user-facing copy lives in ``website_texts.py``.
Performance note: the website optimizes for fast first paint. CSVs are tiny and
cached (:func:`load_leaderboard_csv`); the large per-subset PNGs are only
unzipped on demand (:meth:`LBContainer.image_path`) and only for the subset the
user is currently viewing.
"""
from __future__ import annotations
import re
import zipfile
from collections.abc import Iterable
from dataclasses import dataclass, field
from functools import lru_cache
from pathlib import Path
import pandas as pd
from constants import Constants
DATA_DIR = Path(__file__).parent / "data"
# BeyondArena artifacts live under their own root (see
# scripts/run_generate_beyondarena_website_artifacts.py in the tabarena repo).
BEYOND_DATA_DIR = Path(__file__).parent / "data_beyondarena"
# --------------------------------------------------------------------------- #
# Subset axes
#
# A leaderboard "subset" is one cell of a 5-axis grid. Three axes are *view
# modifiers* surfaced as controls (entrants, imputation, splits); two are
# *content subsets* surfaced as tab bars (tasks, datasets). Keeping the axis
# definitions here (as data, not as if/elif chains) means adding or reordering a
# subset is a one-line edit. The first value of each axis is its default.
# --------------------------------------------------------------------------- #
# Who competes. Every leaderboard number is relative to the field: Elo is pairwise over
# the participants and improvability is measured against the best of them, so each pool is
# its own evaluation with its own artifacts rather than a filter over a shared table.
#
# Models always compete. Systems fall into these independently selectable categories, and
# every combination is published. Independent rather than a cumulative ladder on purpose:
# "LLM-based systems but not the plain open-source ones" is a real question, and a ladder
# cannot express it. Mirrors `SYSTEM_CATEGORIES` in tabarena/evaluation/entrants.py, whose
# `pool_key` builds the folder segments below.
SYSTEM_CATEGORY_LABELS = {
"open": "📊 Open-source systems",
"llm": "🤖 Systems with LLMs",
"api": "🔒 Closed-source API systems",
}
# The tag that puts a system in each category; None is the untagged (plain open-source) group.
SYSTEM_CATEGORY_TAGS = {
"open": None,
"llm": "with-llm",
"api": "closed-source-api",
}
SYSTEM_CATEGORY_NOTES = {
"open": "Systems you can inspect and run yourself, such as AutoGluon.",
"llm": "Systems with an LLM in the loop, including agents.",
"api": "Systems behind a remote API whose internals we cannot inspect.",
}
# Shown on a category that has no entrant yet, which is rendered as a disabled toggle.
CATEGORY_COMING_SOON = "Coming soon: waiting for a submission"
# Folder segment for the pool where no system competes.
MODELS_ONLY_KEY = "models"
def entrants_key(categories: Iterable[str]) -> str:
"""Folder segment for a set of selected categories, in `SYSTEM_CATEGORY_LABELS` order.
Order-independent, so ticking the boxes in any order lands on the same artifacts.
Mirrors `pool_key` in tabarena/evaluation/entrants.py.
"""
selected = set(categories or ())
ordered = [key for key in SYSTEM_CATEGORY_LABELS if key in selected]
return "_".join(ordered) if ordered else MODELS_ONLY_KEY
def widest_entrants_key() -> str:
"""The pool where every category competes; the one to read totals from."""
return entrants_key(SYSTEM_CATEGORY_LABELS)
def entrants_categories(key: str) -> list[str]:
"""The selected category keys encoded in a folder segment."""
return [] if key == MODELS_ONLY_KEY else [k for k in SYSTEM_CATEGORY_LABELS if k in key.split("_")]
def entrants_name(key: str) -> str:
"""Human-readable name for a pool, used in figure labels."""
selected = entrants_categories(key)
if not selected:
return "Models only"
return "Models + " + ", ".join(SYSTEM_CATEGORY_LABELS[k] for k in selected)
def entrants_note(key: str) -> str:
"""One line describing who competes in a pool."""
selected = entrants_categories(key)
if not selected:
return "Individual models only, each run under TabArena's shared tuning protocol."
return "Also competing: " + " ".join(SYSTEM_CATEGORY_NOTES[k] for k in selected)
@lru_cache(maxsize=None)
def available_categories(data_root: str) -> frozenset[str]:
"""Which system categories actually have an entrant in the published artifacts.
Read from the widest pool's leaderboard, so a category nobody has submitted to yet can be
shown as a disabled toggle instead of a setting that silently changes nothing.
"""
path = Path(data_root) / Subset(entrants=widest_entrants_key()).rel_path / "website_leaderboard.csv"
if not path.exists():
return frozenset()
df = load_leaderboard_csv(str(path.resolve()))
if "MethodClass" not in df.columns:
return frozenset()
systems = df[df["MethodClass"] == "system"]
if systems.empty:
return frozenset()
# An empty Tags cell round-trips through CSV as NaN, and `str(nan)` is the string "nan",
# so an untagged system would read as tagged. Test for null rather than truthiness.
tag_sets = [
set() if pd.isna(v) else {t for t in str(v).split(";") if t}
for v in systems.get("Tags", pd.Series(dtype=object))
]
found = set()
for key, tag in SYSTEM_CATEGORY_TAGS.items():
if tag is None:
if any(not tags for tags in tag_sets):
found.add(key)
elif any(tag in tags for tags in tag_sets):
found.add(key)
return frozenset(found)
# axis -> {value: human label}. Insertion order = display order; first = default.
TASK_LABELS = {
"all": "All Tasks",
"classification": "Classification",
"regression": "Regression",
"binary": "Binary",
"multiclass": "Multiclass",
}
DATASET_LABELS = {
"all": "All Datasets",
"small": "Small",
"medium": "Medium",
}
# Short labels used as column headers in the cross-subset overview.
TASK_SHORT = {
"all": "Overall",
"classification": "Class.",
"regression": "Regr.",
"binary": "Binary",
"multiclass": "Multi.",
}
DATASET_SHORT = {
"small": "Small",
"medium": "Medium",
}
# What each choice means, shown as a hover tooltip on the chip (see `main.taStampTitles`).
# Every selector in the control card carries one, so nothing has to be guessed from a label.
TASK_NOTES = {
"all": "Every task type: binary and multiclass classification plus regression.",
"classification": "Classification only, binary and multiclass together.",
"regression": "Regression tasks only, scored with RMSE.",
"binary": "Binary classification only, scored with ROC AUC.",
"multiclass": "Multiclass classification only, scored with log-loss.",
}
DATASET_NOTES = {
"all": "Every curated dataset, whatever its size.",
"small": "Datasets with at most 10,000 training rows.",
"medium": "Datasets with between 10,001 and 100,000 training rows.",
}
# What each row of the control card selects, hovered on the caption at its left. Keyed by the
# class on the row, because a CSS ::before caption cannot carry a title of its own; the title
# goes on the row and a chip's own tooltip still wins over it (see `main.taStampTitles`).
AXIS_NOTES = {
"ta-row-entrants": (
"Who is scored together. Each combination is evaluated separately, so switching re-rates "
"everyone rather than hiding rows: Elo is pairwise over the entrants and Improvability is "
"the gap to the best of them."
),
"axis-care": (
"What to optimise for. Reorders the figures and picks the time axis the Pareto front is "
"plotted against."
),
"axis-metric": (
"Which headline metric the page leads with. The second figure stays pinned to the other "
"one, so both are always on the page."
),
"axis-tasks": "Restrict the leaderboard to one task type.",
"axis-datasets": "Restrict the leaderboard to one dataset-size bucket.",
"ta-row-protocol": "How the reported numbers were computed.",
}
PROTOCOL_NOTES = {
"imputed": (
"Include methods that cannot run on every dataset. Their missing results are imputed "
"with a default RandomForest, which counts against them for not covering the benchmark."
),
"lite": (
"Score each experiment on one split (first fold, first repeat) instead of all repeats. "
"Cheaper and less reliable, but usually a good proxy."
),
}
DATASET_SIZE_NOTE = {
"small": "Small datasets have at most 10,000 training rows.",
"medium": "Medium datasets have between 10,001 and 100,000 training rows.",
"tabpfn": (
"TabPFNv2-compatible datasets contain at most 10,000 samples, "
"500 features, and 10 classes."
),
}
@dataclass(frozen=True)
class Subset:
"""One cell of the leaderboard grid (entrants x imputation x splits x tasks x datasets).
``rel_path`` mirrors ``get_website_folder_name`` in
``tabarena/evaluation/subset_grid.py`` segment for segment: the path *is* the subset's
identity on both sides, so changing the layout means changing both.
"""
entrants: str = "models" # `entrants_key(...)` of the selected categories
imputation: str = "yes" # "yes" | "no"
splits: str = "all" # "all" | "lite"
tasks: str = "all" # see TASK_LABELS
datasets: str = "all" # see DATASET_LABELS
@property
def rel_path(self) -> str:
return (
f"entrants_{self.entrants}/"
f"imputation_{self.imputation}/"
f"splits_{self.splits}/"
f"tasks_{self.tasks}/"
f"datasets_{self.datasets}"
)
#: Family name the artifacts used before systems became their own entrant class. Artifacts
#: generated then are still served: every BeyondArena subset, and any TabArena subset not yet
#: regenerated. Without this the rows keep a family nothing maps a colour or pill to and render
#: grey, so the name is normalized on read and the rest of the app only ever sees "System".
_LEGACY_FAMILY_NAMES = {"Reference Pipeline": Constants.system}
@lru_cache(maxsize=None)
def load_leaderboard_csv(path: str) -> pd.DataFrame:
"""Read a ``website_leaderboard.csv`` (cached; files are tiny and immutable)."""
df = pd.read_csv(path)
df = df.rename(columns={"1#": "#"})
if "TypeName" in df.columns:
df["TypeName"] = df["TypeName"].replace(_LEGACY_FAMILY_NAMES)
return df
VARIANT_RE = re.compile(r"\((default|tuned \+ ensembled|tuned)\)")
def parse_model(model: str) -> tuple[str, str, str | None]:
"""Split a Model cell into (base name, variant, url).
A cell looks like ``[TabFM (default)](https://…)``, optionally followed by an
``[X% IMPUTED]`` tag. Used both for display (``views.py``) and for the JSON
records the API returns (``api.py``), so the two cannot disagree.
"""
link = re.match(r"\[(.*?)\]\((.*?)\)", model)
text, url = (link.group(1), link.group(2)) if link else (model, None)
text = text.split("[")[0].strip() # drop any [X% IMPUTED] tag
variant_match = VARIANT_RE.search(text)
variant = variant_match.group(1) if variant_match else ""
base = VARIANT_RE.sub("", text).strip()
return base, variant, url
def unzip_png(base_dir: Path, img_name: str) -> str:
"""Return the path to ``base_dir/img_name``.png, unzipping the ``.png.zip`` on first access."""
base = Path(base_dir) / img_name
img_path = base.with_suffix(".png")
if img_path.exists():
return str(img_path)
with zipfile.ZipFile(base.with_suffix(".png.zip"), "r") as zipf:
zipf.extractall(img_path.parent)
return str(img_path)
@dataclass
class LBContainer:
"""Loads the artifacts for a single subset under a given data root."""
data_root: Path
subset: Subset
name: str
n_datasets: int | None = None
blurb: str | None = None
base_path: Path = field(init=False)
def __post_init__(self) -> None:
self.base_path = Path(self.data_root) / self.subset.rel_path
for fname in self._listdir():
match = re.match(r"n_datasets_(.+)", fname)
if match:
self.n_datasets = match.group(1)
break
def _listdir(self) -> list[str]:
try:
return [p.name for p in self.base_path.iterdir()]
except FileNotFoundError:
return []
def load_df(self) -> pd.DataFrame:
return load_leaderboard_csv(str((self.base_path / "website_leaderboard.csv").resolve())).copy()
def has_image(self, img_name: str) -> bool:
"""Whether this subset ships a static ``img_name`` figure.
TabArena subsets ship interactive explorers only, so this is False for them; the
BeyondArena subsets and any pre-explorer artifacts still carry PNGs.
"""
base = self.base_path / img_name
return base.with_suffix(".png").exists() or base.with_suffix(".png.zip").exists()
def image_path(self, img_name: str) -> str:
"""Return the path to ``img_name``.png, unzipping it on first access."""
return unzip_png(self.base_path, img_name)
def html_content(self, name: str) -> str | None:
"""Return the inline content of ``name``.html (a self-contained
interactive plot generated by the tabarena artifact pipeline), or
``None`` when the subset's data predates these artifacts — callers
fall back to the static PNG then.
"""
path = self.base_path / f"{name}.html"
try:
return path.read_text(encoding="utf-8")
except FileNotFoundError:
return None
def subset_name(subset: Subset) -> str:
"""Human-readable name for a subset, used in figure labels."""
impute = "with imputation" if subset.imputation == "yes" else "no imputation"
split = "all repeats" if subset.splits == "all" else "Lite"
return (
f"{entrants_name(subset.entrants)} | {TASK_LABELS[subset.tasks]} "
f"| {DATASET_LABELS[subset.datasets]} | {split} | {impute}"
)
def subset_blurb(subset: Subset, n_datasets: int | None) -> str:
"""One-line description of the subset shown above its figures."""
datasets_name = DATASET_LABELS[subset.datasets].lower()
blurb = (
f"Leaderboard for {n_datasets} datasets "
f"({datasets_name}, {TASK_LABELS[subset.tasks].lower()}) "
)
if subset.splits == "lite":
blurb += "for one split (1st fold, 1st repeat) "
blurb += "including all "
if subset.imputation == "yes":
blurb += "(imputed) "
blurb += "models."
# Which entrants competed decides every number above, so it is said here too.
entrant_note = entrants_note(subset.entrants)
if entrant_note:
blurb += f"<br>{entrant_note}"
note = DATASET_SIZE_NOTE.get(subset.datasets)
if note:
blurb += f"<br>{note}"
return blurb
# --------------------------------------------------------------------------- #
# BeyondArena subsets
#
# BeyondArena diverges from TabArena: there is no imputation/splits/tasks/datasets
# grid. Instead a single axis of subset dimensions (split regime, size bucket,
# feature dimensionality/type) is surfaced as one tab bar, and every leaderboard
# is always computed on the recommended `core` protocol (`["core", <dim>]`; the
# "full" subset is `core` with no extra filter). The artifacts are produced by
# scripts/run_generate_beyondarena_website_artifacts.py in the tabarena repo, whose
# `BEYOND_SUBSETS` keys must match the labels below.
# --------------------------------------------------------------------------- #
# label -> human name. Insertion order = tab-bar order; first = default. Groups are
# only used to draw section separators in the tab bar / copy.
BEYOND_SUBSET_LABELS = {
"full": "Full",
"random": "IID",
"temporal": "Temporal",
"grouped": "Grouped",
"tiny": "Tiny",
"small": "Small",
"medium": "Medium",
"large": "Large",
"low-dim": "Low-dim",
"high-dim": "High-dim",
"text": "Text",
"high-cardinality": "High-cardinality",
}
# One-line description shown above each subset's figures. Kept in sync with the
# BeyondArena subset predicates (see BeyondArenaContext.SUBSET_PREDICATES).
BEYOND_SUBSET_NOTE = {
"full": "All BeyondArena datasets, on the recommended core protocol.",
"random": "IID (randomly split) tasks only.",
"temporal": "Temporally split tasks only: train on the past, test on the future.",
"grouped": "Group-wise split tasks only, with disjoint groups between train and test.",
"tiny": "Tiny datasets contain at most 1,000 training rows.",
"small": "Small datasets contain between 1,001 and 10,000 training rows.",
"medium": "Medium datasets contain between 10,001 and 100,000 training rows.",
"large": "Large datasets contain between 100,001 and 1,000,000 training rows.",
"low-dim": "Low-dimensional datasets have at most 100 columns after preprocessing.",
"high-dim": "High-dimensional datasets have more than 100 columns after preprocessing.",
"text": "Datasets that contain one or more text columns.",
"high-cardinality": "Datasets that contain one or more high-cardinality categorical columns.",
}
@dataclass(frozen=True)
class BeyondSubset:
"""One cell of the BeyondArena leaderboard — a single subset dimension, always on core."""
subset: str = "full" # see BEYOND_SUBSET_LABELS
@property
def rel_path(self) -> str:
return f"subsets/{self.subset}"
def beyond_subset_name(subset: BeyondSubset) -> str:
"""Human-readable name for a BeyondArena subset, used in figure labels."""
return f"{BEYOND_SUBSET_LABELS[subset.subset]} · core"
def beyond_subset_blurb(subset: BeyondSubset, n_datasets: int | None) -> str:
"""One-line description of a BeyondArena subset shown above its figures."""
human = BEYOND_SUBSET_LABELS[subset.subset].lower()
blurb = (
f"Leaderboard for {n_datasets} BeyondArena datasets ({human}), evaluated on the "
"recommended <b>core</b> protocol."
)
note = BEYOND_SUBSET_NOTE.get(subset.subset)
if note:
blurb += f"<br>{note}"
return blurb
|