diff --git a/TODO.improve-models/01-urdu-byt5-d1.md b/TODO.improve-models/01-urdu-byt5-d1.md new file mode 100644 index 0000000..d40835f --- /dev/null +++ b/TODO.improve-models/01-urdu-byt5-d1.md @@ -0,0 +1,31 @@ +# 01 — Urdu diacritization d1: ByT5-base + cross-lingual init + +## Why +Urdu is our weakest shipped model (14.77% CER, urdu_diacrit/run-001, +custom char encoder trained on 635K cross-lingually machine-labeled +lines). Arabic — same script family, same task shape — sits at 2.68 DER +on ByT5-base with paragraph context. The gap is architecture + teacher +vintage, not task difficulty. + +## Plan +1. Data: existing corpus on volume `urdu-diacrit-datasets`: + - `urdu-diacritized/{train,val,test}.txt` (635K machine-labeled — + WEAK labels, teacher-poison rule applies: treat as stage-1 only) + - `urdu-diacrit/*.jsonl` (HF G2P-derived pairs from WO #306) +2. `train_urdu_d1.py` (rababa): + - Init: Arabic r5 teacher `/checkpoints/rababa_arabic_byt5/ + run-005-context/best` (ByT5-base) — cross-lingual init gives the + shared-abjad prior instead of starting cold. + - Stage 1: 1 epoch over the weak 635K (line units, byte tokenizer). + - Stage 2: none yet (no gold corpus found — UDD has no verifiable + public URL; do NOT fabricate one). The run's own test split is + machine-labeled too, so the eval is comparative, not absolute. +3. Eval: greedy CER via editdistance on `urdu-diacritized/test.txt`, + identical protocol to the 14.77 number + word-level accuracy. + Target: clearly under 14.77 CER (architecture+init upgrade). +4. Launch detached under supervisor app `rababa-urdu-d1` + (EVAL_DONE guard, checkpoint-resume, volume commits). + +## Guards +- No LLM teacher. No RL. Weak corpus is from our Arabic model only. +- Parallel to r6 (separate Modal app + GPU). diff --git a/TODO.improve-models/02-arabic-r7-news-domain.md b/TODO.improve-models/02-arabic-r7-news-domain.md new file mode 100644 index 0000000..fc2c871 --- /dev/null +++ b/TODO.improve-models/02-arabic-r7-news-domain.md @@ -0,0 +1,46 @@ +# 02 — Arabic r7: news-domain adaptation (OOD repair) + +## Why +r5 paragraph-context specialized to the SadeedDiac domain and trades +~0.5 DER out-of-domain (WikiNews-2024 multi-ref: r5 20.52/12.72 vs +r3 19.99/12.60). If r6 verifies, case endings improve too — but the +OOD gap needs domain data, not morphology. + +## Plan +1. `label_arabic_news.py` (rababa) — runs NOW, parallel to r6: + - Fetch unlabeled Arabic news from HF + (`khalidalt/ultimate_arabic_news`, fallback + `Abdelkareem/arabic-bbc-news`), clean (Arabic-letter fraction, + length, dedupe). + - Pseudo-label with r5 (windowed 1400B zero-skip, greedy, same + harness as eval) → volume `/datasets/arabic-news-r5/`. + - Add GOLD news: `/datasets/wikinews/WikiNews_2014.txt.diac` + (gold-diacritized, different year/documents than the 2024 probe). + - NEVER touch `WikiNews_2024*` — that is the OOD probe. +2. `train_arabic_r7.py` — launch AFTER r6 verdict + labels exist: + - INIT = r6 best if r6 verifies better on SadeedDiac, else r5. + - Mix: cached r5-units (replay, protects ID) + news units + (2014 gold upweighted + r5-pseudo modern news), news ≈ 15-20% + of steps. r5-proven batch/accum, A100-80GB. + - Gates: SadeedDiac-25 windowed zero-skip must not regress beyond + +0.1 DER of the init model; WikiNews-2024 multi-ref must improve. +3. Fold verdict into docs/RESULTS.md + DISTILL-SOURCE-PROMPT.md only + when both gates pass. + +## Guards +- Domain adaptation via pseudo-labels is self-training — the replay + majority + gold 2014 news keeps ID anchored; the ID gate is the + hard stop against entrenchment. +- No RL, no LLM labels. + +## DEFERRED (2026-08-22) + +r6's verified OOD sweep (WikiNews-2024 full 19.82/12.46 — beats r3 +AND r5) absorbed this workstream's purpose: there is no OOD deficit +left to repair. r7 as designed costs 20h of A100-80GB for marginal +OOD gains with ID-regression risk, and its concurrent A100 footprint +is exactly what triggers Modal workspace evictions. News labeling +stopped at 5,600/13,987 windows — all committed and resumable on the +volume (label_progress.jsonl), and train_arabic_r7.py is ready with +--init-run run-006-morph. Reopen ONLY if a news-heavy client use +case emerges or Arabic OOD regresses in the wild. diff --git a/TODO.improve-models/03-rag-homograph-probe.md b/TODO.improve-models/03-rag-homograph-probe.md new file mode 100644 index 0000000..8d4d5d6 --- /dev/null +++ b/TODO.improve-models/03-rag-homograph-probe.md @@ -0,0 +1,32 @@ +# 03 — RAG homograph disambiguation probe (Persian first) + +## Why +Persian v1 sits at 77.34% SentenceBench homograph (ezafe-normalized) — +above published Homo-GE2PE (76.89) but flat: RL was negative, the +mapped-representation line is closed. Retrieval context is the one +untried lever, and homograph resolution is precisely a context problem. + +## Plan +1. `eval_persian_rag_probe.py` (rababa-farsi) — INFERENCE ONLY, no + retraining for the probe: + - Index HomoRich TRAIN homograph sentences (TF-IDF char n-gram + retrieval over sentence contexts, pure numpy/sklearn-free). + - For each SentenceBench test sentence, retrieve top-k (k=3) + contextually nearest TRAIN sentences containing the same + homograph token, format as few-shot prefix: + ` => ;` repeated, then + the test sentence. ByT5-small handles prefix context in bytes. + - Baseline vs RAG on the SAME harness (`eval_sentencebench.py` + protocol, ezafe-normalized homograph accuracy + exact match). +2. Decision rule: + - RAG ≥ +1.5pp homograph → invest: cache retrieval index, then a + fine-tune WITH retrieved prefixes (train-time consistency). + - +0.5..1.5pp → cheap inference-time add-on only, document. + - < +0.5pp → close the lever, record negative in docs/RESULTS.md. +3. If Persian moves, port the probe to Hebrew (Nakdimon homographs) + and Arabic (SadeedDiac residual analysis). + +## Guards +- Retrieval from TRAIN splits only — zero test contamination; assert + no test sentence appears in the index. +- Teacher stays v1 (RELEASE-FROZEN); probe never modifies it. diff --git a/TODO.improve-models/04-hebrew-s46-scaled-weak.md b/TODO.improve-models/04-hebrew-s46-scaled-weak.md new file mode 100644 index 0000000..a8ec3ee --- /dev/null +++ b/TODO.improve-models/04-hebrew-s46-scaled-weak.md @@ -0,0 +1,31 @@ +# 04 — Hebrew s46: scale + diversify the weak stage + +## Why +s45 proved the curriculum (+0.88 DER over gold-only: 16.58 vs 17.46) +but its weak stage was knesset only (1.5M lines, single domain — +parliamentary transcripts). hewiki (80K Hebrew-Wikipedia lines) is a +second, encyclopedic domain sitting unlabeled on the volume. Same +lever, turned up: more + more-diverse weak data before the identical +gold FT. + +## Plan +1. `label_hewiki_full.py` (rababa) — runs NOW on A10G: + - Full-scale Dicta labeling of `/datasets/hewiki/train.txt` + (80K lines) via the batch recipe from `batch_distill_hewiki.py` + (DictaBERT predict batches), nikud-only targets (strip teamim), + length/Hebrew-fraction filters, 40-char window decontam vs + Nakdimon test. + - Output: `/datasets/hebrew-hewiki-dicta/{train,val}.txt`. +2. `train_hebrew_s46.py` — s45 VERBATIM with one change: + - Stage 1 weak = knesset 1.5M + hewiki-dicta (all of it, + ~80K lines ≈ 5% of weak steps — a domain garnish, not a pivot). + - Stage 2 = s43 gold recipe unchanged (hebrew-v4 jsonl, 3 ep, + batch 8, LR 3e-4, warmup 500). + - Eval: beam-4 DER, identical protocol/harness as s45's 16.58. +3. Gates: must beat s45's 16.58 to replace it; otherwise record as + flat and keep s45 (weak-stage ceiling reached). + +## Guards +- Dicta labels are weak only — the gold stage corrects (no-teacher- + poison, the exact s45-validated pattern). +- Zero Nakdimon-test contamination (window decontam). diff --git a/TODO.improve-models/05-khmer-g2p-byt5.md b/TODO.improve-models/05-khmer-g2p-byt5.md new file mode 100644 index 0000000..4a8d35c --- /dev/null +++ b/TODO.improve-models/05-khmer-g2p-byt5.md @@ -0,0 +1,25 @@ +# 05 — Khmer G2P v1: ByT5-small on the 17.9K UNGEGN word pairs + +## Why +The only Khmer artifact is the legacy crystalseq transformer +(net-500-epochs.pth, loss 0.143, no held-out eval) plus its fp16 IMF +zip. Every other language in the zoo runs a modern recipe with a +measured number. Khmer is the breadth gap. + +## Plan +1. Data: secryst-datasets:/data-khmer-translit/{data_kh,data_rom}.csv + — 17,911 aligned word pairs (validated: no dups, p95 word = 36B, + 1 empty rom line filtered). Split 80/10/10 seeded. +2. `train_khmer_g2p.py` (secryst-train repo): + - google/byt5-small, word-level src→tgt, batch 64, LR 3e-4, + 30 epochs, best-on-val-loss checkpointing (small-data recipe). + - Eval: held-out test — word accuracy (exact match) + CER via + editdistance. This is the FIRST measured Khmer number; the + crystalseq model gets the same eval for the comparison table. +3. Export to IMF v1 when the number lands (parts contract, byte+3 + tokenizer) — replaces the fp16 zip of the legacy model. + +## Guards +- Word-level G2P (not diacritization): no LLM-teacher concerns, but + the no-RL/no-LLM standing rules apply anyway. +- Keep the legacy crystalseq artifacts untouched (source files). diff --git a/TODO.improve-models/06-urdu-beam-and-d2.md b/TODO.improve-models/06-urdu-beam-and-d2.md new file mode 100644 index 0000000..3f8c805 --- /dev/null +++ b/TODO.improve-models/06-urdu-beam-and-d2.md @@ -0,0 +1,21 @@ +# 06 — Urdu d1 follow-up: beam-4 eval, then d2 decision + +## Why +d1 (CER 6.40%, word_acc 47.43%) was evaluated GREEDY only. The Hebrew +s45 experience: beam-4 at inference is worth double-digit DER points +on ByT5 diacritizers. Before spending a d2 training run, collect the +free win and re-read the saturation point. + +## Plan +1. `eval_urdu_d1_beam.py` (rababa): beam-4 vs greedy on the identical + test protocol (urdu-diacrit/test.jsonl, CER + word_acc, n=11,714). +2. Decision after the number: + - If beam-4 word_acc still < ~55%: d2 with a second epoch at lower + LR (1e-5) from run-001-d1 — cheap, single A100 pass. + - If beam-4 lifts word_acc >= ~55%: declare d1 final for the weak + corpus; the next real gain requires gold Urdu data (none + verifiable — do not chase it). + +## Guards +- Same test set, same alignment protocol as the d1 verdict so the + numbers are comparable line-for-line. diff --git a/TODO.public b/TODO.public new file mode 100644 index 0000000..b21d175 --- /dev/null +++ b/TODO.public @@ -0,0 +1,68 @@ +# TODO.public — remaining work after the 2026-08-22 audit + +Verdicted and frozen: Arabic r6 (2.5793/1.5317 ID, 19.82/12.46 OOD), +Thai 1.73% PER, Persian v1 (77.34 homograph), Hebrew s45 16.58 (s46 +verdict pending), Urdu d2 5.77 CER (within-corpus), Khmer v1 59% +word-acc. No further teacher training on Arabic/Thai/Persian — the +levers below are the only funded work. + +## 1. Khmer: reframe as vowel-restoration (EXPANSION, not marginal) +The UNGEGN rule map (secryst/data-khmer-translit/ungegn-khmer-system.yaml) +handles FULL orthography; the real-world problem is Khmer's unwritten +inherent vowels and reduced homographic orthography, which rules +cannot restore. Deliverable: a model that romanizes INCOMPLETE input. +- [ ] Rules-vs-ML audit on the 895-word split (full orthography): + run the yaml map, publish both numbers honestly. +- [x] Vowel-strip augmentation: strip vowel signs/coeng from src, + keep full tgt; train Khmer v2 on (full→full) + (stripped→full). + This is a new CAPABILITY (restoration), measured by a + stripped-input benchmark where rules structurally cannot run. + DONE (run-002-restore): stripped 19.22% word_acc / 39.41 CER + (rules 0.0%); full 53.18/28.27 (−5.8pp vs v1 58.99). +- [ ] Ship decision: map for full orthography, v2 for reduced. + Recommended exactly that; optional parked lever = v1-init + two-stage curriculum to reclaim the 5.8pp. + +## 2. Urdu: anchor the claim (zero GPU) +- [ ] Find the official UDD (gold-standard Urdu diacritization) + source — the paper's own release, not a guessed URL. If found: + evaluate d2 on it, publish the comparable number. If not + found: label the claim "within-corpus, no gold available" + everywhere it appears. + +## 3. Hebrew s47: transplant the r6 morph template — CLOSED (negative) +Hebrew's 16.58 residual is plausibly morphology (nikud encodes +gender/number/agreement) — the same shape as Arabic's iʿrāb residual +that r6's aux-task fixed. The template is validated; this is its +first cross-language transplant. +- [x] Ground a Hebrew morph labeler (Dicta morph disambiguator / + HebPipe-class POS+morph) — must run offline-ish on Modal. + DONE: dictabert-morph (probe + labels verified). +- [x] Label knesset + hewiki lines with morph tags (A10G, ~3h). + DONE: 200K knesset lines → /datasets/hebrew-morph/train.jsonl + (dictabert-morph, segmented src + per-token tags, 100% keep + after prefix-split fix; hewiki morph not needed for s47 v1). +- [x] train_hebrew_s47.py: s45 recipe + TAG-prefixed morph stream + (init from s46-best if it beats s45, else s45), aux up x4. +- [x] Gate: beat s45/s46 DER on the identical beam-4 harness. + RESULT (2026-08-23): s47 16.53 vs s46 16.43 — FAILED. + r6's aux-task win is not template-portable; Arabic's residual + was iʿrāb-shaped, Hebrew's is not. s46 stays canonical; line + closed (no s48). + +## 4. Arabic r6 beam-4 probe — CLOSED (negative) +All Arabic numbers are greedy; Hebrew gained 12 DER points from +beam. If beam helps r6, ship beam — free quality, zero training. +DONE (2026-08-23): beam-4 Total DER 2.5588 vs greedy 2.5793, Morph +1.5379 vs 1.5317 — noise both ways. Greedy stays (4x cheaper). + +## 5. Delivery (agent-side per manifest; our job: keep teachers stable) +- [ ] IMF v1 export + index registration: urdu-diac d2, khmer + decision after §1, r6 teacher refresh for the agent's + ara-diac re-distillation. +- [ ] secryst.org models page refresh with the verdict table. +- [ ] Close CI: interscript-ruby #764/#763, maps #181. + +## Non-goals (money discipline) +No r8, no more Thai/Persian, no LLM-teacher experiments (#313 +parked), no Khmer training beyond §1's restoration framing. diff --git a/arabic_to_ipa.py b/arabic_to_ipa.py new file mode 100644 index 0000000..dfe039a --- /dev/null +++ b/arabic_to_ipa.py @@ -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)}") diff --git a/docs/CLE-INQUIRY.md b/docs/CLE-INQUIRY.md new file mode 100644 index 0000000..80cb4d0 --- /dev/null +++ b/docs/CLE-INQUIRY.md @@ -0,0 +1,56 @@ +# CLE inquiry email (draft) + +To: webmaster@cle.org.pk +Subject: Commercial licensing & purchase — Urdu Digest Corpus, Urdu WordNet, and diacritization resources + +--- + +Dear CLE team, + +I work on Interscript / Secryst, an open-source +project building transliteration and diacritization models for low-resource +languages. Our trained model weights are released freely under BSD-3-Clause +licenses; we do not redistribute third-party corpora themselves. + +We would like to purchase the following from the CLE store: + +1. Urdu Digest Corpus — 1 million words (International package) +2. CLE Urdu WordNet + +Could you confirm the price and payment process for these two items? In +particular, please note our use case and confirm the appropriate license: + +- We would train neural diacritization and transliteration models on the + corpus and publish the resulting model weights openly (free of charge, + BSD-3). The corpus itself would not be redistributed. +- The store notes that free access is limited to academic, non-commercial + research, so we assume a commercial license applies. Please quote terms + that cover "training of openly released models" — this distinction + (corpus stays with us, weights go public) matters to us and we want to + comply correctly. + +Separately, three research resources that do not appear in the store +catalog would be decisive for our Urdu work if available under any +license (commercial or evaluation-only): + +1. The diacritized corpus and/or diacritized lexicon behind the CLE thesis + "Automatic Urdu Diacritization" (2009), and any successor data from your + diacritizer work. We are specifically seeking gold-standard diacritized + Urdu text for evaluation of diacritization models. +2. The letter-to-sound rules and pronunciation lexicon from the Urdu TTS + line of work: "Letter to Sound Rules for Urdu Text to Speech System" + (COLING 2004), "Phonological Processing for Urdu Text to Speech System" + (2005), and "Corpus Based Urdu Lexicon Development" (CLT 2007). +3. The Urdu/Hindi transliteration resource described in "Transliterating + Urdu for a Broad-Coverage Urdu/Hindi LFG Grammar" (LREC 2010). + +Are any of these available, and at what terms? Even an evaluation-only +license for the diacritized corpus would be valuable — we would cite CLE +in all resulting publications and model cards. + +Thank you for your time, and for the remarkable body of Urdu language +resources CLE has built. + +Best regards, +[Name] +Interscript / Secryst — [email], [URL] diff --git a/docs/DISTILL-SOURCE-PROMPT.md b/docs/DISTILL-SOURCE-PROMPT.md index 531ae7e..ef37b6c 100644 --- a/docs/DISTILL-SOURCE-PROMPT.md +++ b/docs/DISTILL-SOURCE-PROMPT.md @@ -15,6 +15,10 @@ General rules (unchanged): - NEW standing verdict: RL variants (RAFT/GRPO/GTPO) are all flat or negative on these teachers — do not retrain or RL-polish a teacher, and treat teacher outputs (not any RL variant) as the label source. + Data-side knowledge injection WORKS: r6's morph aux-task is the + template (aux supervision >> policy sharpening). +- r6 VERDICT LANDED (2026-08-21): 2.5793/1.5317, replaces r5. The + "in flight" caveat is void. - SUCCESSORS IN FLIGHT (2026-08-20): r6 Arabic morph aux-task (run-006-morph, resumed from checkpoint-6000 after a preemption; ~15h remaining). Standing rule: it does not replace r5 until a @@ -23,20 +27,21 @@ General rules (unchanged): Thai scaleup600k 1.7260% PER — section 4; Hebrew s45 16.58% DER — section 2.) -## 1. Arabic diacritizer — r5 paragraph-context (580M ByT5-base) ★ NEW CANONICAL -- Path: `/checkpoints/rababa_arabic_byt5/run-005-context/best` (HF) -- Quality: **2.6775 DER (CE) / 1.5965 (w/o CE)** on SadeedDiac-25, - windowed zero-skip at 1400B — beats our verified GLM-5.2 reproduction - (2.6911/1.7179). This is the strongest Arabic teacher we will ship. +## 1. Arabic diacritizer — r6 morph aux-task (580M ByT5-base) ★ CANONICAL (2026-08-21) +- Path: `/checkpoints/rababa_arabic_byt5/run-006-morph/best` (HF) +- Quality: **2.5793 DER (CE) / 1.5317 (w/o CE)** on SadeedDiac-25, + windowed zero-skip at 1400B — beats r5 (2.6775/1.5965). VERIFIED + 2026-08-21; r6 replaces r5 per this update. - Contract: input undiacritized Arabic text → output same text with haraqat. Context matters: feed up to 1400 bytes per call and window longer documents at word boundaries (see `eval_sadeed_windowed.py`). Generation cap = 2x window bytes (diacritized output is 1.4–1.6x input). For short inputs (≤600B) behavior matches r3. - Parity harness: Misraj evaluator (`sadeed_evaluator.py`) on the full - 1,200-paragraph benchmark, zero skips. Student target: ≤ 3.18 DER - (CE) windowed (= teacher +5pp-equivalent; scale from 2.68). -- `run-005-context/best` is RELEASE-FROZEN. GTPO-GRPO run-001 was flat + 1,200-paragraph benchmark, zero skips. Student target: ≤ 3.07 DER + (CE) windowed (= teacher +5pp-equivalent; scale from 2.58). +- `run-005-context/best` remains available as the fallback teacher; + r6's inference contract is identical (no TAG prefix at inference). GTPO-GRPO run-001 was flat vs r5 — do NOT use it as teacher. The 10M char-encoder (`rababa_arabic_v2/run-001/best.pt`, 3.2495/1.8072) remains the embedded-tier teacher if the student must be tiny. diff --git a/docs/MODELS.md b/docs/MODELS.md index 29d7ae5..208bd2d 100644 --- a/docs/MODELS.md +++ b/docs/MODELS.md @@ -8,20 +8,20 @@ verdicts land; check `git log` on this file for recency. | Teacher | Volume path | DER (CE) / (w/o CE) | Protocol | Status | |---|---|---|---|---| -| **r5 paragraph-context (current best)** | `/checkpoints/rababa_arabic_byt5/run-005-context/best` | **2.6775 / 1.5965** | windowed zero-skip, 1400B | ✅ final | +| **r6 morph aux (current best, CANONICAL)** | `/checkpoints/rababa_arabic_byt5/run-006-morph/best` | **2.5793 / 1.5317** | windowed zero-skip, 1400B, greedy | ✅ final | +| r5 paragraph-context | `/checkpoints/rababa_arabic_byt5/run-005-context/best` | 2.6775 / 1.5965 | windowed zero-skip, 1400B | superseded by r6 | | r3 | `/checkpoints/rababa_arabic_byt5/run-003-domain/best` | 2.8126 / 1.6877 | windowed zero-skip, 600B | final | -| r3 raw | same | 2.8429 / 1.7723 | single-shot 1024 | final | | r2 | `/checkpoints/rababa_arabic_byt5/run-002-full-2ep/best` | 2.9406 / 1.8333 | single-shot | final | | char-encoder (10M) | `/checkpoints/rababa_arabic_v2/run-001/best.pt` | 3.2495 / 1.8072 | benchmark protocol | final | -| GTPO-GRPO on r5 | `/checkpoints/rababa_arabic_grpo/run-001/best` | 2.6597 / 1.5818 | 700B windows (≠ r5's 1400B) | final — flat vs r5, kept for reference only | -| RAFT-002 on r3 | `/checkpoints/rababa_arabic_raft/run-002/best` | 2.8515 / 1.7617 | benchmark, beam 4 | final — flat vs r3 | - -Teacher stays **r5**: both RL variants were flat/negative, so r5's best -is the distilled-knowledge ceiling. (GLM-5.2 is NOT a teacher: standing -rule — no LLM teachers for diacritization; see -`results/sadeed-glm-5-2/README.md` for why the distillation idea was -rejected on principle.) +r6 = plain + "TAG: "-prefixed morph aux streams (qalsadi 300K lines, +iʿrāb supervision), init from r5. It also wins OOD (WikiNews-2024 +multi-ref: WER 19.82 / DER 12.46 vs r5 20.52/12.72), so it strictly +dominates every measured surface. Beam-4 probed 2026-08-23: flat — +greedy IS the contract (4x cheaper, same quality). Distill from r6; +the morph stream never appears at inference (no TAG prefix = plain +diacritization). (Standing rule unchanged: no LLM teachers for +diacritization — see `results/sadeed-glm-5-2/README.md`.) ## Evaluation protocol for parity gates @@ -36,13 +36,29 @@ rejected on principle.) ## Other languages (best checkpoints) -- Hebrew diacritization: ByT5-base **s45 phonikud curriculum, 16.58% DER** - (prior s43: 17.46; Nakdimon Biblical - test) — see `docs/RESULTS.md`. +- Hebrew diacritization: ByT5-base **s46 phonikud+hewiki, 16.43% DER** + (s45: 16.58; s43: 17.46; Nakdimon test, beam-4; greedy 16.44 ≈ + beam) — + `/checkpoints/rababa_hebrew/run-s46-phonikud-plus/run-002-gold-ft/best`. + s47 (morph aux transplant) CLOSED NEGATIVE 2026-08-23: 16.53 vs + 16.43 — the r6 template is not portable as-is; teacher line closed. +- Urdu diacritization: **urd-diac-1.0 (shipped, ByT5-small) is the + champion — CER 3.74 / word_acc 67.51** on urdu-diacrit/test.jsonl + (comparable eval 2026-08-23, both models under the 5.14.1 export + stack). d2 (`rababa_urdu_byt5/run-002-d2`, ByT5-base) measured + 5.94/51.95 on the SAME harness — it is the rababa-lineage best + only, not the cross-lineage best; do not ship it over 1.0. + Version trap: the urd-diac-1.0 checkpoint generates EMPTY strings + under transformers 4.46.3 — evaluate legacy checkpoints under their + export stack (torch 2.12.1 / transformers 5.14.1). No public gold; + CLE Pakistan inquiry pending. - Persian G2P: `persian_g2p/run-001/best` (v1, 77.34% SB HA ezafe-norm; - RAFT run tied — v1 remains canonical). -- Thai G2P: umt5 continued-fine-tune + epitran augmentation at scale - (367K lines, full Wikipedia), **1.7260% PER** (prior 2.32%; public - baseline 6.37%) — `/ckpts/secryst_thai_ipa_scaleup600k/run-001/best` - on `secryst-checkpoints`; secryst `docs/paper-thai`. -- Khmer: secryst forward model (59.66% EM) + backward (50.3% EM). + RAFT run tied — v1 remains canonical). FROZEN — no more training. +- Thai G2P: umt5 continued-fine-tune, **1.7260% PER** (public baseline + 6.37%) — `/ckpts/secryst_thai_ipa_scaleup600k/run-001/best` on + `secryst-checkpoints`. FROZEN. +- Khmer G2P: v1 full-orthography 58.99% word_acc / 26.90 CER + (`khmer_g2p_byt5/run-001/best`, secryst-checkpoints; shipped as + khm-latn-1.0) + v2 vowel-restoration run-002 (stripped-input + 19.22% word_acc where rules score 0.0%; full-surface 53.18%). + Ship rule: rules/v1 for full orthography, v2 for reduced input. diff --git a/docs/RESULTS.md b/docs/RESULTS.md index 377c5d3..db2fd74 100644 --- a/docs/RESULTS.md +++ b/docs/RESULTS.md @@ -171,9 +171,10 @@ construction. ### Best result -| Model | DER (beam=4, standard) | Note | -|---|---|---| -| **s45 phonikud curriculum (production)** | **16.58%** | stage 1: 1.5M phonikud knesset weak-pretrain (machine-labeled, deduped, decontaminated); stage 2: s43 gold recipe verbatim. Verified 2026-08-20, 5,095 test examples, `run-s45-phonikud/run-002-gold-ft/best` | +| Model | DER (beam=4, standard) | DER greedy | Note | +|---|---|---|---| +| **s46 phonikud+hewiki (production)** | **16.43%** | **16.44%** | s45 recipe + hewiki weak garnish (73.8K Dicta-labeled wiki lines in stage 1). Verified 2026-08-22/23, 5,095 test examples, `run-s46-phonikud-plus/run-002-gold-ft/best`. Greedy ≈ beam-4 → the shipped runtime path delivers reference quality. New best | +| s45 phonikud curriculum | 16.58% | — | stage 1: 1.5M phonikud knesset weak-pretrain (machine-labeled, deduped, decontaminated); stage 2: s43 gold recipe verbatim. Verified 2026-08-20, 5,095 test examples, `run-s45-phonikud/run-002-gold-ft/best` | | s43 | 17.46% | previous best single model | | v2 | 17.3% | original recipe (22K data); checkpoint has loading quirks under new transformers | | s44 | 17.65% | seed replica | @@ -187,8 +188,23 @@ construction. - All ByT5-base (580M), seq2seq, 3 epochs, beam=4 at inference - Eval scripts: `eval_hebrew_v4_beam4.py`, `analyze_hebrew_errors.py` +### Hebrew s47 — morph aux transplant — CLOSED NEGATIVE (2026-08-23) + +The r6 template's first cross-language move: dictabert-morph TAG +stream (100K knesset lines ×4, segmented src + per-token tags) + +gold×2 + 200K weak pairs, init from s46, LR 2e-5, 1 epoch. +**DER 16.53 (beam-4, 5,095 examples)** vs init s46's 16.43 — no gain. + +Read: the r6 aux-task win is not template-portable as-is. Arabic's +residual was word-final case endings (iʿrāb), which POS+feats +supervision directly disambiguates; Hebrew's 16.4 residual is not +decomposable the same way by gender/number/person tags. **s46 stays +canonical; Hebrew teacher line closed** (no s48 — money discipline). +Checkpoint: `run-s47-morph/best`. + ### Error analysis (v4/s43/s44, analyze_hebrew_errors.py) + | Error type | Count (v4) | Share | |---|---|---| | teamim (cantillation) wrong | **0** | 0% — copy-through from input | @@ -224,3 +240,161 @@ construction. - `/checkpoints/rababa_hebrew_byt5_v2|v4|s43|s44/run-001/best` - Prediction cache: `/datasets/hebrew-pred-cache/{v2,v4,s43,s44}.jsonl` - Corpora: `/datasets/hebrew-v4/{train,val,test}.jsonl` (50,433/2,654/1,864) + +## RAG homograph probe (Persian v1) — CLOSED NEGATIVE (2026-08-20) + +Retrieval few-shot (k=3 char-3gram cosine over the 445K v1 train +sentences, candidates restricted to same-homograph contexts, beam-4, +identical SentenceBench scoring): **26.07% ezafe-norm vs the 77.34% +baseline (−51.3pp)**. Baseline reproduced exactly (77.3400, n=203), +validating the harness. Contamination check: 4 exact test-sentence +overlaps found in train and excluded. + +Interpretation: v1 was never trained with multi-example prompts, so +the few-shot prefix is a hard format shift — the collapse is a prompt +artifact as much as a knowledge result. Verdict per the pre-committed +decision rule (<+0.5pp): the lever is closed for inference-time use; +any revisit must be train-time retrieval conditioning, budgeted as a +new experiment, not a polish of this one. Artifacts: +persian_g2p/rag_probe_result.json, rababa-farsi/eval_persian_rag_probe.py. + +## Urdu d1 — ByT5-base + Arabic-r5 cross-lingual init (2026-08-20) ★ NEW BEST + +`rababa_urdu_byt5/run-001-d1` (HF `best/` on rababa-checkpoints): +ByT5-base initialized from the Arabic r5 paragraph-context teacher, +1 epoch over the 573K Urdu pair corpus (alignment-filtered, deduped; +weak machine labels — comparative eval, not absolute). + +**CER 6.40% / word accuracy 47.43% (greedy, n=11,714)** vs the shipped +Urdu model's 14.77% CER — 2.3x reduction from architecture + teacher +init alone. Caveat: both train and test labels derive from our Arabic +model + G2P back-projection, so this is a within-corpus comparison; +a human-labeled Urdu gold set remains the missing evaluation. Next +lever if wanted: gold corpus (none verifiable exists in our stack), +or paragraph-context windowing for Urdu. Script: train_urdu_d1.py. + +### Urdu d1 beam-4 re-eval — flat (2026-08-20) + +beam4: CER 6.53% / word_acc 47.97% vs greedy 6.57% / 46.99% +(re-run; original verdict 6.40/47.43). Unlike Hebrew s45 (beam worth +12 DER points), Urdu gains nothing from beam — the G2P-back-projected +weak labels are near-deterministic, so greedy is already confident. +Per TODO 06 rule (<55% word_acc): d2 launched (one epoch at 1e-5 from +d1, run-002-d2); corpus-label consistency is the expected ceiling. + +## Urdu d2 — continuous low-LR second epoch (2026-08-20) ★ NEW BEST + +`rababa_urdu_byt5/run-002-d2` (best on volume): 1 epoch at LR 1e-5 +from run-001-d1, identical test protocol. + +**CER 5.77% / word_acc 52.47%** (greedy, n=11,714) vs d1 6.40/47.43 — +CER down 0.63pp, word_acc up 5.0pp. The weak-corpus ceiling is higher +than d1 alone reached; the standing rule remains that absolute +numbers are within-corpus (machine labels both sides). Best model +now: run-002-d2. Script: train_urdu_d2.py. + +## Arabic r6 — morphological aux-task (iʿrāb supervision) ★ NEW CANONICAL TEACHER (2026-08-21) + +### r6 verdict table (SadeedDiac-25, 2026-08-21) + +| Model | Total DER | Morph DER | Protocol | +|---|---|---|---| +| **r6 (greedy, canonical)** | **2.5793** | **1.5317** | windowed zero-skip, 1400B | +| r5 | 2.6775 | 1.5965 | windowed zero-skip, 1400B | + +`rababa_arabic_byt5/run-006-morph/best` (HF, rababa-checkpoints): r5's +plain stream + TAG-prefixed morph stream (qalsadi 300K lines, 68.6% +exact case/tense), two-format multitask on one ByT5-base, upsampled +x4 to ~25% of the mix, init from r5, 1 epoch, A100-80GB. + +**Total DER (CE) 2.5793 / Morphological DER 1.5317** on +SadeedDiac-25, windowed zero-skip at 1400B — vs r5's 2.6775/1.5965. +Both components improved; the diagnosis (33% of residual = word-final +case endings; Total-vs-Morph gap) is confirmed and the fix is +knowledge injection, not policy sharpening. Inference contract is +UNCHANGED from r5 (no TAG prefix at inference = plain diacritization; +1400B windows). r6 REPLACES r5 as the Arabic teacher for distillation. +Script: train_arabic_r6.py. + +### r6 out-of-domain — WikiNews-2024 multi-ref (2026-08-21) + +r6 full-mode **WER 19.8191 / DER 12.4613** (no-CE: WER 14.6571 / +DER 13.7271) — beats BOTH r5 (20.52/12.72) and r3 (19.99/12.60). +The r5-era paragraph-specialization OOD trade-off is erased: the +morph aux-task improved in-domain AND out-of-domain. r6 strictly +dominates all measured surfaces. r7's OOD gate denominator is +therefore 19.82/12.46 (r6), not r5's number. + +### r6 beam-4 probe (2026-08-23) — NEGATIVE + +Beam-4 on the identical windowed harness: **Total DER 2.5588 / +Morph DER 1.5379** vs greedy 2.5793/1.5317 — noise-level change +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 multiref, 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). diff --git a/docs/SOTA_BENCHMARK.md b/docs/SOTA_BENCHMARK.md index f1ca675..3dc9982 100644 --- a/docs/SOTA_BENCHMARK.md +++ b/docs/SOTA_BENCHMARK.md @@ -1,15 +1,27 @@ # SOTA Benchmark — rababa + secryst vs Published Results -> **Date**: 2026-08-09. All our results measured via `modal run modal_app.py::evaluate`. -> SOTA results from published papers and shared task leaderboards. +> **Current state: 2026-08-23.** The tables below the line are the +> 2026-08-09 historical baseline (char-encoder era) kept for +> provenance. Everything since moved to ByT5 seq2seq teachers. -## Summary table +## Current verdict table -| Language | Our model | Our metric | Our result | SOTA result | SOTA model | Gap | -|---|---|---|---|---|---|---| -| **Arabic** | rababa_arabic (5M) | DER | **2.42%** | 1.2% | Sadeed (1.5B) | 2.0× | -| **Hebrew** | rababa_hebrew (24M) | DER | **66.0%** | ~2-5% | Dicta/D-Nikud | 13-33× | -| **Thai** | secryst_thai_ipa (25M) | PER | **2.18%** | ~6% | Historical | **BETTER** | +| Language | Canonical model | Metric | Ours | Published SOTA | Notes | +|---|---|---|---|---|---| +| **Arabic diacritization** | r6 morph-aux (ByT5-base) | DER, SadeedDiac-25 | **2.5793** (Morph 1.5317) | 1.2% (Sadeed, 1.5B LLM) | 2.1× gap at ~300× fewer params; OOD WikiNews-2024 19.82 WER/12.46 DER; beam-4 flat → greedy contract | +| **Hebrew diacritization** | s46 phonikud+hewiki (ByT5-base) | DER, Nakdimon | **16.43** (beam-4) | ~1-3% (Dicta proprietary / D-Nikud) | honest gap remains; s47 morph-aux pending; Nakdimon itself ~8 DER open baseline | +| **Thai G2P** | umt5 continued-FT | PER | **1.73** | ~5-6% historical | frozen — better than published baselines | +| **Persian homograph** | v1 GE2PE | SB HA ezafe-norm | **77.34%** | 76.89% (HomoRich) | frozen — at/above published | +| **Urdu diacritization** | urd-diac-1.0 (shipped, ByT5-small) | CER / word_acc | **3.74 / 67.51** | no public gold | cross-lineage comparable eval 2026-08-23: beats d2 (5.94/51.95) on the same harness; CLE gold inquiry pending | +| **Khmer G2P** | v1 ByT5-small (full) + v2 (restoration) | word_acc | **58.99%** full / **19.22%** stripped | rules: 58.6%/0.0% | v2 is the only system that runs on reduced orthography | + +Technique verdicts baked in: knowledge injection (morph aux-task r6) +beats RL (RAFT/GRPO flat-to-negative ×3, RAG probe negative ×1); +weak-pretrain→gold-FT curriculum (s45→s46) corrects machine-labeled +teachers; beam search helps only where posteriors are soft (Hebrew +yes, Arabic no). + +--- ## Arabic diacritization diff --git a/eval_arabic_r6_beam4.py b/eval_arabic_r6_beam4.py new file mode 100644 index 0000000..16dd9fd --- /dev/null +++ b/eval_arabic_r6_beam4.py @@ -0,0 +1,190 @@ +"""Arabic r6 beam-4 probe (TODO.public §4). + +All Arabic numbers are greedy; Hebrew gained 12 DER points from beam. +Rerun r6's exact windowed zero-skip SadeedDiac-25 eval with beam-4 +from the frozen run-006-morph best. If DER drops, ship beam — free +quality, zero training. Resumable per-window. + +Usage: + modal run --detach eval_arabic_r6_beam4.py +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +import modal + +datasets_volume = modal.Volume.from_name("rababa-datasets", create_if_missing=True) +checkpoints_volume = modal.Volume.from_name("rababa-checkpoints", create_if_missing=True) + +RUN = "rababa_arabic_byt5/run-006-morph" +UNIT_BYTES = 1400 +GREEDY_DER = 2.5793 + +DIACRITICS_RE = re.compile("[ؐ-ًؚ-ٰٟۖ-ۜ۟-۪ۨ-ۭ]") + +image = ( + modal.Image.debian_slim(python_version="3.11") + .pip_install( + "torch==2.5.1", + "transformers==4.46.3", + "pandas", + "pyarrow", + "pyarabic", + "prettytable", + ) + .add_local_file("sadeed_evaluator.py", "/opt/rababa/sadeed_evaluator.py", copy=True) + .add_local_dir("data/sadeed-diac-25", "/opt/rababa/data/sadeed-diac-25", copy=True) + .workdir("/opt/rababa") + .env({"PYTORCH_CUDA_ALLOC_CONF": "expandable_segments:True"}) +) + +app = modal.App("rababa-arabic-r6-beam4", image=image) + + +@app.function( + gpu="A10G", + timeout=6 * 60 * 60, + volumes={"/datasets": datasets_volume, "/checkpoints": checkpoints_volume}, +) +def evaluate() -> dict: + import torch + import pandas as pd + import pyarrow.parquet as pq + from difflib import SequenceMatcher + from transformers import T5ForConditionalGeneration, ByT5Tokenizer + + checkpoints_volume.reload() + out_dir = Path("/checkpoints") / RUN + done_marker = out_dir / "BEAM4_DONE" + if done_marker.exists(): + fe = out_dir / "beam4_eval.json" + return json.loads(fe.read_text(encoding="utf-8")) if fe.exists() else {"status": "already-done"} + + ckpt = str(out_dir / "best") + model = T5ForConditionalGeneration.from_pretrained(ckpt).to("cuda") + tokenizer = ByT5Tokenizer.from_pretrained(ckpt) + model.eval() + + table = pq.read_table("data/sadeed-diac-25/train.parquet") + inputs = [DIACRITICS_RE.sub("", t) for t in table.column("input").to_pylist()] + outputs = table.column("output").to_pylist() + + def split_windows(text: str, budget: int = UNIT_BYTES) -> list[str]: + if len(text.encode("utf-8")) <= budget: + return [text] + words = text.split() + wins, cur, n = [], [], 0 + for w in words: + c = len(w.encode("utf-8")) + 1 + if cur and n + c > budget: + wins.append(" ".join(cur)) + cur, n = [], 0 + cur.append(w) + n += c + if cur: + wins.append(" ".join(cur)) + return wins + + all_windows: list[str] = [] + counts: list[int] = [] + for text in inputs: + ws = split_windows(text) + counts.append(len(ws)) + all_windows.extend(ws) + print(f"[eval] {len(inputs)} paragraphs -> {len(all_windows)} windows", flush=True) + + prog = out_dir / "beam4_progress.jsonl" + saved: dict[int, str] = {} + if prog.exists(): + for line in prog.read_text(encoding="utf-8").splitlines(): + if line.strip(): + row = json.loads(line) + saved[row["i"]] = row["pred"] + print(f"[gen] resuming with {len(saved)} saved windows", flush=True) + + missing = [i for i in range(len(all_windows)) if i not in saved] + n_new = 0 + with torch.no_grad(), prog.open("a", encoding="utf-8") as prog_out: + for bi in range(0, len(missing), 4): + idxs = missing[bi : bi + 4] + batch = [all_windows[i] for i in idxs] + enc = tokenizer( + batch, return_tensors="pt", padding=True, truncation=True, max_length=1600 + ).to("cuda") + with torch.autocast("cuda", torch.bfloat16): + gen = model.generate(**enc, max_new_tokens=3200, num_beams=4) + batch_preds = tokenizer.batch_decode(gen, skip_special_tokens=True) + for i, pred in zip(idxs, batch_preds): + prog_out.write(json.dumps({"i": i, "pred": pred}, ensure_ascii=False) + "\n") + saved[i] = pred + n_new += len(idxs) + if n_new % 80 == 0: + prog_out.flush() + checkpoints_volume.commit() + print(f"[gen] {len(saved)}/{len(all_windows)} (committed)", flush=True) + checkpoints_volume.commit() + preds = [saved[i] for i in range(len(all_windows))] + + def project_haraqat(pred: str, text: str) -> str: + pred_haraqat = [""] + for ch in pred: + if DIACRITICS_RE.match(ch): + pred_haraqat[-1] += ch + else: + pred_haraqat.append("") + pred_haraqat = pred_haraqat[1:] + pred_letters = [c for c in pred if not DIACRITICS_RE.match(c)] + text_letters = [c for c in text if not DIACRITICS_RE.match(c)] + sm = SequenceMatcher(None, text_letters, pred_letters, autojunk=False) + out = [] + for op, i1, i2, j1, j2 in sm.get_opcodes(): + if op == "equal": + for k in range(i2 - i1): + out.append(text_letters[i1 + k] + pred_haraqat[j1 + k]) + else: + for k in range(i1, i2): + out.append(text_letters[k]) + return "".join(out) + + k = 0 + paragraphs = [] + for text, c in zip(inputs, counts): + stitched = " ".join(preds[k : k + c]) + k += c + paragraphs.append(project_haraqat(stitched, text)) + + csv_path = Path("/tmp/sadeed_r6_beam4.csv") + pd.DataFrame({"gt": outputs, "pred": paragraphs}).to_csv(csv_path, index=False, header=False) + (out_dir / "sadeed_preds_beam4.csv").write_text(csv_path.read_text(), encoding="utf-8") + checkpoints_volume.commit() + + import io + import contextlib + + from sadeed_evaluator import ArabicDiacritizationEvaluator as E + + print("\n===== r6 beam-4, windowed zero-skip =====", flush=True) + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + E.report_errors_on_csv_file( + str(csv_path), ground_truth_column_index=0, predicted_column_index=1, has_header=False, + gt_missing_diacritic_is_error=False) + report = buf.getvalue() + print(report, flush=True) + (out_dir / "beam4_report.txt").write_text(report, encoding="utf-8") + + der_line = next((l for l in report.splitlines() if "DER" in l), "") + result = {"greedy_der": GREEDY_DER, "beam": 4, "report_line": der_line.strip()} + (out_dir / "beam4_eval.json").write_text(json.dumps(result, indent=2), encoding="utf-8") + done_marker.touch() + checkpoints_volume.commit() + return result + + +@app.local_entrypoint() +def main(): + print(json.dumps(evaluate.remote(), indent=2)) diff --git a/eval_hebrew_s46_greedy.py b/eval_hebrew_s46_greedy.py new file mode 100644 index 0000000..fc0a147 --- /dev/null +++ b/eval_hebrew_s46_greedy.py @@ -0,0 +1,111 @@ +"""Greedy DER for s46 — the v1 runtime-path metric for heb-diac-1.1 metadata. + +s46's verdict (16.43) is beam-4; the shipped runtime decodes greedy, +so the metadata needs the greedy number on the identical harness. +Resumable per example. + +Usage: + modal run --detach eval_hebrew_s46_greedy.py +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import modal + +checkpoints_volume = modal.Volume.from_name("rababa-checkpoints", create_if_missing=True) +datasets_volume = modal.Volume.from_name("rababa-datasets", create_if_missing=True) + +RUN = "rababa_hebrew/run-s46-phonikud-plus" +_NIKUD_MARKS = set("ְֱֲֳִֵֶַָֹֺֻּֽֿׁׂ־") + +image = ( + modal.Image.debian_slim(python_version="3.11") + .apt_install("build-essential", "git", "curl") + .pip_install("torch==2.5.1", "transformers==4.46.3", "numpy>=1.26,<3", "tqdm>=4.66") + .add_local_dir("src", "/opt/rababa/src", copy=True) + .workdir("/opt/rababa") + .env({"PYTHONPATH": "/opt/rababa/src", "PYTORCH_CUDA_ALLOC_CONF": "expandable_segments:True"}) +) + +app = modal.App("rababa-hebrew-s46-greedy", image=image) + + +@app.function( + gpu="A10G", + timeout=4 * 60 * 60, + volumes={"/checkpoints": checkpoints_volume, "/datasets": datasets_volume}, +) +def evaluate() -> dict: + import torch + from transformers import T5ForConditionalGeneration, ByT5Tokenizer + from rababa.evaluate import seq2seq_der + from rababa.datasets import _find_nakdimon_root + + checkpoints_volume.reload() + datasets_volume.reload() + + ckpt = str(Path("/checkpoints") / RUN / "run-002-gold-ft" / "best") + model = T5ForConditionalGeneration.from_pretrained(ckpt).to("cuda") + tokenizer = ByT5Tokenizer.from_pretrained(ckpt) + model.eval() + + test_path = Path(_find_nakdimon_root()) / "test.txt" + examples = [] + for line in test_path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + undiacritized = "".join(c for c in line if c not in _NIKUD_MARKS).strip() + if 2 <= len(undiacritized) <= 512: + examples.append((undiacritized, line)) + print(f"[eval] {len(examples)} examples", flush=True) + + prog = Path("/checkpoints") / RUN / "greedy_progress.jsonl" + saved: dict[int, tuple[float, int]] = {} + if prog.exists(): + for l in prog.read_text(encoding="utf-8").splitlines(): + if l.strip(): + row = json.loads(l) + saved[row["i"]] = (row["der"], row["n"]) + print(f"[eval] resuming with {len(saved)} done", flush=True) + + missing = [i for i in range(len(examples)) if i not in saved] + n_new = 0 + with torch.no_grad(), prog.open("a", encoding="utf-8") as out: + for bi in range(0, len(missing), 16): + idxs = missing[bi : bi + 16] + batch = [examples[i][0] for i in idxs] + enc = tokenizer(batch, return_tensors="pt", padding=True, truncation=True, + max_length=512).to("cuda") + gen = model.generate(**enc, max_new_tokens=512, num_beams=1) + preds = tokenizer.batch_decode(gen, skip_special_tokens=True) + for i, pred in zip(idxs, preds): + der, n = seq2seq_der(pred.strip(), examples[i][1]) + saved[i] = (der, n) + out.write(json.dumps({"i": i, "der": der, "n": n}) + "\n") + n_new += len(idxs) + if n_new % 320 == 0: + out.flush() + checkpoints_volume.commit() + total = sum(d * n for d, n in saved.values()) + cnt = sum(n for _, n in saved.values()) + print(f"[eval] {len(saved)}/{len(examples)} DER={total/max(1,cnt):.4f} (committed)", flush=True) + checkpoints_volume.commit() + + total_wrong = sum(d * n for d, n in saved.values()) + total_positions = sum(n for _, n in saved.values()) + der = total_wrong / max(1, total_positions) + print(f"=== s46 greedy DER: {der:.4f} ({len(saved)} examples) ===", flush=True) + result = {"der_greedy": der, "n": len(saved), "checkpoint": ckpt, + "der_beam4": 0.1643} + (Path("/checkpoints") / RUN / "greedy_eval.json").write_text(json.dumps(result, indent=2), encoding="utf-8") + checkpoints_volume.commit() + return result + + +@app.local_entrypoint() +def main(): + print(json.dumps(evaluate.remote(), indent=2)) diff --git a/eval_urdu_comparable.py b/eval_urdu_comparable.py new file mode 100644 index 0000000..0df9067 --- /dev/null +++ b/eval_urdu_comparable.py @@ -0,0 +1,123 @@ +"""Urdu comparable eval — settle urd-diac-1.0 vs d2 on ONE harness. + +urd-diac-1.0 (urdu_diacrit/run-001, ByT5-small) shipped with 3.74 CER +on urdu-diacrit/test.jsonl; d2 (rababa_urdu_byt5/run-002-d2, +ByT5-base) measured 5.77 within its own lineage. Different lineages, +possibly different test files — this runs BOTH models greedy on the +SAME urdu-diacrit/test.jsonl so the manifest can name the real best. + +Usage: + modal run --detach eval_urdu_comparable.py +""" + +from __future__ import annotations + +import modal + +urdu_volume = modal.Volume.from_name("urdu-diacrit-datasets", create_if_missing=False) +udiac_volume = modal.Volume.from_name("urdu-diacrit-checkpoints", create_if_missing=False) +checkpoints_volume = modal.Volume.from_name("rababa-checkpoints", create_if_missing=True) + +image = ( + modal.Image.debian_slim(python_version="3.11") + .pip_install("torch==2.5.1", "transformers==4.46.3", "editdistance", "tqdm") +) + +# the shipped urd-diac-1.0 checkpoint was saved/validated under the +# export stack (torch 2.12.1 / transformers 5.14.1); under 4.46.3 it +# generates EMPTY strings. evaluate each model under its own stack. +image514 = ( + modal.Image.debian_slim(python_version="3.11") + .pip_install("torch==2.12.1", "transformers==5.14.1", "editdistance", "tqdm") +) + +app = modal.App("rababa-urdu-comparable", image=image) + +MODELS = { + "urd_diacit_run001_shipped": ("/volumes/udiac/urdu_diacrit/run-001/best", image514), + "d2_rababa_urdu_byt5": ("/volumes/ckpts/rababa_urdu_byt5/run-002-d2/best", image), +} + + +@app.function( + gpu="A10G", + timeout=2 * 60 * 60, + volumes={ + "/datasets": urdu_volume, + "/volumes/udiac": udiac_volume, + "/volumes/ckpts": checkpoints_volume, + }, +) +def load_pairs() -> list[list[str]]: + import json + + pairs = [] + with open("/datasets/urdu-diacrit/test.jsonl", encoding="utf-8") as f: + for line in f: + line = line.strip() + if line: + row = json.loads(line) + if row.get("src") and row.get("tgt"): + pairs.append([row["src"].strip(), row["tgt"].strip()]) + print(f"[data] {len(pairs)} test pairs", flush=True) + return pairs + + +@app.function( + gpu="A10G", + timeout=2 * 60 * 60, + image=image514, + volumes={ + "/datasets": urdu_volume, + "/volumes/udiac": udiac_volume, + "/volumes/ckpts": checkpoints_volume, + }, +) +def eval_model(ckpt: str) -> dict: + import editdistance + import torch + from transformers import AutoModelForSeq2SeqLM, AutoTokenizer + + for v in (urdu_volume, udiac_volume, checkpoints_volume): + v.reload() + + pairs = [(s, t) for s, t in load_pairs.remote()] + tok = AutoTokenizer.from_pretrained(ckpt) + model = AutoModelForSeq2SeqLM.from_pretrained(ckpt).to("cuda") + model.eval() + preds: list[str] = [] + with torch.no_grad(): + for i in range(0, len(pairs), 64): + chunk = pairs[i : i + 64] + enc = tok([s for s, _ in chunk], return_tensors="pt", padding=True, + truncation=True, max_length=256).to("cuda") + gen = model.generate(**enc, max_new_tokens=256, num_beams=1) + preds.extend(tok.batch_decode(gen, skip_special_tokens=True)) + total_ed = total_len = exact = 0 + for (_, tgt), pred in zip(pairs, preds): + total_ed += editdistance.eval(pred.strip(), tgt) + total_len += len(tgt) + exact += int(pred.strip() == tgt) + res = {"cer": 100 * total_ed / max(1, total_len), "word_acc": 100 * exact / len(pairs)} + print(f"[{ckpt}] CER={res['cer']:.2f} word_acc={res['word_acc']:.2f}", flush=True) + return res + + +@app.function( + gpu="A10G", + timeout=2 * 60 * 60, + volumes={ + "/datasets": urdu_volume, + "/volumes/udiac": udiac_volume, + "/volumes/ckpts": checkpoints_volume, + }, +) +def evaluate() -> dict: + return {name: eval_model.remote(ckpt) for name, (ckpt, _img) in MODELS.items()} + + +@app.local_entrypoint() +def main(): + import json + + print(json.dumps(evaluate.remote(), indent=2)) diff --git a/eval_urdu_d1_beam.py b/eval_urdu_d1_beam.py new file mode 100644 index 0000000..11ac261 --- /dev/null +++ b/eval_urdu_d1_beam.py @@ -0,0 +1,92 @@ +"""Urdu d1 beam-4 re-eval — collect the free decode win before any d2. + +d1 verdict was greedy-only (CER 6.40%, word_acc 47.43%). Hebrew s45 +showed beam-4 is worth double-digit points on ByT5 diacritizers. +Same test set, same protocol, line-for-line comparable. + +Usage: + modal run --detach eval_urdu_d1_beam.py +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import modal + +urdu_volume = modal.Volume.from_name("urdu-diacrit-datasets", create_if_missing=False) +checkpoints_volume = modal.Volume.from_name("rababa-checkpoints", create_if_missing=True) + +CKPT = "/checkpoints/rababa_urdu_byt5/run-001-d1/best" + +image = ( + modal.Image.debian_slim(python_version="3.11") + .pip_install("torch==2.5.1", "transformers==4.46.3", "editdistance", "tqdm") + .workdir("/opt/rababa") + .env({"PYTORCH_CUDA_ALLOC_CONF": "expandable_segments:True"}) +) + +app = modal.App("rababa-urdu-d1-beam", image=image) + + +@app.function( + gpu="A10G", + timeout=4 * 60 * 60, + volumes={"/urdu": urdu_volume, "/checkpoints": checkpoints_volume}, +) +def evaluate() -> dict: + import torch + import editdistance + from transformers import AutoModelForSeq2SeqLM, AutoTokenizer + + urdu_volume.reload() + checkpoints_volume.reload() + + test_pairs = [] + for line in Path("/urdu/urdu-diacrit/test.jsonl").read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + row = json.loads(line) + src, tgt = row.get("src", ""), row.get("tgt", "") + if src and tgt: + test_pairs.append((src, tgt)) + print(f"[eval] {len(test_pairs)} test pairs", flush=True) + + tokenizer = AutoTokenizer.from_pretrained(CKPT) + model = AutoModelForSeq2SeqLM.from_pretrained(CKPT).to("cuda") + model.eval() + + def run(beam: int) -> dict: + preds = [] + with torch.no_grad(): + for i in range(0, len(test_pairs), 64): + chunk = test_pairs[i : i + 64] + enc = tokenizer( + [s for s, _ in chunk], return_tensors="pt", padding=True, truncation=True, max_length=1024 + ).to("cuda") + with torch.autocast("cuda", torch.bfloat16): + gen = model.generate(**enc, max_new_tokens=1280, num_beams=beam) + preds.extend(tokenizer.batch_decode(gen, skip_special_tokens=True)) + if (i // 64) % 20 == 0: + print(f"[gen:beam{beam}] {i + len(chunk)}/{len(test_pairs)}", flush=True) + total_ed = total_len = exact = 0 + for (_, tgt), pred in zip(test_pairs, preds): + total_ed += editdistance.eval(pred.strip(), tgt) + total_len += len(tgt) + exact += int(pred.strip() == tgt) + return {"cer": total_ed / max(1, total_len), "word_acc": exact / len(test_pairs)} + + results = {"greedy": run(1), "beam4": run(4)} + results["baseline_d1_greedy"] = {"cer": 0.0640, "word_acc": 0.4743} + print(json.dumps(results, indent=2), flush=True) + out = Path("/checkpoints/rababa_urdu_byt5/run-001-d1/eval_beam.json") + out.write_text(json.dumps(results, indent=2), encoding="utf-8") + checkpoints_volume.commit() + return results + + +@app.local_entrypoint() +def main(): + evaluate.remote() diff --git a/label_arabic_news.py b/label_arabic_news.py new file mode 100644 index 0000000..afe83cb --- /dev/null +++ b/label_arabic_news.py @@ -0,0 +1,224 @@ +"""Pseudo-label Arabic news with r5 for the r7 domain-adaptation mix. + +r5 trades ~0.5 DER out-of-domain (WikiNews-2024 multi-ref). This job +builds the news-domain data r7 needs: +- unlabeled modern Arabic news from HuggingFace (ultimate_arabic_news, + fallback arabic-bbc-news), cleaned and deduped; +- labeled windowed-zero-skip by r5 (greedy, 1400B windows — the same + harness as every r5 eval); +- plus WikiNews_2014 gold diacritized lines (400) copied alongside. + +WikiNews_2024 is NEVER read here — it is the OOD probe. + +Output: /datasets/arabic-news-r5/{news.txt, wikinews2014_gold.txt, DONE} + +Usage: + modal run --detach label_arabic_news.py +""" + +from __future__ import annotations + +import re + +import modal + +datasets_volume = modal.Volume.from_name("rababa-datasets", create_if_missing=True) +checkpoints_volume = modal.Volume.from_name("rababa-checkpoints", create_if_missing=True) + +TEACHER = "/checkpoints/rababa_arabic_byt5/run-005-context/best" +TARGET_NEWS_LINES = 150_000 +WINDOW_BYTES = 1400 + +DIACRITICS_RE = re.compile("[ؐ-ًؚ-ٰٟۖ-ۜ۟-۪ۨ-ۭ]") +ARABIC_LETTER = re.compile("[ء-ي]") + +image = ( + modal.Image.debian_slim(python_version="3.11") + .pip_install( + "torch==2.5.1", + "transformers==4.46.3", + "datasets>=2.20", + "huggingface_hub>=0.24", + "tqdm", + ) + .workdir("/opt/rababa") + .env({"PYTORCH_CUDA_ALLOC_CONF": "expandable_segments:True"}) +) + +app = modal.App("rababa-arabic-news-label", image=image) + + +def _clean(line: str) -> str | None: + line = re.sub(r"[\u200c\u200f\u200e]", " ", line) + line = re.sub(r"\s+", " ", line).strip() + if not (60 <= len(line) <= 2000): + return None + letters = ARABIC_LETTER.findall(line) + if len(letters) / max(1, len(line.replace(" ", ""))) < 0.7: + return None + if DIACRITICS_RE.search(line): + return None + return line + + +@app.function( + gpu="A100-80GB", + timeout=12 * 60 * 60, + volumes={"/datasets": datasets_volume, "/checkpoints": checkpoints_volume}, + secrets=[modal.Secret.from_name("huggingface")], +) +def label() -> dict: + import torch + from datasets import load_dataset + from pathlib import Path + from transformers import AutoModelForSeq2SeqLM, AutoTokenizer + + datasets_volume.reload() + checkpoints_volume.reload() + + out_dir = Path("/datasets/arabic-news-r5") + if (out_dir / "DONE").exists(): + return {"status": "already-done"} + + # ---- fetch unlabeled news (BBC Arabic parquet: id/url/title/summary/text) ---- + def _chunks(article: str) -> list[str]: + # split long articles on sentence boundaries into ~600-1400 char chunks + article = re.sub(r"\s+", " ", article).strip() + if len(article) <= 1600: + return [article] + parts = re.split(r"(?<=[.!؟]) ", article) + out, cur = [], "" + for part in parts: + if cur and len(cur) + len(part) + 1 > 1200: + out.append(cur) + cur = part + else: + cur = f"{cur} {part}".strip() + if cur: + out.append(cur) + return out + + lines: list[str] = [] + tried = [] + for repo, cols in ( + ("Abdelkareem/arabic-bbc-news", ("text", "summary", "title")), + ("khalidalt/ultimate_arabic_news", ("content", "text", "article", "Body")), + ): + try: + tried.append(repo) + before = len(lines) + ds = load_dataset(repo, split="train", streaming=True) + for row in ds: + for c in cols: + v = row.get(c) + if isinstance(v, str) and len(v) >= 60: + for chunk in _chunks(v): + cleaned = _clean(chunk) + if cleaned: + lines.append(cleaned) + break + if len(lines) >= TARGET_NEWS_LINES * 2: + break + print(f"[fetch] {repo}: +{len(lines) - before} raw chunks", flush=True) + except Exception as e: + print(f"[fetch] {repo} failed: {e}", flush=True) + if len(lines) >= TARGET_NEWS_LINES * 2: + break + if not lines: + return {"status": "no-news-source", "tried": tried} + + seen: set[str] = set() + unique: list[str] = [] + for l in lines: + if l in seen: + continue + seen.add(l) + unique.append(l) + unique = unique[:TARGET_NEWS_LINES] + print(f"[fetch] {len(unique)} unique news lines", flush=True) + + # ---- windowed windows over lines ---- + windows: list[str] = [] + cur: list[str] = [] + n = 0 + for line in unique: + c = len(line.encode("utf-8")) + 1 + if cur and n + c > WINDOW_BYTES: + windows.append(" ".join(cur)) + cur, n = [], 0 + cur.append(line) + n += c + if cur: + windows.append(" ".join(cur)) + print(f"[windows] {len(windows)} x <= {WINDOW_BYTES}B", flush=True) + + # ---- r5 pseudo-label ---- + tokenizer = AutoTokenizer.from_pretrained(TEACHER) + model = AutoModelForSeq2SeqLM.from_pretrained(TEACHER).to("cuda") + model.eval() + + import json as _json + out_dir.mkdir(parents=True, exist_ok=True) + prog = out_dir / "label_progress.jsonl" + saved: dict[int, str] = {} + if prog.exists(): + for line in prog.read_text(encoding="utf-8").splitlines(): + if line.strip(): + row = _json.loads(line) + saved[row["i"]] = row["pred"] + print(f"[label] resuming: {len(saved)} windows already labeled", flush=True) + + todo = [i for i in range(len(windows)) if i not in saved] + batch = 8 + with torch.no_grad(), prog.open("a", encoding="utf-8") as prog_out: + for bi in range(0, len(todo), batch): + idxs = todo[bi : bi + batch] + chunk = [windows[i] for i in idxs] + enc = tokenizer( + chunk, return_tensors="pt", padding=True, truncation=True, max_length=1600 + ).to("cuda") + with torch.autocast("cuda", torch.bfloat16): + gen = model.generate(**enc, max_new_tokens=3200, num_beams=1) + batch_preds = tokenizer.batch_decode(gen, skip_special_tokens=True) + for i, pred in zip(idxs, batch_preds): + saved[i] = pred + prog_out.write(_json.dumps({"i": i, "pred": pred}, ensure_ascii=False) + "\n") + if len(saved) % 400 < batch: + prog_out.flush() + datasets_volume.commit() + print(f"[label] {len(saved)}/{len(windows)} (committed)", flush=True) + datasets_volume.commit() + + kept = 0 + with (out_dir / "news.txt").open("w", encoding="utf-8") as f: + for i in range(len(windows)): + pred = saved.get(i, "").strip() + if not pred or len(pred) < 40: + continue + f.write(pred + "\n") + kept += 1 + print(f"[label] kept {kept} labeled units", flush=True) + + # ---- gold 2014 garnish (copy verbatim; diacritized already) ---- + gold_src = Path("/datasets/wikinews/WikiNews_2014.txt.diac") + n_gold = 0 + if gold_src.exists(): + with (out_dir / "wikinews2014_gold.txt").open("w", encoding="utf-8") as f: + for line in gold_src.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if line: + f.write(line + "\n") + n_gold += 1 + print(f"[gold] {n_gold} WikiNews-2014 lines", flush=True) + + (out_dir / "DONE").write_text(f"news={kept} gold2014={n_gold}\n", encoding="utf-8") + datasets_volume.commit() + return {"news_units": kept, "gold_2014": n_gold} + + +@app.local_entrypoint() +def main(): + # 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) diff --git a/label_hebrew_morph.py b/label_hebrew_morph.py new file mode 100644 index 0000000..3337c60 --- /dev/null +++ b/label_hebrew_morph.py @@ -0,0 +1,168 @@ +"""Label Hebrew with dictabert-morph for the s47 aux-task (TODO.public §3). + +The r6 template, transplanted: morphological supervision (POS + +Gender/Number/Person/Tense per token) as a TAG-prefixed aux stream. +Labels a 200K-line slice of the s45 knesset pairs (weak corpus), +6-shard parallel on A10G, incrementally resumable per shard. + +Tag format per token: POS|Gender=Masc|Number=Sing|... (compact, +sorted for determinism). + +Output: /datasets/hebrew-morph/{shard_k_progress.jsonl, DONE} +Usage: + modal run --detach label_hebrew_morph.py +""" + +from __future__ import annotations + +import modal + +datasets_volume = modal.Volume.from_name("rababa-datasets", create_if_missing=True) + +N_SHARDS = 6 +TARGET_LINES = 200_000 + +image = ( + modal.Image.debian_slim(python_version="3.11") + .pip_install("torch==2.5.1", "transformers==4.38.0", "huggingface_hub>=0.24") + .workdir("/opt/rababa") +) + +app = modal.App("rababa-hebrew-morph-label", image=image) + + +def _tag(tok: dict) -> str: + parts = [tok.get("pos", "X")] + for k, v in sorted((tok.get("feats") or {}).items()): + parts.append(f"{k}={v}") + return "|".join(parts) + + +@app.function( + gpu="A10G", + timeout=12 * 60 * 60, + volumes={"/datasets": datasets_volume}, + secrets=[modal.Secret.from_name("huggingface")], +) +def label_shard(shard: int) -> dict: + import json + import time + from pathlib import Path + from transformers import AutoModel, AutoTokenizer + + datasets_volume.reload() + + out_dir = Path("/datasets/hebrew-morph") + out_dir.mkdir(parents=True, exist_ok=True) + done_marker = out_dir / "DONE" + if done_marker.exists(): + return {"status": "already-done"} + + srcs: list[str] = [] + with open("/datasets/hebrew-phonikud/pairs/train.jsonl", encoding="utf-8") as f: + for line in f: + if len(srcs) >= TARGET_LINES: + break + row = json.loads(line) + src = row["src"].strip() + if src and 10 <= len(src) <= 300: + srcs.append(src) + lines = srcs[shard::N_SHARDS] + print(f"[shard {shard}] {len(lines)} lines", flush=True) + + m = "dicta-il/dictabert-morph" + tok = AutoTokenizer.from_pretrained(m) + model = AutoModel.from_pretrained(m, trust_remote_code=True) + model.eval() + + prog = out_dir / f"shard_{shard}_progress.jsonl" + done_idx: set[int] = set() + if prog.exists(): + for line in prog.read_text(encoding="utf-8").splitlines(): + if line.strip(): + done_idx.add(json.loads(line)["li"]) + print(f"[shard {shard}] resuming: {len(done_idx)} done", flush=True) + + todo = [li for li in range(len(lines)) if li not in done_idx] + batch_size = 32 + t0 = time.time() + kept = 0 + with prog.open("a", encoding="utf-8") as out: + for bi in range(0, len(todo), batch_size): + idxs = todo[bi : bi + batch_size] + batch = [lines[li] for li in idxs] + try: + results = model.predict(batch, tok) + except Exception as e: + print(f"[shard {shard}] batch error: {e}", flush=True) + continue + for li, res in zip(idxs, results): + done_idx.add(li) + # dictabert-morph splits prefixed words into separate token + # entries ("ב|ישראל" -> 2 tokens), so len(toks) never matches + # text.split() on real text. The token list IS the ground truth. + toks = res.get("tokens") or [] + bad = not toks or not all( + isinstance(t.get("token"), str) and t.get("pos") for t in toks + ) + if bad: + out.write(json.dumps({"li": li}) + "\n") + continue + tags = [_tag(t) for t in toks] + words = [t["token"] for t in toks] + out.write(json.dumps( + {"li": li, "src": " ".join(words), "tags": tags}, ensure_ascii=False) + "\n") + kept += 1 + if len(done_idx) % 3200 < batch_size: + out.flush() + datasets_volume.commit() + rate = len(done_idx) / max(1.0, time.time() - t0) + print(f"[shard {shard}] {len(done_idx)}/{len(lines)} kept={kept} " + f"{rate:.1f}/s", flush=True) + datasets_volume.commit() + return {"shard": shard, "kept": kept} + + +@app.function(timeout=30 * 60, volumes={"/datasets": datasets_volume}) +def finalize() -> dict: + import json + from pathlib import Path + + datasets_volume.reload() + out_dir = Path("/datasets/hebrew-morph") + shards = sorted(out_dir.glob("shard_*_progress.jsonl")) + n_lines = 0 + with (out_dir / "train.jsonl").open("w", encoding="utf-8") as out: + for shard_file in shards: + for line in shard_file.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + row = json.loads(line) + if "tags" in row: + out.write(json.dumps( + {"src": row["src"], "tags": row["tags"]}, ensure_ascii=False) + "\n") + n_lines += 1 + (out_dir / "DONE").write_text(f"{n_lines} labeled lines\n", encoding="utf-8") + datasets_volume.commit() + return {"labeled": n_lines} + + +@app.function(timeout=30 * 60, volumes={"/datasets": datasets_volume}) +def check_shards() -> list[str]: + import os + + datasets_volume.reload() + return sorted(os.listdir("/datasets/hebrew-morph")) + + +@app.local_entrypoint() +def main(): + # two waves of 3: stay inside the workspace GPU-concurrency budget + print("before wave 1:", check_shards.remote(), flush=True) + list(label_shard.map(range(0, 3))) + print("after wave 1:", check_shards.remote(), flush=True) + list(label_shard.map(range(3, N_SHARDS))) + print("after wave 2:", check_shards.remote(), flush=True) + print(finalize.remote()) + print("after finalize:", check_shards.remote(), flush=True) + diff --git a/label_hewiki_full.py b/label_hewiki_full.py new file mode 100644 index 0000000..5f0bb16 --- /dev/null +++ b/label_hewiki_full.py @@ -0,0 +1,187 @@ +"""Full-scale Dicta labeling of Hebrew Wikipedia for the s46 weak stage. + +batch_distill_hewiki.py proved the recipe (DictaBERT-large-char-menaked +batch predict) but capped at 50K lines and skipped normalization. +This labels ALL of /datasets/hewiki/train.txt (80K lines) and applies +the s45 weak-corpus hygiene: nikud-only targets, length/Hebrew +filters, and 40-char-window decontamination against the Nakdimon test. + +Output: /datasets/hebrew-hewiki-dicta/{train,val}.txt + DONE marker. + +Usage: + modal run --detach label_hewiki_full.py +""" + +from __future__ import annotations + +import modal + +datasets_volume = modal.Volume.from_name("rababa-datasets", create_if_missing=True) + +image = ( + modal.Image.debian_slim(python_version="3.11") + .apt_install("build-essential") + .pip_install( + "torch==2.5.1", + "transformers==4.38.0", + "huggingface_hub>=0.20", + "sentencepiece>=0.2", + "tqdm", + ) + .workdir("/opt/rababa") +) + +app = modal.App("rababa-hewiki-label", image=image) + +_NIKUD_MARKS = set("ְֱֲֳִֵֶַָֹֺֻּֽֿׁׂ־") + + +def _is_hebrew_letter(c: str) -> bool: + return "א" <= c <= "ת" + + +def _normalize(line: str) -> tuple[str, str] | None: + tgt_chars = [] + for c in line: + if c in _NIKUD_MARKS or _is_hebrew_letter(c) or c.isspace() or (33 <= ord(c) <= 126): + tgt_chars.append(c) + target = "".join(tgt_chars).strip() + src = "".join(c for c in target if c not in _NIKUD_MARKS).strip() + if not (10 <= len(src) <= 300): + return None + letters = sum(1 for c in src if _is_hebrew_letter(c)) + if letters / max(1, len(src.replace(" ", ""))) < 0.6: + return None + nikud = sum(1 for c in target if c in _NIKUD_MARKS) + if nikud < len(src.replace(" ", "")) / 12: + return None + return src, target + + +N_SHARDS = 6 + + +@app.function( + gpu="A10G", + timeout=12 * 60 * 60, + volumes={"/datasets": datasets_volume}, + secrets=[modal.Secret.from_name("huggingface")], +) +def label_shard(shard: int) -> list[list[str]]: + """Label lines[shard::N_SHARDS]; returns [src, tgt] pairs.""" + import time + from pathlib import Path + from transformers import AutoModel, AutoTokenizer + + datasets_volume.reload() + + # Nakdimon test windows for decontamination + test_windows: set[str] = set() + nakdi = Path("/datasets/nakdimon-combined") + for cand in (nakdi / "test.txt", nakdi / "val.txt"): + if cand.exists(): + for line in cand.read_text(encoding="utf-8").splitlines(): + t = line.strip() + for i in range(0, max(1, len(t) - 40), 20): + test_windows.add(t[i : i + 40]) + + model_name = "dicta-il/dictabert-large-char-menaked" + tokenizer = AutoTokenizer.from_pretrained(model_name) + model = AutoModel.from_pretrained(model_name, trust_remote_code=True) + model.eval() + + lines = [l.strip() for l in Path("/datasets/hewiki/train.txt").read_text(encoding="utf-8").splitlines()] + lines = [l for l in lines if 10 <= len(l) <= 200][shard::N_SHARDS] + print(f"[shard {shard}] {len(lines)} lines", flush=True) + + def contaminated(src: str) -> bool: + s2 = src.replace(" ", "") + return any(s2[i : i + 40] in test_windows for i in range(0, max(1, len(s2) - 40), 20)) + + import json as _json + prog = Path(f"/datasets/hebrew-hewiki-dicta/shard_{shard}_progress.jsonl") + prog.parent.mkdir(parents=True, exist_ok=True) + done_idx: set[int] = set() + pairs_by_idx: dict[int, list[str]] = {} + if prog.exists(): + for line in prog.read_text(encoding="utf-8").splitlines(): + if line.strip(): + row = _json.loads(line) + done_idx.add(row["li"]) + pairs_by_idx[row["li"]] = [row["src"], row["tgt"]] + print(f"[shard {shard}] resuming: {len(done_idx)} lines already labeled", flush=True) + + todo = [li for li in range(len(lines)) if li not in done_idx] + batch_size = 32 + t0 = time.time() + with prog.open("a", encoding="utf-8") as prog_out: + for bi in range(0, len(todo), batch_size): + idxs = todo[bi : bi + batch_size] + batch = [lines[li] for li in idxs] + try: + predictions = model.predict(batch, tokenizer) + except Exception as e: + print(f"[shard {shard}] batch error: {e}", flush=True) + continue + for li, src_line, pred in zip(idxs, batch, predictions): + done_idx.add(li) + if not pred or not pred.strip(): + prog_out.write(_json.dumps({"li": li}) + "\n") + continue + norm = _normalize(pred.strip()) + if norm is None: + prog_out.write(_json.dumps({"li": li}) + "\n") + continue + src, target = norm + if contaminated(src): + prog_out.write(_json.dumps({"li": li}) + "\n") + continue + pairs_by_idx[li] = [src, target] + prog_out.write(_json.dumps( + {"li": li, "src": src, "tgt": target}, ensure_ascii=False) + "\n") + n = len(done_idx) + if n % 3200 < batch_size: + prog_out.flush() + datasets_volume.commit() + print(f"[shard {shard}] {n}/{len(lines)} kept={len(pairs_by_idx)} (committed)", flush=True) + datasets_volume.commit() + return [pairs_by_idx[li] for li in sorted(pairs_by_idx)] + + +@app.function( + timeout=1 * 60 * 60, + volumes={"/datasets": datasets_volume}, +) +def combine(shard_pairs: list[list[list[str]]]) -> dict: + import random + from pathlib import Path + + datasets_volume.reload() + out_dir = Path("/datasets/hebrew-hewiki-dicta") + if (out_dir / "DONE").exists(): + return {"status": "already-done"} + + flat = [p for shard in shard_pairs for p in shard] + seen: set[str] = set() + deduped: list[list[str]] = [] + for src, target in flat: + if src in seen: + continue + seen.add(src) + deduped.append([src, target]) + + out_dir.mkdir(parents=True, exist_ok=True) + n_val = min(2_000, len(deduped) // 20) + (out_dir / "val.txt").write_text( + "\n".join(t for _, t in deduped[:n_val]) + "\n", encoding="utf-8") + (out_dir / "train.txt").write_text( + "\n".join(t for _, t in deduped[n_val:]) + "\n", encoding="utf-8") + (out_dir / "DONE").write_text(f"{len(deduped)} pairs\n", encoding="utf-8") + datasets_volume.commit() + return {"pairs": len(deduped)} + + +@app.local_entrypoint() +def main(): + shards = list(label_shard.map(range(N_SHARDS))) + print(combine.remote(shards)) diff --git a/probe_morph_format.py b/probe_morph_format.py new file mode 100644 index 0000000..5bcdd4c --- /dev/null +++ b/probe_morph_format.py @@ -0,0 +1,57 @@ +"""Probe: raw dictabert-morph predict() output shape under two transformers versions.""" + +from __future__ import annotations + +import modal + +img38 = ( + modal.Image.debian_slim(python_version="3.11") + .pip_install("torch==2.5.1", "transformers==4.38.0", "huggingface_hub>=0.24") +) +img46 = ( + modal.Image.debian_slim(python_version="3.11") + .pip_install("torch==2.5.1", "transformers==4.46.3", "huggingface_hub>=0.24") +) + +app = modal.App("probe-morph-format") + +SENTS = ["הכלב של רון רץ במהירות גדולה בפארק", "ובבית הספר למדנו תורה"] + + +def _probe(): + from transformers import AutoModel, AutoTokenizer + + m = "dicta-il/dictabert-morph" + tok = AutoTokenizer.from_pretrained(m) + model = AutoModel.from_pretrained(m, trust_remote_code=True) + model.eval() + res = model.predict(SENTS, tok) + for r in res: + print("TYPE:", type(r)) + keys = list(r.keys()) if isinstance(r, dict) else dir(r) + print("KEYS:", keys) + toks = r.get("tokens") if isinstance(r, dict) else getattr(r, "tokens", None) + print("N_TOKS:", len(toks) if toks else None) + print("TEXT_REPR:", repr(r.get("text") if isinstance(r, dict) else str(r))[:200]) + if toks: + print("TOK0_REPR:", repr(toks[0])[:300]) + print("N_TEXT_SPLIT:", len((r.get("text") or "").split()) if isinstance(r, dict) else "?") + print("=====") + + +@app.function(image=img38, secrets=[modal.Secret.from_name("huggingface")], timeout=10 * 60) +def probe_38(): + _probe() + + +@app.function(image=img46, secrets=[modal.Secret.from_name("huggingface")], timeout=10 * 60) +def probe_46(): + _probe() + + +@app.local_entrypoint() +def main(): + print("### transformers 4.38.0 ###") + probe_38.remote() + print("### transformers 4.46.3 ###") + probe_46.remote() diff --git a/probe_morph_real.py b/probe_morph_real.py new file mode 100644 index 0000000..3b79c9e --- /dev/null +++ b/probe_morph_real.py @@ -0,0 +1,68 @@ +"""Probe 2: dictabert-morph predict() on REAL knesset lines, batch 32 — find why kept=0.""" + +from __future__ import annotations + +import modal + +datasets_volume = modal.Volume.from_name("rababa-datasets", create_if_missing=True) + +image = ( + modal.Image.debian_slim(python_version="3.11") + .pip_install("torch==2.5.1", "transformers==4.38.0", "huggingface_hub>=0.24") +) + +app = modal.App("probe-morph-real") + + +@app.function( + image=image, + gpu="A10G", + timeout=10 * 60, + volumes={"/datasets": datasets_volume}, + secrets=[modal.Secret.from_name("huggingface")], +) +def probe() -> None: + import json + + from transformers import AutoModel, AutoTokenizer + + datasets_volume.reload() + srcs = [] + with open("/datasets/hebrew-phonikud/pairs/train.jsonl", encoding="utf-8") as f: + for line in f: + if len(srcs) >= 64: + break + row = json.loads(line) + src = row["src"].strip() + if src and 10 <= len(src) <= 300: + srcs.append(src) + + m = "dicta-il/dictabert-morph" + tok = AutoTokenizer.from_pretrained(m) + model = AutoModel.from_pretrained(m, trust_remote_code=True) + model.eval() + + for bi, bs in ((0, 2), (0, 32)): + batch = srcs[bi : bi + bs] + results = model.predict(batch, tok) + print(f"### batch_size={len(batch)} -> {len(results)} results", flush=True) + ok = 0 + for i, res in enumerate(results): + toks = (res.get("tokens") or []) if isinstance(res, dict) else [] + n_text = len((res.get("text") or "").split()) if isinstance(res, dict) else -1 + if toks and len(toks) == n_text: + ok += 1 + if i < 3: + print(f"[{i}] type={type(res).__name__} keys={list(res.keys()) if isinstance(res, dict) else '?'}", flush=True) + print(f" n_toks={len(toks)} n_text_split={n_text}", flush=True) + print(f" text={repr(res.get('text'))[:120]}", flush=True) + if toks: + print(f" tok0={repr(toks[0])[:220]}", flush=True) + else: + print(f" RES_REPR={repr(res)[:400]}", flush=True) + print(f"### ok={ok}/{len(results)}", flush=True) + + +@app.local_entrypoint() +def main(): + probe.remote() diff --git a/probe_urd_diac_outputs.py b/probe_urd_diac_outputs.py new file mode 100644 index 0000000..0627d52 --- /dev/null +++ b/probe_urd_diac_outputs.py @@ -0,0 +1,69 @@ +"""Probe: what does the shipped urd-diac-1.0 checkpoint actually emit? + +The comparable eval scored it CER 124.58 / word_acc 0.00 on +urdu-diacrit/test.jsonl under transformers 4.46.3 — print raw +(src, tgt, pred) triples to distinguish a broken artifact from a +harness/format mismatch. + +Usage: + modal run --detach probe_urd_diac_outputs.py +""" + +from __future__ import annotations + +import modal + +urdu_volume = modal.Volume.from_name("urdu-diacrit-datasets", create_if_missing=False) +udiac_volume = modal.Volume.from_name("urdu-diacrit-checkpoints", create_if_missing=False) + +image = ( + modal.Image.debian_slim(python_version="3.11") + .pip_install("torch==2.5.1", "transformers==4.46.3") +) + +app = modal.App("rababa-urd-probe", image=image) + + +@app.function( + gpu="T4", + timeout=20 * 60, + volumes={"/datasets": urdu_volume, "/volumes/udiac": udiac_volume}, +) +def probe() -> None: + import json + + import torch + from transformers import AutoModelForSeq2SeqLM, AutoTokenizer + + urdu_volume.reload() + udiac_volume.reload() + + pairs = [] + with open("/datasets/urdu-diacrit/test.jsonl", encoding="utf-8") as f: + for line in f: + line = line.strip() + if line: + row = json.loads(line) + if row.get("src") and row.get("tgt"): + pairs.append((row["src"].strip(), row["tgt"].strip())) + sample = pairs[:5] + + ckpt = "/volumes/udiac/urdu_diacrit/run-001/best" + tok = AutoTokenizer.from_pretrained(ckpt) + model = AutoModelForSeq2SeqLM.from_pretrained(ckpt).to("cuda") + model.eval() + enc = tok([s for s, _ in sample], return_tensors="pt", padding=True, + truncation=True, max_length=256).to("cuda") + with torch.no_grad(): + gen = model.generate(**enc, max_new_tokens=256, num_beams=1) + preds = tok.batch_decode(gen, skip_special_tokens=True) + for (src, tgt), pred in zip(sample, preds): + print(f"SRC : {src[:80]}", flush=True) + print(f"TGT : {tgt[:80]}", flush=True) + print(f"PRED: {pred.strip()[:80]}", flush=True) + print("---", flush=True) + + +@app.local_entrypoint() +def main(): + probe.remote() diff --git a/train_arabic_r6.py b/train_arabic_r6.py index 0d317f3..b6dee5e 100644 --- a/train_arabic_r6.py +++ b/train_arabic_r6.py @@ -279,18 +279,39 @@ def project_haraqat(pred: str, text: str) -> str: all_windows.extend(ws) print(f"[eval] {len(inputs)} paragraphs -> {len(all_windows)} windows", flush=True) - preds: list[str] = [] - with torch.no_grad(): - for i in range(0, len(all_windows), 8): - batch = all_windows[i : i + 8] + # resumable generation: append per-window predictions to the volume + import json as _json + prog = Path("/checkpoints") / RUN / "eval_progress.jsonl" + saved: dict[int, str] = {} + if prog.exists(): + for line in prog.read_text(encoding="utf-8").splitlines(): + if line.strip(): + row = _json.loads(line) + saved[row["i"]] = row["pred"] + print(f"[gen] resuming with {len(saved)} saved windows", flush=True) + + missing = [i for i in range(len(all_windows)) if i not in saved] + n_new = 0 + with torch.no_grad(), prog.open("a", encoding="utf-8") as prog_out: + for bi in range(0, len(missing), 8): + idxs = missing[bi : bi + 8] + batch = [all_windows[i] for i in idxs] enc = tokenizer( batch, return_tensors="pt", padding=True, truncation=True, max_length=1600 ).to(device) with torch.autocast("cuda", torch.bfloat16): gen = trainer.model.generate(**enc, max_new_tokens=3200, num_beams=1) - preds.extend(tokenizer.batch_decode(gen, skip_special_tokens=True)) - if (i // 8) % 40 == 0: - print(f"[gen] {i + len(batch)}/{len(all_windows)}", flush=True) + batch_preds = tokenizer.batch_decode(gen, skip_special_tokens=True) + for i, pred in zip(idxs, batch_preds): + prog_out.write(_json.dumps({"i": i, "pred": pred}, ensure_ascii=False) + "\n") + saved[i] = pred + n_new += len(idxs) + if n_new % 160 == 0: + prog_out.flush() + checkpoints_volume.commit() + print(f"[gen] {len(saved)}/{len(all_windows)} (committed)", flush=True) + checkpoints_volume.commit() + preds = [saved[i] for i in range(len(all_windows))] k = 0 paragraphs = [] diff --git a/train_arabic_r7.py b/train_arabic_r7.py new file mode 100644 index 0000000..02735b3 --- /dev/null +++ b/train_arabic_r7.py @@ -0,0 +1,341 @@ +"""Arabic r7 — news-domain adaptation (OOD repair). + +r5 paragraph-context specialized to SadeedDiac and trades ~0.5 DER +out-of-domain (WikiNews-2024 multi-ref: r5 20.52/12.72 vs r3 +19.99/12.60). r7 adapts the domain: cached r5-units (replay — protects +the ID anchor) plus a news mix from label_arabic_news.py +(r5-pseudo-labeled modern news, x3 upsampled, and WikiNews-2014 GOLD +x4 — 2014 shares documents with Tashkeela-era text, NOT with the 2024 +probe corpus). + +Init: r6-best if r6 verified better on SadeedDiac, else r5 +(override with --init-run). Batch 2/accum 15 (r5-proven at 1400B). + +Gates (both required to replace the init model): +- SadeedDiac-25 windowed zero-skip DER within +0.1 of the init model; +- WikiNews-2024 multi-ref improves (run eval_wikinews_multiref.py + with --model-dir pointing at this run's best). + +Usage: + modal run --detach train_arabic_r7.py + modal run --detach train_arabic_r7.py --init-run rababa_arabic_byt5/run-006-morph +""" + +from __future__ import annotations + +import random +import re +from pathlib import Path + +import modal + +datasets_volume = modal.Volume.from_name("rababa-datasets", create_if_missing=True) +checkpoints_volume = modal.Volume.from_name("rababa-checkpoints", create_if_missing=True) + +RUN = "rababa_arabic_byt5/run-007-news" +DEFAULT_INIT = "rababa_arabic_byt5/run-005-context" +UNIT_BYTES = 1400 +NEWS_UPSAMPLE = 3 +GOLD_UPSAMPLE = 4 +N_VAL = 2_000 + +DIACRITICS_RE = re.compile("[ؐ-ًؚ-ٰٟۖ-ۜ۟-۪ۨ-ۭ]") + +image = ( + modal.Image.debian_slim(python_version="3.11") + .pip_install( + "torch==2.5.1", + "transformers==4.46.3", + "accelerate>=1.1.0", + "pandas", + "tqdm", + "pyarrow", + "pyarabic", + "prettytable", + ) + .add_local_file("sadeed_evaluator.py", "/opt/rababa/sadeed_evaluator.py", copy=True) + .add_local_dir("data/sadeed-diac-25", "/opt/rababa/data/sadeed-diac-25", copy=True) + .workdir("/opt/rababa") + .env({"PYTORCH_CUDA_ALLOC_CONF": "expandable_segments:True"}) +) + +app = modal.App("rababa-arabic-r7", image=image) + + +def make_pair(unit: str) -> tuple[str, str] | None: + src = DIACRITICS_RE.sub("", unit) + if not src: + return None + if len(src.encode("utf-8")) > 1450 or len(unit.encode("utf-8")) > 1450: + return None + return src, unit + + +def _init_choice(explicit: str | None) -> str: + if explicit: + return explicit + r6_eval = Path("/checkpoints/rababa_arabic_byt5/run-006-morph/final_eval.json") + r6_done = Path("/checkpoints/rababa_arabic_byt5/run-006-morph/EVAL_DONE") + if r6_done.exists() and r6_eval.exists(): + import json + + try: + der = json.loads(r6_eval.read_text()).get("sadeed_der_ce") + if der is not None and der < 2.6775: + return "rababa_arabic_byt5/run-006-morph" + except Exception: + pass + return DEFAULT_INIT + + +@app.function( + gpu="A100-80GB", + timeout=24 * 60 * 60, + volumes={"/datasets": datasets_volume, "/checkpoints": checkpoints_volume}, +) +def train(init_run: str | None = None) -> dict: + import torch + from torch.utils.data import Dataset + from transformers import ( + AutoModelForSeq2SeqLM, + AutoTokenizer, + DataCollatorForSeq2Seq, + Seq2SeqTrainer, + Seq2SeqTrainingArguments, + TrainerCallback, + ) + + datasets_volume.reload() + checkpoints_volume.reload() + + done_marker = Path("/checkpoints") / RUN / "EVAL_DONE" + if done_marker.exists(): + return {"run": RUN, "status": "already-done"} + + news_dir = Path("/datasets/arabic-news-r5") + if not (news_dir / "DONE").exists(): + return {"run": RUN, "status": "waiting-for-label_arabic_news"} + + init_run = _init_choice(init_run) + print(f"[init] {init_run}", flush=True) + + print("[data] loading cached r5 paragraph units...", flush=True) + cache = Path("/datasets/r5-units") + domain = [l for l in (cache / "domain.txt").read_text(encoding="utf-8").splitlines() if l.strip()] + replay = [l for l in (cache / "replay.txt").read_text(encoding="utf-8").splitlines() if l.strip()] + + news = [l for l in (news_dir / "news.txt").read_text(encoding="utf-8").splitlines() if l.strip()] + gold = [l for l in (news_dir / "wikinews2014_gold.txt").read_text(encoding="utf-8").splitlines() if l.strip()] + print(f"[data] r5-units={len(domain) + len(replay)} news={len(news)} gold2014={len(gold)}", flush=True) + + pairs: list[tuple[str, str]] = [] + for unit in domain + replay: + p = make_pair(unit) + if p: + pairs.append(p) + n_anchor = len(pairs) + for unit in news * NEWS_UPSAMPLE + gold * GOLD_UPSAMPLE: + p = make_pair(unit) + if p: + pairs.append(p) + random.Random(42).shuffle(pairs) + print(f"[data] anchor={n_anchor} news-mix={len(pairs) - n_anchor} " + f"news-share={(len(pairs) - n_anchor) / len(pairs):.2%}", flush=True) + + combined = [l for l in Path("/datasets/arabic-combined/train.txt").read_text(encoding="utf-8").splitlines() if l.strip()] + random.Random(42).shuffle(combined) + val_pairs = [p for p in (make_pair(l) for l in combined[:N_VAL]) if p][:200] + + init = str(Path("/checkpoints") / init_run / "best") + tokenizer = AutoTokenizer.from_pretrained(init) + model = AutoModelForSeq2SeqLM.from_pretrained(init) + + class UnitDataset(Dataset): + def __init__(self, rows: list[tuple[str, str]]) -> None: + self.rows = rows + + def __len__(self) -> int: + return len(self.rows) + + def __getitem__(self, idx: int) -> dict: + src, tgt = self.rows[idx] + inputs = tokenizer(src, truncation=True, max_length=1600) + labels = tokenizer(tgt, truncation=True, max_length=1600) + inputs["labels"] = labels["input_ids"] + return inputs + + class VolumeCommitCallback(TrainerCallback): + def on_save(self, args, state, control, **kwargs): + try: + checkpoints_volume.commit() + print(f"[volume] committed at step {state.global_step}", flush=True) + except Exception as e: + print(f"[volume] commit failed at step {state.global_step}: {e}", flush=True) + + args = Seq2SeqTrainingArguments( + output_dir="/checkpoints/" + RUN, + num_train_epochs=1, + per_device_train_batch_size=2, + gradient_accumulation_steps=15, + per_device_eval_batch_size=1, + bf16=True, + learning_rate=2e-5, + lr_scheduler_type="cosine", + warmup_steps=200, + weight_decay=0.01, + max_grad_norm=1.0, + label_smoothing_factor=0.1, + seed=42, + save_strategy="steps", + save_steps=300, + eval_strategy="epoch", + save_total_limit=1, + logging_steps=100, + report_to=[], + predict_with_generate=False, + dataloader_num_workers=4, + ) + trainer = Seq2SeqTrainer( + model=model, + args=args, + train_dataset=UnitDataset(pairs), + eval_dataset=UnitDataset(val_pairs), + data_collator=DataCollatorForSeq2Seq(tokenizer=tokenizer, model=model, label_pad_token_id=-100), + callbacks=[VolumeCommitCallback()], + ) + import glob + + latest = sorted(glob.glob(f"/checkpoints/{RUN}/checkpoint-*"), key=lambda p: int(p.rsplit("-", 1)[1])) + resume = latest[-1] if latest else None + print(f"[resume] {resume}", flush=True) + trainer.train(resume_from_checkpoint=resume) + + best = Path("/checkpoints") / RUN / "best" + best.mkdir(parents=True, exist_ok=True) + trainer.save_model(str(best)) + tokenizer.save_pretrained(str(best)) + checkpoints_volume.commit() + + # ---- ID gate: windowed zero-skip SadeedDiac (r6 harness verbatim) ---- + import pandas as pd + import pyarrow.parquet as pq + from difflib import SequenceMatcher + + table = pq.read_table("data/sadeed-diac-25/train.parquet") + inputs = [DIACRITICS_RE.sub("", t) for t in table.column("input").to_pylist()] + outputs = table.column("output").to_pylist() + + def split_windows(text: str, budget: int = UNIT_BYTES) -> list[str]: + if len(text.encode("utf-8")) <= budget: + return [text] + words = text.split() + wins, cur, n = [], [], 0 + for w in words: + c = len(w.encode("utf-8")) + 1 + if cur and n + c > budget: + wins.append(" ".join(cur)) + cur, n = [], 0 + cur.append(w) + n += c + if cur: + wins.append(" ".join(cur)) + return wins + + def project_haraqat(pred: str, text: str) -> str: + pred_haraqat = [""] + for ch in pred: + if DIACRITICS_RE.match(ch): + pred_haraqat[-1] += ch + else: + pred_haraqat.append("") + pred_haraqat = pred_haraqat[1:] + pred_letters = [c for c in pred if not DIACRITICS_RE.match(c)] + text_letters = [c for c in text if not DIACRITICS_RE.match(c)] + sm = SequenceMatcher(None, text_letters, pred_letters, autojunk=False) + out = [] + for op, i1, i2, j1, j2 in sm.get_opcodes(): + if op == "equal": + for k in range(i2 - i1): + out.append(text_letters[i1 + k] + pred_haraqat[j1 + k]) + else: + for k in range(i1, i2): + out.append(text_letters[k]) + return "".join(out) + + device = next(trainer.model.parameters()).device + trainer.model.eval() + + all_windows: list[str] = [] + counts: list[int] = [] + for text in inputs: + ws = split_windows(text) + counts.append(len(ws)) + all_windows.extend(ws) + print(f"[eval] {len(inputs)} paragraphs -> {len(all_windows)} windows", flush=True) + + # resumable generation: append per-window predictions to the volume + import json as _json + prog = Path("/checkpoints") / RUN / "eval_progress.jsonl" + saved: dict[int, str] = {} + if prog.exists(): + for line in prog.read_text(encoding="utf-8").splitlines(): + if line.strip(): + row = _json.loads(line) + saved[row["i"]] = row["pred"] + print(f"[gen] resuming with {len(saved)} saved windows", flush=True) + + missing = [i for i in range(len(all_windows)) if i not in saved] + n_new = 0 + with torch.no_grad(), prog.open("a", encoding="utf-8") as prog_out: + for bi in range(0, len(missing), 8): + idxs = missing[bi : bi + 8] + batch = [all_windows[i] for i in idxs] + enc = tokenizer( + batch, return_tensors="pt", padding=True, truncation=True, max_length=1600 + ).to(device) + with torch.autocast("cuda", torch.bfloat16): + gen = trainer.model.generate(**enc, max_new_tokens=3200, num_beams=1) + batch_preds = tokenizer.batch_decode(gen, skip_special_tokens=True) + for i, pred in zip(idxs, batch_preds): + prog_out.write(_json.dumps({"i": i, "pred": pred}, ensure_ascii=False) + "\n") + saved[i] = pred + n_new += len(idxs) + if n_new % 160 == 0: + prog_out.flush() + checkpoints_volume.commit() + print(f"[gen] {len(saved)}/{len(all_windows)} (committed)", flush=True) + checkpoints_volume.commit() + preds = [saved[i] for i in range(len(all_windows))] + + k = 0 + paragraphs = [] + for text, c in zip(inputs, counts): + stitched = " ".join(preds[k : k + c]) + k += c + paragraphs.append(project_haraqat(stitched, text)) + + csv_path = Path("/tmp/sadeed_r7_windowed.csv") + pd.DataFrame({"gt": outputs, "pred": paragraphs}).to_csv(csv_path, index=False, header=False) + (Path("/checkpoints") / RUN / "sadeed_preds_windowed.csv").write_text( + csv_path.read_text(), encoding="utf-8") + checkpoints_volume.commit() + + from sadeed_evaluator import ArabicDiacritizationEvaluator as E + + print("\n===== r7 news-domain, windowed zero-skip (ID gate) =====", flush=True) + E.report_errors_on_csv_file( + str(csv_path), ground_truth_column_index=0, predicted_column_index=1, has_header=False, + gt_missing_diacritic_is_error=False) + + done_marker.touch() + checkpoints_volume.commit() + return {"run": RUN, "init": init_run} + + +@app.local_entrypoint() +def main(init_run: str | None = None): + # 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) diff --git a/train_arabic_r8.py b/train_arabic_r8.py new file mode 100644 index 0000000..116715f --- /dev/null +++ b/train_arabic_r8.py @@ -0,0 +1,362 @@ +"""Arabic r8 — IPA aux-task training (phonemic supervision). + +Experiment: the phonological-layer claim, made controlled. r6 showed a +morphological auxiliary task moves DER (2.6775 -> 2.5793). r8 swaps the +aux content for a PHONEMIC projection: the same r5 paragraph units, +rendered as broad-phonemic IPA by a deterministic converter +(arabic_to_ipa.py). Same text, two output formats — isolating one +variable: what the auxiliary representation contributes. + +Design (r6 verbatim except the aux stream): +- Stream A (plain): r5's paragraph units (cached r5-units) — identical. +- Stream B (tagged): "IPA: " + undiacritized unit -> IPA of the + diacritized unit. Same units as Stream A (seeded sample sized to the + ~25% aux share r6 used), NOT new text. +- Init from r5 best (as r6 did — so r6 vs r8 differ ONLY in aux + content), A100-80GB, r5-proven batch 2/accum 15, 1 epoch. +- Eval: windowed zero-skip at 1400B (r6 harness verbatim) + a bonus + IPA-stream CER probe (did the model learn the second projection?). + +Baselines: r5 2.6775 / r6 (morph aux) 2.5793. + +Usage: + modal run --detach train_arabic_r8.py +""" + +from __future__ import annotations + +import random +import re +from pathlib import Path + +import modal + +from arabic_to_ipa import to_ipa + +datasets_volume = modal.Volume.from_name("rababa-datasets", create_if_missing=True) +checkpoints_volume = modal.Volume.from_name("rababa-checkpoints", create_if_missing=True) + +RUN = "rababa_arabic_byt5/run-008-ipa" +INIT_RUN = "rababa_arabic_byt5/run-005-context" +UNIT_BYTES = 1400 +AUX_SHARE = 0.25 # match r6's effective tagged share +N_VAL = 2_000 +IPA_PROBE_N = 200 + +DIACRITICS_RE = re.compile("[ؐ-ًؚ-ٰٟۖ-ۜ۟-۪ۨ-ۭ]") + +image = ( + modal.Image.debian_slim(python_version="3.11") + .pip_install( + "torch==2.5.1", + "transformers==4.46.3", + "accelerate>=1.1.0", + "pandas", + "tqdm", + "pyarrow", + "pyarabic", + "editdistance", + "prettytable", + ) + .add_local_file("sadeed_evaluator.py", "/opt/rababa/sadeed_evaluator.py", copy=True) + .add_local_file("arabic_to_ipa.py", "/opt/rababa/arabic_to_ipa.py", copy=True) + .add_local_dir("data/sadeed-diac-25", "/opt/rababa/data/sadeed-diac-25", copy=True) + .workdir("/opt/rababa") + .env({"PYTORCH_CUDA_ALLOC_CONF": "expandable_segments:True"}) +) + +app = modal.App("rababa-arabic-r8", image=image) + + +def make_pair(unit: str) -> tuple[str, str] | None: + src = DIACRITICS_RE.sub("", unit) + if not src: + return None + if len(src.encode("utf-8")) > 1450 or len(unit.encode("utf-8")) > 1450: + return None + return src, unit + + +def make_ipa_pair(unit: str) -> tuple[str, str] | None: + src = DIACRITICS_RE.sub("", unit) + if not src: + return None + ipa = to_ipa(unit) + if not ipa.strip(): + return None + if len(src.encode("utf-8")) > 1450 or len(ipa.encode("utf-8")) > 1050: + return None + return "IPA: " + src, ipa + + +@app.function( + gpu="A100-80GB", + timeout=24 * 60 * 60, + volumes={"/datasets": datasets_volume, "/checkpoints": checkpoints_volume}, +) +def train() -> dict: + import torch + from torch.utils.data import Dataset + from transformers import ( + AutoModelForSeq2SeqLM, + AutoTokenizer, + DataCollatorForSeq2Seq, + Seq2SeqTrainer, + Seq2SeqTrainingArguments, + TrainerCallback, + ) + + datasets_volume.reload() + checkpoints_volume.reload() + + done_marker = Path("/checkpoints") / RUN / "EVAL_DONE" + if done_marker.exists(): + return {"run": RUN, "status": "already-done"} + + print("[data] loading cached r5 paragraph units...", flush=True) + cache = Path("/datasets/r5-units") + domain = [l for l in (cache / "domain.txt").read_text(encoding="utf-8").splitlines() if l.strip()] + replay = [l for l in (cache / "replay.txt").read_text(encoding="utf-8").splitlines() if l.strip()] + random.Random(42).shuffle(domain) + random.Random(42).shuffle(replay) + + pairs: list[tuple[str, str]] = [] + pairs.extend(p for p in (make_pair(u) for u in domain) if p) + pairs.extend(p for p in (make_pair(u) for u in replay) if p) + n_plain = len(pairs) + + # aux stream: the SAME units in IPA, seeded sample at r6's aux share + aux_pool = [p for p in (make_ipa_pair(u) for u in domain) if p] + k = int(n_plain * AUX_SHARE / (1 - AUX_SHARE)) + tagged_pairs = random.Random(43).sample(aux_pool, min(k, len(aux_pool))) + pairs.extend(tagged_pairs) + random.Random(42).shuffle(pairs) + print(f"[data] plain={n_plain} ipa-aux={len(tagged_pairs)} " + f"aux-share={len(tagged_pairs)/len(pairs):.2%} total={len(pairs)}", flush=True) + + combined = [l.strip() for l in Path("/datasets/arabic-combined/train.txt").read_text(encoding="utf-8").splitlines() if l.strip()] + random.Random(42).shuffle(combined) + val_pairs = [p for p in (make_pair(l) for l in combined[:N_VAL]) if p][:200] + + # held-out IPA probe units: end of the domain pool (shuffled seed 42) + probe_units = [u for u in domain[-IPA_PROBE_N:]] + probe_refs = [(DIACRITICS_RE.sub("", u), to_ipa(u)) for u in probe_units] + probe_refs = [p for p in probe_refs if p[0] and p[1]] + + init = str(Path("/checkpoints") / INIT_RUN / "best") + print(f"[init] {init}", flush=True) + tokenizer = AutoTokenizer.from_pretrained(init) + model = AutoModelForSeq2SeqLM.from_pretrained(init) + + class UnitDataset(Dataset): + def __init__(self, rows: list[tuple[str, str]]) -> None: + self.rows = rows + + def __len__(self) -> int: + return len(self.rows) + + def __getitem__(self, idx: int) -> dict: + src, tgt = self.rows[idx] + inputs = tokenizer(src, truncation=True, max_length=1600) + labels = tokenizer(tgt, truncation=True, max_length=1600) + inputs["labels"] = labels["input_ids"] + return inputs + + class VolumeCommitCallback(TrainerCallback): + def on_save(self, args, state, control, **kwargs): + try: + checkpoints_volume.commit() + print(f"[volume] committed at step {state.global_step}", flush=True) + except Exception as e: + print(f"[volume] commit failed at step {state.global_step}: {e}", flush=True) + + args = Seq2SeqTrainingArguments( + output_dir="/checkpoints/" + RUN, + num_train_epochs=1, + per_device_train_batch_size=2, + gradient_accumulation_steps=15, + per_device_eval_batch_size=1, + bf16=True, + learning_rate=2e-5, + lr_scheduler_type="cosine", + warmup_steps=200, + weight_decay=0.01, + max_grad_norm=1.0, + label_smoothing_factor=0.1, + seed=42, + save_strategy="steps", + save_steps=300, + eval_strategy="epoch", + save_total_limit=1, + logging_steps=100, + report_to=[], + predict_with_generate=False, + dataloader_num_workers=4, + ) + trainer = Seq2SeqTrainer( + model=model, + args=args, + train_dataset=UnitDataset(pairs), + eval_dataset=UnitDataset(val_pairs), + data_collator=DataCollatorForSeq2Seq(tokenizer=tokenizer, model=model, label_pad_token_id=-100), + callbacks=[VolumeCommitCallback()], + ) + import glob + + latest = sorted(glob.glob(f"/checkpoints/{RUN}/checkpoint-*"), key=lambda p: int(p.rsplit("-", 1)[1])) + resume = latest[-1] if latest else None + print(f"[resume] {resume}", flush=True) + trainer.train(resume_from_checkpoint=resume) + + best = Path("/checkpoints") / RUN / "best" + best.mkdir(parents=True, exist_ok=True) + trainer.save_model(str(best)) + tokenizer.save_pretrained(str(best)) + checkpoints_volume.commit() + + # ---- windowed zero-skip eval at the training context size (r6 verbatim) ---- + import pandas as pd + import pyarrow.parquet as pq + from difflib import SequenceMatcher + + table = pq.read_table("data/sadeed-diac-25/train.parquet") + inputs = [DIACRITICS_RE.sub("", t) for t in table.column("input").to_pylist()] + outputs = table.column("output").to_pylist() + + def split_windows(text: str, budget: int = UNIT_BYTES) -> list[str]: + if len(text.encode("utf-8")) <= budget: + return [text] + words = text.split() + wins, cur, n = [], [], 0 + for w in words: + c = len(w.encode("utf-8")) + 1 + if cur and n + c > budget: + wins.append(" ".join(cur)) + cur, n = [], 0 + cur.append(w) + n += c + if cur: + wins.append(" ".join(cur)) + return wins + + def project_haraqat(pred: str, text: str) -> str: + pred_haraqat = [""] + for ch in pred: + if DIACRITICS_RE.match(ch): + pred_haraqat[-1] += ch + else: + pred_haraqat.append("") + pred_haraqat = pred_haraqat[1:] + pred_letters = [c for c in pred if not DIACRITICS_RE.match(c)] + text_letters = [c for c in text if not DIACRITICS_RE.match(c)] + sm = SequenceMatcher(None, text_letters, pred_letters, autojunk=False) + out = [] + for op, i1, i2, j1, j2 in sm.get_opcodes(): + if op == "equal": + for k in range(i2 - i1): + out.append(text_letters[i1 + k] + pred_haraqat[j1 + k]) + else: + for k in range(i1, i2): + out.append(text_letters[k]) + return "".join(out) + + device = next(trainer.model.parameters()).device + trainer.model.eval() + + all_windows: list[str] = [] + counts: list[int] = [] + for text in inputs: + ws = split_windows(text) + counts.append(len(ws)) + all_windows.extend(ws) + print(f"[eval] {len(inputs)} paragraphs -> {len(all_windows)} windows", flush=True) + + import json as _json + prog = Path("/checkpoints") / RUN / "eval_progress.jsonl" + saved: dict[int, str] = {} + if prog.exists(): + for line in prog.read_text(encoding="utf-8").splitlines(): + if line.strip(): + row = _json.loads(line) + saved[row["i"]] = row["pred"] + print(f"[gen] resuming with {len(saved)} saved windows", flush=True) + + missing = [i for i in range(len(all_windows)) if i not in saved] + n_new = 0 + with torch.no_grad(), prog.open("a", encoding="utf-8") as prog_out: + for bi in range(0, len(missing), 8): + idxs = missing[bi : bi + 8] + batch = [all_windows[i] for i in idxs] + enc = tokenizer( + batch, return_tensors="pt", padding=True, truncation=True, max_length=1600 + ).to(device) + with torch.autocast("cuda", torch.bfloat16): + gen = trainer.model.generate(**enc, max_new_tokens=3200, num_beams=1) + batch_preds = tokenizer.batch_decode(gen, skip_special_tokens=True) + for i, pred in zip(idxs, batch_preds): + prog_out.write(_json.dumps({"i": i, "pred": pred}, ensure_ascii=False) + "\n") + saved[i] = pred + n_new += len(idxs) + if n_new % 160 == 0: + prog_out.flush() + checkpoints_volume.commit() + print(f"[gen] {len(saved)}/{len(all_windows)} (committed)", flush=True) + checkpoints_volume.commit() + preds = [saved[i] for i in range(len(all_windows))] + + k_par = 0 + paragraphs = [] + for text, c in zip(inputs, counts): + stitched = " ".join(preds[k_par : k_par + c]) + k_par += c + paragraphs.append(project_haraqat(stitched, text)) + + csv_path = Path("/tmp/sadeed_r8_windowed.csv") + pd.DataFrame({"gt": outputs, "pred": paragraphs}).to_csv(csv_path, index=False, header=False) + (Path("/checkpoints") / RUN / "sadeed_preds_windowed.csv").write_text( + csv_path.read_text(), encoding="utf-8") + checkpoints_volume.commit() + + from sadeed_evaluator import ArabicDiacritizationEvaluator as E + + print("\n===== r8 IPA aux-task, windowed zero-skip (vs r5 2.6775 / r6-morph 2.5793) =====", flush=True) + E.report_errors_on_csv_file( + str(csv_path), ground_truth_column_index=0, predicted_column_index=1, has_header=False, + gt_missing_diacritic_is_error=False) + + # ---- bonus: IPA-stream probe (second-projection capability) ---- + import editdistance + + probe_srcs = ["IPA: " + s for s, _ in probe_refs] + probe_outs: list[str] = [] + with torch.no_grad(): + for bi in range(0, len(probe_srcs), 4): + batch = probe_srcs[bi : bi + 4] + enc = tokenizer(batch, return_tensors="pt", padding=True, truncation=True, + max_length=1600).to(device) + with torch.autocast("cuda", torch.bfloat16): + gen = trainer.model.generate(**enc, max_new_tokens=1050, num_beams=1) + probe_outs.extend(tokenizer.batch_decode(gen, skip_special_tokens=True)) + tot_ed = sum(editdistance.eval(p, r) for (_, r), p in zip(probe_refs, probe_outs)) + tot_len = sum(len(r) for _, r in probe_refs) + em = sum(1 for (_, r), p in zip(probe_refs, probe_outs) if p == r) + print(f"\n===== r8 IPA-stream probe: CER {tot_ed / max(tot_len, 1):.4f} " + f"EM {em}/{len(probe_refs)} =====", flush=True) + (Path("/checkpoints") / RUN / "ipa_probe.json").write_text(_json.dumps({ + "cer": tot_ed / max(tot_len, 1), "em": em, "n": len(probe_refs), + }), encoding="utf-8") + + done_marker.touch() + checkpoints_volume.commit() + return {"run": RUN, "ipa_probe_cer": tot_ed / max(tot_len, 1), "ipa_probe_em": em} + + +@app.local_entrypoint() +def main(): + # spawn (fire-and-forget): the run must not be cancellable by a + # workstation network flap — two client-side disconnects killed + # attached runs before this change. Resume/checkpoint guards make + # relaunch idempotent. + handle = train.spawn() + print(f"spawned {handle.object_id}; completion = EVAL_DONE marker at " + f"rababa_checkpoints:{RUN}/EVAL_DONE", flush=True) diff --git a/train_hebrew_s46.py b/train_hebrew_s46.py new file mode 100644 index 0000000..9a9a115 --- /dev/null +++ b/train_hebrew_s46.py @@ -0,0 +1,357 @@ +"""Hebrew s46 — s45 curriculum with a diversified weak stage. + +s45 (16.58% DER, beam-4, Nakdimon test) proved weak-pretrain + gold-FT +using knesset alone (1.5M lines, parliamentary domain). s46 repeats the +recipe verbatim with one change: the weak stage adds ALL of hewiki +Dicta-labeled (label_hewiki_full.py → /datasets/hebrew-hewiki-dicta, +~70K kept lines, encyclopedic domain, decontaminated) on top of the +same s45 knesset pairs. Stage 2 and eval are byte-identical to s45, so +the comparison isolates the weak-diversity variable. + +Gate: must beat 16.58 to replace s45; otherwise record flat and keep +s45 as canonical. + +Usage: + modal run --detach train_hebrew_s46.py +""" + +from __future__ import annotations + +import json +import random +from pathlib import Path + +import modal + +checkpoints_volume = modal.Volume.from_name("rababa-checkpoints", create_if_missing=True) +datasets_volume = modal.Volume.from_name("rababa-datasets", create_if_missing=True) + +RUN = "rababa_hebrew/run-s46-phonikud-plus" +KNESSET_PAIRS = Path("/datasets/hebrew-phonikud/pairs") +HEWIKI_DIR = Path("/datasets/hebrew-hewiki-dicta") +S46_PREP = Path("/datasets/hebrew-hewiki-dicta/s46_pairs.jsonl") + +_NIKUD_MARKS = set("ְֱֲֳִֵֶַָֹֺֻּֽֿׁׂ־") + +image = ( + modal.Image.debian_slim(python_version="3.11") + .apt_install("build-essential", "git", "curl") + .pip_install( + "torch==2.5.1", + "transformers==4.46.3", + "accelerate>=1.1.0", + "numpy>=1.26,<3", + "tqdm>=4.66", + ) + .add_local_dir("src", "/opt/rababa/src", copy=True) + .workdir("/opt/rababa") + .env({"PYTHONPATH": "/opt/rababa/src", "PYTORCH_CUDA_ALLOC_CONF": "expandable_segments:True"}) +) + +app = modal.App("rababa-hebrew-s46", image=image) + + +@app.function(cpu=8, timeout=1 * 60 * 60, volumes={"/datasets": datasets_volume}) +def prep() -> dict: + datasets_volume.reload() + + if not (HEWIKI_DIR / "DONE").exists(): + return {"status": "waiting-for-label_hewiki_full"} + if S46_PREP.exists(): + return {"status": "already-done"} + + rows = [] + for line in (HEWIKI_DIR / "train.txt").read_text(encoding="utf-8").splitlines(): + target = line.strip() + if not target: + continue + src = "".join(c for c in target if c not in _NIKUD_MARKS).strip() + if not src: + continue + rows.append({"src": src, "tgt": target}) + random.Random(42).shuffle(rows) + S46_PREP.write_text( + "\n".join(json.dumps(r, ensure_ascii=False) for r in rows) + "\n", encoding="utf-8") + datasets_volume.commit() + print(f"[prep] hewiki rows={len(rows)}", flush=True) + return {"hewiki_rows": len(rows)} + + +def _load_jsonl(p: Path) -> list[dict]: + return [json.loads(l) for l in p.read_text(encoding="utf-8").splitlines() if l.strip()] + + +def _mk_dataset_class(tokenizer): + from torch.utils.data import Dataset + + class JsonlDataset(Dataset): + def __init__(self, rows: list[dict]) -> None: + self.rows = rows + + def __len__(self) -> int: + return len(self.rows) + + def __getitem__(self, idx: int) -> dict: + r = self.rows[idx] + inputs = tokenizer(r["src"], truncation=True, max_length=512) + labels = tokenizer(r["tgt"], truncation=True, max_length=512) + inputs["labels"] = labels["input_ids"] + return inputs + + return JsonlDataset + + +@app.function( + gpu="A100-80GB", + timeout=12 * 60 * 60, + volumes={"/checkpoints": checkpoints_volume, "/datasets": datasets_volume}, +) +def stage1() -> dict: + from transformers import ( + AutoModelForSeq2SeqLM, + AutoTokenizer, + DataCollatorForSeq2Seq, + Seq2SeqTrainer, + Seq2SeqTrainingArguments, + TrainerCallback, + ) + + checkpoints_volume.reload() + datasets_volume.reload() + + out_dir = Path("/checkpoints") / RUN / "run-001-pretrain" + done = out_dir / "DONE" + if done.exists(): + return {"status": "already-done", "ckpt": str(out_dir / "best")} + if not S46_PREP.exists(): + return {"status": "run-prep-first"} + + tokenizer = AutoTokenizer.from_pretrained("google/byt5-base") + model = AutoModelForSeq2SeqLM.from_pretrained("google/byt5-base") + JsonlDataset = _mk_dataset_class(tokenizer) + train_rows = _load_jsonl(KNESSET_PAIRS / "train.jsonl") + _load_jsonl(S46_PREP) + val_rows = _load_jsonl(KNESSET_PAIRS / "val.jsonl") + random.Random(43).shuffle(train_rows) + print(f"[s1] train={len(train_rows)} (knesset + hewiki) val={len(val_rows)}", flush=True) + + class VolumeCommitCallback(TrainerCallback): + def on_save(self, args, state, control, **kwargs): + try: + checkpoints_volume.commit() + print(f"[volume] committed at step {state.global_step}", flush=True) + except Exception as e: + print(f"[volume] commit failed: {e}", flush=True) + + args = Seq2SeqTrainingArguments( + output_dir=str(out_dir), + num_train_epochs=1, + per_device_train_batch_size=16, + gradient_accumulation_steps=4, + per_device_eval_batch_size=16, + bf16=True, + learning_rate=3e-4, + warmup_steps=500, + weight_decay=0.01, + max_grad_norm=1.0, + label_smoothing_factor=0.1, + seed=42, + save_strategy="steps", + save_steps=500, + eval_strategy="steps", + eval_steps=1000, + save_total_limit=1, + logging_steps=200, + report_to=[], + dataloader_num_workers=4, + ) + trainer = Seq2SeqTrainer( + model=model, + args=args, + train_dataset=JsonlDataset(train_rows), + eval_dataset=JsonlDataset(val_rows), + data_collator=DataCollatorForSeq2Seq(tokenizer=tokenizer, model=model, label_pad_token_id=-100), + callbacks=[VolumeCommitCallback()], + ) + + import glob + + latest = sorted(glob.glob(f"{out_dir}/checkpoint-*"), key=lambda p: int(p.rsplit("-", 1)[1])) + trainer.train(resume_from_checkpoint=latest[-1] if latest else None) + + best = out_dir / "best" + best.mkdir(parents=True, exist_ok=True) + trainer.save_model(str(best)) + tokenizer.save_pretrained(str(best)) + done.touch() + checkpoints_volume.commit() + return {"ckpt": str(best)} + + +@app.function( + gpu="A100", + timeout=8 * 60 * 60, + volumes={"/checkpoints": checkpoints_volume, "/datasets": datasets_volume}, +) +def stage2() -> dict: + from transformers import ( + AutoModelForSeq2SeqLM, + AutoTokenizer, + DataCollatorForSeq2Seq, + Seq2SeqTrainer, + Seq2SeqTrainingArguments, + TrainerCallback, + ) + + checkpoints_volume.reload() + datasets_volume.reload() + + init = Path("/checkpoints") / RUN / "run-001-pretrain" / "best" + out_dir = Path("/checkpoints") / RUN / "run-002-gold-ft" + done = out_dir / "DONE" + if done.exists(): + return {"status": "already-done", "ckpt": str(out_dir / "best")} + + tokenizer = AutoTokenizer.from_pretrained(str(init)) + model = AutoModelForSeq2SeqLM.from_pretrained(str(init)) + JsonlDataset = _mk_dataset_class(tokenizer) + train_rows = _load_jsonl(Path("/datasets/hebrew-v4/train.jsonl")) + val_rows = _load_jsonl(Path("/datasets/hebrew-v4/val.jsonl")) + print(f"[s2] hebrew-v4 train={len(train_rows)} val={len(val_rows)}", flush=True) + + class VolumeCommitCallback(TrainerCallback): + def on_save(self, args, state, control, **kwargs): + try: + checkpoints_volume.commit() + except Exception as e: + print(f"[volume] commit failed: {e}", flush=True) + + args = Seq2SeqTrainingArguments( + output_dir=str(out_dir), + num_train_epochs=3, + per_device_train_batch_size=8, + per_device_eval_batch_size=8, + bf16=True, + learning_rate=3e-4, + warmup_steps=500, + weight_decay=0.01, + max_grad_norm=1.0, + label_smoothing_factor=0.1, + seed=42, + save_strategy="epoch", + eval_strategy="epoch", + save_total_limit=1, + load_best_model_at_end=True, + metric_for_best_model="eval_loss", + greater_is_better=False, + logging_steps=100, + report_to=[], + dataloader_num_workers=4, + ) + trainer = Seq2SeqTrainer( + model=model, + args=args, + train_dataset=JsonlDataset(train_rows), + eval_dataset=JsonlDataset(val_rows), + data_collator=DataCollatorForSeq2Seq(tokenizer=tokenizer, model=model, label_pad_token_id=-100), + callbacks=[VolumeCommitCallback()], + ) + + import glob + + latest = sorted(glob.glob(f"{out_dir}/checkpoint-*"), key=lambda p: int(p.rsplit("-", 1)[1])) + trainer.train(resume_from_checkpoint=latest[-1] if latest else None) + + best = out_dir / "best" + best.mkdir(parents=True, exist_ok=True) + trainer.save_model(str(best)) + tokenizer.save_pretrained(str(best)) + done.touch() + checkpoints_volume.commit() + return {"ckpt": str(best)} + + +@app.function( + gpu="A10G", + timeout=2 * 60 * 60, + volumes={"/checkpoints": checkpoints_volume, "/datasets": datasets_volume}, +) +def evaluate() -> dict: + import torch + from transformers import T5ForConditionalGeneration, ByT5Tokenizer + from rababa.evaluate import seq2seq_der + from rababa.datasets import _find_nakdimon_root + + checkpoints_volume.reload() + datasets_volume.reload() + + ckpt = str(Path("/checkpoints") / RUN / "run-002-gold-ft" / "best") + model = T5ForConditionalGeneration.from_pretrained(ckpt).to("cuda") + tokenizer = ByT5Tokenizer.from_pretrained(ckpt) + model.eval() + + test_path = Path(_find_nakdimon_root()) / "test.txt" + examples = [] + for line in test_path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + undiacritized = "".join(c for c in line if c not in _NIKUD_MARKS).strip() + if 2 <= len(undiacritized) <= 512: + examples.append((undiacritized, line)) + print(f"[eval] {len(examples)} examples", flush=True) + + # resumable beam-4 generation: per-example predictions on the volume + prog = Path("/checkpoints") / RUN / "eval_progress.jsonl" + saved: dict[int, str] = {} + if prog.exists(): + for line in prog.read_text(encoding="utf-8").splitlines(): + if line.strip(): + row = json.loads(line) + saved[row["i"]] = row["pred"] + print(f"[eval] resuming with {len(saved)} saved examples", flush=True) + + missing = [i for i in range(len(examples)) if i not in saved] + n_new = 0 + with torch.no_grad(), prog.open("a", encoding="utf-8") as prog_out: + for bi in range(0, len(missing), 8): + idxs = missing[bi : bi + 8] + batch = [examples[i] for i in idxs] + enc = tokenizer([s for s, _ in batch], return_tensors="pt", padding=True, + truncation=True, max_length=512).to("cuda") + gen = model.generate(**enc, max_new_tokens=512, num_beams=4) + preds = tokenizer.batch_decode(gen, skip_special_tokens=True) + for i, pred in zip(idxs, preds): + prog_out.write(json.dumps({"i": i, "pred": pred}, ensure_ascii=False) + "\n") + saved[i] = pred + n_new += len(idxs) + if n_new % 160 == 0: + prog_out.flush() + checkpoints_volume.commit() + print(f" [{len(saved)}/{len(examples)}] (committed)", flush=True) + checkpoints_volume.commit() + + total_wrong = total_positions = total_n = 0 + for i, (_, gold) in enumerate(examples): + der, n = seq2seq_der(saved[i], gold) + total_wrong += int(der * n) + total_positions += n + total_n += 1 + if i % 240 == 0 and i > 0: + print(f" [{i}/{len(examples)}] DER={total_wrong / max(1, total_positions):.4f}", flush=True) + + der = total_wrong / max(1, total_positions) + print(f"=== s46 diversified-weak DER (beam-4): {der:.4f} ({total_n} examples) ===", flush=True) + result = {"der": der, "n": total_n, "checkpoint": ckpt, + "baseline_s45": 0.1658, "baseline_s43": 0.1746} + (Path("/checkpoints") / RUN / "final_eval.json").write_text( + json.dumps(result, indent=2), encoding="utf-8") + checkpoints_volume.commit() + return result + + +@app.local_entrypoint() +def main(): + print(json.dumps(prep.remote(), indent=2)) + print(json.dumps(stage1.remote(), indent=2)) + print(json.dumps(stage2.remote(), indent=2)) + print(json.dumps(evaluate.remote(), indent=2)) diff --git a/train_hebrew_s47.py b/train_hebrew_s47.py new file mode 100644 index 0000000..92c519d --- /dev/null +++ b/train_hebrew_s47.py @@ -0,0 +1,298 @@ +"""Hebrew s47 — morphological aux-task transplant (TODO.public §3). + +The r6 template, first cross-language move: Hebrew's 16.58 residual is +plausibly morphology (nikud encodes gender/number/agreement), the same +shape as Arabic's iʿrāb residual that r6's aux-task fixed. + +Design (r6-faithful, s45-housed): +- Stream A (plain): hebrew-v4 gold x2 + 200K phonikud weak pairs. +- Stream B (tagged): dictabert-morph-labeled knesset lines from + /datasets/hebrew-morph; input "TAG: " + segmented src, target = + per-token tags ("NOUN|Gender=Masc|..."). The '|' prefix-split marker + tokens are dropped. B upsampled x4. ASCII prefix on a byte model is + perfectly distinguishable; plain inference is unaffected. +- Init: s46-best if its final_eval beats s45's 0.1658, else s45-best. +- LR 2e-5 (continued-training regime, r6-proven), 1 epoch, eff. batch 64. +Eval: beam-4 DER on the Nakdimon test, identical harness as s45/s46, +per-example resumable. Gate: beat 16.58. + +Usage: + modal run --detach train_hebrew_s47.py +""" + +from __future__ import annotations + +import json +import random +from pathlib import Path + +import modal + +checkpoints_volume = modal.Volume.from_name("rababa-checkpoints", create_if_missing=True) +datasets_volume = modal.Volume.from_name("rababa-datasets", create_if_missing=True) + +RUN = "rababa_hebrew/run-s47-morph" +S45_RUN = "rababa_hebrew/run-s45-phonikud" +S46_RUN = "rababa_hebrew/run-s46-phonikud-plus" +S45_DER = 0.1658 + +N_MORPH = 100_000 +N_WEAK = 200_000 +TAG_UPSAMPLE = 4 +GOLD_UPSAMPLE = 2 + +_NIKUD_MARKS = set("ְֱֲֳִֵֶַָֹֺֻּֽֿׁׂ־") + +image = ( + modal.Image.debian_slim(python_version="3.11") + .apt_install("build-essential", "git", "curl") + .pip_install( + "torch==2.5.1", + "transformers==4.46.3", + "accelerate>=1.1.0", + "numpy>=1.26,<3", + "tqdm>=4.66", + ) + .add_local_dir("src", "/opt/rababa/src", copy=True) + .workdir("/opt/rababa") + .env({"PYTHONPATH": "/opt/rababa/src", "PYTORCH_CUDA_ALLOC_CONF": "expandable_segments:True"}) +) + +app = modal.App("rababa-hebrew-s47", image=image) + + +def _load_jsonl(p: Path) -> list[dict]: + return [json.loads(l) for l in p.read_text(encoding="utf-8").splitlines() if l.strip()] + + +def _choose_init() -> tuple[str, float]: + """s46-best if it beat s45, else s45-best (r7-style dynamic choice).""" + base = Path("/checkpoints") + s45 = base / S45_RUN + s45_der = 0.0 + fe = s45 / "final_eval.json" + if fe.exists(): + s45_der = json.loads(fe.read_text(encoding="utf-8"))["der"] + else: + s45_der = S45_DER + s46_fe = base / S46_RUN / "final_eval.json" + if s46_fe.exists(): + s46_der = json.loads(s46_fe.read_text(encoding="utf-8"))["der"] + if s46_der < s45_der: + ckpt = base / S46_RUN / "run-002-gold-ft" / "best" + if ckpt.exists(): + return str(ckpt), s46_der + return str(s45 / "run-002-gold-ft" / "best"), s45_der + + +@app.function( + gpu="A100-80GB", + timeout=24 * 60 * 60, + volumes={"/checkpoints": checkpoints_volume, "/datasets": datasets_volume}, +) +def train() -> dict: + from torch.utils.data import Dataset + from transformers import ( + AutoModelForSeq2SeqLM, + AutoTokenizer, + DataCollatorForSeq2Seq, + Seq2SeqTrainer, + Seq2SeqTrainingArguments, + TrainerCallback, + ) + + checkpoints_volume.reload() + datasets_volume.reload() + + done_marker = Path("/checkpoints") / RUN / "EVAL_DONE" + if done_marker.exists(): + return {"run": RUN, "status": "already-done"} + + init, init_der = _choose_init() + print(f"[init] {init} (der {init_der:.4f})", flush=True) + + gold = _load_jsonl(Path("/datasets/hebrew-v4/train.jsonl")) + weak_all = _load_jsonl(Path("/datasets/hebrew-phonikud/pairs/train.jsonl")) + rng = random.Random(42) + weak = rng.sample(weak_all, min(N_WEAK, len(weak_all))) + print(f"[data] gold={len(gold)} weak={len(weak)}", flush=True) + + morph_path = Path("/datasets/hebrew-morph/train.jsonl") + if not morph_path.exists(): + raise FileNotFoundError(f"{morph_path} missing — run label_hebrew_morph.py first") + morph_rows = _load_jsonl(morph_path)[:N_MORPH] + + tagged_pairs: list[tuple[str, str]] = [] + for r in morph_rows: + words, tags = [], [] + for w, t in zip(r["src"].split(), r["tags"]): + if w == "|": + continue + words.append(w) + tags.append(t) + if 3 <= len(words) <= 64: + tagged_pairs.append(("TAG: " + " ".join(words), " ".join(tags))) + print(f"[data] morph lines={len(morph_rows)} tagged units={len(tagged_pairs)}", flush=True) + + pairs: list[tuple[str, str]] = [] + pairs.extend((r["src"], r["tgt"]) for r in gold * GOLD_UPSAMPLE) + pairs.extend((r["src"], r["tgt"]) for r in weak) + pairs.extend(tagged_pairs * TAG_UPSAMPLE) + rng.shuffle(pairs) + print(f"[data] total={len(pairs)} " + f"(gold x{GOLD_UPSAMPLE}={len(gold)*GOLD_UPSAMPLE} weak={len(weak)} " + f"tagged x{TAG_UPSAMPLE}={len(tagged_pairs)*TAG_UPSAMPLE})", flush=True) + + tokenizer = AutoTokenizer.from_pretrained(init) + model = AutoModelForSeq2SeqLM.from_pretrained(init) + + class PairDataset(Dataset): + def __init__(self, rows: list[tuple[str, str]]) -> None: + self.rows = rows + + def __len__(self) -> int: + return len(self.rows) + + def __getitem__(self, idx: int) -> dict: + src, tgt = self.rows[idx] + inputs = tokenizer(src, truncation=True, max_length=512) + labels = tokenizer(tgt, truncation=True, max_length=512) + inputs["labels"] = labels["input_ids"] + return inputs + + val_rows = [(r["src"], r["tgt"]) for r in _load_jsonl(Path("/datasets/hebrew-v4/val.jsonl"))[:200]] + + class VolumeCommitCallback(TrainerCallback): + def on_save(self, args, state, control, **kwargs): + try: + checkpoints_volume.commit() + print(f"[volume] committed at step {state.global_step}", flush=True) + except Exception as e: + print(f"[volume] commit failed at step {state.global_step}: {e}", flush=True) + + args = Seq2SeqTrainingArguments( + output_dir="/checkpoints/" + RUN, + num_train_epochs=1, + per_device_train_batch_size=16, + gradient_accumulation_steps=4, + per_device_eval_batch_size=16, + bf16=True, + learning_rate=2e-5, + lr_scheduler_type="cosine", + warmup_steps=200, + weight_decay=0.01, + max_grad_norm=1.0, + label_smoothing_factor=0.1, + seed=42, + save_strategy="steps", + save_steps=500, + eval_strategy="steps", + eval_steps=1000, + save_total_limit=1, + logging_steps=100, + report_to=[], + dataloader_num_workers=4, + ) + trainer = Seq2SeqTrainer( + model=model, + args=args, + train_dataset=PairDataset(pairs), + eval_dataset=PairDataset(val_rows), + data_collator=DataCollatorForSeq2Seq(tokenizer=tokenizer, model=model, label_pad_token_id=-100), + callbacks=[VolumeCommitCallback()], + ) + import glob + + latest = sorted(glob.glob(f"/checkpoints/{RUN}/checkpoint-*"), key=lambda p: int(p.rsplit("-", 1)[1])) + resume = latest[-1] if latest else None + print(f"[resume] {resume}", flush=True) + trainer.train(resume_from_checkpoint=resume) + + best = Path("/checkpoints") / RUN / "best" + best.mkdir(parents=True, exist_ok=True) + trainer.save_model(str(best)) + tokenizer.save_pretrained(str(best)) + checkpoints_volume.commit() + + result = evaluate.remote() + return {"run": RUN, "init_der": init_der, **result} + + +@app.function( + gpu="A10G", + timeout=4 * 60 * 60, + volumes={"/checkpoints": checkpoints_volume, "/datasets": datasets_volume}, +) +def evaluate() -> dict: + import torch + from transformers import T5ForConditionalGeneration, ByT5Tokenizer + from rababa.evaluate import seq2seq_der + from rababa.datasets import _find_nakdimon_root + + checkpoints_volume.reload() + datasets_volume.reload() + + ckpt = str(Path("/checkpoints") / RUN / "best") + model = T5ForConditionalGeneration.from_pretrained(ckpt).to("cuda") + tokenizer = ByT5Tokenizer.from_pretrained(ckpt) + model.eval() + + test_path = Path(_find_nakdimon_root()) / "test.txt" + examples = [] + for line in test_path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + undiacritized = "".join(c for c in line if c not in _NIKUD_MARKS).strip() + if 2 <= len(undiacritized) <= 512: + examples.append((undiacritized, line)) + print(f"[eval] {len(examples)} examples", flush=True) + + prog = Path("/checkpoints") / RUN / "eval_progress.jsonl" + saved: dict[int, tuple[float, int]] = {} + if prog.exists(): + for l in prog.read_text(encoding="utf-8").splitlines(): + if l.strip(): + row = json.loads(l) + saved[row["i"]] = (row["der"], row["n"]) + print(f"[eval] resuming with {len(saved)} done", flush=True) + + missing = [i for i in range(len(examples)) if i not in saved] + n_new = 0 + with torch.no_grad(), prog.open("a", encoding="utf-8") as out: + for bi in range(0, len(missing), 8): + idxs = missing[bi : bi + 8] + batch = [examples[i][0] for i in idxs] + enc = tokenizer(batch, return_tensors="pt", padding=True, truncation=True, + max_length=512).to("cuda") + with torch.autocast("cuda", torch.bfloat16): + gen = model.generate(**enc, max_new_tokens=512, num_beams=4) + preds = tokenizer.batch_decode(gen, skip_special_tokens=True) + for i, pred in zip(idxs, preds): + der, n = seq2seq_der(pred.strip(), examples[i][1]) + saved[i] = (der, n) + out.write(json.dumps({"i": i, "der": der, "n": n}) + "\n") + n_new += len(idxs) + if n_new % 160 == 0: + out.flush() + checkpoints_volume.commit() + total = sum(d * n for d, n in saved.values()) + cnt = sum(n for _, n in saved.values()) + print(f"[eval] {len(saved)}/{len(examples)} DER={total/max(1,cnt):.4f} (committed)", flush=True) + checkpoints_volume.commit() + + total_wrong = sum(d * n for d, n in saved.values()) + total_positions = sum(n for _, n in saved.values()) + der = total_wrong / max(1, total_positions) + print(f"=== s47 morph aux DER (beam-4): {der:.4f} ({len(saved)} examples) ===", flush=True) + result = {"der": der, "n": len(saved), "checkpoint": ckpt, + "baseline_s45": S45_DER, "init_der": _choose_init()[1]} + (Path("/checkpoints") / RUN / "final_eval.json").write_text(json.dumps(result, indent=2), encoding="utf-8") + (Path("/checkpoints") / RUN / "EVAL_DONE").touch() + checkpoints_volume.commit() + return result + + +@app.local_entrypoint() +def main(): + train.remote() diff --git a/train_urdu_d1.py b/train_urdu_d1.py new file mode 100644 index 0000000..c209684 --- /dev/null +++ b/train_urdu_d1.py @@ -0,0 +1,235 @@ +"""Urdu d1 — ByT5-base with cross-lingual Arabic init on the Urdu corpus. + +Diagnosis: the shipped Urdu diacritizer (urdu_diacrit/run-001, 14.77% +CER) is a custom char encoder trained on cross-lingually machine- +labeled data. Arabic — same script family, same task shape — reaches +2.68 DER on ByT5-base. This run swaps in the proven architecture with +an Arabic-teacher init. + +Data (volume urdu-diacrit-datasets): +- urdu-diacrit/train.jsonl: 573,130 {"src","tgt"} Urdu pairs + (machine-labeled via our Arabic model + G2P back-projection — WEAK + labels; no gold Urdu corpus exists in our stack, so this is a + comparative eval against the 14.77 baseline, not an absolute one). + NOTE: urdu-diacritized/*.txt is diacritized ARABIC (fiqh text), + NOT Urdu — deliberately unused here. +- Dedup by src (G2P-derived corpora are repetitive), length caps. + +Init: rababa_arabic_byt5/run-005-context/best (ByT5-base) — shared +abjad prior instead of a cold start. + +Eval: greedy CER via editdistance on urdu-diacrit/test.jsonl plus +word-level exact match, identical protocol to the 14.77 number. + +Usage: + modal run --detach train_urdu_d1.py +""" + +from __future__ import annotations + +import json +import random +import re +from pathlib import Path + +import modal + +urdu_volume = modal.Volume.from_name("urdu-diacrit-datasets", create_if_missing=False) +checkpoints_volume = modal.Volume.from_name("rababa-checkpoints", create_if_missing=True) + +RUN = "rababa_urdu_byt5/run-001-d1" +INIT_RUN = "rababa_arabic_byt5/run-005-context" +N_VAL = 2_000 + +DIACRITICS_RE = re.compile("[ؐ-ًؚ-ٰٟۖ-ۜ۟-۪ۨ-ۭ]") + +image = ( + modal.Image.debian_slim(python_version="3.11") + .pip_install( + "torch==2.5.1", + "transformers==4.46.3", + "accelerate>=1.1.0", + "editdistance", + "tqdm", + ) + .workdir("/opt/rababa") + .env({"PYTORCH_CUDA_ALLOC_CONF": "expandable_segments:True"}) +) + +app = modal.App("rababa-urdu-d1", image=image) + + +def load_pairs(path: str) -> list[tuple[str, str]]: + seen: set[str] = set() + pairs: list[tuple[str, str]] = [] + with open(path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + row = json.loads(line) + except json.JSONDecodeError: + continue + src, tgt = row.get("src", ""), row.get("tgt", "") + if not src or not tgt: + continue + if src in seen: + continue + if len(src.encode("utf-8")) > 900 or len(tgt.encode("utf-8")) > 1200: + continue + if DIACRITICS_RE.search(src): + continue # input side must be undiacritized + if DIACRITICS_RE.sub("", tgt).replace(" ", "") != src.replace(" ", ""): + continue # misaligned pair: letters must match after stripping diacritics + seen.add(src) + pairs.append((src, tgt)) + return pairs + + +@app.function( + gpu="A100-80GB", + timeout=24 * 60 * 60, + volumes={"/urdu": urdu_volume, "/checkpoints": checkpoints_volume}, +) +def train() -> dict: + import torch + from torch.utils.data import Dataset + from transformers import ( + AutoModelForSeq2SeqLM, + AutoTokenizer, + DataCollatorForSeq2Seq, + Seq2SeqTrainer, + Seq2SeqTrainingArguments, + TrainerCallback, + ) + + urdu_volume.reload() + checkpoints_volume.reload() + + done_marker = Path("/checkpoints") / RUN / "EVAL_DONE" + if done_marker.exists(): + return {"run": RUN, "status": "already-done"} + + print("[data] loading urdu-diacrit pairs...", flush=True) + train_pairs = load_pairs("/urdu/urdu-diacrit/train.jsonl") + val_pairs = load_pairs("/urdu/urdu-diacrit/val.jsonl")[:N_VAL] + test_pairs = load_pairs("/urdu/urdu-diacrit/test.jsonl") + random.Random(42).shuffle(train_pairs) + print(f"[data] train={len(train_pairs)} val={len(val_pairs)} test={len(test_pairs)}", flush=True) + + init = str(Path("/checkpoints") / INIT_RUN / "best") + print(f"[init] {init}", flush=True) + tokenizer = AutoTokenizer.from_pretrained(init) + model = AutoModelForSeq2SeqLM.from_pretrained(init) + + class PairDataset(Dataset): + def __init__(self, rows: list[tuple[str, str]]) -> None: + self.rows = rows + + def __len__(self) -> int: + return len(self.rows) + + def __getitem__(self, idx: int) -> dict: + src, tgt = self.rows[idx] + inputs = tokenizer(src, truncation=True, max_length=1024) + labels = tokenizer(tgt, truncation=True, max_length=1280) + inputs["labels"] = labels["input_ids"] + return inputs + + class VolumeCommitCallback(TrainerCallback): + def on_save(self, args, state, control, **kwargs): + try: + checkpoints_volume.commit() + print(f"[volume] committed at step {state.global_step}", flush=True) + except Exception as e: + print(f"[volume] commit failed at step {state.global_step}: {e}", flush=True) + + args = Seq2SeqTrainingArguments( + output_dir="/checkpoints/" + RUN, + num_train_epochs=1, + per_device_train_batch_size=32, + gradient_accumulation_steps=4, + per_device_eval_batch_size=32, + bf16=True, + learning_rate=2e-5, + lr_scheduler_type="cosine", + warmup_steps=200, + weight_decay=0.01, + max_grad_norm=1.0, + seed=42, + save_strategy="steps", + save_steps=500, + eval_strategy="steps", + eval_steps=500, + save_total_limit=1, + logging_steps=100, + report_to=[], + predict_with_generate=False, + dataloader_num_workers=4, + ) + trainer = Seq2SeqTrainer( + model=model, + args=args, + train_dataset=PairDataset(train_pairs), + eval_dataset=PairDataset(val_pairs[:500]), + data_collator=DataCollatorForSeq2Seq(tokenizer=tokenizer, model=model, label_pad_token_id=-100), + callbacks=[VolumeCommitCallback()], + ) + import glob + + latest = sorted(glob.glob(f"/checkpoints/{RUN}/checkpoint-*"), key=lambda p: int(p.rsplit("-", 1)[1])) + resume = latest[-1] if latest else None + print(f"[resume] {resume}", flush=True) + trainer.train(resume_from_checkpoint=resume) + + best = Path("/checkpoints") / RUN / "best" + best.mkdir(parents=True, exist_ok=True) + trainer.save_model(str(best)) + tokenizer.save_pretrained(str(best)) + checkpoints_volume.commit() + + # ---- greedy CER + word accuracy on the full test split ---- + import editdistance + + device = next(trainer.model.parameters()).device + trainer.model.eval() + + preds: list[str] = [] + batch = 64 + with torch.no_grad(): + for i in range(0, len(test_pairs), batch): + chunk = test_pairs[i : i + batch] + enc = tokenizer( + [s for s, _ in chunk], return_tensors="pt", padding=True, truncation=True, max_length=1024 + ).to(device) + with torch.autocast("cuda", torch.bfloat16): + gen = trainer.model.generate(**enc, max_new_tokens=1280, num_beams=1) + preds.extend(tokenizer.batch_decode(gen, skip_special_tokens=True)) + if (i // batch) % 20 == 0: + print(f"[gen] {i + len(chunk)}/{len(test_pairs)}", flush=True) + + total_ed = 0 + total_len = 0 + exact = 0 + for (src, tgt), pred in zip(test_pairs, preds): + total_ed += editdistance.eval(pred, tgt) + total_len += len(tgt) + if pred.strip() == tgt.strip(): + exact += 1 + cer = total_ed / max(1, total_len) + word_acc = exact / len(test_pairs) + print(f"[eval] CER={cer:.4f} word_acc={word_acc:.4f} n={len(test_pairs)}", flush=True) + + (Path("/checkpoints") / RUN / "eval.txt").write_text( + f"CER={cer:.4f}\nword_acc={word_acc:.4f}\nn={len(test_pairs)}\n", encoding="utf-8") + checkpoints_volume.commit() + + done_marker.touch() + checkpoints_volume.commit() + return {"run": RUN, "cer": cer, "word_acc": word_acc} + + +@app.local_entrypoint() +def main(): + train.remote() diff --git a/train_urdu_d2.py b/train_urdu_d2.py new file mode 100644 index 0000000..f7f6b68 --- /dev/null +++ b/train_urdu_d2.py @@ -0,0 +1,235 @@ +"""Urdu d2 — second epoch at low LR from d1 (beam-4 was flat, TODO 06 rule). + +Diagnosis: the shipped Urdu diacritizer (urdu_diacrit/run-001, 14.77% +CER) is a custom char encoder trained on cross-lingually machine- +labeled data. Arabic — same script family, same task shape — reaches +2.68 DER on ByT5-base. This run swaps in the proven architecture with +an Arabic-teacher init. + +Data (volume urdu-diacrit-datasets): +- urdu-diacrit/train.jsonl: 573,130 {"src","tgt"} Urdu pairs + (machine-labeled via our Arabic model + G2P back-projection — WEAK + labels; no gold Urdu corpus exists in our stack, so this is a + comparative eval against the 14.77 baseline, not an absolute one). + NOTE: urdu-diacritized/*.txt is diacritized ARABIC (fiqh text), + NOT Urdu — deliberately unused here. +- Dedup by src (G2P-derived corpora are repetitive), length caps. + +Init: rababa_arabic_byt5/run-005-context/best (ByT5-base) — shared +abjad prior instead of a cold start. + +Eval: greedy CER via editdistance on urdu-diacrit/test.jsonl plus +word-level exact match, identical protocol to the 14.77 number. + +Usage: + modal run --detach train_urdu_d1.py +""" + +from __future__ import annotations + +import json +import random +import re +from pathlib import Path + +import modal + +urdu_volume = modal.Volume.from_name("urdu-diacrit-datasets", create_if_missing=False) +checkpoints_volume = modal.Volume.from_name("rababa-checkpoints", create_if_missing=True) + +RUN = "rababa_urdu_byt5/run-002-d2" +INIT_RUN = "rababa_urdu_byt5/run-001-d1" +N_VAL = 2_000 + +DIACRITICS_RE = re.compile("[ؐ-ًؚ-ٰٟۖ-ۜ۟-۪ۨ-ۭ]") + +image = ( + modal.Image.debian_slim(python_version="3.11") + .pip_install( + "torch==2.5.1", + "transformers==4.46.3", + "accelerate>=1.1.0", + "editdistance", + "tqdm", + ) + .workdir("/opt/rababa") + .env({"PYTORCH_CUDA_ALLOC_CONF": "expandable_segments:True"}) +) + +app = modal.App("rababa-urdu-d2", image=image) + + +def load_pairs(path: str) -> list[tuple[str, str]]: + seen: set[str] = set() + pairs: list[tuple[str, str]] = [] + with open(path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + row = json.loads(line) + except json.JSONDecodeError: + continue + src, tgt = row.get("src", ""), row.get("tgt", "") + if not src or not tgt: + continue + if src in seen: + continue + if len(src.encode("utf-8")) > 900 or len(tgt.encode("utf-8")) > 1200: + continue + if DIACRITICS_RE.search(src): + continue # input side must be undiacritized + if DIACRITICS_RE.sub("", tgt).replace(" ", "") != src.replace(" ", ""): + continue # misaligned pair: letters must match after stripping diacritics + seen.add(src) + pairs.append((src, tgt)) + return pairs + + +@app.function( + gpu="A100-80GB", + timeout=24 * 60 * 60, + volumes={"/urdu": urdu_volume, "/checkpoints": checkpoints_volume}, +) +def train() -> dict: + import torch + from torch.utils.data import Dataset + from transformers import ( + AutoModelForSeq2SeqLM, + AutoTokenizer, + DataCollatorForSeq2Seq, + Seq2SeqTrainer, + Seq2SeqTrainingArguments, + TrainerCallback, + ) + + urdu_volume.reload() + checkpoints_volume.reload() + + done_marker = Path("/checkpoints") / RUN / "EVAL_DONE" + if done_marker.exists(): + return {"run": RUN, "status": "already-done"} + + print("[data] loading urdu-diacrit pairs...", flush=True) + train_pairs = load_pairs("/urdu/urdu-diacrit/train.jsonl") + val_pairs = load_pairs("/urdu/urdu-diacrit/val.jsonl")[:N_VAL] + test_pairs = load_pairs("/urdu/urdu-diacrit/test.jsonl") + random.Random(42).shuffle(train_pairs) + print(f"[data] train={len(train_pairs)} val={len(val_pairs)} test={len(test_pairs)}", flush=True) + + init = str(Path("/checkpoints") / INIT_RUN / "best") + print(f"[init] {init}", flush=True) + tokenizer = AutoTokenizer.from_pretrained(init) + model = AutoModelForSeq2SeqLM.from_pretrained(init) + + class PairDataset(Dataset): + def __init__(self, rows: list[tuple[str, str]]) -> None: + self.rows = rows + + def __len__(self) -> int: + return len(self.rows) + + def __getitem__(self, idx: int) -> dict: + src, tgt = self.rows[idx] + inputs = tokenizer(src, truncation=True, max_length=1024) + labels = tokenizer(tgt, truncation=True, max_length=1280) + inputs["labels"] = labels["input_ids"] + return inputs + + class VolumeCommitCallback(TrainerCallback): + def on_save(self, args, state, control, **kwargs): + try: + checkpoints_volume.commit() + print(f"[volume] committed at step {state.global_step}", flush=True) + except Exception as e: + print(f"[volume] commit failed at step {state.global_step}: {e}", flush=True) + + args = Seq2SeqTrainingArguments( + output_dir="/checkpoints/" + RUN, + num_train_epochs=1, + per_device_train_batch_size=32, + gradient_accumulation_steps=4, + per_device_eval_batch_size=32, + bf16=True, + learning_rate=1e-5, + lr_scheduler_type="cosine", + warmup_steps=200, + weight_decay=0.01, + max_grad_norm=1.0, + seed=42, + save_strategy="steps", + save_steps=500, + eval_strategy="steps", + eval_steps=500, + save_total_limit=1, + logging_steps=100, + report_to=[], + predict_with_generate=False, + dataloader_num_workers=4, + ) + trainer = Seq2SeqTrainer( + model=model, + args=args, + train_dataset=PairDataset(train_pairs), + eval_dataset=PairDataset(val_pairs[:500]), + data_collator=DataCollatorForSeq2Seq(tokenizer=tokenizer, model=model, label_pad_token_id=-100), + callbacks=[VolumeCommitCallback()], + ) + import glob + + latest = sorted(glob.glob(f"/checkpoints/{RUN}/checkpoint-*"), key=lambda p: int(p.rsplit("-", 1)[1])) + resume = latest[-1] if latest else None + print(f"[resume] {resume}", flush=True) + trainer.train(resume_from_checkpoint=resume) + + best = Path("/checkpoints") / RUN / "best" + best.mkdir(parents=True, exist_ok=True) + trainer.save_model(str(best)) + tokenizer.save_pretrained(str(best)) + checkpoints_volume.commit() + + # ---- greedy CER + word accuracy on the full test split ---- + import editdistance + + device = next(trainer.model.parameters()).device + trainer.model.eval() + + preds: list[str] = [] + batch = 64 + with torch.no_grad(): + for i in range(0, len(test_pairs), batch): + chunk = test_pairs[i : i + batch] + enc = tokenizer( + [s for s, _ in chunk], return_tensors="pt", padding=True, truncation=True, max_length=1024 + ).to(device) + with torch.autocast("cuda", torch.bfloat16): + gen = trainer.model.generate(**enc, max_new_tokens=1280, num_beams=1) + preds.extend(tokenizer.batch_decode(gen, skip_special_tokens=True)) + if (i // batch) % 20 == 0: + print(f"[gen] {i + len(chunk)}/{len(test_pairs)}", flush=True) + + total_ed = 0 + total_len = 0 + exact = 0 + for (src, tgt), pred in zip(test_pairs, preds): + total_ed += editdistance.eval(pred, tgt) + total_len += len(tgt) + if pred.strip() == tgt.strip(): + exact += 1 + cer = total_ed / max(1, total_len) + word_acc = exact / len(test_pairs) + print(f"[eval] CER={cer:.4f} word_acc={word_acc:.4f} n={len(test_pairs)}", flush=True) + + (Path("/checkpoints") / RUN / "eval.txt").write_text( + f"CER={cer:.4f}\nword_acc={word_acc:.4f}\nn={len(test_pairs)}\n", encoding="utf-8") + checkpoints_volume.commit() + + done_marker.touch() + checkpoints_volume.commit() + return {"run": RUN, "cer": cer, "word_acc": word_acc} + + +@app.local_entrypoint() +def main(): + train.remote()