ffasr / scripts /run_eval_and_publish.py
whojavumusic's picture
normalizer fix
1282534
Raw
History Blame Contribute Delete
34.6 kB
#!/usr/bin/env python3
"""
Run evaluation locally against locally-staged packed conditions, and write the
two CSVs the Space normally publishes to the bucket as plain files on disk:
- <out-dir>/leaderboard.csv (same schema as results/leaderboard.csv)
- <out-dir>/jobs_state.csv (same schema as results/jobs_state.csv)
Nothing is pushed to the Hub. The packed test tensors are read from
`--packed-dir` instead of being downloaded from the bucket — point it at
`data/new_local_generated/packed_small/`, which mirrors `data/packed/` in the
bucket. Files expected: clean.pt, noisy.pt, reverberant.pt (others are ignored).
Apple Silicon / GPU acceleration:
* `--device auto` (default) picks MPS (Apple GPU) on M-series Macs, then CUDA, then CPU.
* `--batch-size N` enables HF-pipeline batched inference (used by the `auto`
cascade for Whisper / Wav2Vec2-pipeline models). Non-pipeline backends
(Granite chat, SpeechBrain, custom CTC) ignore batching and run sample-by-sample.
Usage:
python scripts/run_eval_and_publish.py --model openai/whisper-tiny
python scripts/run_eval_and_publish.py --model openai/whisper-small \\
--device mps --batch-size 16
python scripts/run_eval_and_publish.py --model org/model --family transformers_ctc \\
--packed-dir /path/to/packed_small --out-dir ./local_results
"""
from __future__ import annotations
import argparse
import csv
import io
import json
import os
import sys
import time
import traceback
import uuid
import warnings
warnings.filterwarnings("ignore", category=UserWarning) # HF tokenizer parallelism
from datetime import datetime, timezone
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
DEFAULT_PACKED_DIR = "/Users/shivamsaini/speech-rir-asr-dataset/data/new_local_generated/packed_small"
DEFAULT_OUT_DIR = os.path.join(ROOT, "local_results")
# ---------------------------------------------------------------------------
# Device selection (CUDA / MPS / CPU)
# ---------------------------------------------------------------------------
def _resolve_device(choice: str) -> tuple[str, int]:
"""Return (device_str, device_int) for orchestrator / pipeline APIs.
`device_int` is the value HF pipeline expects: -1 for CPU, 0+ for CUDA.
For MPS we still pass `device_str="mps"` to the pipeline (it accepts strings),
and report device_int=-1 so any code that branches on it treats it as non-CUDA.
"""
import torch
choice = (choice or "auto").lower()
has_cuda = torch.cuda.is_available()
has_mps = bool(getattr(torch.backends, "mps", None) and torch.backends.mps.is_available())
if choice == "auto":
if has_mps:
choice = "mps"
elif has_cuda:
choice = "cuda"
else:
choice = "cpu"
if choice == "cuda":
if not has_cuda:
raise RuntimeError("CUDA requested but torch.cuda.is_available() is False.")
return "cuda", 0
if choice == "mps":
if not has_mps:
raise RuntimeError(
"MPS requested but torch.backends.mps.is_available() is False. "
"Need PyTorch with MPS support on Apple Silicon."
)
# Improve coverage: fall back to CPU for ops MPS doesn't implement.
os.environ.setdefault("PYTORCH_ENABLE_MPS_FALLBACK", "1")
return "mps", -1
if choice == "cpu":
return "cpu", -1
raise ValueError(f"--device must be one of auto/cpu/cuda/mps; got {choice!r}")
# ---------------------------------------------------------------------------
# Local packed-file loader (replaces `download_bucket_file` for `.pt` reads)
# ---------------------------------------------------------------------------
def _patch_packed_loader(packed_dir: str) -> None:
"""Redirect benchmark.dataset.load_packed_condition to read local .pt files.
Must run BEFORE importing orchestrator/run_evaluation so the orchestrator
sees the patched function.
"""
import torch
from benchmark import dataset as bench_ds
cache: dict[str, dict] = {}
def _local_loader(condition_key: str) -> dict:
if condition_key in cache:
return cache[condition_key]
rel = bench_ds.PACKED_FILES[condition_key]
local_path = os.path.join(packed_dir, os.path.basename(rel))
if not os.path.isfile(local_path):
raise FileNotFoundError(
f"Packed file for condition {condition_key!r} not found: {local_path}"
)
data = torch.load(local_path, weights_only=False)
cache[condition_key] = data
return data
bench_ds.load_packed_condition = _local_loader
# ---------------------------------------------------------------------------
# Inline orchestrator: explicit device + optional HF-pipeline batching
# ---------------------------------------------------------------------------
def _resolve_dtype(choice: str, device_str: str):
"""Pick torch dtype.
`auto` policy:
* CUDA -> fp16 (fast + generally stable)
* MPS -> fp32 (several speech models overflow / misbehave in fp16 on Apple GPU)
* CPU -> fp32
"""
import torch
choice = (choice or "auto").lower()
if choice == "auto":
return torch.float16 if device_str == "cuda" else torch.float32
return {
"float32": torch.float32,
"fp32": torch.float32,
"float16": torch.float16,
"fp16": torch.float16,
"half": torch.float16,
"bfloat16": torch.bfloat16,
"bf16": torch.bfloat16,
}.get(choice, torch.float32)
_SEQ2SEQ_INCOMPATIBLE_MODEL_TYPES = {
# Chat-prompted speech models — their processors require a `text` arg with the
# chat template, so the audio-only processor call we use below is wrong.
# Their dedicated backends know the right prompt; let those handle it.
"granite_speech",
"qwen2_audio",
"qwen_audio",
}
# When `--family auto`, route specific model_type values to their dedicated
# backend instead of letting the auto cascade fall through pipeline → universal,
# which loads but crashes at inference for chat-prompted speech models, or
# crashes at batched-collation time for models with variable-length features
# the HF pipeline padding can't reconcile (Cohere ASR).
_MODEL_TYPE_TO_FAMILY = {
"granite_speech": "granite_speech",
"cohere_asr": "universal",
}
def _resolve_family_for_model(family_id: str, model_id: str) -> tuple[str, str]:
"""Return (resolved_family, model_type). If `family_id` is `auto` and the
config exposes a model_type that has a dedicated backend, switch to it."""
try:
from transformers import AutoConfig
cfg = AutoConfig.from_pretrained(model_id, trust_remote_code=True)
model_type = getattr(cfg, "model_type", "") or ""
except Exception:
return family_id, ""
if family_id == "auto" and model_type in _MODEL_TYPE_TO_FAMILY:
return _MODEL_TYPE_TO_FAMILY[model_type], model_type
return family_id, model_type
def _try_build_batched_seq2seq(model_id: str, device_str: str, dtype):
"""Hand-rolled batched seq2seq transcriber (Whisper-style).
Bypasses the HF pipeline so encoder/decoder forward passes are actually
batched, preprocessing pads once per batch, and we can pick fp16. Works for
any model loadable via `AutoModelForSpeechSeq2Seq`.
Returns (transcribe_batch, num_params, cleanup, info) or None when this
path doesn't apply (model needs chat prompts, can't load on this device,
or the audio-only processor signature isn't supported).
"""
import torch
try:
from transformers import AutoConfig, AutoModelForSpeechSeq2Seq, AutoProcessor
except Exception:
return None
# Cheap pre-flight: read the config first and bail for known chat-prompted
# speech models so we don't pay the model-load cost just to crash.
try:
cfg = AutoConfig.from_pretrained(model_id, trust_remote_code=True)
cfg_model_type = getattr(cfg, "model_type", "") or ""
except Exception:
cfg_model_type = ""
if cfg_model_type in _SEQ2SEQ_INCOMPATIBLE_MODEL_TYPES:
return None
try:
processor = AutoProcessor.from_pretrained(model_id, trust_remote_code=True)
except Exception:
return None
# Some chat-style processors are missing `feature_extractor` entirely; their
# `__call__` requires `text=`. Detecting this cleanly is hard, so we treat
# the warm-up below as a probe: if it fails, we fall back.
fe = getattr(processor, "feature_extractor", None)
target_sr = int(getattr(fe, "sampling_rate", 0)) or 16000
try:
model = AutoModelForSpeechSeq2Seq.from_pretrained(
model_id,
torch_dtype=dtype,
low_cpu_mem_usage=True,
trust_remote_code=True,
)
except Exception:
return None
if not hasattr(model, "generate"):
del model
return None
try:
model.to(device_str)
except Exception:
# Some ops not implemented on MPS; bail and let the pipeline path take over.
del model
return None
model.eval()
# Confirm placement (and fall back if torch silently kept it on CPU).
try:
param_device = str(next(model.parameters()).device)
except StopIteration:
param_device = "?"
# Whisper-only kwargs are tried first, then fall back to plain generate().
is_whisper = getattr(getattr(model, "config", None), "model_type", "") == "whisper"
def _generate(input_features, attention_mask=None):
gen_kwargs = {"max_new_tokens": 440}
if attention_mask is not None:
gen_kwargs["attention_mask"] = attention_mask
if cfg_model_type == "cohere_asr":
# Cohere's remote generation code expects this key to be a tensor,
# not None. Without it, HF generation crashes when extending masks.
gen_kwargs["decoder_attention_mask"] = torch.ones(
(int(input_features.shape[0]), 1),
device=input_features.device,
dtype=torch.long,
)
if is_whisper:
try:
with torch.inference_mode():
return model.generate(
input_features,
language="english",
task="transcribe",
**gen_kwargs,
)
except (TypeError, ValueError):
pass
with torch.inference_mode():
return model.generate(input_features, **gen_kwargs)
def transcribe_batch(items: list[dict], batch_size: int) -> list[str]:
audios = [it["raw"] for it in items]
# Packed test data is already 16k mono; processor handles padding.
inputs = processor(audios, sampling_rate=target_sr, return_tensors="pt", padding=True)
if "input_features" in inputs:
feats = inputs["input_features"].to(device_str, dtype=dtype)
elif "input_values" in inputs:
feats = inputs["input_values"].to(device_str, dtype=dtype)
else:
raise RuntimeError(f"Processor returned unexpected keys: {list(inputs.keys())}")
attn = inputs.get("attention_mask")
if attn is not None:
attn = attn.to(device_str)
ids = _generate(feats, attention_mask=attn)
return processor.batch_decode(ids, skip_special_tokens=True)
# Warm-up doubles as a probe: if the processor refuses an audio-only call
# (e.g. chat-prompted speech models we didn't catch above), fall back so
# the dedicated backend can take over.
import numpy as np
try:
warm = [{"raw": np.zeros(target_sr, dtype=np.float32), "sampling_rate": target_sr}] * 2
transcribe_batch(warm, batch_size=2)
except Exception as e:
print(
f"[eval] seq2seq path rejected for {model_id} ({type(e).__name__}: {e}); "
"falling back to pipeline / per-sample.",
file=sys.stderr,
)
del model, processor
if torch.cuda.is_available():
torch.cuda.empty_cache()
return None
from backends._model_utils import count_params
num_params = count_params(model)
def cleanup() -> None:
nonlocal model, processor
del model, processor
info = {
"kind": "seq2seq",
"param_device": param_device,
"dtype": str(dtype).replace("torch.", ""),
"model_type": getattr(getattr(model, "config", None), "model_type", "?"),
"target_sr": target_sr,
}
return transcribe_batch, num_params, cleanup, info
def _try_build_batched_pipeline(model_id: str, device_str: str):
"""Fallback batched path via the HF pipeline. Less aggressive batching than
the hand-rolled seq2seq path but works for CTC models too."""
# Cohere ASR pipeline batching currently breaks in HF pipeline collation for
# variable-length feature tensors. Keep pipeline available for per-sample use,
# but avoid this batched path so seq2seq/per-sample code handles it.
try:
from transformers import AutoConfig
cfg = AutoConfig.from_pretrained(model_id, trust_remote_code=True)
if getattr(cfg, "model_type", "") == "cohere_asr":
return None
except Exception:
pass
try:
from transformers import pipeline
except Exception:
return None
try:
pipe = pipeline(
"automatic-speech-recognition",
model=model_id,
device=device_str,
trust_remote_code=True,
)
except Exception:
return None
import numpy as np
try:
pipe({"raw": np.zeros(16000, dtype=np.float32), "sampling_rate": 16000})
except Exception:
pass
from backends._model_utils import count_params
model_obj = getattr(pipe, "model", None)
num_params = count_params(model_obj)
try:
param_device = str(next(model_obj.parameters()).device) if model_obj else "?"
except Exception:
param_device = "?"
def transcribe_batch(audios_with_sr: list[dict], batch_size: int) -> list[str]:
outs = pipe(audios_with_sr, batch_size=batch_size)
return [str(o["text"]) for o in outs]
def cleanup() -> None:
nonlocal pipe
del pipe
info = {
"kind": "pipeline",
"param_device": param_device,
"dtype": "float32",
"model_type": getattr(getattr(model_obj, "config", None), "model_type", "?"),
}
return transcribe_batch, num_params, cleanup, info
def _eval_condition_batched(
transcribe_batch,
condition_key: str,
batch_size: int,
progress_cb,
):
"""Batched WER eval — calls `transcribe_batch(list[dict], batch_size)` once per condition."""
from jiwer import wer as compute_wer
from backends._audio_utils import safe_pad_audio
from benchmark.dataset import _normalize_for_wer, load_packed_condition
data = load_packed_condition(condition_key)
if not data:
if progress_cb is not None:
progress_cb(condition_key, 0, 0)
return 0.0, 0, 0.0, 0.0
items = list(data.items())
inputs: list[dict] = []
references: list[str] = []
audio_seconds = 0.0
for _sid, sample in items:
audio_np = safe_pad_audio(sample["waveform"].numpy())
sr = int(sample.get("sample_rate", 16000))
audio_seconds += float(len(audio_np)) / float(sr)
inputs.append({"raw": audio_np, "sampling_rate": sr})
references.append(_normalize_for_wer(str(sample["transcript"])))
total = len(items)
predictions: list[str] = []
t0 = time.perf_counter()
# Process in chunks so we can report progress between batches.
for start in range(0, total, batch_size):
chunk = inputs[start : start + batch_size]
texts = transcribe_batch(chunk, batch_size=batch_size)
predictions.extend(_normalize_for_wer(str(t)) for t in texts)
if progress_cb is not None:
try:
progress_cb(condition_key, min(start + len(chunk), total), total)
except Exception:
pass
inference_seconds = time.perf_counter() - t0
return round(compute_wer(references, predictions), 4), len(references), audio_seconds, inference_seconds
def _run_evaluation_local(
model_id: str,
family_id: str,
device_str: str,
device_int: int,
batch_size: int,
dtype_choice: str,
progress_cb,
) -> dict:
"""Inline orchestrator: chosen device + batched fast-path when possible."""
import torch
from evaluation.runtime import disable_broken_torchcodec
disable_broken_torchcodec()
from backends.registry import build_transcriber, resolve_label
from benchmark.dataset import (
PACKED_FILES,
evaluate_condition_wer_timed,
wer_result_key,
load_packed_condition,
)
dtype = _resolve_dtype(dtype_choice, device_str)
resolved_family, model_type = _resolve_family_for_model(family_id, model_id)
if resolved_family != family_id:
print(
f"[eval] family override: {family_id} -> {resolved_family} "
f"(detected model_type={model_type!r})",
file=sys.stderr,
)
family_id = resolved_family
# Pre-compute totals so progress is meaningful from the start.
condition_totals: dict[str, int] = {}
grand_total = 0
for ck in PACKED_FILES:
try:
condition_totals[ck] = len(load_packed_condition(ck))
except Exception:
condition_totals[ck] = 0
grand_total += condition_totals[ck]
if progress_cb is not None:
try:
progress_cb(0, grand_total, "")
except Exception:
pass
# Try the batched fast paths in order: hand-rolled seq2seq → HF pipeline.
batched = None
info = None
if batch_size > 1 and family_id in ("auto", "transformers_pipeline", "universal"):
built = _try_build_batched_seq2seq(model_id, device_str, dtype)
if built is None:
built = _try_build_batched_pipeline(model_id, device_str)
if built is not None:
transcribe_batch, num_params, cleanup, info = built
batched = (transcribe_batch, num_params, cleanup)
print(
f"[eval] batched path: {info['kind']} | model_type={info.get('model_type','?')} | "
f"param_device={info['param_device']} | dtype={info['dtype']} | batch_size={batch_size}",
file=sys.stderr,
)
if batched is not None:
transcribe_batch, num_params, cleanup = batched
eval_family_label = resolve_label("transformers_pipeline")
try:
results: dict = {
"model_id": model_id,
"eval_family": eval_family_label,
"num_params": int(num_params or 0),
}
total_samples = 0
total_audio_s = 0.0
total_infer_s = 0.0
done_so_far = 0
for condition_key in PACKED_FILES:
base = done_so_far
def _cb(ck: str, i: int, _n: int, _b: int = base) -> None:
if progress_cb is not None:
try:
progress_cb(_b + i, grand_total, ck)
except Exception:
pass
wer, count, audio_s, infer_s = _eval_condition_batched(
transcribe_batch, condition_key, batch_size, progress_cb=_cb
)
results[wer_result_key(condition_key)] = wer
total_samples = max(total_samples, count)
total_audio_s += audio_s
total_infer_s += infer_s
done_so_far += count
results["num_samples"] = total_samples
results["eval_audio_seconds"] = round(total_audio_s, 3)
results["eval_wall_time_s"] = round(total_infer_s, 3)
results["eval_rtf"] = (
round(total_audio_s / total_infer_s, 4) if total_infer_s > 1e-6 else ""
)
return results
finally:
cleanup()
if torch.cuda.is_available():
torch.cuda.empty_cache()
transcribe, cleanup = build_transcriber(family_id, model_id, device_str, device_int)
try:
num_params = int(getattr(transcribe, "_num_params", 0) or 0)
backend_batch = getattr(transcribe, "_batch_transcribe", None)
if batch_size > 1 and callable(backend_batch):
print(
f"[eval] backend-native batched path: family={family_id} device={device_str} "
f"batch_size={batch_size}",
file=sys.stderr,
)
results = {
"model_id": model_id,
"eval_family": resolve_label(family_id),
"num_params": num_params,
}
total_samples = 0
total_audio_s = 0.0
total_infer_s = 0.0
done_so_far = 0
for condition_key in PACKED_FILES:
base = done_so_far
def _cb(ck: str, i: int, _n: int, _b: int = base) -> None:
if progress_cb is not None:
try:
progress_cb(_b + i, grand_total, ck)
except Exception:
pass
wer, count, audio_s, infer_s = _eval_condition_batched(
backend_batch, condition_key, batch_size, progress_cb=_cb
)
results[wer_result_key(condition_key)] = wer
total_samples = max(total_samples, count)
total_audio_s += audio_s
total_infer_s += infer_s
done_so_far += count
results["num_samples"] = total_samples
results["eval_audio_seconds"] = round(total_audio_s, 3)
results["eval_wall_time_s"] = round(total_infer_s, 3)
results["eval_rtf"] = (
round(total_audio_s / total_infer_s, 4) if total_infer_s > 1e-6 else ""
)
return results
# Per-sample fallback (any backend; honors device_str).
print(
f"[eval] per-sample path (no batching): family={family_id} device={device_str}",
file=sys.stderr,
)
results = {
"model_id": model_id,
"eval_family": resolve_label(family_id),
"num_params": num_params,
}
total_samples = 0
total_audio_s = 0.0
total_infer_s = 0.0
done_so_far = 0
for condition_key in PACKED_FILES:
base = done_so_far
def _cb(ck: str, i: int, _n: int, _b: int = base) -> None:
if progress_cb is not None:
try:
progress_cb(_b + i, grand_total, ck)
except Exception:
pass
wer, count, audio_s, infer_s = evaluate_condition_wer_timed(
transcribe, condition_key, progress_cb=_cb
)
results[wer_result_key(condition_key)] = wer
total_samples = max(total_samples, count)
total_audio_s += audio_s
total_infer_s += infer_s
done_so_far += count
results["num_samples"] = total_samples
results["eval_audio_seconds"] = round(total_audio_s, 3)
results["eval_wall_time_s"] = round(total_infer_s, 3)
results["eval_rtf"] = (
round(total_audio_s / total_infer_s, 4) if total_infer_s > 1e-6 else ""
)
return results
finally:
cleanup()
if torch.cuda.is_available():
torch.cuda.empty_cache()
# ---------------------------------------------------------------------------
# CSV helpers (local files; same schema as the Space's bucket CSVs)
# ---------------------------------------------------------------------------
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _progress_print(done: int, total: int, condition: str) -> None:
if total <= 0:
sys.stderr.write(f"\r[{condition or 'preparing'}] preparing…")
else:
pct = 100.0 * done / total
sys.stderr.write(
f"\r[{condition or '—'}] {done}/{total} ({pct:5.1f}%) "
)
sys.stderr.flush()
def _read_csv(path: str) -> list[dict]:
if not os.path.isfile(path):
return []
with open(path, "r", encoding="utf-8", newline="") as f:
return list(csv.DictReader(f))
def _write_csv(path: str, fields: list[str], rows: list[dict]) -> None:
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
buf = io.StringIO()
w = csv.DictWriter(buf, fieldnames=fields, extrasaction="ignore")
w.writeheader()
for row in rows:
w.writerow({k: row.get(k, "") for k in fields})
with open(path, "w", encoding="utf-8", newline="") as f:
f.write(buf.getvalue())
def _append_leaderboard_local(csv_path: str, result: dict, notes: str) -> dict:
from analytics import sort_leaderboard_rows_inplace
from init import CSV_FIELDS, leaderboard_row_from_eval_result, normalize_legacy_csv_row
rows = _read_csv(csv_path)
for row in rows:
for k in CSV_FIELDS:
row.setdefault(k, "")
normalize_legacy_csv_row(row)
if any(r.get("model_id") == result["model_id"] for r in rows):
raise RuntimeError(
f"Model {result['model_id']!r} already in {csv_path}; refusing to duplicate."
)
new_row = leaderboard_row_from_eval_result(result, _now_iso(), submission_notes=notes)
normalize_legacy_csv_row(new_row)
rows.append(new_row)
sort_leaderboard_rows_inplace(rows)
_write_csv(csv_path, CSV_FIELDS, rows)
return new_row
_JOBS_CSV_FIELDS = [
"job_id",
"model_id",
"family_id",
"status",
"created_at",
"updated_at",
"error",
"submission_notes",
]
def _upsert_job_local(
csv_path: str,
*,
job_id: str,
model_id: str,
family_id: str,
status: str,
created_at: str,
error: str | None = None,
submission_notes: str = "",
) -> None:
rows = _read_csv(csv_path)
by_id = {(r.get("job_id") or "").strip(): r for r in rows if r.get("job_id")}
by_id[job_id] = {
"job_id": job_id,
"model_id": model_id,
"family_id": family_id,
"status": status,
"created_at": created_at,
"updated_at": _now_iso(),
"error": (error or "").replace("\n", " ")[:2000],
"submission_notes": (submission_notes or "").replace("\n", " ")[:4000],
}
merged = list(by_id.values())
merged.sort(key=lambda r: r.get("created_at") or "")
_write_csv(csv_path, _JOBS_CSV_FIELDS, merged)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main() -> int:
parser = argparse.ArgumentParser(
description="Run FFASR evaluation locally against staged packed files; write CSVs to disk."
)
parser.add_argument("--model", required=True, help="Hugging Face model id (author/name).")
parser.add_argument(
"--family",
default=None,
help="Inference backend family (default: auto). Validated against backends.registry.",
)
parser.add_argument(
"--device",
default="auto",
choices=("auto", "cpu", "cuda", "mps"),
help="Compute device. `auto` picks MPS on Apple Silicon, then CUDA, then CPU.",
)
parser.add_argument(
"--batch-size",
type=int,
default=8,
help="Batch size for HF-pipeline backends (whisper, wav2vec2 pipeline, …). "
"Non-pipeline backends ignore this and run sample-by-sample. Use 1 to disable batching.",
)
parser.add_argument(
"--dtype",
default="auto",
choices=("auto", "float32", "float16", "bfloat16", "fp32", "fp16", "bf16", "half"),
help="Model dtype. `auto` uses fp16 on CUDA/MPS and fp32 on CPU. "
"fp16 typically halves Whisper inference time on M-series Macs.",
)
parser.add_argument(
"--packed-dir",
default=DEFAULT_PACKED_DIR,
help=f"Directory containing clean.pt / noisy.pt / reverberant.pt (default: {DEFAULT_PACKED_DIR}).",
)
parser.add_argument(
"--out-dir",
default=DEFAULT_OUT_DIR,
help=f"Directory to write leaderboard.csv and jobs_state.csv (default: {DEFAULT_OUT_DIR}).",
)
parser.add_argument(
"--leaderboard-csv",
default=None,
help="Override path for leaderboard.csv (default: <out-dir>/leaderboard.csv).",
)
parser.add_argument(
"--jobs-csv",
default=None,
help="Override path for jobs_state.csv (default: <out-dir>/jobs_state.csv).",
)
parser.add_argument(
"--notes",
default="",
help="Optional submission notes recorded with the leaderboard row.",
)
parser.add_argument(
"--job-id",
default=None,
help="Reuse an existing job_id. Defaults to a fresh uuid.",
)
parser.add_argument("--json", action="store_true", help="Print result as JSON at the end.")
args = parser.parse_args()
packed_dir = os.path.abspath(args.packed_dir)
if not os.path.isdir(packed_dir):
print(f"ERROR: --packed-dir does not exist: {packed_dir}", file=sys.stderr)
return 2
for cond in ("clean.pt", "noisy.pt", "reverberant.pt"):
if not os.path.isfile(os.path.join(packed_dir, cond)):
print(f"ERROR: missing {cond} in {packed_dir}", file=sys.stderr)
return 2
_patch_packed_loader(packed_dir)
try:
device_str, device_int = _resolve_device(args.device)
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
return 2
from backends.registry import FAMILY_IDS, default_family_id # noqa: E402
family_id = args.family or default_family_id()
if family_id not in FAMILY_IDS:
print(
f"ERROR: invalid --family {family_id!r}; choose from {list(FAMILY_IDS)}",
file=sys.stderr,
)
return 2
if args.batch_size < 1:
print("ERROR: --batch-size must be >= 1", file=sys.stderr)
return 2
model_id = args.model.strip()
notes = (args.notes or "").strip()[:4000]
out_dir = os.path.abspath(args.out_dir)
leaderboard_csv = args.leaderboard_csv or os.path.join(out_dir, "leaderboard.csv")
jobs_csv = args.jobs_csv or os.path.join(out_dir, "jobs_state.csv")
existing = _read_csv(leaderboard_csv)
if any(r.get("model_id") == model_id for r in existing):
print(
f"ERROR: {model_id!r} already in {leaderboard_csv}; nothing to do.",
file=sys.stderr,
)
return 1
job_id = (args.job_id or str(uuid.uuid4())[:8]).strip()
created = _now_iso()
print(
f"[job {job_id}] running {model_id} (family={family_id}, device={device_str}, "
f"batch_size={args.batch_size}, dtype={args.dtype}) using packed dir {packed_dir}",
file=sys.stderr,
)
_upsert_job_local(
jobs_csv,
job_id=job_id,
model_id=model_id,
family_id=family_id,
status="running",
created_at=created,
submission_notes=notes,
)
try:
result = _run_evaluation_local(
model_id,
family_id,
device_str=device_str,
device_int=device_int,
batch_size=args.batch_size,
dtype_choice=args.dtype,
progress_cb=_progress_print,
)
sys.stderr.write("\n")
except Exception as e:
sys.stderr.write("\n")
traceback.print_exc()
_upsert_job_local(
jobs_csv,
job_id=job_id,
model_id=model_id,
family_id=family_id,
status="failed",
created_at=created,
error=str(e),
submission_notes=notes,
)
print(f"[job {job_id}] FAILED: {e}", file=sys.stderr)
return 1
try:
new_row = _append_leaderboard_local(leaderboard_csv, result, notes)
except Exception as e:
traceback.print_exc()
_upsert_job_local(
jobs_csv,
job_id=job_id,
model_id=model_id,
family_id=family_id,
status="failed",
created_at=created,
error=f"publish failed: {e}",
submission_notes=notes,
)
return 1
_upsert_job_local(
jobs_csv,
job_id=job_id,
model_id=model_id,
family_id=family_id,
status="done",
created_at=created,
submission_notes=notes,
)
print(
f"[job {job_id}] done — wrote row to {leaderboard_csv}, job to {jobs_csv}",
file=sys.stderr,
)
if args.json:
print(json.dumps({"job_id": job_id, "result": result, "row": new_row}, indent=2))
else:
print(f"job_id: {job_id}")
print(f"model_id: {result['model_id']}")
print(f"eval_family: {result['eval_family']}")
print(f"device: {device_str}")
print(f"dtype: {args.dtype}")
print(f"batch_size: {args.batch_size}")
print(f"wer_clean (→ anechoic CSV): {result.get('wer_clean')}")
print(f"wer_noisy (→ lab measured CSV): {result.get('wer_noisy')}")
print(f"wer_reverberant (→ lab sim CSV): {result.get('wer_reverberant')}")
print(f"wer_high (→ High SNR CSV): {result.get('wer_high')}")
print(f"wer_moving (→ Moving Sources CSV): {result.get('wer_moving')}")
print(f"wer_mid (→ Mid SNR CSV): {result.get('wer_mid')}")
print(f"wer_low (→ Low SNR CSV): {result.get('wer_low')}")
print(f"num_samples: {result.get('num_samples')}")
print(f"eval_wall_time_s: {result.get('eval_wall_time_s')}")
print(f"eval_rtf: {result.get('eval_rtf')}")
print(f"num_params: {result.get('num_params')}")
return 0
if __name__ == "__main__":
sys.exit(main())