ffasr / evaluation /orchestrator.py
whojavumusic's picture
selected dataset eval
395e3c6
Raw
History Blame Contribute Delete
17.4 kB
"""Load a model family backend and run WER across packed conditions."""
from __future__ import annotations
import os
from collections.abc import Callable
from functools import lru_cache
from typing import Any
import torch
from benchmark.dataset import (
accumulate_predictions_for_slice,
evaluate_condition_wer_timed,
is_full_condition_set,
load_packed_condition,
resolve_condition_keys,
wer_result_key,
)
from backends.registry import build_transcriber, resolve_label
from evaluation.runtime import (
apply_cpu_thread_settings_once,
disable_broken_torchcodec,
resolve_eval_devices,
use_spaces_gpu_decorator,
)
def _hub_max_duration_s() -> int:
"""Upper bound the Hub accepts for ``spaces.GPU(duration=...)`` (override if HF raises)."""
try:
return max(60, int(os.environ.get("FFASR_ZEROGPU_HUB_MAX_DURATION_S", "600")))
except ValueError:
return 600
def _requested_duration_s() -> int:
try:
return max(60, int(os.environ.get("FFASR_ZEROGPU_MAX_DURATION_S", "600")))
except ValueError:
return 600
def _effective_gpu_duration_s() -> int:
"""``min(FFASR_ZEROGPU_MAX_DURATION_S, FFASR_ZEROGPU_HUB_MAX_DURATION_S)``."""
return min(_requested_duration_s(), _hub_max_duration_s())
def _samples_per_gpu_segment() -> int:
"""
When >0 and ``spaces`` is available, each condition is split into slices of this many
samples; each slice runs in its own ``spaces.GPU`` call (model reload per slice).
"""
try:
v = int(os.environ.get("FFASR_ZEROGPU_SAMPLES_PER_SEGMENT", "0"))
except ValueError:
return 0
return max(0, v)
def _build_transcriber(
family_id: str,
model_id: str,
device_str: str,
device_int: int,
custom_script: str = "",
) -> tuple[Callable[..., str], Callable[[], None]]:
"""Family backend transcriber, or user ``evaluate(Path)`` script when provided."""
if (custom_script or "").strip():
from backends.custom_eval import build_transcriber_from_custom_script
return build_transcriber_from_custom_script(custom_script)
return build_transcriber(family_id, model_id, device_str, device_int)
def _zerogpu_run_segment(payload: dict[str, Any]) -> dict[str, Any]:
"""Single ZeroGPU segment: build model, transcribe ``[start:end)`` for one condition."""
model_id = str(payload["model_id"])
family_id = str(payload["family_id"])
condition_key = str(payload["condition_key"])
start = int(payload["start"])
end = int(payload["end"])
custom_script = str(payload.get("custom_script") or "")
device_str, device_int = resolve_eval_devices()
transcribe, cleanup = _build_transcriber(
family_id, model_id, device_str, device_int, custom_script=custom_script
)
try:
num_params = int(getattr(transcribe, "_num_params", 0) or 0)
preds, refs, audio_s, infer_s, n_done = accumulate_predictions_for_slice(
transcribe, condition_key, start, end, progress_cb=None
)
return {
"predictions": preds,
"references": refs,
"audio_s": audio_s,
"infer_s": infer_s,
"n": n_done,
"num_params": num_params,
}
finally:
cleanup()
if device_str == "cuda" and torch.cuda.is_available():
torch.cuda.empty_cache()
def _run_evaluation_core(
model_id: str,
family_id: str = "auto",
progress_cb: Callable[[int, int, str], None] | None = None,
custom_script: str = "",
condition_keys: list[str] | None = None,
) -> dict:
device_str, device_int = resolve_eval_devices()
keys = resolve_condition_keys(condition_keys)
# Precompute totals so the progress bar has a known denominator from the start.
condition_totals: dict[str, int] = {}
grand_total = 0
for ck in keys:
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
transcribe, cleanup = _build_transcriber(
family_id, model_id, device_str, device_int, custom_script=custom_script
)
try:
# Backends attach `transcribe._num_params` via `_model_utils.attach_params`; defaults to 0
# if the backend couldn't introspect a module.
num_params = int(getattr(transcribe, "_num_params", 0) or 0)
results: dict = {
"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 keys:
condition_base = done_so_far
def _sample_cb(ck: str, i: int, _n: int, _base: int = condition_base) -> None:
if progress_cb is None:
return
try:
progress_cb(_base + i, grand_total, ck)
except Exception:
pass
wer_score, count, audio_s, infer_s = evaluate_condition_wer_timed(
transcribe, condition_key, progress_cb=_sample_cb
)
results[wer_result_key(condition_key)] = wer_score
total_samples = max(total_samples, count)
total_audio_s += audio_s
total_infer_s += infer_s
done_so_far += count
results["eval_conditions"] = list(keys)
results["eval_full_run"] = is_full_condition_set(condition_keys)
results["num_samples"] = total_samples
results["eval_audio_seconds"] = round(total_audio_s, 3)
results["eval_wall_time_s"] = round(total_infer_s, 3)
# RTF: audio duration / compute time (>1 means faster than real-time for batch totals)
if total_infer_s > 1e-6:
results["eval_rtf"] = round(total_audio_s / total_infer_s, 4)
else:
results["eval_rtf"] = ""
if progress_cb is not None:
try:
progress_cb(done_so_far, grand_total, "")
except Exception:
pass
return results
finally:
cleanup()
if device_str == "cuda" and torch.cuda.is_available():
torch.cuda.empty_cache()
def _run_evaluation_core_segmented_local(
model_id: str,
family_id: str = "auto",
progress_cb: Callable[[int, int, str], None] | None = None,
*,
segment_samples: int,
custom_script: str = "",
condition_keys: list[str] | None = None,
) -> dict:
"""
Same semantics as ``_run_evaluation_core_segmented`` but without ``spaces.GPU``:
each slice calls ``_zerogpu_run_segment`` in-process (CPU or CUDA per ``FFASR_DEVICE``).
"""
from jiwer import wer as compute_wer
keys = resolve_condition_keys(condition_keys)
condition_totals: dict[str, int] = {}
grand_total = 0
for ck in keys:
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
results: dict = {
"model_id": model_id,
"eval_family": resolve_label(family_id),
"num_params": 0,
}
total_audio_s = 0.0
total_infer_s = 0.0
total_samples = 0
done_so_far = 0
first_num_params: int | None = None
for condition_key in keys:
data = load_packed_condition(condition_key)
n_items = len(data)
all_p: list[str] = []
all_r: list[str] = []
cond_audio = 0.0
cond_infer = 0.0
if n_items == 0:
results[wer_result_key(condition_key)] = 0.0
continue
for start in range(0, n_items, segment_samples):
end = min(n_items, start + segment_samples)
payload: dict[str, Any] = {
"model_id": model_id,
"family_id": family_id,
"condition_key": condition_key,
"start": start,
"end": end,
"custom_script": custom_script,
}
out = _zerogpu_run_segment(payload)
if first_num_params is None:
first_num_params = int(out.get("num_params") or 0)
all_p.extend(out["predictions"])
all_r.extend(out["references"])
cond_audio += float(out["audio_s"])
cond_infer += float(out["infer_s"])
done_so_far += int(out["n"])
if progress_cb is not None:
try:
progress_cb(done_so_far, grand_total, condition_key)
except Exception:
pass
wer_score = round(compute_wer(all_r, all_p), 4) if all_p else 0.0
results[wer_result_key(condition_key)] = wer_score
total_audio_s += cond_audio
total_infer_s += cond_infer
total_samples = max(total_samples, len(all_p))
results["num_params"] = first_num_params or 0
results["eval_conditions"] = list(keys)
results["eval_full_run"] = is_full_condition_set(condition_keys)
results["num_samples"] = total_samples
results["eval_audio_seconds"] = round(total_audio_s, 3)
results["eval_wall_time_s"] = round(total_infer_s, 3)
if total_infer_s > 1e-6:
results["eval_rtf"] = round(total_audio_s / total_infer_s, 4)
else:
results["eval_rtf"] = ""
if progress_cb is not None:
try:
progress_cb(done_so_far, grand_total, "")
except Exception:
pass
return results
def _run_evaluation_core_segmented(
model_id: str,
family_id: str = "auto",
progress_cb: Callable[[int, int, str], None] | None = None,
*,
segment_samples: int,
gpu_duration_s: int,
gpu_size: str,
custom_script: str = "",
condition_keys: list[str] | None = None,
) -> dict:
"""
Long evaluations: each slice of ``segment_samples`` runs under its own ``spaces.GPU``
budget, then predictions/references are merged and WER is computed once per condition.
"""
from jiwer import wer as compute_wer
import spaces
keys = resolve_condition_keys(condition_keys)
condition_totals: dict[str, int] = {}
grand_total = 0
for ck in keys:
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
wrapped_seg = _spaces_gpu_segment_wrapper(gpu_duration_s, gpu_size)
results: dict = {
"model_id": model_id,
"eval_family": resolve_label(family_id),
"num_params": 0,
}
total_audio_s = 0.0
total_infer_s = 0.0
total_samples = 0
done_so_far = 0
first_num_params: int | None = None
for condition_key in keys:
data = load_packed_condition(condition_key)
n_items = len(data)
all_p: list[str] = []
all_r: list[str] = []
cond_audio = 0.0
cond_infer = 0.0
if n_items == 0:
results[wer_result_key(condition_key)] = 0.0
continue
for start in range(0, n_items, segment_samples):
end = min(n_items, start + segment_samples)
payload: dict[str, Any] = {
"model_id": model_id,
"family_id": family_id,
"condition_key": condition_key,
"start": start,
"end": end,
"custom_script": custom_script,
}
out = wrapped_seg(payload)
if first_num_params is None:
first_num_params = int(out.get("num_params") or 0)
all_p.extend(out["predictions"])
all_r.extend(out["references"])
cond_audio += float(out["audio_s"])
cond_infer += float(out["infer_s"])
done_so_far += int(out["n"])
if progress_cb is not None:
try:
progress_cb(done_so_far, grand_total, condition_key)
except Exception:
pass
wer_score = round(compute_wer(all_r, all_p), 4) if all_p else 0.0
results[wer_result_key(condition_key)] = wer_score
total_audio_s += cond_audio
total_infer_s += cond_infer
total_samples = max(total_samples, len(all_p))
results["num_params"] = first_num_params or 0
results["eval_conditions"] = list(keys)
results["eval_full_run"] = is_full_condition_set(condition_keys)
results["num_samples"] = total_samples
results["eval_audio_seconds"] = round(total_audio_s, 3)
results["eval_wall_time_s"] = round(total_infer_s, 3)
if total_infer_s > 1e-6:
results["eval_rtf"] = round(total_audio_s / total_infer_s, 4)
else:
results["eval_rtf"] = ""
if progress_cb is not None:
try:
progress_cb(done_so_far, grand_total, "")
except Exception:
pass
return results
@lru_cache(maxsize=16)
def _spaces_gpu_wrapped(duration_s: int, size: str):
import spaces
return spaces.GPU(duration=duration_s, size=size)(_run_evaluation_core)
@lru_cache(maxsize=16)
def _spaces_gpu_segment_wrapper(duration_s: int, size: str):
import spaces
return spaces.GPU(duration=duration_s, size=size)(_zerogpu_run_segment)
def run_evaluation(
model_id: str,
family_id: str = "auto",
progress_cb: Callable[[int, int, str], None] | None = None,
custom_script: str = "",
condition_keys: list[str] | None = None,
) -> dict:
"""
Evaluate `model_id` on all packed conditions using the selected family backend.
On Hugging Face **Spaces ZeroGPU**, evaluation runs under ``spaces.GPU``:
- ``FFASR_ZEROGPU_MAX_DURATION_S`` — requested max GPU seconds per **decorated** call
(default **600**). Capped by ``FFASR_ZEROGPU_HUB_MAX_DURATION_S`` (default **600**) because
the Hub rejects overly large values (e.g. 1800s).
- ``FFASR_ZEROGPU_GPU_SIZE`` — ``large`` (default) or ``xlarge``.
- ``FFASR_ZEROGPU_SAMPLES_PER_SEGMENT`` — when **>0**, each condition is split into chunks of
this many samples; **each chunk** uses its own ``spaces.GPU`` call with the duration cap
(the model is **reloaded** every chunk). Merge is exact for WER (full-condition preds/refs).
If the ``spaces`` package is not installed (local dev), evaluation runs without the decorator.
**CPU / device overrides** (see also ``evaluation/runtime.py``):
- ``FFASR_DEVICE`` — ``auto`` (default), ``cpu``, or ``cuda``. Forces CPU on GPU hosts when set to ``cpu``.
- ``FFASR_DISABLE_ZEROGPU`` — when ``1``/``true``, never use ``spaces.GPU`` even if ``spaces`` is installed;
segmented eval (``FFASR_ZEROGPU_SAMPLES_PER_SEGMENT`` > 0) runs in-process slices instead.
- ``FFASR_TORCH_NUM_THREADS`` / ``FFASR_TORCH_NUM_INTEROP_THREADS`` — optional torch thread caps (applied once per process).
If ``progress_cb`` is provided, it is called as
``progress_cb(samples_done_across_all_conditions, samples_total_across_all_conditions, current_condition_key)``
periodically during the run.
When ``custom_script`` is non-empty, it must define ``evaluate(file: pathlib.Path) -> str``;
that function is called once per packed sample (via a temp WAV) instead of the family backend.
Returns wer_clean, wer_noisy, wer_reverberant, wer_real, wer_difficult, num_samples, model_id, eval_family, plus timing.
"""
apply_cpu_thread_settings_once()
disable_broken_torchcodec()
seg = _samples_per_gpu_segment()
if not use_spaces_gpu_decorator():
if seg > 0:
return _run_evaluation_core_segmented_local(
model_id,
family_id=family_id,
progress_cb=progress_cb,
segment_samples=seg,
custom_script=custom_script,
condition_keys=condition_keys,
)
return _run_evaluation_core(
model_id,
family_id=family_id,
progress_cb=progress_cb,
custom_script=custom_script,
condition_keys=condition_keys,
)
eff = _effective_gpu_duration_s()
size = os.environ.get("FFASR_ZEROGPU_GPU_SIZE", "large").strip().lower()
if size not in ("large", "xlarge"):
size = "large"
if seg <= 0:
wrapped = _spaces_gpu_wrapped(eff, size)
return wrapped(
model_id,
family_id=family_id,
progress_cb=progress_cb,
custom_script=custom_script,
condition_keys=condition_keys,
)
return _run_evaluation_core_segmented(
model_id,
family_id=family_id,
progress_cb=progress_cb,
segment_samples=seg,
gpu_duration_s=eff,
gpu_size=size,
custom_script=custom_script,
condition_keys=condition_keys,
)