Skip to content
143 changes: 143 additions & 0 deletions arabic_to_ipa.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"""Deterministic diacritized-Arabic -> broad-phonemic IPA (MSA).

Rule-based converter for the r8 IPA auxiliary task: maps fully or
partially diacritized Arabic text to a broad phonemic transcription.
Deterministic, dependency-free, auditable. Known approximations
(acceptable for auxiliary supervision, not for phonetic evaluation):
- no stress marking
- hamzat al-wasl on the article treated as ʔa
- ج mapped to dʒ (MSA reading); other realizations ignored
- dagger alaf (U+0670) lengthens the preceding vowel
"""

from __future__ import annotations

import re

DIACRITICS = "ؐ-ًؚ-ٰٟۖ-ۜ۟-۪ۨ-ۭ"

CONSONANTS: dict[str, str] = {
"ب": "b", "ت": "t", "ث": "θ", "ج": "dʒ", "ح": "ħ", "خ": "x",
"د": "d", "ذ": "ð", "ر": "r", "ز": "z", "س": "s", "ش": "ʃ",
"ص": "sˤ", "ض": "dˤ", "ط": "tˤ", "ظ": "ðˤ", "ع": "ʕ", "غ": "ɣ",
"ف": "f", "ق": "q", "ك": "k", "ل": "l", "م": "m", "ن": "n",
"ه": "h", "ء": "ʔ", "أ": "ʔ", "إ": "ʔ", "ؤ": "ʔ", "ئ": "ʔ",
"و": "w", "ي": "j",
}
VOWELS: dict[str, str] = {"َ": "a", "ِ": "i", "ُ": "u",
"ً": "an", "ٍ": "in", "ٌ": "un"}
SUN_LETTERS = set("تثدذرزسشصضطظلن")
ARTICLE = re.compile(r"^ال([^%s])" % DIACRITICS)
DIAC_RE = re.compile("[%s]" % DIACRITICS)


def _is_diacritic(ch: str) -> bool:
return bool(DIAC_RE.match(ch))


def _letters(word: str) -> list[str]:
return [c for c in word if not _is_diacritic(c)]


def _units(word: str) -> list[tuple[str, str]]:
"""Split into (letter, marks) units."""
units: list[tuple[str, str]] = []
for ch in word:
if _is_diacritic(ch):
if units:
units[-1] = (units[-1][0], units[-1][1] + ch)
else:
units.append((ch, ""))
return units


def _word_to_ipa(word: str) -> str:
letters = _letters(word)
if not letters:
return ""

units = _units(word)
article = False
prefix = ""
m = ARTICLE.match(word)
if m and len(letters) >= 3 and m.group(1) in SUN_LETTERS:
article = True
prefix = "ʔa"
units = units[2:]
if units:
units[0] = (units[0][0], units[0][1] + "ّ") # force gemination

out: list[str] = [prefix] if prefix else []
prev_vowel = ""
for idx, (letter, marks) in enumerate(units):
geminate = "ّ" in marks
vowel = next((VOWELS[m] for m in marks if m in VOWELS), "")
dagger = "ٰ" in marks
last = idx == len(units) - 1

if letter == "آ":
out.append("ʔaː")
prev_vowel = "a"
elif letter in ("ا", "ى"):
if prev_vowel == "an":
pass # tanwin carrier, silent
elif prev_vowel == "a":
out.append("ː") # a -> aː
elif letter == "ى":
out.append("a")
elif not out:
out.append("ʔa")
else:
out.append("ʔ")
prev_vowel = ""
elif letter == "ة":
if last:
out.append(("t" + vowel) if vowel else "a")
else:
out.append("t" + vowel)
prev_vowel = vowel
elif letter == "و" and prev_vowel == "u" and not vowel:
out.append("ː") # u -> uː
prev_vowel = ""
elif letter == "ي" and prev_vowel == "i" and not vowel:
out.append("ː") # i -> iː
prev_vowel = ""
else:
base = CONSONANTS.get(letter, letter)
out.append(base + ("ː" if geminate and base != letter else ""))
if vowel:
out.append(vowel)
if dagger and out and out[-1] in ("a", "i", "u"):
out.append("ː")
prev_vowel = vowel
return "".join(out)


def to_ipa(text: str) -> str:
rendered: list[str] = []
for w in re.split(r"(\s+)", text):
if not w:
continue
rendered.append(" " if w.isspace() else _word_to_ipa(w))
return "".join(rendered).strip()


if __name__ == "__main__":
import sys

samples = [
"السَّلَامُ عَلَيْكُمْ",
"كِتَابٌ مُفِيدٌ",
"مَرْحَبًا",
"الشَّمْسُ طَالِعَةٌ",
"قَالَ الرَّجُلُ",
"إِنَّ اللَّهَ غَفُورٌ رَحِيمٌ",
"هَذَا الْكِتَابُ",
"بِسْمِ اللَّهِ الرَّحْمَٰنِ الرَّحِيمِ",
"فِي الْبَيْتِ",
"يَكْتُبُ الْوَلَدُ الدَّرْسَ",
]
if len(sys.argv) > 1:
samples = [" ".join(sys.argv[1:])]
for s in samples:
print(f"{s}\n -> {to_ipa(s)}")
65 changes: 65 additions & 0 deletions docs/RESULTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,3 +333,68 @@ both ways. Beam stays UNSHIPPED for Arabic: greedy posteriors are
already sharp (consistent with the knowledge-injection diagnosis),
and beam would cost ~4x inference. The Hebrew beam gain (12 DER
points) does not transfer. Script: eval_arabic_r6_beam4.py.

## Arabic r7 — news-domain adaptation: NEW CANONICAL TEACHER (2026-08-28)

Init from r6, anchor r5-units + 13,986 news units (0.85% mix) + 400
gold-2014 lines. Windowed zero-skip, full 1,200 paragraphs:

### r7 verdict table (SadeedDiac-25, 2026-08-28)

| Model | Total DER | Morph DER | Protocol |
|---|---|---|---|
| **r7 (news-domain)** | **2.2864** | **1.3343** | windowed zero-skip |
| r6 (morph aux) | 2.5793 | 1.5317 | windowed zero-skip |
| r5 | 2.6775 | 1.5965 | windowed zero-skip |

**−0.29pp over r6** — the news mix (teacher-labeled news units + a
small gold anchor) improved IN-DOMAIN substantially, not just OOD.

### r7 OOD verdict table — WikiNews-2024 multi-ref (2026-08-28)

Out-of-domain, WikiNews-2024 multi-ref (QCRI protocol, full mode):

| Model | WER | DER |
|---|---|---|
| **r7** | **17.3794** | **11.8273** |
| r6 | 19.8191 | 12.4613 |
| r5 | 20.52 | 12.72 |

**r7 sweeps: best ID and best OOD of the teacher lineage — r7
REPLACES r6 as the canonical Arabic teacher** (artifacts:
rababa_arabic_byt5/run-007-news/best). On the SadeedDiac-25 leaderboard
it is the best dedicated model under the protocol, behind only
Claude-3.7-Sonnet's published 1.3941, now well clear of GLM-5.2 (2.6911).
Future student distillations take r7 as teacher. Script:
train_arabic_r7.py; artifacts: EVAL_DONE, sadeed_preds_windowed.csv,
wikinews_multiref_r7.json.

## Arabic r8 — IPA aux-task (phonemic supervision): controlled negative vs morph (2026-08-27)

The controlled experiment the r6 claim needed: r8 differs from r6 in
EXACTLY one variable — the aux stream's output representation. Stream B
renders the SAME r5-units as broad-phonemic IPA (deterministic converter,
arabic_to_ipa.py) instead of qalsadi morphology; same ~25% aux share,
same seeded sample, same init (r5), same 1-epoch A100 schedule.

### r8 verdict table (SadeedDiac-25, windowed zero-skip, 1400B, full 1,200)

| Model | Total DER | Morph DER | Protocol |
|---|---|---|---|
| **r6 (morph aux, canonical)** | **2.5793** | **1.5317** | windowed zero-skip |
| r8 (IPA aux) | 2.6588 | 1.5783 | windowed zero-skip |
| r5 (no aux) | 2.6775 | 1.5965 | windowed zero-skip |

IPA-stream probe (200 held-out domain units): **CER 0.0230, EM 62/200**
— the model genuinely learned the second projection, so the comparison
is not confounded by a failed aux task.

Read: IPA aux helps over no-aux (−0.019pp Total DER) but loses to
morphological aux (r8 is +0.080pp worse than r6). Phonemic supervision
is NOT the active ingredient in the r6 win; lexical/morphological
knowledge (iʿrāb) is. The "diacritization helped by phonemes" hypothesis
survives only in its weak form (a structured auxiliary projection beats
none) and fails in its strong form (phonemic specifically). r6 stays the
canonical Arabic teacher. Script: train_arabic_r8.py; artifacts:
rababa_arabic_byt5/run-008-ipa (EVAL_DONE, ipa_probe.json,
sadeed_preds_windowed.csv).
5 changes: 4 additions & 1 deletion label_arabic_news.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,4 +218,7 @@ def _chunks(article: str) -> list[str]:

@app.local_entrypoint()
def main():
label.remote()
# spawn: disconnect-immune (workstation network flaps cancelled
# attached runs); resumable via label_progress.jsonl
handle = label.spawn()
print(f"spawned {handle.object_id}", flush=True)
6 changes: 5 additions & 1 deletion train_arabic_r7.py
Original file line number Diff line number Diff line change
Expand Up @@ -334,4 +334,8 @@ def project_haraqat(pred: str, text: str) -> str:

@app.local_entrypoint()
def main(init_run: str | None = None):
train.remote(init_run=init_run)
# spawn (fire-and-forget): r8's two client-side disconnects killed
# attached runs; resume/EVAL_DONE guards make relaunch idempotent
handle = train.spawn(init_run=init_run)
print(f"spawned {handle.object_id}; completion = EVAL_DONE marker at "
f"rababa_checkpoints:rababa_arabic_byt5/run-007-news/EVAL_DONE", flush=True)
Loading
Loading