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)