endurasolution's picture
Initial release: Aksharakuppy Manglish->Malayalam IME
a828eeb verified
Raw
History Blame Contribute Delete
6.37 kB
import json
import torch
import torch.nn.functional as F
from tokenizer import CharTokenizer, PAD, BOS, EOS
from model import TranslitModel
from config import CFG
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
class Trie:
def __init__(self):
self.root = {}
def insert(self, word, payload):
node = self.root
for c in word:
node = node.setdefault(c, {})
node.setdefault("$", []).append(payload)
def prefix(self, pre, limit=400):
node = self.root
for c in pre:
if c not in node:
return []
node = node[c]
out, stack = [], [node]
while stack and len(out) < limit:
n = stack.pop()
if "$" in n:
out.extend(n["$"])
for k, v in n.items():
if k != "$":
stack.append(v)
return out
class IME:
def __init__(self, ckpt_dir="checkpoints_ime"):
# ---------- lexicon trie ----------
with open(f"{ckpt_dir}/lexicon.json", encoding="utf-8") as f:
lex = json.load(f)
self.trie = Trie()
for g, targets in lex.items():
for m, n in targets.items():
self.trie.insert(g, (g, m, n))
# ---------- neural fallback model ----------
self.tok = CharTokenizer.load(f"{ckpt_dir}/vocab.json")
self.model = TranslitModel(
len(self.tok), CFG.d_model, CFG.nhead,
CFG.num_layers, CFG.dim_ff,
dropout=0.0, max_len=64,
).to(DEVICE)
sd = torch.load(f"{ckpt_dir}/best.pt", map_location=DEVICE)["model"]
sd = {k.replace("_orig_mod.", ""): v for k, v in sd.items()}
self.model.load_state_dict(sd)
self.model.eval()
# ---------- dictionary path (trie prefix lookup + noise filtering) ----------
def _dict_suggest(self, q, k):
hits = self.trie.prefix(q, limit=400)
exact = [h for h in hits if h[0] == q]
pref = [h for h in hits if h[0] != q]
# exact-match noise filter: if a dominant exact form exists,
# drop rare "alternatives" (misaligned one-off pairs)
if exact:
top = max(h[2] for h in exact)
if top >= 5:
exact = [h for h in exact if h[2] >= max(2, top * 0.02)]
exact.sort(key=lambda h: -h[2])
# prefix completions: Aksharantar has millions of n=1 glued
# compounds (e.g. "entesuhruthaya"). Keep a completion only if it
# was seen more than once OR is not much longer than the query,
# then rank by frequency and shortness.
pref = [h for h in pref if h[2] >= 2 or len(h[0]) <= len(q) + 6]
pref.sort(key=lambda h: (-h[2], len(h[0])))
out, seen = [], set()
for g, m, n in exact + pref:
if m not in seen:
seen.add(m)
out.append(m)
if len(out) == k:
break
return out
# ---------- neural path (beam search) ----------
@torch.no_grad()
def _model_suggest(self, q, k=3, beam=8, max_len=48):
src = torch.tensor([self.tok.encode(q)], device=DEVICE)
beams = [(torch.tensor([[BOS]], device=DEVICE), 0.0, False)]
for _ in range(max_len):
if all(f for _, _, f in beams):
break
cands = []
for ids, lp, fin in beams:
if fin:
cands.append((ids, lp, True))
continue
logits = self.model(src, ids)[0, -1]
logp = F.log_softmax(logits.float(), -1)
tl, ti = logp.topk(beam)
for l, ix in zip(tl.tolist(), ti.tolist()):
nids = torch.cat(
[ids, torch.tensor([[ix]], device=DEVICE)], 1)
cands.append((nids, lp + l, ix == EOS))
cands.sort(key=lambda c: c[1] / (c[0].size(1) ** 0.7),
reverse=True)
beams = cands[:beam]
out, seen = [], set()
for ids, _, _ in beams:
s = self.tok.decode(ids[0].tolist())
if s and s not in seen:
seen.add(s)
out.append(s)
if len(out) == k:
break
return out
# ---------- public API ----------
# ---------- public API ----------
def suggest(self, manglish_word, k=5):
q = manglish_word.strip()
if not q:
return []
if self._is_passthrough(q):
return self._number_suggestions(q)
ql = q.lower()
dict_hits = self._dict_suggest(ql, k)
# is there an exact-length dictionary match? (romanization == input)
has_exact = any(h[0] == ql for h in self.trie.prefix(ql, limit=50))
if not has_exact:
# no bare-form entry -> the model's direct transliteration is
# usually the clean word the user wants (e.g. dhoni -> ധോണി)
model_hits = self._model_suggest(ql, k=2)
merged = []
for s in model_hits + dict_hits:
if s not in merged:
merged.append(s)
return merged[:k]
return dict_hits[:k]
@staticmethod
def _is_passthrough(q):
# token is all digits / punctuation / has no latin letters to transliterate
return not any(c.isalpha() and ord(c) < 128 for c in q)
# Western -> Malayalam digit map
_ML_DIGITS = str.maketrans("0123456789", "൦൧൨൩൪൫൬൭൮൯")
def _number_suggestions(self, q):
out = [q] # keep as-is (10)
if any(c.isdigit() for c in q):
ml = q.translate(self._ML_DIGITS) # malayalam numerals (൧൦)
if ml != q:
out.append(ml)
return out
if __name__ == "__main__":
ime = IME()
tests = [
"thi", "thila", # prefix -> dict completions
"amma", "ente", "adukkala", "veedu", # common words
"krithyamaayi",
"njan", "nammal", "keralam", # now covered by aksharantar
"thiruvananthapuram",
"blockchain", "kunjava", # OOV / rare -> model fallback
]
for w in tests:
print(f"{w:22s} -> {ime.suggest(w)}")