mathly / scripts /compare_verbs.py
3v324v23's picture
Enhance pronunciation rules and scoring system
269a3c8
Raw History Blame Contribute Delete
6.62 kB
#!/usr/bin/env python3
"""Check the phonemes of static/assets/arabic_verbs.json against what the scorer uses.
Usage: python scripts/compare_verbs.py # summary + every difference
python scripts/compare_verbs.py --all # also list the forms that match
python scripts/compare_verbs.py --no-vocab # skip the model-vocab check (no tokenizer download)
For every past / future form (and its accepted_phonemes) it reports:
bank the same Arabic word in the phrase bank (phrases.py, workbook LEX, grammar VERBS) and whether its IPA
agrees: "same" (identical), "same sounds" (identical once archify() symbols and base() are applied,
i.e. the scorer would build the same target), or "differs"
archi how archify() rewrites the form (ق ك ث ذ ظ ج as Q K TH DH ZH J) and the letters it could not place
vocab phonemes the model has no symbol for (mapped to a fallback, or not expressible at all)
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from collections import Counter
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from app.archi import archify, split_token, unresolved # noqa: E402
from app.grammar import VERBS # noqa: E402
from app.phones import base # noqa: E402
from app.phrases import PHRASES # noqa: E402
from app.workbook import LEX # noqa: E402
VERBS_JSON = ROOT / "static/assets/arabic_verbs.json"
_MARKS = re.compile(r"[ً-ْٰـ]") # harakat, shadda, sukun, dagger alif, tatweel
def loose(ar: str) -> str:
"""Arabic without diacritics and with the hamza seats of alif folded, for a lookup that tolerates spelling."""
return _MARKS.sub("", ar).translate(str.maketrans("أإآ", "ااا")).replace("ى", "ي")
def bank_words() -> dict[str, set[str]]:
"""Arabic word -> every IPA the bank gives it (single words of each variant, LEX, grammar VERBS)."""
out: dict[str, set[str]] = {}
for p in PHRASES:
for ar, ipa in p["variants"]:
ars, ipas = ar.split(), [w.strip() for w in ipa.split("|")]
if len(ars) == len(ipas):
for a, i in zip(ars, ipas):
out.setdefault(a, set()).add(i)
for key, (_tr, ipas) in LEX.items():
out.setdefault(key.partition("#")[0], set()).update(ipas)
for forms in VERBS.values():
for _he, _tr, ar, ipas in forms:
out.setdefault(ar, set()).update(ipas)
return out
def sounds(ar: str, ipa: str) -> tuple[str, ...]:
"""The target as the scorer sees it: archify() symbols kept, other phonemes reduced to their base() sound."""
out = []
for tok in archify(ar, ipa).split():
if tok == "|":
continue
sym, _members, ph = split_token(tok)
out.append(sym if sym else base(ph))
return tuple(out)
def load_vocab():
from huggingface_hub import hf_hub_download
from app.scorer import Vocab
model_id = os.environ.get("MODEL_ID", "facebook/wav2vec2-xlsr-53-espeak-cv-ft")
path = Path(model_id) / "vocab.json" if Path(model_id).is_dir() else hf_hub_download(model_id, "vocab.json")
return Vocab(json.loads(Path(path).read_text(encoding="utf-8")))
def vocab_issues(vocab, ar: str, ipa: str) -> list[str]:
issues = []
for tok in archify(ar, ipa).split():
before = dict(vocab.downgraded)
try:
vocab.token(tok)
except KeyError as e:
issues.append(f"{tok}: {e}")
continue
issues += [f"{k}->{v}" for k, v in vocab.downgraded.items() if before.get(k) != v]
return issues
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--all", action="store_true", help="also print forms that match the bank")
ap.add_argument("--no-vocab", action="store_true", help="skip the model-vocab check")
args = ap.parse_args()
data = json.loads(VERBS_JSON.read_text(encoding="utf-8"))
bank = bank_words()
bank_loose: dict[str, set[str]] = {}
for a, ipas in bank.items():
bank_loose.setdefault(loose(a), set()).update(ipas)
vocab = None if args.no_vocab else load_vocab()
tally: Counter[str] = Counter()
symbols: Counter[str] = Counter()
for section, entry in data.items():
for verb in entry["verbs"]:
for tense in ("past", "future"):
form = verb[tense]
ar = form["arabic"]
for ipa in [form["phonemes"], *form.get("accepted_phonemes", [])]:
lines = []
for tok in ipa.split():
if split_token(tok)[0]:
symbols[tok] += 1
known = bank.get(ar) or bank_loose.get(loose(ar))
if not known:
status = "not in bank"
elif ipa in known:
status = "same"
elif sounds(ar, ipa) in {sounds(ar, k) for k in known}:
status = "same sounds"
else:
status = "differs"
how = "" if ar in bank else " (matched without diacritics: may be another word)"
lines.append(f" bank: {' / '.join(sorted(known))}{how}")
tally[status] += 1
arch = archify(ar, ipa)
if arch != ipa:
lines.append(f" archi: {arch}")
if missing := unresolved(ar, ipa):
tally["unresolved"] += 1
lines.append(f" unresolved letters: {' '.join(l for _, l in missing)}")
if vocab is not None and (bad := vocab_issues(vocab, ar, ipa)):
tally["vocab"] += 1
lines.append(f" vocab: {', '.join(bad)}")
if status in ("differs",) or missing or args.all or (vocab is not None and bad):
print(f"[{section}] {verb['hebrew']} {tense} {ar} {ipa} -> {status}")
print("\n".join(lines)) if lines else None
print("\n== summary ==")
for k in ("same", "same sounds", "differs", "not in bank", "unresolved", "vocab"):
print(f"{k:12} {tally[k]}")
if symbols:
print("symbols written in the JSON itself (archify() leaves such a word as it is):",
", ".join(f"{s}×{n}" for s, n in symbols.most_common()))
if __name__ == "__main__":
main()