A 0.5B language model that repairs Arabic OCR output — the step between "we scanned the archive" and "the archive is searchable."
raw OCR وأضا ف أن "بريطايا يمكن أن تؤثر على الاتحاد الأوروبى عبر الـعمل هع شركائها".
corrected وأضاف أن "بريطانيا يمكن أن تؤثر على الاتحاد الأوروبي عبر العمل مع شركائها".
Model: Sheeda/arabic-ocr-post-correction-0.5b
Arabic OCR fails differently from Latin OCR, and the reason is structural.
Many Arabic letters share an identical skeleton (rasm) and are told apart only by dots (i'jam). ب ت ث ن are the same stroke. A scanner that loses a dot to a faded patch, or invents one from a speck of dust, changes the letter. The same is true across the alphabet: ج ح خ, د ذ, ر ز, س ش, ص ض, ط ظ, ع غ, ف ق.
Arabic is also cursive, so gaps between words are narrow and the scanner routinely splits one word in two or fuses two into one.
The consequence, measured on this repo's data:
| rate | |
|---|---|
| characters wrong | 8.1% |
| words wrong | 41.1% |
A word is only correct if every character in it is correct, so a small amount of character damage breaks four words in ten. Search, indexing and extraction all operate on words. An archive at 41% word error is scanned, stored, indexed — and unsearchable. That gap is what this closes.
No paired Arabic OCR corpus exists — not on Kaggle, not on the HuggingFace Hub. So the training data is generated: take clean Arabic text, break it the way a scanner breaks it, and train the model to undo the damage.
The corruption (src/ocr_noise.py) is not random noise. It
is weighted toward how the script actually fails:
| corruption | weight |
|---|---|
| dot / skeleton confusion (ب↔ت↔ث, ف↔ق, …) | 42% |
| word splitting and merging | 22% |
| dropped or spurious character | 16% |
| kashida, hallucinated diacritics, transposition | 20% |
Severity is sampled per example between 4% and 18%, so one model handles clean prints and degraded historical pages alike.
Why 0.5B. The output is a near-copy of the input with local repairs — a narrow task that doesn't need a large model. It also means the thing runs on-premise on CPU, which is the deployment mode that matters for archives that cannot leave the building.
Greedy decoding, always. There is exactly one correct answer; sampling only invents text that was never on the page.
200 held-out segments, greedy decoding.
| CER | WER | |
|---|---|---|
| Raw OCR (do nothing) | 0.0808 | 0.4112 |
| Untuned Qwen2.5-0.5B | 1.8123 | 2.2680 |
| Finetuned (57k pairs) | 0.0724 | 0.1765 |
| Finetuned + guardrail | 0.0671 | 0.2042 |
Word error rate falls 41.1% → 20.4%, a 50.3% reduction, while character accuracy improves 16.9% at the same time.
The untuned base model scores CER 1.81. Above 1.0 means the output is not a damaged version of the truth but unrelated text — it answers the instruction conversationally instead of restoring. Every gain is therefore attributable to the finetune, not to Qwen already knowing Arabic.
| training pairs | CER | WER | segments made worse |
|---|---|---|---|
| 0 (untuned) | 1.8123 | 2.2680 | 200/200 |
| 2,000 | 0.2139 | 0.3751 | 170/200 |
| 20,000 | 0.1083 | 0.2327 | 115/200 |
| 56,931 | 0.0724 | 0.1765 | 70/200 |
Loss 1.976 → 1.751 → 1.637; token accuracy 0.667 → 0.700 → 0.717.
| severity | n | baseline CER | model CER |
|---|---|---|---|
| light (<8%) | 62 | 0.0438 | 0.0404 |
| medium (8–14%) | 82 | 0.0813 | 0.0713 |
| heavy (>14%) | 56 | 0.1211 | 0.1092 |
On the 123/200 segments it helps, CER drops 0.0816 → 0.0377. 20/200 come back exactly correct.
A generative model can rewrite rather than repair.
apply_guardrail rejects any correction that diverges from its
input by more than a CER threshold and keeps the original instead.
Divergence is measured against the input, never the reference, so it runs in
production. The threshold is an operating dial:
| policy | CER | WER | corrections kept |
|---|---|---|---|
| raw OCR | 0.0808 | 0.4112 | — |
| accept everything | 0.0724 | 0.1765 | 200/200 |
| drift ≤ 0.15 | 0.0667 | 0.2676 | 133/200 |
| drift ≤ 0.20 | 0.0671 | 0.2042 | 180/200 |
| drift ≤ 0.30 | 0.0695 | 0.1781 | 195/200 |
drift ≤ 0.20 is the default: near-best on both metrics at once. Tighten it
where nothing may be made worse, loosen it where findability outweighs character
fidelity.
Arabic BERT Corpus (56,946 pairs, baseline CER 0.0838 / WER 0.3900) follows the same trajectory at 2,000 pairs: untuned 2.2092 → tuned 0.1956. The behaviour is a property of training scale, not of one dataset.
Trained on a single NVIDIA RTX 5070 Ti Laptop GPU — 12 GB VRAM, Blackwell (sm_120), native bf16, torch 2.11 + CUDA 12.8.
| full run | 56,931 pairs × 2 epochs, 80 minutes |
| throughput | 21.6 samples/s |
| peak VRAM | under 12 GB |
| base model | Qwen2.5-0.5B-Instruct, bf16 (no quantisation) |
| method | LoRA r=32, α=64, all attention + MLP projections |
| schedule | effective batch 32, lr 2e-4 cosine |
Two settings were chosen by measurement rather than convention:
- Gradient checkpointing off. It trades ~30% throughput for memory, and a 0.5B model with LoRA doesn't need it here: 16.9 → 21.6 samples/s.
- Batch size 8, not 16. At 16 the activations exceed VRAM and Windows silently spills to system RAM instead of raising OOM — throughput collapses to 2.6 samples/s. Eight is 8× faster.
It also trains on a free Colab T4. train.py detects that Turing has no bf16
and falls back to fp16 automatically.
Locally:
pip install -r requirements.txt
python src/build_dataset.py --input data/raw --output data/processed
python src/train.py --config configs/qwen05b_lora.yaml
python src/evaluate.py --adapter outputs/arabic-ocr-post-correction/final --max-drift 0.20
python src/infer.py --adapter outputs/arabic-ocr-post-correction/final --text "<noisy arabic>"The data pipeline needs no GPU stack — pip install pyyaml is enough to run
python src/ocr_noise.py and watch the corruption model work.
src/ocr_noise.py the Arabic OCR confusion model
src/build_dataset.py corpus -> (noisy, clean) pairs; auto-detects file format
src/train.py LoRA finetuning
src/evaluate.py CER/WER vs. baseline, by severity
src/infer.py correction, batched, plus the guardrail
src/metrics.py CER/WER, dependency-free
configs/ hyperparameters
notebooks/ Colab training notebook
results/ every number above, plus per-segment predictions
- The corruption is synthetic. It models Arabic OCR failure from the structure of the script; it is not a recording of a real engine's output. Calibrating against real Tesseract or PaddleOCR output on scanned pages is the honest next step, and until that is done these numbers describe performance on synthetic corruption.
- Trained on Modern Standard Arabic news text. Dialectal and heavily classical text are out of distribution.
- The guardrail catches wholesale rewrites, not small confident wrong edits. Those need token-level confidence or a lexicon check.
- Weights are not committed; the adapter lives on the HuggingFace Hub.
MIT — see LICENSE. The base model's license governs the finetuned weights independently, as does the license of the corpus you train on.