Spaces:
Running
Running
File size: 10,477 Bytes
852d295 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 | """Hebrew morphology via DictaBERT โ a real model from the Dicta lab, replacing
hand-rolled particle/suffix stem heuristics.
`lemmas()` uses `dicta-il/dictabert-lex` (lemmatization): it strips attached
particles and reduces inflections to the lexeme ('ืืืฉ'->'ืืฉ', 'ืืืืื'->'ืืืื',
'ืืชืืื ืืช'->'ืชืืื ืืช'), so legality can be defined as *shared lemma* rather than a
letter-list approximation. Same-root-but-different-lemma cases (ืชืืื ื / ืชืืื ืืช)
are a stricter shoresh rule handled separately by the LLM root-judge in probe.py.
First load downloads the model (~mins); afterwards it is HF-cached and loads offline.
"""
from __future__ import annotations
import functools
import json
import os
import re
import threading
import unicodedata
from . import DATA_DIR
_LEX_ID = "dicta-il/dictabert-lex"
_MORPH_ID = "dicta-il/dictabert-morph"
_lock = threading.Lock()
_tok = _model = None
_mtok = _mmodel = None
CONTENT_POS = {"NOUN", "PROPN", "ADJ", "VERB"} # keep as clue words; drop ADP/PRON/DET/CONJ/ADV/NUM
_BATCH = 512 # DictaBERT.predict holds the whole list in memory at once โ chunk to bound it
def _predict(model, tok, words):
"""model.predict over `words` in bounded batches (avoids OOM on large vocabularies)."""
out = []
for i in range(0, len(words), _BATCH):
out.extend(model.predict(words[i : i + _BATCH], tok))
return out
def _load():
global _tok, _model
if _model is None:
with _lock:
if _model is None:
from transformers import AutoModel, AutoTokenizer
_tok = AutoTokenizer.from_pretrained(_LEX_ID, local_files_only=True)
_model = AutoModel.from_pretrained(
_LEX_ID, trust_remote_code=True, local_files_only=True
).eval()
return _tok, _model
def _load_morph():
global _mtok, _mmodel
if _mmodel is None:
with _lock:
if _mmodel is None:
from transformers import AutoModel, AutoTokenizer
_mtok = AutoTokenizer.from_pretrained(_MORPH_ID, local_files_only=True)
_mmodel = AutoModel.from_pretrained(
_MORPH_ID, trust_remote_code=True, local_files_only=True
).eval()
return _mtok, _mmodel
def pos(words) -> list[str]:
"""Coarse UD part-of-speech per (isolated) word via DictaBERT-morph. For a word with
attached particles, the content head's POS wins (so 'ืืืืช' reads as NOUN, not ADP)."""
words = list(words)
if not words:
return []
tok, model = _load_morph()
out = _predict(model, tok, words)
res = []
for item in out:
toks = (item or {}).get("tokens") or []
ps = [t.get("pos") for t in toks if t.get("pos")]
head = next((p for p in ps if p in CONTENT_POS), ps[-1] if ps else "X")
res.append(head)
return res
def lemmas(words) -> list[str]:
"""Lemma of each (isolated) Hebrew word, aligned with `words`. Falls back to the
surface form when the model returns nothing."""
words = list(words)
if not words:
return []
tok, model = _load()
preds = _predict(model, tok, words) # each word treated as its own sentence
out = []
for w, pred in zip(words, preds, strict=False):
lem = None
if pred:
# pred is a list of (token, lemma) for the word's piece(s)
first = pred[0]
lem = first[1] if isinstance(first, (list, tuple)) and len(first) > 1 else None
out.append(lem if lem and lem != "[BLANK]" else w)
return out
@functools.lru_cache(maxsize=1024)
def lemma(word: str) -> str:
return lemmas([word])[0]
_FINALS = str.maketrans("ืืืืฃืฅ", "ืืื ืคืฆ")
_CUSTOM_ROOTS = {
"ืคืจืื": {"ืคืจืื"},
"ืคืจืืืื": {"ืคืจืื"},
}
def _get_lex_roots(w: str, lex: dict) -> set[str]:
if w in _CUSTOM_ROOTS:
return set(_CUSTOM_ROOTS[w])
# Exact spelling is authoritative, final-letter variant is fallback
roots_found = set(lex.get(w, ()))
if not roots_found:
roots_found.update(lex.get(w.translate(_FINALS), ()))
return roots_found
def root_sig(word: str) -> str:
"""A coarse consonantal *shoresh* signature for shared-root legality. Normalise final
forms, drop the matres lectionis (ื / ื), and strip a trailing ื / ืช (nominal/feminine
ending). Two words whose signatures are equal almost always share a root โ ืงืืกื/ืงืกื,
ืจืืืช/ืจืื, ืฉืืืจ/ืฉืืืจื, ืชืืื ื/ืชืืื ืืช โ which plain lemma equality cannot see.
Apply it to a *lemma* (so attached particles and inflection are already gone). It is a
morphologically motivated approximation, tuned to over-reject rather than ever let a
derivative through; residual same-root pairs with a different skeleton are caught by the
DictaLM root-judge (`probe.llm_root_conflicts`)."""
s = word.translate(_FINALS).replace("ื", "").replace("ื", "")
if len(s) >= 4 and s[0] in "ืืื ": # servile prefix: present-participle ื-, hif'il ื-, nif'al ื -
s = s[1:] # ืืคืืโืคืื, ืืคืืืโืืคืืโืคืื, ื ืคืืโืคืื
if len(s) > 3 and s[-1] in "ื ืชื": # agentive/feminine ending: ืคืืืโืคืื, ืฉืืืจืชโืฉืืืจ
s = s[:-1]
return s
# --------------------------------------------------------------------------- #
# Root lexicon โ authoritative shared-root signal (Wiktionary/kaikki-derived)
# --------------------------------------------------------------------------- #
# `roots()` looks a surface word up in data/word2root.json (see data/ROOT_LEXICON_NOTICE.md).
# It is the primary shared-root source for clue legality; `root_sig` above stays as the
# fallback for words the lexicon does not cover.
_ROOT_LEXICON_PATH = os.path.join(DATA_DIR, "word2root.json")
_NIQQUD = re.compile(r"[ึ-ื]") # cantillation + niqqud range
_PUNCT = re.compile(r"[ืณืณ'\"โโโโ`]")
_FINAL_FORMS = str.maketrans("ืืืืฃืฅ", "ืืื ืคืฆ")
def _norm_lookup(word: str) -> str:
"""Normalise a surface word to the lexicon's key form: NFC, niqqud stripped, maqaf/hyphen
removed. Final letters are left intact (correct standalone spelling), matching the keys."""
w = _NIQQUD.sub("", unicodedata.normalize("NFC", word)).strip()
return _PUNCT.sub("", w).replace("ึพ", "").replace("-", "")
@functools.lru_cache(maxsize=1)
def _root_lexicon() -> dict:
"""Surface word -> list of triliteral roots, loaded once from data/word2root.json.
Empty dict if the file is absent, so callers transparently fall back to root_sig."""
try:
with open(_ROOT_LEXICON_PATH, encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return {}
@functools.lru_cache(maxsize=4096)
def roots(word: str) -> set[str]:
"""Triliteral root(s) of a surface Hebrew word per the vendored Wiktionary lexicon.
Handles prefixes, suffixes, plurals, construct forms, and inflections using
lexicon-based decomposition and lemmatization fallbacks."""
lex = _root_lexicon()
if not lex:
return set()
norm = _norm_lookup(word)
if not norm:
return set()
# Tier 1: Direct lookup
res = _get_lex_roots(norm, lex)
if res:
return res
# Tier 2: Systematic prefix/suffix stripping based on lexicon validation
# This avoids loading/running DictaBERT for simple prefix/suffix inflections
PREFIXES = [
"ืื",
"ืื",
"ืื",
"ืื",
"ืื",
"ืืฉ",
"ืฉื",
"ืฉื",
"ืฉื",
"ืฉื",
"ืฉื",
"ื",
"ืฉ",
"ื",
"ื",
"ื",
"ื",
"ื",
]
SUFFIXES = [
"ืืื",
"ืืื",
"ืืื",
"ืืื",
"ืื ื",
"ืื",
"ืื",
"ืื",
"ืื",
"ืื",
"ืื",
"ืื",
"ืื",
"ื ื",
"ืืช",
"ืื",
"ื",
"ืช",
"ื",
"ื",
"ื",
]
candidates = set()
# Try stripping prefixes only
for p in PREFIXES:
if norm.startswith(p) and len(norm) - len(p) >= 2:
stem = norm[len(p) :]
stem_roots = _get_lex_roots(stem, lex)
if stem_roots:
candidates.update(stem_roots)
# Try stripping suffixes only
for s in SUFFIXES:
if norm.endswith(s) and len(norm) - len(s) >= 2:
stem = norm[: -len(s)]
stem_roots = _get_lex_roots(stem, lex)
if stem_roots:
candidates.update(stem_roots)
# Try stripping both prefixes and suffixes
for p in PREFIXES:
for s in SUFFIXES:
if norm.startswith(p) and norm.endswith(s) and len(norm) - len(p) - len(s) >= 2:
stem = norm[len(p) : -len(s)]
stem_roots = _get_lex_roots(stem, lex)
if stem_roots:
candidates.update(stem_roots)
if candidates:
return candidates
# Tier 3: Lemmatizer fallback
try:
lem = lemma(word)
norm_lem = _norm_lookup(lem)
res = _get_lex_roots(norm_lem, lex)
if res:
return res
# Try stripping on the lemma itself
for p in PREFIXES:
if norm_lem.startswith(p) and len(norm_lem) - len(p) >= 2:
stem = norm_lem[len(p) :]
stem_roots = _get_lex_roots(stem, lex)
if stem_roots:
candidates.update(stem_roots)
for s in SUFFIXES:
if norm_lem.endswith(s) and len(norm_lem) - len(s) >= 2:
stem = norm_lem[: -len(s)]
stem_roots = _get_lex_roots(stem, lex)
if stem_roots:
candidates.update(stem_roots)
for p in PREFIXES:
for s in SUFFIXES:
if (
norm_lem.startswith(p)
and norm_lem.endswith(s)
and len(norm_lem) - len(p) - len(s) >= 2
):
stem = norm_lem[len(p) : -len(s)]
stem_roots = _get_lex_roots(stem, lex)
if stem_roots:
candidates.update(stem_roots)
except Exception:
# DictaBERT might fail or not be loaded
pass
return candidates
|