| """ |
| train.py — HuggingFace Training Job entrypoint |
| |
| HF Training Jobs call this file directly: python train.py |
| All configuration is driven by environment variables set in the HF job UI, |
| with sensible defaults for the baseline run. |
| |
| STAGE CONTROL (set via HF job env vars): |
| TRAIN_STAGE=baseline → record Phi-3-Mini zero-shot GSM8K score only |
| TRAIN_STAGE=sft → SFT warm-up (requires baseline done first) |
| TRAIN_STAGE=grpo → GRPO without curriculum |
| TRAIN_STAGE=curriculum → GRPO with curriculum gating (full pipeline) |
| |
| For the FIRST run, set TRAIN_STAGE=baseline. |
| This records the baseline score and exits — fast, cheap, confirms the setup works. |
| |
| REQUIRED ENV VARS (set in HF job secrets/env): |
| HF_TOKEN → your HuggingFace token (for model download + output push) |
| WANDB_API_KEY → your W&B key (optional; training still runs without it) |
| |
| OPTIONAL ENV VARS: |
| TRAIN_STAGE default: baseline |
| OUTPUT_REPO default: (your-hf-username)/ps2-slm-rl-checkpoints |
| BASE_MODEL_ID default: microsoft/Phi-3-mini-4k-instruct |
| EVAL_LIMIT default: 500 (GSM8K examples to eval on) |
| BATCH_SIZE default: 1 |
| GRAD_ACCUM default: 16 |
| LR default: 2e-4 (SFT) / 1e-5 (GRPO) |
| NUM_EPOCHS default: 2 (SFT) / 1 (GRPO) |
| LORA_R default: 16 |
| GRPO_BETA default: 0.05 |
| GRPO_NUM_GENERATIONS default: 8 |
| USE_CURRICULUM default: 1 (1=yes, 0=no; only used in curriculum stage) |
| HF_HUB_ENABLE_HF_TRANSFER default: 1 (set to 1 for faster model downloads) |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| import logging |
| import os |
| import sys |
|
|
| logging.basicConfig( |
| level=logging.INFO, |
| format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", |
| handlers=[logging.StreamHandler(sys.stdout)], |
| ) |
| logger = logging.getLogger("train") |
|
|
|
|
| |
| |
| |
|
|
| def env(key: str, default: str = "") -> str: |
| return os.environ.get(key, default) |
|
|
|
|
| def env_int(key: str, default: int) -> int: |
| return int(os.environ.get(key, default)) |
|
|
|
|
| def env_float(key: str, default: float) -> float: |
| return float(os.environ.get(key, default)) |
|
|
|
|
| def env_bool(key: str, default: bool = True) -> bool: |
| val = os.environ.get(key, "") |
| if not val: |
| return default |
| return val.strip().lower() not in ("0", "false", "no") |
|
|
|
|
| |
| |
| |
|
|
| def setup_environment(): |
| |
| if env_bool("HF_HUB_ENABLE_HF_TRANSFER", True): |
| os.environ["HF_HUB_ENABLE_HF_TRANSFER"] = "1" |
| try: |
| import hf_transfer |
| logger.info("hf_transfer enabled — faster model downloads") |
| except ImportError: |
| logger.warning("hf_transfer not installed; falling back to standard download") |
| os.environ["HF_HUB_ENABLE_HF_TRANSFER"] = "0" |
|
|
| |
| hf_token = env("HF_TOKEN") |
| if hf_token: |
| from huggingface_hub import login |
| login(token=hf_token, add_to_git_credential=False) |
| logger.info("HuggingFace login OK") |
| else: |
| logger.warning("HF_TOKEN not set — model download may fail for gated models") |
|
|
| |
| wandb_key = env("WANDB_API_KEY") |
| if wandb_key: |
| import wandb |
| wandb.login(key=wandb_key) |
| logger.info("W&B login OK") |
| else: |
| logger.info("WANDB_API_KEY not set — disabling W&B logging") |
| os.environ["WANDB_DISABLED"] = "true" |
|
|
| |
| import torch |
| logger.info("PyTorch: %s", torch.__version__) |
| if torch.cuda.is_available(): |
| logger.info("GPU: %s | VRAM: %.1f GB | bfloat16: %s", |
| torch.cuda.get_device_name(0), |
| torch.cuda.get_device_properties(0).total_memory / 1e9, |
| torch.cuda.is_bf16_supported()) |
| else: |
| logger.warning("No GPU detected — training will be extremely slow") |
|
|
|
|
| |
| |
| |
|
|
| def build_config() -> dict: |
| import torch |
| bf16_ok = torch.cuda.is_available() and torch.cuda.is_bf16_supported() |
|
|
| base_model = env("BASE_MODEL_ID", "microsoft/Phi-3-mini-4k-instruct") |
| stage = env("TRAIN_STAGE", "baseline") |
|
|
| cfg = { |
| "stage": stage, |
| "model": { |
| "base_id": base_model, |
| "attn_implementation": "eager", |
| "torch_dtype": "bfloat16" if bf16_ok else "float16", |
| }, |
| "lora": { |
| "r": env_int("LORA_R", 16), |
| "lora_alpha": env_int("LORA_R", 16) * 2, |
| "lora_dropout": 0.05, |
| "bias": "none", |
| "task_type": "CAUSAL_LM", |
| |
| "target_modules": [ |
| "q_proj", "k_proj", "v_proj", "o_proj", |
| "gate_up_proj", "down_proj", |
| ], |
| }, |
| "data": { |
| "gsm8k_fraction": 0.80, |
| "aqua_fraction": 0.20, |
| "val_size": 200, |
| "max_seq_length": 1024, |
| }, |
| "training": { |
| "output_dir": "/tmp/checkpoints/sft", |
| "num_train_epochs": env_int("NUM_EPOCHS", 2), |
| "per_device_train_batch_size": env_int("BATCH_SIZE", 1), |
| "gradient_accumulation_steps": env_int("GRAD_ACCUM", 16), |
| "learning_rate": env_float("LR", 2e-4), |
| "lr_scheduler_type": "cosine", |
| "warmup_ratio": 0.05, |
| "bf16": bf16_ok, |
| "fp16": not bf16_ok, |
| "gradient_checkpointing": True, |
| "logging_steps": 20, |
| "save_strategy": "epoch", |
| "eval_strategy": "steps", |
| "eval_steps": 200, |
| "save_total_limit": 2, |
| |
| "report_to": "wandb" if os.environ.get("WANDB_API_KEY") else "none", |
| "run_name": f"ps2-{stage}", |
| }, |
| "grpo": { |
| "output_dir": "/tmp/checkpoints/grpo", |
| "beta": env_float("GRPO_BETA", 0.05), |
| "num_generations": env_int("GRPO_NUM_GENERATIONS", 8), |
| "max_completion_length": 512, |
| "temperature": 0.8, |
| "top_p": 0.95, |
| "epsilon": 0.2, |
| }, |
| "reward": { |
| "outcome_weight": 1.0, |
| "process_weight": 0.3, |
| "step_weight": 0.1, |
| "format_penalty": -0.1, |
| "reward_cap": 1.5, |
| }, |
| "eval": { |
| "limit": env_int("EVAL_LIMIT", 500), |
| "batch_size": env_int("BATCH_SIZE", 4), |
| "tasks": ["gsm8k"], |
| }, |
| "output_repo": env("OUTPUT_REPO", ""), |
| } |
| return cfg |
|
|
|
|
| |
| |
| |
|
|
| def run_baseline(cfg: dict) -> dict: |
| """ |
| Evaluate Phi-3-Mini zero-shot on GSM8K. |
| Records the baseline score to /tmp/results/baseline.json |
| and optionally pushes it to the output HF repo. |
| Returns the scores dict. |
| """ |
| logger.info("=" * 60) |
| logger.info("STAGE: baseline") |
| logger.info("Model: %s", cfg["model"]["base_id"]) |
| logger.info("GSM8K limit: %d examples", cfg["eval"]["limit"]) |
| logger.info("=" * 60) |
|
|
| from lm_eval import simple_evaluate |
|
|
| model_args = ( |
| f"pretrained={cfg['model']['base_id']}," |
| f"trust_remote_code=True," |
| f"dtype={cfg['model']['torch_dtype']}" |
| ) |
|
|
| logger.info("Running lm_eval on GSM8K (zero-shot)...") |
| results = simple_evaluate( |
| model="hf", |
| model_args=model_args, |
| tasks=["gsm8k"], |
| num_fewshot=0, |
| batch_size=cfg["eval"]["batch_size"], |
| limit=cfg["eval"]["limit"], |
| log_samples=False, |
| ) |
|
|
| gsm8k_raw = results["results"]["gsm8k"] |
| logger.info("GSM8K raw result keys: %s", list(gsm8k_raw.keys())) |
|
|
| |
| score = None |
| for key in ["exact_match,flexible-extract", "exact_match,strict-match", "acc,none"]: |
| if key in gsm8k_raw: |
| score = gsm8k_raw[key] |
| logger.info("Using metric key: %s", key) |
| break |
|
|
| if score is None: |
| logger.error("Could not find score in result: %s", gsm8k_raw) |
| score = 0.0 |
|
|
| scores = { |
| "stage": "baseline", |
| "model": cfg["model"]["base_id"], |
| "gsm8k_zero_shot": round(score, 4), |
| "gsm8k_zero_shot_pct": round(score * 100, 2), |
| "eval_limit": cfg["eval"]["limit"], |
| "raw_keys": gsm8k_raw, |
| } |
|
|
| logger.info("=" * 60) |
| logger.info("BASELINE GSM8K: %.2f%%", score * 100) |
| logger.info("=" * 60) |
|
|
| |
| os.makedirs("/tmp/results", exist_ok=True) |
| with open("/tmp/results/baseline.json", "w") as f: |
| json.dump(scores, f, indent=2) |
| logger.info("Saved: /tmp/results/baseline.json") |
|
|
| |
| _push_results_to_hub(cfg, "/tmp/results/baseline.json", "results/baseline.json") |
|
|
| return scores |
|
|
|
|
| |
| |
| |
|
|
| def run_sft(cfg: dict) -> str: |
| """ |
| Run SFT warm-up. Returns path to saved checkpoint. |
| Checkpoint is also pushed to HF hub repo if OUTPUT_REPO is set. |
| """ |
| logger.info("=" * 60) |
| logger.info("STAGE: sft") |
| logger.info("=" * 60) |
|
|
| from datasets import Dataset |
| from trl import SFTConfig, SFTTrainer |
| from peft import LoraConfig, TaskType |
|
|
| from src.data.dataset import build_sft_dataset |
| from src.model.loader import load_base_model, load_tokenizer |
|
|
| |
| logger.info("Loading SFT datasets...") |
| train_ds, val_ds = build_sft_dataset( |
| gsm8k_fraction=cfg["data"]["gsm8k_fraction"], |
| aqua_fraction=cfg["data"]["aqua_fraction"], |
| val_size=cfg["data"]["val_size"], |
| ) |
|
|
| |
| logger.info("Loading model: %s", cfg["model"]["base_id"]) |
| model = load_base_model( |
| model_id=cfg["model"]["base_id"], |
| torch_dtype=cfg["model"]["torch_dtype"], |
| attn_implementation=cfg["model"]["attn_implementation"], |
| ) |
| tok = load_tokenizer(cfg["model"]["base_id"]) |
|
|
| |
| lc = cfg["lora"] |
| lora_cfg = LoraConfig( |
| task_type=TaskType.CAUSAL_LM, |
| r=lc["r"], |
| lora_alpha=lc["lora_alpha"], |
| lora_dropout=lc["lora_dropout"], |
| bias=lc["bias"], |
| target_modules=lc["target_modules"], |
| ) |
|
|
| |
| t = cfg["training"] |
| sft_cfg = SFTConfig( |
| output_dir=t["output_dir"], |
| num_train_epochs=t["num_train_epochs"], |
| per_device_train_batch_size=t["per_device_train_batch_size"], |
| gradient_accumulation_steps=t["gradient_accumulation_steps"], |
| learning_rate=t["learning_rate"], |
| lr_scheduler_type=t["lr_scheduler_type"], |
| warmup_ratio=t["warmup_ratio"], |
| bf16=t["bf16"], |
| fp16=t.get("fp16", False), |
| gradient_checkpointing=t["gradient_checkpointing"], |
| logging_steps=t["logging_steps"], |
| save_strategy=t["save_strategy"], |
| eval_strategy=t["eval_strategy"], |
| eval_steps=t["eval_steps"], |
| save_total_limit=t["save_total_limit"], |
| report_to=t["report_to"], |
| run_name=t["run_name"], |
| dataset_text_field="text", |
| max_length=cfg["data"]["max_seq_length"], |
| packing=False, |
| ) |
|
|
| trainer = SFTTrainer( |
| model=model, |
| args=sft_cfg, |
| train_dataset=train_ds, |
| eval_dataset=val_ds, |
| processing_class=tok, |
| peft_config=lora_cfg, |
| ) |
|
|
| logger.info("Starting SFT training...") |
| trainer.train() |
| trainer.save_model(t["output_dir"]) |
| tok.save_pretrained(t["output_dir"]) |
| logger.info("SFT checkpoint saved: %s", t["output_dir"]) |
|
|
| _push_checkpoint_to_hub(cfg, t["output_dir"], "sft") |
| return t["output_dir"] |
|
|
|
|
| |
| |
| |
|
|
| def run_grpo(cfg: dict, use_curriculum: bool = False) -> str: |
| """ |
| Run GRPO training (with or without curriculum). |
| Returns path to final checkpoint. |
| """ |
| stage_name = "curriculum_grpo" if use_curriculum else "grpo" |
| logger.info("=" * 60) |
| logger.info("STAGE: %s", stage_name) |
| logger.info("=" * 60) |
|
|
| |
| |
| import yaml as _yaml |
| from scripts.train_grpo import simple_grpo_train, curriculum_grpo_train |
| from src.data.dataset import build_rl_dataset |
| from src.training.curriculum import CurriculumConfig |
|
|
| |
| grpo_script_cfg = { |
| "model": { |
| **cfg["model"], |
| "sft_checkpoint": cfg["training"]["output_dir"], |
| }, |
| "lora": cfg["lora"], |
| "training": { |
| **cfg["training"], |
| "output_dir": cfg["grpo"]["output_dir"], |
| "learning_rate": env_float("LR", 1e-5), |
| "num_train_epochs": env_int("NUM_EPOCHS", 1), |
| "run_name": f"ps2-{stage_name}", |
| "save_strategy": "steps", |
| "save_steps": 200, |
| }, |
| "grpo": cfg["grpo"], |
| "reward": cfg["reward"], |
| } |
|
|
| full_dataset = build_rl_dataset() |
|
|
| if use_curriculum: |
| cur_cfg = CurriculumConfig() |
| out_dir = curriculum_grpo_train(grpo_script_cfg, full_dataset, cur_cfg) |
| else: |
| out_dir = simple_grpo_train(grpo_script_cfg, full_dataset) |
|
|
| _push_checkpoint_to_hub(cfg, out_dir, stage_name) |
| return out_dir |
|
|
|
|
| |
| |
| |
|
|
| def _push_results_to_hub(cfg: dict, local_path: str, repo_path: str) -> None: |
| """Push a results file to the output HF repo.""" |
| repo = cfg.get("output_repo", "") |
| if not repo: |
| logger.info("OUTPUT_REPO not set — skipping hub push for %s", local_path) |
| return |
| try: |
| from huggingface_hub import HfApi |
| api = HfApi() |
| api.upload_file( |
| path_or_fileobj=local_path, |
| path_in_repo=repo_path, |
| repo_id=repo, |
| repo_type="model", |
| ) |
| logger.info("Pushed %s → %s/%s", local_path, repo, repo_path) |
| except Exception as e: |
| logger.error("Hub push failed: %s", e) |
|
|
|
|
| def _push_checkpoint_to_hub(cfg: dict, local_dir: str, subfolder: str) -> None: |
| """Push a checkpoint directory to the output HF repo.""" |
| repo = cfg.get("output_repo", "") |
| if not repo: |
| logger.info("OUTPUT_REPO not set — skipping checkpoint push") |
| return |
| try: |
| from huggingface_hub import HfApi |
| api = HfApi() |
| api.upload_folder( |
| folder_path=local_dir, |
| path_in_repo=f"checkpoints/{subfolder}", |
| repo_id=repo, |
| repo_type="model", |
| ) |
| logger.info("Pushed checkpoint → %s/checkpoints/%s", repo, subfolder) |
| except Exception as e: |
| logger.error("Hub checkpoint push failed: %s", e) |
|
|
|
|
| |
| |
| |
|
|
| def main(): |
| setup_environment() |
| cfg = build_config() |
| stage = cfg["stage"] |
|
|
| logger.info("=" * 60) |
| logger.info("PS2 — RL-Enhanced SLM Reasoning") |
| logger.info("Stage: %s", stage) |
| logger.info("Model: %s", cfg["model"]["base_id"]) |
| logger.info("=" * 60) |
|
|
| if stage == "baseline": |
| scores = run_baseline(cfg) |
| logger.info("Done. GSM8K baseline: %.2f%%", scores["gsm8k_zero_shot_pct"]) |
|
|
| elif stage == "sft": |
| ckpt = run_sft(cfg) |
| logger.info("Done. SFT checkpoint: %s", ckpt) |
|
|
| elif stage == "grpo": |
| ckpt = run_grpo(cfg, use_curriculum=False) |
| logger.info("Done. GRPO checkpoint: %s", ckpt) |
|
|
| elif stage == "curriculum": |
| ckpt = run_grpo(cfg, use_curriculum=True) |
| logger.info("Done. Curriculum GRPO checkpoint: %s", ckpt) |
|
|
| else: |
| logger.error("Unknown TRAIN_STAGE='%s'. Must be one of: baseline, sft, grpo, curriculum", stage) |
| sys.exit(1) |
|
|
|
|
| if __name__ == "__main__": |
| main() |