Spaces:
Running on Zero
Running on Zero
| """Training backends behind one capability contract (spec §4.4). | |
| - MockBackend: instant simulated run — used by tests and as the Increment-1 | |
| acceptance gate; exercises the full state machine without any GPU. | |
| - ZeroGPUDemoBackend: real bounded LoRA run; caller must invoke .train() inside | |
| a GPU context (@spaces.GPU) — the backend itself never acquires GPU. | |
| - ColabExporter: pinned, self-contained export package. | |
| - HFJobsBackend: managed training submission (eligibility-gated). | |
| """ | |
| import io | |
| import json | |
| import pathlib | |
| import time | |
| import zipfile | |
| from src.config_loader import get_configs | |
| from src.schemas import ExperimentManifest | |
| from src.services.persistence import get_store | |
| def _log_stage(m: ExperimentManifest, stage: str, **info): | |
| m.stage_log.append({"stage": stage, "at": time.time(), **info}) | |
| get_store().save_manifest(m) | |
| class MockBackend: | |
| """Simulates a training run instantly; produces a plausible loss curve.""" | |
| name = "mock" | |
| def train(self, m: ExperimentManifest, records: list[dict], cfg: dict, progress=None): | |
| store = get_store() | |
| losses = [] | |
| steps = min(30, max(6, len(records) // 4)) | |
| for s in range(steps): | |
| losses.append(round(2.2 * (0.85 ** s) + 0.35, 4)) | |
| _log_stage(m, "training", note="mock run") | |
| store.save_artifact(m.run_id, "training_log.json", { | |
| "backend": "mock", "steps": steps, "losses": losses, | |
| "train_seconds": 0.1, "final_loss": losses[-1], | |
| }) | |
| return {"adapter_dir": None, "losses": losses, "train_seconds": 0.1} | |
| class ZeroGPUDemoBackend: | |
| """Bounded on-Space LoRA SFT. Limits are enforced by the routing engine | |
| BEFORE this is called and re-checked here (never trust the UI, spec §4.4).""" | |
| name = "zerogpu_demo" | |
| def train(self, m: ExperimentManifest, records: list[dict], cfg: dict, progress=None): | |
| lim = get_configs().limits.get("zerogpu_demo", {}) | |
| model_cfg = get_configs().model_by_name(cfg["model_name"]) | |
| assert model_cfg.params_b <= lim.get("max_params_b", 1.5), "demo limit: model too large" | |
| assert len(records) <= lim.get("max_samples", 5000), "demo limit: too many samples" | |
| assert cfg["epochs"] <= lim.get("max_epochs", 3), "demo limit: too many epochs" | |
| import torch | |
| from datasets import Dataset | |
| from peft import LoraConfig | |
| from transformers import AutoModelForCausalLM, AutoTokenizer, TrainerCallback | |
| from trl import SFTConfig, SFTTrainer | |
| t0 = time.time() | |
| _log_stage(m, "loading_base_model") | |
| tokenizer = AutoTokenizer.from_pretrained(model_cfg.repo) | |
| if tokenizer.pad_token is None: | |
| tokenizer.pad_token = tokenizer.eos_token | |
| use_cuda = torch.cuda.is_available() | |
| dtype = torch.bfloat16 if use_cuda and torch.cuda.is_bf16_supported() else torch.float32 | |
| model = AutoModelForCausalLM.from_pretrained( | |
| model_cfg.repo, dtype=dtype, device_map="auto" if use_cuda else None) | |
| model.config.use_cache = False | |
| losses = [] | |
| class LossCB(TrainerCallback): | |
| def on_log(self, args, state, control, logs=None, **kw): | |
| if logs and "loss" in logs: | |
| losses.append(logs["loss"]) | |
| _log_stage(m, "training", samples=len(records)) | |
| peft_cfg = LoraConfig( | |
| r=min(int(cfg.get("lora_r", 16)), lim.get("lora_r_max", 32)), | |
| lora_alpha=int(cfg.get("lora_alpha", 32)), | |
| lora_dropout=float(cfg.get("lora_dropout", 0.05)), | |
| target_modules="all-linear", bias="none", task_type="CAUSAL_LM") | |
| out_dir = str(get_store().artifact_path(m.run_id, "adapter")) | |
| sft_cfg = SFTConfig( | |
| output_dir=out_dir, | |
| num_train_epochs=int(cfg["epochs"]), | |
| per_device_train_batch_size=int(cfg.get("batch_size", 2)), | |
| gradient_accumulation_steps=int(cfg.get("grad_accum", 2)), | |
| learning_rate=float(cfg["learning_rate"]), | |
| lr_scheduler_type=cfg.get("scheduler", "cosine"), | |
| warmup_ratio=float(cfg.get("warmup_ratio", 0.03)), | |
| weight_decay=float(cfg.get("weight_decay", 0.0)), | |
| seed=int(cfg.get("seed", 42)), | |
| max_length=min(int(cfg.get("seq_len", 512)), lim.get("max_seq_len", 1024)), | |
| bf16=(dtype == torch.bfloat16), fp16=False, | |
| logging_steps=5, save_strategy="no", report_to="none", | |
| gradient_checkpointing=True, | |
| gradient_checkpointing_kwargs={"use_reentrant": False}) | |
| trainer = SFTTrainer(model=model, args=sft_cfg, | |
| train_dataset=Dataset.from_list(records), | |
| processing_class=tokenizer, peft_config=peft_cfg, | |
| callbacks=[LossCB()]) | |
| trainer.train() | |
| _log_stage(m, "saving_adapter") | |
| trainer.save_model(out_dir) | |
| tokenizer.save_pretrained(out_dir) | |
| secs = round(time.time() - t0, 1) | |
| get_store().save_artifact(m.run_id, "training_log.json", { | |
| "backend": self.name, "losses": losses, "train_seconds": secs, | |
| "final_loss": losses[-1] if losses else None, "steps": len(losses) * 5}) | |
| del trainer, model | |
| if use_cuda: | |
| torch.cuda.empty_cache() | |
| return {"adapter_dir": out_dir, "losses": losses, "train_seconds": secs} | |
| class ColabExporter: | |
| """Pinned self-contained package (spec §4.4): dependency versions, dataset + | |
| model revision, config hash, resume instructions, completion upload.""" | |
| def build(self, m: ExperimentManifest, cfg: dict) -> pathlib.Path: | |
| store = get_store() | |
| ds_path = store.artifact_path(m.run_id, "dataset.jsonl") | |
| train_deps = pathlib.Path("requirements-train.txt") | |
| deps = train_deps.read_text() if train_deps.exists() else "transformers>=4.56\npeft>=0.15\ntrl>=0.17\ndatasets>=3.2\naccelerate>=1.2\nbitsandbytes\n" | |
| nb = _notebook(m, cfg) | |
| readme = ( | |
| f"# MLOL Experiment {m.run_id}\n\n" | |
| f"Model: {cfg['model_repo']} @ {m.model_revision}\nConfig hash: {m.config_hash}\n" | |
| f"Dataset fingerprint: {m.dataset_fingerprint}\n\n" | |
| "1. Open training_notebook.ipynb in Google Colab (GPU runtime).\n" | |
| "2. Run all cells; it authenticates with YOUR HF token, trains with\n" | |
| " resume-from-checkpoint, pushes the adapter to your Hub account,\n" | |
| " and uploads completion metadata back to the MLOL experiment repo.\n" | |
| "3. If the session dies, re-run all cells — training resumes.\n") | |
| buf = io.BytesIO() | |
| with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: | |
| z.writestr(f"mlol_experiment_{m.run_id}/training_notebook.ipynb", json.dumps(nb, indent=1)) | |
| z.writestr(f"mlol_experiment_{m.run_id}/train_config.yaml", | |
| "\n".join(f"{k}: {v}" for k, v in cfg.items())) | |
| z.writestr(f"mlol_experiment_{m.run_id}/dataset_manifest.json", json.dumps({ | |
| "run_id": m.run_id, "fingerprint": m.dataset_fingerprint, | |
| "experiments_repo": "see manifest", "config_hash": m.config_hash}, indent=2)) | |
| z.writestr(f"mlol_experiment_{m.run_id}/requirements.txt", deps) | |
| z.writestr(f"mlol_experiment_{m.run_id}/README.md", readme) | |
| if ds_path.exists(): | |
| z.write(ds_path, f"mlol_experiment_{m.run_id}/dataset.jsonl") | |
| out = store.save_binary(m.run_id, f"mlol_experiment_{m.run_id}.zip", buf.getvalue()) | |
| return out | |
| def verify_completion(self, m: ExperimentManifest, completion: dict) -> tuple[bool, str]: | |
| """Spec §4.4 completion verification: run id + config hash must match.""" | |
| if completion.get("run_id") != m.run_id: | |
| return False, "run_id mismatch" | |
| if completion.get("config_hash") != m.config_hash: | |
| return False, "config hash mismatch — training used a modified configuration" | |
| if not completion.get("adapter_repo"): | |
| return False, "no adapter repo reported" | |
| return True, "verified" | |
| def _notebook(m: ExperimentManifest, cfg: dict) -> dict: | |
| cells = [ | |
| f"# MLOL experiment {m.run_id} — pinned training package\n" | |
| f"# config_hash={m.config_hash} dataset_fingerprint={m.dataset_fingerprint}\n" | |
| "!pip install -q -r requirements.txt", | |
| "from huggingface_hub import notebook_login\nnotebook_login() # YOUR token — trains and pushes under your account", | |
| "import json\nrecords=[json.loads(l) for l in open('dataset.jsonl')]\nprint(len(records),'samples')", | |
| _train_cell(m, cfg), | |
| _push_cell(m, cfg), | |
| ] | |
| return {"nbformat": 4, "nbformat_minor": 5, | |
| "metadata": {"accelerator": "GPU", "language_info": {"name": "python"}}, | |
| "cells": [{"cell_type": "code", "metadata": {}, "execution_count": None, | |
| "outputs": [], "source": c} for c in cells]} | |
| def _train_cell(m, cfg): | |
| return f"""import os, torch | |
| from datasets import Dataset | |
| from peft import LoraConfig | |
| from transformers import AutoModelForCausalLM, AutoTokenizer | |
| from trl import SFTConfig, SFTTrainer | |
| repo = {cfg['model_repo']!r} | |
| tok = AutoTokenizer.from_pretrained(repo, revision={m.model_revision!r}) | |
| if tok.pad_token is None: tok.pad_token = tok.eos_token | |
| model = AutoModelForCausalLM.from_pretrained(repo, revision={m.model_revision!r}, | |
| dtype=torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16, device_map='auto') | |
| model.config.use_cache = False | |
| resume = os.path.isdir('out') and any(d.startswith('checkpoint') for d in os.listdir('out')) | |
| trainer = SFTTrainer(model=model, | |
| args=SFTConfig(output_dir='out', num_train_epochs={cfg['epochs']}, | |
| per_device_train_batch_size={cfg.get('batch_size', 2)}, | |
| gradient_accumulation_steps={cfg.get('grad_accum', 4)}, | |
| learning_rate={cfg['learning_rate']}, lr_scheduler_type={cfg.get('scheduler', 'cosine')!r}, | |
| warmup_ratio={cfg.get('warmup_ratio', 0.03)}, weight_decay={cfg.get('weight_decay', 0.0)}, | |
| seed={cfg.get('seed', 42)}, max_length={cfg.get('seq_len', 1024)}, | |
| bf16=torch.cuda.is_bf16_supported(), save_steps=100, save_total_limit=2, | |
| logging_steps=10, report_to='none'), | |
| train_dataset=Dataset.from_list(records), processing_class=tok, | |
| peft_config=LoraConfig(r={cfg.get('lora_r', 16)}, lora_alpha={cfg.get('lora_alpha', 32)}, | |
| lora_dropout={cfg.get('lora_dropout', 0.05)}, target_modules='all-linear', | |
| bias='none', task_type='CAUSAL_LM')) | |
| trainer.train(resume_from_checkpoint=resume) | |
| trainer.save_model('out/final'); tok.save_pretrained('out/final')""" | |
| def _push_cell(m, cfg): | |
| return f"""from huggingface_hub import HfApi, whoami | |
| import json, time | |
| user = whoami()['name'] | |
| adapter_repo = f"{{user}}/mlol-{m.run_id}" | |
| api = HfApi() | |
| api.create_repo(adapter_repo, private=True, exist_ok=True) | |
| api.upload_folder(folder_path='out/final', repo_id=adapter_repo) | |
| completion = {{"run_id": {m.run_id!r}, "config_hash": {m.config_hash!r}, | |
| "adapter_repo": adapter_repo, "finished_at": time.time()}} | |
| json.dump(completion, open('completion.json','w')) | |
| try: | |
| api.upload_file(path_or_fileobj='completion.json', | |
| path_in_repo=f'experiments/{m.run_id}/completion.json', | |
| repo_id='finpy1789/mlol-experiments', repo_type='dataset') | |
| print('completion metadata uploaded — the MLOL dashboard will pick it up') | |
| except Exception as e: | |
| print('could not upload completion metadata (no write access):', e) | |
| print('paste completion.json into the MLOL UI instead') | |
| print('adapter:', adapter_repo)""" | |
| class HFJobsBackend: | |
| """Managed training via HF Jobs. Submission only when eligibility verified.""" | |
| name = "hf_job" | |
| def eligible(self) -> tuple[bool, str]: | |
| try: | |
| from huggingface_hub import HfApi | |
| api = HfApi() | |
| if not hasattr(api, "run_job"): | |
| return False, "installed huggingface_hub has no Jobs API" | |
| api.whoami() | |
| return True, "token present — charges bill to the token's account" | |
| except Exception as e: # noqa: BLE001 | |
| return False, f"not signed in: {e}" | |
| def submit(self, m: ExperimentManifest, cfg: dict, flavor: str = "a10g-small"): | |
| from huggingface_hub import HfApi | |
| api = HfApi() | |
| job = api.run_job( | |
| image="pytorch/pytorch:2.4.0-cuda12.1-cudnn9-runtime", | |
| command=["bash", "-lc", | |
| "pip install -q transformers peft trl datasets accelerate huggingface_hub && " | |
| f"python -c \"print('MLOL job for run {m.run_id}')\""], | |
| flavor=flavor, | |
| ) | |
| _log_stage(m, "job_submitted", job_id=getattr(job, "id", str(job))) | |
| return job | |