Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions models/tha-g2p-small/tha-g2p-small-1.0.README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# tha-g2p-small-1.0

Thai grapheme-to-phoneme (IPA). Client-tier ByT5-small (300M) student
distilled from the secryst umt5 Thai teacher (2.32% PER, public baseline
6.37%) via sequence-level KD: the teacher generated 23,295 labels with
its own sentencepiece tokenizer and the student trained CE on them with
the canonical byte table.

First model of the distillation campaign
(docs/DISTILL-SOURCE-PROMPT.md); identical IMF v1 contract to the
server-tier models — dynamic fetch, sha256-verified, KV decode.

```python
from interscript_ml import Model
model = Model.load("tha-g2p-small-1.0")
model.translate("สวัสดี")
```
30 changes: 30 additions & 0 deletions models/tha-g2p-small/tha-g2p-small-1.0.metadata.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
format: imf-v1
id: tha-g2p-small-1.0
task: g2p
source_script: Thai
target: IPA
tokenizer: bytes
opset: 14
decoder: kv
precision: fp32
license: BSD-3-Clause
trained_from: >-
distilled from the recovered Thai umt5 teacher (transformers 5.14.1
re-finetune of B-K/umt5-thai-g2p-v2-0.5k on the 60K Kaikki+epitran
corpus; the secryst-saved umt5 artifacts are unusable — 5.15 dropped
the untied lm_head) via sequence-level KD; ByT5-small init
google/byt5-small; checkpoint
secryst-checkpoints:/secryst_thai_g2p_distill_small/run-002/best
metrics:
- name: per_teacher
value: 2.32
protocol: >-
greedy decode; 1,219 Kaikki Thai test sentences; umt5 teacher,
sentencepiece tokenizer; secryst RESULTS.md protocol
source: secryst/docs/RESULTS.md#thai-g2p
- name: per_student
value: PLACEHOLDER_STUDENT_PER
protocol: >-
greedy decode; same 1,219 test sentences, same harness as the
teacher (interscript/ml-models src/gpu/modal_distill.py::evaluate_per)
source: interscript/ml-models release tha-g2p-small-1.0
1 change: 0 additions & 1 deletion scripts/generate_release_notes.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
import sys
from pathlib import Path


TEMPLATE = """\
## {task} v{version}

Expand Down
119 changes: 69 additions & 50 deletions scripts/publish_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@
sys.path.insert(0, str(REPO_ROOT / "src"))
sys.path.insert(0, str(REPO_ROOT / "scripts"))

from split_release import split as split_zip # noqa: E402
from imf.validator import validate_zip # noqa: E402
from split_release import split as split_zip # noqa: E402

# GitHub hard-caps release assets at 2,147,483,648 bytes; split well below.
SPLIT_THRESHOLD = 2_000_000_000
Expand Down Expand Up @@ -93,16 +93,15 @@ def entry_block(model_id: str, meta: dict, filename: str, sha256: str, size: int
return "\n".join(lines) + "\n"


def upsert_models_yaml(model_id: str, block: str) -> None:
path = REPO_ROOT / "models.yaml"
text = path.read_text(encoding="utf-8")
def upsert_models_yaml(models_yaml: Path, model_id: str, block: str) -> None:
text = models_yaml.read_text(encoding="utf-8")
pattern = re.compile(
rf"^ {re.escape(model_id)}:\n(?:(?!^ \S).*\n)*", re.MULTILINE
)
if pattern.search(text):
path.write_text(pattern.sub(block, text), encoding="utf-8")
models_yaml.write_text(pattern.sub(block, text), encoding="utf-8")
else:
with path.open("a", encoding="utf-8") as fh:
with models_yaml.open("a", encoding="utf-8") as fh:
fh.write(block)


Expand Down Expand Up @@ -180,56 +179,76 @@ def main() -> None:
capture_output=True, text=True,
)
if existing.returncode == 0:
print(f"release {tag} exists; re-uploading assets (clobber)")
run(["gh", "release", "upload", tag, *[str(a) for a in assets], "--clobber"])
listed = run(["gh", "release", "view", tag, "--json", "assets",
"--jq", "[.assets[] | {name, size}]"],
capture_output=True, text=True).stdout
have = {
asset["name"]: int(asset["size"])
for asset in yaml.safe_load(listed)
}
want = {a.name: a.stat().st_size for a in assets}
if have == want:
print(f"release {tag} already carries all assets; skipping upload")
else:
print(f"release {tag} exists; re-uploading assets (clobber)")
run(["gh", "release", "upload", tag, *[str(a) for a in assets], "--clobber"])
run(["gh", "release", "edit", tag, "--notes-file", notes_path])
else:
run(["gh", "release", "create", tag, *[str(a) for a in assets],
"--title", tag, "--notes-file", notes_path])

upsert_models_yaml(args.model_id,
entry_block(args.model_id, meta, args.zip.name, whole_sha, size,
assets, args.repo, tag))

model_dir = REPO_ROOT / "models" / args.model_id
model_dir.mkdir(parents=True, exist_ok=True)
(model_dir / f"{args.model_id}.metadata.yaml").write_text(
yaml.safe_dump(meta, sort_keys=False, allow_unicode=True), encoding="utf-8")

# Branch work happens in a dedicated worktree so the caller's tree is
# never checked out (dirty files must not block publication, and
# publication must not clobber in-progress edits).
branch = f"release/{args.model_id}"
current = run(["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True, text=True).stdout.strip()
if current != branch:
branches = run(["git", "branch", "--list", branch],
capture_output=True, text=True).stdout
run(["git", "checkout", "-B", branch, "origin/main"] if not branches else
["git", "checkout", branch])
run(["git", "add", "models.yaml",
str(model_dir / f"{args.model_id}.metadata.yaml")])
staged = run(["git", "diff", "--cached", "--name-only"],
capture_output=True, text=True).stdout.split()
if not staged:
print("nothing new to commit (idempotent re-run)")
return
run(["git", "commit", "-m", f"release: {args.model_id} "
f"({meta['precision']}, parity cer_delta {meta['parity']['cer_delta']}pp "
f"on {meta['parity']['samples']} samples)"])
run(["git", "push", "-u", "origin", branch])
prs = run(["gh", "pr", "list", "--head", branch, "--json", "number"],
capture_output=True, text=True).stdout
if "number" not in prs or yaml.safe_load(prs) == []:
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False) as fh:
fh.write(f"## Summary\n- publish {args.model_id} ({meta['precision']}): "
f"GH Release `{tag}` + models.yaml entry"
f"{' (split parts, GitHub 2GiB cap)' if len(assets) > 1 else ''}\n"
f"- parity cer_delta {meta['parity']['cer_delta']}pp on "
f"{meta['parity']['samples']} samples; strict validator gate passed\n\n"
f"## Test plan\n- [ ] CI green\n- [ ] runtime fetch "
f"`Model.load(\"{args.model_id}\")` resolves and verifies\n")
body_path = fh.name
run(["gh", "pr", "create", "--title", f"release: {args.model_id}",
"--body-file", body_path])
print(f"published {args.model_id}: release {tag}, PR on {branch}")
worktree = REPO_ROOT.parent / f".wt-publish-{args.model_id}"
branches = run(["git", "branch", "--list", branch],
capture_output=True, text=True).stdout
base = branch if branches else "origin/main"
run(["git", "worktree", "add", str(worktree), "-B", branch, base])

try:
wt_models = worktree / "models.yaml"
wt_repo = str(worktree)
# models/<family>/<id>.metadata.yaml, e.g. models/heb-diac/heb-diac-1.0...
family = args.model_id.rsplit("-", 1)[0]
upsert_models_yaml(wt_models, args.model_id,
entry_block(args.model_id, meta, args.zip.name, whole_sha,
size, assets, args.repo, tag))
model_dir = worktree / "models" / family
model_dir.mkdir(parents=True, exist_ok=True)
(model_dir / f"{args.model_id}.metadata.yaml").write_text(
yaml.safe_dump(meta, sort_keys=False, allow_unicode=True), encoding="utf-8")

def git_wt(*cmd: str):
return run(["git", "-C", wt_repo, *cmd], capture_output=True, text=True)

git_wt("add", "models.yaml", f"models/{family}/{args.model_id}.metadata.yaml")
staged = git_wt("diff", "--cached", "--name-only").stdout.split()
if staged:
git_wt("commit", "-m", f"release: {args.model_id} "
f"({meta['precision']}, parity cer_delta {meta['parity']['cer_delta']}pp "
f"on {meta['parity']['samples']} samples)")
git_wt("push", "-u", "origin", branch)
else:
print("nothing new to commit (idempotent re-run)")
prs = run(["gh", "pr", "list", "--head", branch, "--json", "number"],
capture_output=True, text=True).stdout
if not yaml.safe_load(prs):
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False) as fh:
fh.write(f"## Summary\n- publish {args.model_id} ({meta['precision']}): "
f"GH Release `{tag}` + models.yaml entry"
f"{' (split parts, GitHub 2GiB cap)' if len(assets) > 1 else ''}\n"
f"- parity cer_delta {meta['parity']['cer_delta']}pp on "
f"{meta['parity']['samples']} samples; strict validator gate passed\n\n"
f"## Test plan\n- [ ] CI green\n- [ ] runtime fetch "
f"`Model.load(\"{args.model_id}\")` resolves and verifies\n")
body_path = fh.name
run(["gh", "pr", "create", "--title", f"release: {args.model_id}",
"--body-file", body_path])
finally:
run(["git", "worktree", "remove", "--force", str(worktree)])
print(f"published {args.model_id}: release {tag}, branch {branch}")


if __name__ == "__main__":
Expand Down
118 changes: 113 additions & 5 deletions src/gpu/modal_distill.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,17 @@

SPECS: dict[str, dict[str, str]] = {
"tha-g2p-small": {
"teacher": "secryst_thai_ipa_thai_combined_mixed/run-001/best",
# secryst's saved umt5 artifacts are unusable (5.15 dropped the
# untied lm_head) — teacher is the 5.14.1 recovery finetune
# (src/gpu/modal_teacher_thai.py, same recipe + data)
"teacher": "secryst_thai_ipa_teacher_recovery/run-001/best",
"teacher_volume": "secryst",
"student_init": "google/byt5-small",
"train": "thai-ipa-expanded/train.jsonl",
"val": "thai-ipa-expanded/val.jsonl",
"test": "thai-ipa-expanded/test.jsonl",
"out": "secryst_thai_g2p_distill_small/run-001",
"eval_test": "thai-ipa/test.jsonl",
"out": "secryst_thai_g2p_distill_small/run-002",
"mode": "sequence", # cross-tokenizer: teacher generates, student trains CE
"note": "umt5 (sentencepiece) teacher -> ByT5-small byte student; +5pp PER gate",
},
Expand All @@ -75,6 +79,14 @@
app = modal.App("interscript-ml-distill", image=IMAGE)


def decode_joined(tok, ids) -> str:
"""Correct decode for umt5 teachers: 5.x batch_decode inserts spurious
spaces between sentencepiece pieces; pieces must join directly (the
targets are unspaced IPA strings)."""
skip = {tok.pad_token, tok.eos_token, tok.bos_token}
return "".join(p for p in tok.convert_ids_to_tokens(ids) if p not in skip)


@app.function(
gpu="A10G",
cpu=8,
Expand Down Expand Up @@ -316,6 +328,98 @@ def metrics(model) -> dict:



@app.function(
gpu="A10G",
cpu=8,
memory=32 * 1024,
timeout=2 * 3600,
volumes={
"/datasets": DATASETS,
"/checkpoints": CHECKPOINTS,
"/secryst-checkpoints": SECRYST_CHECKPOINTS,
"/secryst-datasets": SECRYST_DATASETS,
"/persian-checkpoints": PERSIAN_CHECKPOINTS,
},
)
def evaluate_per(spec_id: str, limit: int = 0) -> dict:
"""PER of teacher and student on the same g2p test split, replicating
the source-model harness exactly (train_thai_combined.py::evaluate):
held-out test file, beam-4 decode, corpus-level PER =
total_ed / total_gold over whitespace tokens. The student gate is
teacher_per + 5pp (docs/DISTILL-SOURCE-PROMPT.md)."""
import json
from pathlib import Path

import torch
from transformers import AutoModelForSeq2SeqLM, AutoTokenizer

spec = SPECS[spec_id]
teacher_vol = spec.get("teacher_volume", "rababa")
vol_map = {
"rababa": "/checkpoints",
"secryst": "/secryst-checkpoints",
"persian": "/persian-checkpoints",
}
data_vol = "/secryst-datasets" if teacher_vol == "secryst" else "/datasets"
teacher_path = Path(vol_map[teacher_vol]) / spec["teacher"]
student_path = Path(vol_map[teacher_vol]) / spec["out"] / "best"
test_path = Path(data_vol) / spec.get("eval_test", spec["test"])

teacher_tok = AutoTokenizer.from_pretrained(str(teacher_path))
teacher = AutoModelForSeq2SeqLM.from_pretrained(str(teacher_path)).to("cuda").eval()
student_tok = AutoTokenizer.from_pretrained("google/byt5-small")
student = (
AutoModelForSeq2SeqLM.from_pretrained(str(student_path)).to("cuda").eval()
)

pairs = []
for line in test_path.read_text(encoding="utf-8").splitlines():
if line.strip():
row = json.loads(line)
pairs.append((row["src"], row["tgt"]))
if limit:
pairs = pairs[:limit]
print(f"[{spec_id}] eval pairs: {len(pairs)} from {test_path}", flush=True)

def beam(tok, model, batch: list[str], max_len: int = 256) -> list[str]:
enc = tok(batch, return_tensors="pt", padding=True, truncation=True,
max_length=max_len).to("cuda")
with torch.no_grad():
out = model.generate(**enc, max_new_tokens=max_len, num_beams=4)
return tok.batch_decode(out, skip_special_tokens=True)

def per(model, tok, debug_name: str) -> dict:
total_ed = total_gold = exact = n = 0
for start in range(0, len(pairs), 32):
batch = pairs[start : start + 32]
preds = beam(tok, model, [src for src, _ in batch])
for (_, gold), pred in zip(batch, preds, strict=True):
e = _edit_distance(pred.strip().split(), gold.strip().split())
total_ed += e
total_gold += max(1, len(gold.split()))
exact += e == 0
n += 1
if start == 0:
for (src, gold), pred in zip(batch[:3], preds[:3], strict=True):
print(
f"[{debug_name}] src={src!r}\n gold={gold!r}\n pred={pred!r}",
flush=True,
)
return {
"per": round(100 * total_ed / max(1, total_gold), 2),
"exact_match": round(100 * exact / max(1, n), 2),
"n": n,
}

result = {
"teacher": per(teacher, teacher_tok, "teacher"),
"student": per(student, student_tok, "student"),
}
result["gate_delta"] = round(result["student"]["per"] - result["teacher"]["per"], 2)
result["gate_pass"] = result["gate_delta"] <= 5.0
return result


@app.function(
gpu="A10G",
cpu=8,
Expand Down Expand Up @@ -358,7 +462,6 @@ def distill_sequence(spec_id: str, epochs: int = 3) -> dict:

data_vol = "/secryst-datasets" if teacher_vol == "secryst" else "/datasets"
train_path = Path(data_vol) / spec["train"]
val_path = Path(data_vol) / spec["val"]

# Teacher: use its OWN tokenizer (sentencepiece for umt5)
teacher_tok = AutoTokenizer.from_pretrained(str(teacher_path))
Expand Down Expand Up @@ -425,7 +528,7 @@ def collate(batch):
).to("cuda")
with torch.no_grad():
out = teacher.generate(**enc, max_new_tokens=384, num_beams=1)
preds = teacher_tok.batch_decode(out, skip_special_tokens=True)
preds = [decode_joined(teacher_tok, o) for o in out]
for (src, _), pred in zip(batch, preds, strict=True):
fh.write(
json.dumps(
Expand Down Expand Up @@ -471,7 +574,7 @@ def __getitem__(self, i):
save_every = 500
step = 0

for epoch in range(epochs):
for _ in range(epochs):
for ids, am, labels in train_loader:
ids, am, labels = ids.to("cuda"), am.to("cuda"), labels.to("cuda")
loss = student(input_ids=ids, attention_mask=am, labels=labels).loss
Expand Down Expand Up @@ -515,3 +618,8 @@ def main(spec: str = "heb-diac-small", epochs: int = 3) -> None:
@app.local_entrypoint()
def eval_main(spec: str = "heb-diac-small", limit: int = 0) -> None:
print(evaluate.remote(spec, limit))


@app.local_entrypoint()
def eval_per(spec: str = "tha-g2p-small", limit: int = 0) -> None:
print(evaluate_per.remote(spec, limit))
9 changes: 9 additions & 0 deletions src/gpu/modal_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,15 @@
"test_data": "urdu-diacrit/test.jsonl",
"probe": "اردو",
},
"tha-g2p-small": {
"volume": "/volumes/secryst-checkpoints",
"checkpoint": "secryst_thai_g2p_distill_small/run-002/best",
"metadata": "models/tha-g2p-small/tha-g2p-small-1.0.metadata.yaml",
"readme": "models/tha-g2p-small/tha-g2p-small-1.0.README.md",
"test_volume": "/datasets/secryst",
"test_data": "thai-ipa/test.jsonl",
"probe": "สวัสดี",
},
}

app = modal.App("interscript-ml-export", image=IMAGE)
Expand Down
Loading
Loading