Zero-Shot Classification
Transformers
Safetensors
Arabic
bert
feature-extraction
arabic
prompt-routing
router
encoder
tiny-model
Instructions to use oddadmix/Nawah-Router-BERT-6M-v2 with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use oddadmix/Nawah-Router-BERT-6M-v2 with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("zero-shot-classification", model="oddadmix/Nawah-Router-BERT-6M-v2")# Load model directly from transformers import AutoTokenizer, AutoModel tokenizer = AutoTokenizer.from_pretrained("oddadmix/Nawah-Router-BERT-6M-v2") model = AutoModel.from_pretrained("oddadmix/Nawah-Router-BERT-6M-v2", device_map="auto") - Notebooks
- Google Colab
- Kaggle
| """ | |
| Arabic zero-shot router with a real routing head. | |
| Two earlier formulations, and why this one: | |
| fixed-slot head - `num_labels = max_lanes` over one pooled vector. Scored exactly 1/n at every | |
| lane count. The head is positional (slot i's logit is w_i . h) and the corpus | |
| randomises lane order, so there is nothing to learn. | |
| pairwise - score each (text, category) separately, argmax. Works (0.842 on unseen lane | |
| sets) but costs n forward passes and each category is scored in isolation, | |
| never against its competitors. | |
| Here the categories live in the sequence and get their *own* pooled vectors, which a shared linear | |
| head turns into one logit each; the softmax is over the categories present. The head is shared | |
| across positions, so it scores category *content*, not slot index - which is what makes it | |
| position-invariant and zero-shot. | |
| Layout is text-first, categories-second, deliberately. The reference encoder is bidirectional so | |
| order does not matter there; our base is causal, and this ordering is what lets every category | |
| token attend to the whole text. Reversed, the categories would be encoded blind to the text. | |
| النص: | |
| {text} | |
| الفئات: | |
| - فئة أولى | |
| - فئة ثانية | |
| Each category's character span is mapped to token indices via the tokenizer's offset mapping and | |
| mean-pooled. Slots beyond a row's category count are masked to -inf before the loss. | |
| """ | |
| import json | |
| import os | |
| from collections import defaultdict | |
| from pathlib import Path | |
| import numpy as np | |
| import torch | |
| import torch.nn as nn | |
| import torch.nn.functional as F | |
| from torch.utils.data import Dataset | |
| from transformers import AutoModel, AutoTokenizer, Trainer, TrainingArguments | |
| os.environ.setdefault("CUDA_VISIBLE_DEVICES", "0") | |
| BASE_MODEL = os.environ.get("BASE_MODEL", "oddadmix/50M-2048-Emhotob") | |
| OUTPUT_DIR = os.environ.get("OUTPUT_DIR", "./Nawah-Router-head-50M") | |
| DATA = Path(os.environ.get("DATA_DIR", "data")) | |
| MAX_ROUTES = int(os.environ.get("MAX_ROUTES", 9)) | |
| MAX_LENGTH = int(os.environ.get("MAX_LENGTH", 320)) | |
| LR = float(os.environ.get("LR", 3e-4)) | |
| EPOCHS = float(os.environ.get("EPOCHS", 3)) | |
| BATCH_SIZE = int(os.environ.get("BATCH_SIZE", 32)) | |
| WARMUP = int(os.environ.get("WARMUP", 500)) | |
| SEED = 42 | |
| def build_text(row): | |
| """-> (full string, [(start, end) char span per category]).""" | |
| head = f"النص:\n{row['text']}\n\nالفئات:\n" | |
| s = head | |
| spans = [] | |
| for c in row["routes"]: | |
| s += "- " | |
| spans.append((len(s), len(s) + len(c))) | |
| s += c + "\n" | |
| return s, spans | |
| class RouterModel(nn.Module): | |
| """Backbone + a shared linear scorer applied to each category's pooled span.""" | |
| def __init__(self, base_model): | |
| super().__init__() | |
| self.backbone = AutoModel.from_pretrained(base_model, dtype=torch.float32) | |
| h = self.backbone.config.hidden_size | |
| self.score = nn.Sequential(nn.Linear(h, h), nn.GELU(), nn.Linear(h, 1)) | |
| self.config = self.backbone.config | |
| def forward(self, input_ids, attention_mask, cat_pool, n_routes, labels=None): | |
| hs = self.backbone(input_ids=input_ids, attention_mask=attention_mask).last_hidden_state | |
| # cat_pool: (B, K, T) row-normalised selector -> (B, K, H) | |
| cat_vecs = torch.bmm(cat_pool.to(hs.dtype), hs) | |
| logits = self.score(cat_vecs).squeeze(-1) # (B, K) | |
| ar = torch.arange(logits.size(1), device=logits.device)[None, :] | |
| logits = logits.masked_fill(ar >= n_routes[:, None], torch.finfo(logits.dtype).min) | |
| loss = F.cross_entropy(logits, labels) if labels is not None else None | |
| return {"loss": loss, "logits": logits} | |
| def load(name): | |
| p = DATA / f"{name}.jsonl" | |
| return [json.loads(l) for l in open(p, encoding="utf-8")] if p.exists() else [] | |
| class RouterDataset(Dataset): | |
| def __init__(self, rows, tok, max_length): | |
| self.rows, self.tok, self.max_length = rows, tok, max_length | |
| def __len__(self): | |
| return len(self.rows) | |
| def __getitem__(self, i): | |
| r = self.rows[i] | |
| text, spans = build_text(r) | |
| enc = self.tok(text, return_offsets_mapping=True, add_special_tokens=False, | |
| truncation=True, max_length=self.max_length) | |
| ids, offs = enc["input_ids"], enc["offset_mapping"] | |
| pool = torch.zeros(MAX_ROUTES, len(ids)) | |
| for ci, (s, e) in enumerate(spans[:MAX_ROUTES]): | |
| idxs = [t for t, (a, b) in enumerate(offs) if a < e and b > s and a != b] | |
| if idxs: | |
| pool[ci, idxs] = 1.0 / len(idxs) | |
| return {"input_ids": torch.tensor(ids, dtype=torch.long), "cat_pool": pool, | |
| "n_routes": torch.tensor(min(len(r["routes"]), MAX_ROUTES), dtype=torch.long), | |
| "labels": torch.tensor(min(r["label"], MAX_ROUTES - 1), dtype=torch.long)} | |
| class Collator: | |
| def __init__(self, pad_id): | |
| self.pad_id = pad_id | |
| def __call__(self, feats): | |
| T = max(f["input_ids"].size(0) for f in feats) | |
| ids, att, pools = [], [], [] | |
| for f in feats: | |
| n = f["input_ids"].size(0); pad = T - n | |
| ids.append(torch.cat([f["input_ids"], torch.full((pad,), self.pad_id, dtype=torch.long)])) | |
| att.append(torch.cat([torch.ones(n, dtype=torch.long), torch.zeros(pad, dtype=torch.long)])) | |
| pools.append(F.pad(f["cat_pool"], (0, pad))) | |
| return {"input_ids": torch.stack(ids), "attention_mask": torch.stack(att), | |
| "cat_pool": torch.stack(pools), | |
| "n_routes": torch.stack([f["n_routes"] for f in feats]), | |
| "labels": torch.stack([f["labels"] for f in feats])} | |
| def metrics_fn(p): | |
| return {"accuracy": float((np.asarray(p.predictions).argmax(-1) == | |
| np.asarray(p.label_ids)).mean())} | |
| def report(model, tok, rows, name, batch=64): | |
| model.eval() | |
| ds = RouterDataset(rows, tok, MAX_LENGTH); coll = Collator(tok.pad_token_id) | |
| preds = [] | |
| for i in range(0, len(rows), batch): | |
| b = coll([ds[j] for j in range(i, min(i + batch, len(rows)))]) | |
| b = {k: v.to(next(model.parameters()).device) for k, v in b.items()} | |
| b.pop("labels") | |
| preds += model(**b)["logits"].argmax(-1).tolist() | |
| correct = [int(p == r["label"]) for p, r in zip(preds, rows)] | |
| acc = sum(correct) / len(rows) | |
| rand = sum(1 / len(r["routes"]) for r in rows) / len(rows) | |
| print(f"\n[{name}] n={len(rows):,} route accuracy {acc:.4f} random {rand:.4f}") | |
| out = {"accuracy": acc, "random_baseline": rand, "n": len(rows)} | |
| for key in ("n_routes", "difficulty", "mode"): | |
| if key == "mode" and not all("mode" in r for r in rows): | |
| continue | |
| b = defaultdict(lambda: [0, 0]) | |
| for c, r in zip(correct, rows): | |
| k = len(r["routes"]) if key == "n_routes" else r[key] | |
| b[k][1] += 1; b[k][0] += c | |
| print(f" by {key:<10} " + | |
| " ".join(f"{k}:{v[0]/v[1]:.3f}(n={v[1]})" for k, v in sorted(b.items(), key=str))) | |
| out[f"by_{key}"] = {str(k): {"acc": v[0]/v[1], "n": v[1]} for k, v in b.items()} | |
| return out | |
| def main(): | |
| tok = AutoTokenizer.from_pretrained(BASE_MODEL) | |
| if tok.pad_token_id is None: | |
| tok.pad_token = tok.eos_token | |
| model = RouterModel(BASE_MODEL) | |
| print(f"[*] {BASE_MODEL} | params {sum(p.numel() for p in model.parameters())/1e6:.2f}M " | |
| f"| hidden {model.config.hidden_size}") | |
| train = load("train") | |
| evals = {"unseen_lanes": load("eval_unseen_lanes"), | |
| "unseen_domain": load("eval_unseen_domain"), | |
| # v2 only: axes held out of training entirely. The strongest zero-shot test, so the | |
| # checkpoint is selected on it when it exists. | |
| "unseen_axis": load("eval_unseen_axis"), | |
| "hard": load("eval_hard")} | |
| evals = {k: v for k, v in evals.items() if v} | |
| print(f"[*] train {len(train):,} | " + " | ".join(f"{k} {len(v):,}" for k, v in evals.items())) | |
| args = TrainingArguments( | |
| output_dir=OUTPUT_DIR, num_train_epochs=EPOCHS, | |
| per_device_train_batch_size=BATCH_SIZE, per_device_eval_batch_size=64, | |
| learning_rate=LR, lr_scheduler_type="cosine", warmup_steps=WARMUP, | |
| max_grad_norm=1.0, bf16=True, logging_steps=200, | |
| eval_strategy="steps", eval_steps=1000, save_strategy="steps", save_steps=1000, | |
| save_total_limit=2, load_best_model_at_end=True, | |
| metric_for_best_model=("eval_unseen_axis_accuracy" if "unseen_axis" in evals | |
| else "eval_unseen_domain_accuracy"), greater_is_better=True, | |
| report_to=[], seed=SEED, dataloader_num_workers=4, remove_unused_columns=False, | |
| label_names=["labels"]) | |
| trainer = Trainer(model=model, args=args, | |
| train_dataset=RouterDataset(train, tok, MAX_LENGTH), | |
| eval_dataset={k: RouterDataset(v, tok, MAX_LENGTH) | |
| for k, v in evals.items() if v}, | |
| data_collator=Collator(tok.pad_token_id), compute_metrics=metrics_fn) | |
| trainer.train() | |
| Path(OUTPUT_DIR).mkdir(exist_ok=True) | |
| torch.save(model.state_dict(), Path(OUTPUT_DIR, "router_model.pt")) | |
| model.backbone.save_pretrained(OUTPUT_DIR); tok.save_pretrained(OUTPUT_DIR) | |
| results = {k: report(model, tok, v, k) for k, v in evals.items() if v} | |
| Path(OUTPUT_DIR, "train_metrics.json").write_text(json.dumps( | |
| {"results": results, "base_model": BASE_MODEL, "formulation": "routing_head", | |
| "log_history": trainer.state.log_history}, ensure_ascii=False, indent=2), encoding="utf-8") | |
| print(f"\n[+] done -> {OUTPUT_DIR}") | |
| if __name__ == "__main__": | |
| main() | |