Massively-parallel hyperparameter sweeps
"# Massively-parallel hyperparameter sweeps\n\nWhen tuning RL runs, finding the optimal set of hyperparameters is time-consuming\nand error-prone if not properly guided or documented. This is made a first-class\noperation in the Modal Dojo so you can move faster and spend less.\n\n```python\nimport re\n\nfrom datasets import load_dataset\n\nfrom modal_dojo import (\n DatasetConfig,\n Qwen3_5_4B,\n Qwen3_5_4B_Recipe,\n TrainConfig,\n TrainingGroup,\n TrainingRun,\n)\n```\n\n## Define the training base\n\nWe'll start by creating the shared model, dataset, and base training recipe\nall sweep runs will share. To stay focused on how to do sweeps, we'll keep\nthis code minimal and only tune a few parameters, but the sky's the limit!\n\n```python\nmodel = Qwen3_5_4B()\n\n\ndef _letter_answer(row) -> bool:\n return bool(re.fullmatch(r\"[A-J]\", str(row[\"expected_answer\"]).strip().upper()))\n\n\nclass OpenScienceDataset(DatasetConfig):\n def input_key(self) -> str:\n return \"messages\"\n\n def label_key(self) -> str:\n return \"label\"\n\n def rows(self):\n ds = load_dataset(\n \"nvidia/OpenScienceReasoning-2\", split=\"train\", streaming=True\n )\n kept = 0\n for row in ds:\n if not _letter_answer(row):\n continue\n yield {\n \"messages\": [{\"role\": \"user\", \"content\": row[\"input\"]}],\n \"label\": str(row[\"expected_answer\"]).strip().upper(),\n }\n kept += 1\n if kept >= 80:\n break\n\n\ntrain_dataset = OpenScienceDataset()\n\n_BOXED_RE = re.compile(r\"\\\\boxed\\{([A-J])\\}\", re.IGNORECASE)\n\n\nasync def letter_rm(args, sample, **kwargs) -> float:\n matches = _BOXED_RE.findall(sample.response or \"\")\n pred = matches[-1].upper() if matches else \"\"\n return float(bool(pred) and pred == sample.label)\n\n\nbase = TrainConfig(\n model=model,\n dataset=train_dataset,\n recipe=Qwen3_5_4B_Recipe(\n custom_rm_function=letter_rm,\n ),\n)\n```\n\n## Create the sweep\n\nWe'll pass in the parameters we wish to test, then preview the set of\nruns the grid search will kick off once we're ready.\n\n```python\ngroup = TrainingGroup(\n base=base,\n grid={\n \"recipe.lr\": [5e-7, 5e-6],\n \"recipe.rollout_temperature\": [0.8, 1.0],\n },\n)\nconfigs = group.get_train_configs()\nprint(f\"{len(configs)} runs in group {group.group_id}:\")\nfor cfg in configs:\n print(f\"- lr={cfg.recipe.lr:<8}, temp={cfg.recipe.rollout_temperature}\")\n```\n\n## Launch it!\n\nOnce it all looks good, `.launch()` it!\n\n```python\nif __name__ == \"__main__\":\n launches = group.launch()\n print(f\"group {group.group_id}: {len(launches)} runs launched\")\n for launch in launches:\n print(\n f\"- {launch.training_run_id}, app={launch.modal_app_id}, group_id={launch.group_id}\"\n )\n if group.failures:\n for overrides, err in group.failures:\n print(f\"- FAILED {overrides}: {err}\")\n\n results = TrainingRun.wait_all(launches)\n print(f\"group {group.group_id}: {len(results)} runs completed\")\n for run in results:\n print(f\"completed {run.training_run_id} (group_id={run.group_id})\")\n```\n"
When tuning RL runs, finding the optimal set of hyperparameters is time-consuming and error-prone if not properly guided or documented. This is made a first-class operation in the Modal Dojo so you can move faster and spend less.
import re
from datasets import load_dataset
from modal_dojo import ( DatasetConfig, Qwen3_5_4B, Qwen3_5_4B_Recipe, TrainConfig, TrainingGroup, TrainingRun,)Define the training base
We’ll start by creating the shared model, dataset, and base training recipe all sweep runs will share. To stay focused on how to do sweeps, we’ll keep this code minimal and only tune a few parameters, but the sky’s the limit!
model = Qwen3_5_4B()
def _letter_answer(row) -> bool: return bool(re.fullmatch(r"[A-J]", str(row["expected_answer"]).strip().upper()))
class OpenScienceDataset(DatasetConfig): def input_key(self) -> str: return "messages"
def label_key(self) -> str: return "label"
def rows(self): ds = load_dataset( "nvidia/OpenScienceReasoning-2", split="train", streaming=True ) kept = 0 for row in ds: if not _letter_answer(row): continue yield { "messages": [{"role": "user", "content": row["input"]}], "label": str(row["expected_answer"]).strip().upper(), } kept += 1 if kept >= 80: break
train_dataset = OpenScienceDataset()
_BOXED_RE = re.compile(r"\\boxed\{([A-J])\}", re.IGNORECASE)
async def letter_rm(args, sample, **kwargs) -> float: matches = _BOXED_RE.findall(sample.response or "") pred = matches[-1].upper() if matches else "" return float(bool(pred) and pred == sample.label)
base = TrainConfig( model=model, dataset=train_dataset, recipe=Qwen3_5_4B_Recipe( custom_rm_function=letter_rm, ),)Create the sweep
We’ll pass in the parameters we wish to test, then preview the set of runs the grid search will kick off once we’re ready.
group = TrainingGroup( base=base, grid={ "recipe.lr": [5e-7, 5e-6], "recipe.rollout_temperature": [0.8, 1.0], },)configs = group.get_train_configs()print(f"{len(configs)} runs in group {group.group_id}:")for cfg in configs: print(f"- lr={cfg.recipe.lr:<8}, temp={cfg.recipe.rollout_temperature}")Launch it!
Once it all looks good, .launch() it!
if __name__ == "__main__": launches = group.launch() print(f"group {group.group_id}: {len(launches)} runs launched") for launch in launches: print( f"- {launch.training_run_id}, app={launch.modal_app_id}, group_id={launch.group_id}" ) if group.failures: for overrides, err in group.failures: print(f"- FAILED {overrides}: {err}")
results = TrainingRun.wait_all(launches) print(f"group {group.group_id}: {len(results)} runs completed") for run in results: print(f"completed {run.training_run_id} (group_id={run.group_id})")