bhashantar 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bhashantar/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """IndicTrans2 English-to-Indic translation on CPU, without transformers or fairseq."""
2
+
3
+ from bhashantar.engine import (
4
+ MAX_BATCH_CHARS,
5
+ MAX_BATCH_SENTENCES,
6
+ MAX_SENTENCE_CHARS,
7
+ MAX_SOURCE_TOKENS,
8
+ InputTooLarge,
9
+ ModelNotAvailable,
10
+ Translator,
11
+ translate,
12
+ )
13
+ from bhashantar.languages import SOURCE, TARGETS, UnknownLanguage
14
+
15
+ __version__ = "0.1.0"
16
+
17
+ __all__ = [
18
+ "MAX_BATCH_CHARS",
19
+ "MAX_BATCH_SENTENCES",
20
+ "MAX_SENTENCE_CHARS",
21
+ "MAX_SOURCE_TOKENS",
22
+ "SOURCE",
23
+ "TARGETS",
24
+ "InputTooLarge",
25
+ "ModelNotAvailable",
26
+ "Translator",
27
+ "UnknownLanguage",
28
+ "translate",
29
+ ]
bhashantar/cli.py ADDED
@@ -0,0 +1,70 @@
1
+ """bhashantar command line: translate lines, list targets, convert the checkpoint."""
2
+
3
+ import argparse
4
+ import logging
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from bhashantar import engine
9
+ from bhashantar.languages import SOURCE, TARGETS, UnknownLanguage
10
+
11
+
12
+ def _translate(args) -> int:
13
+ if args.text:
14
+ lines = args.text
15
+ else:
16
+ stream = open(args.input, encoding="utf-8") if args.input else sys.stdin
17
+ with stream:
18
+ lines = [line.rstrip("\n") for line in stream]
19
+ translator = engine.Translator(model_dir=args.model, threads=args.threads, beam_size=args.beam)
20
+ out = []
21
+ for start in range(0, len(lines), engine.MAX_BATCH_SENTENCES):
22
+ out += translator.translate(lines[start : start + engine.MAX_BATCH_SENTENCES], src=args.src, tgt=args.tgt)
23
+ sys.stdout.reconfigure(encoding="utf-8")
24
+ sys.stdout.write("\n".join(out) + "\n")
25
+ return 0
26
+
27
+
28
+ def _convert(args) -> int:
29
+ from bhashantar import convert
30
+
31
+ checkpoint = Path(args.checkpoint) if args.checkpoint else convert.download_checkpoint()
32
+ out = convert.convert(checkpoint, Path(args.out) if args.out else engine.converted_model_dir())
33
+ print(out)
34
+ return 0
35
+
36
+
37
+ def main(argv: list[str] | None = None) -> int:
38
+ parser = argparse.ArgumentParser(prog="bhashantar", description="IndicTrans2 en-indic-dist-200M on CPU via CTranslate2 int8")
39
+ parser.add_argument("-v", "--verbose", action="store_true")
40
+ sub = parser.add_subparsers(dest="command", required=True)
41
+
42
+ t = sub.add_parser("translate", help="translate English lines (arguments, --input file, or stdin)")
43
+ t.add_argument("text", nargs="*")
44
+ t.add_argument("--tgt", required=True, help="target code, e.g. hin_Deva; see `bhashantar languages`")
45
+ t.add_argument("--src", default=SOURCE)
46
+ t.add_argument("-i", "--input", help="UTF-8 file, one sentence per line")
47
+ t.add_argument("--model", help="converted model directory (default: cache, then hub)")
48
+ t.add_argument("--threads", type=int, default=4)
49
+ t.add_argument("--beam", type=int, default=5)
50
+ t.set_defaults(run=_translate)
51
+
52
+ lang = sub.add_parser("languages", help="list target codes")
53
+ lang.set_defaults(run=lambda _args: print("\n".join(TARGETS)) or 0)
54
+
55
+ c = sub.add_parser("convert", help="convert the official checkpoint to CTranslate2 int8 (needs [convert] extra)")
56
+ c.add_argument("--checkpoint", help="local checkpoint directory (default: download the gated official repo)")
57
+ c.add_argument("--out", help=f"output directory (default: {engine.converted_model_dir()})")
58
+ c.set_defaults(run=_convert)
59
+
60
+ args = parser.parse_args(argv)
61
+ logging.basicConfig(level=logging.INFO if args.verbose else logging.WARNING, format="%(name)s: %(message)s")
62
+ try:
63
+ return args.run(args)
64
+ except (UnknownLanguage, engine.InputTooLarge, engine.ModelNotAvailable) as exc:
65
+ print(f"bhashantar: {exc}", file=sys.stderr)
66
+ return 2
67
+
68
+
69
+ if __name__ == "__main__":
70
+ sys.exit(main())
bhashantar/convert.py ADDED
@@ -0,0 +1,176 @@
1
+ """Convert the Hugging Face IndicTrans2 checkpoint to a CTranslate2 model.
2
+
3
+ CTranslate2 ships a converter for fairseq checkpoints but not for this
4
+ checkpoint's custom `IndicTransForConditionalGeneration`, and the fairseq
5
+ checkpoint needs fairseq installed. The architecture is the fairseq
6
+ pre-norm transformer with `layernorm_embedding`, so this module fills the same
7
+ `TransformerSpec` that `ctranslate2.converters.fairseq` fills, reading tensors
8
+ by their Hugging Face names. Needs `pip install bhashantar[convert]`.
9
+ """
10
+
11
+ import hashlib
12
+ import json
13
+ import logging
14
+ import math
15
+ import shutil
16
+ from pathlib import Path
17
+
18
+ import numpy as np
19
+
20
+ log = logging.getLogger(__name__)
21
+
22
+ SOURCE_REPO = "ai4bharat/indictrans2-en-indic-dist-200M"
23
+ _ASSETS = ("model.SRC", "model.TGT", "LICENSE")
24
+
25
+
26
+ def _load_tensors(checkpoint: Path) -> dict:
27
+ safetensors_file = checkpoint / "model.safetensors"
28
+ if safetensors_file.is_file():
29
+ from safetensors.numpy import load_file
30
+
31
+ return load_file(str(safetensors_file))
32
+ bin_file = checkpoint / "pytorch_model.bin"
33
+ if not bin_file.is_file():
34
+ raise FileNotFoundError(f"{checkpoint} has neither model.safetensors nor pytorch_model.bin")
35
+ try:
36
+ import torch
37
+ except ImportError as exc:
38
+ raise RuntimeError(f"{bin_file.name} is a torch pickle; install torch to read it, or use model.safetensors") from exc
39
+ state = torch.load(bin_file, map_location="cpu", weights_only=True, mmap=True)
40
+ return {name: tensor.float().numpy() for name, tensor in state.items()}
41
+
42
+
43
+ def sinusoidal_positions(max_positions: int, dim: int, padding_idx: int) -> np.ndarray:
44
+ """Position table as the checkpoint computes it at runtime (it is not
45
+ stored). Rows start at padding_idx + 1, which is where the reference model
46
+ places the first token."""
47
+ rows = max_positions + padding_idx + 1
48
+ half = dim // 2
49
+ scale = math.log(10000) / (half - 1)
50
+ freqs = np.exp(np.arange(half, dtype=np.float32) * -scale)
51
+ angles = np.arange(rows, dtype=np.float32)[:, None] * freqs[None, :]
52
+ table = np.concatenate([np.sin(angles), np.cos(angles)], axis=1).astype(np.float32)
53
+ return table[padding_idx + 1 :]
54
+
55
+
56
+ def _vocabulary(path: Path) -> list[str]:
57
+ token_to_id = json.loads(path.read_text(encoding="utf-8"))
58
+ tokens = [None] * len(token_to_id)
59
+ for token, index in token_to_id.items():
60
+ tokens[index] = token
61
+ if any(t is None for t in tokens):
62
+ raise ValueError(f"{path.name} ids are not contiguous from 0")
63
+ return tokens
64
+
65
+
66
+ def build_spec(checkpoint: Path):
67
+ from ctranslate2.converters import utils
68
+ from ctranslate2.specs import common_spec, transformer_spec
69
+
70
+ config = json.loads((checkpoint / "config.json").read_text(encoding="utf-8"))
71
+ if config.get("activation_function") != "gelu":
72
+ raise ValueError(f"activation {config.get('activation_function')!r} not handled; expected 'gelu'")
73
+ if not (config["encoder_normalize_before"] and config["decoder_normalize_before"] and config["layernorm_embedding"]):
74
+ raise ValueError("expected pre-norm encoder and decoder with layernorm_embedding")
75
+
76
+ tensors = _load_tensors(checkpoint)
77
+ spec = transformer_spec.TransformerSpec.from_config(
78
+ (config["encoder_layers"], config["decoder_layers"]),
79
+ config["encoder_attention_heads"],
80
+ pre_norm=True,
81
+ activation=common_spec.Activation.GELU,
82
+ layernorm_embedding=True,
83
+ )
84
+
85
+ def take(name):
86
+ return tensors.pop(name)
87
+
88
+ def linear(target, prefix):
89
+ target.weight = take(f"{prefix}.weight")
90
+ target.bias = take(f"{prefix}.bias")
91
+
92
+ def norm(target, prefix):
93
+ target.gamma = take(f"{prefix}.weight")
94
+ target.beta = take(f"{prefix}.bias")
95
+
96
+ def attention(target, prefix, self_attention):
97
+ names = ("q_proj", "k_proj", "v_proj") if self_attention else ("k_proj", "v_proj")
98
+ parts = [common_spec.LinearSpec() for _ in names]
99
+ for part, name in zip(parts, names):
100
+ linear(part, f"{prefix}.{name}")
101
+ if self_attention:
102
+ utils.fuse_linear(target.linear[0], parts)
103
+ else:
104
+ linear(target.linear[0], f"{prefix}.q_proj")
105
+ utils.fuse_linear(target.linear[1], parts)
106
+ linear(target.linear[-1], f"{prefix}.out_proj")
107
+
108
+ def stack(target, prefix, dim_key, max_pos_key):
109
+ embeddings = target.embeddings[0] if isinstance(target.embeddings, list) else target.embeddings
110
+ embeddings.weight = take(f"{prefix}.embed_tokens.weight")
111
+ target.scale_embeddings = math.sqrt(config[dim_key]) if config["scale_embedding"] else 1.0
112
+ target.position_encodings.encodings = sinusoidal_positions(
113
+ config[max_pos_key], config[dim_key], config["pad_token_id"]
114
+ )
115
+ norm(target.layernorm_embedding, f"{prefix}.layernorm_embedding")
116
+ norm(target.layer_norm, f"{prefix}.layer_norm")
117
+ for i, layer in enumerate(target.layer):
118
+ p = f"{prefix}.layers.{i}"
119
+ attention(layer.self_attention, f"{p}.self_attn", self_attention=True)
120
+ norm(layer.self_attention.layer_norm, f"{p}.self_attn_layer_norm")
121
+ if hasattr(layer, "attention"):
122
+ attention(layer.attention, f"{p}.encoder_attn", self_attention=False)
123
+ norm(layer.attention.layer_norm, f"{p}.encoder_attn_layer_norm")
124
+ norm(layer.ffn.layer_norm, f"{p}.final_layer_norm")
125
+ linear(layer.ffn.linear_0, f"{p}.fc1")
126
+ linear(layer.ffn.linear_1, f"{p}.fc2")
127
+
128
+ stack(spec.encoder, "model.encoder", "encoder_embed_dim", "max_source_positions")
129
+ stack(spec.decoder, "model.decoder", "decoder_embed_dim", "max_target_positions")
130
+ spec.decoder.projection.weight = take("lm_head.weight")
131
+ leftovers = sorted(n for n in tensors if "embed_positions" not in n)
132
+ if leftovers:
133
+ raise ValueError(f"checkpoint tensors not mapped: {leftovers[:5]}")
134
+
135
+ spec.register_source_vocabulary(_vocabulary(checkpoint / "dict.SRC.json"))
136
+ spec.register_target_vocabulary(_vocabulary(checkpoint / "dict.TGT.json"))
137
+ spec.config.decoder_start_token = "</s>"
138
+ spec.config.add_source_eos = True
139
+ return spec
140
+
141
+
142
+ def _sha256(path: Path) -> str:
143
+ digest = hashlib.sha256()
144
+ with path.open("rb") as fh:
145
+ for block in iter(lambda: fh.read(1 << 20), b""):
146
+ digest.update(block)
147
+ return digest.hexdigest()
148
+
149
+
150
+ def convert(checkpoint: Path, out_dir: Path, quantization: str = "int8") -> Path:
151
+ checkpoint, out_dir = Path(checkpoint), Path(out_dir)
152
+ spec = build_spec(checkpoint)
153
+ spec.validate()
154
+ spec.optimize(quantization=quantization)
155
+ out_dir.mkdir(parents=True, exist_ok=True)
156
+ spec.save(str(out_dir))
157
+ for name in _ASSETS:
158
+ if (checkpoint / name).is_file():
159
+ shutil.copy2(checkpoint / name, out_dir / name)
160
+ weights = next(p for p in (checkpoint / "model.safetensors", checkpoint / "pytorch_model.bin") if p.is_file())
161
+ provenance = {
162
+ "source_repo": SOURCE_REPO,
163
+ "source_weights": weights.name,
164
+ "source_sha256": _sha256(weights),
165
+ "quantization": quantization,
166
+ }
167
+ (out_dir / "provenance.json").write_text(json.dumps(provenance, indent=2) + "\n", encoding="utf-8")
168
+ log.info("wrote %s", out_dir)
169
+ return out_dir
170
+
171
+
172
+ def download_checkpoint() -> Path:
173
+ from huggingface_hub import snapshot_download
174
+
175
+ files = ["config.json", "dict.SRC.json", "dict.TGT.json", "model.SRC", "model.TGT", "LICENSE", "model.safetensors"]
176
+ return Path(snapshot_download(SOURCE_REPO, allow_patterns=files))
bhashantar/engine.py ADDED
@@ -0,0 +1,187 @@
1
+ """CTranslate2 int8 inference for IndicTrans2 en-indic-dist-200M."""
2
+
3
+ import logging
4
+ import os
5
+ from functools import lru_cache
6
+ from pathlib import Path
7
+
8
+ import ctranslate2
9
+ import sentencepiece as spm
10
+
11
+ from bhashantar import textprep
12
+ from bhashantar.languages import SOURCE, check_direction
13
+
14
+ log = logging.getLogger(__name__)
15
+
16
+ HUB_REPO = "zaidwhy/indictrans2-en-indic-dist-200M-ct2-int8"
17
+ MODEL_ENV = "BHASHANTAR_MODEL"
18
+
19
+ MAX_SENTENCE_CHARS = 1000
20
+ MAX_BATCH_SENTENCES = 512
21
+ MAX_BATCH_CHARS = 50_000
22
+ # The checkpoint was trained with 256 source positions: two language tags,
23
+ # the pieces, and </s>. Longer input is refused, not truncated, because
24
+ # truncation drops the end of the sentence without telling anyone.
25
+ MAX_SOURCE_TOKENS = 256
26
+
27
+ _SPECIAL = {"<s>", "<pad>", "</s>", "<unk>"}
28
+
29
+
30
+ class InputTooLarge(ValueError):
31
+ """Input over one of the documented caps; the message names the cap."""
32
+
33
+
34
+ class ModelNotAvailable(RuntimeError):
35
+ """No converted model on disk and none downloadable."""
36
+
37
+
38
+ def converted_model_dir() -> Path:
39
+ hf_home = Path(os.environ.get("HF_HOME", Path.home() / ".cache" / "huggingface"))
40
+ return hf_home / "bhashantar" / "en-indic-dist-200M-ct2-int8"
41
+
42
+
43
+ def resolve_model_dir(model_dir: str | os.PathLike | None = None) -> Path:
44
+ """Explicit path, then $BHASHANTAR_MODEL, then a local `bhashantar convert`
45
+ output, then the hub repo (cache first, network only on a cache miss)."""
46
+ if model_dir is None:
47
+ model_dir = os.environ.get(MODEL_ENV)
48
+ if model_dir is not None:
49
+ path = Path(model_dir)
50
+ if not (path / "model.bin").is_file():
51
+ raise ModelNotAvailable(f"{path} has no model.bin; point it at a converted CTranslate2 model directory")
52
+ return path
53
+ local = converted_model_dir()
54
+ if (local / "model.bin").is_file():
55
+ return local
56
+
57
+ from huggingface_hub import snapshot_download
58
+ from huggingface_hub.errors import HfHubHTTPError, LocalEntryNotFoundError
59
+
60
+ try:
61
+ return Path(snapshot_download(HUB_REPO, local_files_only=True))
62
+ except LocalEntryNotFoundError:
63
+ log.info("model not cached, downloading %s", HUB_REPO)
64
+ try:
65
+ return Path(snapshot_download(HUB_REPO))
66
+ except (HfHubHTTPError, LocalEntryNotFoundError, OSError) as exc:
67
+ raise ModelNotAvailable(
68
+ f"could not fetch {HUB_REPO} ({exc.__class__.__name__}). Either run "
69
+ f"`bhashantar convert` once (needs `pip install bhashantar[convert]` and a Hugging Face "
70
+ f"login with access to ai4bharat/indictrans2-en-indic-dist-200M), or set {MODEL_ENV} "
71
+ f"to a converted model directory"
72
+ ) from exc
73
+
74
+
75
+ def _clean_up_spaces(text: str) -> str:
76
+ # transformers' clean_up_tokenization, which the reference decode applies.
77
+ for spaced, tight in (
78
+ (" .", "."),
79
+ (" ?", "?"),
80
+ (" !", "!"),
81
+ (" ,", ","),
82
+ (" ' ", "'"),
83
+ (" n't", "n't"),
84
+ (" 'm", "'m"),
85
+ (" 's", "'s"),
86
+ (" 've", "'ve"),
87
+ (" 're", "'re"),
88
+ ):
89
+ text = text.replace(spaced, tight)
90
+ return text
91
+
92
+
93
+ class Translator:
94
+ def __init__(
95
+ self,
96
+ model_dir: str | os.PathLike | None = None,
97
+ threads: int = 4,
98
+ beam_size: int = 5,
99
+ max_batch_size: int = 16,
100
+ ):
101
+ # Loading waits for the first translate() so that bad codes and
102
+ # oversize input are refused before 200 MB of weights are read.
103
+ self._requested_dir = model_dir
104
+ self.threads = threads
105
+ self.beam_size = beam_size
106
+ self.max_batch_size = max_batch_size
107
+ self.model_dir = None
108
+ self._model = None
109
+
110
+ def _load(self) -> None:
111
+ self.model_dir = resolve_model_dir(self._requested_dir)
112
+ self._source_pieces = spm.SentencePieceProcessor(model_file=str(self.model_dir / "model.SRC"))
113
+ self._model = ctranslate2.Translator(
114
+ str(self.model_dir), device="cpu", compute_type="int8", intra_threads=self.threads
115
+ )
116
+ log.info("loaded %s", self.model_dir)
117
+
118
+ def _source_tokens(self, model_text: str) -> list[str]:
119
+ src_tag, tgt_tag, body = model_text.split(" ", 2)
120
+ return [src_tag, tgt_tag] + self._source_pieces.encode(body, out_type=str)
121
+
122
+ def translate(self, texts: list[str], src: str = SOURCE, tgt: str = "hin_Deva") -> list[str]:
123
+ check_direction(src, tgt)
124
+ if isinstance(texts, str):
125
+ raise TypeError("texts must be a list of strings; wrap a single sentence as [text]")
126
+ _check_batch(texts)
127
+ if self._model is None:
128
+ self._load()
129
+
130
+ prepared, sources, slots = [], [], []
131
+ for i, sentence in enumerate(texts):
132
+ if not sentence.strip():
133
+ continue
134
+ p = textprep.prepare(sentence, tgt)
135
+ tokens = self._source_tokens(p.model_text)
136
+ if len(tokens) + 1 > MAX_SOURCE_TOKENS:
137
+ raise InputTooLarge(
138
+ f"texts[{i}] is {len(tokens) + 1} tokens after preprocessing; "
139
+ f"MAX_SOURCE_TOKENS is {MAX_SOURCE_TOKENS}. Split the sentence."
140
+ )
141
+ prepared.append(p)
142
+ sources.append(tokens)
143
+ slots.append(i)
144
+
145
+ out = [""] * len(texts)
146
+ if not sources:
147
+ return out
148
+ results = self._model.translate_batch(
149
+ sources,
150
+ beam_size=self.beam_size,
151
+ max_batch_size=self.max_batch_size,
152
+ max_decoding_length=MAX_SOURCE_TOKENS,
153
+ )
154
+ for slot, p, result in zip(slots, prepared, results):
155
+ pieces = [t for t in result.hypotheses[0] if t not in _SPECIAL]
156
+ decoded = _clean_up_spaces("".join(pieces).replace("▁", " ").strip())
157
+ out[slot] = textprep.finish(decoded, tgt, p.placeholders)
158
+ return out
159
+
160
+
161
+ def _check_batch(texts: list[str]) -> None:
162
+ if len(texts) > MAX_BATCH_SENTENCES:
163
+ raise InputTooLarge(f"{len(texts)} sentences in one call; MAX_BATCH_SENTENCES is {MAX_BATCH_SENTENCES}")
164
+ total = 0
165
+ for i, sentence in enumerate(texts):
166
+ if not isinstance(sentence, str):
167
+ raise TypeError(f"texts[{i}] is {type(sentence).__name__}, expected str")
168
+ if len(sentence) > MAX_SENTENCE_CHARS:
169
+ raise InputTooLarge(
170
+ f"texts[{i}] is {len(sentence)} characters; MAX_SENTENCE_CHARS is {MAX_SENTENCE_CHARS}"
171
+ )
172
+ total += len(sentence)
173
+ if total > MAX_BATCH_CHARS:
174
+ raise InputTooLarge(f"batch is {total} characters; MAX_BATCH_CHARS is {MAX_BATCH_CHARS}")
175
+
176
+
177
+ @lru_cache(maxsize=1)
178
+ def default_translator() -> Translator:
179
+ return Translator()
180
+
181
+
182
+ def translate(texts: list[str], src: str = SOURCE, tgt: str = "hin_Deva") -> list[str]:
183
+ """Translate English sentences into one Indic language with the shared model.
184
+
185
+ The first call loads the model; later calls reuse it.
186
+ """
187
+ return default_translator().translate(texts, src=src, tgt=tgt)
@@ -0,0 +1,98 @@
1
+ """Script conversion and detokenisation for Indic output.
2
+
3
+ Vendored from indic_nlp_library (MIT, Copyright (c) 2013-present Anoop
4
+ Kunchukuttan): `UnicodeIndicTransliterator.transliterate` and
5
+ `trivial_detokenize_indic`, reduced to the paths IndicTrans2 inference uses.
6
+ Behaviour is kept byte-for-byte, quirks included, because the parity number
7
+ depends on it. The upstream package pulls pandas, morfessor and sphinx for
8
+ code we do not call.
9
+
10
+ IndicTrans2 decodes every Brahmic target in Devanagari; converting back to the
11
+ target script is a fixed code-point offset inside the 0x00-0x6F "coordinated"
12
+ range that all these Unicode blocks share.
13
+ """
14
+
15
+ import regex as re
16
+
17
+ SCRIPT_RANGES = {
18
+ "pa": 0x0A00,
19
+ "gu": 0x0A80,
20
+ "or": 0x0B00,
21
+ "ta": 0x0B80,
22
+ "te": 0x0C00,
23
+ "kn": 0x0C80,
24
+ "ml": 0x0D00,
25
+ "si": 0x0D80,
26
+ "hi": 0x0900,
27
+ "mr": 0x0900,
28
+ "kK": 0x0900,
29
+ "sa": 0x0900,
30
+ "ne": 0x0900,
31
+ "sd": 0x0900,
32
+ "bn": 0x0980,
33
+ "as": 0x0980,
34
+ }
35
+ _COORDINATED_LAST = 0x6F
36
+ _DANDAS = ("।", "॥")
37
+
38
+
39
+ def _tamil_offset(offset: int) -> int:
40
+ # Tamil has no voiced or aspirated plosives; fold them onto the unvoiced
41
+ # unaspirated letter of the same row (ja keeps its own letter).
42
+ if 0x15 <= offset <= 0x28 and offset != 0x1C and (offset - 0x15) % 5 not in (0, 4):
43
+ offset = 0x15 + 5 * ((offset - 0x15) // 5)
44
+ if offset in (0x2B, 0x2C, 0x2D):
45
+ offset = 0x2A
46
+ if offset == 0x36:
47
+ offset = 0x37
48
+ return offset
49
+
50
+
51
+ def transliterate(text: str, from_lang: str, to_lang: str) -> str:
52
+ if from_lang not in SCRIPT_RANGES or to_lang not in SCRIPT_RANGES:
53
+ return text
54
+ base_from = SCRIPT_RANGES[from_lang]
55
+ base_to = SCRIPT_RANGES[to_lang]
56
+ out = []
57
+ for ch in text:
58
+ offset = ord(ch) - base_from
59
+ if 0 <= offset <= _COORDINATED_LAST and ch not in _DANDAS:
60
+ if to_lang == "ta":
61
+ offset = _tamil_offset(offset)
62
+ ch = chr(base_to + offset)
63
+ out.append(ch)
64
+ return "".join(out)
65
+
66
+
67
+ _LEFT_ATTACH = re.compile(r"[ ]([!%)\]},.:;>?।॥])")
68
+ _RIGHT_ATTACH = re.compile(r"([#$(\[{<@])[ ]")
69
+ _BOTH_ATTACH = re.compile(r"[ ]([-/\\])[ ]")
70
+ _NUMBER_RUN = re.compile(r"([0-9]+ [,.:/] )+[0-9]+")
71
+
72
+
73
+ def detokenize(text: str) -> str:
74
+ parts = []
75
+ prev = 0
76
+ for m in _NUMBER_RUN.finditer(text):
77
+ # Upstream only collapses a number run when text precedes it; a run at
78
+ # position 0 is left spaced. Kept as is for parity.
79
+ if m.start() > prev:
80
+ parts.append(text[prev : m.start()])
81
+ parts.append(text[m.start() : m.end()].replace(" ", ""))
82
+ prev = m.end()
83
+ parts.append(text[prev:])
84
+ s = "".join(parts)
85
+ s = _BOTH_ATTACH.sub("\\1", s)
86
+ s = _LEFT_ATTACH.sub("\\1", s)
87
+ s = _RIGHT_ATTACH.sub("\\1", s)
88
+ for quote in "'\"`":
89
+ marked = []
90
+ count = 0
91
+ for ch in s:
92
+ if ch == quote:
93
+ marked.append("@RA" if count % 2 == 0 else "@LA")
94
+ count += 1
95
+ else:
96
+ marked.append(ch)
97
+ s = "".join(marked).replace("@RA ", quote).replace(" @LA", quote).replace("@RA", quote).replace("@LA", quote)
98
+ return s
@@ -0,0 +1,80 @@
1
+ """Language codes the en-indic-dist-200M checkpoint was trained on.
2
+
3
+ Codes are FLORES-200 style (`hin_Deva`). The checkpoint only translates out of
4
+ English, so `eng_Latn` is the one source and the 25 Indic codes are targets.
5
+ `ISO` maps each code to the indicnlp language key that the upstream
6
+ post-processing used for script conversion and detokenisation; several codes
7
+ map to "hi" on purpose, because their script needs no conversion.
8
+ """
9
+
10
+ SOURCE = "eng_Latn"
11
+
12
+ ISO = {
13
+ "asm_Beng": "as",
14
+ "ben_Beng": "bn",
15
+ "brx_Deva": "hi",
16
+ "doi_Deva": "hi",
17
+ "gom_Deva": "kK",
18
+ "guj_Gujr": "gu",
19
+ "hin_Deva": "hi",
20
+ "kan_Knda": "kn",
21
+ "kas_Arab": "ur",
22
+ "kas_Deva": "hi",
23
+ "mai_Deva": "hi",
24
+ "mal_Mlym": "ml",
25
+ "mar_Deva": "mr",
26
+ "mni_Beng": "bn",
27
+ "mni_Mtei": "hi",
28
+ "npi_Deva": "ne",
29
+ "ory_Orya": "or",
30
+ "pan_Guru": "pa",
31
+ "san_Deva": "hi",
32
+ "sat_Olck": "or",
33
+ "snd_Arab": "ur",
34
+ "snd_Deva": "hi",
35
+ "tam_Taml": "ta",
36
+ "tel_Telu": "te",
37
+ "urd_Arab": "ur",
38
+ }
39
+
40
+ TARGETS = tuple(sorted(ISO))
41
+
42
+ # Unicode blocks per script suffix, used to tell whether output landed in the
43
+ # script the caller asked for.
44
+ SCRIPT_BLOCKS = {
45
+ "Deva": [(0x0900, 0x097F)],
46
+ "Beng": [(0x0980, 0x09FF)],
47
+ "Guru": [(0x0A00, 0x0A7F)],
48
+ "Gujr": [(0x0A80, 0x0AFF)],
49
+ "Orya": [(0x0B00, 0x0B7F)],
50
+ "Taml": [(0x0B80, 0x0BFF)],
51
+ "Telu": [(0x0C00, 0x0C7F)],
52
+ "Knda": [(0x0C80, 0x0CFF)],
53
+ "Mlym": [(0x0D00, 0x0D7F)],
54
+ "Arab": [(0x0600, 0x06FF), (0x0750, 0x077F), (0xFB50, 0xFDFF), (0xFE70, 0xFEFF)],
55
+ "Olck": [(0x1C50, 0x1C7F)],
56
+ "Mtei": [(0xABC0, 0xABFF), (0xAAE0, 0xAAFF)],
57
+ }
58
+
59
+
60
+ class UnknownLanguage(ValueError):
61
+ """A language code outside what this checkpoint supports."""
62
+
63
+
64
+ def check_direction(src: str, tgt: str) -> None:
65
+ if src != SOURCE:
66
+ raise UnknownLanguage(
67
+ f"src={src!r} is not supported: this model translates from {SOURCE!r} only"
68
+ )
69
+ if tgt not in ISO:
70
+ raise UnknownLanguage(f"tgt={tgt!r} is not a supported target; valid codes: {', '.join(TARGETS)}")
71
+
72
+
73
+ def script_share(text: str, code: str) -> float:
74
+ """Fraction of letters in `text` that fall inside the script of `code`."""
75
+ blocks = SCRIPT_BLOCKS[code.split("_", 1)[1]]
76
+ letters = [ch for ch in text if ch.isalpha() or 0x0900 <= ord(ch) <= 0x0DFF]
77
+ if not letters:
78
+ return 0.0
79
+ inside = sum(1 for ch in letters if any(lo <= ord(ch) <= hi for lo, hi in blocks))
80
+ return inside / len(letters)
bhashantar/textprep.py ADDED
@@ -0,0 +1,183 @@
1
+ """Text in and out of the model: English normalisation, entity placeholders,
2
+ Indic script restoration.
3
+
4
+ Vendored from IndicTransToolkit's IndicProcessor (MIT, Copyright (c) Varun
5
+ Gumma), cut down to the English-source path this checkpoint uses. The upstream
6
+ class keeps placeholder maps in a shared queue, so a failed batch leaves stale
7
+ maps behind for the next one; here each sentence carries its own map.
8
+
9
+ Regexes, their order and the "ID" failure spellings are upstream's. Changing
10
+ any of them moves the parity number, so they are not tidied.
11
+ """
12
+
13
+ from dataclasses import dataclass, field
14
+
15
+ import regex as re
16
+ from sacremoses import MosesPunctNormalizer, MosesTokenizer
17
+
18
+ from bhashantar import indic_script
19
+ from bhashantar.languages import ISO, SOURCE
20
+
21
+ _DIGITS = {}
22
+ for _ascii, _variants in enumerate(
23
+ [
24
+ "\u09e6\u0ae6\u0ce6\u0966\u0660\uabf0\u0b66\u0a66\u1c50\u06f0",
25
+ "\u09e7\u0ae7\u0967\u0ce7\u06f1\uabf1\u0b67\u0a67\u1c51\u0c67",
26
+ "\u09e8\u0ae8\u0968\u0ce8\u06f2\uabf2\u0b68\u0a68\u1c52\u0c68",
27
+ "\u09e9\u0ae9\u0969\u0ce9\u06f3\uabf3\u0b69\u0a69\u1c53\u0c69",
28
+ "\u09ea\u0aea\u096a\u0cea\u06f4\uabf4\u0b6a\u0a6a\u1c54\u0c6a",
29
+ "\u09eb\u0aeb\u096b\u0ceb\u06f5\uabf5\u0b6b\u0a6b\u1c55\u0c6b",
30
+ "\u09ec\u0aec\u096c\u0cec\u06f6\uabf6\u0b6c\u0a6c\u1c56\u0c6c",
31
+ "\u09ed\u0aed\u096d\u0ced\u06f7\uabf7\u0b6d\u0a6d\u1c57\u0c6d",
32
+ "\u09ee\u0aee\u096e\u0cee\u06f8\uabf8\u0b6e\u0a6e\u1c58\u0c6e",
33
+ "\u09ef\u0aef\u096f\u0cef\u06f9\uabf9\u0b6f\u0a6f\u1c59\u0c6f",
34
+ ]
35
+ ):
36
+ for _ch in _variants:
37
+ _DIGITS[ord(_ch)] = str(_ascii)
38
+
39
+ _PUNC_REPLACEMENTS = [
40
+ (re.compile(r"\r"), ""),
41
+ (re.compile(r"\(\s*"), "("),
42
+ (re.compile(r"\s*\)"), ")"),
43
+ (re.compile(r"\s:\s?"), ":"),
44
+ (re.compile(r"\s;\s?"), ";"),
45
+ (re.compile(r"[`\u00b4\u2018\u201a\u2019]"), "'"),
46
+ (re.compile(r"[\u201e\u201c\u201d\u00ab\u00bb]"), '"'),
47
+ (re.compile("[\u2013\u2014]"), "-"),
48
+ (re.compile(r"\.\.\."), "..."),
49
+ (re.compile(r" %"), "%"),
50
+ (re.compile(r"n\u00ba "), "n\u00ba "),
51
+ (re.compile(r" \u00baC"), " \u00baC"),
52
+ (re.compile(r" [?!;]"), lambda m: m.group(0).strip()),
53
+ (re.compile(r", "), ", "),
54
+ ]
55
+ _MULTISPACE = re.compile(r"[ ]{2,}")
56
+ _BRACKET_PUNC = re.compile(r"\) ([\.!:?;,])")
57
+ _DIGIT_SPACE_PERCENT = re.compile(r"(\d) %")
58
+ _QUOTE_PUNC = re.compile(r"\"([,\.]+)")
59
+ _DIGIT_SPACE_DIGIT = re.compile(r"(\d) (\d)")
60
+
61
+ URL_PATTERN = re.compile(r"\b(?<![\w/.])(?:(?:https?|ftp)://)?(?:(?:[\w-]+\.)+(?!\.))(?:[\w/\-?#&=%.]+)+(?!\.\w+)\b")
62
+ NUMERAL_PATTERN = re.compile(
63
+ r"(~?\d+\.?\d*\s?%?\s?-?\s?~?\d+\.?\d*\s?%|~?\d+%|\d+[-\/.,:']\d+[-\/.,:'+]\d+(?:\.\d+)?|\d+[-\/.:'+]\d+(?:\.\d+)?)"
64
+ )
65
+ EMAIL_PATTERN = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}")
66
+ HANDLE_PATTERN = re.compile(r"[A-Za-z0-9]*[#|@]\w+")
67
+ _PLACEHOLDER_ORDER = (EMAIL_PATTERN, URL_PATTERN, NUMERAL_PATTERN, HANDLE_PATTERN)
68
+
69
+ # How the model sometimes mangles "<ID1>" in its output, per script.
70
+ _ID_SPELLINGS = [
71
+ "\u0622\u06cc \u0688\u06cc ",
72
+ "\uabd1\uabe5\uabcf\uabd7\uabe4",
73
+ "\u0906\u0908\u0921\u0940",
74
+ "\u0906\u0908 . \u0921\u0940 . ",
75
+ "\u0906\u0908 . \u0921\u0940 .",
76
+ "\u0906\u0908. \u0921\u0940. ",
77
+ "\u0906\u0908. \u0921\u0940.",
78
+ "\u0906\u092f. \u0921\u0940. ",
79
+ "\u0906\u092f. \u0921\u0940.",
80
+ "\u0906\u092f . \u0921\u0940 . ",
81
+ # Upstream is missing a comma here, so these two spellings arrive fused.
82
+ "\u0906\u092f . \u0921\u0940 .\u0906\u0907 . \u0921\u0940 . ",
83
+ "\u0906\u0907 . \u0921\u0940 .",
84
+ "\u0906\u0907. \u0921\u0940. ",
85
+ "\u0906\u0907. \u0921\u0940.",
86
+ "\u0910\u091f\u093f",
87
+ "\u0622\u0626\u06cc \u0688\u06cc ",
88
+ "\u1c5f\u1c6d\u1c70\u1c64 \u1c7e",
89
+ "\u0906\u092f\u0921\u0940",
90
+ "\u0910\u0921\u093f",
91
+ "\u0906\u0907\u0921\u093f",
92
+ "\u1c5f\u1c6d\u1c70\u1c64",
93
+ ]
94
+
95
+ _moses_norm = MosesPunctNormalizer()
96
+ _moses_tok = MosesTokenizer(lang="en")
97
+
98
+
99
+ @dataclass
100
+ class PreparedSentence:
101
+ model_text: str
102
+ placeholders: dict = field(default_factory=dict)
103
+
104
+
105
+ def _normalise_punctuation(text: str) -> str:
106
+ for pattern, replacement in _PUNC_REPLACEMENTS:
107
+ text = pattern.sub(replacement, text)
108
+ text = _MULTISPACE.sub(" ", text)
109
+ text = _BRACKET_PUNC.sub(r")\1", text)
110
+ text = _DIGIT_SPACE_PERCENT.sub(r"\1%", text)
111
+ text = _QUOTE_PUNC.sub(r'\1"', text)
112
+ text = _DIGIT_SPACE_DIGIT.sub(r"\1.\2", text)
113
+ return text.strip()
114
+
115
+
116
+ def _spellings_for(serial: int) -> list[str]:
117
+ n = serial
118
+ spellings = []
119
+ for tag in ("ID", "id"):
120
+ spellings += [f"<{tag}{n}>", f"< {tag}{n} >", f"[{tag}{n}]", f"[ {tag}{n} ]", f"[{tag} {n}]"]
121
+ spellings += [f"<{tag}{n}]", f"< {tag}{n}]", f"<{tag}{n} ]"]
122
+ for c in _ID_SPELLINGS:
123
+ spellings += [
124
+ f"<{c}{n}>",
125
+ f"< {c}{n} >",
126
+ f"< {c} {n} >",
127
+ f"<{c} {n}]",
128
+ f"< {c} {n} ]",
129
+ f"[{c}{n}]",
130
+ f"[{c} {n}]",
131
+ f"[ {c}{n} ]",
132
+ f"[ {c} {n} ]",
133
+ f"{c} {n}",
134
+ f"{c}{n}",
135
+ ]
136
+ return spellings
137
+
138
+
139
+ def _wrap_entities(text: str) -> tuple[str, dict]:
140
+ """Swap URLs, emails, numbers and handles for <IDn> so the model copies
141
+ them instead of translating them. Returns the text and the restore map."""
142
+ serial = 1
143
+ restore = {}
144
+ for pattern in _PLACEHOLDER_ORDER:
145
+ # Upstream iterates a set, so serial order follows string hashing and
146
+ # varies between processes; sorting makes it stable without changing
147
+ # which entities are wrapped.
148
+ for match in sorted(set(pattern.findall(text))):
149
+ if pattern is URL_PATTERN and len(match.replace(".", "")) < 4:
150
+ continue
151
+ if pattern is NUMERAL_PATTERN and len(match.replace(" ", "").replace(".", "").replace(":", "")) < 4:
152
+ continue
153
+ for spelling in _spellings_for(serial):
154
+ restore[spelling] = match
155
+ text = text.replace(match, f"<ID{serial}>")
156
+ serial += 1
157
+ text = re.sub(r"\s+", " ", text).replace(">/", ">").replace("]/", "]")
158
+ return text, restore
159
+
160
+
161
+ def prepare(sentence: str, tgt: str) -> PreparedSentence:
162
+ text = _normalise_punctuation(sentence)
163
+ text = text.translate(_DIGITS)
164
+ text, restore = _wrap_entities(text)
165
+ text = _moses_norm.normalize(text.strip())
166
+ text = " ".join(_moses_tok.tokenize(text, escape=False)).strip()
167
+ return PreparedSentence(model_text=f"{SOURCE} {tgt} {text}", placeholders=restore)
168
+
169
+
170
+ def finish(decoded: str, tgt: str, placeholders: dict) -> str:
171
+ lang, script = tgt.split("_", 1)
172
+ if script in ("Arab", "Aran"):
173
+ decoded = (
174
+ decoded.replace(" \u061f", "\u061f")
175
+ .replace(" \u06d4", "\u06d4")
176
+ .replace(" \u060c", "\u060c")
177
+ .replace("\u066e\u06ea", "\u0620")
178
+ )
179
+ if lang == "ory":
180
+ decoded = decoded.replace("\u0b2f\u0b3c", "\u0b5f")
181
+ for spelling, original in placeholders.items():
182
+ decoded = decoded.replace(spelling, original)
183
+ return indic_script.detokenize(indic_script.transliterate(decoded, "hi", ISO[tgt]))
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.5
2
+ Name: bhashantar
3
+ Version: 0.1.0
4
+ Summary: IndicTrans2 English-to-Indic translation on CPU via CTranslate2 int8, no transformers or fairseq at runtime
5
+ Author-email: Zaid Ali Syed <sidzaid72@gmail.com>
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ License-File: NOTICE
9
+ Keywords: ctranslate2,hindi,indic,indictrans2,machine-translation
10
+ Classifier: Natural Language :: Hindi
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Text Processing :: Linguistic
13
+ Requires-Python: >=3.10
14
+ Requires-Dist: ctranslate2<5,>=4.4
15
+ Requires-Dist: huggingface-hub>=0.24
16
+ Requires-Dist: regex>=2023.0
17
+ Requires-Dist: sacremoses>=0.1.1
18
+ Requires-Dist: sentencepiece<0.3,>=0.2
19
+ Provides-Extra: convert
20
+ Requires-Dist: safetensors>=0.4; extra == 'convert'
21
+ Provides-Extra: dev
22
+ Requires-Dist: psutil>=5.9; extra == 'dev'
23
+ Requires-Dist: pytest>=8; extra == 'dev'
24
+ Requires-Dist: sacrebleu>=2.4; extra == 'dev'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # bhashantar
28
+
29
+ English to 25 Indic languages with AI4Bharat's IndicTrans2 (`en-indic-dist-200M`), on CPU, through CTranslate2 int8. No transformers, no torch, no fairseq and no IndicTransToolkit at runtime.
30
+
31
+ ```python
32
+ from bhashantar import translate
33
+
34
+ translate(["The weather in Mumbai is pleasant today."], tgt="hin_Deva")
35
+ # ['मुंबई में आज मौसम सुखद है।']
36
+ ```
37
+
38
+ ```
39
+ $ bhashantar translate --tgt hin_Deva "Send the report to ops@example.com by 15-03-2025."
40
+ रिपोर्ट को 15-03-2025 द्वारा ops@example.com पर भेजें।
41
+ ```
42
+
43
+ ## Why this exists
44
+
45
+ The official checkpoint loads through `trust_remote_code` files written against transformers 4.3x. On a fresh Python 3.12 venv with transformers 5.22.2 and IndicTransToolkit 1.1.1 (reproduced 2026-11-09):
46
+
47
+ ```
48
+ from IndicTransToolkit.processor import IndicProcessor
49
+ ImportError: cannot import name 'PreTrainedTokenizerBase' from 'transformers.tokenization_utils'
50
+
51
+ AutoTokenizer.from_pretrained(..., trust_remote_code=True)
52
+ ModuleNotFoundError: No module named 'transformers.onnx'
53
+
54
+ AutoModelForSeq2SeqLM.from_pretrained(..., trust_remote_code=True)
55
+ ModuleNotFoundError: No module named 'transformers.onnx'
56
+ ```
57
+
58
+ This is [IndicTransToolkit#29](https://github.com/VarunGumma/IndicTransToolkit/issues/29). A fix for the remote code is open as discussion #15 on the model repo, unmerged at the time of writing. Pinning transformers 4.50.3 makes the stack load again; that pinned stack is the reference this package is measured against.
59
+
60
+ ## Data path
61
+
62
+ ```
63
+ raw text
64
+ -> punctuation and digit normalisation, URLs/emails/numbers swapped for <IDn> (textprep.prepare)
65
+ -> Moses tokenisation, "eng_Latn hin_Deva" tags, SentencePiece pieces (engine)
66
+ -> CTranslate2 int8 beam search (beam 5), Devanagari-unified pieces out
67
+ -> detokenise pieces, restore <IDn>, Devanagari -> target script, reattach punctuation (textprep.finish)
68
+ ```
69
+
70
+ The model writes every Brahmic target in Devanagari; Tamil, Bengali and the rest come from a fixed Unicode offset afterwards. That step and the placeholder restore are where a port silently goes wrong, so both are vendored from IndicTransToolkit and indic_nlp_library (MIT, see `NOTICE`) with their regexes and quirks intact. Two deliberate changes: placeholder maps travel with each sentence instead of a shared queue, and placeholder numbering is sorted instead of following set order, which varied between processes.
71
+
72
+ ## Two ways this fails, and the guard for each
73
+
74
+ **Silent quality loss from conversion or quantisation.** CTranslate2 has no converter for this checkpoint's custom class, so `bhashantar.convert` maps the Hugging Face tensors onto the same `TransformerSpec` the fairseq converter fills. A wrong position offset or a missed layer norm still produces fluent-looking text. The guard is `bench/parity.py`: chrF against human references for both this package and the upstream model, with the pass bar fixed before the first run.
75
+
76
+ **Breakage on library upgrades.** Runtime dependencies are only what inference touches: `ctranslate2` (capped below 5, since `model.bin` is tied to the major), `sentencepiece`, `sacremoses`, `regex`, `huggingface_hub`. `.github/workflows/smoke.yml` installs the newest versions in range every week and translates.
77
+
78
+ **The opinion:** parity is published as a number, not claimed. If the delta ever exceeds the bar, the table says so.
79
+
80
+ ## Parity
81
+
82
+ FLORES-200 devtest, first 100 sentences, English source. The FLORES files come from the public `dl.fbaipublicfiles.com/nllb/flores200_dataset.tar.gz`; AI4Bharat's IN22 and the Hugging Face FLORES+ copy are both gated. Reference = upstream model, transformers 4.50.3 + IndicTransToolkit 1.1.1, fp32, beam 5. Pass bar, set before running: |delta| <= 1.0 chrF.
83
+
84
+ | pair | chrF reference | chrF bhashantar | delta | identical outputs | pass |
85
+ |---|---|---|---|---|---|
86
+ | eng_Latn-hin_Deva | 65.69 | 65.17 | -0.52 | 61/100 | yes |
87
+ | eng_Latn-ben_Beng | 54.52 | 54.62 | +0.10 | 54/100 | yes |
88
+ | eng_Latn-tam_Taml | 61.33 | 61.59 | +0.27 | 56/100 | yes |
89
+
90
+ Reproduce: `python bench/parity.py score <flores devtest dir> bench/results` (outputs and `parity.json` are committed in `bench/results`).
91
+
92
+ "Identical outputs" counts sentences where both systems produce the same string; the rest differ in word choice (for example कनाडाई मधुमेह संघ against कैनेडियन डायबिटीज एसोसिएशन), the expected effect of int8 weights on beam search. Conversion error and quantisation error are not separated: an fp32 CTranslate2 conversion did not fit on the build machine's disk.
93
+
94
+ Reference weights: the official repo is gated, so the reference used `Raghavan/indictrans2-en-indic-dist-200M`, whose `pytorch_model.bin` has the same Git LFS pointer as the official file (sha256 `52671a26...0fef0`, verified locally); tokenizer files match the official Git blob ids.
95
+
96
+ ## Speed and memory
97
+
98
+ | | bhashantar (CT2 int8) | upstream (transformers fp32) |
99
+ |---|---|---|
100
+ | model load | 0.47 s | not timed |
101
+ | ms per sentence, eng to tam_Taml, beam 5 | 323 (all 100 in one call) | 2,786 (batch 2, 80 sentences timed) |
102
+ | peak RSS | 707 MB | 1,472 MB |
103
+
104
+ The two latency figures are not like for like: the reference had to run at batch 2 because the build machine ran out of commit memory at batch 8, and it was resumed once mid-run, so only the last 80 sentences were timed. bhashantar on Hindi and Bengali: 328 and 308 ms per sentence.
105
+
106
+ Measured on a laptop (Windows 11, 16 GB RAM, 4 threads, other jobs running), so treat them as relative, not absolute.
107
+
108
+ ## Limits
109
+
110
+ - One direction: English to Indic. The `indic-en` and `indic-indic` checkpoints are not converted.
111
+ - Caps, refused with the cap named in the error: 1,000 characters per sentence, 512 sentences and 50,000 characters per call, 256 subword tokens per sentence after preprocessing. Long input is refused, not truncated.
112
+ - Santali (`sat_Olck`) output mixes in Oriya and Arabic words for some sentences. The upstream model does the same; the test marks it as a known model limit.
113
+ - Sentence-level: split paragraphs yourself.
114
+
115
+ ## Model files
116
+
117
+ `translate()` looks for a model in this order: the `model_dir` argument, `$BHASHANTAR_MODEL`, a local conversion at `$HF_HOME/bhashantar/en-indic-dist-200M-ct2-int8`, then the Hugging Face repo `zaidwhy/indictrans2-en-indic-dist-200M-ct2-int8` (cache first, so it works offline after the first download).
118
+
119
+ That repo is not published yet. Until it is, convert once yourself (needs a Hugging Face login with the model's terms accepted):
120
+
121
+ ```
122
+ pip install "bhashantar[convert]"
123
+ hf auth login
124
+ bhashantar convert
125
+ ```
126
+
127
+ ## Development
128
+
129
+ ```
130
+ pip install -e ".[dev]"
131
+ pytest # offline tests, no model
132
+ pytest -m model # end to end, needs the converted model
133
+ ```
134
+
135
+ ## Maintainer: publishing (not done yet)
136
+
137
+ ```
138
+ # 1. weights, from a machine with the converted model
139
+ hf auth login
140
+ hf repo create zaidwhy/indictrans2-en-indic-dist-200M-ct2-int8 --repo-type model
141
+ hf upload zaidwhy/indictrans2-en-indic-dist-200M-ct2-int8 ~/.cache/huggingface/bhashantar/en-indic-dist-200M-ct2-int8 .
142
+
143
+ # 2. check that a clean install fetches it
144
+ python -m venv /tmp/bt && /tmp/bt/bin/pip install . && /tmp/bt/bin/bhashantar translate --tgt tam_Taml "Good morning."
145
+
146
+ # 3. package
147
+ python -m pip install build twine
148
+ python -m build
149
+ twine check dist/*
150
+ twine upload dist/*
151
+ ```
152
+
153
+ The model directory carries AI4Bharat's MIT `LICENSE` and a `provenance.json` with the source weights' sha256; add a model card stating the parity numbers above before uploading.
154
+
155
+ ## License
156
+
157
+ MIT. Vendored code and model weights keep their own MIT notices; see `NOTICE`.
@@ -0,0 +1,13 @@
1
+ bhashantar/__init__.py,sha256=QUPDOPNchCIe9ReXFNmtOlreGxX9CumP7fAW5BAplLg,627
2
+ bhashantar/cli.py,sha256=dKlzR1YBnL5gFpPXOxfRHSF8pWUrmVMa7RY8vPMnbdw,2980
3
+ bhashantar/convert.py,sha256=ZW_Bw-G3zTnOMex8xYuGzOz-SBGgZeTbu_egpsc9XGE,7579
4
+ bhashantar/engine.py,sha256=6CVY8_YtZT4Byzvn4XzMmq0bnbAJsmonR_npd920Wd8,7142
5
+ bhashantar/indic_script.py,sha256=jsU2Yg5gAF0AS5ZmeaE3_ovcja-Z7Nof4m5-HC2l8Vw,3180
6
+ bhashantar/languages.py,sha256=QynSwLoSGNASx2ExlE1hf1rK-Te_yKlUMvqFjKjQI-0,2492
7
+ bhashantar/textprep.py,sha256=sMVNqVsJUqYerUfsIHK5-co_D0HMbt44ziPYAzNDDwo,7098
8
+ bhashantar-0.1.0.dist-info/METADATA,sha256=YGdSgNPRwmCSZRkdAOshWWNXxToEgEnsLIxqptgFud4,8673
9
+ bhashantar-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
10
+ bhashantar-0.1.0.dist-info/entry_points.txt,sha256=Q5CQYoIc_r5RSHvEEgD48tyLVwJseiYS3i69dcuVpTg,51
11
+ bhashantar-0.1.0.dist-info/licenses/LICENSE,sha256=B3roCCeZDO06exjk5EZUsN6DaDNEXHFd8B54pONKgXQ,1070
12
+ bhashantar-0.1.0.dist-info/licenses/NOTICE,sha256=-xSkcwQsesBzl97EP7ZEa9NuXZOUtGcktol4-n4Dz8Q,1098
13
+ bhashantar-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ bhashantar = bhashantar.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zaid Ali Syed
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,25 @@
1
+ bhashantar includes code adapted from the following MIT-licensed projects.
2
+
3
+ src/bhashantar/textprep.py
4
+ Adapted from IndicTransToolkit (processor.pyx, IndicProcessor)
5
+ https://github.com/VarunGumma/IndicTransToolkit
6
+ MIT License, Copyright (c) Varun Gumma.
7
+
8
+ src/bhashantar/indic_script.py
9
+ Adapted from indic_nlp_library (unicode_transliterate.py, indic_detokenize.py, langinfo.py)
10
+ https://github.com/anoopkunchukuttan/indic_nlp_library
11
+ MIT License, Copyright (c) 2013-present Anoop Kunchukuttan.
12
+
13
+ src/bhashantar/convert.py
14
+ Weight mapping follows ctranslate2/converters/fairseq.py
15
+ https://github.com/OpenNMT/CTranslate2
16
+ MIT License, Copyright (c) 2019 SYSTRAN.
17
+
18
+ Model weights (downloaded at runtime, not shipped in this package)
19
+ IndicTrans2 en-indic-dist-200M by AI4Bharat
20
+ https://huggingface.co/ai4bharat/indictrans2-en-indic-dist-200M
21
+ MIT License. The converted CTranslate2 model directory carries a copy of
22
+ that LICENSE file.
23
+
24
+ Both MIT notices require that the copyright and permission notice be included
25
+ in copies or substantial portions; the full MIT text is in LICENSE.