"""Word Error Rate evaluation for speech recognition models. Evaluates transcription quality on LibriSpeech test-clean using Qwen3-ASR (via mlx-audio) for inference and edit distance for WER computation. """ from dataclasses import dataclass import numpy as np @dataclass class WERResult: n_samples: int wer: float # Word Error Rate n_correct_words: int n_total_words: int n_substitutions: int n_insertions: int n_deletions: int def _edit_distance(ref: list[str], hyp: list[str]) -> tuple[int, int, int]: """Compute edit distance between reference and hypothesis word lists. Returns (substitutions, insertions, deletions). """ n = len(ref) m = len(hyp) # dp[i][j] = (cost, subs, ins, dels) for ref[:i] vs hyp[:j] dp = [[(0, 0, 0, 0)] * (m + 1) for _ in range(n + 1)] for i in range(1, n + 1): dp[i][0] = (i, 0, 0, i) for j in range(1, m + 1): dp[0][j] = (j, 0, j, 0) for i in range(1, n + 1): for j in range(1, m + 1): if ref[i - 1] == hyp[j - 1]: dp[i][j] = dp[i - 1][j - 1] else: sub = dp[i - 1][j - 1] ins = dp[i][j - 1] dele = dp[i - 1][j] candidates = [ (sub[0] + 1, sub[1] + 1, sub[2], sub[3]), (ins[0] + 1, ins[1], ins[2] + 1, ins[3]), (dele[0] + 1, dele[1], dele[2], dele[3] + 1), ] dp[i][j] = min(candidates, key=lambda x: x[0]) _, subs, ins, dels = dp[n][m] return subs, ins, dels def evaluate_wer_mlx( model, n_samples: int = 100, seed: int = 42, ) -> WERResult: """Evaluate WER on LibriSpeech test-clean using a Qwen3-ASR model. Args: model: MLX Qwen3-ASR model with .generate() method n_samples: Number of audio samples to evaluate seed: Random seed for sample selection Returns: WERResult with WER metrics """ from datasets import load_dataset from tqdm import tqdm rng = np.random.RandomState(seed) print(f" Loading LibriSpeech test-clean...") ds = load_dataset("librispeech_asr", "clean", split="test") indices = rng.choice(len(ds), size=min(n_samples, len(ds)), replace=False) indices.sort() total_subs = 0 total_ins = 0 total_dels = 0 total_words = 0 for idx in tqdm(indices, desc=" WER eval"): item = ds[int(idx)] audio = item["audio"]["array"].astype(np.float32) ref_text = item["text"].lower().strip() ref_words = ref_text.split() if not ref_words: continue total_words += len(ref_words) try: result = model.generate( audio, language="English", temperature=0.0, ) hyp_text = result.text.lower().strip() hyp_words = hyp_text.split() subs, ins, dels = _edit_distance(ref_words, hyp_words) total_subs += subs total_ins += ins total_dels += dels except Exception as e: # Count all words as deletions on failure total_dels += len(ref_words) wer = (total_subs + total_ins + total_dels) / max(total_words, 1) n_correct = total_words - total_subs - total_dels print(f" WER: {wer:.4f} ({total_subs}S + {total_ins}I + {total_dels}D / {total_words} words)") return WERResult( n_samples=len(indices), wer=wer, n_correct_words=max(n_correct, 0), n_total_words=total_words, n_substitutions=total_subs, n_insertions=total_ins, n_deletions=total_dels, ) def print_wer_report(result: WERResult): """Print WER evaluation results.""" print(f"\n WER Report") print(f" {'=' * 50}") print(f" Samples: {result.n_samples}") print(f" WER: {result.wer:.4f} ({result.wer * 100:.2f}%)") print(f" Total words: {result.n_total_words}") print(f" Errors: {result.n_substitutions}S + {result.n_insertions}I + {result.n_deletions}D") print(f" {'=' * 50}")