Spaces:
Sleeping
Sleeping
| """Generate the per-stage DARAG notebooks from one spec (run me to (re)build them). | |
| Notebooks are kept thin and *generated* so they never drift: all logic lives in | |
| ``gec`` + ``scribe/training/scripts/*`` and each notebook just bootstraps, installs, | |
| and calls one stage. Edit the spec here, run ``python scribe/training/scripts/build_notebooks.py``, | |
| and the ``notebooks/NN_*.ipynb`` files are rewritten. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| from pathlib import Path | |
| NOTEBOOKS_DIR = Path(__file__).resolve().parents[1] / "notebooks" | |
| # Minimal, self-contained repo bootstrap: can't import carepath until sys.path is | |
| # set, so this cell locates-or-clones the repo before any package import. | |
| BOOTSTRAP = """\ | |
| # --- CarePath stage bootstrap (short by design) --- | |
| import importlib.util | |
| import os | |
| import shutil | |
| import subprocess | |
| import sys | |
| from pathlib import Path | |
| def _find(start): | |
| for d in [start, *start.parents]: | |
| if (d / 'pyproject.toml').exists() and (d / 'scribe' / 'carepath').exists(): | |
| return d | |
| return None | |
| def _token(): | |
| # Colab Secrets live in userdata, NOT os.environ - check both. | |
| for key in ('CAREPATH_GITHUB_TOKEN', 'GITHUB_TOKEN'): | |
| if os.environ.get(key): | |
| return os.environ[key] | |
| try: | |
| from google.colab import userdata | |
| for key in ('CAREPATH_GITHUB_TOKEN', 'GITHUB_TOKEN'): | |
| try: | |
| val = userdata.get(key) | |
| if val: | |
| return val | |
| except Exception: | |
| pass | |
| except Exception: | |
| pass | |
| return None | |
| REPO = _find(Path.cwd().resolve()) | |
| if REPO is None and importlib.util.find_spec('google.colab'): | |
| target = Path('/content/carepath') | |
| if _find(target): # already cloned in this runtime | |
| REPO = target | |
| else: | |
| if target.exists(): | |
| shutil.rmtree(target) # remove a half-cloned leftover | |
| url = os.environ.get('CAREPATH_REPO_URL', 'https://github.com/truong-tt/carepath.git') | |
| tok = _token() | |
| if tok and url.startswith('https://github.com/'): | |
| url = url.replace('https://', f'https://x-access-token:{tok}@') | |
| r = subprocess.run(['git', 'clone', url, str(target)], capture_output=True, text=True) | |
| if r.returncode != 0: | |
| err = (r.stderr or r.stdout) | |
| if tok: | |
| err = err.replace(tok, '***') | |
| raise SystemExit( | |
| 'git clone failed. This repo is private — add a Colab Secret named ' | |
| 'GITHUB_TOKEN (key icon in the left sidebar, toggle "Notebook access") ' | |
| 'holding a GitHub token with read access to the repo, then re-run.\\n\\n' + err) | |
| REPO = target | |
| assert REPO, 'Open this notebook from inside the CarePath repo.' | |
| os.chdir(REPO); sys.path[:0] = [str(REPO / 'scribe' / 'training'), str(REPO / 'scribe')] | |
| PROFILE = os.environ.get('CAREPATH_PROFILE', 'full') # default full; set CAREPATH_PROFILE=smoke for a plumbing-only check | |
| from gec.notebook import init_stage | |
| CTX = init_stage(PROFILE); P = CTX.paths; PROF = CTX.profile | |
| """ | |
| INSTALL = """\ | |
| # Install the GEC training stack (idempotent; needed once per Colab runtime). | |
| import subprocess | |
| import sys | |
| subprocess.run([sys.executable, '-m', 'pip', 'install', '-q', '-e', '.[training]']) | |
| """ | |
| INSTALL_TTS = INSTALL + """subprocess.run([sys.executable, '-m', 'pip', 'install', '-q', 'coqui-tts']) | |
| """ | |
| # GPU notebook: also swap in the CUDA build of sherpa-onnx so the bulk ASR decode | |
| # (6 decodes per clip with N-best) runs on the GPU. Colab GPU runtimes are | |
| # CUDA 12 / cuDNN 9. If the wheel can't be installed, the plain CPU wheel stays | |
| # and onnxruntime falls back to CPU with a warning — slower, never broken. | |
| INSTALL_GPU_ASR = INSTALL_TTS + """\ | |
| subprocess.run([sys.executable, '-m', 'pip', 'install', '-q', | |
| 'sherpa-onnx==1.13.3+cuda12.cudnn9', | |
| '-f', 'https://k2-fsa.github.io/sherpa/onnx/cuda.html']) | |
| """ | |
| # Each stage: (num, slug, gpu, install, title_md, body_code). | |
| STAGES = [ | |
| (0, "setup_and_config", False, INSTALL, """\ | |
| # CarePath DARAG — Stage 00: Setup & Config `[CPU]` | |
| Bootstrap the repo, pick a **profile** (`smoke` plumbing check / `full` ViMedCSS | |
| run), mount Drive on Colab, and print the resolved paths. Every later stage reuses | |
| this `init_stage` so run-sizes and artifact paths have one source of truth.""", """\ | |
| from gec import env | |
| print(env.gpu_report()) | |
| print('dataset ->', CTX.dataset) | |
| print('datastore ->', P.datastore) | |
| print('real pairs ->', P.real_pairs) | |
| print('adapters ->', P.adapters) | |
| print('serve dir ->', P.serve_bundle) | |
| # --- Colab Pro runtime plan (which runtime per notebook) --- | |
| print(''' | |
| Run the four notebooks in order, each on the runtime it names: | |
| 00_data_prep [CPU] datastore + labeled pairs (free, minutes) | |
| 01_asr_synthesis [GPU L4] real ASR pairs (bulk decode) + synthetic | |
| transcripts + TTS + synth pairs + leakage | |
| 02_train_predict [GPU A100] harvest + augment + QLoRA train + predict | |
| 03_evaluate_export [CPU] WER/NE-F1 tables + gate + serve bundle | |
| Full run ~ 3 seeds x 4 variants; training dominates GPU units; the ASR pair decode | |
| (6 decodes/clip over ~150h of audio) and viXTTS are the other long poles — both | |
| GPU. Every step --resumes, so a disconnect continues, it does not restart. | |
| Set CAREPATH_PROFILE=full for the real ViMedCSS run (smoke = plumbing only).''') | |
| """), | |
| (1, "build_datastore", False, INSTALL, """\ | |
| # Stage 01: Build the NE / code-switch datastore `[CPU]` | |
| Paper §4.2 Step 1 — union the curated lexicon, dataset `cs_terms_list`, and mined | |
| code-switch tokens into the retrieval datastore.""", """\ | |
| CTX.run_step(['scribe/training/scripts/build_datastore.py', '--dataset', CTX.dataset, | |
| '--limit-per-split', str(PROF.limit_per_split or 0), '--output', str(P.datastore)]) | |
| import json | |
| print('terms:', json.load(open(P.datastore, encoding='utf-8'))['metadata']['term_count']) | |
| CTX.save([str(P.datastore)]) # notebook 01 (GPU) restores this for pair building | |
| """), | |
| (2, "asr_pairs", True, INSTALL_GPU_ASR, """\ | |
| # Stage 02: Real GEC pairs + N-best + error-signal report `[GPU]` | |
| Paper §3.1 — run Gipformer (or mock for smoke) over ViMedCSS audio to build | |
| `raw_asr -> gold_text` pairs. `--n-best` adds the perturbation hypotheses (paper | |
| §4.3), so the full run decodes each clip 6x — that is the pipeline's bulk decode | |
| and it needs the CUDA provider (weeks on a 2-vCPU runtime, hours on an L4). The | |
| **error-signal report** warns if the ASR is too accurate on train to teach the | |
| corrector (paper §3.2).""", """\ | |
| CTX.restore([str(P.datastore)]) # built in the CPU data-prep notebook | |
| pairs_out = CTX.durable(P.real_pairs) # write straight to Drive on Colab so --resume survives a disconnect | |
| CTX.run_step(['scribe/training/scripts/make_pairs.py', '--dataset', CTX.dataset, '--output', pairs_out, | |
| '--asr-provider', PROF.asr_provider, '--datastore', str(P.datastore), | |
| '--retrieval-backend', PROF.retrieval_backend, | |
| '--limit-per-split', str(PROF.limit_per_split or 0), | |
| '--n-best', str(PROF.n_best), '--resume'], | |
| env_extra={'GIPFORMER_PROVIDER': os.environ.get('GIPFORMER_PROVIDER', 'cuda')}) | |
| from gec.data import read_jsonl | |
| from gec.evaluate import train_error_signal | |
| print(train_error_signal(read_jsonl(pairs_out))) | |
| """), | |
| (3, "synth_transcripts", True, INSTALL, """\ | |
| # Stage 03: Synthetic in-domain transcripts `[GPU-light]` | |
| Paper §4.1 Step 1 — few-shot an open LLM for new in-domain transcripts, with the | |
| n-gram leakage guard rejecting near-copies. `synth_count=None` (full) matches the | |
| real train size (nsyn = n).""", """\ | |
| # Pull inputs from Drive: no-op in the same session, lets a fresh runtime | |
| # (or a teammate's Colab) resume mid-notebook. | |
| CTX.restore([str(P.datastore), str(P.real_pairs)]) | |
| from gec.data import read_jsonl | |
| count = PROF.synth_count or (sum(1 for r in read_jsonl(P.real_pairs) if r.get('split') == 'train') or 50) | |
| args = ['scribe/training/scripts/gen_synthetic.py', '--pairs', str(P.real_pairs), | |
| '--output', CTX.durable(P.synth_clean), '--count', str(count)] # durable: persist so a later disconnect won't regenerate | |
| if PROF.name != 'smoke': | |
| args.append('--load-in-4bit') | |
| CTX.run_step(args) | |
| """), | |
| (4, "voice_clone_tts", True, INSTALL_TTS, """\ | |
| # Stage 04: Voice-cloning TTS `[GPU]` | |
| Paper §4.1 Step 2 / App. D — synthesize speech with viXTTS conditioned on random | |
| in-domain reference clips (falls back to single-speaker MMS, labeled as such).""", """\ | |
| args = ['scribe/training/scripts/voice_clone_tts.py', '--input', CTX.durable(P.synth_clean), | |
| '--output', CTX.durable(P.tts_manifest), '--provider', PROF.tts_provider, # durable: viXTTS is long, survive a disconnect | |
| '--ref-dataset', CTX.dataset, '--ref-count', '20', '--resume'] | |
| if PROF.synth_tts_limit: | |
| args += ['--limit', str(PROF.synth_tts_limit)] | |
| CTX.run_step(args) | |
| """), | |
| (5, "synth_pairs", True, INSTALL, """\ | |
| # Stage 05: Synthetic GEC pairs (+ N-best) `[CPU/GPU]` | |
| Paper §4.1 Step 3 — run the ASR over the voice-cloned audio to get synthetic | |
| `raw_asr -> gold_text` pairs, with the same perturbation N-best as the real pairs.""", """\ | |
| CTX.run_step(['scribe/training/scripts/make_synth_pairs.py', '--input', CTX.durable(P.tts_manifest), | |
| '--output', CTX.durable(P.synth_pairs), '--datastore', str(P.datastore), | |
| '--n-best', str(PROF.n_best), '--resume'], # durable: survive a disconnect, no re-ASR | |
| env_extra={'GIPFORMER_PROVIDER': os.environ.get('GIPFORMER_PROVIDER', 'cuda')}) | |
| """), | |
| (6, "leakage_report", True, INSTALL, """\ | |
| # Stage 06: Leakage report — in-domain but not memorized `[GPU-light]` | |
| Paper App. C / Table 6 — SentenceBERT cosine + BLEU of synthetic vs real. High | |
| cosine + low BLEU means the synthetic data is on-domain without copying.""", """\ | |
| CTX.run_step(['scribe/training/scripts/check_leakage.py', '--synthetic', CTX.durable(P.synth_clean), | |
| '--real', str(P.real_pairs), '--output', str(P.leakage)]) | |
| import json | |
| print(json.load(open(P.leakage, encoding='utf-8'))) | |
| """), | |
| (7, "augment_and_train", True, INSTALL, """\ | |
| # Stage 07: Augment + QLoRA fine-tune (multi-seed) `[GPU]` | |
| Paper §5 — merge real ViMedCSS pairs with synthetic (nsyn = n), then train | |
| the DARAG variants over the profile's seeds (full averages 3). Auto-resumes from | |
| checkpoints.""", """\ | |
| # Continue-in-a-teammate's-Colab: pull this stage's inputs from Drive first. | |
| CTX.restore([str(P.datastore), str(P.real_pairs)]) | |
| CTX.restore_optional([str(P.synth_pairs)]) | |
| # Learn real ASR confusions into the datastore (paper Limitation #1), then refresh | |
| # every pair's retrieved NEs so the RAC prompt carries the right term. | |
| harvest = [str(P.real_pairs)] | |
| if Path(P.synth_pairs).exists(): | |
| harvest.append(str(P.synth_pairs)) | |
| CTX.run_step(['scribe/training/scripts/harvest_aliases.py', '--datastore', str(P.datastore), | |
| '--pairs', *harvest, '--refresh', '--backend', PROF.retrieval_backend]) | |
| CTX.save([str(P.datastore)]) # enriched datastore feeds eval + the serve bundle | |
| real = [str(P.real_pairs)] | |
| CTX.run_step(['scribe/training/scripts/augment.py', '--real', *real, '--synthetic', str(P.synth_pairs), | |
| '--output', str(P.augmented), '--nsyn-factor', str(PROF.nsyn_factor)]) | |
| CTX.save([str(P.augmented)]) # persist training data to Drive so a teammate can resume | |
| train = ['scribe/training/scripts/train.py', '--pairs', str(P.augmented), '--output-dir', str(P.adapters), | |
| '--max-steps', str(PROF.max_steps), '--seeds', *[str(s) for s in PROF.seeds]] | |
| train.append('--all-variants' if PROF.all_variants else '--variant') | |
| if not PROF.all_variants: | |
| train.append('full') | |
| CTX.run_step(train) | |
| """), | |
| (8, "predict", True, INSTALL, """\ | |
| # Stage 08: LLM/RAG baseline + trained predictions `[GPU]` | |
| Run the LLM/RAG baseline and the trained `full` adapter so one file carries every | |
| column the tables compare (`raw_asr`, `corrected_text`, `gec_pred`).""", """\ | |
| import os | |
| os.environ.setdefault('LLM_PROVIDER', 'offline') | |
| adir = str(P.adapters) | |
| if PROF.all_variants: | |
| adir = f'{adir}/full' | |
| if len(PROF.seeds) > 1: | |
| adir = f'{adir}/seed-{PROF.seeds[0]}' | |
| CTX.run_step(['scribe/training/scripts/llm_rag_baseline.py', '--input', str(P.real_pairs), '--output', str(P.llm_rag)]) | |
| CTX.run_step(['scribe/training/scripts/predict.py', '--pairs', str(P.llm_rag), '--adapter-dir', adir, | |
| '--output', str(P.darag_preds), '--column', 'gec_pred']) | |
| CTX.save([str(P.darag_preds)]) # hand predictions to the CPU evaluate/export notebook | |
| """), | |
| (9, "evaluate_and_gate", False, INSTALL, """\ | |
| # Stage 09: WER + NE-F1 tables + acceptance gate `[CPU]` | |
| Paper Tables 3 & 4 — WER (syllable + word) and NE micro-F1, then the gate: ship the | |
| adapter only if it matches/beats every baseline on val + hard. For `full`, repeat | |
| predict/evaluate per seed and pass the reports to `evaluate.aggregate_reports` for | |
| mean±std.""", """\ | |
| CTX.restore([str(P.darag_preds)]) # predictions from the train+predict notebook | |
| CTX.run_step(['scribe/training/scripts/evaluate.py', '--input', str(P.darag_preds), '--prediction-columns', | |
| 'raw_asr', 'corrected_text', 'gec_pred', '--wer-output', str(P.darag_wer), | |
| '--ne-f1-output', str(P.darag_ne_f1)]) | |
| CTX.run_step(['scribe/training/scripts/gate.py', '--report', str(P.darag_wer)]) | |
| import json | |
| from gec.evaluate import render_ne_f1_table | |
| print(render_ne_f1_table(json.load(open(P.darag_ne_f1, encoding='utf-8')))) | |
| CTX.save([str(P.darag_wer), str(P.darag_ne_f1), str(P.leakage)]) | |
| """), | |
| (10, "export_and_serve", False, INSTALL, """\ | |
| # Stage 10: Export the gated adapter into a serve bundle `[CPU]` | |
| Package the accepted `full` adapter + the enriched datastore + the frozen DARAG | |
| prompt into a portable `serve_manifest.json` bundle. The FastAPI backend serves it | |
| with `LLM_PROVIDER=gec_local` `GEC_BUNDLE_PATH=<bundle>` — RAC retrieval and a | |
| clinical safety gate (fallback to offline) are wired in `carepath.services.gec_local`. | |
| Run this only after Stage 09's gate accepts the adapter.""", """\ | |
| from pathlib import Path | |
| CTX.restore([str(P.datastore)]) | |
| adir = str(P.adapters) | |
| if PROF.all_variants: | |
| adir = f'{adir}/full' | |
| if len(PROF.seeds) > 1: | |
| adir = f'{adir}/seed-{PROF.seeds[0]}' | |
| CTX.run_step(['scribe/training/scripts/export_serve.py', '--adapter-dir', adir, | |
| '--datastore', str(P.datastore), '--output', str(P.serve_bundle), | |
| '--gate-report', str(P.darag_wer)]) | |
| CTX.save([str(P.serve_bundle)]) | |
| print('Serve with: LLM_PROVIDER=gec_local GEC_BUNDLE_PATH=' + str(P.serve_bundle)) | |
| """), | |
| ] | |
| # Group the 11 thin stages into 4 notebooks by RUNTIME tier — one notebook runs | |
| # on one Colab runtime, so cheap text work stays on free CPU runtimes and every | |
| # bulk decode/training step gets a GPU (the full-profile ASR decode is ~6x ~150h | |
| # of audio: weeks on 2 vCPUs, hours with the CUDA provider on an L4). Resume is | |
| # preserved by each step's --resume / restore / save, not by notebook boundaries, | |
| # so a disconnect re-enters the notebook and skips finished steps. | |
| GROUPS = [ | |
| (0, "data_prep", INSTALL, [0, 1], """\ | |
| # CarePath DARAG — 00 Data prep `[CPU runtime]` | |
| Setup + NE datastore. Text-only — minutes on a free Colab CPU runtime. The bulk | |
| ASR decode lives in notebook 01's GPU. Each step resumes, so re-running after a | |
| disconnect skips finished work."""), | |
| (1, "asr_synthesis", INSTALL_GPU_ASR, [2, 3, 4, 5, 6], """\ | |
| # 01 ASR pairs + synthesis `[GPU — L4 is enough]` | |
| Real ASR pairs first (the pipeline's bulk decode: 6 decodes per clip with N-best, | |
| CUDA sherpa-onnx), then synthetic transcripts → voice-cloned TTS → synthetic pairs | |
| → leakage report. Long poles: pair decode + viXTTS, both | |
| resume per item."""), | |
| (2, "train_predict", INSTALL, [7, 8], """\ | |
| # 02 Train + predict `[GPU — A100 advised]` | |
| Harvest real ASR confusions, augment, QLoRA fine-tune the DARAG variants | |
| (multi-seed), then run predictions. Training auto-resumes | |
| from the latest checkpoint."""), | |
| (3, "evaluate_export", INSTALL, [9, 10], """\ | |
| # 03 Evaluate + export `[CPU runtime]` | |
| WER + NE-F1 tables, acceptance gate, then bundle the gated adapter for serving | |
| CPU — run on a free runtime."""), | |
| ] | |
| def _src(text: str) -> list[str]: | |
| return list(text.splitlines(keepends=True)) | |
| def code(text: str) -> dict: | |
| return {"cell_type": "code", "metadata": {}, "execution_count": None, "outputs": [], "source": _src(text)} | |
| def md(text: str) -> dict: | |
| return {"cell_type": "markdown", "metadata": {}, "source": _src(text)} | |
| def build() -> None: | |
| NOTEBOOKS_DIR.mkdir(parents=True, exist_ok=True) | |
| for stale in NOTEBOOKS_DIR.glob("[0-9][0-9]_*.ipynb"): | |
| stale.unlink() # drop the previous numbering before regenerating | |
| stage_by_num = {num: (title, body) for num, _slug, _gpu, _inst, title, body in STAGES} | |
| for gnum, gslug, install, stage_nums, intro in GROUPS: | |
| cells = [md(intro), code(BOOTSTRAP), code(install)] | |
| for snum in stage_nums: | |
| title, body = stage_by_num[snum] | |
| cells += [md(title), code(body)] | |
| notebook = { | |
| "cells": cells, | |
| "metadata": { | |
| "kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"}, | |
| "language_info": {"name": "python"}, | |
| }, | |
| "nbformat": 4, | |
| "nbformat_minor": 5, | |
| } | |
| path = NOTEBOOKS_DIR / f"{gnum:02d}_{gslug}.ipynb" | |
| path.write_text(json.dumps(notebook, ensure_ascii=False, indent=1), encoding="utf-8") | |
| print(f"wrote {path.name} (stages {stage_nums})") | |
| if __name__ == "__main__": | |
| build() | |