Upload folder using huggingface_hub
Browse files- README.md +11 -12
- bar_plot_comparison.png +2 -2
- renikud_onnx.py +22 -384
README.md
CHANGED
|
@@ -6,7 +6,7 @@ pipeline_tag: text-to-speech
|
|
| 6 |
---
|
| 7 |
# RenikudPlus — Hebrew grapheme-to-phoneme
|
| 8 |
|
| 9 |
-
Hebrew text in, IPA out, with stress marks. Trained without any benchmark-derived data; every number below reproduces from these files. On 126k words of unseen podcast speech it scores 93.
|
| 10 |
|
| 11 |

|
| 12 |
|
|
@@ -14,20 +14,20 @@ Hebrew text in, IPA out, with stress marks. Trained without any benchmark-derive
|
|
| 14 |
|---|---:|---:|---:|---:|---:|
|
| 15 |
| **OVERALL** | **86.2** | 85.9 | 84.6 | 78.2 | 66.4 |
|
| 16 |
| Gender | **99.3** | 99.3 | 77.0 | 59.9 | 37.5 |
|
| 17 |
-
| Min. Stress Pairs |
|
| 18 |
-
| Stress Homographs |
|
| 19 |
-
| Names |
|
| 20 |
-
| Acronyms | **79.6** |
|
| 21 |
-
| Slang | **
|
| 22 |
| Penultimate Stress | 85.4 | 86.1 | **89.4** | 82.1 | 60.9 |
|
| 23 |
-
| Rare Phonemes |
|
| 24 |
-
| Foreign | **78.
|
| 25 |
-
| Colloquial |
|
| 26 |
| ILSpeech-test | 93.2 | 93.1 | **96.0** | 92.5 | 85.5 |
|
| 27 |
|
| 28 |
```python
|
| 29 |
from renikud_onnx import G2P # pip install onnxruntime numpy
|
| 30 |
-
g2p = G2P("model.onnx") # or model_int8.onnx
|
| 31 |
g2p.phonemize("הלכתי לספר וקראתי ספר בזמן שהוא ספר כמה אצבעות יש לו")
|
| 32 |
# halˈaχti lesapˈeʁ vekaʁˈati sˈefeʁ bizmˈan ʃehˈu safˈaʁ kˈama ʔetsbaʔˈot jˈeʃ lˈo
|
| 33 |
g2p.phonemize("המחיר 1250 שקלים") # digits -> words: hameχˈiʁ ʔˈelef matˈajim…
|
|
@@ -36,5 +36,4 @@ g2p.phonemize("אני רוצה להגיד לך משהו חשוב", speaker=2, ta
|
|
| 36 |
|
| 37 |
- `model.onnx` — fp32 weights
|
| 38 |
- `model_int8.onnx` — quantized, 4× smaller
|
| 39 |
-
- `
|
| 40 |
-
- `renikud_onnx.py` — wrapper (decoding, rescoring, long inputs, numbers)
|
|
|
|
| 6 |
---
|
| 7 |
# RenikudPlus — Hebrew grapheme-to-phoneme
|
| 8 |
|
| 9 |
+
Hebrew text in, IPA out, with stress marks. Trained without any benchmark-derived data; every number below reproduces from these files. On 126k words of unseen podcast speech it scores 93.3% word accuracy.
|
| 10 |
|
| 11 |

|
| 12 |
|
|
|
|
| 14 |
|---|---:|---:|---:|---:|---:|
|
| 15 |
| **OVERALL** | **86.2** | 85.9 | 84.6 | 78.2 | 66.4 |
|
| 16 |
| Gender | **99.3** | 99.3 | 77.0 | 59.9 | 37.5 |
|
| 17 |
+
| Min. Stress Pairs | 90.0 | **91.3** | 88.0 | 80.7 | 78.7 |
|
| 18 |
+
| Stress Homographs | **91.1** | 90.6 | 92.6 | 79.8 | 78.3 |
|
| 19 |
+
| Names | 80.0 | **82.0** | 75.3 | 67.3 | 68.0 |
|
| 20 |
+
| Acronyms | **79.6** | 76.3 | 79.0 | 56.6 | 37.5 |
|
| 21 |
+
| Slang | **75.6** | 75.0 | 71.2 | 59.0 | 41.7 |
|
| 22 |
| Penultimate Stress | 85.4 | 86.1 | **89.4** | 82.1 | 60.9 |
|
| 23 |
+
| Rare Phonemes | **58.3** | 55.0 | 57.6 | 41.1 | 19.9 |
|
| 24 |
+
| Foreign | **78.1** | 77.4 | 71.6 | 56.8 | 36.1 |
|
| 25 |
+
| Colloquial | 51.0 | 51.0 | 25.2 | **54.3** | 9.3 |
|
| 26 |
| ILSpeech-test | 93.2 | 93.1 | **96.0** | 92.5 | 85.5 |
|
| 27 |
|
| 28 |
```python
|
| 29 |
from renikud_onnx import G2P # pip install onnxruntime numpy
|
| 30 |
+
g2p = G2P("model.onnx") # or model_int8.onnx
|
| 31 |
g2p.phonemize("הלכתי לספר וקראתי ספר בזמן שהוא ספר כמה אצבעות יש לו")
|
| 32 |
# halˈaχti lesapˈeʁ vekaʁˈati sˈefeʁ bizmˈan ʃehˈu safˈaʁ kˈama ʔetsbaʔˈot jˈeʃ lˈo
|
| 33 |
g2p.phonemize("המחיר 1250 שקלים") # digits -> words: hameχˈiʁ ʔˈelef matˈajim…
|
|
|
|
| 36 |
|
| 37 |
- `model.onnx` — fp32 weights
|
| 38 |
- `model_int8.onnx` — quantized, 4× smaller
|
| 39 |
+
- `renikud_onnx.py` — wrapper (decoding, long inputs, numbers, speaker inputs)
|
|
|
bar_plot_comparison.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
renikud_onnx.py
CHANGED
|
@@ -3,30 +3,19 @@
|
|
| 3 |
Usage: put this file next to your script, then `from renikud_onnx import G2P`.
|
| 4 |
Requires: onnxruntime, numpy. Bundles the Hebrew number front end.
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
the model file, at its tuned settings (tau = 0.2, corpus mask). Pass
|
| 10 |
-
`datastore=None` to switch it off, or a path to point it elsewhere.
|
| 11 |
-
2. a force-only runtime lexicon: `lexicon=` — a surface -> IPA map that always
|
| 12 |
-
wins where it matches. Off unless you pass one.
|
| 13 |
"""
|
| 14 |
from __future__ import annotations
|
| 15 |
|
| 16 |
import json
|
| 17 |
-
import os
|
| 18 |
import re
|
| 19 |
import unicodedata
|
| 20 |
|
| 21 |
import numpy as np
|
| 22 |
import onnxruntime as ort
|
| 23 |
|
| 24 |
-
#: rescoring datastore looked for beside the model file when `datastore="auto"`.
|
| 25 |
-
DEFAULT_DATASTORE_NAME = "datastore.json"
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
# --------------------------------------------------------------------------
|
| 31 |
# Number words
|
| 32 |
# --------------------------------------------------------------------------
|
|
@@ -763,23 +752,13 @@ def normalize_graphemes(text: str) -> str:
|
|
| 763 |
|
| 764 |
|
| 765 |
# ==========================================================================
|
| 766 |
-
#
|
| 767 |
# --------------------------------------------------------------------------
|
| 768 |
-
# The
|
| 769 |
-
#
|
| 770 |
-
# The candidate inventory, the proposal energies, the trust gate and the aligner
|
| 771 |
-
# all live in this file, with their constants and tie-breaks intact.
|
| 772 |
# ==========================================================================
|
| 773 |
|
| 774 |
-
#
|
| 775 |
-
SRC_LEX, SRC_HOMO, SRC_CORPUS, SRC_OLD = 1, 2, 4, 8
|
| 776 |
-
|
| 777 |
-
#: every source except SRC_OLD.
|
| 778 |
-
MASK_ALL = SRC_LEX | SRC_HOMO | SRC_CORPUS
|
| 779 |
-
#: the default: corpus attestation only.
|
| 780 |
-
MASK_CORPUS = SRC_CORPUS
|
| 781 |
-
|
| 782 |
-
#: punctuation stripped off an IPA token before comparing it to a proposal.
|
| 783 |
PUNCT_STRIP = ".,?!:;\"'()-—…«»"
|
| 784 |
|
| 785 |
_IPA_ASCII_FIXUPS = {"g": "ɡ", "x": "χ", "r": "ʁ"}
|
|
@@ -792,53 +771,6 @@ def normalize_ipa(text: str) -> str:
|
|
| 792 |
return text
|
| 793 |
|
| 794 |
|
| 795 |
-
# --- label vocabularies ----------------------------------------------------
|
| 796 |
-
VOWEL_SYMBOLS = ("∅", "a", "e", "i", "o", "u")
|
| 797 |
-
VOWEL_TO_ID = {v: i for i, v in enumerate(VOWEL_SYMBOLS)}
|
| 798 |
-
NUM_VOWEL_CLASSES = len(VOWEL_SYMBOLS)
|
| 799 |
-
|
| 800 |
-
CONSONANT_SYMBOLS = (
|
| 801 |
-
"∅",
|
| 802 |
-
"b", "v", "d", "h", "z", "χ", "t", "j", "k", "l", "m", "n", "s", "f", "p",
|
| 803 |
-
"ts", "tʃ", "w", "ʔ", "ɡ", "ʁ", "ʃ", "ʒ", "dʒ",
|
| 804 |
-
)
|
| 805 |
-
CONSONANT_TO_ID = {c: i for i, c in enumerate(CONSONANT_SYMBOLS)}
|
| 806 |
-
|
| 807 |
-
_VOWELS_SET = frozenset("aeiou")
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
def chunk_to_labels(chunk: str) -> tuple[str, str, int]:
|
| 811 |
-
"""Aligned IPA chunk -> (consonant, vowel, stress)."""
|
| 812 |
-
if not chunk or chunk == " ":
|
| 813 |
-
return ("∅", "∅", 0)
|
| 814 |
-
pos = 0
|
| 815 |
-
stress = 0
|
| 816 |
-
if STRESS_MARK in chunk:
|
| 817 |
-
stress = 1
|
| 818 |
-
chunk = chunk.replace(STRESS_MARK, "")
|
| 819 |
-
consonant = "∅"
|
| 820 |
-
for multi in ("tʃ", "dʒ", "ts"):
|
| 821 |
-
if chunk.startswith(multi):
|
| 822 |
-
consonant = multi
|
| 823 |
-
pos = len(multi)
|
| 824 |
-
break
|
| 825 |
-
else:
|
| 826 |
-
if pos < len(chunk) and chunk[pos] not in _VOWELS_SET:
|
| 827 |
-
consonant = chunk[pos]
|
| 828 |
-
pos += 1
|
| 829 |
-
vowel = chunk[pos:] if pos < len(chunk) else "∅"
|
| 830 |
-
if not vowel:
|
| 831 |
-
vowel = "∅"
|
| 832 |
-
if vowel.endswith("χ"):
|
| 833 |
-
consonant = "χ"
|
| 834 |
-
vowel = vowel[:-1] or "∅"
|
| 835 |
-
return (
|
| 836 |
-
consonant if consonant in CONSONANT_TO_ID else "∅",
|
| 837 |
-
vowel if vowel in VOWEL_TO_ID else "∅",
|
| 838 |
-
stress,
|
| 839 |
-
)
|
| 840 |
-
|
| 841 |
-
|
| 842 |
# --- the aligner -----------------------------------------------------------
|
| 843 |
ALIGN_LETTER_CONSONANTS: dict[str, tuple[str, ...]] = {
|
| 844 |
"א": ("ʔ", ""), "ב": ("b", "v"), "ג": ("ɡ", "dʒ"), "ד": ("d",),
|
|
@@ -988,172 +920,6 @@ def align_word(heb_word: str, ipa_word: str, cells: tuple = ()) -> list[tuple[st
|
|
| 988 |
return list(result) if result is not None else None
|
| 989 |
|
| 990 |
|
| 991 |
-
def candidate_labels(heb_word: str, ipa_word: str,
|
| 992 |
-
cells: tuple = ()) -> list[tuple[int, int, int]] | None:
|
| 993 |
-
"""eval_candidate_rescore.candidate_labels — per-letter (cid, vid, stress)."""
|
| 994 |
-
aligned = align_word(heb_word, ipa_word, cells)
|
| 995 |
-
if aligned is None:
|
| 996 |
-
return None
|
| 997 |
-
out = []
|
| 998 |
-
for _, chunk in aligned:
|
| 999 |
-
c, v, s = chunk_to_labels(chunk)
|
| 1000 |
-
cid, vid = CONSONANT_TO_ID.get(c), VOWEL_TO_ID.get(v)
|
| 1001 |
-
if cid is None or vid is None:
|
| 1002 |
-
return None
|
| 1003 |
-
out.append((cid, vid, s))
|
| 1004 |
-
return out
|
| 1005 |
-
|
| 1006 |
-
|
| 1007 |
-
# --- the candidate inventory -----------------------------------------------
|
| 1008 |
-
_VE_RE = re.compile(r"^(ve|va|vi|v)")
|
| 1009 |
-
_LEGAL_IPA_RE = re.compile(r"^[abdefghijklmnoprstuvwzɡʁʃʒʔˈχ]+$")
|
| 1010 |
-
|
| 1011 |
-
|
| 1012 |
-
def _inv_clean(ipa: str) -> str | None:
|
| 1013 |
-
ipa = normalize_ipa(ipa.strip())
|
| 1014 |
-
return ipa if ipa and _LEGAL_IPA_RE.match(ipa) else None
|
| 1015 |
-
|
| 1016 |
-
|
| 1017 |
-
def _inv_heb_ok(w: str) -> bool:
|
| 1018 |
-
"""A separator-bearing surface (במד״א, ג'אז) can never be a candidate at any
|
| 1019 |
-
tau. The force lexicon exists for exactly this class."""
|
| 1020 |
-
return bool(w) and all(_is_hebrew(c) for c in w)
|
| 1021 |
-
|
| 1022 |
-
|
| 1023 |
-
def _inv_veva_surface(readings: list[str]) -> bool:
|
| 1024 |
-
seen: dict[str, str] = {}
|
| 1025 |
-
for r in readings:
|
| 1026 |
-
m = _VE_RE.match(r)
|
| 1027 |
-
if not m:
|
| 1028 |
-
continue
|
| 1029 |
-
stem = r[m.end():]
|
| 1030 |
-
if stem in seen and seen[stem] != r:
|
| 1031 |
-
return True
|
| 1032 |
-
seen[stem] = r
|
| 1033 |
-
return False
|
| 1034 |
-
|
| 1035 |
-
|
| 1036 |
-
def build_inventory(datastore: str,
|
| 1037 |
-
lexnikud: str | None = None,
|
| 1038 |
-
homograph_inventory: str | None = None,
|
| 1039 |
-
old_lexicon: str | None = None,
|
| 1040 |
-
corpus_min_total: int = 5,
|
| 1041 |
-
corpus_min_count: int = 3,
|
| 1042 |
-
corpus_min_share: float = 0.05,
|
| 1043 |
-
homo_min_share: float = 0.02) -> tuple[dict, dict]:
|
| 1044 |
-
"""Build the candidate inventory. Every source path is supplied by the caller.
|
| 1045 |
-
|
| 1046 |
-
Only `datastore` is required, and it is the only source shipped beside the
|
| 1047 |
-
model; the other three are optional extras.
|
| 1048 |
-
"""
|
| 1049 |
-
surf: dict[str, dict[str, int]] = {}
|
| 1050 |
-
|
| 1051 |
-
def add(w: str, ipa: str, bit: int) -> None:
|
| 1052 |
-
if not _inv_heb_ok(w):
|
| 1053 |
-
return
|
| 1054 |
-
c = _inv_clean(ipa)
|
| 1055 |
-
if c is None:
|
| 1056 |
-
return
|
| 1057 |
-
d = surf.setdefault(w, {})
|
| 1058 |
-
d[c] = d.get(c, 0) | bit
|
| 1059 |
-
|
| 1060 |
-
stats: dict = {}
|
| 1061 |
-
|
| 1062 |
-
n = 0
|
| 1063 |
-
if lexnikud:
|
| 1064 |
-
with open(lexnikud, encoding="utf-8") as fh:
|
| 1065 |
-
fh.readline()
|
| 1066 |
-
for line in fh:
|
| 1067 |
-
p = line.rstrip("\n").split("\t")
|
| 1068 |
-
if len(p) == 3:
|
| 1069 |
-
add(p[1], p[2], SRC_LEX)
|
| 1070 |
-
n += 1
|
| 1071 |
-
stats["lexnikud_rows"] = n
|
| 1072 |
-
|
| 1073 |
-
n = 0
|
| 1074 |
-
if homograph_inventory:
|
| 1075 |
-
with open(homograph_inventory, encoding="utf-8") as fh:
|
| 1076 |
-
inv = json.load(fh)
|
| 1077 |
-
for w, d in inv.items():
|
| 1078 |
-
tot = max(d.get("total", 0), 1)
|
| 1079 |
-
for ipa, c in d.get("readings", {}).items():
|
| 1080 |
-
if c / tot >= homo_min_share:
|
| 1081 |
-
add(w, ipa, SRC_HOMO)
|
| 1082 |
-
n += 1
|
| 1083 |
-
stats["homograph_inventory_readings"] = n
|
| 1084 |
-
|
| 1085 |
-
with open(datastore, encoding="utf-8") as fh:
|
| 1086 |
-
ds = json.load(fh)
|
| 1087 |
-
n = 0
|
| 1088 |
-
for w, d in ds.items():
|
| 1089 |
-
tot = d.get("total", 0)
|
| 1090 |
-
if tot < corpus_min_total:
|
| 1091 |
-
continue
|
| 1092 |
-
for ipa, c in d.get("readings", {}).items():
|
| 1093 |
-
if c >= corpus_min_count and c / tot >= corpus_min_share:
|
| 1094 |
-
add(w, ipa, SRC_CORPUS)
|
| 1095 |
-
n += 1
|
| 1096 |
-
stats["corpus_datastore_readings"] = n
|
| 1097 |
-
|
| 1098 |
-
n = 0
|
| 1099 |
-
if old_lexicon:
|
| 1100 |
-
with open(old_lexicon, encoding="utf-8") as fh:
|
| 1101 |
-
head = fh.readline()
|
| 1102 |
-
if "word" not in head:
|
| 1103 |
-
fh.seek(0)
|
| 1104 |
-
for line in fh:
|
| 1105 |
-
p = line.rstrip("\n").split("\t")
|
| 1106 |
-
if len(p) < 2 or not p[0] or not p[1]:
|
| 1107 |
-
continue
|
| 1108 |
-
for ipa in p[1].split(";"):
|
| 1109 |
-
add(p[0].strip(), ipa, SRC_OLD)
|
| 1110 |
-
n += 1
|
| 1111 |
-
stats["old_lexicon_readings"] = n
|
| 1112 |
-
|
| 1113 |
-
dropped = [w for w, r in surf.items() if _inv_veva_surface(list(r))]
|
| 1114 |
-
for w in dropped:
|
| 1115 |
-
del surf[w]
|
| 1116 |
-
stats["veva_surfaces_dropped"] = len(dropped)
|
| 1117 |
-
stats["total_surfaces"] = len(surf)
|
| 1118 |
-
stats["total_readings"] = sum(len(r) for r in surf.values())
|
| 1119 |
-
stats["multi_reading_surfaces"] = sum(1 for r in surf.values() if len(r) > 1)
|
| 1120 |
-
return surf, stats
|
| 1121 |
-
|
| 1122 |
-
|
| 1123 |
-
# --- the trust gate --------------------------------------------------------
|
| 1124 |
-
def apply_gate(rec: dict, tau: float, mask: int) -> tuple[str, list[dict]]:
|
| 1125 |
-
"""Take an attested reading wherever it costs less than `tau` nats/letter.
|
| 1126 |
-
|
| 1127 |
-
`rec` is `{"base": <ipa string>, "props": [...]}`.
|
| 1128 |
-
"""
|
| 1129 |
-
words = rec["base"].split()
|
| 1130 |
-
touched = []
|
| 1131 |
-
for p in rec["props"]:
|
| 1132 |
-
best = None
|
| 1133 |
-
for ipa, delta, src in p["cands"]:
|
| 1134 |
-
if not (src & mask):
|
| 1135 |
-
continue
|
| 1136 |
-
if delta < -tau:
|
| 1137 |
-
continue
|
| 1138 |
-
if best is None or delta > best[1]:
|
| 1139 |
-
best = (ipa, delta)
|
| 1140 |
-
if best is None or best[0] == p["base_ipa"]:
|
| 1141 |
-
continue
|
| 1142 |
-
wi = p["wi"]
|
| 1143 |
-
if wi >= len(words):
|
| 1144 |
-
continue
|
| 1145 |
-
tok = words[wi]
|
| 1146 |
-
lead = len(tok) - len(tok.lstrip(PUNCT_STRIP))
|
| 1147 |
-
trail = len(tok) - len(tok.rstrip(PUNCT_STRIP))
|
| 1148 |
-
core = tok[lead: len(tok) - trail] if trail else tok[lead:]
|
| 1149 |
-
if core != p["base_ipa"]:
|
| 1150 |
-
continue
|
| 1151 |
-
words[wi] = tok[:lead] + best[0] + (tok[len(tok) - trail:] if trail else "")
|
| 1152 |
-
touched.append({"wi": wi, "surface": p["surface"], "from": p["base_ipa"],
|
| 1153 |
-
"to": best[0], "delta": best[1]})
|
| 1154 |
-
return " ".join(words), touched
|
| 1155 |
-
|
| 1156 |
-
|
| 1157 |
# ==========================================================================
|
| 1158 |
# THE FORCE-ONLY RUNTIME LEXICON
|
| 1159 |
# ==========================================================================
|
|
@@ -1171,11 +937,8 @@ LEX_EDGE_STRIP = ".,?!:;()[]{}…«»־–—"
|
|
| 1171 |
class ForceLexicon:
|
| 1172 |
"""A user-supplied `surface -> IPA` map that ALWAYS wins where it matches.
|
| 1173 |
|
| 1174 |
-
It is
|
| 1175 |
-
|
| 1176 |
-
surface **including separators** (gershayim, geresh, hyphen), normalized with
|
| 1177 |
-
this module's `normalize_graphemes` — precisely the key space the candidate
|
| 1178 |
-
store cannot hold.
|
| 1179 |
|
| 1180 |
Every entry is validated with `align_word` at load time. Entries that fail
|
| 1181 |
are collected and reported; by default the constructor raises with the full
|
|
@@ -1303,35 +1066,16 @@ def apply_force_lexicon(text: str, ipa: str, lex: "ForceLexicon") -> tuple[str,
|
|
| 1303 |
class G2P:
|
| 1304 |
def __init__(self, model_path: str, exact_map: bool = True,
|
| 1305 |
chunk_chars: int | None = SAFE_CHUNK_CHARS,
|
| 1306 |
-
datastore: str | None = "auto",
|
| 1307 |
-
tau: float = 0.2,
|
| 1308 |
-
mask: str | int = "corpus",
|
| 1309 |
-
inventory_sources: dict | None = None,
|
| 1310 |
align_cells=(),
|
| 1311 |
lexicon=None,
|
| 1312 |
on_invalid_lexicon: str = "raise") -> None:
|
| 1313 |
# `chunk_chars` is the long-input decode window. The default 2,046 is the
|
| 1314 |
# encoder's hard wall (2,048 positions - CLS/SEP); None disables windowing
|
| 1315 |
# and lets onnxruntime raise past the wall.
|
| 1316 |
-
#
|
| 1317 |
-
#
|
| 1318 |
-
# `datastore.json` beside the model file and switches the layer on
|
| 1319 |
-
# if it is there; `datastore=None` switches it off; a path points it
|
| 1320 |
-
# somewhere else. `lexicon=None` -> the force map is never built.
|
| 1321 |
-
# datastore : corpus datastore JSON (the SRC_CORPUS bit),
|
| 1322 |
-
# "auto", or None
|
| 1323 |
-
# tau : trust gate, nats/letter (0.2 is tuned for these
|
| 1324 |
-
# weights; re-tune it for any other model)
|
| 1325 |
-
# mask : "corpus" (the default), "all", or an int bitmask
|
| 1326 |
-
# inventory_sources : optional extra inventory sources
|
| 1327 |
-
# align_cells : () standard (default), or "extended"
|
| 1328 |
# lexicon : dict or TSV path, force-only override
|
| 1329 |
self._chunk_chars = chunk_chars
|
| 1330 |
-
if datastore == "auto":
|
| 1331 |
-
_side = os.path.join(os.path.dirname(os.path.abspath(model_path)),
|
| 1332 |
-
DEFAULT_DATASTORE_NAME)
|
| 1333 |
-
datastore = _side if os.path.exists(_side) else None
|
| 1334 |
-
self.datastore_path = datastore
|
| 1335 |
self._session = ort.InferenceSession(model_path)
|
| 1336 |
meta = self._session.get_modelmeta().custom_metadata_map
|
| 1337 |
self._vocab: dict[str, int] = json.loads(meta["vocab"])
|
|
@@ -1366,95 +1110,14 @@ class G2P:
|
|
| 1366 |
for letter, allowed in self._letter_constraints.items():
|
| 1367 |
self._forbidden[ord(letter) - ALEF_ORD, list(allowed)] = False
|
| 1368 |
|
| 1369 |
-
# ---- the rescoring layer --------------------------------------------
|
| 1370 |
self._align_cells = resolve_align_cells(align_cells)
|
| 1371 |
-
self._tau = float(tau)
|
| 1372 |
-
if isinstance(mask, str):
|
| 1373 |
-
key = mask.strip().lower()
|
| 1374 |
-
if key not in ("corpus", "all"):
|
| 1375 |
-
raise ValueError("mask must be 'corpus', 'all' or an int bitmask")
|
| 1376 |
-
self._mask = MASK_CORPUS if key == "corpus" else MASK_ALL
|
| 1377 |
-
else:
|
| 1378 |
-
self._mask = int(mask)
|
| 1379 |
-
self._surfaces = None
|
| 1380 |
-
self.inventory_stats: dict | None = None
|
| 1381 |
-
self._cand_cache: dict = {}
|
| 1382 |
-
if datastore is not None:
|
| 1383 |
-
if not self.supports_exact_map:
|
| 1384 |
-
raise ValueError(
|
| 1385 |
-
"the tau rescorer scores candidates against the exact-MAP cascade "
|
| 1386 |
-
"energy; this model was exported without the cascade conditioning "
|
| 1387 |
-
"metadata, so the rescorer cannot be enabled on it")
|
| 1388 |
-
srcs = dict(inventory_sources or {})
|
| 1389 |
-
unknown = set(srcs) - {"lexnikud", "homograph_inventory", "old_lexicon"}
|
| 1390 |
-
if unknown:
|
| 1391 |
-
raise ValueError(f"unknown inventory_sources key(s): {sorted(unknown)}")
|
| 1392 |
-
self._surfaces, self.inventory_stats = build_inventory(datastore, **srcs)
|
| 1393 |
-
self.last_touched: list = []
|
| 1394 |
self.last_forced: list = []
|
| 1395 |
self._lexicon = None
|
| 1396 |
if lexicon is not None:
|
| 1397 |
self._lexicon = ForceLexicon(lexicon, align_cells=self._align_cells,
|
| 1398 |
on_invalid=on_invalid_lexicon)
|
| 1399 |
|
| 1400 |
-
|
| 1401 |
-
def rescoring_enabled(self) -> bool:
|
| 1402 |
-
return self._surfaces is not None
|
| 1403 |
-
|
| 1404 |
-
def _cand_labels(self, surface: str, ipa: str):
|
| 1405 |
-
key = (surface, ipa)
|
| 1406 |
-
if key not in self._cand_cache:
|
| 1407 |
-
try:
|
| 1408 |
-
self._cand_cache[key] = candidate_labels(surface, ipa, self._align_cells)
|
| 1409 |
-
except Exception:
|
| 1410 |
-
self._cand_cache[key] = None
|
| 1411 |
-
return self._cand_cache[key]
|
| 1412 |
-
|
| 1413 |
-
def _propose(self, text: str, base: str, E: np.ndarray, best_u: np.ndarray,
|
| 1414 |
-
best_s: np.ndarray, char_tok: dict, stressed: set) -> list[dict]:
|
| 1415 |
-
"""Candidate loop over the energies the exact-MAP decode already computed."""
|
| 1416 |
-
props: list[dict] = []
|
| 1417 |
-
base_words = base.split()
|
| 1418 |
-
spans = list(re.finditer(r"\S+", text))
|
| 1419 |
-
if len(base_words) != len(spans):
|
| 1420 |
-
return props
|
| 1421 |
-
for wi, mt in enumerate(spans):
|
| 1422 |
-
word = mt.group()
|
| 1423 |
-
if "-" in word:
|
| 1424 |
-
continue # hyphen compounds are two prosodic words; out of scope
|
| 1425 |
-
core = "".join(ch for ch in word if _is_hebrew(ch))
|
| 1426 |
-
cands_map = self._surfaces.get(core)
|
| 1427 |
-
if not core or not cands_map:
|
| 1428 |
-
continue
|
| 1429 |
-
idxs = [i for i in range(mt.start(), mt.end()) if _is_hebrew(text[i])]
|
| 1430 |
-
if len(idxs) != len(core) or idxs != list(range(idxs[0], idxs[0] + len(core))):
|
| 1431 |
-
continue
|
| 1432 |
-
if any(i not in char_tok for i in idxs):
|
| 1433 |
-
continue
|
| 1434 |
-
toks = [char_tok[i] for i in idxs]
|
| 1435 |
-
free = sum(float(best_s[t] if t in stressed else best_u[t]) for t in toks)
|
| 1436 |
-
base_ipa = base_words[wi].strip(PUNCT_STRIP)
|
| 1437 |
-
out_c = []
|
| 1438 |
-
for ipa, src in cands_map.items():
|
| 1439 |
-
labels = self._cand_labels(core, ipa)
|
| 1440 |
-
if labels is None or len(labels) != len(toks):
|
| 1441 |
-
continue
|
| 1442 |
-
sc = 0.0
|
| 1443 |
-
ok = True
|
| 1444 |
-
for t, (c, v, s) in zip(toks, labels):
|
| 1445 |
-
e = float(E[t, c, v, s])
|
| 1446 |
-
if e < NEG / 2:
|
| 1447 |
-
ok = False
|
| 1448 |
-
break
|
| 1449 |
-
sc += e
|
| 1450 |
-
if not ok:
|
| 1451 |
-
continue
|
| 1452 |
-
out_c.append([ipa, round((sc - free) / len(toks), 5), src])
|
| 1453 |
-
if out_c:
|
| 1454 |
-
out_c.sort(key=lambda x: -x[1])
|
| 1455 |
-
props.append({"wi": wi, "surface": core, "base_ipa": base_ipa,
|
| 1456 |
-
"cands": out_c[:6]})
|
| 1457 |
-
return props
|
| 1458 |
|
| 1459 |
def _tokenize(self, text: str) -> tuple[list[int], list[int], list[tuple[int, int]]]:
|
| 1460 |
"""Tokenize character by character, return ids, mask, and offset mapping."""
|
|
@@ -1509,7 +1172,6 @@ class G2P:
|
|
| 1509 |
consonant_logits: np.ndarray,
|
| 1510 |
vowel_logits: np.ndarray,
|
| 1511 |
stress_logits: np.ndarray,
|
| 1512 |
-
return_energy: bool = False,
|
| 1513 |
):
|
| 1514 |
"""Closed-form MAP over the joint cascade energy.
|
| 1515 |
|
|
@@ -1566,9 +1228,6 @@ class G2P:
|
|
| 1566 |
stressed.add(max(cands, key=lambda t: gain[t]))
|
| 1567 |
|
| 1568 |
flat = np.where(np.isin(np.arange(S), list(stressed)), arg_s, arg_u)
|
| 1569 |
-
if return_energy:
|
| 1570 |
-
# the rescorer scores candidates against exactly these energies.
|
| 1571 |
-
return flat // V, flat % V, stressed, E, best_u, best_s, char_tok
|
| 1572 |
return flat // V, flat % V, stressed
|
| 1573 |
|
| 1574 |
def phonemize(
|
|
@@ -1637,28 +1296,15 @@ class G2P:
|
|
| 1637 |
"this model was exported without the cascade conditioning metadata; "
|
| 1638 |
"re-export it, or pass exact_map=False for the greedy decode"
|
| 1639 |
)
|
| 1640 |
-
energy = None
|
| 1641 |
if use_exact:
|
| 1642 |
-
|
| 1643 |
offsets,
|
| 1644 |
normalized,
|
| 1645 |
consonant_logits[0].astype(np.float32),
|
| 1646 |
vowel_logits[0].astype(np.float32),
|
| 1647 |
stress_logits[0].astype(np.float32),
|
| 1648 |
-
return_energy=self._surfaces is not None,
|
| 1649 |
)
|
| 1650 |
-
if self._surfaces is not None:
|
| 1651 |
-
(consonant_preds, vowel_preds, stressed_positions,
|
| 1652 |
-
_E, _best_u, _best_s, _char_tok) = out
|
| 1653 |
-
energy = (_E, _best_u, _best_s, _char_tok)
|
| 1654 |
-
else:
|
| 1655 |
-
consonant_preds, vowel_preds, stressed_positions = out
|
| 1656 |
else:
|
| 1657 |
-
if self._surfaces is not None:
|
| 1658 |
-
raise ValueError(
|
| 1659 |
-
"the tau rescorer is defined on the exact-MAP cascade energies; "
|
| 1660 |
-
"it cannot run with exact_map=False")
|
| 1661 |
-
|
| 1662 |
consonant_preds = consonant_logits[0].argmax(axis=-1)
|
| 1663 |
vowel_preds = vowel_logits[0].argmax(axis=-1)
|
| 1664 |
stressed_positions = self._best_stress_per_word(
|
|
@@ -1724,26 +1370,18 @@ class G2P:
|
|
| 1724 |
|
| 1725 |
out_str = "".join(result)
|
| 1726 |
|
| 1727 |
-
# ---- post-
|
| 1728 |
-
#
|
| 1729 |
-
#
|
| 1730 |
-
#
|
| 1731 |
-
#
|
| 1732 |
-
#
|
| 1733 |
-
|
| 1734 |
-
if self._surfaces is not None or self._lexicon is not None:
|
| 1735 |
lead_ws = out_str[:len(out_str) - len(out_str.lstrip())]
|
| 1736 |
trail_ws = out_str[len(out_str.rstrip()):]
|
| 1737 |
body = out_str.strip()
|
| 1738 |
-
|
| 1739 |
-
|
| 1740 |
-
props = self._propose(normalized, body, _E, _best_u, _best_s,
|
| 1741 |
-
_char_tok, stressed_positions)
|
| 1742 |
-
body, self.last_touched = apply_gate(
|
| 1743 |
-
{"base": body, "props": props}, self._tau, self._mask)
|
| 1744 |
-
if self._lexicon is not None:
|
| 1745 |
-
body, self.last_forced = apply_force_lexicon(
|
| 1746 |
-
normalized.strip(), body, self._lexicon)
|
| 1747 |
out_str = lead_ws + body + trail_ws
|
| 1748 |
|
| 1749 |
return out_str
|
|
|
|
| 3 |
Usage: put this file next to your script, then `from renikud_onnx import G2P`.
|
| 4 |
Requires: onnxruntime, numpy. Bundles the Hebrew number front end.
|
| 5 |
|
| 6 |
+
One optional decode layer beyond the neural graph: a force-only runtime
|
| 7 |
+
lexicon (`lexicon=` — a surface -> IPA map that always wins where it
|
| 8 |
+
matches). Off unless you pass one.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
"""
|
| 10 |
from __future__ import annotations
|
| 11 |
|
| 12 |
import json
|
|
|
|
| 13 |
import re
|
| 14 |
import unicodedata
|
| 15 |
|
| 16 |
import numpy as np
|
| 17 |
import onnxruntime as ort
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
# --------------------------------------------------------------------------
|
| 20 |
# Number words
|
| 21 |
# --------------------------------------------------------------------------
|
|
|
|
| 752 |
|
| 753 |
|
| 754 |
# ==========================================================================
|
| 755 |
+
# THE ALIGNER, AND THE FORCE-ONLY LEXICON
|
| 756 |
# --------------------------------------------------------------------------
|
| 757 |
+
# The force lexicon is off unless `lexicon=` is passed; the aligner validates
|
| 758 |
+
# its entries at load time.
|
|
|
|
|
|
|
| 759 |
# ==========================================================================
|
| 760 |
|
| 761 |
+
#: punctuation stripped off an IPA token before comparing it to a replacement.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 762 |
PUNCT_STRIP = ".,?!:;\"'()-—…«»"
|
| 763 |
|
| 764 |
_IPA_ASCII_FIXUPS = {"g": "ɡ", "x": "χ", "r": "ʁ"}
|
|
|
|
| 771 |
return text
|
| 772 |
|
| 773 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 774 |
# --- the aligner -----------------------------------------------------------
|
| 775 |
ALIGN_LETTER_CONSONANTS: dict[str, tuple[str, ...]] = {
|
| 776 |
"א": ("ʔ", ""), "ב": ("b", "v"), "ג": ("ɡ", "dʒ"), "ד": ("d",),
|
|
|
|
| 920 |
return list(result) if result is not None else None
|
| 921 |
|
| 922 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 923 |
# ==========================================================================
|
| 924 |
# THE FORCE-ONLY RUNTIME LEXICON
|
| 925 |
# ==========================================================================
|
|
|
|
| 937 |
class ForceLexicon:
|
| 938 |
"""A user-supplied `surface -> IPA` map that ALWAYS wins where it matches.
|
| 939 |
|
| 940 |
+
It is keyed on the RAW surface **including separators** (gershayim, geresh,
|
| 941 |
+
hyphen), normalized with this module's `normalize_graphemes`.
|
|
|
|
|
|
|
|
|
|
| 942 |
|
| 943 |
Every entry is validated with `align_word` at load time. Entries that fail
|
| 944 |
are collected and reported; by default the constructor raises with the full
|
|
|
|
| 1066 |
class G2P:
|
| 1067 |
def __init__(self, model_path: str, exact_map: bool = True,
|
| 1068 |
chunk_chars: int | None = SAFE_CHUNK_CHARS,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1069 |
align_cells=(),
|
| 1070 |
lexicon=None,
|
| 1071 |
on_invalid_lexicon: str = "raise") -> None:
|
| 1072 |
# `chunk_chars` is the long-input decode window. The default 2,046 is the
|
| 1073 |
# encoder's hard wall (2,048 positions - CLS/SEP); None disables windowing
|
| 1074 |
# and lets onnxruntime raise past the wall.
|
| 1075 |
+
# align_cells : () standard (default), or "extended" -- widens the
|
| 1076 |
+
# aligner used to validate `lexicon` entries.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1077 |
# lexicon : dict or TSV path, force-only override
|
| 1078 |
self._chunk_chars = chunk_chars
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1079 |
self._session = ort.InferenceSession(model_path)
|
| 1080 |
meta = self._session.get_modelmeta().custom_metadata_map
|
| 1081 |
self._vocab: dict[str, int] = json.loads(meta["vocab"])
|
|
|
|
| 1110 |
for letter, allowed in self._letter_constraints.items():
|
| 1111 |
self._forbidden[ord(letter) - ALEF_ORD, list(allowed)] = False
|
| 1112 |
|
|
|
|
| 1113 |
self._align_cells = resolve_align_cells(align_cells)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1114 |
self.last_forced: list = []
|
| 1115 |
self._lexicon = None
|
| 1116 |
if lexicon is not None:
|
| 1117 |
self._lexicon = ForceLexicon(lexicon, align_cells=self._align_cells,
|
| 1118 |
on_invalid=on_invalid_lexicon)
|
| 1119 |
|
| 1120 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1121 |
|
| 1122 |
def _tokenize(self, text: str) -> tuple[list[int], list[int], list[tuple[int, int]]]:
|
| 1123 |
"""Tokenize character by character, return ids, mask, and offset mapping."""
|
|
|
|
| 1172 |
consonant_logits: np.ndarray,
|
| 1173 |
vowel_logits: np.ndarray,
|
| 1174 |
stress_logits: np.ndarray,
|
|
|
|
| 1175 |
):
|
| 1176 |
"""Closed-form MAP over the joint cascade energy.
|
| 1177 |
|
|
|
|
| 1228 |
stressed.add(max(cands, key=lambda t: gain[t]))
|
| 1229 |
|
| 1230 |
flat = np.where(np.isin(np.arange(S), list(stressed)), arg_s, arg_u)
|
|
|
|
|
|
|
|
|
|
| 1231 |
return flat // V, flat % V, stressed
|
| 1232 |
|
| 1233 |
def phonemize(
|
|
|
|
| 1296 |
"this model was exported without the cascade conditioning metadata; "
|
| 1297 |
"re-export it, or pass exact_map=False for the greedy decode"
|
| 1298 |
)
|
|
|
|
| 1299 |
if use_exact:
|
| 1300 |
+
consonant_preds, vowel_preds, stressed_positions = self._exact_map(
|
| 1301 |
offsets,
|
| 1302 |
normalized,
|
| 1303 |
consonant_logits[0].astype(np.float32),
|
| 1304 |
vowel_logits[0].astype(np.float32),
|
| 1305 |
stress_logits[0].astype(np.float32),
|
|
|
|
| 1306 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1307 |
else:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1308 |
consonant_preds = consonant_logits[0].argmax(axis=-1)
|
| 1309 |
vowel_preds = vowel_logits[0].argmax(axis=-1)
|
| 1310 |
stressed_positions = self._best_stress_per_word(
|
|
|
|
| 1370 |
|
| 1371 |
out_str = "".join(result)
|
| 1372 |
|
| 1373 |
+
# ---- force-lexicon post-pass (no-op unless a lexicon was passed) ----
|
| 1374 |
+
# Operates on whitespace-delimited words, normalizing runs of inter-word
|
| 1375 |
+
# whitespace to single spaces. Leading/trailing whitespace of the window
|
| 1376 |
+
# is preserved so that concatenating windows stays lossless; the
|
| 1377 |
+
# reference texts carry none, so a single-window decode is bit-identical
|
| 1378 |
+
# to the reference.
|
| 1379 |
+
if self._lexicon is not None:
|
|
|
|
| 1380 |
lead_ws = out_str[:len(out_str) - len(out_str.lstrip())]
|
| 1381 |
trail_ws = out_str[len(out_str.rstrip()):]
|
| 1382 |
body = out_str.strip()
|
| 1383 |
+
body, self.last_forced = apply_force_lexicon(
|
| 1384 |
+
normalized.strip(), body, self._lexicon)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1385 |
out_str = lead_ws + body + trail_ws
|
| 1386 |
|
| 1387 |
return out_str
|