Fill-Mask
Transformers
Safetensors
Arabic
English
bert
arabic
english
encoder
bilingual
mlm
tiny-model
from-scratch
Instructions to use oddadmix/Nawah-BERT-6M-bilingual with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use oddadmix/Nawah-BERT-6M-bilingual with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("fill-mask", model="oddadmix/Nawah-BERT-6M-bilingual")# Load model directly from transformers import AutoTokenizer, AutoModelForMaskedLM tokenizer = AutoTokenizer.from_pretrained("oddadmix/Nawah-BERT-6M-bilingual") model = AutoModelForMaskedLM.from_pretrained("oddadmix/Nawah-BERT-6M-bilingual", device_map="auto") - Notebooks
- Google Colab
- Kaggle
| """© KAND CA 2026 - Nawah-BERT-6M-bilingual: BertForMaskedLM, 50/50 Arabic/English, from scratch. | |
| Same 6M rung as train-bert-ladder.py (hidden 128, 8 layers, 2 heads, 5,993,600 params) and the | |
| same sparse-MLM-head / O(1)-resume machinery, but three things differ: | |
| 1. **A tokenizer trained for both languages.** custom_llama_tokenizer (32K, Arabic-only fit) costs | |
| English ~2.02 tok/word; train_tokenizer_bilingual.py fixes that at the source by interleaving | |
| Arabic and English documents before learning BPE merges, at a real but modest cost to Arabic | |
| (1.20 -> 1.37 tok/word) in exchange for English falling to 1.15. Every bin below is tokenized | |
| with `bilingual32k_tokenizer`, wrapped for AutoTokenizer in `bilingual32k-tokenizer-hf/`. | |
| 2. **Two 5B-token bins, one stream.** `DualMemmapDataset` (forked from train_mlm_dialect.py's | |
| class of the same name - see that module's docstring for why the fixed-permutation design | |
| keeps resume O(1)) draws a 50/50 share of tokens_ar_5B_bi32k.bin and tokens_en_5B_bi32k.bin. | |
| Both bins are exactly 5B tokens, so share=0.5 needs no truncation on either side - a genuinely | |
| balanced single pass, unlike the dialect run's 70/30 with dialect repeated to fill the budget. | |
| 3. **BertForMaskedLM, not ModernBERT** - this is the same architecture as the existing 6M rung, | |
| so a downstream comparison (router head, RuleCheck, Guard) is a clean like-for-like swap of the | |
| backbone, holding architecture and task fixed and varying only the pretraining corpus and | |
| tokenizer. | |
| python train_bert6m_bilingual.py --bench 20 | |
| python train_bert6m_bilingual.py | |
| """ | |
| import argparse | |
| import os | |
| import numpy as np | |
| import torch | |
| import torch.nn.functional as F | |
| from torch.utils.data import Dataset, SequentialSampler | |
| from transformers import (BertConfig, BertForMaskedLM, Trainer, TrainingArguments) | |
| from transformers.modeling_outputs import MaskedLMOutput | |
| from transformers.trainer_utils import get_last_checkpoint | |
| ROOT = os.path.dirname(os.path.abspath(__file__)) | |
| AR_BIN, AR_TOKENS = os.path.join(ROOT, "tokens_ar_5B_bi32k.bin"), 5_000_000_000 | |
| EN_BIN, EN_TOKENS = os.path.join(ROOT, "tokens_en_5B_bi32k.bin"), 5_000_000_000 | |
| TOKENIZER_DIR = os.path.join(ROOT, "bilingual32k-tokenizer-hf") | |
| OUTPUT_DIR = os.path.join(ROOT, "Nawah-BERT-6M-bilingual-pretrain") | |
| FINAL_DIR = os.path.join(ROOT, "Nawah-BERT-6M-bilingual-pretrain-FINAL") | |
| SEQ_LEN = 2048 | |
| VOCAB = 32_000 | |
| LAYERS = 8 | |
| HEAD_DIM = 64 | |
| HIDDEN = 128 | |
| EXPECTED_PARAMS = 5_993_600 | |
| PERM_SEED = 1234 | |
| HOLDOUT_CHUNKS = 2_048 # reserved for eval, never trained on, per language | |
| MICRO_BATCH = 16 | |
| GRAD_ACCUM = 8 | |
| CHUNKS_PER_STEP = MICRO_BATCH * GRAD_ACCUM # 128 | |
| TOKENS_PER_STEP = CHUNKS_PER_STEP * SEQ_LEN # 262,144 | |
| MLM_PROB_MAIN, MLM_PROB_FINAL = 0.30, 0.15 | |
| MLM_SWITCH_FRAC = 0.89 | |
| LR = 6e-4 | |
| WARMUP_STEPS = 2_000 | |
| class DualMemmapDataset(Dataset): | |
| """50/50 Arabic/English chunks in one fixed, resumable order. | |
| See train_mlm_dialect.py's class of the same name for the full rationale; the only change | |
| here is share=0.5 over two EQUAL-SIZE bins, so neither side needs truncating or repeating - | |
| every chunk of both bins is used exactly once. | |
| """ | |
| def __init__(self, ar_bin, ar_tokens, en_bin, en_tokens, share=0.5, seq_len=SEQ_LEN, | |
| start_chunk=0, seed=PERM_SEED, chunks_per_step=CHUNKS_PER_STEP, max_steps=None): | |
| self.seq_len = seq_len | |
| self.paths = [ar_bin, en_bin] | |
| self._data = [None, None] | |
| self.n_tok = [ar_tokens, en_tokens] | |
| for p, n in zip(self.paths, self.n_tok): | |
| if not os.path.exists(p): | |
| raise SystemExit(f"[!] token bin not found: {p}") | |
| size = os.path.getsize(p) | |
| if size != n * 2: | |
| raise SystemExit(f"[!] {p} is {size:,} bytes; expected {n:,} tokens " | |
| f"({n * 2:,} bytes). Refusing to guess.") | |
| n_ar = ar_tokens // seq_len | |
| n_en = en_tokens // seq_len | |
| # Both bins are the same size and share=0.5, so this reduces to n_ar == n_en with no | |
| # truncation on either side - written generally so a future unequal-size bin still works. | |
| want_ar = n_ar | |
| want_en = min(n_en, int(want_ar * (1 - share) / share)) | |
| idx = [(0, i) for i in range(want_ar)] + [(1, j) for j in range(want_en)] | |
| rng = np.random.default_rng(seed) | |
| order = rng.permutation(len(idx)) | |
| self.index = np.asarray(idx, dtype=np.int64)[order] | |
| self.n_total = len(self.index) | |
| self.n_train = self.n_total - HOLDOUT_CHUNKS | |
| self.start_chunk = start_chunk | |
| self.chunks_per_step = chunks_per_step | |
| self.total_steps = max_steps or (self.n_train // chunks_per_step) | |
| self.switch_step = int(self.total_steps * MLM_SWITCH_FRAC) | |
| print(f"[=] Arabic {ar_bin} ({ar_tokens / 1e9:.2f}B tokens, {n_ar:,} chunks -> " | |
| f"{want_ar:,} used)") | |
| print(f"[=] English {en_bin} ({en_tokens / 1e9:.2f}B tokens, {n_en:,} chunks -> " | |
| f"{want_en:,} used)") | |
| print(f"[+] {self.n_total:,} chunks, realised Arabic share " | |
| f"{want_ar / max(self.n_total, 1):.3f}; {HOLDOUT_CHUNKS:,} held out; " | |
| f"{self.total_steps:,} steps; mask 30%->15% at step {self.switch_step:,}") | |
| def data(self, src): | |
| if self._data[src] is None: | |
| n = self.n_tok[src] // self.seq_len * self.seq_len | |
| self._data[src] = np.memmap(self.paths[src], dtype=np.uint16, mode="r", shape=(n,)) | |
| return self._data[src] | |
| def holdout_indices(self): | |
| return self.index[self.n_train:] | |
| def chunk(self, src, chunk_id): | |
| s = int(chunk_id) * self.seq_len | |
| arr = np.asarray(self.data(src)[s:s + self.seq_len], dtype=np.int64) | |
| return torch.from_numpy(arr) | |
| def __len__(self): | |
| return self.n_train | |
| def __getitem__(self, i): | |
| pos = (self.start_chunk + i) % self.n_train | |
| step = (self.start_chunk + i) // self.chunks_per_step | |
| src, cid = self.index[pos] | |
| return {"input_ids": self.chunk(int(src), int(cid)), | |
| "mlm_prob": MLM_PROB_MAIN if step < self.switch_step else MLM_PROB_FINAL} | |
| def mlm_collate(features): | |
| """80/10/10 BERT masking, vectorised, per-item mlm_prob (carries the anneal).""" | |
| from transformers import AutoTokenizer | |
| tok = mlm_collate.tok | |
| ids = torch.stack([f["input_ids"] for f in features]) | |
| probs = torch.tensor([f["mlm_prob"] for f in features]).unsqueeze(1) | |
| special = torch.zeros_like(ids, dtype=torch.bool) | |
| for tid in (tok.bos_token_id, tok.eos_token_id, tok.pad_token_id): | |
| special |= ids == tid | |
| prob_matrix = probs.expand_as(ids).clone() | |
| prob_matrix.masked_fill_(special, 0.0) | |
| masked = torch.bernoulli(prob_matrix).bool() | |
| labels = ids.clone() | |
| labels[~masked] = -100 | |
| replace = torch.bernoulli(torch.full(ids.shape, 0.8)).bool() & masked | |
| ids = ids.clone() | |
| ids[replace] = tok.mask_token_id | |
| random_repl = torch.bernoulli(torch.full(ids.shape, 0.5)).bool() & masked & ~replace | |
| rand_ids = torch.randint(5, tok.vocab_size, ids.shape, dtype=ids.dtype) | |
| ids[random_repl] = rand_ids[random_repl] | |
| return {"input_ids": ids, "labels": labels} | |
| class SparseBertMLM(BertForMaskedLM): | |
| """BertForMaskedLM, LM head run only at masked positions - see train-bert-ladder.py.""" | |
| def forward(self, input_ids=None, attention_mask=None, token_type_ids=None, | |
| position_ids=None, labels=None, num_items_in_batch=None, **kw): | |
| out = self.bert(input_ids=input_ids, attention_mask=attention_mask, | |
| token_type_ids=token_type_ids, position_ids=position_ids, **kw) | |
| hidden = out.last_hidden_state | |
| if labels is None: | |
| return MaskedLMOutput(logits=self.cls(hidden)) | |
| flat = labels.reshape(-1) | |
| idx = (flat != -100).nonzero(as_tuple=True)[0] | |
| sel = hidden.reshape(-1, hidden.size(-1)).index_select(0, idx) | |
| logits = self.cls(sel).float() | |
| tgt = flat.index_select(0, idx) | |
| if num_items_in_batch is not None: | |
| loss = F.cross_entropy(logits, tgt, reduction="sum") / num_items_in_batch | |
| else: | |
| loss = F.cross_entropy(logits, tgt) | |
| return MaskedLMOutput(loss=loss, logits=logits) | |
| SparseBertMLM.__name__ = "BertForMaskedLM" | |
| class OffsetTrainer(Trainer): | |
| def _get_train_sampler(self, *a, **kw): | |
| return SequentialSampler(self.train_dataset) | |
| def reconcile_tf32(): | |
| torch.backends.cuda.matmul.allow_tf32 = True | |
| torch.backends.cudnn.allow_tf32 = True | |
| def latest_checkpoint(d): | |
| return get_last_checkpoint(d) if os.path.isdir(d) else None | |
| def main(): | |
| p = argparse.ArgumentParser() | |
| p.add_argument("--bench", type=int, default=0) | |
| p.add_argument("--micro-batch", type=int, default=MICRO_BATCH) | |
| p.add_argument("--grad-accum", type=int, default=GRAD_ACCUM) | |
| a = p.parse_args() | |
| if a.micro_batch * a.grad_accum != CHUNKS_PER_STEP: | |
| raise SystemExit(f"[!] micro-batch x grad-accum must be {CHUNKS_PER_STEP}") | |
| reconcile_tf32() | |
| from transformers import AutoTokenizer | |
| tok = AutoTokenizer.from_pretrained(TOKENIZER_DIR) | |
| mlm_collate.tok = tok | |
| ds = DualMemmapDataset(AR_BIN, AR_TOKENS, EN_BIN, EN_TOKENS, | |
| chunks_per_step=a.micro_batch * a.grad_accum, | |
| max_steps=a.bench or None) | |
| max_steps = a.bench or ds.total_steps | |
| ckpt = latest_checkpoint(OUTPUT_DIR) if not a.bench else None | |
| if ckpt: | |
| model = SparseBertMLM.from_pretrained(ckpt, dtype=torch.float32) | |
| step = int(os.path.basename(ckpt).split("-")[1]) | |
| ds.start_chunk = step * ds.chunks_per_step | |
| print(f"[*] resuming {ckpt} at step {step:,} -> chunk {ds.start_chunk:,}", flush=True) | |
| else: | |
| cfg = BertConfig(vocab_size=VOCAB, hidden_size=HIDDEN, num_hidden_layers=LAYERS, | |
| num_attention_heads=HIDDEN // HEAD_DIM, intermediate_size=4 * HIDDEN, | |
| max_position_embeddings=SEQ_LEN, tie_word_embeddings=True, | |
| pad_token_id=tok.pad_token_id) | |
| model = SparseBertMLM(cfg) | |
| n = sum(q.numel() for q in model.parameters()) | |
| print(f"[*] parameters {n:,} (expected {EXPECTED_PARAMS:,})", flush=True) | |
| if not ckpt and n != EXPECTED_PARAMS: | |
| raise SystemExit(f"[!] parameter count {n:,} != expected {EXPECTED_PARAMS:,}") | |
| args = TrainingArguments( | |
| output_dir=OUTPUT_DIR, max_steps=max_steps, | |
| per_device_train_batch_size=a.micro_batch, gradient_accumulation_steps=a.grad_accum, | |
| learning_rate=LR, lr_scheduler_type="cosine", | |
| warmup_steps=0 if a.bench else WARMUP_STEPS, | |
| weight_decay=0.1, adam_beta1=0.9, adam_beta2=0.95, max_grad_norm=1.0, | |
| optim="adamw_torch_fused", bf16=True, torch_compile=True, | |
| save_strategy="no" if a.bench else "steps", save_steps=250, save_total_limit=2, | |
| logging_steps=10 if a.bench else 100, | |
| report_to="none" if a.bench else "tensorboard", | |
| dataloader_num_workers=min(8, (os.cpu_count() or 8) // 2), | |
| dataloader_pin_memory=True, remove_unused_columns=False, ignore_data_skip=True, | |
| seed=PERM_SEED, disable_tqdm=False) | |
| trainer = OffsetTrainer(model=model, args=args, train_dataset=ds, data_collator=mlm_collate, | |
| processing_class=tok) | |
| trainer.train(resume_from_checkpoint=ckpt) | |
| if a.bench: | |
| print(f"[*] peak VRAM: {torch.cuda.max_memory_allocated() / 2**30:.2f} GiB") | |
| print("[*] benchmark finished - nothing saved.") | |
| return | |
| trainer.save_model(FINAL_DIR) | |
| tok.save_pretrained(FINAL_DIR) | |
| print(f"[+] saved -> {FINAL_DIR}") | |
| if __name__ == "__main__": | |
| main() | |