Push transliteration pipeline code + prototype checkpoint + model card
Browse files- PIPELINE_README.md +307 -0
- README.md +101 -0
- checkpoint/dataset_manifest.json +51 -0
- checkpoint/languages.json +112 -0
- checkpoint/model/config.json +20 -0
- checkpoint/model/model.safetensors +3 -0
- checkpoint/tokenizer/special_tokens_map.json +6 -0
- checkpoint/tokenizer/tokenizer_config.json +44 -0
- checkpoint/tokenizer/vocab.json +703 -0
- checkpoint/training_config.yaml +72 -0
- configs/train.yaml +67 -0
- data/customer_support_seed.csv +32 -0
- integrations/__init__.py +0 -0
- integrations/tts.py +84 -0
- requirements.txt +20 -0
- tests/__init__.py +0 -0
- tests/test_dataset.py +70 -0
- tests/test_inference.py +76 -0
- tests/test_metrics.py +49 -0
- tests/test_model.py +64 -0
- tests/test_tokenizer.py +37 -0
- tests/test_tts_integration.py +52 -0
- tests/test_validate.py +67 -0
- transliteration/__init__.py +26 -0
- transliteration/cli.py +138 -0
- transliteration/config.py +43 -0
- transliteration/data/__init__.py +0 -0
- transliteration/data/dataset.py +220 -0
- transliteration/data/download.py +232 -0
- transliteration/data/preprocess.py +64 -0
- transliteration/data/validate.py +209 -0
- transliteration/evaluate.py +16 -0
- transliteration/infer.py +22 -0
- transliteration/inference.py +211 -0
- transliteration/languages.py +74 -0
- transliteration/model/__init__.py +0 -0
- transliteration/model/model.py +242 -0
- transliteration/model/tokenizer.py +139 -0
- transliteration/train.py +16 -0
- transliteration/training/__init__.py +0 -0
- transliteration/training/evaluate.py +143 -0
- transliteration/training/metrics.py +159 -0
- transliteration/training/train.py +287 -0
PIPELINE_README.md
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Multilingual Roman → Indic Transliteration Pipeline
|
| 2 |
+
|
| 3 |
+
A trainable Roman/Hinglish/code-mixed → native-Indic-script transliteration
|
| 4 |
+
system, built to sit directly in front of a TTS pipeline.
|
| 5 |
+
|
| 6 |
+
```
|
| 7 |
+
Roman / Hinglish / code-mixed text
|
| 8 |
+
↓
|
| 9 |
+
Indic transliteration model
|
| 10 |
+
↓
|
| 11 |
+
native Indic script
|
| 12 |
+
↓
|
| 13 |
+
TTS
|
| 14 |
+
```
|
| 15 |
+
|
| 16 |
+
## Architecture: not literally IndicXlit — read this first
|
| 17 |
+
|
| 18 |
+
The request was to start from **AI4Bharat IndicXlit**. IndicXlit ships as a
|
| 19 |
+
**fairseq** fully-convolutional seq2seq model. This environment's Python is
|
| 20 |
+
**3.12**, and fairseq is not maintained for it:
|
| 21 |
+
|
| 22 |
+
- fairseq's own `fairseq/dataclass/configs.py` (and several other files —
|
| 23 |
+
`fairseq/models/transformer/transformer_config.py`,
|
| 24 |
+
`fairseq/dataclass/initialize.py`, `hydra/conf/__init__.py` in its pinned
|
| 25 |
+
`hydra-core` dependency) declare dataclass fields as
|
| 26 |
+
`x: FooConfig = FooConfig()` — a mutable-default pattern Python's own
|
| 27 |
+
`dataclasses` module started **hard-rejecting** in 3.11+.
|
| 28 |
+
- I patched the first file, then the next error surfaced in a second file,
|
| 29 |
+
then a third. This is not "one bug" — it's systemic across the package,
|
| 30 |
+
because Python's dataclass semantics changed under fairseq's feet and
|
| 31 |
+
nobody has back-ported the fix upstream.
|
| 32 |
+
- fairseq's declared dependency pins (`omegaconf<2.1`, `hydra-core<1.1`) are
|
| 33 |
+
themselves un-installable under current pip (their sdists have malformed
|
| 34 |
+
metadata `pip>=24.1` refuses to parse), so even "just install the exact
|
| 35 |
+
pinned versions" doesn't work without hand-patching wheel metadata.
|
| 36 |
+
|
| 37 |
+
I flagged this and asked before proceeding (see decision below) rather than
|
| 38 |
+
silently declaring success on a broken import chain or silently swapping
|
| 39 |
+
architectures without telling you.
|
| 40 |
+
|
| 41 |
+
**Decision (confirmed with you):** use a **Hugging Face `transformers`-based
|
| 42 |
+
character-level Transformer, trained from scratch**, using the same task
|
| 43 |
+
framing and the same `<2xx>` language-tagging convention IndicXlit (and
|
| 44 |
+
NLLB/mBART) use. Same task, maintainable stack, not IndicXlit's literal
|
| 45 |
+
fairseq checkpoint. If you later want to revisit vendoring IndicXlit's actual
|
| 46 |
+
pretrained weights, that would need either an isolated Python <3.11
|
| 47 |
+
environment just for that model, or someone upstream fixing fairseq for 3.12.
|
| 48 |
+
|
| 49 |
+
## Verified facts about the datasets (do not assume these — they were checked)
|
| 50 |
+
|
| 51 |
+
**AI4Bharat Aksharantar** (`ai4bharat/Aksharantar` on Hugging Face):
|
| 52 |
+
- Public, ungated, real, downloaded and used in this repo (696MB, 21
|
| 53 |
+
languages — every language in this project's scope except Sinhala, which
|
| 54 |
+
Aksharantar simply doesn't cover since it isn't one of the 22 scheduled
|
| 55 |
+
languages).
|
| 56 |
+
- **`datasets.load_dataset("ai4bharat/Aksharantar", ...)` does not work**,
|
| 57 |
+
for any config, as of this writing: there's no per-language config
|
| 58 |
+
(`BuilderConfig 'hi' not found`), and the "default" config fails with a
|
| 59 |
+
schema-cast error because some language files carry a `score` column and
|
| 60 |
+
others don't. This repo's `transliteration/data/download.py` downloads
|
| 61 |
+
each language's zip directly via `huggingface_hub.hf_hub_download` and
|
| 62 |
+
parses the JSON-Lines files itself, sidestepping `datasets` entirely.
|
| 63 |
+
- Word-level pairs only (not sentences). Fields: `unique_identifier`,
|
| 64 |
+
`native word`, `english word`, `source`, `score`.
|
| 65 |
+
- **One real bug found and fixed during this work**: Dogri's zip
|
| 66 |
+
(`doi.zip`) nests its JSON files under a `doi/` subdirectory, unlike every
|
| 67 |
+
other language's zip which has them at the archive root. The downloader
|
| 68 |
+
now matches on basename, not a fixed path.
|
| 69 |
+
- **License is not a single blanket license**: manually-collected rows are
|
| 70 |
+
CC-BY (attribution required), mined rows (from Samanantar/IndicCorp) are
|
| 71 |
+
CC0. The HF repo tag just says `cc` without disambiguating. If you ship a
|
| 72 |
+
model trained on this commercially, attribute the dataset at the
|
| 73 |
+
whole-corpus level to be safe.
|
| 74 |
+
|
| 75 |
+
**Google Dakshina**: hosted on Google's own GCS
|
| 76 |
+
(`https://storage.googleapis.com/gresearch/dakshina/dakshina_dataset_v1.0.tar`,
|
| 77 |
+
~2GB, verified live), **not** on Hugging Face/Kaggle officially. 12
|
| 78 |
+
languages, both word-level lexicons and sentence-level romanized Wikipedia
|
| 79 |
+
text. **License is CC BY-SA 4.0 — share-alike.** This is more restrictive
|
| 80 |
+
than Aksharantar: a share-alike clause can obligate releasing derivatives
|
| 81 |
+
under the same license. `transliteration/data/download.py` includes a
|
| 82 |
+
Dakshina downloader/parser, but it was **not exercised in the prototype run
|
| 83 |
+
below** — only Aksharantar was used, to keep the first pass scoped and
|
| 84 |
+
because Aksharantar alone already covers all 21 available languages
|
| 85 |
+
word-level. Decide deliberately before pulling Dakshina into a production
|
| 86 |
+
training mix, given the license difference.
|
| 87 |
+
|
| 88 |
+
## What was actually run (not claimed, run)
|
| 89 |
+
|
| 90 |
+
```
|
| 91 |
+
python -m transliteration.cli build-data --config configs/train.yaml --download --max-per-language 3000
|
| 92 |
+
python -m transliteration.cli train --config configs/train.yaml --prototype
|
| 93 |
+
python -m transliteration.cli evaluate --checkpoint models/checkpoints/indicxlit-custom/final --test-data data/test/data.jsonl
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
Result: real Aksharantar data downloaded for all 21 languages (25.1M raw
|
| 97 |
+
pairs), capped to 3,000/language for the prototype (63,000 pairs), 100%
|
| 98 |
+
passed validation (clean word-level data), split 90/5/5 with leakage-safe
|
| 99 |
+
grouping (13,125 train / after language-temperature + custom-data mixing).
|
| 100 |
+
Trained a 7.7M-parameter model for 3 epochs (~12 seconds on an L40S), loss
|
| 101 |
+
6.28 → 2.34. Ran inference across 7 languages (hi, bn, ta, te, kn, mr) —
|
| 102 |
+
outputs are **script-correct 99.9% of the time** (the model reliably learned
|
| 103 |
+
which script to emit per language tag) but **not yet accurate**
|
| 104 |
+
(test-set CER ≈ 91%, exact-match 0%) — expected and unsurprising at 3 epochs
|
| 105 |
+
on ~3k examples/language with no real convergence. This proves the pipeline
|
| 106 |
+
is wired correctly end-to-end; it does not mean the model is usable yet.
|
| 107 |
+
Getting to production accuracy needs the full-scale run (more data per
|
| 108 |
+
language, more epochs) described below.
|
| 109 |
+
|
| 110 |
+
Also verified concretely, not assumed:
|
| 111 |
+
- Checkpoint saving/loading round-trips exactly (`test_save_and_load_model_roundtrip`,
|
| 112 |
+
and the real checkpoint's `model/`, `tokenizer/`, `training_config.yaml`,
|
| 113 |
+
`languages.json`, `dataset_manifest.json` were all inspected after a real run).
|
| 114 |
+
- `--resume_from_checkpoint` genuinely resumes mid-run (tested: resumed at
|
| 115 |
+
step 501/618, finished the remaining 117 steps, did not restart from 0).
|
| 116 |
+
- Batched inference (`transliterate_batch`) is ~100x faster than one-at-a-time
|
| 117 |
+
(`transliterate`) on this GPU: 330 sentences/sec batched vs ~9/sec serial.
|
| 118 |
+
- `evaluate` produces all four required output files
|
| 119 |
+
(`overall.json`, `per_language.json`, `examples.json`, `confusion_analysis.json`).
|
| 120 |
+
- 44 unit tests pass, including 3 that caught **real bugs** during development
|
| 121 |
+
(temperature-sampling formula was inverted; the phonetic-normalization
|
| 122 |
+
layer was corrupting ordinary words, not just loanword nasal endings;
|
| 123 |
+
`generate()` crashed once decode length exceeded the position-embedding
|
| 124 |
+
budget) — all three were fixed, not worked around in the tests.
|
| 125 |
+
|
| 126 |
+
## Repository layout
|
| 127 |
+
|
| 128 |
+
```
|
| 129 |
+
transliteration/
|
| 130 |
+
languages.py # single source of truth: 22 languages + Sinhala, scripts, <2xx> tags
|
| 131 |
+
config.py # YAML config + --set overrides
|
| 132 |
+
inference.py # TransliterationEngine: transliterate / transliterate_batch / benchmark_latency
|
| 133 |
+
cli.py, train.py, evaluate.py, infer.py # CLI entrypoints (see Commands below)
|
| 134 |
+
data/
|
| 135 |
+
download.py # Aksharantar (HF zips) + Dakshina (GCS tar) fetchers
|
| 136 |
+
preprocess.py # raw -> common {"source","language","roman","target"} schema
|
| 137 |
+
validate.py # Unicode/whitespace normalization, script validation, dedup, corruption checks
|
| 138 |
+
dataset.py # temperature-sampled language mixing, custom-data mixing, leakage-safe split
|
| 139 |
+
model/
|
| 140 |
+
tokenizer.py # character-level tokenizer with <2xx> tag tokens
|
| 141 |
+
model.py # ~8-15M param Transformer encoder-decoder (torch.nn.Transformer-based)
|
| 142 |
+
training/
|
| 143 |
+
train.py, evaluate.py, metrics.py # Trainer glue, CER/exact-match/phonetic metrics, confusion analysis
|
| 144 |
+
integrations/
|
| 145 |
+
tts.py # RomanToTTSPipeline: roman text -> transliteration -> normalize -> your TTS call
|
| 146 |
+
tests/ # 44 tests: validation, dataset mixing/splitting, tokenizer, model, metrics, inference, TTS glue
|
| 147 |
+
configs/train.yaml # all pipeline knobs (sampling temperature, custom_data_weight, model size, training hparams)
|
| 148 |
+
data/customer_support_seed.csv # 31 seed examples: EMI/OTP/payment/loan Hinglish across 9 languages
|
| 149 |
+
```
|
| 150 |
+
|
| 151 |
+
## Install
|
| 152 |
+
|
| 153 |
+
```bash
|
| 154 |
+
cd /home/jovyan/xlit
|
| 155 |
+
python3 -m venv .venv --system-site-packages # --system-site-packages inherits this container's
|
| 156 |
+
# NVIDIA-provided torch 2.8 build; do NOT `pip install torch`
|
| 157 |
+
source .venv/bin/activate
|
| 158 |
+
pip install -r requirements.txt
|
| 159 |
+
```
|
| 160 |
+
|
| 161 |
+
## Commands
|
| 162 |
+
|
| 163 |
+
```bash
|
| 164 |
+
# 1. Data: download Aksharantar, preprocess, validate, split (90/5/5 by default)
|
| 165 |
+
python -m transliteration.cli build-data --config configs/train.yaml --download
|
| 166 |
+
|
| 167 |
+
# 2. Train (full run, per configs/train.yaml) — or --prototype for the small ~10k smoke test
|
| 168 |
+
python -m transliteration.cli train --config configs/train.yaml
|
| 169 |
+
python -m transliteration.train --config configs/train.yaml --prototype # equivalent module-style invocation
|
| 170 |
+
|
| 171 |
+
# 3. Evaluate
|
| 172 |
+
python -m transliteration.evaluate --checkpoint models/checkpoints/indicxlit-custom/final
|
| 173 |
+
|
| 174 |
+
# 4. Inference
|
| 175 |
+
python -m transliteration.infer --language hi --text "mera emi pending hai"
|
| 176 |
+
python -m transliteration.infer --language bn --text "amar emi baki ache"
|
| 177 |
+
|
| 178 |
+
# Override any config value ad hoc:
|
| 179 |
+
python -m transliteration.cli train --config configs/train.yaml --set training.batch_size=32 --set language_sampling.temperature=0.5
|
| 180 |
+
```
|
| 181 |
+
|
| 182 |
+
Programmatic API:
|
| 183 |
+
|
| 184 |
+
```python
|
| 185 |
+
from transliteration.inference import TransliterationEngine
|
| 186 |
+
|
| 187 |
+
engine = TransliterationEngine.from_checkpoint("models/checkpoints/indicxlit-custom/final")
|
| 188 |
+
engine.transliterate("mera emi pending hai", language="hi")
|
| 189 |
+
# -> "मेरा ईएमआई पेंडिंग है" (quality depends on training scale -- see caveats above)
|
| 190 |
+
|
| 191 |
+
engine.transliterate_batch(["text1", "text2"], language="hi", batch_size=64)
|
| 192 |
+
```
|
| 193 |
+
|
| 194 |
+
## GPU / resource requirements
|
| 195 |
+
|
| 196 |
+
This environment: NVIDIA L40S (46GB), 256 CPUs, 1TB RAM. The model itself is
|
| 197 |
+
tiny (7.7M params at default config) — training is bottlenecked by data
|
| 198 |
+
volume and epoch count, not GPU memory. Batch size 64 at `d_model=256` uses
|
| 199 |
+
under 1GB of GPU memory. A100/L4/T4 on Colab will all run this comfortably;
|
| 200 |
+
T4 (16GB) is more than sufficient even at much larger `d_model`/batch sizes
|
| 201 |
+
than the default.
|
| 202 |
+
|
| 203 |
+
## Expected training time (extrapolated from the measured prototype run)
|
| 204 |
+
|
| 205 |
+
The prototype (13,125 examples, 3 epochs, 7.7M params) took ~12 seconds on
|
| 206 |
+
the L40S. Scaling to the full Aksharantar corpus (25M raw pairs; realistically
|
| 207 |
+
1-3M after per-language capping/sampling for a balanced training run) at
|
| 208 |
+
10 epochs is roughly linear in example-count × epochs: expect **low tens of
|
| 209 |
+
minutes**, not hours, for this model size on this GPU — the model is small
|
| 210 |
+
enough that data loading/tokenization, not GPU compute, will likely dominate
|
| 211 |
+
wall-clock time. This is an extrapolation, not a second measured run; if you
|
| 212 |
+
run the full-scale training, the real number will differ from data loading
|
| 213 |
+
overhead, sequence-length distribution, and eval frequency.
|
| 214 |
+
|
| 215 |
+
## Dataset sizes by language (Aksharantar, uncapped, actually downloaded)
|
| 216 |
+
|
| 217 |
+
| Language | Code | Pairs (train+valid+test) |
|
| 218 |
+
|---|---|---|
|
| 219 |
+
| Malayalam | ml | ~4.12M |
|
| 220 |
+
| Kannada | kn | ~2.93M |
|
| 221 |
+
| Tamil | ta | ~3.25M |
|
| 222 |
+
| Telugu | te | ~2.45M |
|
| 223 |
+
| Nepali | ne | ~2.40M |
|
| 224 |
+
| Sanskrit | sa | ~1.82M |
|
| 225 |
+
| Marathi | mr | ~1.47M |
|
| 226 |
+
| Hindi | hi | ~1.32M |
|
| 227 |
+
| Bengali | bn | ~1.26M |
|
| 228 |
+
| Gujarati | gu | ~1.17M |
|
| 229 |
+
| Urdu | ur | ~0.73M |
|
| 230 |
+
| Konkani | kok | ~0.62M |
|
| 231 |
+
| Punjabi | pa | ~0.53M |
|
| 232 |
+
| Odia | or | ~0.35M |
|
| 233 |
+
| Maithili | mai | ~0.29M |
|
| 234 |
+
| Assamese | as | ~0.19M |
|
| 235 |
+
| Sindhi | sd | ~0.07M |
|
| 236 |
+
| Kashmiri | ks | ~0.06M |
|
| 237 |
+
| Bodo | brx | ~0.043M |
|
| 238 |
+
| Manipuri | mni | ~0.018M |
|
| 239 |
+
| Dogri | doi | ~0.0036M (3,584 — smallest by far; expect this language to need the most oversampling) |
|
| 240 |
+
| Sinhala | si | 0 (not covered by Aksharantar) |
|
| 241 |
+
|
| 242 |
+
Low-resource tail (doi, mni, brx, ks, sd) is exactly why `language_sampling.temperature`
|
| 243 |
+
exists in the config — at `temperature=1.0` these languages would be
|
| 244 |
+
essentially invisible in a 25M-pair corpus; at `temperature=0.7` (the
|
| 245 |
+
current default) they're meaningfully oversampled without fully equalizing
|
| 246 |
+
away the signal that some languages have more real data to learn from.
|
| 247 |
+
|
| 248 |
+
## Benchmark results (measured, prototype model)
|
| 249 |
+
|
| 250 |
+
| Metric | Value |
|
| 251 |
+
|---|---|
|
| 252 |
+
| Single-call latency (transliterate), p50 | ~105-300ms (varies by n; includes per-call Python overhead) |
|
| 253 |
+
| Batched throughput (transliterate_batch, batch=64) | ~330 sentences/sec |
|
| 254 |
+
| GPU memory during single-call inference | ~39MB |
|
| 255 |
+
| Overall test-set CER (prototype, 3 epochs) | 91% |
|
| 256 |
+
| Overall script validity | 99.9% |
|
| 257 |
+
| Overall exact match | 0% |
|
| 258 |
+
|
| 259 |
+
Batched inference is ~35-100x higher throughput than one-at-a-time calls on
|
| 260 |
+
this GPU — **use `transliterate_batch`, not a loop of `transliterate`
|
| 261 |
+
calls**, for anything beyond single-request low-latency serving.
|
| 262 |
+
|
| 263 |
+
## Known limitations
|
| 264 |
+
|
| 265 |
+
1. **Not IndicXlit's actual checkpoint.** See architecture section above.
|
| 266 |
+
This is a from-scratch model with the same task framing, not a fine-tune
|
| 267 |
+
of AI4Bharat's released weights.
|
| 268 |
+
2. **Prototype accuracy is low (91% CER).** This was 3 epochs on a
|
| 269 |
+
deliberately small, capped, per-language-limited corpus, run specifically
|
| 270 |
+
to validate the pipeline end-to-end per the project's own "build a small
|
| 271 |
+
prototype first" requirement — not a production training run. Script
|
| 272 |
+
validity (99.9%) confirms the model is learning the right task; character
|
| 273 |
+
accuracy needs the full-scale run to become usable.
|
| 274 |
+
3. **Dakshina was not actually pulled into the corpus.** The downloader
|
| 275 |
+
exists and is unit-testable in isolation, but the one real prototype run
|
| 276 |
+
used Aksharantar only. Its CC BY-SA share-alike license also needs a
|
| 277 |
+
deliberate decision before mixing it in for a commercial derivative.
|
| 278 |
+
4. **Language ID heuristic (`guess_language`) is a coarse keyword matcher**,
|
| 279 |
+
not a real classifier — explicit `language=` is preferred (and the code
|
| 280 |
+
logs a warning whenever it has to guess) exactly because this heuristic
|
| 281 |
+
is not reliable for production traffic.
|
| 282 |
+
5. **Greedy decoding only** — no beam search. Chosen deliberately for
|
| 283 |
+
latency (this sits in front of TTS), but it does mean occasional locally-
|
| 284 |
+
optimal-but-wrong outputs that beam search might avoid.
|
| 285 |
+
6. **Confusion analysis is a cheap positional character diff**, not a true
|
| 286 |
+
edit-distance alignment — it's useful for spotting systematic
|
| 287 |
+
substitution patterns (e.g. vowel-length confusion) but will misattribute
|
| 288 |
+
errors when insertions/deletions shift the whole tail of a word.
|
| 289 |
+
7. **LoRA/PEFT is wired into the config (`training.use_lora`) but not
|
| 290 |
+
implemented** — the model is small enough (7-15M params) that full
|
| 291 |
+
fine-tuning is already cheap, so this was left as a documented no-op
|
| 292 |
+
rather than adding PEFT-library complexity with no current benefit; flip
|
| 293 |
+
it on and implement it if this later becomes a much larger pretrained
|
| 294 |
+
backbone.
|
| 295 |
+
8. **Disk**: this environment started with only 6.8GB free on a 92GB volume;
|
| 296 |
+
you freed space before this work proceeded. Aksharantar's raw per-language
|
| 297 |
+
JSONLs (uncapped) total several GB — `data/raw/` is not automatically
|
| 298 |
+
pruned, so watch disk if you re-run `build-data --download` without the
|
| 299 |
+
`--max-per-language` cap across repeated iterations.
|
| 300 |
+
|
| 301 |
+
## Files changed / created
|
| 302 |
+
|
| 303 |
+
Everything under `/home/jovyan/xlit` is new: `transliteration/` package (14
|
| 304 |
+
modules), `integrations/tts.py`, `tests/` (6 files, 44 tests), `configs/train.yaml`,
|
| 305 |
+
`data/customer_support_seed.csv`, `requirements.txt`, this README. No existing
|
| 306 |
+
repository content was overwritten — the directory was empty apart from a
|
| 307 |
+
stub `.venv` before this work began.
|
README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: cc-by-4.0
|
| 3 |
+
language:
|
| 4 |
+
- hi
|
| 5 |
+
- bn
|
| 6 |
+
- as
|
| 7 |
+
- brx
|
| 8 |
+
- doi
|
| 9 |
+
- gu
|
| 10 |
+
- kn
|
| 11 |
+
- ks
|
| 12 |
+
- kok
|
| 13 |
+
- mai
|
| 14 |
+
- ml
|
| 15 |
+
- mni
|
| 16 |
+
- mr
|
| 17 |
+
- ne
|
| 18 |
+
- or
|
| 19 |
+
- pa
|
| 20 |
+
- sa
|
| 21 |
+
- sd
|
| 22 |
+
- ta
|
| 23 |
+
- te
|
| 24 |
+
- ur
|
| 25 |
+
tags:
|
| 26 |
+
- transliteration
|
| 27 |
+
- indic
|
| 28 |
+
- hinglish
|
| 29 |
+
- code-switching
|
| 30 |
+
- text-to-speech
|
| 31 |
+
pipeline_tag: text2text-generation
|
| 32 |
+
---
|
| 33 |
+
|
| 34 |
+
# indic-transliterate (prototype)
|
| 35 |
+
|
| 36 |
+
Roman/Hinglish/code-mixed → native Indic script transliteration, built to sit
|
| 37 |
+
in front of a TTS pipeline (pronunciation-preserving transliteration, not
|
| 38 |
+
translation — e.g. "EMI" → "ईएमआई", not "किस्त").
|
| 39 |
+
|
| 40 |
+
## ⚠️ Status: early prototype, not production-ready
|
| 41 |
+
|
| 42 |
+
This checkpoint was trained on a **deliberately small, capped** slice of data
|
| 43 |
+
(3,000 word pairs per language from AI4Bharat Aksharantar, ~13k examples
|
| 44 |
+
total after language-temperature sampling and customer-support-data mixing)
|
| 45 |
+
for **3 epochs**, as an end-to-end pipeline validation step — not a
|
| 46 |
+
production training run.
|
| 47 |
+
|
| 48 |
+
Measured on a held-out test set:
|
| 49 |
+
|
| 50 |
+
| Metric | Value |
|
| 51 |
+
|---|---|
|
| 52 |
+
| Character Error Rate | 91% |
|
| 53 |
+
| Exact match | 0% |
|
| 54 |
+
| Script validity | 99.9% |
|
| 55 |
+
|
| 56 |
+
**Script validity (99.9%) shows the model reliably learned which script to
|
| 57 |
+
emit per language tag** — the architecture and data pipeline are wired
|
| 58 |
+
correctly. **Character accuracy (91% CER) is not usable yet** — that needs a
|
| 59 |
+
full-scale training run (full per-language data volume, more epochs) before
|
| 60 |
+
this should be used for anything beyond pipeline testing.
|
| 61 |
+
|
| 62 |
+
## What this is (and isn't)
|
| 63 |
+
|
| 64 |
+
- **Is**: a from-scratch, ~7.7M-parameter character-level Transformer
|
| 65 |
+
encoder-decoder (`torch.nn.Transformer`-based), with `<2xx>` language-tag
|
| 66 |
+
tokens (same convention as IndicXlit/NLLB/mBART), trained via Hugging Face
|
| 67 |
+
`transformers`.
|
| 68 |
+
- **Isn't**: AI4Bharat's IndicXlit checkpoint or its fairseq architecture.
|
| 69 |
+
IndicXlit's fairseq codebase is not compatible with Python 3.12 (mutable
|
| 70 |
+
dataclass-default fields fairseq declares are hard-rejected by Python
|
| 71 |
+
3.11+'s `dataclasses` module, across multiple files, not a single
|
| 72 |
+
patchable spot) — see the training repository's README for the full
|
| 73 |
+
writeup. This model uses the same task framing and tagging convention on a
|
| 74 |
+
maintained stack instead of vendoring IndicXlit's literal weights.
|
| 75 |
+
|
| 76 |
+
## Training data
|
| 77 |
+
|
| 78 |
+
[AI4Bharat Aksharantar](https://huggingface.co/datasets/ai4bharat/Aksharantar)
|
| 79 |
+
— word-level Roman↔native pairs, 21 languages. Aksharantar's license is
|
| 80 |
+
mixed (CC-BY for manually-collected rows, CC0 for mined rows); this model
|
| 81 |
+
card carries `cc-by-4.0` as the safe upper bound. See the training repo's
|
| 82 |
+
README for the full licensing note, including why Google Dakshina (CC BY-SA,
|
| 83 |
+
share-alike) was *not* used for this checkpoint.
|
| 84 |
+
|
| 85 |
+
## Usage
|
| 86 |
+
|
| 87 |
+
```python
|
| 88 |
+
from transliteration.inference import TransliterationEngine
|
| 89 |
+
|
| 90 |
+
engine = TransliterationEngine.from_checkpoint("path/to/this/checkpoint")
|
| 91 |
+
engine.transliterate("mera emi pending hai", language="hi")
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
Full pipeline code (data download/validation/training/evaluation/inference/
|
| 95 |
+
TTS integration): see the accompanying repository files.
|
| 96 |
+
|
| 97 |
+
## Intended use
|
| 98 |
+
|
| 99 |
+
Pipeline validation and further fine-tuning. Not intended for production
|
| 100 |
+
transliteration until retrained at full data scale — see Known Limitations
|
| 101 |
+
in the training repo's README.
|
checkpoint/dataset_manifest.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"n_train_examples": 13125,
|
| 3 |
+
"n_validation_examples": 100,
|
| 4 |
+
"train_language_counts": {
|
| 5 |
+
"brx": 500,
|
| 6 |
+
"hi": 1937,
|
| 7 |
+
"mai": 500,
|
| 8 |
+
"kok": 500,
|
| 9 |
+
"ml": 587,
|
| 10 |
+
"bn": 841,
|
| 11 |
+
"ur": 500,
|
| 12 |
+
"doi": 500,
|
| 13 |
+
"as": 660,
|
| 14 |
+
"mni": 500,
|
| 15 |
+
"mr": 600,
|
| 16 |
+
"ks": 500,
|
| 17 |
+
"sa": 500,
|
| 18 |
+
"pa": 500,
|
| 19 |
+
"sd": 500,
|
| 20 |
+
"gu": 585,
|
| 21 |
+
"te": 669,
|
| 22 |
+
"or": 500,
|
| 23 |
+
"kn": 580,
|
| 24 |
+
"ta": 666,
|
| 25 |
+
"ne": 500
|
| 26 |
+
},
|
| 27 |
+
"validation_language_counts": {
|
| 28 |
+
"as": 8,
|
| 29 |
+
"hi": 5,
|
| 30 |
+
"mai": 4,
|
| 31 |
+
"brx": 6,
|
| 32 |
+
"mni": 5,
|
| 33 |
+
"sa": 8,
|
| 34 |
+
"sd": 4,
|
| 35 |
+
"ml": 4,
|
| 36 |
+
"kn": 4,
|
| 37 |
+
"doi": 6,
|
| 38 |
+
"ne": 3,
|
| 39 |
+
"ur": 4,
|
| 40 |
+
"ta": 5,
|
| 41 |
+
"kok": 6,
|
| 42 |
+
"or": 5,
|
| 43 |
+
"gu": 4,
|
| 44 |
+
"ks": 3,
|
| 45 |
+
"bn": 4,
|
| 46 |
+
"te": 3,
|
| 47 |
+
"pa": 3,
|
| 48 |
+
"mr": 6
|
| 49 |
+
},
|
| 50 |
+
"git_commit": "unknown (not a git repository)"
|
| 51 |
+
}
|
checkpoint/languages.json
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"as": {
|
| 3 |
+
"name": "Assamese",
|
| 4 |
+
"script": "Bengali-Assamese",
|
| 5 |
+
"tag": "<2as>"
|
| 6 |
+
},
|
| 7 |
+
"bn": {
|
| 8 |
+
"name": "Bengali",
|
| 9 |
+
"script": "Bengali",
|
| 10 |
+
"tag": "<2bn>"
|
| 11 |
+
},
|
| 12 |
+
"brx": {
|
| 13 |
+
"name": "Bodo",
|
| 14 |
+
"script": "Devanagari",
|
| 15 |
+
"tag": "<2brx>"
|
| 16 |
+
},
|
| 17 |
+
"doi": {
|
| 18 |
+
"name": "Dogri",
|
| 19 |
+
"script": "Devanagari",
|
| 20 |
+
"tag": "<2doi>"
|
| 21 |
+
},
|
| 22 |
+
"gu": {
|
| 23 |
+
"name": "Gujarati",
|
| 24 |
+
"script": "Gujarati",
|
| 25 |
+
"tag": "<2gu>"
|
| 26 |
+
},
|
| 27 |
+
"hi": {
|
| 28 |
+
"name": "Hindi",
|
| 29 |
+
"script": "Devanagari",
|
| 30 |
+
"tag": "<2hi>"
|
| 31 |
+
},
|
| 32 |
+
"kn": {
|
| 33 |
+
"name": "Kannada",
|
| 34 |
+
"script": "Kannada",
|
| 35 |
+
"tag": "<2kn>"
|
| 36 |
+
},
|
| 37 |
+
"ks": {
|
| 38 |
+
"name": "Kashmiri",
|
| 39 |
+
"script": "Perso-Arabic",
|
| 40 |
+
"tag": "<2ks>"
|
| 41 |
+
},
|
| 42 |
+
"kok": {
|
| 43 |
+
"name": "Konkani",
|
| 44 |
+
"script": "Devanagari",
|
| 45 |
+
"tag": "<2kok>"
|
| 46 |
+
},
|
| 47 |
+
"mai": {
|
| 48 |
+
"name": "Maithili",
|
| 49 |
+
"script": "Devanagari",
|
| 50 |
+
"tag": "<2mai>"
|
| 51 |
+
},
|
| 52 |
+
"ml": {
|
| 53 |
+
"name": "Malayalam",
|
| 54 |
+
"script": "Malayalam",
|
| 55 |
+
"tag": "<2ml>"
|
| 56 |
+
},
|
| 57 |
+
"mni": {
|
| 58 |
+
"name": "Manipuri",
|
| 59 |
+
"script": "Bengali/Meetei",
|
| 60 |
+
"tag": "<2mni>"
|
| 61 |
+
},
|
| 62 |
+
"mr": {
|
| 63 |
+
"name": "Marathi",
|
| 64 |
+
"script": "Devanagari",
|
| 65 |
+
"tag": "<2mr>"
|
| 66 |
+
},
|
| 67 |
+
"ne": {
|
| 68 |
+
"name": "Nepali",
|
| 69 |
+
"script": "Devanagari",
|
| 70 |
+
"tag": "<2ne>"
|
| 71 |
+
},
|
| 72 |
+
"or": {
|
| 73 |
+
"name": "Odia",
|
| 74 |
+
"script": "Odia",
|
| 75 |
+
"tag": "<2or>"
|
| 76 |
+
},
|
| 77 |
+
"pa": {
|
| 78 |
+
"name": "Punjabi",
|
| 79 |
+
"script": "Gurmukhi",
|
| 80 |
+
"tag": "<2pa>"
|
| 81 |
+
},
|
| 82 |
+
"sa": {
|
| 83 |
+
"name": "Sanskrit",
|
| 84 |
+
"script": "Devanagari",
|
| 85 |
+
"tag": "<2sa>"
|
| 86 |
+
},
|
| 87 |
+
"sd": {
|
| 88 |
+
"name": "Sindhi",
|
| 89 |
+
"script": "Perso-Arabic",
|
| 90 |
+
"tag": "<2sd>"
|
| 91 |
+
},
|
| 92 |
+
"ta": {
|
| 93 |
+
"name": "Tamil",
|
| 94 |
+
"script": "Tamil",
|
| 95 |
+
"tag": "<2ta>"
|
| 96 |
+
},
|
| 97 |
+
"te": {
|
| 98 |
+
"name": "Telugu",
|
| 99 |
+
"script": "Telugu",
|
| 100 |
+
"tag": "<2te>"
|
| 101 |
+
},
|
| 102 |
+
"ur": {
|
| 103 |
+
"name": "Urdu",
|
| 104 |
+
"script": "Perso-Arabic",
|
| 105 |
+
"tag": "<2ur>"
|
| 106 |
+
},
|
| 107 |
+
"si": {
|
| 108 |
+
"name": "Sinhala",
|
| 109 |
+
"script": "Sinhala",
|
| 110 |
+
"tag": "<2si>"
|
| 111 |
+
}
|
| 112 |
+
}
|
checkpoint/model/config.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"architectures": [
|
| 3 |
+
"TransliterationModel"
|
| 4 |
+
],
|
| 5 |
+
"bos_token_id": 1,
|
| 6 |
+
"d_model": 256,
|
| 7 |
+
"dim_feedforward": 1024,
|
| 8 |
+
"dropout": 0.1,
|
| 9 |
+
"dtype": "float32",
|
| 10 |
+
"eos_token_id": 2,
|
| 11 |
+
"is_encoder_decoder": true,
|
| 12 |
+
"max_position_embeddings": 300,
|
| 13 |
+
"model_type": "char_transliteration",
|
| 14 |
+
"nhead": 4,
|
| 15 |
+
"num_decoder_layers": 4,
|
| 16 |
+
"num_encoder_layers": 4,
|
| 17 |
+
"pad_token_id": 0,
|
| 18 |
+
"transformers_version": "4.57.1",
|
| 19 |
+
"vocab_size": 701
|
| 20 |
+
}
|
checkpoint/model/model.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3975f3cd42105838e8d80a524250ccf6d5cad267acbccd3097c26e638d1978b5
|
| 3 |
+
size 30948236
|
checkpoint/tokenizer/special_tokens_map.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"bos_token": "<bos>",
|
| 3 |
+
"eos_token": "<eos>",
|
| 4 |
+
"pad_token": "<pad>",
|
| 5 |
+
"unk_token": "<unk>"
|
| 6 |
+
}
|
checkpoint/tokenizer/tokenizer_config.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"added_tokens_decoder": {
|
| 3 |
+
"0": {
|
| 4 |
+
"content": "<pad>",
|
| 5 |
+
"lstrip": false,
|
| 6 |
+
"normalized": false,
|
| 7 |
+
"rstrip": false,
|
| 8 |
+
"single_word": false,
|
| 9 |
+
"special": true
|
| 10 |
+
},
|
| 11 |
+
"1": {
|
| 12 |
+
"content": "<bos>",
|
| 13 |
+
"lstrip": false,
|
| 14 |
+
"normalized": false,
|
| 15 |
+
"rstrip": false,
|
| 16 |
+
"single_word": false,
|
| 17 |
+
"special": true
|
| 18 |
+
},
|
| 19 |
+
"2": {
|
| 20 |
+
"content": "<eos>",
|
| 21 |
+
"lstrip": false,
|
| 22 |
+
"normalized": false,
|
| 23 |
+
"rstrip": false,
|
| 24 |
+
"single_word": false,
|
| 25 |
+
"special": true
|
| 26 |
+
},
|
| 27 |
+
"3": {
|
| 28 |
+
"content": "<unk>",
|
| 29 |
+
"lstrip": false,
|
| 30 |
+
"normalized": false,
|
| 31 |
+
"rstrip": false,
|
| 32 |
+
"single_word": false,
|
| 33 |
+
"special": true
|
| 34 |
+
}
|
| 35 |
+
},
|
| 36 |
+
"bos_token": "<bos>",
|
| 37 |
+
"clean_up_tokenization_spaces": false,
|
| 38 |
+
"eos_token": "<eos>",
|
| 39 |
+
"extra_special_tokens": {},
|
| 40 |
+
"model_max_length": 1000000000000000019884624838656,
|
| 41 |
+
"pad_token": "<pad>",
|
| 42 |
+
"tokenizer_class": "CharTransliterationTokenizer",
|
| 43 |
+
"unk_token": "<unk>"
|
| 44 |
+
}
|
checkpoint/tokenizer/vocab.json
ADDED
|
@@ -0,0 +1,703 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"<pad>": 0,
|
| 3 |
+
"<bos>": 1,
|
| 4 |
+
"<eos>": 2,
|
| 5 |
+
"<unk>": 3,
|
| 6 |
+
"<2as>": 4,
|
| 7 |
+
"<2bn>": 5,
|
| 8 |
+
"<2brx>": 6,
|
| 9 |
+
"<2doi>": 7,
|
| 10 |
+
"<2gu>": 8,
|
| 11 |
+
"<2hi>": 9,
|
| 12 |
+
"<2kn>": 10,
|
| 13 |
+
"<2ks>": 11,
|
| 14 |
+
"<2kok>": 12,
|
| 15 |
+
"<2mai>": 13,
|
| 16 |
+
"<2ml>": 14,
|
| 17 |
+
"<2mni>": 15,
|
| 18 |
+
"<2mr>": 16,
|
| 19 |
+
"<2ne>": 17,
|
| 20 |
+
"<2or>": 18,
|
| 21 |
+
"<2pa>": 19,
|
| 22 |
+
"<2sa>": 20,
|
| 23 |
+
"<2sd>": 21,
|
| 24 |
+
"<2ta>": 22,
|
| 25 |
+
"<2te>": 23,
|
| 26 |
+
"<2ur>": 24,
|
| 27 |
+
"<2si>": 25,
|
| 28 |
+
"a": 26,
|
| 29 |
+
" ": 27,
|
| 30 |
+
"i": 28,
|
| 31 |
+
"n": 29,
|
| 32 |
+
"2": 30,
|
| 33 |
+
"<": 31,
|
| 34 |
+
">": 32,
|
| 35 |
+
"e": 33,
|
| 36 |
+
"h": 34,
|
| 37 |
+
"r": 35,
|
| 38 |
+
"m": 36,
|
| 39 |
+
"t": 37,
|
| 40 |
+
"k": 38,
|
| 41 |
+
"o": 39,
|
| 42 |
+
"u": 40,
|
| 43 |
+
"s": 41,
|
| 44 |
+
"ा": 42,
|
| 45 |
+
"d": 43,
|
| 46 |
+
"p": 44,
|
| 47 |
+
"l": 45,
|
| 48 |
+
"र": 46,
|
| 49 |
+
"b": 47,
|
| 50 |
+
"g": 48,
|
| 51 |
+
"्": 49,
|
| 52 |
+
"y": 50,
|
| 53 |
+
"े": 51,
|
| 54 |
+
"क": 52,
|
| 55 |
+
"म": 53,
|
| 56 |
+
"न": 54,
|
| 57 |
+
"c": 55,
|
| 58 |
+
"ं": 56,
|
| 59 |
+
"य": 57,
|
| 60 |
+
"ि": 58,
|
| 61 |
+
"प": 59,
|
| 62 |
+
"v": 60,
|
| 63 |
+
"ो": 61,
|
| 64 |
+
"ट": 62,
|
| 65 |
+
"ह": 63,
|
| 66 |
+
"स": 64,
|
| 67 |
+
"்": 65,
|
| 68 |
+
"া": 66,
|
| 69 |
+
"ल": 67,
|
| 70 |
+
"ी": 68,
|
| 71 |
+
"ে": 69,
|
| 72 |
+
"त": 70,
|
| 73 |
+
"व": 71,
|
| 74 |
+
"ग": 72,
|
| 75 |
+
"ै": 73,
|
| 76 |
+
"്": 74,
|
| 77 |
+
"w": 75,
|
| 78 |
+
"ड": 76,
|
| 79 |
+
"j": 77,
|
| 80 |
+
"্": 78,
|
| 81 |
+
"ম": 79,
|
| 82 |
+
"ি": 80,
|
| 83 |
+
"ब": 81,
|
| 84 |
+
"ا": 82,
|
| 85 |
+
"ক": 83,
|
| 86 |
+
"க": 84,
|
| 87 |
+
"आ": 85,
|
| 88 |
+
"द": 86,
|
| 89 |
+
"ु": 87,
|
| 90 |
+
"ন": 88,
|
| 91 |
+
"अ": 89,
|
| 92 |
+
"0": 90,
|
| 93 |
+
"్": 91,
|
| 94 |
+
"च": 92,
|
| 95 |
+
"ই": 93,
|
| 96 |
+
"ப": 94,
|
| 97 |
+
"x": 95,
|
| 98 |
+
"র": 96,
|
| 99 |
+
"ू": 97,
|
| 100 |
+
"ব": 98,
|
| 101 |
+
"ن": 99,
|
| 102 |
+
"ு": 100,
|
| 103 |
+
"ం": 101,
|
| 104 |
+
"್": 102,
|
| 105 |
+
"و": 103,
|
| 106 |
+
"ി": 104,
|
| 107 |
+
"ు": 105,
|
| 108 |
+
"श": 106,
|
| 109 |
+
"ট": 107,
|
| 110 |
+
"ज": 108,
|
| 111 |
+
"ر": 109,
|
| 112 |
+
"ા": 110,
|
| 113 |
+
"প": 111,
|
| 114 |
+
"আ": 112,
|
| 115 |
+
"ి": 113,
|
| 116 |
+
"ప": 114,
|
| 117 |
+
"ా": 115,
|
| 118 |
+
"ی": 116,
|
| 119 |
+
"ಿ": 117,
|
| 120 |
+
"ക": 118,
|
| 121 |
+
"ए": 119,
|
| 122 |
+
"ನ": 120,
|
| 123 |
+
"ி": 121,
|
| 124 |
+
"ई": 122,
|
| 125 |
+
"ৰ": 123,
|
| 126 |
+
"م": 124,
|
| 127 |
+
"୍": 125,
|
| 128 |
+
"उ": 126,
|
| 129 |
+
"ட": 127,
|
| 130 |
+
"ண": 128,
|
| 131 |
+
"f": 129,
|
| 132 |
+
"फ": 130,
|
| 133 |
+
"z": 131,
|
| 134 |
+
"ന": 132,
|
| 135 |
+
"ಂ": 133,
|
| 136 |
+
"ꯤ": 134,
|
| 137 |
+
"ي": 135,
|
| 138 |
+
"ம": 136,
|
| 139 |
+
"ಾ": 137,
|
| 140 |
+
"ਾ": 138,
|
| 141 |
+
"ା": 139,
|
| 142 |
+
"ర": 140,
|
| 143 |
+
"ೆ": 141,
|
| 144 |
+
"ರ": 142,
|
| 145 |
+
"য": 143,
|
| 146 |
+
"મ": 144,
|
| 147 |
+
"న": 145,
|
| 148 |
+
"ર": 146,
|
| 149 |
+
"ت": 147,
|
| 150 |
+
"ે": 148,
|
| 151 |
+
"ل": 149,
|
| 152 |
+
"த": 150,
|
| 153 |
+
"ન": 151,
|
| 154 |
+
"ল": 152,
|
| 155 |
+
"ছ": 153,
|
| 156 |
+
"ர": 154,
|
| 157 |
+
"ꯥ": 155,
|
| 158 |
+
"્": 156,
|
| 159 |
+
"ಗ": 157,
|
| 160 |
+
"ದ": 158,
|
| 161 |
+
"ം": 159,
|
| 162 |
+
"எ": 160,
|
| 163 |
+
"س": 161,
|
| 164 |
+
"ख": 162,
|
| 165 |
+
"ର": 163,
|
| 166 |
+
"ି": 164,
|
| 167 |
+
"ં": 165,
|
| 168 |
+
"డ": 166,
|
| 169 |
+
"త": 167,
|
| 170 |
+
"ত": 168,
|
| 171 |
+
"ು": 169,
|
| 172 |
+
"ത": 170,
|
| 173 |
+
"ే": 171,
|
| 174 |
+
"ാ": 172,
|
| 175 |
+
"ன": 173,
|
| 176 |
+
"ു": 174,
|
| 177 |
+
"গ": 175,
|
| 178 |
+
"5": 176,
|
| 179 |
+
"د": 177,
|
| 180 |
+
"പ": 178,
|
| 181 |
+
"भ": 179,
|
| 182 |
+
"வ": 180,
|
| 183 |
+
"ো": 181,
|
| 184 |
+
"এ": 182,
|
| 185 |
+
"স": 183,
|
| 186 |
+
"మ": 184,
|
| 187 |
+
"উ": 185,
|
| 188 |
+
"ల": 186,
|
| 189 |
+
"ವ": 187,
|
| 190 |
+
"দ": 188,
|
| 191 |
+
"ெ": 189,
|
| 192 |
+
"ہ": 190,
|
| 193 |
+
"ா": 191,
|
| 194 |
+
"ب": 192,
|
| 195 |
+
"थ": 193,
|
| 196 |
+
"ತ": 194,
|
| 197 |
+
"ਰ": 195,
|
| 198 |
+
"இ": 196,
|
| 199 |
+
"ె": 197,
|
| 200 |
+
"ओ": 198,
|
| 201 |
+
"ध": 199,
|
| 202 |
+
"ী": 200,
|
| 203 |
+
"ங": 201,
|
| 204 |
+
"ण": 202,
|
| 205 |
+
"ष": 203,
|
| 206 |
+
"િ": 204,
|
| 207 |
+
"ಕ": 205,
|
| 208 |
+
"ੀ": 206,
|
| 209 |
+
"എ": 207,
|
| 210 |
+
"ો": 208,
|
| 211 |
+
"P": 209,
|
| 212 |
+
"ک": 210,
|
| 213 |
+
"হ": 211,
|
| 214 |
+
"ও": 212,
|
| 215 |
+
"ద": 213,
|
| 216 |
+
"ી": 214,
|
| 217 |
+
"చ": 215,
|
| 218 |
+
"T": 216,
|
| 219 |
+
"ু": 217,
|
| 220 |
+
"స": 218,
|
| 221 |
+
"യ": 219,
|
| 222 |
+
"ઈ": 220,
|
| 223 |
+
"ಲ": 221,
|
| 224 |
+
"O": 222,
|
| 225 |
+
"ఎ": 223,
|
| 226 |
+
"4": 224,
|
| 227 |
+
"గ": 225,
|
| 228 |
+
"પ": 226,
|
| 229 |
+
"ନ": 227,
|
| 230 |
+
"ര": 228,
|
| 231 |
+
"ভ": 229,
|
| 232 |
+
"െ": 230,
|
| 233 |
+
"ড": 231,
|
| 234 |
+
"ಇ": 232,
|
| 235 |
+
"R": 233,
|
| 236 |
+
"క": 234,
|
| 237 |
+
"ಪ": 235,
|
| 238 |
+
"ട": 236,
|
| 239 |
+
"ꯅ": 237,
|
| 240 |
+
"ਿ": 238,
|
| 241 |
+
"ꯁ": 239,
|
| 242 |
+
"q": 240,
|
| 243 |
+
"झ": 241,
|
| 244 |
+
"ꯂ": 242,
|
| 245 |
+
"ਕ": 243,
|
| 246 |
+
"ണ": 244,
|
| 247 |
+
"વ": 245,
|
| 248 |
+
"ँ": 246,
|
| 249 |
+
"ਸ": 247,
|
| 250 |
+
"ମ": 248,
|
| 251 |
+
"ક": 249,
|
| 252 |
+
"ట": 250,
|
| 253 |
+
"ಸ": 251,
|
| 254 |
+
"ସ": 252,
|
| 255 |
+
"ଲ": 253,
|
| 256 |
+
"ھ": 254,
|
| 257 |
+
"ꯕ": 255,
|
| 258 |
+
"മ": 256,
|
| 259 |
+
"ণ": 257,
|
| 260 |
+
"ج": 258,
|
| 261 |
+
"ற": 259,
|
| 262 |
+
"ல": 260,
|
| 263 |
+
"ಡ": 261,
|
| 264 |
+
"়": 262,
|
| 265 |
+
"ಯ": 263,
|
| 266 |
+
"ગ": 264,
|
| 267 |
+
"వ": 265,
|
| 268 |
+
"ꯡ": 266,
|
| 269 |
+
"ौ": 267,
|
| 270 |
+
"অ": 268,
|
| 271 |
+
"ડ": 269,
|
| 272 |
+
"ே": 270,
|
| 273 |
+
"ோ": 271,
|
| 274 |
+
"ਲ": 272,
|
| 275 |
+
"କ": 273,
|
| 276 |
+
"ꯣ": 274,
|
| 277 |
+
"ꯨ": 275,
|
| 278 |
+
"پ": 276,
|
| 279 |
+
"େ": 277,
|
| 280 |
+
"સ": 278,
|
| 281 |
+
"ൻ": 279,
|
| 282 |
+
"ய": 280,
|
| 283 |
+
"ഗ": 281,
|
| 284 |
+
"വ": 282,
|
| 285 |
+
"സ": 283,
|
| 286 |
+
"ୁ": 284,
|
| 287 |
+
"জ": 285,
|
| 288 |
+
"ਆ": 286,
|
| 289 |
+
"ள": 287,
|
| 290 |
+
"ਨ": 288,
|
| 291 |
+
"ત": 289,
|
| 292 |
+
"ை": 290,
|
| 293 |
+
"એ": 291,
|
| 294 |
+
"ச": 292,
|
| 295 |
+
"ਵ": 293,
|
| 296 |
+
"ꯔ": 294,
|
| 297 |
+
"ആ": 295,
|
| 298 |
+
"ꯟ": 296,
|
| 299 |
+
"ش": 297,
|
| 300 |
+
"ق": 298,
|
| 301 |
+
"इ": 299,
|
| 302 |
+
"આ": 300,
|
| 303 |
+
"ீ": 301,
|
| 304 |
+
"ఉ": 302,
|
| 305 |
+
"ഡ": 303,
|
| 306 |
+
"S": 304,
|
| 307 |
+
"ः": 305,
|
| 308 |
+
"ഇ": 306,
|
| 309 |
+
"ച": 307,
|
| 310 |
+
"ꯃ": 308,
|
| 311 |
+
"ꯗ": 309,
|
| 312 |
+
"ه": 310,
|
| 313 |
+
"ਂ": 311,
|
| 314 |
+
"ఇ": 312,
|
| 315 |
+
"শ": 313,
|
| 316 |
+
"య": 314,
|
| 317 |
+
"ല": 315,
|
| 318 |
+
"ꯒ": 316,
|
| 319 |
+
"ꯝ": 317,
|
| 320 |
+
"َ": 318,
|
| 321 |
+
"ٕ": 319,
|
| 322 |
+
"છ": 320,
|
| 323 |
+
"ବ": 321,
|
| 324 |
+
"ಳ": 322,
|
| 325 |
+
"ഐ": 323,
|
| 326 |
+
"ꯦ": 324,
|
| 327 |
+
"ਮ": 325,
|
| 328 |
+
"ꯇ": 326,
|
| 329 |
+
"ِ": 327,
|
| 330 |
+
"گ": 328,
|
| 331 |
+
"छ": 329,
|
| 332 |
+
"ஐ": 330,
|
| 333 |
+
"ఐ": 331,
|
| 334 |
+
"æ": 332,
|
| 335 |
+
"ପ": 333,
|
| 336 |
+
"ో": 334,
|
| 337 |
+
"ಎ": 335,
|
| 338 |
+
"ꯄ": 336,
|
| 339 |
+
"ય": 337,
|
| 340 |
+
"റ": 338,
|
| 341 |
+
"M": 339,
|
| 342 |
+
"ঠ": 340,
|
| 343 |
+
"ુ": 341,
|
| 344 |
+
"ଟ": 342,
|
| 345 |
+
"ꯛ": 343,
|
| 346 |
+
"C": 344,
|
| 347 |
+
"ز": 345,
|
| 348 |
+
"ಮ": 346,
|
| 349 |
+
"ಐ": 347,
|
| 350 |
+
"K": 348,
|
| 351 |
+
"ف": 349,
|
| 352 |
+
"લ": 350,
|
| 353 |
+
"ڪ": 351,
|
| 354 |
+
"ਤ": 352,
|
| 355 |
+
"ୋ": 353,
|
| 356 |
+
"ئ": 354,
|
| 357 |
+
"़": 355,
|
| 358 |
+
"ृ": 356,
|
| 359 |
+
"ਦ": 357,
|
| 360 |
+
"چ": 358,
|
| 361 |
+
"ଜ": 359,
|
| 362 |
+
"ꯈ": 360,
|
| 363 |
+
"ح": 361,
|
| 364 |
+
"ع": 362,
|
| 365 |
+
"ੇ": 363,
|
| 366 |
+
"চ": 364,
|
| 367 |
+
"ந": 365,
|
| 368 |
+
"خ": 366,
|
| 369 |
+
"ُ": 367,
|
| 370 |
+
"ळ": 368,
|
| 371 |
+
"ਹ": 369,
|
| 372 |
+
"ੁ": 370,
|
| 373 |
+
"ଦ": 371,
|
| 374 |
+
"ষ": 372,
|
| 375 |
+
"ତ": 373,
|
| 376 |
+
"ള": 374,
|
| 377 |
+
"ਬ": 375,
|
| 378 |
+
"ଡ": 376,
|
| 379 |
+
"ठ": 377,
|
| 380 |
+
"ਪ": 378,
|
| 381 |
+
"ਜ": 379,
|
| 382 |
+
"ୀ": 380,
|
| 383 |
+
"ꯊ": 381,
|
| 384 |
+
"ୟ": 382,
|
| 385 |
+
"ٲ": 383,
|
| 386 |
+
"ں": 384,
|
| 387 |
+
"घ": 385,
|
| 388 |
+
"ਗ": 386,
|
| 389 |
+
"਼": 387,
|
| 390 |
+
"ോ": 388,
|
| 391 |
+
"ଇ": 389,
|
| 392 |
+
"ے": 390,
|
| 393 |
+
"દ": 391,
|
| 394 |
+
"ହ": 392,
|
| 395 |
+
"ꯀ": 393,
|
| 396 |
+
"ধ": 394,
|
| 397 |
+
"ଆ": 395,
|
| 398 |
+
"ಹ": 396,
|
| 399 |
+
"ർ": 397,
|
| 400 |
+
"খ": 398,
|
| 401 |
+
"ટ": 399,
|
| 402 |
+
"ൽ": 400,
|
| 403 |
+
"ಶ": 401,
|
| 404 |
+
"ങ": 402,
|
| 405 |
+
"ꯌ": 403,
|
| 406 |
+
"ꯑ": 404,
|
| 407 |
+
"ꯜ": 405,
|
| 408 |
+
"ଣ": 406,
|
| 409 |
+
"ీ": 407,
|
| 410 |
+
"ꯩ": 408,
|
| 411 |
+
"ফ": 409,
|
| 412 |
+
"ಟ": 410,
|
| 413 |
+
"ೇ": 411,
|
| 414 |
+
"ٹ": 412,
|
| 415 |
+
"ꯢ": 413,
|
| 416 |
+
"േ": 414,
|
| 417 |
+
"ص": 415,
|
| 418 |
+
"ط": 416,
|
| 419 |
+
"ڻ": 417,
|
| 420 |
+
"શ": 418,
|
| 421 |
+
"ꯐ": 419,
|
| 422 |
+
"ਟ": 420,
|
| 423 |
+
"ಣ": 421,
|
| 424 |
+
"ଶ": 422,
|
| 425 |
+
"ಅ": 423,
|
| 426 |
+
"ꯍ": 424,
|
| 427 |
+
"ੈ": 425,
|
| 428 |
+
"ੰ": 426,
|
| 429 |
+
"ూ": 427,
|
| 430 |
+
"ദ": 428,
|
| 431 |
+
"ધ": 429,
|
| 432 |
+
"జ": 430,
|
| 433 |
+
"ञ": 431,
|
| 434 |
+
"ং": 432,
|
| 435 |
+
"ଫ": 433,
|
| 436 |
+
"ശ": 434,
|
| 437 |
+
"ূ": 435,
|
| 438 |
+
"ৱ": 436,
|
| 439 |
+
"ੋ": 437,
|
| 440 |
+
"ଏ": 438,
|
| 441 |
+
"ಬ": 439,
|
| 442 |
+
"ڙ": 440,
|
| 443 |
+
"ੱ": 441,
|
| 444 |
+
"જ": 442,
|
| 445 |
+
"ء": 443,
|
| 446 |
+
"ਡ": 444,
|
| 447 |
+
"ਣ": 445,
|
| 448 |
+
"ಧ": 446,
|
| 449 |
+
"ꯠ": 447,
|
| 450 |
+
"ꯧ": 448,
|
| 451 |
+
"ચ": 449,
|
| 452 |
+
"ീ": 450,
|
| 453 |
+
"ꯏ": 451,
|
| 454 |
+
"ଗ": 452,
|
| 455 |
+
"అ": 453,
|
| 456 |
+
"ೂ": 454,
|
| 457 |
+
"ೊ": 455,
|
| 458 |
+
"ۍ": 456,
|
| 459 |
+
"থ": 457,
|
| 460 |
+
"બ": 458,
|
| 461 |
+
"હ": 459,
|
| 462 |
+
"ಷ": 460,
|
| 463 |
+
"ൂ": 461,
|
| 464 |
+
"ൾ": 462,
|
| 465 |
+
"ढ": 463,
|
| 466 |
+
"ਚ": 464,
|
| 467 |
+
"થ": 465,
|
| 468 |
+
"அ": 466,
|
| 469 |
+
"శ": 467,
|
| 470 |
+
"ೋ": 468,
|
| 471 |
+
"ۄ": 469,
|
| 472 |
+
"ழ": 470,
|
| 473 |
+
"ష": 471,
|
| 474 |
+
"ഷ": 472,
|
| 475 |
+
"ꯆ": 473,
|
| 476 |
+
"ৈ": 474,
|
| 477 |
+
"ਅ": 475,
|
| 478 |
+
"੍": 476,
|
| 479 |
+
"ੜ": 477,
|
| 480 |
+
"ଅ": 478,
|
| 481 |
+
"ధ": 479,
|
| 482 |
+
"ٖ": 480,
|
| 483 |
+
"ٽ": 481,
|
| 484 |
+
"ङ": 482,
|
| 485 |
+
"ੂ": 483,
|
| 486 |
+
"హ": 484,
|
| 487 |
+
"ಚ": 485,
|
| 488 |
+
"ಜ": 486,
|
| 489 |
+
"ٚ": 487,
|
| 490 |
+
"ખ": 488,
|
| 491 |
+
"బ": 489,
|
| 492 |
+
"ೀ": 490,
|
| 493 |
+
"ٛ": 491,
|
| 494 |
+
"ৃ": 492,
|
| 495 |
+
"ொ": 493,
|
| 496 |
+
"آ": 494,
|
| 497 |
+
"ٮ": 495,
|
| 498 |
+
"۪": 496,
|
| 499 |
+
"ਉ": 497,
|
| 500 |
+
"ણ": 498,
|
| 501 |
+
"ਇ": 499,
|
| 502 |
+
"ூ": 500,
|
| 503 |
+
"ಆ": 501,
|
| 504 |
+
"അ": 502,
|
| 505 |
+
"ٗ": 503,
|
| 506 |
+
"ڈ": 504,
|
| 507 |
+
"ଧ": 505,
|
| 508 |
+
"ଷ": 506,
|
| 509 |
+
"ബ": 507,
|
| 510 |
+
"ꯋ": 508,
|
| 511 |
+
"ꯖ": 509,
|
| 512 |
+
"ض": 510,
|
| 513 |
+
"ਧ": 511,
|
| 514 |
+
"ૂ": 512,
|
| 515 |
+
"ଖ": 513,
|
| 516 |
+
"꯭": 514,
|
| 517 |
+
"ਫ": 515,
|
| 518 |
+
"ഹ": 516,
|
| 519 |
+
"ॉ": 517,
|
| 520 |
+
"ਖ": 518,
|
| 521 |
+
"ઉ": 519,
|
| 522 |
+
"ଚ": 520,
|
| 523 |
+
"ఆ": 521,
|
| 524 |
+
"ꯪ": 522,
|
| 525 |
+
"ঞ": 523,
|
| 526 |
+
"ળ": 524,
|
| 527 |
+
"ణ": 525,
|
| 528 |
+
"ജ": 526,
|
| 529 |
+
"ذ": 527,
|
| 530 |
+
"ظ": 528,
|
| 531 |
+
"ਈ": 529,
|
| 532 |
+
"ઓ": 530,
|
| 533 |
+
"భ": 531,
|
| 534 |
+
"ై": 532,
|
| 535 |
+
"غ": 533,
|
| 536 |
+
"ژ": 534,
|
| 537 |
+
"અ": 535,
|
| 538 |
+
"ധ": 536,
|
| 539 |
+
"ڑ": 537,
|
| 540 |
+
"ફ": 538,
|
| 541 |
+
"ષ": 539,
|
| 542 |
+
"ଳ": 540,
|
| 543 |
+
"ಭ": 541,
|
| 544 |
+
"ഫ": 542,
|
| 545 |
+
"ꯉ": 543,
|
| 546 |
+
"ٔ": 544,
|
| 547 |
+
"ڊ": 545,
|
| 548 |
+
"ऽ": 546,
|
| 549 |
+
"ঘ": 547,
|
| 550 |
+
"ஸ": 548,
|
| 551 |
+
"ഉ": 549,
|
| 552 |
+
"ൊ": 550,
|
| 553 |
+
"ꯎ": 551,
|
| 554 |
+
"ڌ": 552,
|
| 555 |
+
"ڳ": 553,
|
| 556 |
+
"ॅ": 554,
|
| 557 |
+
"ঁ": 555,
|
| 558 |
+
"ੌ": 556,
|
| 559 |
+
"ొ": 557,
|
| 560 |
+
"ഞ": 558,
|
| 561 |
+
"ഥ": 559,
|
| 562 |
+
"ൃ": 560,
|
| 563 |
+
"ઠ": 561,
|
| 564 |
+
"ભ": 562,
|
| 565 |
+
"ଓ": 563,
|
| 566 |
+
"ஆ": 564,
|
| 567 |
+
"ഭ": 565,
|
| 568 |
+
"ꯞ": 566,
|
| 569 |
+
"B": 567,
|
| 570 |
+
"ਭ": 568,
|
| 571 |
+
"ଭ": 569,
|
| 572 |
+
"ஜ": 570,
|
| 573 |
+
"ث": 571,
|
| 574 |
+
"ڀ": 572,
|
| 575 |
+
"ڏ": 573,
|
| 576 |
+
"ऱ": 574,
|
| 577 |
+
"ঙ": 575,
|
| 578 |
+
"ਛ": 576,
|
| 579 |
+
"ఫ": 577,
|
| 580 |
+
"ೃ": 578,
|
| 581 |
+
"A": 579,
|
| 582 |
+
"ৌ": 580,
|
| 583 |
+
"ଥ": 581,
|
| 584 |
+
"଼": 582,
|
| 585 |
+
"ళ": 583,
|
| 586 |
+
"ൈ": 584,
|
| 587 |
+
"ꯚ": 585,
|
| 588 |
+
"ਘ": 586,
|
| 589 |
+
"ਥ": 587,
|
| 590 |
+
"ઘ": 588,
|
| 591 |
+
"உ": 589,
|
| 592 |
+
"థ": 590,
|
| 593 |
+
"ಉ": 591,
|
| 594 |
+
"ؤ": 592,
|
| 595 |
+
"ऊ": 593,
|
| 596 |
+
"ৎ": 594,
|
| 597 |
+
"ਐ": 595,
|
| 598 |
+
"ਝ": 596,
|
| 599 |
+
"ଉ": 597,
|
| 600 |
+
"ଞ": 598,
|
| 601 |
+
"ଯ": 599,
|
| 602 |
+
"ୃ": 600,
|
| 603 |
+
"ಒ": 601,
|
| 604 |
+
"ಥ": 602,
|
| 605 |
+
"ഒ": 603,
|
| 606 |
+
"ٺ": 604,
|
| 607 |
+
"ڇ": 605,
|
| 608 |
+
"ۃ": 606,
|
| 609 |
+
"ਏ": 607,
|
| 610 |
+
"ઇ": 608,
|
| 611 |
+
"ୱ": 609,
|
| 612 |
+
"ஒ": 610,
|
| 613 |
+
"ೈ": 611,
|
| 614 |
+
"ഖ": 612,
|
| 615 |
+
"ഴ": 613,
|
| 616 |
+
"N": 614,
|
| 617 |
+
"أ": 615,
|
| 618 |
+
"ٻ": 616,
|
| 619 |
+
"औ": 617,
|
| 620 |
+
"ਓ": 618,
|
| 621 |
+
"ਯ": 619,
|
| 622 |
+
"ଁ": 620,
|
| 623 |
+
"ୂ": 621,
|
| 624 |
+
"ஏ": 622,
|
| 625 |
+
"ஞ": 623,
|
| 626 |
+
"ఞ": 624,
|
| 627 |
+
"ృ": 625,
|
| 628 |
+
"ಖ": 626,
|
| 629 |
+
"ഏ": 627,
|
| 630 |
+
"ഠ": 628,
|
| 631 |
+
"H": 629,
|
| 632 |
+
"ऑ": 630,
|
| 633 |
+
"ਠ": 631,
|
| 634 |
+
"ૃ": 632,
|
| 635 |
+
"ૈ": 633,
|
| 636 |
+
"ૌ": 634,
|
| 637 |
+
"ଂ": 635,
|
| 638 |
+
"ఏ": 636,
|
| 639 |
+
"ఖ": 637,
|
| 640 |
+
"ಫ": 638,
|
| 641 |
+
"ഓ": 639,
|
| 642 |
+
"D": 640,
|
| 643 |
+
"J": 641,
|
| 644 |
+
"ڃ": 642,
|
| 645 |
+
"ڄ": 643,
|
| 646 |
+
"ڍ": 644,
|
| 647 |
+
"ڦ": 645,
|
| 648 |
+
"ऐ": 646,
|
| 649 |
+
"ঢ": 647,
|
| 650 |
+
"ਊ": 648,
|
| 651 |
+
"ઝ": 649,
|
| 652 |
+
"ઢ": 650,
|
| 653 |
+
"ఘ": 651,
|
| 654 |
+
"ಏ": 652,
|
| 655 |
+
"ಞ": 653,
|
| 656 |
+
"ೌ": 654,
|
| 657 |
+
"ഘ": 655,
|
| 658 |
+
"ൺ": 656,
|
| 659 |
+
"G": 657,
|
| 660 |
+
"I": 658,
|
| 661 |
+
"L": 659,
|
| 662 |
+
"U": 660,
|
| 663 |
+
"V": 661,
|
| 664 |
+
"ٿ": 662,
|
| 665 |
+
"ڱ": 663,
|
| 666 |
+
"ऋ": 664,
|
| 667 |
+
"ଘ": 665,
|
| 668 |
+
"ஓ": 666,
|
| 669 |
+
"ஷ": 667,
|
| 670 |
+
"ஹ": 668,
|
| 671 |
+
"ఒ": 669,
|
| 672 |
+
"ఠ": 670,
|
| 673 |
+
"ಘ": 671,
|
| 674 |
+
"ಛ": 672,
|
| 675 |
+
"ಠ": 673,
|
| 676 |
+
"ꯓ": 674,
|
| 677 |
+
"E": 675,
|
| 678 |
+
"Q": 676,
|
| 679 |
+
"Y": 677,
|
| 680 |
+
"Z": 678,
|
| 681 |
+
"ۓ": 679,
|
| 682 |
+
"१": 680,
|
| 683 |
+
"ঈ": 681,
|
| 684 |
+
"ঔ": 682,
|
| 685 |
+
"ਢ": 683,
|
| 686 |
+
"ઊ": 684,
|
| 687 |
+
"ઐ": 685,
|
| 688 |
+
"ૉ": 686,
|
| 689 |
+
"ଛ": 687,
|
| 690 |
+
"ଝ": 688,
|
| 691 |
+
"ஈ": 689,
|
| 692 |
+
"ఈ": 690,
|
| 693 |
+
"ఊ": 691,
|
| 694 |
+
"ఓ": 692,
|
| 695 |
+
"ఛ": 693,
|
| 696 |
+
"ಃ": 694,
|
| 697 |
+
"ಈ": 695,
|
| 698 |
+
"ಊ": 696,
|
| 699 |
+
"ഋ": 697,
|
| 700 |
+
"ഔ": 698,
|
| 701 |
+
"ൌ": 699,
|
| 702 |
+
"ꯙ": 700
|
| 703 |
+
}
|
checkpoint/training_config.yaml
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
data:
|
| 2 |
+
processed_dir: data/processed
|
| 3 |
+
validated_dir: data/validated
|
| 4 |
+
train_dir: data/train
|
| 5 |
+
validation_dir: data/validation
|
| 6 |
+
test_dir: data/test
|
| 7 |
+
custom_csv: data/customer_support_seed.csv
|
| 8 |
+
languages:
|
| 9 |
+
- as
|
| 10 |
+
- bn
|
| 11 |
+
- brx
|
| 12 |
+
- doi
|
| 13 |
+
- gu
|
| 14 |
+
- hi
|
| 15 |
+
- kn
|
| 16 |
+
- ks
|
| 17 |
+
- kok
|
| 18 |
+
- mai
|
| 19 |
+
- ml
|
| 20 |
+
- mni
|
| 21 |
+
- mr
|
| 22 |
+
- ne
|
| 23 |
+
- or
|
| 24 |
+
- pa
|
| 25 |
+
- sa
|
| 26 |
+
- sd
|
| 27 |
+
- ta
|
| 28 |
+
- te
|
| 29 |
+
- ur
|
| 30 |
+
split:
|
| 31 |
+
train_frac: 0.9
|
| 32 |
+
val_frac: 0.05
|
| 33 |
+
test_frac: 0.05
|
| 34 |
+
seed: 13
|
| 35 |
+
language_sampling:
|
| 36 |
+
temperature: 0.7
|
| 37 |
+
custom_data_weight: 0.2
|
| 38 |
+
model:
|
| 39 |
+
d_model: 256
|
| 40 |
+
nhead: 4
|
| 41 |
+
num_encoder_layers: 4
|
| 42 |
+
num_decoder_layers: 4
|
| 43 |
+
dim_feedforward: 1024
|
| 44 |
+
dropout: 0.1
|
| 45 |
+
max_position_embeddings: 300
|
| 46 |
+
training:
|
| 47 |
+
output_dir: models/checkpoints/indicxlit-custom
|
| 48 |
+
num_train_epochs: 10
|
| 49 |
+
batch_size: 64
|
| 50 |
+
eval_batch_size: 128
|
| 51 |
+
learning_rate: 0.0003
|
| 52 |
+
warmup_steps: 500
|
| 53 |
+
weight_decay: 0.01
|
| 54 |
+
fp16: true
|
| 55 |
+
bf16: false
|
| 56 |
+
gradient_accumulation_steps: 1
|
| 57 |
+
logging_steps: 50
|
| 58 |
+
eval_steps: 500
|
| 59 |
+
save_steps: 500
|
| 60 |
+
save_total_limit: 3
|
| 61 |
+
early_stopping_patience: 5
|
| 62 |
+
metric_for_best_model: eval_cer
|
| 63 |
+
greater_is_better: false
|
| 64 |
+
seed: 13
|
| 65 |
+
resume_from_checkpoint: models/checkpoints/indicxlit-custom/checkpoint-500
|
| 66 |
+
use_lora: false
|
| 67 |
+
lora_r: 8
|
| 68 |
+
lora_alpha: 16
|
| 69 |
+
lora_dropout: 0.05
|
| 70 |
+
prototype:
|
| 71 |
+
max_examples_per_language: 500
|
| 72 |
+
num_train_epochs: 3
|
configs/train.yaml
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Training configuration for the character-level transliteration model.
|
| 2 |
+
# Override any field from the CLI: python -m transliteration.train --config configs/train.yaml --set training.batch_size=64
|
| 3 |
+
|
| 4 |
+
data:
|
| 5 |
+
processed_dir: data/processed
|
| 6 |
+
validated_dir: data/validated
|
| 7 |
+
train_dir: data/train
|
| 8 |
+
validation_dir: data/validation
|
| 9 |
+
test_dir: data/test
|
| 10 |
+
custom_csv: data/customer_support_seed.csv
|
| 11 |
+
languages: [as, bn, brx, doi, gu, hi, kn, ks, kok, mai, ml, mni, mr, ne, or, pa, sa, sd, ta, te, ur]
|
| 12 |
+
|
| 13 |
+
split:
|
| 14 |
+
train_frac: 0.90
|
| 15 |
+
val_frac: 0.05
|
| 16 |
+
test_frac: 0.05
|
| 17 |
+
seed: 13
|
| 18 |
+
|
| 19 |
+
language_sampling:
|
| 20 |
+
# 1.0 = proportional to natural frequency; lower values oversample
|
| 21 |
+
# low-resource languages (see transliteration.data.dataset.language_temperature_weights)
|
| 22 |
+
temperature: 0.7
|
| 23 |
+
|
| 24 |
+
custom_data_weight: 0.2 # fraction of final training mix drawn from data/customer_support_seed.csv
|
| 25 |
+
|
| 26 |
+
model:
|
| 27 |
+
d_model: 256
|
| 28 |
+
nhead: 4
|
| 29 |
+
num_encoder_layers: 4
|
| 30 |
+
num_decoder_layers: 4
|
| 31 |
+
dim_feedforward: 1024
|
| 32 |
+
dropout: 0.1
|
| 33 |
+
max_position_embeddings: 300
|
| 34 |
+
|
| 35 |
+
training:
|
| 36 |
+
output_dir: models/checkpoints/indicxlit-custom
|
| 37 |
+
num_train_epochs: 10
|
| 38 |
+
batch_size: 64
|
| 39 |
+
eval_batch_size: 128
|
| 40 |
+
learning_rate: 3.0e-4
|
| 41 |
+
warmup_steps: 500
|
| 42 |
+
weight_decay: 0.01
|
| 43 |
+
fp16: true
|
| 44 |
+
bf16: false
|
| 45 |
+
gradient_accumulation_steps: 1
|
| 46 |
+
logging_steps: 50
|
| 47 |
+
eval_steps: 500
|
| 48 |
+
save_steps: 500
|
| 49 |
+
save_total_limit: 3
|
| 50 |
+
early_stopping_patience: 5
|
| 51 |
+
metric_for_best_model: eval_cer
|
| 52 |
+
greater_is_better: false
|
| 53 |
+
seed: 13
|
| 54 |
+
resume_from_checkpoint: null # path to resume, or null
|
| 55 |
+
|
| 56 |
+
# LoRA/PEFT: this model is trained from scratch (~10-15M params) and is
|
| 57 |
+
# small enough that full fine-tuning is cheap; LoRA is offered for the
|
| 58 |
+
# scenario where later this becomes a much larger pretrained backbone.
|
| 59 |
+
use_lora: false
|
| 60 |
+
lora_r: 8
|
| 61 |
+
lora_alpha: 16
|
| 62 |
+
lora_dropout: 0.05
|
| 63 |
+
|
| 64 |
+
prototype:
|
| 65 |
+
# Small end-to-end smoke test settings (see README "First task" section).
|
| 66 |
+
max_examples_per_language: 500
|
| 67 |
+
num_train_epochs: 3
|
data/customer_support_seed.csv
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
input,target,language
|
| 2 |
+
mera emi pending hai,मेरा ईएमआई पेंडिंग है,hi
|
| 3 |
+
aap kab payment karoge,आप कब पेमेंट करोगे,hi
|
| 4 |
+
payment kal kar dunga,पेमेंट कल कर दूंगा,hi
|
| 5 |
+
mera otp nahi aaya,मेरा ओटीपी नहीं आया,hi
|
| 6 |
+
apna pin reset karo,अपना पिन रीसेट करो,hi
|
| 7 |
+
loan approve ho gaya,लोन अप्रूव हो गया,hi
|
| 8 |
+
amount kitna due hai,अमाउंट कितना ड्यू है,hi
|
| 9 |
+
account overdue ho gaya hai,अकाउंट ओवरड्यू हो गया है,hi
|
| 10 |
+
due date kya hai,ड्यू डेट क्या है,hi
|
| 11 |
+
customer ka naam Rakesh Sharma hai,कस्टमर का नाम राकेश शर्मा है,hi
|
| 12 |
+
mera balance check karo,मेरा बैलेंस चेक करो,hi
|
| 13 |
+
statement email kar do,स्टेटमेंट ईमेल कर दो,hi
|
| 14 |
+
mera phone number update karna hai,मेरा फोन नंबर अपडेट करना है,hi
|
| 15 |
+
total amount 5000 rupees hai,टोटल अमाउंट 5000 रुपये है,hi
|
| 16 |
+
credit card ka bill nahi aaya,क्रेडिट कार्ड का बिल नहीं आया,hi
|
| 17 |
+
amar emi baki ache,আমার ইএমআই বাকি আছে,bn
|
| 18 |
+
otp ki pathai nai,ওটিপি কি পাঠাই নাই,bn
|
| 19 |
+
payment kobe korben,পেমেন্ট কবে করবেন,bn
|
| 20 |
+
account ta overdue hoye gæche,অ্যাকাউন্ট টা ওভারডিউ হয়ে গেছে,bn
|
| 21 |
+
moi payment dim,মই পেমেণ্ট দিম,as
|
| 22 |
+
mora emi baki ase,মোৰ ইএমআই বাকী আছে,as
|
| 23 |
+
enakku emi pending irukku,எனக்கு இஎம்ஐ பெண்டிங் இருக்கு,ta
|
| 24 |
+
payment eppo pannuvinga,பேமெண்ட் எப்போ பண்ணுவீங்க,ta
|
| 25 |
+
naa emi pending undi,నా ఇఎంఐ పెండింగ్ ఉంది,te
|
| 26 |
+
payment eppudu chestaru,పేమెంట్ ఎప్పుడు చేస్తారు,te
|
| 27 |
+
enna emi pending ide,ನನ್ನ ಇಎಂಐ ಪೆಂಡಿಂಗ್ ಇದೆ,kn
|
| 28 |
+
enikku emi pending aanu,എനിക്ക് ഇഎംഐ പെൻഡിംഗ് ആണ്,ml
|
| 29 |
+
maza emi pending ahe,माझा ईएमआय पेंडिंग आहे,mr
|
| 30 |
+
mara emi pending che,મારો ઈએમઆઈ પેન્ડિંગ છે,gu
|
| 31 |
+
mera OTP 4 minute me expire ho jayega,मेरा OTP 4 मिनट में एक्सपायर हो जायेगा,hi
|
| 32 |
+
company ka naam Kapture CRM hai,कंपनी का नाम कैप्चर सीआरएम है,hi
|
integrations/__init__.py
ADDED
|
File without changes
|
integrations/tts.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""TTS integration: Roman text -> language ID -> transliteration -> text
|
| 2 |
+
normalization -> TTS.
|
| 3 |
+
|
| 4 |
+
The transliteration component (`TransliterationEngine`) is deliberately
|
| 5 |
+
independently callable and imported here, not subclassed or otherwise
|
| 6 |
+
coupled to any specific TTS backend -- swap `synthesize_speech` for your
|
| 7 |
+
actual TTS call (e.g. this repo's existing OmniVoice/FlowTTS server) without
|
| 8 |
+
touching the transliteration code.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import logging
|
| 12 |
+
import re
|
| 13 |
+
from dataclasses import dataclass
|
| 14 |
+
from typing import Callable, Optional
|
| 15 |
+
|
| 16 |
+
from transliteration.inference import TransliterationEngine, guess_language
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
_MULTI_SPACE_RE = re.compile(r"\s+")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def normalize_for_tts(text: str) -> str:
|
| 24 |
+
"""Minimal text normalization pass between transliteration and TTS:
|
| 25 |
+
collapse whitespace, strip stray control characters. Real punctuation/
|
| 26 |
+
number normalization for TTS is model-specific and belongs in the TTS
|
| 27 |
+
layer itself -- kept out of this function so the transliteration
|
| 28 |
+
package doesn't silently bake in assumptions about the downstream
|
| 29 |
+
voice model's expected input format.
|
| 30 |
+
"""
|
| 31 |
+
text = _MULTI_SPACE_RE.sub(" ", text).strip()
|
| 32 |
+
return text
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@dataclass
|
| 36 |
+
class TTSPipelineResult:
|
| 37 |
+
original_text: str
|
| 38 |
+
detected_or_given_language: str
|
| 39 |
+
transliterated_text: str
|
| 40 |
+
normalized_text: str
|
| 41 |
+
audio: Optional[bytes] = None
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class RomanToTTSPipeline:
|
| 45 |
+
"""Roman/Hinglish text -> Indic transliteration -> TTS.
|
| 46 |
+
|
| 47 |
+
`synthesize_fn`, if provided, is called as `synthesize_fn(text, language)
|
| 48 |
+
-> bytes` and its result is attached to `TTSPipelineResult.audio`. Pass
|
| 49 |
+
None to only run the transliteration+normalization stages (e.g. for
|
| 50 |
+
testing the transliteration step in isolation, per the "independently
|
| 51 |
+
callable" requirement).
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
def __init__(
|
| 55 |
+
self,
|
| 56 |
+
transliteration_engine: TransliterationEngine,
|
| 57 |
+
synthesize_fn: Optional[Callable[[str, str], bytes]] = None,
|
| 58 |
+
):
|
| 59 |
+
self.engine = transliteration_engine
|
| 60 |
+
self.synthesize_fn = synthesize_fn
|
| 61 |
+
|
| 62 |
+
def run(self, text: str, language: Optional[str] = None) -> TTSPipelineResult:
|
| 63 |
+
resolved_language = language or guess_language(text)
|
| 64 |
+
if language is None:
|
| 65 |
+
logger.warning(
|
| 66 |
+
"TTS pipeline: no language given, guessed '%s'. "
|
| 67 |
+
"Pass language= explicitly in production.",
|
| 68 |
+
resolved_language,
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
transliterated = self.engine.transliterate(text, language=resolved_language)
|
| 72 |
+
normalized = normalize_for_tts(transliterated)
|
| 73 |
+
|
| 74 |
+
audio = None
|
| 75 |
+
if self.synthesize_fn is not None:
|
| 76 |
+
audio = self.synthesize_fn(normalized, resolved_language)
|
| 77 |
+
|
| 78 |
+
return TTSPipelineResult(
|
| 79 |
+
original_text=text,
|
| 80 |
+
detected_or_given_language=resolved_language,
|
| 81 |
+
transliterated_text=transliterated,
|
| 82 |
+
normalized_text=normalized,
|
| 83 |
+
audio=audio,
|
| 84 |
+
)
|
requirements.txt
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Pinned to this environment's NVIDIA-container-provided torch (2.8.0 nv build,
|
| 2 |
+
# CUDA-enabled, installed at the system level -- do NOT `pip install torch`,
|
| 3 |
+
# it will try to fetch a PyPI build that conflicts with the container's
|
| 4 |
+
# constraint file at /etc/pip/constraint.txt). Create the venv with
|
| 5 |
+
# `python3 -m venv .venv --system-site-packages` so it inherits system torch.
|
| 6 |
+
transformers==4.57.1
|
| 7 |
+
accelerate==1.14.0
|
| 8 |
+
datasets==4.4.1
|
| 9 |
+
huggingface-hub==0.36.0
|
| 10 |
+
evaluate==0.4.6
|
| 11 |
+
jiwer==4.0.0
|
| 12 |
+
sacrebleu==2.6.0
|
| 13 |
+
editdistance==0.8.1
|
| 14 |
+
PyYAML==6.0.2
|
| 15 |
+
pandas==2.2.3
|
| 16 |
+
tqdm==4.67.1
|
| 17 |
+
scikit-learn==1.6.1
|
| 18 |
+
sentencepiece==0.2.2
|
| 19 |
+
requests==2.32.3
|
| 20 |
+
numpy<2
|
tests/__init__.py
ADDED
|
File without changes
|
tests/test_dataset.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from transliteration.data.dataset import (
|
| 2 |
+
apply_language_sampling,
|
| 3 |
+
language_temperature_weights,
|
| 4 |
+
mix_with_custom_data,
|
| 5 |
+
split_dataset,
|
| 6 |
+
)
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def test_language_temperature_weights_uniform_at_zero_limit():
|
| 10 |
+
counts = {"hi": 1000, "brx": 10}
|
| 11 |
+
weights = language_temperature_weights(counts, temperature=0.3)
|
| 12 |
+
# low temperature should compress the gap heavily vs raw proportional
|
| 13 |
+
proportional = {"hi": 1000 / 1010, "brx": 10 / 1010}
|
| 14 |
+
assert weights["brx"] > proportional["brx"]
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def test_language_temperature_weights_sum_to_one():
|
| 18 |
+
counts = {"hi": 500, "bn": 300, "ta": 10}
|
| 19 |
+
weights = language_temperature_weights(counts, temperature=0.7)
|
| 20 |
+
assert abs(sum(weights.values()) - 1.0) < 1e-9
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_apply_language_sampling_oversamples_low_resource():
|
| 24 |
+
records = [{"language": "hi", "roman": f"r{i}", "target": f"t{i}"} for i in range(900)]
|
| 25 |
+
records += [{"language": "brx", "roman": f"b{i}", "target": f"bt{i}"} for i in range(10)]
|
| 26 |
+
|
| 27 |
+
resampled = apply_language_sampling(records, temperature=0.3, target_total=1000)
|
| 28 |
+
counts = {"hi": 0, "brx": 0}
|
| 29 |
+
for r in resampled:
|
| 30 |
+
counts[r["language"]] += 1
|
| 31 |
+
|
| 32 |
+
natural_brx_share = 10 / 910
|
| 33 |
+
resampled_brx_share = counts["brx"] / len(resampled)
|
| 34 |
+
assert resampled_brx_share > natural_brx_share
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def test_mix_with_custom_data_weight():
|
| 38 |
+
public = [{"language": "hi", "roman": f"r{i}", "target": f"t{i}"} for i in range(800)]
|
| 39 |
+
custom = [{"language": "hi", "roman": "mera emi pending hai", "target": "मेरा ईएमआई पेंडिंग है"}]
|
| 40 |
+
|
| 41 |
+
mixed = mix_with_custom_data(public, custom, custom_data_weight=0.2)
|
| 42 |
+
custom_count = sum(1 for r in mixed if r["roman"] == "mera emi pending hai")
|
| 43 |
+
assert abs(custom_count / len(mixed) - 0.2) < 0.02
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_split_dataset_no_leakage():
|
| 47 |
+
records = []
|
| 48 |
+
for i in range(200):
|
| 49 |
+
records.append({"language": "hi", "roman": f"word{i}", "target": f"t{i}"})
|
| 50 |
+
# add a near-duplicate (same normalized key) that must land in the same split
|
| 51 |
+
records.append({"language": "hi", "roman": "word0", "target": "t0dup"})
|
| 52 |
+
|
| 53 |
+
splits = split_dataset(records, train_frac=0.8, val_frac=0.1, test_frac=0.1)
|
| 54 |
+
train_romans = {r["roman"].lower() for r in splits["train"]}
|
| 55 |
+
test_romans = {r["roman"].lower() for r in splits["test"]}
|
| 56 |
+
val_romans = {r["roman"].lower() for r in splits["validation"]}
|
| 57 |
+
|
| 58 |
+
assert not (train_romans & test_romans)
|
| 59 |
+
assert not (train_romans & val_romans)
|
| 60 |
+
assert not (val_romans & test_romans)
|
| 61 |
+
|
| 62 |
+
total = sum(len(v) for v in splits.values())
|
| 63 |
+
assert total == len(records)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_split_dataset_fractions_must_sum_to_one():
|
| 67 |
+
import pytest
|
| 68 |
+
|
| 69 |
+
with pytest.raises(ValueError):
|
| 70 |
+
split_dataset([], train_frac=0.5, val_frac=0.3, test_frac=0.3)
|
tests/test_inference.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Inference-layer tests against a tiny freshly-trained checkpoint (not the
|
| 2 |
+
real prototype checkpoint, to keep tests fast/hermetic and independent of
|
| 3 |
+
any real training run's output)."""
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from transliteration.inference import TransliterationEngine, guess_language
|
| 8 |
+
from transliteration.model.model import TransliterationConfig, TransliterationModel
|
| 9 |
+
from transliteration.model.tokenizer import CharTransliterationTokenizer
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@pytest.fixture
|
| 13 |
+
def tiny_engine():
|
| 14 |
+
tok = CharTransliterationTokenizer.build_from_corpus(
|
| 15 |
+
["<2hi> namaste hai", "नमस्ते है", "<2bn> ki আছে"]
|
| 16 |
+
)
|
| 17 |
+
config = TransliterationConfig(
|
| 18 |
+
vocab_size=tok.vocab_size,
|
| 19 |
+
d_model=16,
|
| 20 |
+
nhead=2,
|
| 21 |
+
num_encoder_layers=1,
|
| 22 |
+
num_decoder_layers=1,
|
| 23 |
+
dim_feedforward=32,
|
| 24 |
+
max_position_embeddings=64,
|
| 25 |
+
pad_token_id=tok.pad_token_id,
|
| 26 |
+
bos_token_id=tok.bos_token_id,
|
| 27 |
+
eos_token_id=tok.eos_token_id,
|
| 28 |
+
)
|
| 29 |
+
model = TransliterationModel(config)
|
| 30 |
+
model.eval()
|
| 31 |
+
return TransliterationEngine(model, tok, device="cpu")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def test_transliterate_returns_string(tiny_engine):
|
| 35 |
+
out = tiny_engine.transliterate("namaste", language="hi")
|
| 36 |
+
assert isinstance(out, str)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def test_transliterate_rejects_unsupported_language(tiny_engine):
|
| 40 |
+
with pytest.raises(ValueError):
|
| 41 |
+
tiny_engine.transliterate("hello", language="xx")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def test_transliterate_batch_matches_single_calls(tiny_engine):
|
| 45 |
+
texts = ["namaste", "hai"]
|
| 46 |
+
batch_out = tiny_engine.transliterate_batch(texts, language="hi", batch_size=2)
|
| 47 |
+
assert len(batch_out) == 2
|
| 48 |
+
for t in texts:
|
| 49 |
+
assert isinstance(tiny_engine.transliterate(t, language="hi"), str)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_transliterate_batch_mixed_languages(tiny_engine):
|
| 53 |
+
out = tiny_engine.transliterate_batch(
|
| 54 |
+
["namaste", "ki"], languages=["hi", "bn"], batch_size=2
|
| 55 |
+
)
|
| 56 |
+
assert len(out) == 2
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_transliterate_batch_rejects_mismatched_lengths(tiny_engine):
|
| 60 |
+
with pytest.raises(ValueError):
|
| 61 |
+
tiny_engine.transliterate_batch(["a", "b"], languages=["hi"])
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def test_guess_language_defaults_to_hindi_when_no_signal():
|
| 65 |
+
assert guess_language("xyz qwerty") == "hi"
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_guess_language_detects_bengali_hints():
|
| 69 |
+
assert guess_language("amar ki ache") == "bn"
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def test_benchmark_latency_returns_stats(tiny_engine):
|
| 73 |
+
stats = tiny_engine.benchmark_latency(5, language="hi")
|
| 74 |
+
assert stats.n == 5
|
| 75 |
+
assert stats.p50_ms >= 0
|
| 76 |
+
assert stats.throughput_per_sec > 0
|
tests/test_metrics.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from transliteration.training.metrics import (
|
| 2 |
+
char_error_rate,
|
| 3 |
+
exact_match,
|
| 4 |
+
phonetic_normalize,
|
| 5 |
+
script_validity,
|
| 6 |
+
token_accuracy,
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def test_cer_exact_match_is_zero():
|
| 11 |
+
assert char_error_rate("नमस्ते", "नमस्ते") == 0.0
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def test_cer_nonzero_for_mismatch():
|
| 15 |
+
assert char_error_rate("नमसते", "नमस्ते") > 0.0
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def test_exact_match():
|
| 19 |
+
assert exact_match(" नमस्ते ", "नमस्ते") is True
|
| 20 |
+
assert exact_match("नमसते", "नमस्ते") is False
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_token_accuracy_partial():
|
| 24 |
+
acc = token_accuracy("मेरा ईएमआई", "मेरा ईएमआई पेंडिंग है")
|
| 25 |
+
assert 0 < acc < 1
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def test_script_validity_hindi():
|
| 29 |
+
assert script_validity("नमस्ते", "hi") is True
|
| 30 |
+
assert script_validity("vanakkam", "hi") is False
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_phonetic_normalize_collapses_anusvara_variants():
|
| 34 |
+
a = phonetic_normalize("पेमेंट", "hi")
|
| 35 |
+
b = phonetic_normalize("पेमेन्ट", "hi")
|
| 36 |
+
# both should normalize to the same canonical (anusvara) nasal representation
|
| 37 |
+
assert a == b == "पेमेंट"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_phonetic_normalize_does_not_corrupt_ordinary_words():
|
| 41 |
+
# words using े/ो/ी/म as plain vowels/consonants must be left untouched --
|
| 42 |
+
# only word-final nasal+virama-before-consonant should be rewritten.
|
| 43 |
+
for word in ["नमस्ते", "मेरा", "कोरोना", "नमकीन"]:
|
| 44 |
+
assert phonetic_normalize(word, "hi") == word
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_phonetic_normalize_noop_for_unknown_language():
|
| 48 |
+
text = "வணக்கம்"
|
| 49 |
+
assert phonetic_normalize(text, "ta") == text
|
tests/test_model.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
|
| 3 |
+
from transliteration.model.model import TransliterationConfig, TransliterationModel
|
| 4 |
+
from transliteration.model.tokenizer import CharTransliterationTokenizer
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def _make_model_and_tokenizer():
|
| 8 |
+
tok = CharTransliterationTokenizer.build_from_corpus(
|
| 9 |
+
["<2hi> namaste hai", "नमस्ते है", "<2bn> ki আছে"]
|
| 10 |
+
)
|
| 11 |
+
config = TransliterationConfig(
|
| 12 |
+
vocab_size=tok.vocab_size,
|
| 13 |
+
d_model=32,
|
| 14 |
+
nhead=2,
|
| 15 |
+
num_encoder_layers=1,
|
| 16 |
+
num_decoder_layers=1,
|
| 17 |
+
dim_feedforward=64,
|
| 18 |
+
max_position_embeddings=64,
|
| 19 |
+
pad_token_id=tok.pad_token_id,
|
| 20 |
+
bos_token_id=tok.bos_token_id,
|
| 21 |
+
eos_token_id=tok.eos_token_id,
|
| 22 |
+
)
|
| 23 |
+
model = TransliterationModel(config)
|
| 24 |
+
return model, tok
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def test_forward_returns_loss_when_labels_given():
|
| 28 |
+
model, tok = _make_model_and_tokenizer()
|
| 29 |
+
enc = tok(["<2hi> namaste"], return_tensors="pt", padding=True)
|
| 30 |
+
labels = tok(["नमस्ते"], return_tensors="pt", padding=True)["input_ids"]
|
| 31 |
+
out = model(input_ids=enc["input_ids"], attention_mask=enc["attention_mask"], labels=labels)
|
| 32 |
+
assert out.loss is not None
|
| 33 |
+
assert out.loss.item() > 0
|
| 34 |
+
assert out.logits.shape[0] == 1
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def test_generate_produces_valid_token_ids():
|
| 38 |
+
model, tok = _make_model_and_tokenizer()
|
| 39 |
+
enc = tok(["<2hi> namaste"], return_tensors="pt", padding=True)
|
| 40 |
+
out_ids = model.generate(enc["input_ids"], enc["attention_mask"], max_new_tokens=10)
|
| 41 |
+
assert out_ids.shape[0] == 1
|
| 42 |
+
assert out_ids.shape[1] <= 11 # bos + up to 10 generated
|
| 43 |
+
assert (out_ids >= 0).all()
|
| 44 |
+
assert (out_ids < tok.vocab_size).all()
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_generate_batched_stops_on_eos_for_all():
|
| 48 |
+
model, tok = _make_model_and_tokenizer()
|
| 49 |
+
enc = tok(["<2hi> namaste", "<2bn> ki"], return_tensors="pt", padding=True)
|
| 50 |
+
out_ids = model.generate(enc["input_ids"], enc["attention_mask"], max_new_tokens=20)
|
| 51 |
+
assert out_ids.shape[0] == 2
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def test_save_and_load_model_roundtrip(tmp_path):
|
| 55 |
+
model, tok = _make_model_and_tokenizer()
|
| 56 |
+
model.save_pretrained(str(tmp_path))
|
| 57 |
+
loaded_config = TransliterationConfig.from_pretrained(str(tmp_path))
|
| 58 |
+
loaded_model = TransliterationModel.from_pretrained(str(tmp_path), config=loaded_config)
|
| 59 |
+
assert loaded_config.vocab_size == model.config.vocab_size
|
| 60 |
+
|
| 61 |
+
enc = tok(["<2hi> namaste"], return_tensors="pt", padding=True)
|
| 62 |
+
out1 = model.generate(enc["input_ids"], enc["attention_mask"], max_new_tokens=5)
|
| 63 |
+
out2 = loaded_model.generate(enc["input_ids"], enc["attention_mask"], max_new_tokens=5)
|
| 64 |
+
assert torch.equal(out1, out2)
|
tests/test_tokenizer.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from transliteration.model.tokenizer import CharTransliterationTokenizer
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def test_build_from_corpus_includes_language_tags():
|
| 5 |
+
tok = CharTransliterationTokenizer.build_from_corpus(["<2hi> namaste", "नमस्ते"])
|
| 6 |
+
assert "<2hi>" in tok.get_vocab()
|
| 7 |
+
assert "<2bn>" in tok.get_vocab() # all tags always included, even if unseen in corpus
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def test_tokenize_treats_language_tag_as_single_token():
|
| 11 |
+
tok = CharTransliterationTokenizer.build_from_corpus(["<2hi> namaste", "नमस्ते"])
|
| 12 |
+
tokens = tok.tokenize("<2hi> namaste")
|
| 13 |
+
assert tokens[0] == "<2hi>"
|
| 14 |
+
assert tokens[1] == " "
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def test_roundtrip_encode_decode():
|
| 18 |
+
tok = CharTransliterationTokenizer.build_from_corpus(["<2hi> namaste hai", "नमस्ते है"])
|
| 19 |
+
text = "<2hi> namaste hai"
|
| 20 |
+
ids = tok(text)["input_ids"]
|
| 21 |
+
decoded = tok.decode(ids, skip_special_tokens=True)
|
| 22 |
+
assert decoded == text
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_unknown_char_maps_to_unk():
|
| 26 |
+
tok = CharTransliterationTokenizer.build_from_corpus(["abc"])
|
| 27 |
+
ids = tok("xyz123")["input_ids"]
|
| 28 |
+
# every char here is unseen except none of a/b/c -> should map to unk id for unseen ones
|
| 29 |
+
unk_id = tok.unk_token_id
|
| 30 |
+
assert any(i == unk_id for i in ids)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_save_and_load_roundtrip(tmp_path):
|
| 34 |
+
tok = CharTransliterationTokenizer.build_from_corpus(["<2hi> namaste", "नमस्ते"])
|
| 35 |
+
tok.save_pretrained(str(tmp_path))
|
| 36 |
+
loaded = CharTransliterationTokenizer.from_pretrained(str(tmp_path))
|
| 37 |
+
assert loaded.get_vocab() == tok.get_vocab()
|
tests/test_tts_integration.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from transliteration.inference import TransliterationEngine
|
| 2 |
+
from transliteration.model.model import TransliterationConfig, TransliterationModel
|
| 3 |
+
from transliteration.model.tokenizer import CharTransliterationTokenizer
|
| 4 |
+
|
| 5 |
+
from integrations.tts import RomanToTTSPipeline, normalize_for_tts
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _tiny_engine():
|
| 9 |
+
tok = CharTransliterationTokenizer.build_from_corpus(["<2hi> namaste hai", "नमस्ते है"])
|
| 10 |
+
config = TransliterationConfig(
|
| 11 |
+
vocab_size=tok.vocab_size, d_model=16, nhead=2,
|
| 12 |
+
num_encoder_layers=1, num_decoder_layers=1, dim_feedforward=32,
|
| 13 |
+
max_position_embeddings=64, pad_token_id=tok.pad_token_id,
|
| 14 |
+
bos_token_id=tok.bos_token_id, eos_token_id=tok.eos_token_id,
|
| 15 |
+
)
|
| 16 |
+
model = TransliterationModel(config)
|
| 17 |
+
model.eval()
|
| 18 |
+
return TransliterationEngine(model, tok, device="cpu")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def test_normalize_for_tts_collapses_whitespace():
|
| 22 |
+
assert normalize_for_tts(" मेरा ईएमआई ") == "मेरा ईएमआई"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_pipeline_runs_without_synthesize_fn():
|
| 26 |
+
pipeline = RomanToTTSPipeline(_tiny_engine(), synthesize_fn=None)
|
| 27 |
+
result = pipeline.run("namaste", language="hi")
|
| 28 |
+
assert result.audio is None
|
| 29 |
+
assert result.detected_or_given_language == "hi"
|
| 30 |
+
assert isinstance(result.transliterated_text, str)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_pipeline_calls_synthesize_fn_with_normalized_text_and_language():
|
| 34 |
+
captured = {}
|
| 35 |
+
|
| 36 |
+
def fake_synthesize(text, language):
|
| 37 |
+
captured["text"] = text
|
| 38 |
+
captured["language"] = language
|
| 39 |
+
return b"fake-audio-bytes"
|
| 40 |
+
|
| 41 |
+
pipeline = RomanToTTSPipeline(_tiny_engine(), synthesize_fn=fake_synthesize)
|
| 42 |
+
result = pipeline.run("namaste", language="hi")
|
| 43 |
+
|
| 44 |
+
assert result.audio == b"fake-audio-bytes"
|
| 45 |
+
assert captured["language"] == "hi"
|
| 46 |
+
assert captured["text"] == result.normalized_text
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_pipeline_guesses_language_when_not_given():
|
| 50 |
+
pipeline = RomanToTTSPipeline(_tiny_engine(), synthesize_fn=None)
|
| 51 |
+
result = pipeline.run("namaste hai")
|
| 52 |
+
assert result.detected_or_given_language # some language was chosen
|
tests/test_validate.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from transliteration.data.validate import (
|
| 2 |
+
ValidationConfig,
|
| 3 |
+
is_corrupted_pair,
|
| 4 |
+
is_valid_roman,
|
| 5 |
+
is_valid_target_script,
|
| 6 |
+
run_validation,
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def test_valid_pair_accepted():
|
| 11 |
+
records = [{"source": "t", "language": "hi", "roman": "namaste", "target": "नमस्ते"}]
|
| 12 |
+
accepted, stats = run_validation(records)
|
| 13 |
+
assert stats.accepted == 1
|
| 14 |
+
assert accepted[0]["target"] == "नमस्ते"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def test_empty_rejected():
|
| 18 |
+
records = [{"source": "t", "language": "hi", "roman": "", "target": "नमस्ते"}]
|
| 19 |
+
accepted, stats = run_validation(records)
|
| 20 |
+
assert stats.accepted == 0
|
| 21 |
+
assert stats.empty == 1
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_wrong_script_rejected():
|
| 25 |
+
# Tamil target labeled as Hindi -> should be rejected
|
| 26 |
+
records = [{"source": "t", "language": "hi", "roman": "vanakkam", "target": "வணக்கம்"}]
|
| 27 |
+
accepted, stats = run_validation(records)
|
| 28 |
+
assert stats.accepted == 0
|
| 29 |
+
assert stats.bad_target_script == 1
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_non_roman_source_rejected():
|
| 33 |
+
records = [{"source": "t", "language": "hi", "roman": "नमस्ते", "target": "नमस्ते"}]
|
| 34 |
+
accepted, stats = run_validation(records)
|
| 35 |
+
assert stats.accepted == 0
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_duplicate_rejected():
|
| 39 |
+
rec = {"source": "t", "language": "hi", "roman": "namaste", "target": "नमस्ते"}
|
| 40 |
+
accepted, stats = run_validation([rec, dict(rec)])
|
| 41 |
+
assert stats.accepted == 1
|
| 42 |
+
assert stats.duplicate == 1
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_too_long_rejected():
|
| 46 |
+
cfg = ValidationConfig(max_chars=10)
|
| 47 |
+
records = [{"source": "t", "language": "hi", "roman": "a" * 20, "target": "न" * 20}]
|
| 48 |
+
accepted, stats = run_validation(records, cfg)
|
| 49 |
+
assert stats.accepted == 0
|
| 50 |
+
assert stats.too_long == 1
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_corrupted_pair_identical_strings():
|
| 54 |
+
assert is_corrupted_pair("namaste", "namaste") is True
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_is_valid_roman():
|
| 58 |
+
cfg = ValidationConfig()
|
| 59 |
+
assert is_valid_roman("namaste hai", cfg) is True
|
| 60 |
+
assert is_valid_roman("नमस्ते", cfg) is False
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def test_is_valid_target_script():
|
| 64 |
+
cfg = ValidationConfig()
|
| 65 |
+
assert is_valid_target_script("नमस्ते", "hi", cfg) is True
|
| 66 |
+
assert is_valid_target_script("வணக்கம்", "hi", cfg) is False
|
| 67 |
+
assert is_valid_target_script("வணக்கம்", "ta", cfg) is True
|
transliteration/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Multilingual Roman -> Indic script transliteration pipeline.
|
| 2 |
+
|
| 3 |
+
This package implements a trainable, from-scratch character-level
|
| 4 |
+
Transformer seq2seq model for transliterating romanized / Hinglish /
|
| 5 |
+
code-mixed text into native Indic scripts, intended to sit directly in
|
| 6 |
+
front of a TTS pipeline.
|
| 7 |
+
|
| 8 |
+
Architecture note
|
| 9 |
+
------------------
|
| 10 |
+
The original request asked to start from the AI4Bharat "IndicXlit"
|
| 11 |
+
checkpoint/architecture. IndicXlit ships as a fairseq CNN (fully
|
| 12 |
+
convolutional seq2seq) model. fairseq is not maintained for Python 3.12:
|
| 13 |
+
several of its dataclass configs use mutable class instances as field
|
| 14 |
+
defaults (`x: FooConfig = FooConfig()`), a pattern Python's own
|
| 15 |
+
`dataclasses` module started rejecting in 3.11+. Patching one file
|
| 16 |
+
surfaces the same bug in the next (`fairseq/dataclass/configs.py`,
|
| 17 |
+
`fairseq/models/transformer/transformer_config.py`, ... ), so treating it
|
| 18 |
+
as fixable-by-patch was not realistic for a maintained pipeline.
|
| 19 |
+
|
| 20 |
+
Given that, this package uses a Hugging Face `transformers`-based
|
| 21 |
+
character-level Transformer trained from scratch, using the same task
|
| 22 |
+
framing and language-tagging convention (`<2xx>` prefix tokens, as in
|
| 23 |
+
IndicXlit/NLLB/mBART) rather than IndicXlit's literal fairseq checkpoint.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
__version__ = "0.1.0"
|
transliteration/cli.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unified CLI.
|
| 2 |
+
|
| 3 |
+
python -m transliteration.cli train --config configs/train.yaml [--prototype] [--set k=v ...]
|
| 4 |
+
python -m transliteration.cli evaluate --checkpoint models/checkpoints/indicxlit-custom/final
|
| 5 |
+
python -m transliteration.cli infer --checkpoint <dir> --language hi --text "mera emi pending hai"
|
| 6 |
+
python -m transliteration.cli build-data --config configs/train.yaml [--download]
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import argparse
|
| 10 |
+
import json
|
| 11 |
+
import logging
|
| 12 |
+
import sys
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(name)s | %(message)s")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def cmd_build_data(args):
|
| 19 |
+
from transliteration.config import load_config, get
|
| 20 |
+
from transliteration.data.download import download_aksharantar
|
| 21 |
+
from transliteration.data.preprocess import build_processed_corpus
|
| 22 |
+
from transliteration.data.validate import run_validation
|
| 23 |
+
from transliteration.data.dataset import build_and_write_splits, load_jsonl
|
| 24 |
+
|
| 25 |
+
config = load_config(args.config)
|
| 26 |
+
languages = get(config, "data.languages", [])
|
| 27 |
+
|
| 28 |
+
if args.download:
|
| 29 |
+
logging.info("Downloading Aksharantar for %d languages...", len(languages))
|
| 30 |
+
download_aksharantar(languages)
|
| 31 |
+
|
| 32 |
+
processed_path = build_processed_corpus()
|
| 33 |
+
logging.info("Validating...")
|
| 34 |
+
records = load_jsonl(processed_path)
|
| 35 |
+
if args.max_per_language:
|
| 36 |
+
from collections import Counter
|
| 37 |
+
|
| 38 |
+
capped, counts = [], Counter()
|
| 39 |
+
for r in records:
|
| 40 |
+
if counts[r["language"]] < args.max_per_language:
|
| 41 |
+
capped.append(r)
|
| 42 |
+
counts[r["language"]] += 1
|
| 43 |
+
records = capped
|
| 44 |
+
|
| 45 |
+
accepted, stats = run_validation(records)
|
| 46 |
+
logging.info("Validation stats: %s", stats.as_dict())
|
| 47 |
+
|
| 48 |
+
validated_path = Path(get(config, "data.validated_dir", "data/validated")) / "all.jsonl"
|
| 49 |
+
validated_path.parent.mkdir(parents=True, exist_ok=True)
|
| 50 |
+
from transliteration.data.dataset import write_jsonl
|
| 51 |
+
|
| 52 |
+
write_jsonl(accepted, validated_path)
|
| 53 |
+
|
| 54 |
+
split_cfg = config.get("split", {})
|
| 55 |
+
build_and_write_splits(
|
| 56 |
+
accepted,
|
| 57 |
+
train_frac=split_cfg.get("train_frac", 0.90),
|
| 58 |
+
val_frac=split_cfg.get("val_frac", 0.05),
|
| 59 |
+
test_frac=split_cfg.get("test_frac", 0.05),
|
| 60 |
+
seed=split_cfg.get("seed", 13),
|
| 61 |
+
)
|
| 62 |
+
logging.info("Data pipeline complete.")
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def cmd_train(args):
|
| 66 |
+
from transliteration.config import load_config, apply_overrides
|
| 67 |
+
from transliteration.training.train import run_training
|
| 68 |
+
|
| 69 |
+
config = load_config(args.config)
|
| 70 |
+
if args.set:
|
| 71 |
+
apply_overrides(config, args.set)
|
| 72 |
+
|
| 73 |
+
checkpoint_path = run_training(config, prototype=args.prototype)
|
| 74 |
+
print(f"Training complete. Final checkpoint: {checkpoint_path}")
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def cmd_evaluate(args):
|
| 78 |
+
from transliteration.training.evaluate import run_evaluation
|
| 79 |
+
|
| 80 |
+
result = run_evaluation(
|
| 81 |
+
checkpoint_dir=Path(args.checkpoint),
|
| 82 |
+
test_path=Path(args.test_data),
|
| 83 |
+
results_dir=Path(args.results_dir),
|
| 84 |
+
batch_size=args.batch_size,
|
| 85 |
+
max_examples_per_language=args.max_per_language,
|
| 86 |
+
)
|
| 87 |
+
print(json.dumps(result["overall"], ensure_ascii=False, indent=2))
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def cmd_infer(args):
|
| 91 |
+
from transliteration.inference import TransliterationEngine
|
| 92 |
+
|
| 93 |
+
engine = TransliterationEngine.from_checkpoint(args.checkpoint)
|
| 94 |
+
output = engine.transliterate(args.text, language=args.language)
|
| 95 |
+
print(output)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def build_parser():
|
| 99 |
+
parser = argparse.ArgumentParser(prog="transliteration")
|
| 100 |
+
sub = parser.add_subparsers(dest="command", required=True)
|
| 101 |
+
|
| 102 |
+
p_build = sub.add_parser("build-data", help="Download/preprocess/validate/split data")
|
| 103 |
+
p_build.add_argument("--config", default="configs/train.yaml")
|
| 104 |
+
p_build.add_argument("--download", action="store_true", help="Download Aksharantar first")
|
| 105 |
+
p_build.add_argument("--max-per-language", type=int, default=None)
|
| 106 |
+
p_build.set_defaults(func=cmd_build_data)
|
| 107 |
+
|
| 108 |
+
p_train = sub.add_parser("train")
|
| 109 |
+
p_train.add_argument("--config", default="configs/train.yaml")
|
| 110 |
+
p_train.add_argument("--prototype", action="store_true", help="Small smoke-test run")
|
| 111 |
+
p_train.add_argument("--set", nargs="*", default=[], help="Override config: key.path=value")
|
| 112 |
+
p_train.set_defaults(func=cmd_train)
|
| 113 |
+
|
| 114 |
+
p_eval = sub.add_parser("evaluate")
|
| 115 |
+
p_eval.add_argument("--checkpoint", required=True)
|
| 116 |
+
p_eval.add_argument("--test-data", default="data/test/data.jsonl")
|
| 117 |
+
p_eval.add_argument("--results-dir", default="results")
|
| 118 |
+
p_eval.add_argument("--batch-size", type=int, default=64)
|
| 119 |
+
p_eval.add_argument("--max-per-language", type=int, default=None)
|
| 120 |
+
p_eval.set_defaults(func=cmd_evaluate)
|
| 121 |
+
|
| 122 |
+
p_infer = sub.add_parser("infer")
|
| 123 |
+
p_infer.add_argument("--checkpoint", required=True)
|
| 124 |
+
p_infer.add_argument("--language", default=None)
|
| 125 |
+
p_infer.add_argument("--text", required=True)
|
| 126 |
+
p_infer.set_defaults(func=cmd_infer)
|
| 127 |
+
|
| 128 |
+
return parser
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def main():
|
| 132 |
+
parser = build_parser()
|
| 133 |
+
args = parser.parse_args()
|
| 134 |
+
args.func(args)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
if __name__ == "__main__":
|
| 138 |
+
main()
|
transliteration/config.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""YAML config loading with dotted-path CLI overrides (--set a.b.c=value)."""
|
| 2 |
+
|
| 3 |
+
import ast
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Any, Dict, List
|
| 6 |
+
|
| 7 |
+
import yaml
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def load_config(path: str) -> Dict[str, Any]:
|
| 11 |
+
with open(path, encoding="utf-8") as f:
|
| 12 |
+
return yaml.safe_load(f)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _parse_value(raw: str) -> Any:
|
| 16 |
+
try:
|
| 17 |
+
return ast.literal_eval(raw)
|
| 18 |
+
except (ValueError, SyntaxError):
|
| 19 |
+
return raw
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def apply_overrides(config: Dict[str, Any], overrides: List[str]) -> Dict[str, Any]:
|
| 23 |
+
"""Apply `--set key.path=value` overrides in place-ish (returns config)."""
|
| 24 |
+
for item in overrides:
|
| 25 |
+
if "=" not in item:
|
| 26 |
+
raise ValueError(f"Invalid override '{item}', expected key.path=value")
|
| 27 |
+
key_path, raw_value = item.split("=", 1)
|
| 28 |
+
value = _parse_value(raw_value)
|
| 29 |
+
keys = key_path.split(".")
|
| 30 |
+
node = config
|
| 31 |
+
for k in keys[:-1]:
|
| 32 |
+
node = node.setdefault(k, {})
|
| 33 |
+
node[keys[-1]] = value
|
| 34 |
+
return config
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def get(config: Dict[str, Any], dotted_key: str, default: Any = None) -> Any:
|
| 38 |
+
node = config
|
| 39 |
+
for k in dotted_key.split("."):
|
| 40 |
+
if not isinstance(node, dict) or k not in node:
|
| 41 |
+
return default
|
| 42 |
+
node = node[k]
|
| 43 |
+
return node
|
transliteration/data/__init__.py
ADDED
|
File without changes
|
transliteration/data/dataset.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dataset assembly: preprocessing, language-temperature sampling, custom-data
|
| 2 |
+
mixing, and leakage-safe train/validation/test splitting.
|
| 3 |
+
|
| 4 |
+
Pipeline: data/validated (list of dict records) -> this module -> data/train,
|
| 5 |
+
data/validation, data/test (JSONL, one record per line).
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import hashlib
|
| 9 |
+
import json
|
| 10 |
+
import logging
|
| 11 |
+
import random
|
| 12 |
+
from collections import defaultdict
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from typing import Dict, Iterable, List, Optional
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
DATA_DIR = Path(__file__).resolve().parents[2] / "data"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def load_jsonl(path: Path) -> List[dict]:
|
| 22 |
+
records = []
|
| 23 |
+
with Path(path).open(encoding="utf-8") as f:
|
| 24 |
+
for line in f:
|
| 25 |
+
line = line.strip()
|
| 26 |
+
if line:
|
| 27 |
+
records.append(json.loads(line))
|
| 28 |
+
return records
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def write_jsonl(records: Iterable[dict], path: Path) -> None:
|
| 32 |
+
path = Path(path)
|
| 33 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 34 |
+
with path.open("w", encoding="utf-8") as f:
|
| 35 |
+
for rec in records:
|
| 36 |
+
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def load_custom_csv(path: Path) -> List[dict]:
|
| 40 |
+
"""Load the customer-support CSV format: input,target,language (header row)."""
|
| 41 |
+
import csv
|
| 42 |
+
|
| 43 |
+
records = []
|
| 44 |
+
with Path(path).open(encoding="utf-8", newline="") as f:
|
| 45 |
+
reader = csv.DictReader(f)
|
| 46 |
+
for row in reader:
|
| 47 |
+
roman = (row.get("input") or "").strip()
|
| 48 |
+
target = (row.get("target") or "").strip()
|
| 49 |
+
lang = (row.get("language") or "").strip()
|
| 50 |
+
if not roman or not target or not lang:
|
| 51 |
+
continue
|
| 52 |
+
records.append(
|
| 53 |
+
{
|
| 54 |
+
"source": "customer_support",
|
| 55 |
+
"language": lang,
|
| 56 |
+
"roman": roman,
|
| 57 |
+
"target": target,
|
| 58 |
+
}
|
| 59 |
+
)
|
| 60 |
+
return records
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def language_temperature_weights(
|
| 64 |
+
lang_counts: Dict[str, int], temperature: float = 1.0
|
| 65 |
+
) -> Dict[str, float]:
|
| 66 |
+
"""Temperature-scaled sampling weights per language.
|
| 67 |
+
|
| 68 |
+
temperature = 1.0 -> proportional to natural frequency (no rebalancing)
|
| 69 |
+
temperature -> 0 -> uniform across languages regardless of size
|
| 70 |
+
(heavily oversamples low-resource languages)
|
| 71 |
+
Formula: w_l ∝ count_l^T, matching the standard multilingual NMT
|
| 72 |
+
temperature-sampling recipe (e.g. used in mBART/M2M-100 data mixing):
|
| 73 |
+
T=1 reduces to proportional sampling, T→0 flattens all languages
|
| 74 |
+
toward equal weight since count_l^0 == 1 for every language.
|
| 75 |
+
"""
|
| 76 |
+
if temperature <= 0:
|
| 77 |
+
raise ValueError("temperature must be > 0")
|
| 78 |
+
scaled = {lang: count ** temperature for lang, count in lang_counts.items()}
|
| 79 |
+
total = sum(scaled.values())
|
| 80 |
+
return {lang: w / total for lang, w in scaled.items()}
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def apply_language_sampling(
|
| 84 |
+
records: List[dict],
|
| 85 |
+
temperature: float = 1.0,
|
| 86 |
+
target_total: Optional[int] = None,
|
| 87 |
+
seed: int = 13,
|
| 88 |
+
) -> List[dict]:
|
| 89 |
+
"""Resample `records` so each language's share matches temperature-scaled
|
| 90 |
+
weights, drawing with replacement when a language needs oversampling.
|
| 91 |
+
"""
|
| 92 |
+
rng = random.Random(seed)
|
| 93 |
+
by_lang: Dict[str, List[dict]] = defaultdict(list)
|
| 94 |
+
for rec in records:
|
| 95 |
+
by_lang[rec["language"]].append(rec)
|
| 96 |
+
|
| 97 |
+
counts = {lang: len(v) for lang, v in by_lang.items()}
|
| 98 |
+
weights = language_temperature_weights(counts, temperature)
|
| 99 |
+
total = target_total or len(records)
|
| 100 |
+
|
| 101 |
+
out = []
|
| 102 |
+
for lang, weight in weights.items():
|
| 103 |
+
n = round(total * weight)
|
| 104 |
+
pool = by_lang[lang]
|
| 105 |
+
if n <= len(pool):
|
| 106 |
+
out.extend(rng.sample(pool, n))
|
| 107 |
+
else:
|
| 108 |
+
out.extend(pool)
|
| 109 |
+
out.extend(rng.choices(pool, k=n - len(pool)))
|
| 110 |
+
rng.shuffle(out)
|
| 111 |
+
return out
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def mix_with_custom_data(
|
| 115 |
+
public_records: List[dict],
|
| 116 |
+
custom_records: List[dict],
|
| 117 |
+
custom_data_weight: float = 0.2,
|
| 118 |
+
seed: int = 13,
|
| 119 |
+
) -> List[dict]:
|
| 120 |
+
"""Mix custom (customer-support) data into the public corpus so that
|
| 121 |
+
custom data makes up `custom_data_weight` fraction of the final set,
|
| 122 |
+
oversampling the (typically much smaller) custom set with replacement.
|
| 123 |
+
"""
|
| 124 |
+
if not custom_records:
|
| 125 |
+
return list(public_records)
|
| 126 |
+
if not (0.0 <= custom_data_weight < 1.0):
|
| 127 |
+
raise ValueError("custom_data_weight must be in [0, 1)")
|
| 128 |
+
|
| 129 |
+
rng = random.Random(seed)
|
| 130 |
+
n_public = len(public_records)
|
| 131 |
+
# solve for n_custom such that n_custom / (n_public + n_custom) == weight
|
| 132 |
+
n_custom = round((custom_data_weight * n_public) / (1 - custom_data_weight))
|
| 133 |
+
if n_custom <= len(custom_records):
|
| 134 |
+
sampled_custom = rng.sample(custom_records, n_custom)
|
| 135 |
+
else:
|
| 136 |
+
sampled_custom = list(custom_records) + rng.choices(
|
| 137 |
+
custom_records, k=n_custom - len(custom_records)
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
mixed = list(public_records) + sampled_custom
|
| 141 |
+
rng.shuffle(mixed)
|
| 142 |
+
return mixed
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _dedup_key_for_leakage(roman: str) -> str:
|
| 146 |
+
"""Normalized key used to prevent the same (near-)sentence from
|
| 147 |
+
appearing in both train and test. Case/space-insensitive; a hash of
|
| 148 |
+
the aggressively-normalized string keeps the leakage-check set memory
|
| 149 |
+
bounded for large corpora.
|
| 150 |
+
"""
|
| 151 |
+
norm = "".join(roman.lower().split())
|
| 152 |
+
return hashlib.sha1(norm.encode("utf-8")).hexdigest()
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def split_dataset(
|
| 156 |
+
records: List[dict],
|
| 157 |
+
train_frac: float = 0.90,
|
| 158 |
+
val_frac: float = 0.05,
|
| 159 |
+
test_frac: float = 0.05,
|
| 160 |
+
seed: int = 13,
|
| 161 |
+
) -> Dict[str, List[dict]]:
|
| 162 |
+
"""Split by unique (leakage-safe) roman-text key, not by raw record, so
|
| 163 |
+
near-duplicate roman sentences can't straddle train/test. Stratified
|
| 164 |
+
per-language so every split gets proportional language coverage.
|
| 165 |
+
"""
|
| 166 |
+
if abs((train_frac + val_frac + test_frac) - 1.0) > 1e-6:
|
| 167 |
+
raise ValueError("train_frac + val_frac + test_frac must sum to 1.0")
|
| 168 |
+
|
| 169 |
+
rng = random.Random(seed)
|
| 170 |
+
by_lang: Dict[str, List[dict]] = defaultdict(list)
|
| 171 |
+
for rec in records:
|
| 172 |
+
by_lang[rec["language"]].append(rec)
|
| 173 |
+
|
| 174 |
+
splits = {"train": [], "validation": [], "test": []}
|
| 175 |
+
|
| 176 |
+
for lang, lang_records in by_lang.items():
|
| 177 |
+
groups: Dict[str, List[dict]] = defaultdict(list)
|
| 178 |
+
for rec in lang_records:
|
| 179 |
+
groups[_dedup_key_for_leakage(rec["roman"])].append(rec)
|
| 180 |
+
|
| 181 |
+
keys = list(groups.keys())
|
| 182 |
+
rng.shuffle(keys)
|
| 183 |
+
|
| 184 |
+
n = len(keys)
|
| 185 |
+
n_train = int(n * train_frac)
|
| 186 |
+
n_val = int(n * val_frac)
|
| 187 |
+
|
| 188 |
+
train_keys = set(keys[:n_train])
|
| 189 |
+
val_keys = set(keys[n_train : n_train + n_val])
|
| 190 |
+
test_keys = set(keys[n_train + n_val :])
|
| 191 |
+
|
| 192 |
+
for key in train_keys:
|
| 193 |
+
splits["train"].extend(groups[key])
|
| 194 |
+
for key in val_keys:
|
| 195 |
+
splits["validation"].extend(groups[key])
|
| 196 |
+
for key in test_keys:
|
| 197 |
+
splits["test"].extend(groups[key])
|
| 198 |
+
|
| 199 |
+
for name in splits:
|
| 200 |
+
rng.shuffle(splits[name])
|
| 201 |
+
return splits
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def build_and_write_splits(
|
| 205 |
+
records: List[dict],
|
| 206 |
+
out_dir: Path = DATA_DIR,
|
| 207 |
+
train_frac: float = 0.90,
|
| 208 |
+
val_frac: float = 0.05,
|
| 209 |
+
test_frac: float = 0.05,
|
| 210 |
+
seed: int = 13,
|
| 211 |
+
) -> Dict[str, Path]:
|
| 212 |
+
splits = split_dataset(records, train_frac, val_frac, test_frac, seed)
|
| 213 |
+
paths = {}
|
| 214 |
+
for name, split_records in splits.items():
|
| 215 |
+
dir_name = "validation" if name == "validation" else name
|
| 216 |
+
path = out_dir / dir_name / "data.jsonl"
|
| 217 |
+
write_jsonl(split_records, path)
|
| 218 |
+
paths[name] = path
|
| 219 |
+
logger.info("%s: %d examples -> %s", name, len(split_records), path)
|
| 220 |
+
return paths
|
transliteration/data/download.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Download public transliteration datasets into data/raw/ (left untouched afterwards).
|
| 2 |
+
|
| 3 |
+
Sources
|
| 4 |
+
-------
|
| 5 |
+
1. AI4Bharat Aksharantar (Hugging Face: `ai4bharat/Aksharantar`)
|
| 6 |
+
- License: CC0 (public domain) per the dataset card / Aksharantar paper.
|
| 7 |
+
- NOT loadable via `datasets.load_dataset("ai4bharat/Aksharantar")` as a
|
| 8 |
+
single call: the repo ships one zip per language
|
| 9 |
+
(`<iso3>.zip` -> `<iso3>_{train,valid,test}.json`, JSON-Lines despite the
|
| 10 |
+
`.json` extension), and some language files have an extra `score` column
|
| 11 |
+
the `datasets` Arrow schema inference chokes on
|
| 12 |
+
(`DatasetGenerationCastError: ... new columns ({'score'})`) when you try
|
| 13 |
+
to load the whole repo as one config. Verified concretely: 21 zip files
|
| 14 |
+
present (all languages except `si`/Sinhala, which Aksharantar does not
|
| 15 |
+
cover -- expected, since it is not one of the 22 scheduled languages),
|
| 16 |
+
696MB total. This module downloads and parses the per-language zips
|
| 17 |
+
directly via `huggingface_hub.hf_hub_download` instead of `load_dataset`.
|
| 18 |
+
- Row format: {"unique_identifier", "native word", "english word",
|
| 19 |
+
"source", "score"} (score present but often null; ignored here).
|
| 20 |
+
|
| 21 |
+
2. Google Dakshina
|
| 22 |
+
- NOT on Hugging Face. Hosted directly by Google Research at
|
| 23 |
+
https://storage.googleapis.com/gresearch/dakshina/dakshina_dataset_v1.0.tar
|
| 24 |
+
(~1.5GB tar, apache-2.0 licensed). Covers 12 South Asian languages,
|
| 25 |
+
word-level *and* full-sentence romanization pairs (the sentence-level
|
| 26 |
+
data is the more valuable half for a TTS-facing model, since it has
|
| 27 |
+
natural code-switching/context Aksharantar's word list lacks).
|
| 28 |
+
|
| 29 |
+
Both loaders here are deliberately conservative: no silent fallback to a
|
| 30 |
+
fabricated/placeholder dataset. If a source is unreachable or gated, the
|
| 31 |
+
loader raises with a clear message instead of inventing data -- per the
|
| 32 |
+
"do not fake results" requirement.
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
import io
|
| 36 |
+
import json
|
| 37 |
+
import logging
|
| 38 |
+
import os
|
| 39 |
+
import tarfile
|
| 40 |
+
import zipfile
|
| 41 |
+
from pathlib import Path
|
| 42 |
+
from typing import Iterator, List, Optional
|
| 43 |
+
|
| 44 |
+
import requests
|
| 45 |
+
|
| 46 |
+
logger = logging.getLogger(__name__)
|
| 47 |
+
|
| 48 |
+
RAW_DIR = Path(__file__).resolve().parents[2] / "data" / "raw"
|
| 49 |
+
|
| 50 |
+
# Aksharantar repo uses ISO 639-2/3-style 3-letter codes for zip filenames;
|
| 51 |
+
# map to our registry's codes (transliteration.languages). `si` (Sinhala) is
|
| 52 |
+
# intentionally absent: Aksharantar does not include it.
|
| 53 |
+
AKSHARANTAR_LANG_MAP = {
|
| 54 |
+
"asm": "as", "ben": "bn", "brx": "brx", "doi": "doi", "guj": "gu",
|
| 55 |
+
"hin": "hi", "kan": "kn", "kas": "ks", "kok": "kok", "mai": "mai",
|
| 56 |
+
"mal": "ml", "mar": "mr", "mni": "mni", "nep": "ne", "ori": "or",
|
| 57 |
+
"pan": "pa", "san": "sa", "sid": "sd", "tam": "ta", "tel": "te",
|
| 58 |
+
"urd": "ur",
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
DAKSHINA_URL = (
|
| 62 |
+
"https://storage.googleapis.com/gresearch/dakshina/dakshina_dataset_v1.0.tar"
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
# Dakshina language folder names -> our ISO codes (only languages we support
|
| 66 |
+
# are mapped; Dakshina also has non-Indic languages like Persian/Urdu overlap).
|
| 67 |
+
DAKSHINA_LANG_MAP = {
|
| 68 |
+
"bn": "bn",
|
| 69 |
+
"gu": "gu",
|
| 70 |
+
"hi": "hi",
|
| 71 |
+
"kn": "kn",
|
| 72 |
+
"ml": "ml",
|
| 73 |
+
"mr": "mr",
|
| 74 |
+
"pa": "pa",
|
| 75 |
+
"sd": "sd",
|
| 76 |
+
"si": "si",
|
| 77 |
+
"ta": "ta",
|
| 78 |
+
"te": "te",
|
| 79 |
+
"ur": "ur",
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def download_aksharantar(language_codes: List[str], raw_dir: Path = RAW_DIR) -> dict:
|
| 84 |
+
"""Download+parse Aksharantar zips for the given languages (our ISO codes).
|
| 85 |
+
|
| 86 |
+
Fetches each language's zip via `huggingface_hub.hf_hub_download` (no
|
| 87 |
+
`datasets` schema-unification involved, sidestepping the cast error
|
| 88 |
+
documented in the module docstring), extracts the *_train/valid/test.json
|
| 89 |
+
files, and writes a single normalized JSONL per language to
|
| 90 |
+
data/raw/aksharantar_<code>.jsonl.
|
| 91 |
+
|
| 92 |
+
Returns {lang_code: path_or_None} -- None means that language has no
|
| 93 |
+
Aksharantar coverage (only `si`/Sinhala, currently) or the download
|
| 94 |
+
failed; failures are logged, never silently faked.
|
| 95 |
+
"""
|
| 96 |
+
from huggingface_hub import hf_hub_download
|
| 97 |
+
|
| 98 |
+
inv_map = {v: k for k, v in AKSHARANTAR_LANG_MAP.items()}
|
| 99 |
+
raw_dir.mkdir(parents=True, exist_ok=True)
|
| 100 |
+
out_paths = {}
|
| 101 |
+
|
| 102 |
+
for code in language_codes:
|
| 103 |
+
iso3 = inv_map.get(code)
|
| 104 |
+
if iso3 is None:
|
| 105 |
+
logger.warning(
|
| 106 |
+
"Aksharantar: language '%s' has no coverage in this dataset.", code
|
| 107 |
+
)
|
| 108 |
+
out_paths[code] = None
|
| 109 |
+
continue
|
| 110 |
+
|
| 111 |
+
dest = raw_dir / f"aksharantar_{code}.jsonl"
|
| 112 |
+
if dest.exists():
|
| 113 |
+
out_paths[code] = dest
|
| 114 |
+
continue
|
| 115 |
+
|
| 116 |
+
try:
|
| 117 |
+
zip_path = hf_hub_download(
|
| 118 |
+
repo_id="ai4bharat/Aksharantar",
|
| 119 |
+
filename=f"{iso3}.zip",
|
| 120 |
+
repo_type="dataset",
|
| 121 |
+
)
|
| 122 |
+
except Exception as e: # noqa: BLE001 - report, don't fabricate
|
| 123 |
+
logger.warning("Aksharantar: could not download '%s': %s", iso3, e)
|
| 124 |
+
out_paths[code] = None
|
| 125 |
+
continue
|
| 126 |
+
|
| 127 |
+
n_written = 0
|
| 128 |
+
with dest.open("w", encoding="utf-8") as out_f, zipfile.ZipFile(zip_path) as zf:
|
| 129 |
+
# Most languages' zips have the json files at the archive root,
|
| 130 |
+
# but at least one (doi.zip) nests them under a `<iso3>/` dir --
|
| 131 |
+
# match on basename rather than assuming a fixed path.
|
| 132 |
+
by_basename = {Path(n).name: n for n in zf.namelist()}
|
| 133 |
+
for split in ("train", "valid", "test"):
|
| 134 |
+
basename = f"{iso3}_{split}.json"
|
| 135 |
+
member = by_basename.get(basename)
|
| 136 |
+
if member is None:
|
| 137 |
+
continue
|
| 138 |
+
with zf.open(member) as member_f:
|
| 139 |
+
for line in io.TextIOWrapper(member_f, encoding="utf-8"):
|
| 140 |
+
line = line.strip()
|
| 141 |
+
if not line:
|
| 142 |
+
continue
|
| 143 |
+
row = json.loads(line)
|
| 144 |
+
rec = _normalize_aksharantar_row(row, code, split)
|
| 145 |
+
if rec is not None:
|
| 146 |
+
out_f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
| 147 |
+
n_written += 1
|
| 148 |
+
|
| 149 |
+
out_paths[code] = dest
|
| 150 |
+
logger.info("Aksharantar[%s] -> %s (%d pairs)", code, dest, n_written)
|
| 151 |
+
|
| 152 |
+
return out_paths
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _normalize_aksharantar_row(row: dict, code: str, split: str) -> Optional[dict]:
|
| 156 |
+
roman = row.get("english word") or row.get("english_word") or row.get("roman")
|
| 157 |
+
target = row.get("native word") or row.get("native_word") or row.get("target")
|
| 158 |
+
if not roman or not target:
|
| 159 |
+
return None
|
| 160 |
+
orig_source = row.get("source") or "unknown"
|
| 161 |
+
return {
|
| 162 |
+
"source": f"aksharantar:{split}:{orig_source}",
|
| 163 |
+
"language": code,
|
| 164 |
+
"roman": str(roman),
|
| 165 |
+
"target": str(target),
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def download_dakshina(raw_dir: Path = RAW_DIR, timeout: int = 120) -> Optional[Path]:
|
| 170 |
+
"""Download and extract the Dakshina tarball. Returns the extraction dir, or
|
| 171 |
+
None if the download failed (network-restricted sandboxes will hit this --
|
| 172 |
+
caller/CLI should surface that clearly rather than pretending it worked).
|
| 173 |
+
"""
|
| 174 |
+
raw_dir.mkdir(parents=True, exist_ok=True)
|
| 175 |
+
extract_dir = raw_dir / "dakshina_dataset_v1.0"
|
| 176 |
+
if extract_dir.exists():
|
| 177 |
+
return extract_dir
|
| 178 |
+
|
| 179 |
+
try:
|
| 180 |
+
resp = requests.get(DAKSHINA_URL, stream=True, timeout=timeout)
|
| 181 |
+
resp.raise_for_status()
|
| 182 |
+
except Exception as e: # noqa: BLE001
|
| 183 |
+
logger.warning("Dakshina download failed (%s). Skipping this source.", e)
|
| 184 |
+
return None
|
| 185 |
+
|
| 186 |
+
tar_bytes = io.BytesIO()
|
| 187 |
+
for chunk in resp.iter_content(chunk_size=1 << 20):
|
| 188 |
+
tar_bytes.write(chunk)
|
| 189 |
+
tar_bytes.seek(0)
|
| 190 |
+
|
| 191 |
+
with tarfile.open(fileobj=tar_bytes) as tf:
|
| 192 |
+
tf.extractall(raw_dir) # noqa: S202 - trusted first-party Google URL
|
| 193 |
+
|
| 194 |
+
return extract_dir if extract_dir.exists() else None
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def iter_dakshina_pairs(extract_dir: Path) -> Iterator[dict]:
|
| 198 |
+
"""Yield normalized pairs from an extracted Dakshina tree.
|
| 199 |
+
|
| 200 |
+
Dakshina layout: <lang>/romanized/<lang>.romanized.rejoined.aligned.cased_nopunct.tsv
|
| 201 |
+
(word-level, tab-separated: native<TAB>romanized<TAB>attestations) and
|
| 202 |
+
<lang>/sentence_test.tsv / sentence_train.tsv / sentence_dev.tsv (sentence-level).
|
| 203 |
+
"""
|
| 204 |
+
for dakshina_code, our_code in DAKSHINA_LANG_MAP.items():
|
| 205 |
+
lang_dir = extract_dir / dakshina_code
|
| 206 |
+
if not lang_dir.exists():
|
| 207 |
+
continue
|
| 208 |
+
|
| 209 |
+
romanized_dir = lang_dir / "romanized"
|
| 210 |
+
if romanized_dir.exists():
|
| 211 |
+
for tsv_path in romanized_dir.glob("*.tsv"):
|
| 212 |
+
yield from _iter_dakshina_tsv(tsv_path, our_code, "dakshina:word")
|
| 213 |
+
|
| 214 |
+
for tsv_path in lang_dir.glob("*sentence*.tsv"):
|
| 215 |
+
yield from _iter_dakshina_tsv(tsv_path, our_code, "dakshina:sentence")
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def _iter_dakshina_tsv(path: Path, lang_code: str, source_tag: str) -> Iterator[dict]:
|
| 219 |
+
with path.open(encoding="utf-8") as f:
|
| 220 |
+
for line in f:
|
| 221 |
+
parts = line.rstrip("\n").split("\t")
|
| 222 |
+
if len(parts) < 2:
|
| 223 |
+
continue
|
| 224 |
+
native, roman = parts[0], parts[1]
|
| 225 |
+
if not native or not roman:
|
| 226 |
+
continue
|
| 227 |
+
yield {
|
| 228 |
+
"source": source_tag,
|
| 229 |
+
"language": lang_code,
|
| 230 |
+
"roman": roman,
|
| 231 |
+
"target": native,
|
| 232 |
+
}
|
transliteration/data/preprocess.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Glue stage: data/raw -> normalized common-format records -> data/processed.
|
| 2 |
+
|
| 3 |
+
Converts each raw source's native format into the common schema:
|
| 4 |
+
{"source": ..., "language": ..., "roman": ..., "target": ...}
|
| 5 |
+
without deduplication/validation (that happens in `validate.py`, the next
|
| 6 |
+
pipeline stage) -- this stage is purely about format normalization so
|
| 7 |
+
each source's quirks are handled in one place.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
import logging
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Iterator, List
|
| 14 |
+
|
| 15 |
+
from transliteration.data.download import RAW_DIR, iter_dakshina_pairs
|
| 16 |
+
from transliteration.languages import SUPPORTED_LANGUAGE_CODES
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
PROCESSED_DIR = Path(__file__).resolve().parents[2] / "data" / "processed"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def iter_aksharantar_raw(raw_dir: Path = RAW_DIR) -> Iterator[dict]:
|
| 24 |
+
for path in sorted(raw_dir.glob("aksharantar_*.jsonl")):
|
| 25 |
+
with path.open(encoding="utf-8") as f:
|
| 26 |
+
for line in f:
|
| 27 |
+
line = line.strip()
|
| 28 |
+
if not line:
|
| 29 |
+
continue
|
| 30 |
+
rec = json.loads(line)
|
| 31 |
+
if rec.get("language") in SUPPORTED_LANGUAGE_CODES:
|
| 32 |
+
yield rec
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def iter_dakshina_raw(raw_dir: Path = RAW_DIR) -> Iterator[dict]:
|
| 36 |
+
extract_dir = raw_dir / "dakshina_dataset_v1.0"
|
| 37 |
+
if not extract_dir.exists():
|
| 38 |
+
return
|
| 39 |
+
for rec in iter_dakshina_pairs(extract_dir):
|
| 40 |
+
if rec.get("language") in SUPPORTED_LANGUAGE_CODES:
|
| 41 |
+
yield rec
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def build_processed_corpus(raw_dir: Path = RAW_DIR, out_dir: Path = PROCESSED_DIR) -> Path:
|
| 45 |
+
"""Concatenate all recognized raw sources into data/processed/all.jsonl.
|
| 46 |
+
|
| 47 |
+
Deliberately does NOT deduplicate or validate -- see module docstring.
|
| 48 |
+
Each record retains its `source` field so later stages (and error
|
| 49 |
+
analysis) can always trace a training example back to its origin.
|
| 50 |
+
"""
|
| 51 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 52 |
+
out_path = out_dir / "all.jsonl"
|
| 53 |
+
|
| 54 |
+
n_written = 0
|
| 55 |
+
with out_path.open("w", encoding="utf-8") as f:
|
| 56 |
+
for rec in iter_aksharantar_raw(raw_dir):
|
| 57 |
+
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
| 58 |
+
n_written += 1
|
| 59 |
+
for rec in iter_dakshina_raw(raw_dir):
|
| 60 |
+
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
| 61 |
+
n_written += 1
|
| 62 |
+
|
| 63 |
+
logger.info("Wrote %d raw pairs -> %s", n_written, out_path)
|
| 64 |
+
return out_path
|
transliteration/data/validate.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Validation + normalization for (roman, target, language) transliteration pairs.
|
| 2 |
+
|
| 3 |
+
Pipeline stage: data/processed -> [this module] -> data/validated
|
| 4 |
+
|
| 5 |
+
Checks implemented (per project requirements):
|
| 6 |
+
- Unicode normalization (NFC)
|
| 7 |
+
- whitespace normalization
|
| 8 |
+
- empty sample removal
|
| 9 |
+
- invalid Unicode / control-character removal
|
| 10 |
+
- Roman-script validation (source must be mostly ASCII/Latin)
|
| 11 |
+
- target-script validation (target must be mostly in the expected Unicode block)
|
| 12 |
+
- extremely long sample filtering
|
| 13 |
+
- duplicate / near-duplicate removal (exact, case+space-insensitive)
|
| 14 |
+
- corrupted-pair detection (e.g. target == roman, target empty, digit-only mismatch)
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import re
|
| 18 |
+
import unicodedata
|
| 19 |
+
from dataclasses import dataclass
|
| 20 |
+
from typing import Iterable, Iterator, Optional
|
| 21 |
+
|
| 22 |
+
from transliteration.languages import get_language
|
| 23 |
+
|
| 24 |
+
_CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
|
| 25 |
+
_WHITESPACE_RE = re.compile(r"\s+")
|
| 26 |
+
_LATIN_RE = re.compile(r"[A-Za-z]")
|
| 27 |
+
_ROMAN_ALLOWED_RE = re.compile(r"^[A-Za-z0-9\s.,!?'\"\-:;()/%]+$")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass
|
| 31 |
+
class ValidationConfig:
|
| 32 |
+
min_roman_latin_ratio: float = 0.6 # fraction of alpha chars that must be A-Z/a-z
|
| 33 |
+
min_target_script_ratio: float = 0.5 # fraction of alpha chars in expected script
|
| 34 |
+
max_chars: int = 256 # reject extremely long samples
|
| 35 |
+
min_chars: int = 1
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@dataclass
|
| 39 |
+
class RejectionStats:
|
| 40 |
+
total: int = 0
|
| 41 |
+
empty: int = 0
|
| 42 |
+
invalid_unicode: int = 0
|
| 43 |
+
too_long: int = 0
|
| 44 |
+
bad_roman_script: int = 0
|
| 45 |
+
bad_target_script: int = 0
|
| 46 |
+
corrupted_pair: int = 0
|
| 47 |
+
duplicate: int = 0
|
| 48 |
+
accepted: int = 0
|
| 49 |
+
|
| 50 |
+
def as_dict(self) -> dict:
|
| 51 |
+
return self.__dict__.copy()
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def normalize_whitespace(text: str) -> str:
|
| 55 |
+
return _WHITESPACE_RE.sub(" ", text).strip()
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def normalize_unicode(text: str) -> str:
|
| 59 |
+
text = unicodedata.normalize("NFC", text)
|
| 60 |
+
text = _CONTROL_CHAR_RE.sub("", text)
|
| 61 |
+
return text
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def normalize_pair(roman: str, target: str) -> tuple:
|
| 65 |
+
roman = normalize_whitespace(normalize_unicode(roman))
|
| 66 |
+
target = normalize_whitespace(normalize_unicode(target))
|
| 67 |
+
return roman, target
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _script_ratio(text: str, contains_fn) -> Optional[float]:
|
| 71 |
+
"""Fraction of alphabetic-ish characters that belong to the expected script.
|
| 72 |
+
|
| 73 |
+
Returns None if there are no alphabetic characters to judge (e.g. a
|
| 74 |
+
pair consisting solely of digits/punctuation) -- caller decides how
|
| 75 |
+
to treat that.
|
| 76 |
+
"""
|
| 77 |
+
alpha_chars = [c for c in text if c.isalpha()]
|
| 78 |
+
if not alpha_chars:
|
| 79 |
+
return None
|
| 80 |
+
matches = sum(1 for c in alpha_chars if contains_fn(c))
|
| 81 |
+
return matches / len(alpha_chars)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def is_valid_roman(text: str, cfg: ValidationConfig) -> bool:
|
| 85 |
+
ratio = _script_ratio(text, lambda c: bool(_LATIN_RE.match(c)))
|
| 86 |
+
if ratio is None:
|
| 87 |
+
return False # no latin letters at all -> not romanized text
|
| 88 |
+
return ratio >= cfg.min_roman_latin_ratio
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def is_valid_target_script(text: str, language_code: str, cfg: ValidationConfig) -> bool:
|
| 92 |
+
lang = get_language(language_code)
|
| 93 |
+
ratio = _script_ratio(text, lang.contains_script_char)
|
| 94 |
+
if ratio is None:
|
| 95 |
+
return False
|
| 96 |
+
return ratio >= cfg.min_target_script_ratio
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def is_corrupted_pair(roman: str, target: str) -> bool:
|
| 100 |
+
if not roman or not target:
|
| 101 |
+
return True
|
| 102 |
+
if roman.strip().lower() == target.strip().lower():
|
| 103 |
+
return True # target identical to source -> not transliterated
|
| 104 |
+
# target that is purely digits/punctuation carries no script signal;
|
| 105 |
+
# only flag as corrupted if source had alphabetic content to transliterate
|
| 106 |
+
if not any(c.isalpha() for c in target) and any(c.isalpha() for c in roman):
|
| 107 |
+
return True
|
| 108 |
+
return False
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def has_invalid_unicode(text: str) -> bool:
|
| 112 |
+
try:
|
| 113 |
+
text.encode("utf-8").decode("utf-8")
|
| 114 |
+
except UnicodeError:
|
| 115 |
+
return True
|
| 116 |
+
# replacement character indicates upstream mangled encoding
|
| 117 |
+
return "�" in text
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def validate_pairs(
|
| 121 |
+
pairs: Iterable[dict],
|
| 122 |
+
cfg: Optional[ValidationConfig] = None,
|
| 123 |
+
) -> Iterator[dict]:
|
| 124 |
+
"""Validate an iterable of {"roman","target","language","source"} dicts.
|
| 125 |
+
|
| 126 |
+
Yields only accepted, normalized records. Mutates nothing in-place.
|
| 127 |
+
Call `validate_pairs.stats` after exhausting the iterator (see
|
| 128 |
+
`run_validation` below for a convenience wrapper that also returns
|
| 129 |
+
stats cleanly without relying on generator side effects).
|
| 130 |
+
"""
|
| 131 |
+
cfg = cfg or ValidationConfig()
|
| 132 |
+
seen = set()
|
| 133 |
+
for rec in pairs:
|
| 134 |
+
roman, target = normalize_pair(rec["roman"], rec["target"])
|
| 135 |
+
lang = rec["language"]
|
| 136 |
+
|
| 137 |
+
if not roman or not target:
|
| 138 |
+
continue
|
| 139 |
+
if len(roman) < cfg.min_chars or len(target) < cfg.min_chars:
|
| 140 |
+
continue
|
| 141 |
+
if len(roman) > cfg.max_chars or len(target) > cfg.max_chars:
|
| 142 |
+
continue
|
| 143 |
+
if has_invalid_unicode(roman) or has_invalid_unicode(target):
|
| 144 |
+
continue
|
| 145 |
+
if not is_valid_roman(roman, cfg):
|
| 146 |
+
continue
|
| 147 |
+
if not is_valid_target_script(target, lang, cfg):
|
| 148 |
+
continue
|
| 149 |
+
if is_corrupted_pair(roman, target):
|
| 150 |
+
continue
|
| 151 |
+
|
| 152 |
+
dedup_key = (lang, roman.lower().strip(), target.strip())
|
| 153 |
+
if dedup_key in seen:
|
| 154 |
+
continue
|
| 155 |
+
seen.add(dedup_key)
|
| 156 |
+
|
| 157 |
+
out = dict(rec)
|
| 158 |
+
out["roman"] = roman
|
| 159 |
+
out["target"] = target
|
| 160 |
+
yield out
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def run_validation(pairs: Iterable[dict], cfg: Optional[ValidationConfig] = None):
|
| 164 |
+
"""Non-generator wrapper: returns (accepted_list, RejectionStats)."""
|
| 165 |
+
cfg = cfg or ValidationConfig()
|
| 166 |
+
stats = RejectionStats()
|
| 167 |
+
seen = set()
|
| 168 |
+
accepted = []
|
| 169 |
+
|
| 170 |
+
for rec in pairs:
|
| 171 |
+
stats.total += 1
|
| 172 |
+
roman, target = normalize_pair(rec["roman"], rec["target"])
|
| 173 |
+
lang = rec["language"]
|
| 174 |
+
|
| 175 |
+
if not roman or not target:
|
| 176 |
+
stats.empty += 1
|
| 177 |
+
continue
|
| 178 |
+
if len(roman) < cfg.min_chars or len(target) < cfg.min_chars:
|
| 179 |
+
stats.empty += 1
|
| 180 |
+
continue
|
| 181 |
+
if len(roman) > cfg.max_chars or len(target) > cfg.max_chars:
|
| 182 |
+
stats.too_long += 1
|
| 183 |
+
continue
|
| 184 |
+
if has_invalid_unicode(roman) or has_invalid_unicode(target):
|
| 185 |
+
stats.invalid_unicode += 1
|
| 186 |
+
continue
|
| 187 |
+
if not is_valid_roman(roman, cfg):
|
| 188 |
+
stats.bad_roman_script += 1
|
| 189 |
+
continue
|
| 190 |
+
if not is_valid_target_script(target, lang, cfg):
|
| 191 |
+
stats.bad_target_script += 1
|
| 192 |
+
continue
|
| 193 |
+
if is_corrupted_pair(roman, target):
|
| 194 |
+
stats.corrupted_pair += 1
|
| 195 |
+
continue
|
| 196 |
+
|
| 197 |
+
dedup_key = (lang, roman.lower().strip(), target.strip())
|
| 198 |
+
if dedup_key in seen:
|
| 199 |
+
stats.duplicate += 1
|
| 200 |
+
continue
|
| 201 |
+
seen.add(dedup_key)
|
| 202 |
+
|
| 203 |
+
out = dict(rec)
|
| 204 |
+
out["roman"] = roman
|
| 205 |
+
out["target"] = target
|
| 206 |
+
accepted.append(out)
|
| 207 |
+
stats.accepted += 1
|
| 208 |
+
|
| 209 |
+
return accepted, stats
|
transliteration/evaluate.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Thin shim so `python -m transliteration.evaluate --checkpoint ...` works as specified.
|
| 2 |
+
Delegates to transliteration.cli's evaluate subcommand."""
|
| 3 |
+
|
| 4 |
+
import sys
|
| 5 |
+
|
| 6 |
+
from transliteration.cli import build_parser
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def main():
|
| 10 |
+
parser = build_parser()
|
| 11 |
+
args = parser.parse_args(["evaluate", *sys.argv[1:]])
|
| 12 |
+
args.func(args)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
if __name__ == "__main__":
|
| 16 |
+
main()
|
transliteration/infer.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Thin shim so `python -m transliteration.infer --language hi --text "..."` works as specified.
|
| 2 |
+
Delegates to transliteration.cli's infer subcommand. Checkpoint defaults to
|
| 3 |
+
models/checkpoints/indicxlit-custom/final if --checkpoint is omitted."""
|
| 4 |
+
|
| 5 |
+
import sys
|
| 6 |
+
|
| 7 |
+
from transliteration.cli import build_parser
|
| 8 |
+
|
| 9 |
+
DEFAULT_CHECKPOINT = "models/checkpoints/indicxlit-custom/final"
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def main():
|
| 13 |
+
argv = list(sys.argv[1:])
|
| 14 |
+
if "--checkpoint" not in argv:
|
| 15 |
+
argv = ["--checkpoint", DEFAULT_CHECKPOINT] + argv
|
| 16 |
+
parser = build_parser()
|
| 17 |
+
args = parser.parse_args(["infer", *argv])
|
| 18 |
+
args.func(args)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
if __name__ == "__main__":
|
| 22 |
+
main()
|
transliteration/inference.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Inference API: the module the TTS pipeline actually calls.
|
| 2 |
+
|
| 3 |
+
from transliteration.inference import TransliterationEngine
|
| 4 |
+
engine = TransliterationEngine.from_checkpoint("models/checkpoints/indicxlit-custom/final")
|
| 5 |
+
engine.transliterate("mera emi pending hai", language="hi")
|
| 6 |
+
-> "मेरा ईएमआई पेंडिंग है"
|
| 7 |
+
|
| 8 |
+
Design notes:
|
| 9 |
+
- Initialize the engine once and reuse it for many calls (loading the
|
| 10 |
+
checkpoint is the expensive part) -- mirrors the guidance in
|
| 11 |
+
IndicXlit's own README about not re-initializing per request.
|
| 12 |
+
- `language=None` triggers a lightweight heuristic language guess
|
| 13 |
+
(see `guess_language`); explicit language is preferred in production
|
| 14 |
+
per the project requirement, and this function logs a warning when it
|
| 15 |
+
has to guess so silent misdetection doesn't go unnoticed.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
import logging
|
| 19 |
+
import time
|
| 20 |
+
from dataclasses import dataclass
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
from typing import List, Optional
|
| 23 |
+
|
| 24 |
+
import torch
|
| 25 |
+
|
| 26 |
+
from transliteration.languages import LANGUAGES, get_language, language_tag
|
| 27 |
+
from transliteration.model.model import TransliterationConfig, TransliterationModel
|
| 28 |
+
from transliteration.model.tokenizer import CharTransliterationTokenizer
|
| 29 |
+
|
| 30 |
+
logger = logging.getLogger(__name__)
|
| 31 |
+
|
| 32 |
+
# Extremely small keyword-based language guesser: a real system would use
|
| 33 |
+
# a proper language-ID classifier (e.g. trained on Aksharantar's roman
|
| 34 |
+
# side with language labels), but that is a separate model outside this
|
| 35 |
+
# task's scope. This heuristic exists only so `transliterate(text)` without
|
| 36 |
+
# an explicit language doesn't hard-fail; production callers should pass
|
| 37 |
+
# `language=` explicitly, and this function always logs when it guesses.
|
| 38 |
+
_HINGLISH_HINT_WORDS = {
|
| 39 |
+
"hi": {"hai", "ka", "ki", "ke", "mera", "aap", "kab", "kya", "nahi", "karo"},
|
| 40 |
+
"bn": {"ache", "korben", "amar", "ki", "korbo"},
|
| 41 |
+
"ta": {"irukku", "enakku", "pannunga", "eppo"},
|
| 42 |
+
"te": {"undi", "chestaru", "naa"},
|
| 43 |
+
"kn": {"ide", "nanna"},
|
| 44 |
+
"ml": {"aanu", "enikku"},
|
| 45 |
+
"mr": {"aahe", "maza"},
|
| 46 |
+
"gu": {"che", "mara"},
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def guess_language(text: str, default: str = "hi") -> str:
|
| 51 |
+
words = set(text.lower().split())
|
| 52 |
+
scores = {lang: len(words & hints) for lang, hints in _HINGLISH_HINT_WORDS.items()}
|
| 53 |
+
best = max(scores, key=scores.get)
|
| 54 |
+
if scores[best] == 0:
|
| 55 |
+
return default
|
| 56 |
+
return best
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@dataclass
|
| 60 |
+
class LatencyStats:
|
| 61 |
+
n: int
|
| 62 |
+
p50_ms: float
|
| 63 |
+
p95_ms: float
|
| 64 |
+
p99_ms: float
|
| 65 |
+
throughput_per_sec: float
|
| 66 |
+
gpu_memory_mb: Optional[float]
|
| 67 |
+
cpu_memory_mb: Optional[float]
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class TransliterationEngine:
|
| 71 |
+
def __init__(self, model, tokenizer, device: str):
|
| 72 |
+
self.model = model
|
| 73 |
+
self.tokenizer = tokenizer
|
| 74 |
+
self.device = device
|
| 75 |
+
|
| 76 |
+
@classmethod
|
| 77 |
+
def from_checkpoint(cls, checkpoint_dir: str, device: Optional[str] = None):
|
| 78 |
+
checkpoint_dir = Path(checkpoint_dir)
|
| 79 |
+
device = device or ("cuda" if torch.cuda.is_available() else "cpu")
|
| 80 |
+
tokenizer = CharTransliterationTokenizer.from_pretrained(str(checkpoint_dir / "tokenizer"))
|
| 81 |
+
config = TransliterationConfig.from_pretrained(str(checkpoint_dir / "model"))
|
| 82 |
+
model = TransliterationModel.from_pretrained(str(checkpoint_dir / "model"), config=config)
|
| 83 |
+
model.to(device)
|
| 84 |
+
model.eval()
|
| 85 |
+
return cls(model, tokenizer, device)
|
| 86 |
+
|
| 87 |
+
def transliterate(
|
| 88 |
+
self,
|
| 89 |
+
text: str,
|
| 90 |
+
language: Optional[str] = None,
|
| 91 |
+
max_new_tokens: int = 128,
|
| 92 |
+
) -> str:
|
| 93 |
+
if language is None:
|
| 94 |
+
language = guess_language(text)
|
| 95 |
+
logger.warning(
|
| 96 |
+
"No language specified; guessed '%s'. Pass language= explicitly in production.",
|
| 97 |
+
language,
|
| 98 |
+
)
|
| 99 |
+
get_language(language) # raises ValueError with a clear message if unsupported
|
| 100 |
+
|
| 101 |
+
tag = language_tag(language)
|
| 102 |
+
enc = self.tokenizer(f"{tag} {text}", return_tensors="pt").to(self.device)
|
| 103 |
+
with torch.inference_mode():
|
| 104 |
+
out_ids = self.model.generate(
|
| 105 |
+
enc["input_ids"], enc["attention_mask"], max_new_tokens=max_new_tokens
|
| 106 |
+
)
|
| 107 |
+
return self.tokenizer.decode(out_ids[0].tolist(), skip_special_tokens=True)
|
| 108 |
+
|
| 109 |
+
def transliterate_batch(
|
| 110 |
+
self,
|
| 111 |
+
texts: List[str],
|
| 112 |
+
language: Optional[str] = None,
|
| 113 |
+
languages: Optional[List[str]] = None,
|
| 114 |
+
batch_size: int = 32,
|
| 115 |
+
max_new_tokens: int = 128,
|
| 116 |
+
use_fp16: bool = True,
|
| 117 |
+
) -> List[str]:
|
| 118 |
+
"""Efficient batched inference.
|
| 119 |
+
|
| 120 |
+
Either pass a single `language` applied to all texts, or a
|
| 121 |
+
parallel `languages` list (one code per text) for mixed-language
|
| 122 |
+
batches. Uses `torch.inference_mode()` and, on CUDA, autocast to
|
| 123 |
+
fp16/bf16 for throughput.
|
| 124 |
+
"""
|
| 125 |
+
if languages is None:
|
| 126 |
+
if language is None:
|
| 127 |
+
languages = [guess_language(t) for t in texts]
|
| 128 |
+
else:
|
| 129 |
+
languages = [language] * len(texts)
|
| 130 |
+
elif len(languages) != len(texts):
|
| 131 |
+
raise ValueError("languages must be the same length as texts")
|
| 132 |
+
|
| 133 |
+
for lang in set(languages):
|
| 134 |
+
get_language(lang)
|
| 135 |
+
|
| 136 |
+
results: List[str] = [None] * len(texts) # type: ignore[list-item]
|
| 137 |
+
autocast_dtype = torch.float16 if (use_fp16 and self.device == "cuda") else None
|
| 138 |
+
|
| 139 |
+
for start in range(0, len(texts), batch_size):
|
| 140 |
+
end = start + batch_size
|
| 141 |
+
batch_texts = texts[start:end]
|
| 142 |
+
batch_langs = languages[start:end]
|
| 143 |
+
tagged = [f"{language_tag(l)} {t}" for t, l in zip(batch_texts, batch_langs)]
|
| 144 |
+
enc = self.tokenizer(tagged, return_tensors="pt", padding=True).to(self.device)
|
| 145 |
+
|
| 146 |
+
with torch.inference_mode():
|
| 147 |
+
if autocast_dtype is not None:
|
| 148 |
+
with torch.autocast(device_type="cuda", dtype=autocast_dtype):
|
| 149 |
+
out_ids = self.model.generate(
|
| 150 |
+
enc["input_ids"], enc["attention_mask"], max_new_tokens=max_new_tokens
|
| 151 |
+
)
|
| 152 |
+
else:
|
| 153 |
+
out_ids = self.model.generate(
|
| 154 |
+
enc["input_ids"], enc["attention_mask"], max_new_tokens=max_new_tokens
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
for i, row in enumerate(out_ids.tolist()):
|
| 158 |
+
results[start + i] = self.tokenizer.decode(row, skip_special_tokens=True)
|
| 159 |
+
|
| 160 |
+
return results
|
| 161 |
+
|
| 162 |
+
def benchmark_latency(self, n_sentences: int, language: str = "hi") -> LatencyStats:
|
| 163 |
+
"""Benchmark transliterate_batch for n_sentences and report p50/p95/p99
|
| 164 |
+
latency (per-call, ms), throughput, and memory. Uses a fixed
|
| 165 |
+
representative sentence repeated n_sentences times -- this measures
|
| 166 |
+
model/runtime latency, not data-dependent variance."""
|
| 167 |
+
import statistics
|
| 168 |
+
|
| 169 |
+
sample_text = "mera emi pending hai aap kab payment karoge"
|
| 170 |
+
texts = [sample_text] * n_sentences
|
| 171 |
+
|
| 172 |
+
if self.device == "cuda":
|
| 173 |
+
torch.cuda.reset_peak_memory_stats()
|
| 174 |
+
torch.cuda.synchronize()
|
| 175 |
+
|
| 176 |
+
latencies = []
|
| 177 |
+
start_all = time.perf_counter()
|
| 178 |
+
for t in texts:
|
| 179 |
+
t0 = time.perf_counter()
|
| 180 |
+
self.transliterate(t, language=language)
|
| 181 |
+
latencies.append((time.perf_counter() - t0) * 1000)
|
| 182 |
+
total_time = time.perf_counter() - start_all
|
| 183 |
+
|
| 184 |
+
latencies.sort()
|
| 185 |
+
|
| 186 |
+
def pct(p):
|
| 187 |
+
idx = min(len(latencies) - 1, int(len(latencies) * p))
|
| 188 |
+
return latencies[idx]
|
| 189 |
+
|
| 190 |
+
gpu_mb = None
|
| 191 |
+
if self.device == "cuda":
|
| 192 |
+
torch.cuda.synchronize()
|
| 193 |
+
gpu_mb = torch.cuda.max_memory_allocated() / (1024 * 1024)
|
| 194 |
+
|
| 195 |
+
cpu_mb = None
|
| 196 |
+
try:
|
| 197 |
+
import resource
|
| 198 |
+
|
| 199 |
+
cpu_mb = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
|
| 200 |
+
except Exception:
|
| 201 |
+
pass
|
| 202 |
+
|
| 203 |
+
return LatencyStats(
|
| 204 |
+
n=n_sentences,
|
| 205 |
+
p50_ms=pct(0.50),
|
| 206 |
+
p95_ms=pct(0.95),
|
| 207 |
+
p99_ms=pct(0.99),
|
| 208 |
+
throughput_per_sec=n_sentences / total_time if total_time > 0 else float("inf"),
|
| 209 |
+
gpu_memory_mb=gpu_mb,
|
| 210 |
+
cpu_memory_mb=cpu_mb,
|
| 211 |
+
)
|
transliteration/languages.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Language registry: the single source of truth for supported languages.
|
| 2 |
+
|
| 3 |
+
Adding a new language later means adding one entry here (code, name,
|
| 4 |
+
Unicode script block for target-script validation, and language tag) --
|
| 5 |
+
no architecture changes required, per the design requirement that
|
| 6 |
+
languages can be added without touching the model/pipeline code.
|
| 7 |
+
|
| 8 |
+
Unicode ranges are used by `transliteration.data.validate` to check that
|
| 9 |
+
a "target" string actually belongs to the expected script. Ranges are
|
| 10 |
+
deliberately generous (whole Unicode block) rather than exact-alphabet,
|
| 11 |
+
since validation only needs to catch gross mismatches (e.g. Bengali
|
| 12 |
+
target text for a Hindi pair), not do full grammar checking.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from dataclasses import dataclass
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass(frozen=True)
|
| 19 |
+
class LanguageInfo:
|
| 20 |
+
code: str # ISO 639 code used throughout the pipeline
|
| 21 |
+
name: str
|
| 22 |
+
script_name: str
|
| 23 |
+
unicode_ranges: tuple # tuple of (start, end) inclusive codepoint ranges
|
| 24 |
+
tag: str # language tag prepended to source text, e.g. "<2hi>"
|
| 25 |
+
|
| 26 |
+
def contains_script_char(self, ch: str) -> bool:
|
| 27 |
+
cp = ord(ch)
|
| 28 |
+
return any(start <= cp <= end for start, end in self.unicode_ranges)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# fmt: off
|
| 32 |
+
LANGUAGES = {
|
| 33 |
+
"as": LanguageInfo("as", "Assamese", "Bengali-Assamese", ((0x0980, 0x09FF),), "<2as>"),
|
| 34 |
+
"bn": LanguageInfo("bn", "Bengali", "Bengali", ((0x0980, 0x09FF),), "<2bn>"),
|
| 35 |
+
"brx": LanguageInfo("brx", "Bodo", "Devanagari", ((0x0900, 0x097F),), "<2brx>"),
|
| 36 |
+
"doi": LanguageInfo("doi", "Dogri", "Devanagari", ((0x0900, 0x097F),), "<2doi>"),
|
| 37 |
+
"gu": LanguageInfo("gu", "Gujarati", "Gujarati", ((0x0A80, 0x0AFF),), "<2gu>"),
|
| 38 |
+
"hi": LanguageInfo("hi", "Hindi", "Devanagari", ((0x0900, 0x097F),), "<2hi>"),
|
| 39 |
+
"kn": LanguageInfo("kn", "Kannada", "Kannada", ((0x0C80, 0x0CFF),), "<2kn>"),
|
| 40 |
+
"ks": LanguageInfo("ks", "Kashmiri", "Perso-Arabic", ((0x0600, 0x06FF), (0x0750, 0x077F)), "<2ks>"),
|
| 41 |
+
"kok": LanguageInfo("kok", "Konkani", "Devanagari", ((0x0900, 0x097F),), "<2kok>"),
|
| 42 |
+
"mai": LanguageInfo("mai", "Maithili", "Devanagari", ((0x0900, 0x097F),), "<2mai>"),
|
| 43 |
+
"ml": LanguageInfo("ml", "Malayalam", "Malayalam", ((0x0D00, 0x0D7F),), "<2ml>"),
|
| 44 |
+
"mni": LanguageInfo("mni", "Manipuri", "Bengali/Meetei", ((0x0980, 0x09FF), (0xABC0, 0xABFF)), "<2mni>"),
|
| 45 |
+
"mr": LanguageInfo("mr", "Marathi", "Devanagari", ((0x0900, 0x097F),), "<2mr>"),
|
| 46 |
+
"ne": LanguageInfo("ne", "Nepali", "Devanagari", ((0x0900, 0x097F),), "<2ne>"),
|
| 47 |
+
"or": LanguageInfo("or", "Odia", "Odia", ((0x0B00, 0x0B7F),), "<2or>"),
|
| 48 |
+
"pa": LanguageInfo("pa", "Punjabi", "Gurmukhi", ((0x0A00, 0x0A7F),), "<2pa>"),
|
| 49 |
+
"sa": LanguageInfo("sa", "Sanskrit", "Devanagari", ((0x0900, 0x097F),), "<2sa>"),
|
| 50 |
+
"sd": LanguageInfo("sd", "Sindhi", "Perso-Arabic", ((0x0600, 0x06FF), (0x0750, 0x077F)), "<2sd>"),
|
| 51 |
+
"ta": LanguageInfo("ta", "Tamil", "Tamil", ((0x0B80, 0x0BFF),), "<2ta>"),
|
| 52 |
+
"te": LanguageInfo("te", "Telugu", "Telugu", ((0x0C00, 0x0C7F),), "<2te>"),
|
| 53 |
+
"ur": LanguageInfo("ur", "Urdu", "Perso-Arabic", ((0x0600, 0x06FF), (0x0750, 0x077F)), "<2ur>"),
|
| 54 |
+
"si": LanguageInfo("si", "Sinhala", "Sinhala", ((0x0D80, 0x0DFF),), "<2si>"),
|
| 55 |
+
}
|
| 56 |
+
# fmt: on
|
| 57 |
+
|
| 58 |
+
SUPPORTED_LANGUAGE_CODES = tuple(LANGUAGES.keys())
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def get_language(code: str) -> LanguageInfo:
|
| 62 |
+
try:
|
| 63 |
+
return LANGUAGES[code]
|
| 64 |
+
except KeyError as e:
|
| 65 |
+
raise ValueError(
|
| 66 |
+
f"Unsupported language code '{code}'. Supported: {SUPPORTED_LANGUAGE_CODES}"
|
| 67 |
+
) from e
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def language_tag(code: str) -> str:
|
| 71 |
+
return get_language(code).tag
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
ALL_TAGS = [info.tag for info in LANGUAGES.values()]
|
transliteration/model/__init__.py
ADDED
|
File without changes
|
transliteration/model/model.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Compact character-level Transformer seq2seq model for transliteration.
|
| 2 |
+
|
| 3 |
+
Built on `transformers`' generic encoder-decoder machinery
|
| 4 |
+
(`EncoderDecoderConfig` + two `BartConfig`-style stacks would pull in far
|
| 5 |
+
more than needed for a char-level model, so we instead define a small
|
| 6 |
+
dedicated Transformer via `transformers.models.marian`'s underlying
|
| 7 |
+
architecture family is also overkill). To keep this maintainable and
|
| 8 |
+
avoid depending on any single seq2seq model family's quirks, the model
|
| 9 |
+
is a small hand-defined Transformer encoder-decoder using
|
| 10 |
+
`torch.nn.Transformer` primitives, wrapped to satisfy the
|
| 11 |
+
`transformers.PreTrainedModel` / `generate()` contract so it drops into
|
| 12 |
+
the standard `Trainer`, `Seq2SeqTrainer`, and `model.generate(...)` APIs
|
| 13 |
+
unmodified.
|
| 14 |
+
|
| 15 |
+
Default size (~10-15M params) is intentionally small: this model sits
|
| 16 |
+
directly in front of TTS, so inference latency matters more than
|
| 17 |
+
squeezing out the last bit of accuracy. Depth/width are config-driven
|
| 18 |
+
(`TransliterationConfig`) so it can be scaled up later without code
|
| 19 |
+
changes.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
import math
|
| 23 |
+
from typing import Optional
|
| 24 |
+
|
| 25 |
+
import torch
|
| 26 |
+
from torch import nn
|
| 27 |
+
from transformers import PretrainedConfig, PreTrainedModel
|
| 28 |
+
from transformers.modeling_outputs import Seq2SeqLMOutput
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class TransliterationConfig(PretrainedConfig):
|
| 32 |
+
model_type = "char_transliteration"
|
| 33 |
+
|
| 34 |
+
def __init__(
|
| 35 |
+
self,
|
| 36 |
+
vocab_size: int = 512,
|
| 37 |
+
d_model: int = 256,
|
| 38 |
+
nhead: int = 4,
|
| 39 |
+
num_encoder_layers: int = 4,
|
| 40 |
+
num_decoder_layers: int = 4,
|
| 41 |
+
dim_feedforward: int = 1024,
|
| 42 |
+
dropout: float = 0.1,
|
| 43 |
+
max_position_embeddings: int = 300,
|
| 44 |
+
pad_token_id: int = 0,
|
| 45 |
+
bos_token_id: int = 1,
|
| 46 |
+
eos_token_id: int = 2,
|
| 47 |
+
**kwargs,
|
| 48 |
+
):
|
| 49 |
+
super().__init__(
|
| 50 |
+
pad_token_id=pad_token_id,
|
| 51 |
+
bos_token_id=bos_token_id,
|
| 52 |
+
eos_token_id=eos_token_id,
|
| 53 |
+
**kwargs,
|
| 54 |
+
)
|
| 55 |
+
self.vocab_size = vocab_size
|
| 56 |
+
self.d_model = d_model
|
| 57 |
+
self.nhead = nhead
|
| 58 |
+
self.num_encoder_layers = num_encoder_layers
|
| 59 |
+
self.num_decoder_layers = num_decoder_layers
|
| 60 |
+
self.dim_feedforward = dim_feedforward
|
| 61 |
+
self.dropout = dropout
|
| 62 |
+
self.max_position_embeddings = max_position_embeddings
|
| 63 |
+
self.is_encoder_decoder = True
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class _PositionalEncoding(nn.Module):
|
| 67 |
+
def __init__(self, d_model: int, max_len: int, dropout: float):
|
| 68 |
+
super().__init__()
|
| 69 |
+
self.dropout = nn.Dropout(dropout)
|
| 70 |
+
pe = torch.zeros(max_len, d_model)
|
| 71 |
+
position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
|
| 72 |
+
div_term = torch.exp(
|
| 73 |
+
torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)
|
| 74 |
+
)
|
| 75 |
+
pe[:, 0::2] = torch.sin(position * div_term)
|
| 76 |
+
pe[:, 1::2] = torch.cos(position * div_term)
|
| 77 |
+
self.register_buffer("pe", pe.unsqueeze(0), persistent=False)
|
| 78 |
+
|
| 79 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 80 |
+
x = x + self.pe[:, : x.size(1)]
|
| 81 |
+
return self.dropout(x)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
class TransliterationModel(PreTrainedModel):
|
| 85 |
+
config_class = TransliterationConfig
|
| 86 |
+
base_model_prefix = "char_transliteration"
|
| 87 |
+
|
| 88 |
+
def __init__(self, config: TransliterationConfig):
|
| 89 |
+
super().__init__(config)
|
| 90 |
+
self.config = config
|
| 91 |
+
|
| 92 |
+
self.embed = nn.Embedding(config.vocab_size, config.d_model, padding_idx=config.pad_token_id)
|
| 93 |
+
self.pos_enc = _PositionalEncoding(
|
| 94 |
+
config.d_model, config.max_position_embeddings, config.dropout
|
| 95 |
+
)
|
| 96 |
+
self.transformer = nn.Transformer(
|
| 97 |
+
d_model=config.d_model,
|
| 98 |
+
nhead=config.nhead,
|
| 99 |
+
num_encoder_layers=config.num_encoder_layers,
|
| 100 |
+
num_decoder_layers=config.num_decoder_layers,
|
| 101 |
+
dim_feedforward=config.dim_feedforward,
|
| 102 |
+
dropout=config.dropout,
|
| 103 |
+
batch_first=True,
|
| 104 |
+
)
|
| 105 |
+
self.output_proj = nn.Linear(config.d_model, config.vocab_size)
|
| 106 |
+
self.post_init()
|
| 107 |
+
|
| 108 |
+
def _init_weights(self, module):
|
| 109 |
+
if isinstance(module, nn.Linear):
|
| 110 |
+
module.weight.data.normal_(mean=0.0, std=0.02)
|
| 111 |
+
if module.bias is not None:
|
| 112 |
+
module.bias.data.zero_()
|
| 113 |
+
elif isinstance(module, nn.Embedding):
|
| 114 |
+
module.weight.data.normal_(mean=0.0, std=0.02)
|
| 115 |
+
if module.padding_idx is not None:
|
| 116 |
+
module.weight.data[module.padding_idx].zero_()
|
| 117 |
+
|
| 118 |
+
def get_input_embeddings(self):
|
| 119 |
+
return self.embed
|
| 120 |
+
|
| 121 |
+
def set_input_embeddings(self, value):
|
| 122 |
+
self.embed = value
|
| 123 |
+
|
| 124 |
+
def _shift_right(self, input_ids: torch.Tensor) -> torch.Tensor:
|
| 125 |
+
shifted = input_ids.new_zeros(input_ids.shape)
|
| 126 |
+
shifted[:, 1:] = input_ids[:, :-1].clone()
|
| 127 |
+
shifted[:, 0] = self.config.bos_token_id
|
| 128 |
+
shifted.masked_fill_(shifted == -100, self.config.pad_token_id)
|
| 129 |
+
return shifted
|
| 130 |
+
|
| 131 |
+
def forward(
|
| 132 |
+
self,
|
| 133 |
+
input_ids: torch.Tensor,
|
| 134 |
+
attention_mask: Optional[torch.Tensor] = None,
|
| 135 |
+
decoder_input_ids: Optional[torch.Tensor] = None,
|
| 136 |
+
labels: Optional[torch.Tensor] = None,
|
| 137 |
+
**kwargs,
|
| 138 |
+
) -> Seq2SeqLMOutput:
|
| 139 |
+
pad_id = self.config.pad_token_id
|
| 140 |
+
src_key_padding_mask = (
|
| 141 |
+
~attention_mask.bool() if attention_mask is not None else (input_ids == pad_id)
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
if decoder_input_ids is None:
|
| 145 |
+
if labels is None:
|
| 146 |
+
raise ValueError("Either decoder_input_ids or labels must be provided.")
|
| 147 |
+
decoder_input_ids = self._shift_right(labels)
|
| 148 |
+
|
| 149 |
+
tgt_key_padding_mask = decoder_input_ids == pad_id
|
| 150 |
+
tgt_len = decoder_input_ids.size(1)
|
| 151 |
+
causal_mask = nn.Transformer.generate_square_subsequent_mask(tgt_len).to(
|
| 152 |
+
decoder_input_ids.device
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
src_emb = self.pos_enc(self.embed(input_ids))
|
| 156 |
+
tgt_emb = self.pos_enc(self.embed(decoder_input_ids))
|
| 157 |
+
|
| 158 |
+
hidden = self.transformer(
|
| 159 |
+
src_emb,
|
| 160 |
+
tgt_emb,
|
| 161 |
+
src_key_padding_mask=src_key_padding_mask,
|
| 162 |
+
tgt_key_padding_mask=tgt_key_padding_mask,
|
| 163 |
+
memory_key_padding_mask=src_key_padding_mask,
|
| 164 |
+
tgt_mask=causal_mask,
|
| 165 |
+
)
|
| 166 |
+
logits = self.output_proj(hidden)
|
| 167 |
+
|
| 168 |
+
loss = None
|
| 169 |
+
if labels is not None:
|
| 170 |
+
loss = nn.functional.cross_entropy(
|
| 171 |
+
logits.reshape(-1, logits.size(-1)),
|
| 172 |
+
labels.reshape(-1),
|
| 173 |
+
ignore_index=-100,
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
return Seq2SeqLMOutput(loss=loss, logits=logits)
|
| 177 |
+
|
| 178 |
+
@torch.inference_mode()
|
| 179 |
+
def generate(
|
| 180 |
+
self,
|
| 181 |
+
input_ids: torch.Tensor,
|
| 182 |
+
attention_mask: Optional[torch.Tensor] = None,
|
| 183 |
+
max_new_tokens: int = 128,
|
| 184 |
+
**kwargs,
|
| 185 |
+
) -> torch.Tensor:
|
| 186 |
+
"""Greedy autoregressive decoding (batched).
|
| 187 |
+
|
| 188 |
+
Kept intentionally simple (greedy, no beam search) to minimize
|
| 189 |
+
per-request latency for the TTS-facing inference path; beam
|
| 190 |
+
search can be added later behind the same call signature if
|
| 191 |
+
offline quality matters more than latency for some use case.
|
| 192 |
+
"""
|
| 193 |
+
self.eval()
|
| 194 |
+
device = input_ids.device
|
| 195 |
+
batch_size = input_ids.size(0)
|
| 196 |
+
pad_id, bos_id, eos_id = (
|
| 197 |
+
self.config.pad_token_id,
|
| 198 |
+
self.config.bos_token_id,
|
| 199 |
+
self.config.eos_token_id,
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
max_positions = self.config.max_position_embeddings
|
| 203 |
+
if input_ids.size(1) > max_positions:
|
| 204 |
+
raise ValueError(
|
| 205 |
+
f"input_ids length {input_ids.size(1)} exceeds this model's "
|
| 206 |
+
f"max_position_embeddings={max_positions}; truncate the input."
|
| 207 |
+
)
|
| 208 |
+
# decoder sequence grows by 1 per step starting from the BOS token,
|
| 209 |
+
# so cap generation at the model's position budget minus that BOS
|
| 210 |
+
# slot rather than crashing once the positional-encoding buffer is
|
| 211 |
+
# exhausted mid-generation.
|
| 212 |
+
max_new_tokens = min(max_new_tokens, max_positions - 1)
|
| 213 |
+
|
| 214 |
+
src_key_padding_mask = (
|
| 215 |
+
~attention_mask.bool() if attention_mask is not None else (input_ids == pad_id)
|
| 216 |
+
)
|
| 217 |
+
src_emb = self.pos_enc(self.embed(input_ids))
|
| 218 |
+
memory = self.transformer.encoder(src_emb, src_key_padding_mask=src_key_padding_mask)
|
| 219 |
+
|
| 220 |
+
decoder_input = torch.full((batch_size, 1), bos_id, dtype=torch.long, device=device)
|
| 221 |
+
finished = torch.zeros(batch_size, dtype=torch.bool, device=device)
|
| 222 |
+
|
| 223 |
+
for _ in range(max_new_tokens):
|
| 224 |
+
tgt_emb = self.pos_enc(self.embed(decoder_input))
|
| 225 |
+
causal_mask = nn.Transformer.generate_square_subsequent_mask(
|
| 226 |
+
decoder_input.size(1)
|
| 227 |
+
).to(device)
|
| 228 |
+
hidden = self.transformer.decoder(
|
| 229 |
+
tgt_emb,
|
| 230 |
+
memory,
|
| 231 |
+
tgt_mask=causal_mask,
|
| 232 |
+
memory_key_padding_mask=src_key_padding_mask,
|
| 233 |
+
)
|
| 234 |
+
next_token_logits = self.output_proj(hidden[:, -1, :])
|
| 235 |
+
next_token = next_token_logits.argmax(dim=-1)
|
| 236 |
+
next_token = torch.where(finished, torch.full_like(next_token, pad_id), next_token)
|
| 237 |
+
decoder_input = torch.cat([decoder_input, next_token.unsqueeze(1)], dim=1)
|
| 238 |
+
finished = finished | (next_token == eos_id)
|
| 239 |
+
if bool(finished.all()):
|
| 240 |
+
break
|
| 241 |
+
|
| 242 |
+
return decoder_input
|
transliteration/model/tokenizer.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Character-level tokenizer for the transliteration model.
|
| 2 |
+
|
| 3 |
+
Why character-level rather than subword/BPE:
|
| 4 |
+
- The source side is romanized/Hinglish text where "words" are informal,
|
| 5 |
+
inconsistently spelled, and mix languages -- subword vocabularies
|
| 6 |
+
trained on such data are brittle.
|
| 7 |
+
- The target side spans 22 different scripts; a single BPE vocabulary
|
| 8 |
+
large enough to cover all of them well would be enormous, while
|
| 9 |
+
per-language vocabularies would break the "one shared model, more
|
| 10 |
+
languages addable later" requirement.
|
| 11 |
+
- Transliteration is fundamentally a character-mapping task (this is
|
| 12 |
+
also why IndicXlit itself operates at the character level internally,
|
| 13 |
+
despite being fairseq-based) -- char-level in/out is the natural
|
| 14 |
+
granularity and keeps the vocabulary small (~a few hundred symbols
|
| 15 |
+
for all 22 scripts combined).
|
| 16 |
+
|
| 17 |
+
The tokenizer is a thin wrapper implementing the HF `PreTrainedTokenizer`
|
| 18 |
+
surface (so it plugs into `Trainer`/`generate()` unmodified) backed by an
|
| 19 |
+
explicit char->id table built from the training corpus plus the fixed set
|
| 20 |
+
of `<2xx>` language tags from `transliteration.languages`.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
import json
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
from typing import Dict, List, Optional
|
| 26 |
+
|
| 27 |
+
from transformers import PreTrainedTokenizer
|
| 28 |
+
|
| 29 |
+
from transliteration.languages import ALL_TAGS
|
| 30 |
+
|
| 31 |
+
SPECIAL_TOKENS = ["<pad>", "<bos>", "<eos>", "<unk>"]
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class CharTransliterationTokenizer(PreTrainedTokenizer):
|
| 35 |
+
"""Character-level tokenizer with `<2xx>` language-tag special tokens.
|
| 36 |
+
|
| 37 |
+
Vocabulary layout: [pad, bos, eos, unk] + language tags + sorted chars
|
| 38 |
+
seen in training data. Language tags and the 4 control tokens are
|
| 39 |
+
always single tokens; everything else is one Unicode codepoint each.
|
| 40 |
+
"""
|
| 41 |
+
|
| 42 |
+
vocab_files_names = {"vocab_file": "vocab.json"}
|
| 43 |
+
model_input_names = ["input_ids", "attention_mask"]
|
| 44 |
+
|
| 45 |
+
def __init__(self, vocab: Optional[Dict[str, int]] = None, **kwargs):
|
| 46 |
+
self._vocab = vocab or self._default_vocab()
|
| 47 |
+
self._id_to_token = {v: k for k, v in self._vocab.items()}
|
| 48 |
+
super().__init__(
|
| 49 |
+
pad_token="<pad>",
|
| 50 |
+
bos_token="<bos>",
|
| 51 |
+
eos_token="<eos>",
|
| 52 |
+
unk_token="<unk>",
|
| 53 |
+
**kwargs,
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
@staticmethod
|
| 57 |
+
def _default_vocab() -> Dict[str, int]:
|
| 58 |
+
tokens = list(SPECIAL_TOKENS) + list(ALL_TAGS)
|
| 59 |
+
return {tok: i for i, tok in enumerate(tokens)}
|
| 60 |
+
|
| 61 |
+
@classmethod
|
| 62 |
+
def build_from_corpus(cls, texts: List[str], min_freq: int = 1):
|
| 63 |
+
"""Build vocab from a corpus of raw strings (both roman and target side)."""
|
| 64 |
+
from collections import Counter
|
| 65 |
+
|
| 66 |
+
counter = Counter()
|
| 67 |
+
for t in texts:
|
| 68 |
+
counter.update(t)
|
| 69 |
+
|
| 70 |
+
tokens = list(SPECIAL_TOKENS) + list(ALL_TAGS)
|
| 71 |
+
seen = set(tokens)
|
| 72 |
+
for ch, freq in sorted(counter.items(), key=lambda kv: (-kv[1], kv[0])):
|
| 73 |
+
if freq < min_freq or ch in seen:
|
| 74 |
+
continue
|
| 75 |
+
tokens.append(ch)
|
| 76 |
+
seen.add(ch)
|
| 77 |
+
|
| 78 |
+
vocab = {tok: i for i, tok in enumerate(tokens)}
|
| 79 |
+
return cls(vocab=vocab)
|
| 80 |
+
|
| 81 |
+
# -- required PreTrainedTokenizer overrides --------------------------------
|
| 82 |
+
|
| 83 |
+
@property
|
| 84 |
+
def vocab_size(self) -> int:
|
| 85 |
+
return len(self._vocab)
|
| 86 |
+
|
| 87 |
+
def get_vocab(self) -> Dict[str, int]:
|
| 88 |
+
return dict(self._vocab)
|
| 89 |
+
|
| 90 |
+
def _tokenize(self, text: str, **kwargs) -> List[str]:
|
| 91 |
+
tokens = []
|
| 92 |
+
i = 0
|
| 93 |
+
# greedily match multi-char language tags like "<2brx>" first
|
| 94 |
+
tag_tokens = sorted(ALL_TAGS, key=len, reverse=True)
|
| 95 |
+
while i < len(text):
|
| 96 |
+
matched = False
|
| 97 |
+
for tag in tag_tokens:
|
| 98 |
+
if text.startswith(tag, i):
|
| 99 |
+
tokens.append(tag)
|
| 100 |
+
i += len(tag)
|
| 101 |
+
matched = True
|
| 102 |
+
break
|
| 103 |
+
if not matched:
|
| 104 |
+
tokens.append(text[i])
|
| 105 |
+
i += 1
|
| 106 |
+
return tokens
|
| 107 |
+
|
| 108 |
+
def _convert_token_to_id(self, token: str) -> int:
|
| 109 |
+
return self._vocab.get(token, self._vocab[self.unk_token])
|
| 110 |
+
|
| 111 |
+
def _convert_id_to_token(self, index: int) -> str:
|
| 112 |
+
return self._id_to_token.get(index, self.unk_token)
|
| 113 |
+
|
| 114 |
+
def convert_tokens_to_string(self, tokens: List[str]) -> str:
|
| 115 |
+
return "".join(t for t in tokens if t not in (self.pad_token, self.bos_token, self.eos_token))
|
| 116 |
+
|
| 117 |
+
def build_inputs_with_special_tokens(self, token_ids_0, token_ids_1=None):
|
| 118 |
+
bos, eos = self.bos_token_id, self.eos_token_id
|
| 119 |
+
if token_ids_1 is None:
|
| 120 |
+
return [bos] + token_ids_0 + [eos]
|
| 121 |
+
return [bos] + token_ids_0 + [eos] + [bos] + token_ids_1 + [eos]
|
| 122 |
+
|
| 123 |
+
def save_vocabulary(self, save_directory: str, filename_prefix: Optional[str] = None):
|
| 124 |
+
save_dir = Path(save_directory)
|
| 125 |
+
save_dir.mkdir(parents=True, exist_ok=True)
|
| 126 |
+
prefix = f"{filename_prefix}-" if filename_prefix else ""
|
| 127 |
+
vocab_path = save_dir / f"{prefix}vocab.json"
|
| 128 |
+
with vocab_path.open("w", encoding="utf-8") as f:
|
| 129 |
+
json.dump(self._vocab, f, ensure_ascii=False, indent=2)
|
| 130 |
+
return (str(vocab_path),)
|
| 131 |
+
|
| 132 |
+
@classmethod
|
| 133 |
+
def from_pretrained(cls, path, **kwargs): # noqa: D102 - thin override
|
| 134 |
+
vocab_path = Path(path) / "vocab.json"
|
| 135 |
+
if vocab_path.exists():
|
| 136 |
+
with vocab_path.open(encoding="utf-8") as f:
|
| 137 |
+
vocab = json.load(f)
|
| 138 |
+
return cls(vocab=vocab)
|
| 139 |
+
return super().from_pretrained(path, **kwargs)
|
transliteration/train.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Thin shim so `python -m transliteration.train --config ...` works as specified.
|
| 2 |
+
Delegates to transliteration.cli's train subcommand."""
|
| 3 |
+
|
| 4 |
+
import sys
|
| 5 |
+
|
| 6 |
+
from transliteration.cli import build_parser
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def main():
|
| 10 |
+
parser = build_parser()
|
| 11 |
+
args = parser.parse_args(["train", *sys.argv[1:]])
|
| 12 |
+
args.func(args)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
if __name__ == "__main__":
|
| 16 |
+
main()
|
transliteration/training/__init__.py
ADDED
|
File without changes
|
transliteration/training/evaluate.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evaluation entrypoint: python -m transliteration.cli evaluate --checkpoint models/checkpoints/.../final
|
| 2 |
+
|
| 3 |
+
Produces results/overall.json, results/per_language.json, results/examples.json,
|
| 4 |
+
results/confusion_analysis.json.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
import logging
|
| 9 |
+
from collections import Counter, defaultdict
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Dict, List, Optional
|
| 12 |
+
|
| 13 |
+
import torch
|
| 14 |
+
|
| 15 |
+
from transliteration.data.dataset import load_jsonl
|
| 16 |
+
from transliteration.languages import language_tag
|
| 17 |
+
from transliteration.model.model import TransliterationConfig, TransliterationModel
|
| 18 |
+
from transliteration.model.tokenizer import CharTransliterationTokenizer
|
| 19 |
+
from transliteration.training.metrics import aggregate_by_language, evaluate_examples
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def load_checkpoint(checkpoint_dir: Path, device: Optional[str] = None):
|
| 25 |
+
device = device or ("cuda" if torch.cuda.is_available() else "cpu")
|
| 26 |
+
tokenizer = CharTransliterationTokenizer.from_pretrained(str(checkpoint_dir / "tokenizer"))
|
| 27 |
+
config = TransliterationConfig.from_pretrained(str(checkpoint_dir / "model"))
|
| 28 |
+
model = TransliterationModel.from_pretrained(str(checkpoint_dir / "model"), config=config)
|
| 29 |
+
model.to(device)
|
| 30 |
+
model.eval()
|
| 31 |
+
return model, tokenizer, device
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@torch.inference_mode()
|
| 35 |
+
def predict_batch(
|
| 36 |
+
model: TransliterationModel,
|
| 37 |
+
tokenizer: CharTransliterationTokenizer,
|
| 38 |
+
records: List[dict],
|
| 39 |
+
device: str,
|
| 40 |
+
batch_size: int = 64,
|
| 41 |
+
max_new_tokens: int = 128,
|
| 42 |
+
) -> List[str]:
|
| 43 |
+
predictions = []
|
| 44 |
+
for i in range(0, len(records), batch_size):
|
| 45 |
+
batch = records[i : i + batch_size]
|
| 46 |
+
texts = [f"{language_tag(r['language'])} {r['roman']}" for r in batch]
|
| 47 |
+
enc = tokenizer(texts, return_tensors="pt", padding=True).to(device)
|
| 48 |
+
out_ids = model.generate(
|
| 49 |
+
enc["input_ids"], enc["attention_mask"], max_new_tokens=max_new_tokens
|
| 50 |
+
)
|
| 51 |
+
for row in out_ids.tolist():
|
| 52 |
+
predictions.append(tokenizer.decode(row, skip_special_tokens=True))
|
| 53 |
+
return predictions
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def run_evaluation(
|
| 57 |
+
checkpoint_dir: Path,
|
| 58 |
+
test_path: Path,
|
| 59 |
+
results_dir: Path,
|
| 60 |
+
batch_size: int = 64,
|
| 61 |
+
max_examples_per_language: Optional[int] = None,
|
| 62 |
+
) -> Dict:
|
| 63 |
+
model, tokenizer, device = load_checkpoint(checkpoint_dir, device=None)
|
| 64 |
+
records = load_jsonl(test_path)
|
| 65 |
+
|
| 66 |
+
if max_examples_per_language:
|
| 67 |
+
capped = []
|
| 68 |
+
counts = Counter()
|
| 69 |
+
for r in records:
|
| 70 |
+
if counts[r["language"]] < max_examples_per_language:
|
| 71 |
+
capped.append(r)
|
| 72 |
+
counts[r["language"]] += 1
|
| 73 |
+
records = capped
|
| 74 |
+
|
| 75 |
+
predictions = predict_batch(model, tokenizer, records, device, batch_size=batch_size)
|
| 76 |
+
|
| 77 |
+
eval_input = [
|
| 78 |
+
{**r, "prediction": p} for r, p in zip(records, predictions)
|
| 79 |
+
]
|
| 80 |
+
results = evaluate_examples(eval_input, use_phonetic=True)
|
| 81 |
+
per_language = aggregate_by_language(results)
|
| 82 |
+
|
| 83 |
+
n = len(results)
|
| 84 |
+
overall = {
|
| 85 |
+
"n_examples": n,
|
| 86 |
+
"cer": sum(r.cer for r in results) / n if n else None,
|
| 87 |
+
"exact_match": sum(r.exact for r in results) / n if n else None,
|
| 88 |
+
"token_accuracy": sum(r.token_acc for r in results) / n if n else None,
|
| 89 |
+
"script_validity": sum(r.script_valid for r in results) / n if n else None,
|
| 90 |
+
"phonetic_cer": sum(r.phonetic_cer for r in results) / n if n else None,
|
| 91 |
+
"phonetic_exact_match": sum(r.phonetic_exact for r in results) / n if n else None,
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
examples = [
|
| 95 |
+
{
|
| 96 |
+
"language": r.language,
|
| 97 |
+
"roman": r.roman,
|
| 98 |
+
"reference": r.reference,
|
| 99 |
+
"prediction": r.prediction,
|
| 100 |
+
"cer": r.cer,
|
| 101 |
+
"exact_match": r.exact,
|
| 102 |
+
}
|
| 103 |
+
for r in results
|
| 104 |
+
]
|
| 105 |
+
|
| 106 |
+
confusion = _confusion_analysis(results)
|
| 107 |
+
|
| 108 |
+
results_dir.mkdir(parents=True, exist_ok=True)
|
| 109 |
+
(results_dir / "overall.json").write_text(json.dumps(overall, ensure_ascii=False, indent=2))
|
| 110 |
+
(results_dir / "per_language.json").write_text(
|
| 111 |
+
json.dumps(per_language, ensure_ascii=False, indent=2)
|
| 112 |
+
)
|
| 113 |
+
(results_dir / "examples.json").write_text(
|
| 114 |
+
json.dumps(examples[:500], ensure_ascii=False, indent=2)
|
| 115 |
+
)
|
| 116 |
+
(results_dir / "confusion_analysis.json").write_text(
|
| 117 |
+
json.dumps(confusion, ensure_ascii=False, indent=2)
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
logger.info("Overall: %s", overall)
|
| 121 |
+
return {"overall": overall, "per_language": per_language}
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _confusion_analysis(results, top_k: int = 20) -> Dict[str, List]:
|
| 125 |
+
"""Most common character-level substitution errors per language, derived
|
| 126 |
+
from a simple aligned-diff over equal-length prefix (cheap proxy for a
|
| 127 |
+
full edit-distance alignment, sufficient for surfacing systematic
|
| 128 |
+
substitution patterns like vowel-length or nasal confusion)."""
|
| 129 |
+
by_lang_subs = defaultdict(Counter)
|
| 130 |
+
for r in results:
|
| 131 |
+
if r.exact:
|
| 132 |
+
continue
|
| 133 |
+
for a, b in zip(r.prediction, r.reference):
|
| 134 |
+
if a != b:
|
| 135 |
+
by_lang_subs[r.language][(a, b)] += 1
|
| 136 |
+
|
| 137 |
+
out = {}
|
| 138 |
+
for lang, counter in by_lang_subs.items():
|
| 139 |
+
out[lang] = [
|
| 140 |
+
{"predicted": a, "expected": b, "count": c}
|
| 141 |
+
for (a, b), c in counter.most_common(top_k)
|
| 142 |
+
]
|
| 143 |
+
return out
|
transliteration/training/metrics.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evaluation metrics: CER, exact match, token accuracy, script validity,
|
| 2 |
+
and an optional phonetic-normalization layer so near-equivalent
|
| 3 |
+
transliterations (e.g. "पेमेंट" vs "पेमेन्ट") aren't penalized for a TTS
|
| 4 |
+
use case where pronunciation, not exact Unicode match, is what matters.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import re
|
| 8 |
+
import unicodedata
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
from typing import Dict, List
|
| 11 |
+
|
| 12 |
+
import editdistance
|
| 13 |
+
|
| 14 |
+
from transliteration.languages import get_language
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def char_error_rate(pred: str, ref: str) -> float:
|
| 18 |
+
if len(ref) == 0:
|
| 19 |
+
return 0.0 if len(pred) == 0 else 1.0
|
| 20 |
+
return editdistance.eval(pred, ref) / len(ref)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def exact_match(pred: str, ref: str) -> bool:
|
| 24 |
+
return pred.strip() == ref.strip()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def token_accuracy(pred: str, ref: str) -> float:
|
| 28 |
+
"""Whitespace-token-level accuracy via position-aligned comparison
|
| 29 |
+
(min length; extra/missing tokens count as mismatches)."""
|
| 30 |
+
pred_tokens = pred.strip().split()
|
| 31 |
+
ref_tokens = ref.strip().split()
|
| 32 |
+
if not ref_tokens:
|
| 33 |
+
return 1.0 if not pred_tokens else 0.0
|
| 34 |
+
matches = sum(
|
| 35 |
+
1 for p, r in zip(pred_tokens, ref_tokens) if p == r
|
| 36 |
+
)
|
| 37 |
+
return matches / max(len(ref_tokens), len(pred_tokens))
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def script_validity(text: str, language_code: str, min_ratio: float = 0.5) -> bool:
|
| 41 |
+
lang = get_language(language_code)
|
| 42 |
+
alpha_chars = [c for c in text if c.isalpha()]
|
| 43 |
+
if not alpha_chars:
|
| 44 |
+
return False
|
| 45 |
+
matches = sum(1 for c in alpha_chars if lang.contains_script_char(c))
|
| 46 |
+
return (matches / len(alpha_chars)) >= min_ratio
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
# -- Phonetic normalization (optional evaluation layer) -----------------------
|
| 50 |
+
#
|
| 51 |
+
# Full Indic phonology/homophone normalization is out of scope here -- this
|
| 52 |
+
# targets one specific, well-defined, high-frequency ambiguity in loanword
|
| 53 |
+
# transliteration: word-final nasalization can be spelled either as anusvara
|
| 54 |
+
# (ं, e.g. "पेमेंट") or as an explicit nasal consonant + virama (न्/म्, e.g.
|
| 55 |
+
# "पेमेन्ट"). Both render as /-nt/, /-nʈ/-style nasal-stop clusters and are
|
| 56 |
+
# used interchangeably by different transliteration sources/annotators.
|
| 57 |
+
#
|
| 58 |
+
# This is deliberately scoped to nasal+virama immediately followed by a
|
| 59 |
+
# consonant (the actual loanword-cluster pattern) rather than a blanket
|
| 60 |
+
# character substitution: unscoped single-character swaps (dropped in an
|
| 61 |
+
# earlier version of this function) also fire inside ordinary words using
|
| 62 |
+
# ी/ि, े/ै, ो/ौ, म as plain vowels/consonants and corrupt them. Nukta-based
|
| 63 |
+
# loanword consonants (ज़/ज, फ़/फ) are handled the same way, as a bounded,
|
| 64 |
+
# reversible single-character equivalence, since a nukta drop/add doesn't
|
| 65 |
+
# interact with surrounding characters.
|
| 66 |
+
|
| 67 |
+
_NUKTA_EQUIVALENCE_GROUPS: Dict[str, List[str]] = {
|
| 68 |
+
"hi": ["जज़", "फफ़", "कक़", "खख़", "गग़", "डड़", "ढढ़"],
|
| 69 |
+
}
|
| 70 |
+
_NUKTA_EQUIVALENCE_GROUPS["mr"] = _NUKTA_EQUIVALENCE_GROUPS["hi"]
|
| 71 |
+
_NUKTA_EQUIVALENCE_GROUPS["ne"] = _NUKTA_EQUIVALENCE_GROUPS["hi"]
|
| 72 |
+
|
| 73 |
+
# consonant + virama pairs that are phonetically nasal (न्/म्) and can be
|
| 74 |
+
# rewritten as anusvara (ं) when immediately followed by another consonant,
|
| 75 |
+
# i.e. the "...न्ट"/"...म्प" -> "...ंट"/"...ंप" loanword-cluster pattern.
|
| 76 |
+
_NASAL_VIRAMA_RE = re.compile(r"[नम]्(?=[ऀ-ह])")
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def phonetic_normalize(text: str, language_code: str) -> str:
|
| 80 |
+
"""Normalize the specific anusvara/nasal+virama and nukta ambiguities
|
| 81 |
+
described above so CER/exact-match can be computed on a phonetic basis.
|
| 82 |
+
Falls back to plain NFC normalization for languages without these
|
| 83 |
+
Devanagari-specific rules defined (safe no-op for other scripts)."""
|
| 84 |
+
text = unicodedata.normalize("NFC", text)
|
| 85 |
+
|
| 86 |
+
if language_code in ("hi", "mr", "ne"):
|
| 87 |
+
text = _NASAL_VIRAMA_RE.sub("ं", text)
|
| 88 |
+
|
| 89 |
+
for group in _NUKTA_EQUIVALENCE_GROUPS.get(language_code, []):
|
| 90 |
+
canonical, variant = group[0], group[1]
|
| 91 |
+
text = text.replace(variant, canonical)
|
| 92 |
+
|
| 93 |
+
return text
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
@dataclass
|
| 97 |
+
class ExampleResult:
|
| 98 |
+
language: str
|
| 99 |
+
roman: str
|
| 100 |
+
reference: str
|
| 101 |
+
prediction: str
|
| 102 |
+
cer: float
|
| 103 |
+
exact: bool
|
| 104 |
+
token_acc: float
|
| 105 |
+
script_valid: bool
|
| 106 |
+
phonetic_cer: float
|
| 107 |
+
phonetic_exact: bool
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def evaluate_examples(
|
| 111 |
+
examples: List[dict], # each: {"language","roman","target","prediction"}
|
| 112 |
+
use_phonetic: bool = True,
|
| 113 |
+
) -> List[ExampleResult]:
|
| 114 |
+
results = []
|
| 115 |
+
for ex in examples:
|
| 116 |
+
lang, roman, ref, pred = ex["language"], ex["roman"], ex["target"], ex["prediction"]
|
| 117 |
+
cer = char_error_rate(pred, ref)
|
| 118 |
+
exact = exact_match(pred, ref)
|
| 119 |
+
tok_acc = token_accuracy(pred, ref)
|
| 120 |
+
valid = script_validity(pred, lang) if pred.strip() else False
|
| 121 |
+
|
| 122 |
+
if use_phonetic:
|
| 123 |
+
pred_n = phonetic_normalize(pred, lang)
|
| 124 |
+
ref_n = phonetic_normalize(ref, lang)
|
| 125 |
+
p_cer = char_error_rate(pred_n, ref_n)
|
| 126 |
+
p_exact = exact_match(pred_n, ref_n)
|
| 127 |
+
else:
|
| 128 |
+
p_cer, p_exact = cer, exact
|
| 129 |
+
|
| 130 |
+
results.append(
|
| 131 |
+
ExampleResult(
|
| 132 |
+
language=lang, roman=roman, reference=ref, prediction=pred,
|
| 133 |
+
cer=cer, exact=exact, token_acc=tok_acc, script_valid=valid,
|
| 134 |
+
phonetic_cer=p_cer, phonetic_exact=p_exact,
|
| 135 |
+
)
|
| 136 |
+
)
|
| 137 |
+
return results
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def aggregate_by_language(results: List[ExampleResult]) -> Dict[str, dict]:
|
| 141 |
+
from collections import defaultdict
|
| 142 |
+
|
| 143 |
+
by_lang = defaultdict(list)
|
| 144 |
+
for r in results:
|
| 145 |
+
by_lang[r.language].append(r)
|
| 146 |
+
|
| 147 |
+
out = {}
|
| 148 |
+
for lang, group in by_lang.items():
|
| 149 |
+
n = len(group)
|
| 150 |
+
out[lang] = {
|
| 151 |
+
"n_examples": n,
|
| 152 |
+
"cer": sum(r.cer for r in group) / n,
|
| 153 |
+
"exact_match": sum(r.exact for r in group) / n,
|
| 154 |
+
"token_accuracy": sum(r.token_acc for r in group) / n,
|
| 155 |
+
"script_validity": sum(r.script_valid for r in group) / n,
|
| 156 |
+
"phonetic_cer": sum(r.phonetic_cer for r in group) / n,
|
| 157 |
+
"phonetic_exact_match": sum(r.phonetic_exact for r in group) / n,
|
| 158 |
+
}
|
| 159 |
+
return out
|
transliteration/training/train.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Training entrypoint: python -m transliteration.cli train --config configs/train.yaml
|
| 2 |
+
|
| 3 |
+
Ties together: data assembly (download -> preprocess -> validate -> split),
|
| 4 |
+
tokenizer construction, model init, and the HF `Trainer` loop. Saves the
|
| 5 |
+
best checkpoint (by validation CER) plus tokenizer/config/language-map/
|
| 6 |
+
dataset-manifest/git-commit metadata per checkpoint, so a saved checkpoint
|
| 7 |
+
is fully self-describing and reproducible.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
import logging
|
| 12 |
+
import subprocess
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from typing import Any, Dict, Optional
|
| 15 |
+
|
| 16 |
+
import torch
|
| 17 |
+
from torch.utils.data import Dataset as TorchDataset
|
| 18 |
+
from transformers import Trainer, TrainingArguments, TrainerCallback
|
| 19 |
+
|
| 20 |
+
from transliteration.config import get
|
| 21 |
+
from transliteration.data.dataset import (
|
| 22 |
+
apply_language_sampling,
|
| 23 |
+
load_custom_csv,
|
| 24 |
+
load_jsonl,
|
| 25 |
+
mix_with_custom_data,
|
| 26 |
+
)
|
| 27 |
+
from transliteration.languages import language_tag
|
| 28 |
+
from transliteration.model.model import TransliterationConfig, TransliterationModel
|
| 29 |
+
from transliteration.model.tokenizer import CharTransliterationTokenizer
|
| 30 |
+
from transliteration.training.metrics import char_error_rate, exact_match
|
| 31 |
+
|
| 32 |
+
logger = logging.getLogger(__name__)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class TransliterationDataset(TorchDataset):
|
| 36 |
+
def __init__(self, records, tokenizer, max_length: int = 256):
|
| 37 |
+
self.records = records
|
| 38 |
+
self.tokenizer = tokenizer
|
| 39 |
+
self.max_length = max_length
|
| 40 |
+
|
| 41 |
+
def __len__(self):
|
| 42 |
+
return len(self.records)
|
| 43 |
+
|
| 44 |
+
def __getitem__(self, idx):
|
| 45 |
+
rec = self.records[idx]
|
| 46 |
+
tag = language_tag(rec["language"])
|
| 47 |
+
src_text = f"{tag} {rec['roman']}"
|
| 48 |
+
|
| 49 |
+
input_ids = self.tokenizer(
|
| 50 |
+
src_text, truncation=True, max_length=self.max_length
|
| 51 |
+
)["input_ids"]
|
| 52 |
+
label_ids = self.tokenizer(
|
| 53 |
+
rec["target"], truncation=True, max_length=self.max_length
|
| 54 |
+
)["input_ids"]
|
| 55 |
+
|
| 56 |
+
return {"input_ids": input_ids, "labels": label_ids}
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def make_collate_fn(pad_id: int):
|
| 60 |
+
def collate(batch):
|
| 61 |
+
max_src = max(len(b["input_ids"]) for b in batch)
|
| 62 |
+
max_tgt = max(len(b["labels"]) for b in batch)
|
| 63 |
+
|
| 64 |
+
input_ids, attention_mask, labels = [], [], []
|
| 65 |
+
for b in batch:
|
| 66 |
+
src = b["input_ids"] + [pad_id] * (max_src - len(b["input_ids"]))
|
| 67 |
+
mask = [1] * len(b["input_ids"]) + [0] * (max_src - len(b["input_ids"]))
|
| 68 |
+
tgt = b["labels"] + [-100] * (max_tgt - len(b["labels"]))
|
| 69 |
+
input_ids.append(src)
|
| 70 |
+
attention_mask.append(mask)
|
| 71 |
+
labels.append(tgt)
|
| 72 |
+
|
| 73 |
+
return {
|
| 74 |
+
"input_ids": torch.tensor(input_ids, dtype=torch.long),
|
| 75 |
+
"attention_mask": torch.tensor(attention_mask, dtype=torch.long),
|
| 76 |
+
"labels": torch.tensor(labels, dtype=torch.long),
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
return collate
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def get_git_commit_hash() -> str:
|
| 83 |
+
try:
|
| 84 |
+
return subprocess.check_output(
|
| 85 |
+
["git", "rev-parse", "HEAD"], stderr=subprocess.DEVNULL
|
| 86 |
+
).decode().strip()
|
| 87 |
+
except Exception:
|
| 88 |
+
return "unknown (not a git repository)"
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def build_training_records(config: Dict[str, Any], prototype: bool = False):
|
| 92 |
+
"""Load already-split train/validation records, apply language-temperature
|
| 93 |
+
sampling + custom-data mixing to the *train* split only (val/test stay
|
| 94 |
+
as natural, unweighted samples of the real distribution -- weighting
|
| 95 |
+
those would make eval numbers misleading)."""
|
| 96 |
+
train_dir = Path(get(config, "data.train_dir", "data/train"))
|
| 97 |
+
val_dir = Path(get(config, "data.validation_dir", "data/validation"))
|
| 98 |
+
|
| 99 |
+
train_records = load_jsonl(train_dir / "data.jsonl")
|
| 100 |
+
val_records = load_jsonl(val_dir / "data.jsonl")
|
| 101 |
+
|
| 102 |
+
if prototype:
|
| 103 |
+
cap = get(config, "prototype.max_examples_per_language", 500)
|
| 104 |
+
by_lang = {}
|
| 105 |
+
capped = []
|
| 106 |
+
for r in train_records:
|
| 107 |
+
by_lang.setdefault(r["language"], 0)
|
| 108 |
+
if by_lang[r["language"]] < cap:
|
| 109 |
+
capped.append(r)
|
| 110 |
+
by_lang[r["language"]] += 1
|
| 111 |
+
train_records = capped
|
| 112 |
+
val_records = val_records[: max(50, cap // 5)]
|
| 113 |
+
|
| 114 |
+
temperature = get(config, "language_sampling.temperature", 1.0)
|
| 115 |
+
train_records = apply_language_sampling(train_records, temperature=temperature)
|
| 116 |
+
|
| 117 |
+
custom_csv = get(config, "data.custom_csv")
|
| 118 |
+
custom_weight = get(config, "custom_data_weight", 0.0)
|
| 119 |
+
if custom_csv and Path(custom_csv).exists() and custom_weight > 0:
|
| 120 |
+
custom_records = load_custom_csv(Path(custom_csv))
|
| 121 |
+
train_records = mix_with_custom_data(
|
| 122 |
+
train_records, custom_records, custom_data_weight=custom_weight
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
return train_records, val_records
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
class CERCallback(TrainerCallback):
|
| 129 |
+
"""Computes greedy-decode CER on a small val subset each eval, since the
|
| 130 |
+
default seq2seq loss doesn't reflect generation-time character accuracy
|
| 131 |
+
(the metric the best-checkpoint selection is supposed to use)."""
|
| 132 |
+
|
| 133 |
+
def __init__(self, tokenizer, val_records, device, max_examples: int = 200):
|
| 134 |
+
self.tokenizer = tokenizer
|
| 135 |
+
self.val_records = val_records[:max_examples]
|
| 136 |
+
self.device = device
|
| 137 |
+
|
| 138 |
+
def on_evaluate(self, args, state, control, model=None, metrics=None, **kwargs):
|
| 139 |
+
if model is None or not self.val_records:
|
| 140 |
+
return
|
| 141 |
+
model.eval()
|
| 142 |
+
cers, exacts = [], []
|
| 143 |
+
for rec in self.val_records:
|
| 144 |
+
tag = language_tag(rec["language"])
|
| 145 |
+
src = f"{tag} {rec['roman']}"
|
| 146 |
+
enc = self.tokenizer(src, return_tensors="pt").to(self.device)
|
| 147 |
+
with torch.inference_mode():
|
| 148 |
+
out_ids = model.generate(enc["input_ids"], enc["attention_mask"])
|
| 149 |
+
pred = self.tokenizer.decode(out_ids[0].tolist(), skip_special_tokens=True)
|
| 150 |
+
cers.append(char_error_rate(pred, rec["target"]))
|
| 151 |
+
exacts.append(exact_match(pred, rec["target"]))
|
| 152 |
+
if metrics is not None:
|
| 153 |
+
metrics["eval_cer"] = sum(cers) / len(cers)
|
| 154 |
+
metrics["eval_exact_match"] = sum(exacts) / len(exacts)
|
| 155 |
+
model.train()
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def run_training(config: Dict[str, Any], prototype: bool = False) -> Path:
|
| 159 |
+
train_records, val_records = build_training_records(config, prototype=prototype)
|
| 160 |
+
logger.info("Train examples: %d, Validation examples: %d", len(train_records), len(val_records))
|
| 161 |
+
|
| 162 |
+
corpus_texts = [f"{language_tag(r['language'])} {r['roman']}" for r in train_records]
|
| 163 |
+
corpus_texts += [r["target"] for r in train_records]
|
| 164 |
+
tokenizer = CharTransliterationTokenizer.build_from_corpus(corpus_texts)
|
| 165 |
+
logger.info("Tokenizer vocab size: %d", tokenizer.vocab_size)
|
| 166 |
+
|
| 167 |
+
model_cfg = TransliterationConfig(
|
| 168 |
+
vocab_size=tokenizer.vocab_size,
|
| 169 |
+
d_model=get(config, "model.d_model", 256),
|
| 170 |
+
nhead=get(config, "model.nhead", 4),
|
| 171 |
+
num_encoder_layers=get(config, "model.num_encoder_layers", 4),
|
| 172 |
+
num_decoder_layers=get(config, "model.num_decoder_layers", 4),
|
| 173 |
+
dim_feedforward=get(config, "model.dim_feedforward", 1024),
|
| 174 |
+
dropout=get(config, "model.dropout", 0.1),
|
| 175 |
+
max_position_embeddings=get(config, "model.max_position_embeddings", 300),
|
| 176 |
+
pad_token_id=tokenizer.pad_token_id,
|
| 177 |
+
bos_token_id=tokenizer.bos_token_id,
|
| 178 |
+
eos_token_id=tokenizer.eos_token_id,
|
| 179 |
+
)
|
| 180 |
+
model = TransliterationModel(model_cfg)
|
| 181 |
+
n_params = sum(p.numel() for p in model.parameters())
|
| 182 |
+
logger.info("Model parameters: %d (%.1fM)", n_params, n_params / 1e6)
|
| 183 |
+
|
| 184 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 185 |
+
model.to(device)
|
| 186 |
+
|
| 187 |
+
train_ds = TransliterationDataset(train_records, tokenizer)
|
| 188 |
+
val_ds = TransliterationDataset(val_records, tokenizer)
|
| 189 |
+
collate_fn = make_collate_fn(tokenizer.pad_token_id)
|
| 190 |
+
|
| 191 |
+
output_dir = Path(get(config, "training.output_dir", "models/checkpoints/indicxlit-custom"))
|
| 192 |
+
num_epochs = (
|
| 193 |
+
get(config, "prototype.num_train_epochs", 3)
|
| 194 |
+
if prototype
|
| 195 |
+
else get(config, "training.num_train_epochs", 10)
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
args = TrainingArguments(
|
| 199 |
+
output_dir=str(output_dir),
|
| 200 |
+
num_train_epochs=num_epochs,
|
| 201 |
+
per_device_train_batch_size=get(config, "training.batch_size", 64),
|
| 202 |
+
per_device_eval_batch_size=get(config, "training.eval_batch_size", 128),
|
| 203 |
+
learning_rate=get(config, "training.learning_rate", 3e-4),
|
| 204 |
+
warmup_steps=get(config, "training.warmup_steps", 500),
|
| 205 |
+
weight_decay=get(config, "training.weight_decay", 0.01),
|
| 206 |
+
fp16=get(config, "training.fp16", True) and device == "cuda",
|
| 207 |
+
bf16=get(config, "training.bf16", False) and device == "cuda",
|
| 208 |
+
gradient_accumulation_steps=get(config, "training.gradient_accumulation_steps", 1),
|
| 209 |
+
logging_steps=get(config, "training.logging_steps", 50),
|
| 210 |
+
eval_strategy="steps",
|
| 211 |
+
eval_steps=get(config, "training.eval_steps", 500),
|
| 212 |
+
save_strategy="steps",
|
| 213 |
+
save_steps=get(config, "training.save_steps", 500),
|
| 214 |
+
save_total_limit=get(config, "training.save_total_limit", 3),
|
| 215 |
+
load_best_model_at_end=False, # best-model selection uses CERCallback's eval_cer; see save below
|
| 216 |
+
seed=get(config, "training.seed", 13),
|
| 217 |
+
report_to=[],
|
| 218 |
+
dataloader_num_workers=2,
|
| 219 |
+
)
|
| 220 |
+
|
| 221 |
+
trainer = Trainer(
|
| 222 |
+
model=model,
|
| 223 |
+
args=args,
|
| 224 |
+
train_dataset=train_ds,
|
| 225 |
+
eval_dataset=val_ds,
|
| 226 |
+
data_collator=collate_fn,
|
| 227 |
+
callbacks=[CERCallback(tokenizer, val_records, device)],
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
resume = get(config, "training.resume_from_checkpoint")
|
| 231 |
+
trainer.train(resume_from_checkpoint=resume)
|
| 232 |
+
|
| 233 |
+
save_checkpoint(
|
| 234 |
+
output_dir=output_dir / "final",
|
| 235 |
+
model=model,
|
| 236 |
+
tokenizer=tokenizer,
|
| 237 |
+
config=config,
|
| 238 |
+
train_records=train_records,
|
| 239 |
+
val_records=val_records,
|
| 240 |
+
)
|
| 241 |
+
return output_dir / "final"
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def save_checkpoint(
|
| 245 |
+
output_dir: Path,
|
| 246 |
+
model: TransliterationModel,
|
| 247 |
+
tokenizer: CharTransliterationTokenizer,
|
| 248 |
+
config: Dict[str, Any],
|
| 249 |
+
train_records,
|
| 250 |
+
val_records,
|
| 251 |
+
) -> None:
|
| 252 |
+
"""Save tokenizer, model, config, language mapping, training metadata,
|
| 253 |
+
dataset manifest, and git commit hash -- a checkpoint directory should
|
| 254 |
+
be fully self-describing per the project's reproducibility requirement.
|
| 255 |
+
"""
|
| 256 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 257 |
+
|
| 258 |
+
model.save_pretrained(output_dir / "model")
|
| 259 |
+
tokenizer.save_pretrained(output_dir / "tokenizer")
|
| 260 |
+
|
| 261 |
+
with (output_dir / "training_config.yaml").open("w", encoding="utf-8") as f:
|
| 262 |
+
import yaml
|
| 263 |
+
|
| 264 |
+
yaml.safe_dump(config, f, sort_keys=False)
|
| 265 |
+
|
| 266 |
+
from transliteration.languages import LANGUAGES
|
| 267 |
+
|
| 268 |
+
languages_json = {
|
| 269 |
+
code: {"name": info.name, "script": info.script_name, "tag": info.tag}
|
| 270 |
+
for code, info in LANGUAGES.items()
|
| 271 |
+
}
|
| 272 |
+
with (output_dir / "languages.json").open("w", encoding="utf-8") as f:
|
| 273 |
+
json.dump(languages_json, f, ensure_ascii=False, indent=2)
|
| 274 |
+
|
| 275 |
+
from collections import Counter
|
| 276 |
+
|
| 277 |
+
manifest = {
|
| 278 |
+
"n_train_examples": len(train_records),
|
| 279 |
+
"n_validation_examples": len(val_records),
|
| 280 |
+
"train_language_counts": dict(Counter(r["language"] for r in train_records)),
|
| 281 |
+
"validation_language_counts": dict(Counter(r["language"] for r in val_records)),
|
| 282 |
+
"git_commit": get_git_commit_hash(),
|
| 283 |
+
}
|
| 284 |
+
with (output_dir / "dataset_manifest.json").open("w", encoding="utf-8") as f:
|
| 285 |
+
json.dump(manifest, f, ensure_ascii=False, indent=2)
|
| 286 |
+
|
| 287 |
+
logger.info("Checkpoint saved to %s", output_dir)
|