notmax123 commited on
Commit
1009221
·
verified ·
1 Parent(s): 377331b

Upload folder using huggingface_hub

Browse files
Files changed (3) hide show
  1. README.md +11 -12
  2. bar_plot_comparison.png +2 -2
  3. 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.5% word accuracy.
10
 
11
  ![word accuracy by category](bar_plot_comparison.png)
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 | **90.7** | 90.7 | 88.0 | 80.7 | 78.7 |
18
- | Stress Homographs | 90.6 | 90.6 | **92.6** | 79.8 | 78.3 |
19
- | Names | **80.0** | 79.3 | 75.3 | 67.3 | 68.0 |
20
- | Acronyms | **79.6** | 79.0 | 79.0 | 56.6 | 37.5 |
21
- | Slang | **76.9** | 76.9 | 71.2 | 59.0 | 41.7 |
22
  | Penultimate Stress | 85.4 | 86.1 | **89.4** | 82.1 | 60.9 |
23
- | Rare Phonemes | 55.6 | 53.0 | **57.6** | 41.1 | 19.9 |
24
- | Foreign | **78.7** | 78.1 | 71.6 | 56.8 | 36.1 |
25
- | Colloquial | 52.3 | 51.7 | 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; keep datastore.json beside it
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
- - `datastore.json` — pronunciation store the wrapper uses at decode time
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
  ![word accuracy by category](bar_plot_comparison.png)
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

  • SHA256: c9d54567bce6ced801014d8d45870b1fb4afc8e3364ad7984266163d08c45d05
  • Pointer size: 131 Bytes
  • Size of remote file: 108 kB

Git LFS Details

  • SHA256: 217354c6dccfe3629d2368bdf4359a62d1e43bc055403308904ce0332628ec00
  • Pointer size: 131 Bytes
  • Size of remote file: 108 kB
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
- Two decode layers beyond the neural graph:
7
-
8
- 1. the attested-reading rescorer. ON when `datastore.json` is found next to
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
- # ATTESTED-READING RESCORING, AND THE FORCE-ONLY LEXICON
767
  # --------------------------------------------------------------------------
768
- # The rescorer is ON when a datastore is found beside the model file and OFF
769
- # with `datastore=None`; the force lexicon is off unless `lexicon=` is passed.
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
- # --- source bits of the candidate inventory --------------------------------
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 NOT a candidate: it never enters `apply_gate`, is never scored against
1175
- the model, and is applied after the rescoring pass. It is keyed on the RAW
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
- # THE RESCORING LAYER. `datastore="auto"` (the default) looks for
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
- @property
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
- out = self._exact_map(
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-passes (no-ops unless enabled at construction) ------------
1728
- # Both operate on whitespace-delimited words, so they normalize runs of
1729
- # inter-word whitespace to single spaces exactly as the reference
1730
- # path does (the gate ends in `" ".join(words)`). Leading and
1731
- # trailing whitespace of the window is preserved so that concatenating
1732
- # windows stays lossless; the reference texts carry none, so a
1733
- # single-window decode is bit-identical to the reference.
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
- if self._surfaces is not None and energy is not None:
1739
- _E, _best_u, _best_s, _char_tok = energy
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