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 +29 -0
- bhashantar/cli.py +70 -0
- bhashantar/convert.py +176 -0
- bhashantar/engine.py +187 -0
- bhashantar/indic_script.py +98 -0
- bhashantar/languages.py +80 -0
- bhashantar/textprep.py +183 -0
- bhashantar-0.1.0.dist-info/METADATA +157 -0
- bhashantar-0.1.0.dist-info/RECORD +13 -0
- bhashantar-0.1.0.dist-info/WHEEL +4 -0
- bhashantar-0.1.0.dist-info/entry_points.txt +2 -0
- bhashantar-0.1.0.dist-info/licenses/LICENSE +21 -0
- bhashantar-0.1.0.dist-info/licenses/NOTICE +25 -0
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
|
bhashantar/languages.py
ADDED
|
@@ -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,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.
|