Token Classification
Transformers
Safetensors
Arabic
English
marian
text2text-generation
word-alignment
arabic
dialectal-arabic
Instructions to use oddadmix/Jisr-WordAlign-29M with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use oddadmix/Jisr-WordAlign-29M with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("token-classification", model="oddadmix/Jisr-WordAlign-29M")# Load model directly from transformers import AutoTokenizer, AutoModelForSeq2SeqLM tokenizer = AutoTokenizer.from_pretrained("oddadmix/Jisr-WordAlign-29M") model = AutoModelForSeq2SeqLM.from_pretrained("oddadmix/Jisr-WordAlign-29M", device_map="auto") - Notebooks
- Google Colab
- Kaggle
File size: 12,634 Bytes
76f6c3c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 | """© KAND CA 2026 - Sentence embeddings from the Jisr MT encoder.
Surgery, not a new model: Emhotob-MT-50M-FINAL's encoder is 29.28M of the 49.06M
total (14.34M of that the shared 32000x448 table). Drop the decoder and lm_head,
mean-pool over the attention mask, L2 normalise. Nothing is trained here.
FLORES+ devtest, 1012-way ar->en retrieval, untrained, measured before any of
this was designed - "centred" subtracts the per-language mean before normalising:
tag (en / ar) raw doc-ctr global-ctr
(none) 0.134 0.217 0.163
>>eng<< / >>ara<< (own) 0.252 0.976 0.943
>>ara<< / >>ara<< 0.939 0.973 0.960
>>ara<< / >>eng<< 0.916 0.970 0.908
Three decisions come out of that table, and the first two are easy to get
backwards - I did, before measuring the no-tag row properly:
1. A TAG IS NECESSARY; WHICH TAG IS A TIE-BREAK. Untagged, centring only reaches
0.217 - the two languages are simply not in a comparable space. Tagged, every
combination lands at 0.970-0.976 once centred, a spread of about 6 sentences
in 1012. So the tag and centring are complementary, not substitutes: the tag
makes the spaces comparable, centring removes what is left. Reading the raw
column alone suggests ">>ara<< by 70 points" and that conclusion is an
artifact of skipping the centring step.
2. CENTRE PER DOCUMENT, NOT GLOBALLY. doc beats global at every tag (0.976 vs
0.943 at the default) and at every document size measured:
N 4 8 16 32 64 128
none 0.881 0.816 0.718 0.608 0.519 0.438
doc 1.000 1.000 0.998 0.997 0.994 0.992
global 0.998 0.999 0.994 0.990 0.984 0.976
The worry that a 4-sentence document cannot estimate its own mean is wrong:
the offset being removed is large and shared, so even a 4-sample estimate of
it helps. This is not leakage - an aligner always holds the whole document,
so per-document statistics are the real operating condition. The fitted
global means stay in align_config.json as the fallback for one-off calls
that have no document context.
3. LAYER 5 BEATS LAYER 6 everywhere, centred and raw. The last layer is
specialised for decoder cross-attention rather than for representing the
sentence, so pool from the penultimate one.
Rejected: max pooling (0.527 at L6 best) and first-token pooling (0.096) - token
0 IS the tag, so first-token pooling reads the prefix and nothing else.
The tag changes the first word's tokenisation (leading space: "The" -> "ĠThe"),
so it has to be byte-identical between fitting the means and using them. That is
why TAG lives here as one dict and is written into align_config.json, never
hard-coded at a second call site.
"""
import argparse
import json
import os
import warnings
warnings.filterwarnings("ignore")
import numpy as np
import torch
from transformers import AutoTokenizer, MarianMTModel
TAG = {"en": ">>eng<<", "ar": ">>ara<<"} # tag = the text's own language
POOL_LAYER = 5 # penultimate, measured
CFG_NAME = "align_config.json"
def load_encoder(path, device="cuda"):
"""Return (tok, encoder, cfg). Works for the raw MT model and a trained one."""
tok = AutoTokenizer.from_pretrained(path)
full = MarianMTModel.from_pretrained(path)
enc = full.model.encoder
del full.model.decoder, full.lm_head
enc = enc.to(device).eval()
cfg = {"tag": dict(TAG), "pool_layer": POOL_LAYER, "mu": None}
p = os.path.join(path, CFG_NAME)
if os.path.exists(p):
with open(p) as f:
cfg.update(json.load(f))
return tok, enc, cfg
@torch.no_grad()
def encode_raw(tok, enc, texts, lang, bs=256, layer=POOL_LAYER, tag=None,
max_length=256):
"""Masked mean-pooled hidden states. No centring, no normalisation."""
tag = TAG[lang] if tag is None else tag
out = []
for i in range(0, len(texts), bs):
b = texts[i:i + bs]
b = [f"{tag} {t}" for t in b] if tag else list(b)
e = tok(b, return_tensors="pt", padding=True, truncation=True,
max_length=max_length).to(enc.device)
h = enc(**e, output_hidden_states=True).hidden_states[layer]
m = e["attention_mask"].unsqueeze(-1).to(h.dtype)
out.append(((h * m).sum(1) / m.sum(1)).float().cpu().numpy())
return np.concatenate(out) if out else np.zeros((0, enc.config.d_model),
dtype=np.float32)
def normalize(x):
n = np.linalg.norm(x, axis=1, keepdims=True)
return x / np.maximum(n, 1e-9)
def embed(tok, enc, texts, lang, cfg=None, bs=256, center="doc", mu=None,
layer=None, max_length=256):
"""Embeddings ready for cosine: centred then L2-normalised.
center="global" uses the fitted per-language mean (the default, and the only
mode that is safe on short documents); "doc" re-estimates from these texts,
which is better when a document is long and hopeless when it is four
sentences; "none" disables it and is there so the ablation is runnable.
"""
cfg = cfg or {}
layer = layer if layer is not None else cfg.get("pool_layer", POOL_LAYER)
tag = (cfg.get("tag") or TAG).get(lang)
x = encode_raw(tok, enc, texts, lang, bs=bs, layer=layer, tag=tag,
max_length=max_length)
if len(x) == 0:
return x
if center == "doc":
x = x - x.mean(0, keepdims=True)
elif center == "global":
m = mu if mu is not None else (cfg.get("mu") or {}).get(lang)
if m is None:
raise ValueError(
f"center='global' needs a fitted mean for {lang!r}; run "
f"align_encoder.py --fit-means, or pass center='doc'")
x = x - np.asarray(m, dtype=np.float32)
return normalize(x)
def fit_means(tok, enc, pairs, cfg=None, bs=256, layer=None):
"""Per-language mean vectors, from unit-normalised embeddings.
Normalise first, then average: the mean of raw vectors is dominated by
whichever sentences happen to have the largest norm, and norm tracks length.
"""
cfg = cfg or {}
layer = layer if layer is not None else cfg.get("pool_layer", POOL_LAYER)
mu = {}
for lang in ("en", "ar"):
x = normalize(encode_raw(tok, enc, pairs[lang], lang, bs=bs, layer=layer,
tag=(cfg.get("tag") or TAG)[lang]))
mu[lang] = x.mean(0).astype(np.float32).tolist()
return mu
def _flores(split="devtest"):
from datasets import load_dataset
ds = load_dataset("openlanguagedata/flores_plus", split=split)
en, ar = {}, {}
for r in ds:
# iso_15924 guard: arb_Latn is also in this split, 1012 rows of
# ROMANISED Arabic. Matching on iso_639_3 alone silently swaps it in
# and drops P@1 from 0.87 to 0.07.
if r["iso_639_3"] == "eng" and r["iso_15924"] == "Latn":
en[r["id"]] = r["text"]
elif r["iso_639_3"] == "arb" and r["iso_15924"] == "Arab":
ar[r["id"]] = r["text"]
ids = sorted(set(en) & set(ar))
return [en[i] for i in ids], [ar[i] for i in ids]
def p_at_k(xe, xa, k=1):
s = xa @ xe.T
idx = np.argsort(-s, axis=1)[:, :k]
return float(np.mean([i in row for i, row in enumerate(idx)]))
def _msa_pairs(n):
from datasets import load_dataset
ds = load_dataset("oddadmix/quick-mt-en-ar-5m", split="train")
ds = ds.select(range(min(n * 4, len(ds))))
en, ar = [], []
for msgs in ds["messages"]:
m = {x["role"]: x["content"] for x in msgs}
if m.get("system") == "Translate to Arabic":
s, t = m.get("user"), m.get("assistant")
elif m.get("system") == "Translate to English":
t, s = m.get("user"), m.get("assistant")
else:
continue
if s and t:
en.append(s.strip())
ar.append(t.strip())
if len(en) >= n:
break
return {"en": en, "ar": ar}
def probe(tok, enc, cfg, args):
EN, AR = _flores(args.split)
print(f"[*] FLORES+ {args.split}: {len(EN)} pairs", flush=True)
print(f"\n{'layer':>5} {'en tag':>9} {'ar tag':>9} {'centre':>7} "
f"{'P@1':>7} {'P@10':>7}")
print("-" * 50)
# "" is the explicit no-tag sentinel. None must NOT be used here: encode_raw
# reads None as "fall back to TAG[lang]", so a (None, None) row silently
# re-measures the default and reports it as the untagged baseline.
combos = [(">>eng<<", ">>ara<<"), (">>ara<<", ">>ara<<"),
(">>ara<<", ">>eng<<"), ("", "")]
mu = (cfg.get("mu") or {})
for layer in args.layers:
for te, ta in combos:
xe = encode_raw(tok, enc, EN, "en", args.bs, layer, te)
xa = encode_raw(tok, enc, AR, "ar", args.bs, layer, ta)
for mode in ("none", "doc", "global"):
if mode == "none":
a, b = normalize(xe), normalize(xa)
elif mode == "doc":
a = normalize(xe - xe.mean(0, keepdims=True))
b = normalize(xa - xa.mean(0, keepdims=True))
else:
if not mu:
continue
a = normalize(normalize(xe) - np.asarray(mu["en"]))
b = normalize(normalize(xa) - np.asarray(mu["ar"]))
print(f"{layer:>5} {str(te):>9} {str(ta):>9} {mode:>7} "
f"{p_at_k(a,b,1):>7.3f} {p_at_k(a,b,10):>7.3f}", flush=True)
print("-" * 50)
# the question the retrieval table cannot answer: per-document centring is
# estimated from N sentences, and real documents are short.
if args.doc_sizes:
print(f"\n[*] per-doc centring vs document size (mean over samples)")
print(f"{'N':>5} {'none':>8} {'doc':>8} {'global':>8}")
print("-" * 32)
xe = encode_raw(tok, enc, EN, "en", args.bs, cfg.get("pool_layer", 5),
TAG["en"])
xa = encode_raw(tok, enc, AR, "ar", args.bs, cfg.get("pool_layer", 5),
TAG["ar"])
rng = np.random.default_rng(1234)
for N in args.doc_sizes:
acc = {"none": [], "doc": [], "global": []}
for _ in range(200):
i = rng.choice(len(EN), size=min(N, len(EN)), replace=False)
e, a = xe[i], xa[i]
acc["none"].append(p_at_k(normalize(e), normalize(a)))
acc["doc"].append(p_at_k(normalize(e - e.mean(0, keepdims=True)),
normalize(a - a.mean(0, keepdims=True))))
if mu:
acc["global"].append(p_at_k(
normalize(normalize(e) - np.asarray(mu["en"])),
normalize(normalize(a) - np.asarray(mu["ar"]))))
g = f"{np.mean(acc['global']):>8.3f}" if mu else f"{'-':>8}"
print(f"{N:>5} {np.mean(acc['none']):>8.3f} "
f"{np.mean(acc['doc']):>8.3f} {g}", flush=True)
print("-" * 32)
print("[*] Retrieval within a document is the aligner's actual job, so")
print("[*] these rows - not the 1012-way table - decide the default.")
def main():
p = argparse.ArgumentParser()
p.add_argument("--model", default="./Emhotob-MT-50M-FINAL")
p.add_argument("--probe", action="store_true")
p.add_argument("--fit-means", action="store_true")
p.add_argument("--fit-n", type=int, default=100000)
p.add_argument("--split", default="devtest")
p.add_argument("--bs", type=int, default=256)
p.add_argument("--layers", type=int, nargs="+", default=[4, 5, 6])
p.add_argument("--doc-sizes", type=int, nargs="*",
default=[4, 8, 16, 32, 64, 128])
a = p.parse_args()
tok, enc, cfg = load_encoder(a.model)
print(f"[*] {a.model}: encoder "
f"{sum(x.numel() for x in enc.parameters())/1e6:.2f}M params, "
f"d_model {enc.config.d_model}, layer {cfg.get('pool_layer')}",
flush=True)
if a.fit_means:
print(f"[*] fitting per-language means on {a.fit_n:,} MSA pairs",
flush=True)
cfg["mu"] = fit_means(tok, enc, _msa_pairs(a.fit_n), cfg, a.bs)
out = os.path.join(a.model, CFG_NAME)
with open(out, "w") as f:
json.dump({"tag": cfg["tag"], "pool_layer": cfg["pool_layer"],
"mu": cfg["mu"], "fit_n": a.fit_n}, f)
print(f"[+] wrote {out}", flush=True)
if a.probe:
probe(tok, enc, cfg, a)
if __name__ == "__main__":
main()
|