Nawah-Router-BERT-6M-v2 / train_router_head.py
oddadmix's picture
add train_router_head.py
daef8b7 verified
Raw
History Blame Contribute Delete
9.78 kB
"""
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())}
@torch.no_grad()
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()