Jisr-WordAlign-29M

The encoder half of the Jisr Arabic-English MT model, finetuned for word alignment. 29M parameters, 448-dim, 6 layers. Trained from scratch.

Cosine over hidden_states[6], max-pooled from subwords up to whitespace words, matched with SimAlign-style mutual argmax. No decoder, no search - the similarity matrix is the model.

Results

AER against gemma-4-31B two-pass gold, lower is better. Baseline is the same encoder before this training, i.e. the MT model straight out of the box:

gold set untrained this model P R
FLORES (MSA) 0.181 0.164 0.899 0.736
Alexandria (Egyptian) 0.306 0.251 0.849 0.616
dialect transcripts 0.379 0.287 0.820 0.574

The gain scales with how dialectal the eval is - training data was dialect - and MSA improves too, so it is not a trade. The sentence aligner (oddadmix/Jisr-Align-50M) is much worse at this task (0.259 / 0.443 / 0.508): contrastive training on a pooled vector costs token-level accuracy.

Gold: oddadmix/jisr-align-gold, config word.

Two things it does not do

One-to-many. Mutual argmax and itermax both blank a word's row and column once it is linked, so the output is always a matching. Arabic clitics (ุจูŠุชู‡ุง = "her house") genuinely need two links and cannot get them. That is the decoder, not the encoder - the similarity matrix usually has both cells hot - and it needs a different matching rule or morphological segmentation.

Guarantee its labels. The gold it was trained and measured on is LLM-adjudicated, and the two passes disagreed 21-27% of the time. Read AER here as a reliable ranking between models, not as ground truth.

Training

3 epochs of symmetric cross-entropy over the pooled word-similarity matrix, uniform target across each word's gold partners, 9999 dialect sentence pairs from oddadmix/dialectal-arabic-english-parallel adjudicated in two opposite- order passes. Batch 16, lr 1e-5, learnable temperature from 0.05, layer 6. Converged by ~step 1200 of 1779. Model selection ran on held-out GENERATED pairs; the three gold sets above were scored once from the final checkpoint, so none of them fed back into which checkpoint was kept.

Usage

Complete and self-contained - this is the whole aligner, not a sketch of it. It reproduces word_align.py from the training repo link-for-link.

import re

import numpy as np
import torch
from transformers import AutoTokenizer, MarianMTModel

M = "oddadmix/Jisr-WordAlign-29M"
tok = AutoTokenizer.from_pretrained(M)
enc = MarianMTModel.from_pretrained(M).model.encoder.eval()

TAG = {"ar": ">>ara<<", "en": ">>eng<<"}
STRIP = "ุŒุ›ุŸ.,!?;:()[]{}\"'โ€œโ€ยซยปโ€ฆ-"
LAYER = 6

@torch.no_grad()
def words_and_states(text, lang):
    # Whitespace words, their subword indices, and L2-normalised states.
    tag = TAG[lang] + " "
    b = tok(tag + text, return_tensors="pt", truncation=True, max_length=256,
            return_offsets_mapping=True)
    off = b.pop("offset_mapping")[0].tolist()
    H = enc(**b, output_hidden_states=True).hidden_states[LAYER][0]
    H = H.float().numpy()

    # Keep a token if it ENDS past the tag, not if it STARTS past it: the
    # tokenizer folds the tag's trailing space into the first real word, so a
    # start-based test silently deletes word 0 of every sentence.
    keep = [t for t, (s, e) in enumerate(off) if e > s and e > len(tag)]
    span = [(max(0, off[t][0] - len(tag)), off[t][1] - len(tag)) for t in keep]
    H = H[keep]
    H = H / (np.linalg.norm(H, axis=1, keepdims=True) + 1e-9)

    words = [(m.group(0), m.start(), m.end()) for m in re.finditer(r"\S+", text)]
    idx = []
    for _, a, z in words:
        t = [k for k, (s, e) in enumerate(span) if s < z and e > a]
        # Drop punctuation-only subwords. A full stop is the SAME token in both
        # languages, so a max over subword pairs would hand it to every
        # sentence-final word. Kept only for words that are all punctuation.
        real = [k for k in t if text[span[k][0]:span[k][1]].strip(STRIP)]
        idx.append(real or t)
    return [w for w, _, _ in words], idx, H

def align(ar_text, en_text):
    wa, ia, A = words_and_states(ar_text, "ar")
    wb, ib, B = words_and_states(en_text, "en")
    S = A @ B.T
    W = np.full((len(wa), len(wb)), -1.0, dtype=np.float32)
    for i, ta in enumerate(ia):
        for j, tb in enumerate(ib):
            if ta and tb:
                W[i, j] = S[np.ix_(ta, tb)].max()   # MAX over subword pairs
    bi, bj = W.argmax(1), W.argmax(0)               # mutual argmax
    return [(wa[i], wb[bi[i]], float(W[i, bi[i]]))
            for i in range(len(wa)) if bj[bi[i]] == i and W[i, bi[i]] > -1]

for a, b, s in align(
        "ุชุทููˆ ุงู„ุฅุจุฑุฉ ุงู„ููˆู„ุงุฐูŠู‘ุฉ ุนู„ู‰ ุงู„ู…ุงุก ุจุณุจุจ ุงู„ุชูˆุชู‘ุฑ ุงู„ุณุทุญูŠ.",
        "The steel needle floats on top of the water because of surface tension."):
    print(f"{a:<22} {b:<12} {s:.3f}")
ุชุทููˆ                   floats       0.442
ุงู„ุฅุจุฑุฉ                 needle       0.496
ุงู„ููˆู„ุงุฐูŠู‘ุฉ             steel        0.716
ุนู„ู‰                    on           0.682
ุงู„ู…ุงุก                  water        0.781
ุจุณุจุจ                   because      0.792
ุงู„ุชูˆุชู‘ุฑ                tension.     0.553
ุงู„ุณุทุญูŠ.                surface      0.609

The last two links cross, and that is correct: Arabic puts the adjective after the noun, so ุงู„ุชูˆุชู‘ุฑ ุงู„ุณุทุญูŠ is literally "the-tension the-surface".

Three details are load-bearing. Layer 6, not the last. Max over subword pairs, not mean - a content word splits unevenly across its subwords and averaging buries the piece that matched. And drop punctuation-only subwords before pooling: measured without that filter, ุงู„ุขู†. linked to added. at 0.889 and outscored the correct ุงู„ุขู† ~ now at 0.746.

ยฉ KAND CA 2026

Downloads last month
10
Safetensors
Model size
48.2M params
Tensor type
F32
ยท
Inference Providers NEW
This model isn't deployed by any Inference Provider. ๐Ÿ™‹ Ask for provider support

Space using oddadmix/Jisr-WordAlign-29M 1