Initial release: Aksharakuppy Manglish->Malayalam IME
Browse files- .gitattributes +1 -0
- .ipynb_checkpoints/README-checkpoint.md +47 -0
- README.md +47 -0
- checkpoints_ime/best.pt +3 -0
- checkpoints_ime/lexicon.json +3 -0
- checkpoints_ime/vocab.json +1 -0
- config.py +53 -0
- ime.py +179 -0
- model.py +65 -0
- server.py +123 -0
- tokenizer.py +41 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
checkpoints_ime/lexicon.json filter=lfs diff=lfs merge=lfs -text
|
.ipynb_checkpoints/README-checkpoint.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: apache-2.0
|
| 3 |
+
language:
|
| 4 |
+
- ml
|
| 5 |
+
tags:
|
| 6 |
+
- transliteration
|
| 7 |
+
- malayalam
|
| 8 |
+
- manglish
|
| 9 |
+
- input-method
|
| 10 |
+
- ime
|
| 11 |
+
library_name: pytorch
|
| 12 |
+
pipeline_tag: translation
|
| 13 |
+
datasets:
|
| 14 |
+
- ai4bharat/Aksharantar
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
# Aksharakuppy — Manglish → Malayalam Transliteration IME
|
| 18 |
+
|
| 19 |
+
A character-level Transformer + frequency-dictionary hybrid that converts
|
| 20 |
+
romanized Malayalam (Manglish) into Malayalam script, with Google-IME-style
|
| 21 |
+
word suggestions.
|
| 22 |
+
|
| 23 |
+
## Model
|
| 24 |
+
- 4-layer encoder-decoder Transformer, ~16.6M params, character-level tokenization
|
| 25 |
+
- Hybrid: ~677k-word frequency lexicon (trie prefix lookup) + neural fallback for out-of-vocabulary words
|
| 26 |
+
- Trained from scratch on a custom corpus + AI4Bharat Aksharantar (Malayalam) + Google Input Tools corrections
|
| 27 |
+
|
| 28 |
+
## Usage
|
| 29 |
+
```python
|
| 30 |
+
from ime import IME
|
| 31 |
+
ime = IME("checkpoints_ime")
|
| 32 |
+
print(ime.suggest("dhoni")) # ['ധോണി', ...]
|
| 33 |
+
print(ime.suggest("keralam")) # ['കേരളം', ...]
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
## Web demo
|
| 37 |
+
```bash
|
| 38 |
+
pip install torch fastapi uvicorn
|
| 39 |
+
uvicorn server:app --host 0.0.0.0 --port 8000
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
## Limitations
|
| 43 |
+
- Ambiguous romanizations may return multiple valid words; the intended word is usually in the top 5.
|
| 44 |
+
- Coverage reflects the training lexicon; rare or new words use the neural fallback.
|
| 45 |
+
|
| 46 |
+
## License
|
| 47 |
+
Apache-2.0. Training data includes AI4Bharat Aksharantar (manually collected: CC-BY; mined: CC0).
|
README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: apache-2.0
|
| 3 |
+
language:
|
| 4 |
+
- ml
|
| 5 |
+
tags:
|
| 6 |
+
- transliteration
|
| 7 |
+
- malayalam
|
| 8 |
+
- manglish
|
| 9 |
+
- input-method
|
| 10 |
+
- ime
|
| 11 |
+
library_name: pytorch
|
| 12 |
+
pipeline_tag: translation
|
| 13 |
+
datasets:
|
| 14 |
+
- ai4bharat/Aksharantar
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
# Aksharakuppy — Manglish → Malayalam Transliteration IME
|
| 18 |
+
|
| 19 |
+
A character-level Transformer + frequency-dictionary hybrid that converts
|
| 20 |
+
romanized Malayalam (Manglish) into Malayalam script, with Google-IME-style
|
| 21 |
+
word suggestions.
|
| 22 |
+
|
| 23 |
+
## Model
|
| 24 |
+
- 4-layer encoder-decoder Transformer, ~16.6M params, character-level tokenization
|
| 25 |
+
- Hybrid: ~677k-word frequency lexicon (trie prefix lookup) + neural fallback for out-of-vocabulary words
|
| 26 |
+
- Trained from scratch on a custom corpus + AI4Bharat Aksharantar (Malayalam) + Google Input Tools corrections
|
| 27 |
+
|
| 28 |
+
## Usage
|
| 29 |
+
```python
|
| 30 |
+
from ime import IME
|
| 31 |
+
ime = IME("checkpoints_ime")
|
| 32 |
+
print(ime.suggest("dhoni")) # ['ധോണി', ...]
|
| 33 |
+
print(ime.suggest("keralam")) # ['കേരളം', ...]
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
## Web demo
|
| 37 |
+
```bash
|
| 38 |
+
pip install torch fastapi uvicorn
|
| 39 |
+
uvicorn server:app --host 0.0.0.0 --port 8000
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
## Limitations
|
| 43 |
+
- Ambiguous romanizations may return multiple valid words; the intended word is usually in the top 5.
|
| 44 |
+
- Coverage reflects the training lexicon; rare or new words use the neural fallback.
|
| 45 |
+
|
| 46 |
+
## License
|
| 47 |
+
Apache-2.0. Training data includes AI4Bharat Aksharantar (manually collected: CC-BY; mined: CC0).
|
checkpoints_ime/best.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:85f811c32ec277b2017abafb6afe44a11feefe3ca8600707baa61c3a077e6941
|
| 3 |
+
size 199500041
|
checkpoints_ime/lexicon.json
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:6285398a3e68b7ed1888fa3936026f61a91dfa6b820c5cd3b2e3c0ef8fbff5e2
|
| 3 |
+
size 40547832
|
checkpoints_ime/vocab.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{"<pad>": 0, "<bos>": 1, "<eos>": 2, "<unk>": 3, "-": 4, "a": 5, "b": 6, "c": 7, "d": 8, "e": 9, "f": 10, "g": 11, "h": 12, "i": 13, "j": 14, "k": 15, "l": 16, "m": 17, "n": 18, "o": 19, "p": 20, "q": 21, "r": 22, "s": 23, "t": 24, "u": 25, "v": 26, "w": 27, "x": 28, "y": 29, "z": 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}
|
config.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import torch
|
| 3 |
+
|
| 4 |
+
MODE = os.environ.get("TRANSLIT_MODE", "sentence") # "sentence" | "ime"
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class Config:
|
| 8 |
+
# ---------------- common ----------------
|
| 9 |
+
d_model = 384
|
| 10 |
+
nhead = 6
|
| 11 |
+
num_layers = 4
|
| 12 |
+
dim_ff = 1536
|
| 13 |
+
dropout = 0.1
|
| 14 |
+
|
| 15 |
+
batch_size = 1024
|
| 16 |
+
lr = 7e-4
|
| 17 |
+
warmup_steps = 2000
|
| 18 |
+
weight_decay = 0.01
|
| 19 |
+
label_smooth = 0.1
|
| 20 |
+
grad_clip = 1.0
|
| 21 |
+
num_workers = 24
|
| 22 |
+
amp_dtype = torch.bfloat16
|
| 23 |
+
|
| 24 |
+
max_src_len = 256
|
| 25 |
+
max_tgt_len = 320
|
| 26 |
+
|
| 27 |
+
dry_run_samples = 20_000
|
| 28 |
+
dry_run_steps = 100
|
| 29 |
+
|
| 30 |
+
# ---------------- mode specific ----------------
|
| 31 |
+
if MODE == "ime":
|
| 32 |
+
train_path = "data/word_train.jsonl"
|
| 33 |
+
val_path = "data/word_val.jsonl"
|
| 34 |
+
test_path = "data/word_test.jsonl"
|
| 35 |
+
ckpt_dir = "checkpoints_ime"
|
| 36 |
+
max_len = 64
|
| 37 |
+
batch_size = 1024 # big dataset again -> big batch
|
| 38 |
+
epochs = 10 # millions of words -> few epochs suffice
|
| 39 |
+
lr = 7e-4
|
| 40 |
+
warmup_steps = 2000
|
| 41 |
+
dropout = 0.1 # plenty of data -> normal dropout
|
| 42 |
+
else:
|
| 43 |
+
# sentence-level Malayalam -> Manglish (original)
|
| 44 |
+
train_path = "data/train.jsonl"
|
| 45 |
+
val_path = "data/val.jsonl"
|
| 46 |
+
test_path = "data/test.jsonl"
|
| 47 |
+
ckpt_dir = "checkpoints"
|
| 48 |
+
max_len = 512
|
| 49 |
+
epochs = 8
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
CFG = Config()
|
| 53 |
+
print(f"[config] mode={MODE} data={CFG.train_path} ckpt={CFG.ckpt_dir}")
|
ime.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import torch
|
| 3 |
+
import torch.nn.functional as F
|
| 4 |
+
from tokenizer import CharTokenizer, PAD, BOS, EOS
|
| 5 |
+
from model import TranslitModel
|
| 6 |
+
from config import CFG
|
| 7 |
+
|
| 8 |
+
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class Trie:
|
| 12 |
+
def __init__(self):
|
| 13 |
+
self.root = {}
|
| 14 |
+
|
| 15 |
+
def insert(self, word, payload):
|
| 16 |
+
node = self.root
|
| 17 |
+
for c in word:
|
| 18 |
+
node = node.setdefault(c, {})
|
| 19 |
+
node.setdefault("$", []).append(payload)
|
| 20 |
+
|
| 21 |
+
def prefix(self, pre, limit=400):
|
| 22 |
+
node = self.root
|
| 23 |
+
for c in pre:
|
| 24 |
+
if c not in node:
|
| 25 |
+
return []
|
| 26 |
+
node = node[c]
|
| 27 |
+
out, stack = [], [node]
|
| 28 |
+
while stack and len(out) < limit:
|
| 29 |
+
n = stack.pop()
|
| 30 |
+
if "$" in n:
|
| 31 |
+
out.extend(n["$"])
|
| 32 |
+
for k, v in n.items():
|
| 33 |
+
if k != "$":
|
| 34 |
+
stack.append(v)
|
| 35 |
+
return out
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class IME:
|
| 39 |
+
def __init__(self, ckpt_dir="checkpoints_ime"):
|
| 40 |
+
# ---------- lexicon trie ----------
|
| 41 |
+
with open(f"{ckpt_dir}/lexicon.json", encoding="utf-8") as f:
|
| 42 |
+
lex = json.load(f)
|
| 43 |
+
self.trie = Trie()
|
| 44 |
+
for g, targets in lex.items():
|
| 45 |
+
for m, n in targets.items():
|
| 46 |
+
self.trie.insert(g, (g, m, n))
|
| 47 |
+
|
| 48 |
+
# ---------- neural fallback model ----------
|
| 49 |
+
self.tok = CharTokenizer.load(f"{ckpt_dir}/vocab.json")
|
| 50 |
+
self.model = TranslitModel(
|
| 51 |
+
len(self.tok), CFG.d_model, CFG.nhead,
|
| 52 |
+
CFG.num_layers, CFG.dim_ff,
|
| 53 |
+
dropout=0.0, max_len=64,
|
| 54 |
+
).to(DEVICE)
|
| 55 |
+
sd = torch.load(f"{ckpt_dir}/best.pt", map_location=DEVICE)["model"]
|
| 56 |
+
sd = {k.replace("_orig_mod.", ""): v for k, v in sd.items()}
|
| 57 |
+
self.model.load_state_dict(sd)
|
| 58 |
+
self.model.eval()
|
| 59 |
+
|
| 60 |
+
# ---------- dictionary path (trie prefix lookup + noise filtering) ----------
|
| 61 |
+
def _dict_suggest(self, q, k):
|
| 62 |
+
hits = self.trie.prefix(q, limit=400)
|
| 63 |
+
exact = [h for h in hits if h[0] == q]
|
| 64 |
+
pref = [h for h in hits if h[0] != q]
|
| 65 |
+
|
| 66 |
+
# exact-match noise filter: if a dominant exact form exists,
|
| 67 |
+
# drop rare "alternatives" (misaligned one-off pairs)
|
| 68 |
+
if exact:
|
| 69 |
+
top = max(h[2] for h in exact)
|
| 70 |
+
if top >= 5:
|
| 71 |
+
exact = [h for h in exact if h[2] >= max(2, top * 0.02)]
|
| 72 |
+
exact.sort(key=lambda h: -h[2])
|
| 73 |
+
|
| 74 |
+
# prefix completions: Aksharantar has millions of n=1 glued
|
| 75 |
+
# compounds (e.g. "entesuhruthaya"). Keep a completion only if it
|
| 76 |
+
# was seen more than once OR is not much longer than the query,
|
| 77 |
+
# then rank by frequency and shortness.
|
| 78 |
+
pref = [h for h in pref if h[2] >= 2 or len(h[0]) <= len(q) + 6]
|
| 79 |
+
pref.sort(key=lambda h: (-h[2], len(h[0])))
|
| 80 |
+
|
| 81 |
+
out, seen = [], set()
|
| 82 |
+
for g, m, n in exact + pref:
|
| 83 |
+
if m not in seen:
|
| 84 |
+
seen.add(m)
|
| 85 |
+
out.append(m)
|
| 86 |
+
if len(out) == k:
|
| 87 |
+
break
|
| 88 |
+
return out
|
| 89 |
+
|
| 90 |
+
# ---------- neural path (beam search) ----------
|
| 91 |
+
@torch.no_grad()
|
| 92 |
+
def _model_suggest(self, q, k=3, beam=8, max_len=48):
|
| 93 |
+
src = torch.tensor([self.tok.encode(q)], device=DEVICE)
|
| 94 |
+
beams = [(torch.tensor([[BOS]], device=DEVICE), 0.0, False)]
|
| 95 |
+
for _ in range(max_len):
|
| 96 |
+
if all(f for _, _, f in beams):
|
| 97 |
+
break
|
| 98 |
+
cands = []
|
| 99 |
+
for ids, lp, fin in beams:
|
| 100 |
+
if fin:
|
| 101 |
+
cands.append((ids, lp, True))
|
| 102 |
+
continue
|
| 103 |
+
logits = self.model(src, ids)[0, -1]
|
| 104 |
+
logp = F.log_softmax(logits.float(), -1)
|
| 105 |
+
tl, ti = logp.topk(beam)
|
| 106 |
+
for l, ix in zip(tl.tolist(), ti.tolist()):
|
| 107 |
+
nids = torch.cat(
|
| 108 |
+
[ids, torch.tensor([[ix]], device=DEVICE)], 1)
|
| 109 |
+
cands.append((nids, lp + l, ix == EOS))
|
| 110 |
+
cands.sort(key=lambda c: c[1] / (c[0].size(1) ** 0.7),
|
| 111 |
+
reverse=True)
|
| 112 |
+
beams = cands[:beam]
|
| 113 |
+
|
| 114 |
+
out, seen = [], set()
|
| 115 |
+
for ids, _, _ in beams:
|
| 116 |
+
s = self.tok.decode(ids[0].tolist())
|
| 117 |
+
if s and s not in seen:
|
| 118 |
+
seen.add(s)
|
| 119 |
+
out.append(s)
|
| 120 |
+
if len(out) == k:
|
| 121 |
+
break
|
| 122 |
+
return out
|
| 123 |
+
|
| 124 |
+
# ---------- public API ----------
|
| 125 |
+
# ---------- public API ----------
|
| 126 |
+
def suggest(self, manglish_word, k=5):
|
| 127 |
+
q = manglish_word.strip()
|
| 128 |
+
if not q:
|
| 129 |
+
return []
|
| 130 |
+
if self._is_passthrough(q):
|
| 131 |
+
return self._number_suggestions(q)
|
| 132 |
+
|
| 133 |
+
ql = q.lower()
|
| 134 |
+
dict_hits = self._dict_suggest(ql, k)
|
| 135 |
+
|
| 136 |
+
# is there an exact-length dictionary match? (romanization == input)
|
| 137 |
+
has_exact = any(h[0] == ql for h in self.trie.prefix(ql, limit=50))
|
| 138 |
+
|
| 139 |
+
if not has_exact:
|
| 140 |
+
# no bare-form entry -> the model's direct transliteration is
|
| 141 |
+
# usually the clean word the user wants (e.g. dhoni -> ധോണി)
|
| 142 |
+
model_hits = self._model_suggest(ql, k=2)
|
| 143 |
+
merged = []
|
| 144 |
+
for s in model_hits + dict_hits:
|
| 145 |
+
if s not in merged:
|
| 146 |
+
merged.append(s)
|
| 147 |
+
return merged[:k]
|
| 148 |
+
|
| 149 |
+
return dict_hits[:k]
|
| 150 |
+
|
| 151 |
+
@staticmethod
|
| 152 |
+
def _is_passthrough(q):
|
| 153 |
+
# token is all digits / punctuation / has no latin letters to transliterate
|
| 154 |
+
return not any(c.isalpha() and ord(c) < 128 for c in q)
|
| 155 |
+
|
| 156 |
+
# Western -> Malayalam digit map
|
| 157 |
+
_ML_DIGITS = str.maketrans("0123456789", "൦൧൨൩൪൫൬൭൮൯")
|
| 158 |
+
|
| 159 |
+
def _number_suggestions(self, q):
|
| 160 |
+
out = [q] # keep as-is (10)
|
| 161 |
+
if any(c.isdigit() for c in q):
|
| 162 |
+
ml = q.translate(self._ML_DIGITS) # malayalam numerals (൧൦)
|
| 163 |
+
if ml != q:
|
| 164 |
+
out.append(ml)
|
| 165 |
+
return out
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
if __name__ == "__main__":
|
| 169 |
+
ime = IME()
|
| 170 |
+
tests = [
|
| 171 |
+
"thi", "thila", # prefix -> dict completions
|
| 172 |
+
"amma", "ente", "adukkala", "veedu", # common words
|
| 173 |
+
"krithyamaayi",
|
| 174 |
+
"njan", "nammal", "keralam", # now covered by aksharantar
|
| 175 |
+
"thiruvananthapuram",
|
| 176 |
+
"blockchain", "kunjava", # OOV / rare -> model fallback
|
| 177 |
+
]
|
| 178 |
+
for w in tests:
|
| 179 |
+
print(f"{w:22s} -> {ime.suggest(w)}")
|
model.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import math, torch
|
| 2 |
+
import torch.nn as nn
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class PositionalEncoding(nn.Module):
|
| 6 |
+
def __init__(self, d_model, max_len=512):
|
| 7 |
+
super().__init__()
|
| 8 |
+
pe = torch.zeros(max_len, d_model)
|
| 9 |
+
pos = torch.arange(max_len).unsqueeze(1).float()
|
| 10 |
+
div = torch.exp(torch.arange(0, d_model, 2).float()
|
| 11 |
+
* (-math.log(10000.0) / d_model))
|
| 12 |
+
pe[:, 0::2] = torch.sin(pos * div)
|
| 13 |
+
pe[:, 1::2] = torch.cos(pos * div)
|
| 14 |
+
self.register_buffer("pe", pe.unsqueeze(0))
|
| 15 |
+
|
| 16 |
+
def forward(self, x):
|
| 17 |
+
return x + self.pe[:, : x.size(1)]
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class TranslitModel(nn.Module):
|
| 21 |
+
def __init__(self, vocab_size, d_model=384, nhead=6,
|
| 22 |
+
num_layers=4, dim_ff=1536, dropout=0.1, max_len=512):
|
| 23 |
+
super().__init__()
|
| 24 |
+
self.d_model = d_model
|
| 25 |
+
|
| 26 |
+
self.embed = nn.Embedding(vocab_size, d_model, padding_idx=0)
|
| 27 |
+
self.pos = PositionalEncoding(d_model, max_len)
|
| 28 |
+
|
| 29 |
+
self.transformer = nn.Transformer(
|
| 30 |
+
d_model=d_model,
|
| 31 |
+
nhead=nhead,
|
| 32 |
+
num_encoder_layers=num_layers,
|
| 33 |
+
num_decoder_layers=num_layers,
|
| 34 |
+
dim_feedforward=dim_ff,
|
| 35 |
+
dropout=dropout,
|
| 36 |
+
batch_first=True,
|
| 37 |
+
norm_first=True,
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
self.out = nn.Linear(d_model, vocab_size)
|
| 41 |
+
self.out.weight = self.embed.weight # weight tying
|
| 42 |
+
|
| 43 |
+
# ---- proper init for tied embedding (fixes huge initial loss) ----
|
| 44 |
+
nn.init.normal_(self.embed.weight, mean=0.0, std=d_model ** -0.5)
|
| 45 |
+
nn.init.zeros_(self.out.bias)
|
| 46 |
+
with torch.no_grad():
|
| 47 |
+
self.embed.weight[0].fill_(0) # keep padding row at zero
|
| 48 |
+
|
| 49 |
+
def forward(self, src, tgt_in):
|
| 50 |
+
src_pad = src == 0
|
| 51 |
+
tgt_pad = tgt_in == 0
|
| 52 |
+
causal = nn.Transformer.generate_square_subsequent_mask(
|
| 53 |
+
tgt_in.size(1), device=src.device)
|
| 54 |
+
|
| 55 |
+
s = self.pos(self.embed(src) * math.sqrt(self.d_model))
|
| 56 |
+
t = self.pos(self.embed(tgt_in) * math.sqrt(self.d_model))
|
| 57 |
+
|
| 58 |
+
h = self.transformer(
|
| 59 |
+
s, t,
|
| 60 |
+
tgt_mask=causal,
|
| 61 |
+
src_key_padding_mask=src_pad,
|
| 62 |
+
tgt_key_padding_mask=tgt_pad,
|
| 63 |
+
memory_key_padding_mask=src_pad,
|
| 64 |
+
)
|
| 65 |
+
return self.out(h)
|
server.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from fastapi.responses import HTMLResponse
|
| 3 |
+
from ime import IME
|
| 4 |
+
|
| 5 |
+
app = FastAPI()
|
| 6 |
+
ime = IME()
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@app.get("/suggest")
|
| 10 |
+
def suggest(q: str, k: int = 5):
|
| 11 |
+
return {"input": q, "suggestions": ime.suggest(q, k=k)}
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
PAGE = """
|
| 15 |
+
<!doctype html>
|
| 16 |
+
<html lang="en">
|
| 17 |
+
<head>
|
| 18 |
+
<meta charset="utf-8">
|
| 19 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 20 |
+
<title>Manglish → Malayalam IME</title>
|
| 21 |
+
<style>
|
| 22 |
+
:root{
|
| 23 |
+
--bg:#0f1115; --panel:#171a21; --panel2:#1e222b;
|
| 24 |
+
--text:#e6e8ec; --muted:#8b93a1; --accent:#4f8cff;
|
| 25 |
+
--accent-soft:#22314f; --border:#2a2f3a; --chip:#232833;
|
| 26 |
+
}
|
| 27 |
+
*{box-sizing:border-box}
|
| 28 |
+
body{
|
| 29 |
+
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
| 30 |
+
background:var(--bg); color:var(--text);
|
| 31 |
+
margin:0; min-height:100vh;
|
| 32 |
+
display:flex; align-items:flex-start; justify-content:center;
|
| 33 |
+
}
|
| 34 |
+
.wrap{width:100%; max-width:680px; padding:56px 20px}
|
| 35 |
+
h1{font-size:22px; font-weight:600; margin:0 0 4px}
|
| 36 |
+
.sub{color:var(--muted); font-size:14px; margin:0 0 28px}
|
| 37 |
+
#out{
|
| 38 |
+
font-size:26px; line-height:1.5; min-height:52px;
|
| 39 |
+
background:var(--panel); border:1px solid var(--border);
|
| 40 |
+
border-radius:12px; padding:14px 16px; margin-bottom:16px;
|
| 41 |
+
word-break:break-word;
|
| 42 |
+
}
|
| 43 |
+
#out:empty::before{content:"Your Malayalam text appears here…"; color:var(--muted); font-size:17px}
|
| 44 |
+
input{
|
| 45 |
+
width:100%; font-size:22px; padding:14px 16px;
|
| 46 |
+
background:var(--panel2); color:var(--text);
|
| 47 |
+
border:1px solid var(--border); border-radius:12px; outline:none;
|
| 48 |
+
transition:border-color .15s;
|
| 49 |
+
}
|
| 50 |
+
input:focus{border-color:var(--accent)}
|
| 51 |
+
input::placeholder{color:var(--muted)}
|
| 52 |
+
#sug{margin-top:12px; min-height:52px; display:flex; flex-wrap:wrap; gap:8px}
|
| 53 |
+
.s{
|
| 54 |
+
padding:9px 16px; background:var(--chip); color:var(--text);
|
| 55 |
+
border:1px solid var(--border); border-radius:10px;
|
| 56 |
+
font-size:22px; cursor:pointer; user-select:none;
|
| 57 |
+
transition:background .12s, border-color .12s;
|
| 58 |
+
}
|
| 59 |
+
.s:hover{background:#2b313d}
|
| 60 |
+
.s:first-child{background:var(--accent-soft); border-color:var(--accent)}
|
| 61 |
+
.hint{color:var(--muted); font-size:13px; margin-top:18px; line-height:1.6}
|
| 62 |
+
.hint b{color:var(--text); font-weight:600}
|
| 63 |
+
</style>
|
| 64 |
+
</head>
|
| 65 |
+
<body>
|
| 66 |
+
<div class="wrap">
|
| 67 |
+
<h1>Manglish → Malayalam IME</h1>
|
| 68 |
+
<p class="sub">Type Malayalam using English letters.</p>
|
| 69 |
+
|
| 70 |
+
<div id="out"></div>
|
| 71 |
+
<input id="inp" placeholder="type manglish… e.g. ente veedu" autocomplete="off" autofocus>
|
| 72 |
+
<div id="sug"></div>
|
| 73 |
+
|
| 74 |
+
<p class="hint">
|
| 75 |
+
<b>space</b> or <b>enter</b> = accept first suggestion ·
|
| 76 |
+
<b>click</b> a chip to pick ·
|
| 77 |
+
<b>backspace</b> on empty input deletes last word
|
| 78 |
+
</p>
|
| 79 |
+
</div>
|
| 80 |
+
|
| 81 |
+
<script>
|
| 82 |
+
const inp=document.getElementById('inp'),
|
| 83 |
+
sug=document.getElementById('sug'),
|
| 84 |
+
out=document.getElementById('out');
|
| 85 |
+
let timer=null,current=[],ctr=0;
|
| 86 |
+
|
| 87 |
+
inp.addEventListener('input',()=>{clearTimeout(timer);timer=setTimeout(fetchSug,110)});
|
| 88 |
+
|
| 89 |
+
async function fetchSug(){
|
| 90 |
+
const q=inp.value.trim();
|
| 91 |
+
if(!q){sug.innerHTML='';current=[];return}
|
| 92 |
+
const my=++ctr;
|
| 93 |
+
try{
|
| 94 |
+
const r=await fetch('/suggest?q='+encodeURIComponent(q));
|
| 95 |
+
const j=await r.json();
|
| 96 |
+
if(my!==ctr)return; // ignore stale responses
|
| 97 |
+
current=j.suggestions;
|
| 98 |
+
sug.innerHTML=current.map(s=>`<span class="s">${s}</span>`).join('');
|
| 99 |
+
[...sug.children].forEach((el,i)=>el.onclick=()=>pick(current[i]));
|
| 100 |
+
}catch(e){/* ignore */}
|
| 101 |
+
}
|
| 102 |
+
function pick(s){
|
| 103 |
+
out.textContent+=s+' ';
|
| 104 |
+
inp.value='';sug.innerHTML='';current=[];inp.focus();
|
| 105 |
+
}
|
| 106 |
+
inp.addEventListener('keydown',e=>{
|
| 107 |
+
if((e.key===' '||e.key==='Enter')&¤t.length){
|
| 108 |
+
e.preventDefault();pick(current[0]);
|
| 109 |
+
}else if(e.key==='Backspace'&&!inp.value){
|
| 110 |
+
e.preventDefault();
|
| 111 |
+
out.textContent=out.textContent.trimEnd().split(' ').slice(0,-1).join(' ');
|
| 112 |
+
if(out.textContent)out.textContent+=' ';
|
| 113 |
+
}
|
| 114 |
+
});
|
| 115 |
+
</script>
|
| 116 |
+
</body>
|
| 117 |
+
</html>
|
| 118 |
+
"""
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
@app.get("/", response_class=HTMLResponse)
|
| 122 |
+
def index():
|
| 123 |
+
return PAGE
|
tokenizer.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
|
| 3 |
+
PAD, BOS, EOS, UNK = 0, 1, 2, 3
|
| 4 |
+
SPECIALS = ["<pad>", "<bos>", "<eos>", "<unk>"]
|
| 5 |
+
|
| 6 |
+
class CharTokenizer:
|
| 7 |
+
def __init__(self, vocab=None):
|
| 8 |
+
self.vocab = vocab or {}
|
| 9 |
+
self.inv = {i: c for c, i in self.vocab.items()}
|
| 10 |
+
|
| 11 |
+
@classmethod
|
| 12 |
+
def build(cls, texts, min_freq=20):
|
| 13 |
+
from collections import Counter
|
| 14 |
+
cnt = Counter()
|
| 15 |
+
for t in texts:
|
| 16 |
+
cnt.update(t)
|
| 17 |
+
vocab = {s: i for i, s in enumerate(SPECIALS)}
|
| 18 |
+
for ch, freq in sorted(cnt.items()):
|
| 19 |
+
if freq >= min_freq:
|
| 20 |
+
vocab[ch] = len(vocab)
|
| 21 |
+
return cls(vocab)
|
| 22 |
+
|
| 23 |
+
def encode(self, text, add_special=True):
|
| 24 |
+
ids = [self.vocab.get(c, UNK) for c in text]
|
| 25 |
+
return [BOS] + ids + [EOS] if add_special else ids
|
| 26 |
+
|
| 27 |
+
def decode(self, ids):
|
| 28 |
+
return "".join(self.inv.get(i, "") for i in ids
|
| 29 |
+
if i not in (PAD, BOS, EOS, UNK))
|
| 30 |
+
|
| 31 |
+
def save(self, path):
|
| 32 |
+
with open(path, "w", encoding="utf-8") as f:
|
| 33 |
+
json.dump(self.vocab, f, ensure_ascii=False)
|
| 34 |
+
|
| 35 |
+
@classmethod
|
| 36 |
+
def load(cls, path):
|
| 37 |
+
with open(path, encoding="utf-8") as f:
|
| 38 |
+
return cls(json.load(f))
|
| 39 |
+
|
| 40 |
+
def __len__(self):
|
| 41 |
+
return len(self.vocab)
|