african-speech-lab / lm /decoder.py
Or4kool's picture
Upload folder using huggingface_hub
e6032a0 verified
Raw
History Blame Contribute Delete
4.57 kB
"""Phase 4 β€” per-language pyctcdecode decoders backed by KenLM.
Builds a `BeamSearchDecoderCTC` per language and routes by the clip's locale.
Reused by `transcribe.py` (demo) and the Phase 5 evaluation script.
"""
from __future__ import annotations
import os
from typing import Dict, List, Optional
def ctc_labels(tokenizer) -> List[str]:
"""Vocab tokens (id order) mapped to the pyctcdecode/wav2vec2 convention:
pad β†’ "" (CTC blank), word delimiter "|" β†’ " ", others unchanged.
"""
vocab = tokenizer.get_vocab() # {token: id}
tokens = [tok for tok, _ in sorted(vocab.items(), key=lambda kv: kv[1])]
labels = []
for tok in tokens:
if tok == tokenizer.pad_token:
labels.append("") # CTC blank
elif tok == tokenizer.word_delimiter_token:
labels.append(" ") # word boundary
else:
labels.append(tok)
return labels
SPECIAL_TOKENS = ("<unk>", "<s>", "</s>")
MIN_UNIGRAM_COVERAGE = 0.90
def arpa_unigrams(arpa_path: str) -> List[str]:
"""The .arpa's own 1-gram vocabulary (excluding KenLM's special tokens)."""
words, in_section = [], False
with open(arpa_path, encoding="utf-8") as f:
for line in f:
if not in_section:
if line.startswith("\\1-grams:"):
in_section = True
continue
if line.startswith("\\"): # next section (\2-grams: / \end\)
break
parts = line.rstrip("\n").split("\t") # blank lines -> len 1, skipped
if len(parts) >= 2 and parts[1] not in SPECIAL_TOKENS:
words.append(parts[1])
return words
def check_unigram_coverage(arpa_path: str, unigrams: List[str],
min_coverage: float = MIN_UNIGRAM_COVERAGE) -> float:
"""Fraction of the LM's OWN vocabulary present in `unigrams`; raises if low.
pyctcdecode adds `unk_score_offset` (-10 logp) to every word that is not in
the unigram list, EVEN IF the KenLM models it well. So a unigram list that
under-covers the .arpa turns a large LM into a tiny closed vocabulary with a
-10 cliff around it. pyctcdecode only warns on the OPPOSITE direction
(unigrams missing from the LM), which stays near 100% precisely when this
failure is at its worst β€” so it cannot catch a stale list. Checked here.
"""
lm_vocab = set(arpa_unigrams(arpa_path))
if not lm_vocab:
return 1.0
coverage = len(lm_vocab & set(unigrams)) / len(lm_vocab)
if coverage < min_coverage:
raise RuntimeError(
f"unigrams.txt covers only {coverage:.2%} of {arpa_path}'s "
f"{len(lm_vocab)} vocabulary words (min {min_coverage:.0%}) β€” it is "
f"STALE relative to this .arpa. pyctcdecode would penalise the "
f"other {len(lm_vocab - set(unigrams))} LM-known words by "
f"unk_score_offset. Regenerate it from the corpus this .arpa was "
f"trained on:\n"
f" python src/lm/build_kenlm.py --unigrams-only <lang> <corpus.txt>"
)
return coverage
def load_decoders(
lm_dir: str,
languages: List[str],
labels: List[str],
alpha: float,
beta: float,
) -> Dict[str, "object"]:
"""Return {lang: BeamSearchDecoderCTC}. Skips languages with no .arpa."""
from pyctcdecode import build_ctcdecoder
decoders: Dict[str, object] = {}
for lang in languages:
arpa = os.path.join(lm_dir, lang, f"{lang}.arpa")
if not os.path.exists(arpa):
print(f" [decoder] no LM for '{lang}' ({arpa}); will fall back to greedy")
continue
unigrams: Optional[List[str]] = None
uni = os.path.join(lm_dir, lang, "unigrams.txt")
if os.path.exists(uni):
with open(uni, encoding="utf-8") as f:
unigrams = [w for w in f.read().split("\n") if w]
cov = check_unigram_coverage(arpa, unigrams)
print(f" [decoder] {lang}: unigrams cover {cov:.1%} of LM vocab")
decoders[lang] = build_ctcdecoder(
labels, kenlm_model_path=arpa, unigrams=unigrams, alpha=alpha, beta=beta
)
return decoders
def decode_logits(decoders, lang, logits, beam_width: int):
"""LM-decode a single (T, vocab) logits array for `lang`. Returns text or None
if no decoder exists for that language (caller should fall back to greedy)."""
dec = decoders.get(lang)
if dec is None:
return None
return dec.decode(logits, beam_width=beam_width).strip()