shmulc's picture
deploy via make deploy
852d295 verified
Raw
History Blame Contribute Delete
5.32 kB
"""Experimental encoders that are intentionally separate from the serving path.
``NumberbatchEncoder`` has a fixed vocabulary: it never uses a subword or
semantic fallback. Lookup tries, in this exact order: (1) the supplied surface
form, (2) that form with one leading Hebrew servile prefix removed when it
starts with one of ื”, ื•, ื‘, ื›, ืœ, ืž, ืฉ, and (3) underscores/spaces exchanged
for the exact and prefix-stripped forms, in that order. An unresolved word is
represented by an all-NaN row so experiment code can explicitly exclude it as
OOV.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import numpy as np
from . import DATA_DIR
DATA = Path(DATA_DIR)
SERVILE_PREFIXES = frozenset("ื”ื•ื‘ื›ืœืžืฉ")
class NumberbatchEncoder:
"""Fixed-vocabulary Hebrew ConceptNet Numberbatch 19.08 vectors."""
def __init__(self) -> None:
self.model_id = "conceptnet-numberbatch-he-19.08"
with (DATA / "numberbatch_he_vocab.json").open(encoding="utf-8") as source:
self.vocab = json.load(source)
self.vectors = np.load(DATA / "numberbatch_he.npy", mmap_mode="r")
if self.vectors.ndim != 2 or self.vectors.dtype != np.float32:
raise ValueError("Numberbatch vectors must be a 2-D float32 array")
if len(self.vocab) != self.vectors.shape[0]:
raise ValueError("Numberbatch vocabulary and vector rows are misaligned")
if len(set(self.vocab)) != len(self.vocab):
raise ValueError("Numberbatch vocabulary contains duplicate surface terms")
self.word_to_row = {word: row for row, word in enumerate(self.vocab)}
@property
def dim(self) -> int:
return int(self.vectors.shape[1])
def _candidates(self, word: str):
"""Yield documented, trivial lookup variants once each."""
base = [word]
if word and word[0] in SERVILE_PREFIXES:
base.append(word[1:])
seen: set[str] = set()
# Exact surface form, then one prefix-stripped form.
for candidate in base:
if candidate not in seen:
seen.add(candidate)
yield candidate
# Finally try only the two trivial multiword spelling exchanges.
for candidate in base:
for variant in (candidate.replace("_", " "), candidate.replace(" ", "_")):
if variant not in seen:
seen.add(variant)
yield variant
def _row_for(self, word: str) -> int | None:
for candidate in self._candidates(word):
row = self.word_to_row.get(candidate)
if row is not None:
return row
return None
def embed(self, words) -> np.ndarray:
words = list(words)
result = np.full((len(words), self.dim), np.nan, dtype=np.float32)
for output_row, word in enumerate(words):
row = self._row_for(word)
if row is not None:
result[output_row] = self.vectors[row]
elif len(words) < 100:
result[output_row] = 0.0
return result
class BlendEncoder:
"""Concatenated L2-normalized blend of fastText and Numberbatch."""
def __init__(self, w_ft: float, w_nb: float) -> None:
self.model_id = f"blend_ft_{w_ft}_nb_{w_nb}"
from .probe import make_encoder
self.ft = make_encoder("fasttext")
self.nb = NumberbatchEncoder()
self.w_ft = w_ft
self.w_nb = w_nb
def embed(self, words) -> np.ndarray:
words = list(words)
V_ft = self.ft.embed(words)
V_nb = self.nb.embed(words)
V_nb_clean = np.nan_to_num(V_nb, nan=0.0)
V_blend = np.concatenate([self.w_ft * V_ft, self.w_nb * V_nb_clean], axis=-1)
norms = np.linalg.norm(V_blend, axis=1, keepdims=True)
V_blend /= norms + 1e-9
return V_blend
def make_exp_encoder(key: str):
"""Return the experimental Numberbatch encoder, a BlendEncoder, or a registered probe encoder."""
if key == "numberbatch":
return NumberbatchEncoder()
if key.startswith("blend_"):
parts = key.split("_")
if len(parts) == 3:
w_ft = float(parts[1])
w_nb = float(parts[2])
return BlendEncoder(w_ft, w_nb)
from .probe import make_encoder
return make_encoder(key)
def _selftest() -> None:
encoder = NumberbatchEncoder()
words = ["ืžืœืš", "ืฉื•ืœื—ืŸ", "ื ื”ืจ", "ืคืจื•ื™ื“"]
vectors = encoder.embed(words)
cosines = vectors @ vectors.T
print(f"model_id={encoder.model_id}")
print(f"dim={encoder.dim} N={len(encoder.vocab)}")
print("pairwise_cosines")
print(" " + " ".join(f"{word:>8}" for word in words))
for word, row in zip(words, cosines, strict=False):
print(f"{word:>6} " + " ".join(f"{value:8.4f}" for value in row))
oov = encoder.embed(["ื–ื–ื–ื–ื–ื–ื–"])[0]
print(f"oov_all_nan={bool(np.isnan(oov).all())}")
if not np.isnan(oov).all():
raise SystemExit("OOV handling failed")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--selftest", action="store_true")
args = parser.parse_args()
if args.selftest:
_selftest()
else:
parser.print_help()