| |
| """ |
| BabyLM Challenge 2026 - Batch Experiment Runner |
| |
| Reads experiments.csv, generates YAML configs, runs training, and collects results. |
| |
| Usage: |
| # Run all Phase 1 experiments |
| python run_experiments.py --phase 1 |
| |
| # Run specific experiments by ID |
| python run_experiments.py --ids P1.1 P1.2 P1.3 |
| |
| # Run all experiments in a phase range |
| python run_experiments.py --phase 1 2 3A 3B |
| |
| # Dry run (generate configs but don't train) |
| python run_experiments.py --phase 1 --dry-run |
| |
| # Resume (skip already completed experiments) |
| python run_experiments.py --phase 1 --resume |
| """ |
|
|
| import argparse |
| import csv |
| import json |
| import os |
| import subprocess |
| import sys |
| import time |
| import yaml |
| from pathlib import Path |
| from datetime import datetime |
|
|
| ROOT = Path(__file__).resolve().parent |
| EXPERIMENTS_CSV = ROOT / "experiments.csv" |
| CONFIGS_DIR = ROOT / "experiment_configs" |
| RESULTS_DIR = ROOT / "experiment_results" |
| RESULTS_JSON = RESULTS_DIR / "all_results.json" |
|
|
|
|
| |
| |
| |
|
|
| |
| CSV_TO_YAML = { |
| |
| "arch": ("model", "arch"), |
| "hidden_size": ("model", "hidden_size"), |
| "num_layers": ("model", "num_layers"), |
| "num_heads": ("model", "num_heads"), |
| "intermediate_size": ("model", "intermediate_size"), |
| "use_rope": ("model", "use_rope"), |
| "use_geglu": ("model", "use_geglu"), |
| "use_pre_norm": ("model", "use_pre_norm"), |
| "use_attention_gate": ("model", "use_attention_gate"), |
| "use_dwa": ("model", "use_dwa"), |
| "use_moe": ("model", "use_moe"), |
| "moe_num_experts": ("model", "moe_num_experts"), |
| "moe_top_k": ("model", "moe_top_k"), |
| "moe_expert_size": ("model", "moe_expert_size"), |
| "moe_freq_penalty": ("model", "moe_freq_penalty"), |
| "use_attn_res": ("model", "use_attn_res"), |
| "attn_res_num_blocks":("model", "attn_res_num_blocks"), |
| "dropout": ("model", "dropout"), |
| "position_bucket_size":("model", "position_bucket_size"), |
| "z_loss_weight": ("model", "z_loss_weight"), |
| "rope_theta": ("model", "rope_theta"), |
| "rtd_lambda": ("model", "rtd_lambda"), |
| "gen_size_ratio": ("model", "gen_size_ratio"), |
| |
| "data": ("data", "train_file"), |
| "tokenizer": ("data", "tokenizer"), |
| "seq_len": ("data", "max_seq_len"), |
| |
| "embedding": ("embedding", "type"), |
| "embedding_init": ("embedding", "init"), |
| |
| "objective": ("training", "objective"), |
| "epochs": ("training", "epochs"), |
| "batch_size": ("training", "batch_size"), |
| "lr": ("training", "learning_rate"), |
| "weight_decay": ("training", "weight_decay"), |
| "warmup_ratio": ("training", "warmup_ratio"), |
| "max_grad_norm": ("training", "max_grad_norm"), |
| "grad_accum": ("training", "gradient_accumulation_steps"), |
| "mntp_ratio": ("training", "mntp_ratio"), |
| "seed": ("training", "seed"), |
| |
| "masking": ("masking", "type"), |
| "mask_ratio": ("masking", "mask_ratio"), |
| "mask_ratio_end": ("masking", "mask_ratio_end"), |
| "amlm_lambda": ("masking", "amlm_lambda"), |
| "amlm_update_interval":("masking", "amlm_update_interval"), |
| "amlm_min_ratio": ("masking", "amlm_min_ratio"), |
| "amlm_max_ratio": ("masking", "amlm_max_ratio"), |
| |
| "optimizer": ("optimizer", "type"), |
| "optimizer_betas": ("optimizer", "betas"), |
| "forgetter": ("optimizer", "forgetter"), |
| |
| "kd_enabled": ("distillation", "enabled"), |
| "kd_teacher": ("distillation", "teacher_model"), |
| "kd_temperature": ("distillation", "temperature"), |
| "kd_alpha": ("distillation", "alpha"), |
| |
| "ckpt_averaging": ("checkpoint", "averaging"), |
| "ckpt_avg_last_k": ("checkpoint", "avg_last_k"), |
| } |
|
|
| |
| DATA_PATH_MAP = { |
| "sample_A": "data/8_sample_A/train.txt", |
| "sample_B": "data/8_sample_B/train.txt", |
| "sample_C": "data/8_sample_C/train.txt", |
| "sample_D": "data/8_sample_D/train.txt", |
| "champion_replica": "data/8_champion_replica/train.txt", |
| "B_paraphrase": "data/8_B_paraphrase/train.txt", |
| "B_variation_sets": "data/8_B_variation_sets/train.txt", |
| "B_recombitext": "data/8_B_recombitext/train.txt", |
| "B_cd_synth": "data/8_B_cd_synth/train.txt", |
| "B_mattr": "data/8_B_mattr/train.txt", |
| "eval_mixed": "data/8_eval_mixed/train.txt", |
| "no_eval": "data/8_no_eval/train.txt", |
| "full_augment": "data/8_full_augment/train.txt", |
| "B_fineweb33": "data/8_B_fineweb33/train.txt", |
| "B_fineweb67": "data/8_B_fineweb67/train.txt", |
| "B_knowledge": "data/8_B_knowledge/train.txt", |
| "B_para_fineweb": "data/8_B_para_fineweb/train.txt", |
| "3way_equal": "data/8_3way_equal/train.txt", |
| } |
|
|
|
|
| def parse_value(val: str): |
| """Parse a CSV string value to the appropriate Python type.""" |
| if val == "": |
| return None |
| if val in ("True", "true"): |
| return True |
| if val in ("False", "false"): |
| return False |
| if val == "none": |
| return None |
| try: |
| return int(val) |
| except ValueError: |
| pass |
| try: |
| return float(val) |
| except ValueError: |
| pass |
| return val |
|
|
|
|
| def parse_betas(val: str): |
| """Parse betas string like '(0.9,0.98)' to list [0.9, 0.98].""" |
| if not val or val == "None": |
| return [0.9, 0.98] |
| cleaned = val.strip("()") |
| parts = [float(x.strip()) for x in cleaned.split(",")] |
| return parts |
|
|
|
|
| def csv_row_to_yaml(row: dict) -> dict: |
| """Convert a CSV experiment row to a YAML config dict.""" |
| yaml_dict = {"name": row["name"]} |
| sections = {} |
|
|
| for csv_col, (section, key) in CSV_TO_YAML.items(): |
| val = row.get(csv_col) |
| if val is None or val == "": |
| continue |
|
|
| if section not in sections: |
| sections[section] = {} |
|
|
| |
| if csv_col == "data": |
| |
| data_id = val |
| path = DATA_PATH_MAP.get(data_id, f"data/8_{data_id}/train.txt") |
| sections[section][key] = path |
| continue |
|
|
| if csv_col == "optimizer_betas": |
| sections[section][key] = parse_betas(val) |
| continue |
|
|
| if csv_col == "masking" and val == "none": |
| sections[section][key] = "standard" |
| continue |
|
|
| sections[section][key] = parse_value(val) |
|
|
| yaml_dict.update(sections) |
| return yaml_dict |
|
|
|
|
| |
| |
| |
|
|
| def load_experiments(csv_path: Path) -> list[dict]: |
| """Load experiments from CSV, skipping comment rows.""" |
| experiments = [] |
| with open(csv_path) as f: |
| reader = csv.DictReader(f) |
| for row in reader: |
| if row.get("id", "").startswith("##"): |
| continue |
| experiments.append(row) |
| return experiments |
|
|
|
|
| def filter_experiments(experiments: list[dict], |
| phases: list[str] = None, |
| ids: list[str] = None) -> list[dict]: |
| """Filter experiments by phase or ID.""" |
| if ids: |
| return [e for e in experiments if e["id"] in ids] |
| if phases: |
| return [e for e in experiments if e.get("phase") in phases] |
| return experiments |
|
|
|
|
| |
| |
| |
|
|
| def run_experiment(exp: dict, dry_run: bool = False) -> dict: |
| """Generate YAML config, run training, return result info.""" |
| exp_id = exp["id"] |
| exp_name = exp["name"] |
|
|
| |
| CONFIGS_DIR.mkdir(parents=True, exist_ok=True) |
| yaml_config = csv_row_to_yaml(exp) |
| yaml_path = CONFIGS_DIR / f"{exp_id.replace('.', '_')}.yaml" |
|
|
| with open(yaml_path, "w") as f: |
| yaml.dump(yaml_config, f, default_flow_style=False, allow_unicode=True) |
|
|
| print(f"\n{'='*60}") |
| print(f" Experiment {exp_id}: {exp_name}") |
| print(f" Config: {yaml_path}") |
| print(f" Phase: {exp.get('phase', '?')}") |
| print(f"{'='*60}") |
|
|
| if dry_run: |
| print(f" [DRY RUN] Would run: python -m scripts.03_training.train --config {yaml_path}") |
| return {"id": exp_id, "name": exp_name, "status": "dry_run", "config": str(yaml_path)} |
|
|
| |
| data_id = exp.get("data", "sample_B") |
| data_path = ROOT / DATA_PATH_MAP.get(data_id, f"data/8_{data_id}/train.txt") |
| if not data_path.exists(): |
| print(f" [SKIP] Data file not found: {data_path}") |
| return {"id": exp_id, "name": exp_name, "status": "skipped", "reason": f"data not found: {data_path}"} |
|
|
| |
| start_time = time.time() |
| cmd = [ |
| sys.executable, "-m", "scripts.03_training.train", |
| "--config", str(yaml_path), |
| ] |
|
|
| try: |
| result = subprocess.run( |
| cmd, cwd=str(ROOT), |
| capture_output=True, text=True, timeout=7200, |
| ) |
| elapsed = time.time() - start_time |
|
|
| |
| RESULTS_DIR.mkdir(parents=True, exist_ok=True) |
| log_path = RESULTS_DIR / f"{exp_id.replace('.', '_')}.log" |
| with open(log_path, "w") as f: |
| f.write(f"=== STDOUT ===\n{result.stdout}\n\n=== STDERR ===\n{result.stderr}") |
|
|
| status = "completed" if result.returncode == 0 else "failed" |
| exp_result = { |
| "id": exp_id, "name": exp_name, "status": status, |
| "returncode": result.returncode, |
| "elapsed_sec": round(elapsed), |
| "config": str(yaml_path), "log": str(log_path), |
| "timestamp": datetime.now().isoformat(), |
| } |
|
|
| |
| if status == "completed": |
| eval_results_file = ROOT / "models" / exp_name / "eval_results" / "eval_results.json" |
| if eval_results_file.exists(): |
| try: |
| with open(eval_results_file) as ef: |
| eval_data = json.load(ef) |
| summary = eval_data.get("summary", {}) |
| exp_result["eval_summary"] = summary |
| |
| if "blimp" in summary: |
| exp_result["blimp"] = summary["blimp"] |
| if "ewok" in summary: |
| exp_result["ewok"] = summary["ewok"] |
| if "avg_zero_shot" in summary: |
| exp_result["avg_zero_shot"] = summary["avg_zero_shot"] |
| print(f" Eval results captured: BLiMP={summary.get('blimp', 'N/A')}, " |
| f"Avg={summary.get('avg_zero_shot', 'N/A')}") |
| except Exception as e: |
| print(f" WARNING: Could not read eval results: {e}") |
| else: |
| print(f" NOTE: No eval results found at {eval_results_file}") |
|
|
| return exp_result |
|
|
| except subprocess.TimeoutExpired: |
| return {"id": exp_id, "name": exp_name, "status": "timeout"} |
| except Exception as e: |
| return {"id": exp_id, "name": exp_name, "status": "error", "error": str(e)} |
|
|
|
|
| |
| |
| |
|
|
| def load_results() -> dict: |
| """Load existing results from JSON.""" |
| if RESULTS_JSON.exists(): |
| with open(RESULTS_JSON) as f: |
| return json.load(f) |
| return {} |
|
|
|
|
| def save_result(result: dict): |
| """Append one result to the results JSON.""" |
| RESULTS_DIR.mkdir(parents=True, exist_ok=True) |
| results = load_results() |
| results[result["id"]] = result |
| with open(RESULTS_JSON, "w") as f: |
| json.dump(results, f, indent=2, ensure_ascii=False) |
|
|
|
|
| def is_completed(exp_id: str) -> bool: |
| """Check if an experiment was already completed.""" |
| results = load_results() |
| return results.get(exp_id, {}).get("status") == "completed" |
|
|
|
|
| |
| |
| |
|
|
| def main(): |
| parser = argparse.ArgumentParser(description="BabyLM Batch Experiment Runner") |
| parser.add_argument("--phase", nargs="*", help="Phase(s) to run (e.g., 1 2 3A)") |
| parser.add_argument("--ids", nargs="*", help="Specific experiment IDs (e.g., P1.1 P2.3)") |
| parser.add_argument("--dry-run", action="store_true", help="Generate configs only, don't train") |
| parser.add_argument("--resume", action="store_true", help="Skip already completed experiments") |
| parser.add_argument("--csv", default=str(EXPERIMENTS_CSV), help="Path to experiments CSV") |
| parser.add_argument("--list", action="store_true", help="List experiments without running") |
| args = parser.parse_args() |
|
|
| |
| experiments = load_experiments(Path(args.csv)) |
| filtered = filter_experiments(experiments, phases=args.phase, ids=args.ids) |
|
|
| if not filtered: |
| print("No experiments matched the filter criteria.") |
| print(f" Available phases: {sorted(set(e.get('phase','?') for e in experiments))}") |
| return |
|
|
| |
| if args.list: |
| print(f"\n{'ID':<10} {'Phase':<6} {'Name':<30} {'Epochs':<6} {'Data':<15}") |
| print("-" * 75) |
| for exp in filtered: |
| print(f"{exp['id']:<10} {exp.get('phase','?'):<6} {exp['name']:<30} " |
| f"{exp.get('epochs','?'):<6} {exp.get('data','?'):<15}") |
| print(f"\nTotal: {len(filtered)} experiments") |
| return |
|
|
| |
| print(f"\n{'='*60}") |
| print(f" BabyLM Batch Runner") |
| print(f" Experiments: {len(filtered)}") |
| print(f" Dry run: {args.dry_run}") |
| print(f" Resume: {args.resume}") |
| print(f"{'='*60}") |
|
|
| completed = 0 |
| skipped = 0 |
| failed = 0 |
|
|
| for i, exp in enumerate(filtered): |
| if args.resume and is_completed(exp["id"]): |
| print(f" [{i+1}/{len(filtered)}] {exp['id']} β already completed, skipping") |
| skipped += 1 |
| continue |
|
|
| result = run_experiment(exp, dry_run=args.dry_run) |
| save_result(result) |
|
|
| if result["status"] == "completed": |
| completed += 1 |
| elif result["status"] in ("failed", "error", "timeout"): |
| failed += 1 |
| print(f" [WARNING] {exp['id']} {result['status']}") |
|
|
| |
| print(f"\n{'='*80}") |
| print(f" Results Summary") |
| print(f"{'='*80}") |
| all_results = load_results() |
| print(f" {'ID':<10} {'Name':<25} {'Status':<10} {'BLiMP':<8} {'Avg ZS':<8} {'Time':<8}") |
| print(f" {'-'*70}") |
| for exp in filtered: |
| r = all_results.get(exp["id"], {}) |
| st = r.get("status", "?") |
| blimp = r.get("blimp", "") |
| avg_zs = r.get("avg_zero_shot", "") |
| elapsed = r.get("elapsed_sec", "") |
| blimp_str = f"{blimp:.1f}" if isinstance(blimp, (int, float)) else str(blimp) |
| avg_str = f"{avg_zs:.1f}" if isinstance(avg_zs, (int, float)) else str(avg_zs) |
| time_str = f"{elapsed}s" if elapsed else "" |
| print(f" {exp['id']:<10} {exp['name']:<25} {st:<10} {blimp_str:<8} {avg_str:<8} {time_str:<8}") |
| print(f"\n Done: {completed} completed, {skipped} skipped, {failed} failed") |
| print(f" Results: {RESULTS_JSON}") |
| print(f"{'='*80}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|