Spaces:
Running on Zero
Running on Zero
File size: 18,265 Bytes
a1fcbcc 86bdc8e a1fcbcc 86bdc8e a1fcbcc 1936853 a1fcbcc ba1980c a1fcbcc 1936853 a1fcbcc ba1980c a1fcbcc acbe59f a1fcbcc 86bdc8e a1fcbcc 86bdc8e a1fcbcc 86bdc8e a1fcbcc 86bdc8e a1fcbcc 86bdc8e a1fcbcc 86bdc8e a1fcbcc ecc211b a1fcbcc ecc211b a1fcbcc ecc211b a1fcbcc ecc211b a1fcbcc 86bdc8e a1fcbcc 86bdc8e a1fcbcc 86bdc8e a1fcbcc 86bdc8e a1fcbcc ecc211b a1fcbcc ecc211b a1fcbcc ecc211b 86bdc8e ecc211b 86bdc8e a1fcbcc ecc211b a1fcbcc ecc211b | 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 | import hashlib
import json
import logging
import math
import os
import re
from collections import defaultdict
from datetime import datetime, timezone
from html import escape
from pathlib import Path
import spaces
import gradio as gr
from dotenv import load_dotenv
from huggingface_hub import CommitOperationAdd, HfApi, hf_hub_download
load_dotenv(Path(__file__).with_name(".env"))
REPO_ID = "gamemeine/pllum-slurm-dataset"
DATA_FILE = "data/evaluation_dataset.gt.json"
TOLERANCE = 1e-6
ERROR_TYPES = ("wrong_answer", "parsing_error", "tool_error", "call_budget_exceeded",
"context_limit", "missing_prediction", "evaluation_error")
RECENT_LIMIT = 50
api = HfApi(token=os.environ.get("HF_TOKEN"))
@spaces.GPU(duration=1)
def _zero_gpu_startup_marker():
"""Register with ZeroGPU's startup check; the app never calls this function."""
return None
def reject_constant(value):
raise ValueError(f"Invalid JSON value: {value}")
def read_json(path):
return json.loads(Path(path).read_text(encoding="utf-8"), parse_constant=reject_constant)
def download(filename, revision):
return hf_hub_download(REPO_ID, filename, repo_type="dataset", revision=revision, token=api.token)
def load_benchmark(revision):
content = Path(download(DATA_FILE, revision)).read_bytes()
rows = json.loads(content.decode("utf-8"), parse_constant=reject_constant)
if not isinstance(rows, list) or not all(isinstance(row, dict) for row in rows):
raise ValueError("The benchmark must contain a list of question objects.")
if not rows:
raise ValueError("The benchmark is empty.")
return rows, hashlib.sha256(content).hexdigest()
def number(value):
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
raise ValueError("Prediction must be a number or null.")
if isinstance(value, str):
value = value.replace(",", ".")
value = float(value)
if not math.isfinite(value):
raise ValueError("Numbers must be finite.")
return value
def classify_error(error):
"""Classify evaluator messages"""
text = str(error).lower()
if "tool call budget exhausted" in text:
return "call_budget_exceeded"
if "exceeds max-model-len" in text:
return "context_limit"
if "unknown tool:" in text or " requires arguments " in text or re.match(
r"^(add|subtract|multiply|divide|modulo|power|absolute_value|square_root):", text
):
return "tool_error"
if any(marker in text for marker in (
"expected only a final number", "final answer does not contain a number",
"expected exactly one tagged", "tool call must contain", "tool name must",
"tool arguments must", "expected a finite real number", "floating-point range",
"expecting value", "expecting property name", "expecting ',' delimiter",
"expecting ':' delimiter", "unterminated string", "extra data", "invalid control character", "invalid escape",
)):
return "parsing_error"
return "evaluation_error"
def score(samples, benchmark):
if not isinstance(samples, list) or not all(isinstance(sample, dict) for sample in samples):
raise ValueError("samples.json must contain a list of evaluator samples.")
def index_rows(rows, label):
indexed = {}
for row in rows:
sample_id = row.get("id")
if isinstance(sample_id, bool) or not isinstance(sample_id, (str, int)) or (isinstance(sample_id, str) and not sample_id.strip()):
raise ValueError(f"Each {label} row must have a nonempty string or integer id.")
if sample_id in indexed:
raise ValueError(f"Duplicate id in {label}: {sample_id!r}.")
indexed[sample_id] = row
return indexed
references = index_rows(benchmark, "benchmark")
submitted = index_rows(samples, "submission")
if not references:
raise ValueError("The benchmark is empty.")
if submitted.keys() != references.keys():
missing = references.keys() - submitted.keys()
unknown = submitted.keys() - references.keys()
raise ValueError(f"Submission ids must match the full benchmark: {len(missing)} missing, {len(unknown)} unknown.")
categories = defaultdict(lambda: {"total": 0, "correct": 0})
error_counts = dict.fromkeys(ERROR_TYPES, 0)
details = []
for index, sample in enumerate(samples):
reference = references[sample["id"]]
answer = number(reference["answer"])
category_name = reference["category"]
if not isinstance(category_name, str) or not category_name:
raise ValueError("Each benchmark row must have a nonempty category.")
prediction, absolute_error = None, None
prediction_error = False
if sample.get("prediction") is not None:
try:
prediction = number(sample["prediction"])
absolute_error = abs(prediction - answer)
if not math.isfinite(absolute_error):
absolute_error = None
except (ValueError, TypeError, OverflowError):
prediction_error = True
if sample.get("error"):
outcome = classify_error(sample["error"])
elif prediction_error:
outcome = "parsing_error"
elif prediction is None:
outcome = "missing_prediction"
elif abs(prediction - answer) <= TOLERANCE:
outcome = "correct"
else:
outcome = "wrong_answer"
correct = outcome == "correct"
if not correct:
error_counts[outcome] += 1
details.append({
"id": sample["id"], "index": index,
"prompt": reference.get("prompt", reference.get("task")), "category": category_name,
"expected_answer": answer, "prediction": prediction,
"absolute_error": absolute_error, "outcome": outcome, "error": sample.get("error"),
})
category = categories[category_name]
category["total"] += 1
category["correct"] += int(correct)
for category in categories.values():
category["accuracy"] = category["correct"] / category["total"]
correct = sum(category["correct"] for category in categories.values())
return {
"total": len(samples), "correct": correct, "accuracy": correct / len(samples),
"matching": "id", "by_category": dict(sorted(categories.items())),
"error_counts": error_counts, "sample_results": details,
}
def tables(results, categories):
difficulty_order = {"easy": 0, "medium": 1, "challanging": 2, "hard": 3}
categories = sorted(categories, key=lambda category: (difficulty_order.get(category.lower(), 4), category))
headers = ["Login", "Accuracy (%)"] + [
f"{category.capitalize()} (%)" for category in categories
] + ["Submission"]
def row(result):
return [result["login"], round(100 * result["accuracy"], 2)] + [
round(100 * result["by_category"][category]["accuracy"], 2) for category in categories
] + [result["submission_id"]]
recent = sorted(results, key=lambda result: result["created_at"], reverse=True)[:RECENT_LIMIT]
submissions = gr.update(
headers=headers + ["Submitted (UTC)", "Correct / total"] + list(ERROR_TYPES),
value=[
row(result) + [result["created_at"][:19].replace("T", " "), f"{result['correct']} / {result['total']}"]
+ [result["error_counts"][error] for error in ERROR_TYPES]
for result in recent
],
)
best = {}
# Highest accuracy wins; the earliest submission wins an exact tie.
for result in sorted(results, key=lambda result: (-result["accuracy"], result["created_at"], result["submission_id"])):
best.setdefault(result["login"], result)
leaderboard = gr.update(
headers=["#"] + headers,
value=[[rank] + row(result) for rank, result in enumerate(best.values(), 1)],
)
return submissions, leaderboard
def refresh_tables():
try:
revision = api.dataset_info(REPO_ID).sha
benchmark, digest = load_benchmark(revision)
categories = sorted({row["category"] for row in benchmark})
results = []
for filename in api.list_repo_files(REPO_ID, repo_type="dataset", revision=revision):
if filename.startswith("submissions/") and filename.endswith("/results.json"):
result = read_json(download(filename, revision))
if result.get("dataset_sha256") == digest:
# Rescore older submissions using the current ID matching rules.
if "error_counts" not in result or result.get("matching") != "id":
samples_file = filename.removesuffix("results.json") + "samples.json"
result.update(score(read_json(download(samples_file, revision)), benchmark))
results.append(result)
return tables(results, categories)
except Exception as exc:
logging.exception("Could not load submissions")
raise gr.Error("Could not load results. Check HF_TOKEN access and the benchmark file in the dataset.") from exc
def submission_card(result):
errors = "".join(
f'<li><span>{escape(name.replace("_", " ").capitalize())}</span><strong>{count}</strong></li>'
for name, count in result["error_counts"].items() if count
)
error_summary = (
f'<ul class="submission-errors">{errors}</ul>' if errors
else '<p class="submission-no-errors">No errors</p>'
)
return f"""<section class="submission-card" role="status" aria-live="polite">
<div class="submission-saved"><span aria-hidden="true">✓</span> Submission saved</div>
<code class="submission-id">{escape(result['submission_id'])}</code>
<div class="submission-score">
<span class="submission-score-label">Accuracy</span>
<strong>{result['accuracy']:.2%}</strong>
<span class="submission-score-detail">{result['correct']} / {result['total']} correct</span>
</div>
<div class="submission-error-summary"><h3>Errors</h3>{error_summary}</div>
</section>"""
def submit(login, filepath):
login = (login or "").strip()
if not re.fullmatch(r"tutorial0(?:0[1-9]|[12][0-9]|3[0-3])", login):
raise gr.Error("Use your assigned login: tutorial001–tutorial033.")
if not filepath:
raise gr.Error("Upload samples.json.")
try:
samples = read_json(filepath)
revision = api.dataset_info(REPO_ID).sha
benchmark, digest = load_benchmark(revision)
result = score(samples, benchmark)
except (ValueError, TypeError, KeyError, OverflowError) as exc:
raise gr.Error(f"Invalid submission: {exc}") from exc
except Exception as exc:
logging.exception("Could not load the benchmark")
raise gr.Error("Could not load the benchmark. Check dataset access.") from exc
now = datetime.now(timezone.utc)
submission_id = f"{login}-{now:%Y%m%dT%H%M%S%fZ}"
result.update(login=login, submission_id=submission_id, created_at=now.isoformat(),
dataset=DATA_FILE, dataset_sha256=digest, tolerance=TOLERANCE)
try:
api.create_commit(
repo_id=REPO_ID, repo_type="dataset", commit_message=f"Submission {submission_id}",
operations=[
CommitOperationAdd(path_in_repo=f"submissions/{submission_id}/samples.json", path_or_fileobj=Path(filepath)),
CommitOperationAdd(path_in_repo=f"submissions/{submission_id}/results.json",
path_or_fileobj=json.dumps(result, ensure_ascii=False, indent=2, allow_nan=False).encode("utf-8")),
],
)
except Exception as exc:
logging.exception("Could not save the submission")
raise gr.Error("Could not save the submission. HF_TOKEN needs write access to the dataset.") from exc
status = submission_card(result)
try:
return status, *refresh_tables()
except gr.Error:
return status + '<p class="submission-refresh-note">Results are temporarily unavailable — click Refresh.</p>', gr.skip(), gr.skip()
CSS = Path(__file__).with_name("styles.css").read_text(encoding="utf-8")
THEME = gr.themes.Base(
primary_hue="teal", secondary_hue="purple", neutral_hue="slate",
).set(
body_background_fill="#f5f5fa", body_background_fill_dark="#19172e",
body_text_color="#2b2661", body_text_color_dark="#eeecfa",
body_text_color_subdued="#69667e", body_text_color_subdued_dark="#bab5d1",
block_background_fill="#ffffff", block_background_fill_dark="#24213d",
panel_background_fill="#ffffff", panel_background_fill_dark="#24213d",
block_border_color="#e4e2ee", block_border_color_dark="#403b5e",
input_background_fill="#fafafe", input_background_fill_dark="#19172e",
input_border_color="#d8d5e5", input_border_color_dark="#504969",
input_border_color_focus="#17bcaf", input_border_color_focus_dark="#17bcaf",
button_primary_background_fill="#17bcaf", button_primary_background_fill_dark="#17bcaf",
button_primary_background_fill_hover="#38cec2", button_primary_background_fill_hover_dark="#38cec2",
button_primary_text_color="#172943", button_primary_text_color_dark="#172943",
button_primary_text_color_hover="#172943", button_primary_text_color_hover_dark="#172943",
button_primary_border_color="#17bcaf", button_primary_border_color_dark="#17bcaf",
button_secondary_background_fill="#f3f0f8", button_secondary_background_fill_dark="#352c4e",
button_secondary_text_color="#59358c", button_secondary_text_color_dark="#e0cfef",
table_even_background_fill="#f8f7fc", table_even_background_fill_dark="#2b2746",
table_odd_background_fill="#ffffff", table_odd_background_fill_dark="#24213d",
table_border_color="#e4e2ee", table_border_color_dark="#403b5e",
table_row_focus="#e4f7f4", table_row_focus_dark="#234340",
link_text_color="#59358c", link_text_color_dark="#70dfd5",
)
with gr.Blocks(title="Evaluating LLMs on Athena: a practical introduction to Slurm") as demo:
with gr.Column(elem_id="workshop"):
gr.HTML("""<header class="workshop-hero">
<div class="hero-shapes" aria-hidden="true"><i></i><i></i><i></i></div>
<p class="eyebrow">MLinPL Tutorial · PLLuM</p>
<h1>Evaluating LLMs on Athena:<span>a practical introduction to Slurm</span></h1>
<p class="hero-note">Run an experiment. Evaluate your model. Compare the results.</p>
</header>""", elem_id="hero")
with gr.Column(elem_id="submit-panel"):
gr.Markdown("""## Submit your evaluation
Enter your assigned login and upload **samples.json** from a full benchmark evaluation.
`$SCRATCH/pllum-slurm-lab/evaluation/samples.json`
""", elem_id="intro")
with gr.Row(elem_id="upload-fields"):
login = gr.Textbox(label="Login", placeholder="tutorial001", info="tutorial001–tutorial033",
elem_id="login-field", scale=1, min_width=220)
upload = gr.File(label="samples.json", file_types=[".json"], type="filepath",
elem_id="upload-field", scale=2, min_width=280, height=160)
with gr.Row(elem_id="submit-actions"):
send = gr.Button("Submit", variant="primary", elem_id="submit-button", scale=0, min_width=240)
status = gr.HTML(elem_id="status")
with gr.Accordion("How scoring works", open=False, elem_id="scoring-accordion"):
gr.Markdown("""Upload all benchmark IDs. Samples are matched by **id**. A prediction is correct if it matches the reference within **1e-6**
and has no evaluator error. **Accuracy = correct / all questions**, overall and per category.
Each failed question receives one error type:
| Error type | Meaning |
| --- | --- |
| `wrong_answer` | A valid numerical prediction falls outside the tolerance. |
| `parsing_error` | The evaluator could not parse the response, or the prediction is not a finite number. |
| `tool_error` | An unknown tool, wrong arguments, or a failed calculator operation. |
| `call_budget_exceeded` | The model exceeded the allowed number of tool calls. |
| `context_limit` | The conversation exceeded the model context limit. |
| `missing_prediction` | No final prediction and no reported evaluator error. |
| `evaluation_error` | Another evaluator failure, including unrecognized error messages. |
Error types are inferred from the evaluator's error messages. A reported evaluator error takes precedence
over the prediction. Error columns show **counts of failed questions**, so correct + all error counts = total.
The saved `results.json` includes per-question outcomes, reference answers and absolute differences.
Both tabs show results for the current benchmark only.
""", elem_id="scoring")
with gr.Column(elem_id="results-panel"):
with gr.Tabs(elem_id="results-tabs"):
with gr.Tab("Leaderboard"):
leaderboard_table = gr.Dataframe(interactive=False, label="Best result per login",
elem_id="leaderboard-table", max_height=420)
with gr.Tab("Submissions"):
submissions_table = gr.Dataframe(interactive=False, label="Latest submissions",
elem_id="submissions-table", max_height=420)
gr.Markdown("Accuracy is shown in %. Equal scores are ranked by earliest submission. "
"Submissions shows the 50 latest runs.", elem_id="results-note")
with gr.Row(elem_id="refresh-actions"):
refresh = gr.Button("Refresh", elem_id="refresh-button", scale=0, min_width=160)
outputs = [submissions_table, leaderboard_table]
send.click(submit, inputs=[login, upload], outputs=[status, *outputs], concurrency_limit=1)
refresh.click(refresh_tables, outputs=outputs)
demo.load(refresh_tables, outputs=outputs)
if __name__ == "__main__":
demo.queue().launch(theme=THEME, css=CSS)
|