Spaces:
Running
Running
Upload from GitHub Actions: main: checkpoint per fully-evaluated model instead of once at the end
Browse filesThe eval loop previously saved only after ALL batches finished, so a run
killed by the GitHub 6h runner cap (or any interruption) persisted nothing
while still being billed for the API calls.
Now evaluate one model at a time and push to HF after each model completes
its entire matrix. Two guarantees:
- progress survives interruption (next run skips fully-logged models), so
a large onboarding completes across multiple runs;
- a model only enters the published aggregate once fully covered AND
healthy (publishable_models excludes models past the auto-blocklist
failure threshold), so a sparse/broken model can't show an inflated mean.
Lifts publishable_models/checkpoint to module level; unit-tested the gate.
- evals/main.py +109 -70
evals/main.py
CHANGED
|
@@ -5,7 +5,11 @@ from os import environ
|
|
| 5 |
|
| 6 |
import pandas as pd
|
| 7 |
from languages import languages
|
| 8 |
-
from models import
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
from rich import print
|
| 10 |
from tasks import tasks
|
| 11 |
from tqdm.asyncio import tqdm_asyncio
|
|
@@ -31,6 +35,60 @@ ALLOW_HF_PUSH_RESULTS = (
|
|
| 31 |
and n_models >= CANONICAL_N_MODELS_FOR_PUSH
|
| 32 |
)
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
async def evaluate():
|
| 35 |
start_time = time.time()
|
| 36 |
|
|
@@ -63,82 +121,63 @@ async def evaluate():
|
|
| 63 |
completed = set(completed_df.apply(tuple, axis=1))
|
| 64 |
combis = combis[~combis.apply(lambda row: tuple(row) in completed, axis=1)]
|
| 65 |
|
| 66 |
-
print(f"Running {len(combis)} evaluation tasks...")
|
| 67 |
-
|
| 68 |
-
# batching (asyncio.gather + rate-limiting can in principle run everything at once, but in practice batching is more efficient / necessary)
|
| 69 |
-
batch_size = 2000
|
| 70 |
-
batch_results = [
|
| 71 |
-
await tqdm_asyncio.gather(
|
| 72 |
-
*[tasks[task_name](model, bcp_47, sentence_nr)
|
| 73 |
-
for _, (task_name, model, bcp_47, sentence_nr) in batch.iterrows()]
|
| 74 |
-
)
|
| 75 |
-
for i in tqdm(range(0, len(combis), batch_size), colour='blue', desc='Batches')
|
| 76 |
-
for batch in [combis[i:i + batch_size]]
|
| 77 |
-
]
|
| 78 |
-
results = [r for batch in batch_results for result in batch for r in result]
|
| 79 |
-
results = pd.DataFrame(results) if results else pd.DataFrame(columns=["task", "model", "bcp_47", "metric", "sentence_nr", "score", "origin"])
|
| 80 |
-
|
| 81 |
-
# Defense-in-depth: if a huge fraction of NEW rows are errors, an
|
| 82 |
-
# infrastructure problem (rate limits, regional ban, dependency outage)
|
| 83 |
-
# likely degraded the run rather than the models actually being broken.
|
| 84 |
-
# Refuse to push the polluted snapshot so auto_blocklist doesn't kick in
|
| 85 |
-
# against innocent models on the next cycle. FatalAPIError already covers
|
| 86 |
-
# auth/credit failures upstream β this is the catch-all for everything else.
|
| 87 |
-
if not results.empty and "status" in results.columns:
|
| 88 |
-
new_error_rate = (results["status"] != "ok").mean()
|
| 89 |
-
if new_error_rate > 0.8:
|
| 90 |
-
raise RuntimeError(
|
| 91 |
-
f"Run produced {new_error_rate:.1%} errors in {len(results)} "
|
| 92 |
-
f"new rows (threshold 80%). Likely an infra failure; refusing "
|
| 93 |
-
f"to push to HF. Investigate the logs above and re-run when "
|
| 94 |
-
f"resolved."
|
| 95 |
-
)
|
| 96 |
|
| 97 |
-
# Merge with cached results (immutable log, prefer latest results on conflict)
|
| 98 |
-
all_results = pd.concat([old_results, results]).drop_duplicates(
|
| 99 |
-
subset=["task", "model", "bcp_47", "metric", "sentence_nr"],
|
| 100 |
-
keep="last",
|
| 101 |
-
) if not old_results.empty else results
|
| 102 |
-
|
| 103 |
-
# Filter to current models Γ languages and aggregate.
|
| 104 |
-
# Only aggregate over successful evaluations (status == \"ok\" or missing).
|
| 105 |
current_models = set(models.iloc[:n_models]["id"])
|
| 106 |
current_languages = set(languages.head(n_languages)["bcp_47"])
|
| 107 |
-
if "status" in all_results.columns:
|
| 108 |
-
valid_mask = all_results["status"].isna() | (all_results["status"] == "ok")
|
| 109 |
-
valid_results = all_results[valid_mask]
|
| 110 |
-
else:
|
| 111 |
-
valid_results = all_results
|
| 112 |
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
)
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
save(all_results, "results-detailed")
|
| 122 |
|
| 123 |
-
#
|
| 124 |
-
#
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
)
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
|
|
|
|
|
|
|
|
|
| 142 |
|
| 143 |
elapsed = time.time() - start_time
|
| 144 |
print(f"Evaluation completed in {str(timedelta(seconds=int(elapsed)))}")
|
|
|
|
| 5 |
|
| 6 |
import pandas as pd
|
| 7 |
from languages import languages
|
| 8 |
+
from models import (
|
| 9 |
+
models,
|
| 10 |
+
AUTO_BLOCKLIST_MIN_ATTEMPTS,
|
| 11 |
+
AUTO_BLOCKLIST_FAIL_PCT_THRESHOLD,
|
| 12 |
+
)
|
| 13 |
from rich import print
|
| 14 |
from tasks import tasks
|
| 15 |
from tqdm.asyncio import tqdm_asyncio
|
|
|
|
| 35 |
and n_models >= CANONICAL_N_MODELS_FOR_PUSH
|
| 36 |
)
|
| 37 |
|
| 38 |
+
def publishable_models(log_df, cohort_models):
|
| 39 |
+
"""Cohort models safe to include in the published aggregate: full coverage
|
| 40 |
+
(guaranteed because we only checkpoint completed models) AND an acceptable
|
| 41 |
+
success rate. A model that is mostly errors over a meaningful sample is
|
| 42 |
+
*broken*, not *low-scoring* β keep it out of the aggregate (auto_blocklist
|
| 43 |
+
drops it from the cohort on the next run). This is the guard that prevents
|
| 44 |
+
a sparsely-successful model from showing an inflated mean."""
|
| 45 |
+
if "status" not in log_df.columns:
|
| 46 |
+
return set(cohort_models)
|
| 47 |
+
keep = set()
|
| 48 |
+
in_cohort = log_df[log_df["model"].isin(cohort_models)]
|
| 49 |
+
for mid, grp in in_cohort.groupby("model"):
|
| 50 |
+
total = len(grp)
|
| 51 |
+
failed = (grp["status"] == "error").sum()
|
| 52 |
+
if (total >= AUTO_BLOCKLIST_MIN_ATTEMPTS
|
| 53 |
+
and failed / total * 100 >= AUTO_BLOCKLIST_FAIL_PCT_THRESHOLD):
|
| 54 |
+
continue
|
| 55 |
+
keep.add(mid)
|
| 56 |
+
return keep
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def checkpoint(log_df, cohort_models, cohort_languages, note):
|
| 60 |
+
"""Push the immutable log + the aggregate (current cohort, healthy
|
| 61 |
+
fully-covered models only) to HuggingFace (or locally if below scale)."""
|
| 62 |
+
if "status" in log_df.columns:
|
| 63 |
+
valid = log_df[log_df["status"].isna() | (log_df["status"] == "ok")]
|
| 64 |
+
else:
|
| 65 |
+
valid = log_df
|
| 66 |
+
keep = publishable_models(log_df, cohort_models)
|
| 67 |
+
agg = (
|
| 68 |
+
valid[valid["model"].isin(keep) & valid["bcp_47"].isin(cohort_languages)]
|
| 69 |
+
.groupby(["model", "bcp_47", "task", "metric"])
|
| 70 |
+
.agg({"score": "mean", "origin": "first"})
|
| 71 |
+
.reset_index()
|
| 72 |
+
)
|
| 73 |
+
# results-detailed is append-merged (immutable log); safe at any scale.
|
| 74 |
+
save(log_df, "results-detailed")
|
| 75 |
+
# The aggregated tables are filtered by cohort_models Γ cohort_languages, so
|
| 76 |
+
# a partial-scale run would truncate the published view β push only from a
|
| 77 |
+
# full-scale run; otherwise local-only.
|
| 78 |
+
if ALLOW_HF_PUSH_RESULTS:
|
| 79 |
+
save(agg, "results")
|
| 80 |
+
save(models, "models")
|
| 81 |
+
save(languages, "languages")
|
| 82 |
+
else:
|
| 83 |
+
save_local_only(agg, "results")
|
| 84 |
+
save_local_only(models, "models")
|
| 85 |
+
save_local_only(languages, "languages")
|
| 86 |
+
print(f" β checkpoint after {note}: {len(keep)} models published, "
|
| 87 |
+
f"{len(log_df)} detailed rows "
|
| 88 |
+
f"({'HF' if ALLOW_HF_PUSH_RESULTS else 'local-only'})")
|
| 89 |
+
return agg
|
| 90 |
+
|
| 91 |
+
|
| 92 |
async def evaluate():
|
| 93 |
start_time = time.time()
|
| 94 |
|
|
|
|
| 121 |
completed = set(completed_df.apply(tuple, axis=1))
|
| 122 |
combis = combis[~combis.apply(lambda row: tuple(row) in completed, axis=1)]
|
| 123 |
|
| 124 |
+
print(f"Running {len(combis)} evaluation tasks across {combis['model'].nunique()} models...")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
current_models = set(models.iloc[:n_models]["id"])
|
| 127 |
current_languages = set(languages.head(n_languages)["bcp_47"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
|
| 129 |
+
# We evaluate ONE MODEL AT A TIME and checkpoint to HuggingFace after each
|
| 130 |
+
# model finishes its ENTIRE matrix (every task Γ language Γ sentence
|
| 131 |
+
# attempted). This buys two things:
|
| 132 |
+
# 1. Progress survives interruption β the GitHub-hosted runner's hard 6h
|
| 133 |
+
# cap, or a local laptop sleeping. The next run skips models already
|
| 134 |
+
# fully logged (status=="ok" rows), so a large onboarding completes
|
| 135 |
+
# across however many runs it takes instead of losing everything.
|
| 136 |
+
# 2. A model only enters the PUBLISHED aggregate once it has full
|
| 137 |
+
# benchmark coverage, so a half-evaluated model can never show an
|
| 138 |
+
# inflated score from a sparse sample (the failure mode that once put
|
| 139 |
+
# a small model at rank #1).
|
| 140 |
+
all_results = old_results.copy() if not old_results.empty else pd.DataFrame(
|
| 141 |
+
columns=["task", "model", "bcp_47", "metric", "sentence_nr", "score", "origin", "status"]
|
| 142 |
)
|
| 143 |
+
dedup_keys = ["task", "model", "bcp_47", "metric", "sentence_nr"]
|
| 144 |
+
batch_size = 2000
|
|
|
|
| 145 |
|
| 146 |
+
# Cohort order, so each checkpoint boundary is a fully-computed model.
|
| 147 |
+
# Models already fully cached have no pending combis and are skipped.
|
| 148 |
+
pending_models = [m for m in models.iloc[:n_models]["id"].tolist()
|
| 149 |
+
if (combis["model"] == m).any()]
|
| 150 |
+
results_agg = None
|
| 151 |
+
for mi, model_id in enumerate(pending_models, 1):
|
| 152 |
+
model_combis = combis[combis["model"] == model_id]
|
| 153 |
+
print(f"[{mi}/{len(pending_models)}] {model_id}: {len(model_combis)} new samples")
|
| 154 |
+
model_out = []
|
| 155 |
+
for i in tqdm(range(0, len(model_combis), batch_size),
|
| 156 |
+
colour="blue", desc=model_id):
|
| 157 |
+
batch = model_combis.iloc[i:i + batch_size]
|
| 158 |
+
batch_res = await tqdm_asyncio.gather(
|
| 159 |
+
*[tasks[task_name](model, bcp_47, sentence_nr)
|
| 160 |
+
for _, (task_name, model, bcp_47, sentence_nr) in batch.iterrows()]
|
| 161 |
+
)
|
| 162 |
+
model_out.extend(r for result in batch_res for r in result)
|
| 163 |
+
model_df = pd.DataFrame(model_out) if model_out else pd.DataFrame(columns=all_results.columns)
|
| 164 |
+
|
| 165 |
+
if not model_df.empty and "status" in model_df.columns:
|
| 166 |
+
err = (model_df["status"] != "ok").mean()
|
| 167 |
+
if err > 0.8:
|
| 168 |
+
# Logged so auto_blocklist sees it; publish-health filter keeps
|
| 169 |
+
# it out of the aggregate this run.
|
| 170 |
+
print(f" β {model_id}: {err:.0%} of new rows errored β logged, not published")
|
| 171 |
+
|
| 172 |
+
all_results = pd.concat([all_results, model_df]).drop_duplicates(
|
| 173 |
+
subset=dedup_keys, keep="last"
|
| 174 |
)
|
| 175 |
+
results_agg = checkpoint(all_results, current_models, current_languages, model_id)
|
| 176 |
+
|
| 177 |
+
if results_agg is None:
|
| 178 |
+
# Everything was already cached β still refresh the published tables
|
| 179 |
+
# from the existing log (e.g. cohort/cost metadata may have changed).
|
| 180 |
+
results_agg = checkpoint(all_results, current_models, current_languages, "no new work")
|
| 181 |
|
| 182 |
elapsed = time.time() - start_time
|
| 183 |
print(f"Evaluation completed in {str(timedelta(seconds=int(elapsed)))}")
|