---
license: apache-2.0
language: [ar]
library_name: onnx
pipeline_tag: text-generation
tags: [arabic, story-generation, scaling-laws, onnx, small-language-model]
---
# RuqLM — small Arabic language models
**▶ Try it live: [ruqlm.web.app](https://ruqlm.web.app)** — runs entirely in your
browser, no server, no signup needed for guest access.
A ladder of five Arabic language models, **0.74M to 29.89M parameters**, trained
from scratch on a corpus of **50,003 Arabic stories** under a fixed budget of 76.8M
training tokens. The tokenizer, the corpus and the weights were all built for this
project.
Weights are **full-precision ONNX** — no quantisation. Perplexity matches the
original PyTorch checkpoints to three decimals (9.2009 vs 9.2009), so these are the
trained models, not an approximation of them.
---
## The ladder
| Rung | Parameters | d_model | Layers | Heads | `.onnx` | `.safetensors` |
|---|---|---|---|---|---|---|
| `ruq-30m` | 29.89M | 512 | 8 | 8 | 114.8 MB | 114.0 MB |
| `ruq-15m` | 13.77M | 384 | 6 | 6 | 53.1 MB | 52.5 MB |
| `ruq-5m` | 5.31M | 256 | 4 | 4 | 20.7 MB | 20.3 MB |
| `ruq-2m` | 1.90M | 128 | 4 | 4 | 7.7 MB | 7.3 MB |
| `ruq-0.7m` | 0.74M | 64 | 4 | 4 | 3.2 MB | 2.8 MB |
Decoder-only transformer: RMSNorm, RoPE, SwiGLU, tied embeddings, 512-token context.
Tokenizer is a byte-level BPE with an 8,192 vocabulary trained on the same corpus
(1.411 tokens per word).
---
## Usage
```bash
pip install onnxruntime tokenizers huggingface_hub numpy
```
Colab has none of these preinstalled except `numpy`, so run the line above first
(prefix it with `!` in a notebook cell).
```python
import re, unicodedata
import numpy as np, onnxruntime as ort
from huggingface_hub import hf_hub_download
from tokenizers import Tokenizer
REPO, EOS = "Ruqiya/ruqlm", 2
tok = Tokenizer.from_file(hf_hub_download(REPO, "tokenizer.json"))
sess = ort.InferenceSession(hf_hub_download(REPO, "ruq-30m.onnx"))
# The tokenizer was trained on normalised text: diacritics and tatweel
# removed. Skipping this step gives a different, worse tokenisation.
_DIACRITICS = re.compile(r"[\u064b-\u0652\u0670\u0653-\u0655]")
_ZERO_WIDTH = re.compile(r"[\u200b-\u200f\u202a-\u202e\ufeff]")
def normalize(text):
text = unicodedata.normalize("NFC", text)
text = _ZERO_WIDTH.sub("", text).replace("ـ", "")
return re.sub(r"\s+", " ", _DIACRITICS.sub("", text)).strip()
def generate(prompt, max_new_tokens=120, temperature=0.85, top_k=50, seed=None):
"""The model outputs logits for the last position only; sampling is here."""
rng = np.random.default_rng(seed)
ids = tok.encode(normalize(prompt)).ids
if ids and ids[-1] == EOS: # drop the trailing so it continues
ids = ids[:-1]
for _ in range(max_new_tokens):
logits = sess.run(None, {"input_ids": np.array([ids], dtype=np.int64)})[0][0]
logits = logits.astype(np.float64) / temperature
kth = np.partition(logits, -top_k)[-top_k] # top-k filter
logits[logits < kth] = -np.inf
probs = np.exp(logits - logits.max())
probs /= probs.sum()
nxt = int(rng.choice(len(probs), p=probs))
if nxt == EOS:
break
ids.append(nxt)
return tok.decode(ids)
print(generate("كان يا ما كان"))
```
Output (`ruq-5m`, seed 0):
> كان يا ما كان في حديقة بيت. كان النجار يعمل بجد ليطعم عائلته. وفي يوم من الأيام،
> وأثناء عمله، وجد النجار سلحفاة صغيرة ضائعة…
### PyTorch
The same weights are published as safetensors alongside the architecture module, for
continued training or fine-tuning:
```python
import json, re, unicodedata, torch
from huggingface_hub import hf_hub_download
from safetensors.torch import load_file
from tokenizers import Tokenizer
REPO, EOS = "Ruqiya/ruqlm", 2
tok = Tokenizer.from_file(hf_hub_download(REPO, "tokenizer.json"))
_DIACRITICS = re.compile(r"[\u064b-\u0652\u0670\u0653-\u0655]")
_ZERO_WIDTH = re.compile(r"[\u200b-\u200f\u202a-\u202e\ufeff]")
def normalize(text):
text = unicodedata.normalize("NFC", text)
text = _ZERO_WIDTH.sub("", text).replace("\u0640", "") # tatweel
return re.sub(r"\s+", " ", _DIACRITICS.sub("", text)).strip()
# the architecture module has to be on the path before it can be imported
hf_hub_download(REPO, "modeling_ruqlm.py", local_dir=".")
from modeling_ruqlm import RuqLM, ModelArgs
cfg = json.load(open(hf_hub_download(REPO, "configs.json")))["ruq-5m"]
model = RuqLM(ModelArgs(**cfg))
state = load_file(hf_hub_download(REPO, "ruq-5m.safetensors"))
state["lm_head.weight"] = state["tok_emb.weight"] # embeddings are tied
model.load_state_dict(state)
model.eval()
ids = tok.encode(normalize("كان يا ما كان")).ids[:-1] # drop the trailing
out = model.generate(torch.tensor([ids]), max_new_tokens=120,
temperature=0.85, top_k=50, eos_id=EOS)
print(tok.decode(out[0].tolist()))
```
`lm_head` and `tok_emb` are the same tensor, so only one is stored and the tie is
restored on load. `modeling_ruqlm.py` depends on nothing but `torch`.
### Interface
The graph takes `input_ids` of shape `[1, seq]` and returns `logits` for the **last
position only**, shape `[1, 8192]`. The sampling loop lives outside the model, so
temperature and top-k can change without re-exporting.
### In the browser
```html
```
The tokenizer normalises text the same way the Python example does, so no separate
normalisation step is needed here.
### Fine-tuning
The safetensors carry weights only — no optimiser state — so a fine-tune starts
from a fresh optimiser:
```python
model.train()
opt = torch.optim.AdamW(model.parameters(), lr=1e-4)
# input_ids: LongTensor [batch, seq]; the model shifts internally, so labels
# are the inputs themselves
logits, loss = model(input_ids, labels=input_ids)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
opt.step(); opt.zero_grad()
```
Context length is 512. Chat-role tokens `<|user|>` (id 4) and `<|assistant|>`
(id 5) are already reserved in the vocabulary, so instruction tuning needs no
tokenizer change.
---
## Limitations
These are **base text-completion models, not chat models.** They were trained on
Arabic stories only, so they continue what you start; they do not answer questions or
follow instructions, having had no instruction tuning (SFT) or RLHF. Expect
narrative, not answers.
Morphology and orthography are strong. **Semantic and referential coherence are
weaker** — which is what the accompanying study measures, and what these models were
built to quantify rather than to solve. Even the largest rung abandons 5.9% of the
characters it introduces, where the training corpus abandons 0.0%.
The corpus is synthetic, generated by two teacher models: **ALLaM 2.7B** (75%) and
**Qwen3.6-27B** (25%), with quality gates rejecting foreign characters and template
openings. Both teachers are Apache-2.0.
---
## What the models are for
They were built to answer a measurement question: across a 40× range of parameters
at fixed data, which linguistic competence scales and which saturates?
| Axis | Behaviour across the ladder |
|---|---|
| Lexical diversity (TTR) | **saturated** — 0.719 ± 0.005 → 0.721 ± 0.005 (0.4σ) |
| Character tracking | **scales** — 31.6% → 5.9% abandoned (6.3σ) |
| Orthography, agreement | saturated below 0.74M |
Capacity buys coherence, not vocabulary.
---
## Paper
*In preparation.* The paper covers the corpus construction, the evaluation
framework, and every measurement reported here.
```bibtex
@misc{binsafi2026ruqlm,
title = {What Scales in a Small Arabic Language Model?
Morphology Saturates, Coherence Does Not},
author = {Bin Safi, Ruqiya},
year = {2026},
note = {Preprint}
}
```