-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathmodal_app.py
More file actions
1481 lines (1296 loc) · 58.4 KB
/
Copy pathmodal_app.py
File metadata and controls
1481 lines (1296 loc) · 58.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Modal app for rababa training + export + evaluation (Arabic + Hebrew).
Both languages go through the same `train_supervised`, `pretrain_mlm`,
`export_student_onnx` functions. Task dispatch (dataset + collate) lives
in `rababa.tasks`, model dispatch (single vs multi head) in
`rababa.models.base.build_model`.
Usage:
# First-time auth:
modal token new
# Test connection + dataset fetch:
modal run modal_app.py::fetch_data --task rababa_arabic
modal run modal_app.py::fetch_data --task rababa_hebrew
# MLM pretrain (A100, ~6h):
modal run modal_app.py::pretrain --task rababa_arabic_pretrain
modal run modal_app.py::pretrain --task rababa_hebrew_pretrain
# Train (A100, ~3h), optionally with pretrained encoder init:
modal run modal_app.py::train --task rababa_arabic \\
--init-from-pretrain /checkpoints/rababa_arabic_pretrain/run-001/best.pt
modal run modal_app.py::train --task rababa_hebrew
# Export to ONNX + int8 (A10G, ~30m):
modal run modal_app.py::export_onnx --task rababa_arabic --version v0.1.0
modal run modal_app.py::export_onnx --task rababa_hebrew --version v0.1.0
# Evaluate (A10G):
modal run modal_app.py::evaluate --task rababa_arabic
Volumes:
datasets — fetched Tashkeela / Nakdimon corpora (idempotent).
checkpoints — per-epoch + best.pt model weights.
models — final ONNX exports.
"""
from __future__ import annotations
import modal
from pathlib import Path
APP_NAME = "rababa"
PYTHON_VERSION = "3.11"
datasets_volume = modal.Volume.from_name(f"{APP_NAME}-datasets", create_if_missing=True)
checkpoints_volume = modal.Volume.from_name(f"{APP_NAME}-checkpoints", create_if_missing=True)
models_volume = modal.Volume.from_name(f"{APP_NAME}-models", create_if_missing=True)
image = (
modal.Image.debian_slim(python_version=PYTHON_VERSION)
.apt_install("build-essential", "git")
.pip_install(
"torch>=2.4,<3",
"numpy>=1.26,<3",
"omegaconf>=2.3,<3",
"onnx>=1.17",
"onnxscript>=0.1",
"onnxruntime>=1.20",
"tqdm>=4.66",
"pyyaml>=6.0",
"wandb>=0.18",
"transformers>=4.46",
"accelerate>=1.1",
"datasets>=3.0",
"litert-torch>=0.9",
"ai-edge-quantizer>=0.8",
)
.add_local_dir("src", "/opt/rababa/src", copy=True)
.add_local_dir("configs", "/opt/rababa/configs", copy=True)
.add_local_dir("test-datasets", "/opt/rababa/test-datasets", copy=True)
.add_local_file("pyproject.toml", "/opt/rababa/pyproject.toml", copy=True)
.workdir("/opt/rababa")
.env({"PYTHONPATH": "/opt/rababa/src"})
# ---- Data repos baked in at build time (single source of truth = git) ----
# Each clone is --depth 1 to keep image size minimal. To update the corpus,
# bump the commit SHA via a no-op commit + push to the source repo, which
# invalidates Modal's image cache via the .add_local_dir hash on this file.
.run_commands(
"git clone --depth 1 https://github.com/interscript/rababa-tashkeela.git /opt/rababa/data/tashkeela",
"git clone --depth 1 https://github.com/interscript/rababa-tashkeela-full.git /opt/rababa/data/tashkeela-full",
"git clone --depth 1 https://github.com/interscript/rababa-arwiki.git /opt/rababa/data/arwiki",
"git clone --depth 1 https://github.com/interscript/rababa-sefaria.git /opt/rababa/data/sefaria",
"git clone --depth 1 https://github.com/interscript/rababa-hewiki.git /opt/rababa/data/hewiki",
"git clone --depth 1 https://github.com/interscript/rababa-hebrew-distilled.git /opt/rababa/data/hebrew-distilled",
# EMNLP 2025 QCRI advancing-arabic-diacritization — refined datasets + SadeedDiac-25 benchmark.
"git clone --depth 1 https://github.com/qcri/advancing-arabic-diacritization.git /opt/rababa/data/qcri-diac",
)
)
app = modal.App(name=APP_NAME, image=image)
@app.function(
gpu="A10G",
timeout=60 * 60,
volumes={"/datasets": datasets_volume},
secrets=[modal.Secret.from_name("huggingface")],
)
def fetch_data(task: str) -> dict[str, object]:
"""Verify data is present and assemble combined Hebrew corpus if needed.
Data repos are baked into the Modal image at build time (git clone in
the image recipe). This function just verifies presence and, for Hebrew
tasks, concatenates Sefaria + distilled into a combined train/val/test.
The /datasets volume mount is kept for backwards compatibility with
checkpoint/model volumes but is no longer the source of truth — git is.
"""
import hashlib
from pathlib import Path
summary: dict[str, object] = {"task": task, "files": {}}
if task in {"rababa_arabic", "rababa_arabic_pretrain"}:
# Tashkeela is shipped with the repo at /opt/rababa/test-datasets/tashkeela.
root = Path("/opt/rababa/test-datasets/tashkeela")
elif task in {"rababa_arabic_pro", "rababa_arabic_pro_pretrain", "rababa_arabic_v2"}:
# Merged corpus: GPLv2 Tashkeela-full + Sadeed HF + QCRI EMNLP 2025.
# Built on first call, cached on the /datasets volume for re-use.
root = Path("/datasets/arabic-combined")
if not (root / "train.txt").is_file():
print(f"[fetch_data] building combined Arabic corpus at {root} ...")
_build_arabic_combined_corpus(root)
else:
print(f"[fetch_data] combined Arabic corpus already present at {root}")
elif task in {"rababa_hebrew", "rababa_hebrew_pretrain", "rababa_hebrew_seq2seq", "rababa_hebrew_byt5", "rababa_hebrew_byt5_base", "rababa_hebrew_byt5_freeze", "rababa_hebrew_byt5_ft", "rababa_hebrew_byt5_v2"}:
# Assemble combined Hebrew corpus from Sefaria (Biblical) + distilled (Modern).
sefaria = Path("/opt/rababa/data/sefaria")
distilled = Path("/opt/rababa/data/hebrew-distilled")
combined = Path("/opt/rababa/data/nakdimon-combined")
combined.mkdir(parents=True, exist_ok=True)
for split in ("train", "val", "test"):
parts = []
for src_repo, subdir_prefix in (
(sefaria, "sefaria"),
(distilled, "hebrew_distilled"),
):
# Try both naming conventions.
for name in (f"{split}.txt", f"{subdir_prefix}_{split}/{split}.txt"):
p = src_repo / name
if p.is_file():
parts.append(p.read_text(encoding="utf-8"))
break
(combined / f"{split}.txt").write_text("".join(parts), encoding="utf-8")
# For v2: also add DictaBERT-distilled Wikipedia data (10K modern Hebrew lines)
if task == "rababa_hebrew_byt5_v2":
datasets_volume.reload()
dictabert_distilled = Path("/datasets/hebrew-dictabert-distilled/train.txt")
if dictabert_distilled.is_file():
extra = dictabert_distilled.read_text(encoding="utf-8")
with (combined / "train.txt").open("a", encoding="utf-8") as f:
f.write(extra)
print(f"[fetch] added {len(extra.splitlines())} DictaBERT-distilled lines", flush=True)
root = combined
else:
raise ValueError(f"fetch_data for {task!r} not implemented")
for split in ("train", "val", "test"):
path = root / f"{split}.txt"
if not path.is_file():
# Also accept sharded layout for verification.
shards = sorted(root.glob(f"{split}-*.txt"))
if not shards:
raise FileNotFoundError(f"missing {split}: {path}")
path = shards[0]
line_count = sum(
sum(1 for _ in p.open(encoding="utf-8")) for p in shards
)
sha = "sharded"
else:
sha = hashlib.sha256(path.read_bytes()).hexdigest()
line_count = sum(1 for _ in path.open(encoding="utf-8"))
summary["files"][split] = {"path": str(path), "sha256": sha, "lines": line_count}
# Persist any volume writes (sadeed-hf download, arabic-combined merge).
datasets_volume.commit()
return summary
def _iter_lines(path: Path):
"""Yield stripped non-empty lines from a file."""
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line:
yield line
def _iter_corpus_files(root: Path, split: str) -> list[Path]:
"""Find files for a split under root, handling sharded + legacy layouts.
Looks for:
- {split}-*.txt at root (sharded)
- {split}.txt at root (legacy)
- root/{split}/*.txt (subdir)
- root/tashkeela_full_{split}/{split}-*.txt (our full-corpus layout)
- root/*_{split}/{split}-*.txt (generic subdir prefix)
"""
shards = sorted(root.glob(f"{split}-*.txt"))
if shards:
return shards
legacy = root / f"{split}.txt"
if legacy.is_file():
return [legacy]
# Our tashkeela-full layout: tashkeela_full_train/train-001.txt
for sub in (
root / f"tashkeela_full_{split}",
root / f"tashkeela_{split}",
root / split,
):
if sub.is_dir():
found = sorted(sub.glob(f"{split}-*.txt")) or sorted(sub.glob("*.txt"))
if found:
return found
# Any subdir whose name contains the split keyword.
for sub in sorted(root.iterdir()) if root.is_dir() else []:
if sub.is_dir() and split in sub.name.lower():
found = sorted(sub.glob(f"{split}-*.txt")) or sorted(sub.glob("*.txt"))
if found:
return found
return []
def _maybe_download_sadeed_hf(dest_dir: Path) -> bool:
"""Download Misraj/Sadeed_Tashkeela from HuggingFace if HF_TOKEN is set.
Returns True if the dataset was downloaded and written to
dest_dir/{train,val,test}.txt; False if HF_TOKEN is unset or the
download failed (we fall back to Tashkeela + QCRI only).
Output format: one diacritized Arabic line per row (the `output`
field of the HF dataset). Lines are deduplicated within each split.
"""
import os
token = os.environ.get("HF_TOKEN")
if not token:
print("[sadeed-hf] HF_TOKEN not set — skipping Sadeed HF download")
return False
try:
from datasets import load_dataset
print("[sadeed-hf] downloading Misraj/Sadeed_Tashkeela ...")
ds = load_dataset("Misraj/Sadeed_Tashkeela", token=token)
except Exception as e:
print(f"[sadeed-hf] download failed: {e!r} — skipping")
return False
dest_dir.mkdir(parents=True, exist_ok=True)
# Sadeed_Tashkeela has train + test splits. Carve 10% of train as val.
splits_present = list(ds.keys())
print(f"[sadeed-hf] splits present: {splits_present}")
train_ds = ds["train"] if "train" in splits_present else ds[splits_present[0]]
test_ds = ds.get("test") or ds.get(splits_present[-1])
train_val_split = train_ds.train_test_split(test_size=0.1, seed=42)
train_ds, val_ds = train_val_split["train"], train_val_split["test"]
for name, subset in (("train", train_ds), ("val", val_ds), ("test", test_ds)):
out = dest_dir / f"{name}.txt"
seen: set[str] = set()
with out.open("w", encoding="utf-8") as f:
for ex in subset:
line = (ex.get("output") or "").strip()
if not line or line in seen:
continue
seen.add(line)
f.write(line + "\n")
print(f"[sadeed-hf] {name}.txt: {len(seen):,} unique lines")
return True
def _find_qcri_files(root: Path) -> dict[str, Path]:
"""Locate train/val/test files in the qcri/advancing-arabic-diacritization repo.
The repo layout is not documented up-front; we search broadly. Each
split is the first .txt file whose path contains the split keyword.
"""
out: dict[str, Path] = {}
all_txt = sorted(root.rglob("*.txt"))
for split in ("train", "val", "test"):
for p in all_txt:
path_str = str(p).lower()
# Acceptable: 'train.txt', 'train-001.txt', 'train_split.txt',
# subdir named train/anything.txt
if split in path_str or f"/{split}/" in path_str or f"-{split}" in path_str:
# Avoid train/test mixups: ensure the split keyword is the
# strongest signal in the path.
if split == "val" and "val" not in p.stem.lower():
continue
out[split] = p
break
return out
def _build_arabic_combined_corpus(dest_root: Path) -> None:
"""Merge GPLv2 Tashkeela + Sadeed HF + QCRI EMNLP 2025 into dest_root.
Output: dest_root/{train,val,test}.txt — one diacritized Arabic line
per row, deduplicated across all sources.
Sources:
1. /opt/rababa/data/tashkeela-full (GPLv2, image-baked)
2. /datasets/sadeed-hf/ (HF, gated, downloaded if HF_TOKEN set)
3. /opt/rababa/data/qcri-diac/ (CC BY-NC-SA, image-baked)
Graceful fallback: missing sources are skipped with a warning.
"""
dest_root.mkdir(parents=True, exist_ok=True)
# 1. Sadeed HF — gated, needs token. Best-effort; skipped if no token.
sadeed_root = Path("/datasets/sadeed-hf")
if not (sadeed_root / "train.txt").is_file():
_maybe_download_sadeed_hf(sadeed_root)
# 2. QCRI EMNLP 2025 — image-baked.
qcri_root = Path("/opt/rababa/data/qcri-diac")
qcri_files = _find_qcri_files(qcri_root) if qcri_root.is_dir() else {}
if not qcri_files:
print(f"[combined] WARNING: no QCRI files under {qcri_root}")
else:
print(f"[combined] QCRI files: {qcri_files}")
# 3. GPLv2 Tashkeela-full — image-baked. Primary source.
tashkeela_full = Path("/opt/rababa/data/tashkeela-full")
if not tashkeela_full.is_dir():
raise RuntimeError(
f"tashkeela-full missing at {tashkeela_full} — image recipe is wrong"
)
sources: list[tuple[str, Path]] = [("tashkeela-full", tashkeela_full)]
if (sadeed_root / "train.txt").is_file():
sources.append(("sadeed-hf", sadeed_root))
if qcri_files:
# Synthetic root whose _iter_corpus_files returns the explicit paths.
# Easier: handle QCRI separately below.
pass
for split in ("train", "val", "test"):
seen: set[str] = set()
out_path = dest_root / f"{split}.txt"
with out_path.open("w", encoding="utf-8") as f:
# GPLv2 Tashkeela-full (sharded)
for src_name, src_root in sources:
files = _iter_corpus_files(src_root, split)
if not files:
print(f"[combined] {split}/{src_name}: no files")
continue
count = 0
for fp in files:
for line in _iter_lines(fp):
if line not in seen:
seen.add(line)
f.write(line + "\n")
count += 1
print(f"[combined] {split}/{src_name}: +{count:,} unique lines")
# QCRI (custom find)
if qcri_files and split in qcri_files:
count = 0
for line in _iter_lines(qcri_files[split]):
if line not in seen:
seen.add(line)
f.write(line + "\n")
count += 1
print(f"[combined] {split}/qcri: +{count:,} unique lines")
print(f"[combined] {split}.txt: {len(seen):,} total unique lines")
def _fetch_nakdimon_corpus(dest: Path) -> None:
"""Clone Nakdimon repo and assemble a train/val/test split from the
open test corpus.
The Nakdimon *training* corpus is Dicta-licensed and not redistributable,
so we use the open test corpus (`tests/new/expected/`) — 110 files
across 10 categories (books, wiki, verdicts, etc.). We split 80/10/10
into train/val/test by file. This is methodologically impure (training
on what was meant to be a test set) but produces a usable v0.1.0
preview. v0.5.0 should switch to a proper Hebrew corpus
(Wikisource nikud, Open Scriptures Hebrew).
"""
import random
import shutil
import subprocess
import tempfile
nakdimon_url = "https://github.com/elazarg/nakdimon.git"
with tempfile.TemporaryDirectory() as tmp:
clone_dir = Path(tmp) / "nakdimon"
subprocess.run(
["git", "clone", "--depth", "1", nakdimon_url, str(clone_dir)],
check=True,
)
# Collect all test files (pointed Hebrew, one line per row).
test_root = clone_dir / "tests" / "new" / "expected"
all_files: list[Path] = []
for category_dir in sorted(test_root.iterdir()):
if category_dir.is_dir():
all_files.extend(sorted(category_dir.glob("*.txt")))
if not all_files:
raise RuntimeError(
f"No Hebrew test files found under {test_root}. "
"Nakdimon repo layout may have changed."
)
# Deterministic 80/10/10 split by file.
rng = random.Random(42)
rng.shuffle(all_files)
n = len(all_files)
n_train = int(n * 0.8)
n_val = int(n * 0.1)
train_files = all_files[:n_train]
val_files = all_files[n_train : n_train + n_val]
test_files = all_files[n_train + n_val :]
dest.mkdir(parents=True, exist_ok=True)
for split, files in (
("train", train_files),
("val", val_files),
("test", test_files),
):
out = dest / f"{split}.txt"
with out.open("w", encoding="utf-8") as f:
for src in files:
f.write(src.read_text(encoding="utf-8"))
line_count = sum(1 for _ in out.open(encoding="utf-8"))
print(f" {split}.txt: {len(files)} files, {line_count} lines")
@app.function(
gpu="A100",
timeout=24 * 60 * 60,
volumes={"/checkpoints": checkpoints_volume, "/datasets": datasets_volume},
)
def train(
task: str,
epochs: int | None = None,
init_from_pretrain: str | None = None,
fresh: bool = False,
) -> dict[str, object]:
"""Run Tier 1 supervised training. Returns path to best checkpoint.
Dispatches dataset/collate via `rababa.tasks`; model via cfg.model.arch.
Works for rababa_arabic (single-head) and rababa_hebrew (multi-head).
"""
import torch
datasets_volume.reload()
from rababa.config import load_task_config, to_dict
from rababa.tasks import build_supervised_loaders
from rababa.training import train_supervised
cfg = load_task_config(task)
if epochs is not None:
cfg.train.epochs = epochs
if init_from_pretrain is not None:
cfg.train.init_from_pretrain = init_from_pretrain
device = torch.device("cuda")
ckpt_root = Path("/checkpoints") / task / "run-001"
metrics_path = Path("/checkpoints") / "metrics" / f"metrics-{task}-train.jsonl"
metrics_path.parent.mkdir(parents=True, exist_ok=True)
if fresh:
import shutil
if ckpt_root.is_dir():
shutil.rmtree(ckpt_root)
print(f"[fresh] removed existing {ckpt_root}")
if metrics_path.is_file():
metrics_path.unlink()
print(f"[fresh] removed existing {metrics_path}")
ckpt_root.mkdir(parents=True, exist_ok=True)
arch = to_dict(cfg).get("model", {}).get("arch", "")
# ByT5 path: use HuggingFace Seq2SeqTrainer (pretrained backbone).
if arch == "byt5_hebrew":
from rababa.models.byt5_hebrew import train_byt5
from rababa.datasets import _find_nakdimon_root
from pathlib import Path as _P
data_root = _find_nakdimon_root()
# For v2: append DictaBERT-distilled data to train corpus
if task == "rababa_hebrew_byt5_v2":
datasets_volume.reload()
extra = _P("/datasets/hebrew-dictabert-distilled/train.txt")
if extra.is_file():
train_path = _P(data_root) / "train.txt"
with train_path.open("a", encoding="utf-8") as f:
f.write(extra.read_text(encoding="utf-8"))
print(f"[v2] appended DictaBERT-distilled data to train corpus", flush=True)
best_path = train_byt5(
cfg=to_dict(cfg),
train_path=_P(data_root) / "train.txt",
val_path=_P(data_root) / "val.txt",
ckpt_root=ckpt_root,
metrics_path=metrics_path,
)
checkpoints_volume.commit()
datasets_volume.commit()
return {"checkpoint_root": str(ckpt_root), "best": best_path}
train_loader, val_loader = build_supervised_loaders(cfg)
train_supervised(
train_loader=train_loader,
val_loader=val_loader,
cfg=to_dict(cfg),
device=device,
ckpt_root=ckpt_root,
metrics_path=metrics_path,
)
checkpoints_volume.commit()
datasets_volume.commit()
return {"checkpoint_root": str(ckpt_root), "best": str(ckpt_root / "best.pt")}
@app.function(
gpu="A100",
timeout=24 * 60 * 60,
volumes={"/checkpoints": checkpoints_volume, "/datasets": datasets_volume},
)
def pretrain(task: str, epochs: int | None = None) -> dict[str, object]:
"""Run MLM pretraining. Returns path to best encoder checkpoint."""
import torch
# Explicit reload: ensure we see files written by fetch_data's volume commit.
# Without this, the container's snapshot may be stale relative to the
# orchestrator's commit, leading to "corpus not found" failures.
datasets_volume.reload()
from rababa.config import load_task_config, to_dict
from rababa.tasks import build_mlm_loaders
cfg = load_task_config(task)
if epochs is not None:
cfg.train.epochs = epochs
train_loader, val_loader = build_mlm_loaders(cfg)
# Dispatch on cfg.train.pretrain_method (default: mlm).
method = cfg.train.get("pretrain_method", "mlm") if hasattr(cfg.train, "get") else "mlm"
device = torch.device("cuda")
ckpt_root = Path("/checkpoints") / task / "run-001"
metrics_path = Path("/checkpoints") / "metrics" / f"metrics-{task}-pretrain.jsonl"
metrics_path.parent.mkdir(parents=True, exist_ok=True)
if method == "electra":
from rababa.training.electra import pretrain_electra
pretrain_electra(
train_loader=train_loader,
val_loader=val_loader,
cfg=to_dict(cfg),
device=device,
ckpt_root=ckpt_root,
metrics_path=metrics_path,
)
elif method == "mtp":
from rababa.training.pretrain_mtp import pretrain_mtp
pretrain_mtp(
train_loader=train_loader,
val_loader=val_loader,
cfg=to_dict(cfg),
device=device,
ckpt_root=ckpt_root,
metrics_path=metrics_path,
)
else:
from rababa.training import pretrain_mlm
pretrain_mlm(
train_loader=train_loader,
val_loader=val_loader,
cfg=to_dict(cfg),
device=device,
ckpt_root=ckpt_root,
metrics_path=metrics_path,
)
checkpoints_volume.commit()
return {
"checkpoint_root": str(ckpt_root),
"best": str(ckpt_root / "best.pt"),
"pretrain_method": method,
}
@app.function(
gpu="A10G",
timeout=30 * 60,
volumes={"/checkpoints": checkpoints_volume, "/models": models_volume},
)
def export_onnx(task: str, version: str, checkpoint: str | None = None) -> dict[str, object]:
"""Export checkpoint → ONNX fp32 + int8. Handles single- and multi-head."""
from rababa.config import load_task_config, to_dict
from rababa.export import export_student_onnx, quantize_dynamic_int8
cfg = load_task_config(task)
cfg_dict = to_dict(cfg) # type: ignore[arg-type]
batch_size = int(cfg.model.get("batch_size", 32))
max_len = int(cfg.model.get("max_len", 200))
if checkpoint is None:
checkpoint = str(Path("/checkpoints") / task / "run-001" / "best.pt")
out_dir = Path("/models") / task
out_dir.mkdir(parents=True, exist_ok=True)
fp32_path = out_dir / f"{task}-{version}-fp32.onnx"
q8_path = out_dir / f"{task}-{version}-q8.onnx"
export_student_onnx(Path(checkpoint), cfg_dict, fp32_path, batch_size, max_len)
quantize_dynamic_int8(fp32_path, q8_path)
models_volume.commit()
return {"fp32": str(fp32_path), "q8": str(q8_path)}
@app.function(
gpu="A10G",
timeout=30 * 60,
volumes={"/checkpoints": checkpoints_volume, "/models": models_volume},
)
def export_tflite(task: str, version: str, checkpoint: str | None = None) -> dict[str, object]:
"""Export checkpoint → TFLite (.tflite) for LiteRT.js browser runtime.
Same model architecture, same I/O contract — different serialization
format. fp32 only for v0.1.0; int8 (PT2E) is a follow-up.
"""
from rababa.config import load_task_config, to_dict
from rababa.export_tflite import export_student_tflite
cfg = load_task_config(task)
cfg_dict = to_dict(cfg) # type: ignore[arg-type]
batch_size = int(cfg.model.get("batch_size", 32))
max_len = int(cfg.model.get("max_len", 200))
if checkpoint is None:
checkpoint = str(Path("/checkpoints") / task / "run-001" / "best.pt")
out_dir = Path("/models") / task
out_dir.mkdir(parents=True, exist_ok=True)
tflite_path = out_dir / f"{task}-{version}-fp32.tflite"
export_student_tflite(Path(checkpoint), cfg_dict, tflite_path, batch_size, max_len)
models_volume.commit()
return {"tflite": str(tflite_path)}
@app.function(
gpu="A100",
timeout=6 * 60 * 60,
volumes={"/checkpoints": checkpoints_volume, "/datasets": datasets_volume},
)
def _train_seed(task: str, seed: int) -> str:
"""Train one model with a specific seed. Used by the multi_seed stage.
Returns the path to the trained checkpoint's best.pt.
"""
from pathlib import Path
from rababa.config import load_task_config, to_dict
from rababa.tasks import build_supervised_loaders
from rababa.training import train_supervised
from rababa.training.multi_seed import _set_seed
import torch
datasets_volume.reload()
_set_seed(seed)
cfg = load_task_config(task)
train_loader, val_loader = build_supervised_loaders(cfg)
device = torch.device("cuda")
seed_root = Path("/checkpoints") / task / f"seed-{seed:03d}" / "run-001"
seed_root.mkdir(parents=True, exist_ok=True)
metrics_path = Path("/checkpoints") / "metrics" / f"metrics-{task}-seed-{seed:03d}.jsonl"
train_supervised(
train_loader=train_loader,
val_loader=val_loader,
cfg=to_dict(cfg),
device=device,
ckpt_root=seed_root,
metrics_path=metrics_path,
)
checkpoints_volume.commit()
return str(seed_root / "best.pt")
@app.function(
gpu="A10G",
timeout=30 * 60,
volumes={"/checkpoints": checkpoints_volume, "/datasets": datasets_volume},
)
def evaluate(task: str, checkpoint: str | None = None) -> dict[str, object]:
"""Compute per-head DER + aggregate DER on test split.
Uses the unified Diacritizer protocol — same code path for Arabic (1 head)
and Hebrew (3 heads).
"""
import torch
from rababa.config import load_task_config, to_dict
from rababa.evaluate import diacritization_error_rate, per_example_accuracy
from rababa.models.base import build_model
from rababa.tasks import build_test_loader
cfg = load_task_config(task)
cfg_dict = to_dict(cfg) # type: ignore[arg-type]
device = torch.device("cuda")
if checkpoint is None:
checkpoint = str(Path("/checkpoints") / task / "run-001" / "best.pt")
arch = cfg_dict.get("model", {}).get("arch", "")
# ByT5 path: load from HuggingFace checkpoint, use generate() + DER.
if arch == "byt5_hebrew":
from transformers import T5ForConditionalGeneration, ByT5Tokenizer
from rababa.models.byt5_hebrew import evaluate_byt5
from rababa.datasets import _find_nakdimon_root
from pathlib import Path as _P
ckpt_dir = checkpoint
if not _P(ckpt_dir).is_dir():
ckpt_dir = str(_P(checkpoint).parent / "best")
model = T5ForConditionalGeneration.from_pretrained(ckpt_dir).to(device)
tokenizer = ByT5Tokenizer.from_pretrained(ckpt_dir)
data_root = _find_nakdimon_root()
result = evaluate_byt5(model, tokenizer, _P(data_root) / "test.txt", device)
result["task"] = task
result["checkpoint"] = checkpoint
result["head_names"] = ["diacritized"]
import json
print("=== evaluate result (byt5) ===")
print(json.dumps(result, indent=2, default=str))
return result
model = build_model(cfg_dict).to(device)
state = torch.load(checkpoint, map_location=device, weights_only=False)
if isinstance(state, dict) and "model" in state:
state = state["model"]
model.load_state_dict(state)
model.eval()
head_names = model.head_names()
if arch == "hebrew_seq2seq":
from rababa.models.hebrew_seq2seq import (
HebrewSeq2SeqDataset, hebrew_seq2seq_collate, build_hebrew_vocab,
)
from rababa.tasks import _get_data_root
from rababa.datasets import _find_nakdimon_root
from pathlib import Path as _P
from torch.utils.data import DataLoader as _DL
root = _get_data_root(cfg)
nakdimon_root = root if root else str(_find_nakdimon_root())
data_max_len = int(cfg.data.get("max_len", 200)) if hasattr(cfg.data, "get") else 200
vocab = build_hebrew_vocab(_P(nakdimon_root) / "train.txt")
test_ds = HebrewSeq2SeqDataset(_P(nakdimon_root) / "test.txt", vocab, max_len=data_max_len)
loader = _DL(test_ds, batch_size=32, shuffle=False, collate_fn=hebrew_seq2seq_collate)
total_wrong = 0
total_positions = 0
total_n = 0
with torch.no_grad():
for batch in loader:
src = batch.src.to(device)
src_kpm = src == model.pad_id
from rababa.evaluate import seq2seq_batch_der
der, n = seq2seq_batch_der(
model, src, src_kpm, model.vocab, batch.raw, device,
)
total_wrong += int(der * n)
total_positions += n
total_n += src.size(0)
agg_der = total_wrong / max(1, total_positions)
result = {
"task": task,
"checkpoint": checkpoint,
"head_names": head_names,
"n_examples": total_n,
"per_head_der": [agg_der],
"per_head_per_example_accuracy": [1.0 - agg_der],
"der_aggregate": agg_der,
"der": agg_der,
"per_example_accuracy": 1.0 - agg_der,
}
import json
print("=== evaluate result (seq2seq) ===")
print(json.dumps(result, indent=2, default=str))
return result
if arch == "alephbert":
from rababa.models.alephbert import AlephBERTHebrewDataset
from rababa.tasks import _get_data_root, _get_max_len
root = _get_data_root(cfg)
max_len = _get_max_len(cfg)
test_ds = AlephBERTHebrewDataset("test", root=root, max_len=max_len)
from torch.utils.data import DataLoader
from rababa.training.collate import multi_head_collate_batch
loader = DataLoader(test_ds, batch_size=32, shuffle=False, collate_fn=multi_head_collate_batch)
else:
loader = build_test_loader(task=task, batch_size=32)
head_der = [0.0] * len(head_names)
head_acc = [0.0] * len(head_names)
aggregate_wrong = 0
aggregate_total = 0
total_n = 0
with torch.no_grad():
for batch in loader:
src = batch.src.to(device)
lengths = batch.lengths.to(device)
targets = [t.to(device) for t in batch.targets]
outputs = model.forward_heads(src, lengths)
any_wrong = None
any_evaluable = None
for h_idx, (logits, target) in enumerate(zip(outputs, targets, strict=True)):
head_der[h_idx] += diacritization_error_rate(logits, target) * src.size(0)
head_acc[h_idx] += per_example_accuracy(logits, target) * src.size(0)
preds = logits.argmax(dim=-1)
head_mask = target != 0
head_wrong = (preds != target) & head_mask
any_wrong = head_wrong if any_wrong is None else (any_wrong | head_wrong)
any_evaluable = head_mask if any_evaluable is None else (any_evaluable | head_mask)
aggregate_wrong += any_wrong.sum().item()
aggregate_total += any_evaluable.sum().item()
total_n += src.size(0)
result = {
"task": task,
"checkpoint": checkpoint,
"head_names": head_names,
"n_examples": total_n,
"per_head_der": [d / max(1, total_n) for d in head_der],
"per_head_per_example_accuracy": [a / max(1, total_n) for a in head_acc],
"der_aggregate": aggregate_wrong / max(1, aggregate_total),
"der": aggregate_wrong / max(1, aggregate_total),
"per_example_accuracy": head_acc[0] / max(1, total_n),
}
# Print so the result is visible in `modal run` stdout (not just returned).
import json
print("=== evaluate result ===")
print(json.dumps(result, indent=2, default=str))
return result
@app.function(
gpu="A100",
timeout=30 * 60,
volumes={"/checkpoints": checkpoints_volume, "/datasets": datasets_volume},
)
def ensemble_evaluate(
task: str,
n_seeds: int = 3,
) -> dict[str, object]:
"""Evaluate ensemble of N seed checkpoints on test split. Averages softmax
predictions across models for better DER.
"""
from rababa.evaluate_ensemble import ensemble_evaluate as _ens_eval
return _ens_eval(task=task, n_seeds=n_seeds)
# ---- Distillation: auto-label unpointed Hebrew via Dicta Nakdan API ----
DICTA_URL = "https://nakdan-2-0.loadbalancer.dicta.org.il/api"
@app.function(
cpu=2,
timeout=2 * 60 * 60,
volumes={"/datasets": datasets_volume},
)
def distill_hebrew_chunk(chunk_index: int, total_chunks: int, source_path: str) -> dict[str, object]:
"""Process one chunk of unpointed Hebrew lines via Dicta Nakdan API.
Designed to be called via `.starmap()` so N chunks run in parallel
across N containers. Each container handles 1/N of the input.
"""
import requests
from pathlib import Path
src = Path(source_path)
all_lines = src.read_text(encoding="utf-8").splitlines()
n = len(all_lines)
chunk_size = (n + total_chunks - 1) // total_chunks
start = chunk_index * chunk_size
end = min(start + chunk_size, n)
chunk = all_lines[start:end]
# Write pointed output to a per-chunk file; merged later.
out_path = Path("/datasets") / "hebrew-distilled" / f"chunk-{chunk_index:04d}.txt"
out_path.parent.mkdir(parents=True, exist_ok=True)
stats = {"chunk": chunk_index, "total": 0, "kept": 0, "low_confidence_words": 0, "failed": 0}
with out_path.open("w", encoding="utf-8") as out_f:
for i, line in enumerate(chunk):
line = line.strip()
if not line or len(line) < 10:
continue
stats["total"] += 1
try:
resp = requests.post(
DICTA_URL,
json={"data": line, "genre": "modern"},
headers={"Content-Type": "application/json"},
timeout=15,
)
resp.raise_for_status()
words = resp.json()
pointed_parts = []
for w in words:
if w.get("sep"):
pointed_parts.append(w["word"])
continue
options = w.get("options") or []
if not options:
pointed_parts.append(w["word"])
continue
if not w.get("fconfident", False):
stats["low_confidence_words"] += 1
# Always take top prediction — Dicta is reliable even when
# fconfident=false (the flag is conservative). Let the
# downstream student model learn from any residual noise.
pointed_parts.append(options[0])
pointed_line = "".join(pointed_parts).strip()
if pointed_line:
out_f.write(pointed_line + "\n")
stats["kept"] += 1
except Exception:
stats["failed"] += 1
if (i + 1) % 500 == 0:
print(f" chunk {chunk_index}: {i + 1}/{len(chunk)} kept={stats['kept']}", flush=True)
out_f.flush()
datasets_volume.commit()
return stats
@app.function(
cpu=1,
timeout=10 * 60,
volumes={"/datasets": datasets_volume},
)
def merge_distilled_chunks(n_chunks: int, out_path: str = "/datasets/hebrew-distilled/train.txt") -> dict[str, object]:
"""Concatenate all chunk files into a single train.txt."""
from pathlib import Path
out = Path(out_path)
out.parent.mkdir(parents=True, exist_ok=True)
total_lines = 0
chunks_used = 0
with out.open("w", encoding="utf-8") as out_f:
for i in range(n_chunks):
chunk_file = Path("/datasets") / "hebrew-distilled" / f"chunk-{i:04d}.txt"
if not chunk_file.is_file():
continue
text = chunk_file.read_text(encoding="utf-8")
out_f.write(text)
if not text.endswith("\n"):
out_f.write("\n")
total_lines += text.count("\n")
chunks_used += 1
datasets_volume.commit()
return {"chunks_used": chunks_used, "total_lines": total_lines, "out_path": out_path}
@app.function(
cpu=1,
timeout=4 * 60 * 60,
volumes={"/datasets": datasets_volume},
)
def distill_hebrew(
source_path: str = "/hewiki/train.txt",
n_parallel: int = 20,
commit_to_repo: bool = False,
) -> dict[str, object]:
"""Top-level entry: dispatch N parallel containers, then merge.
Returns aggregate stats. Output: /datasets/hebrew-distilled/train.txt
Designed for `modal app deploy` invocation — runs entirely server-side
with a 4-hour timeout (the 300s client RPC limit only applies to
`modal run`). Use `modal app deploy` then `modal app call` for the
long-running path; use `modal run` only for small smoke tests.
Set commit_to_repo=True to push the distilled corpus back to the
rababa-hebrew-distilled GitHub repo so future builds pick it up
via the image-recipe git clone.
"""
from pathlib import Path
src = Path(source_path)
if not src.is_file():
raise FileNotFoundError(f"Source corpus not found: {src}")
# Dispatch chunks in parallel.
chunk_indices = list(range(n_parallel))
print(f"Dispatching {n_parallel} parallel workers on {source_path}...", flush=True)
stats_list = list(distill_hebrew_chunk.starmap(
[(i, n_parallel, source_path) for i in chunk_indices],
))