diff --git a/CLAUDE.md b/CLAUDE.md
index ff1901a..7341565 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -43,7 +43,9 @@ soup train --config soup.yaml
**Data pipeline:** `data/loader.py` handles local files (JSONL/JSON/CSV/Parquet) and HuggingFace datasets. `data/formats.py` auto-detects and normalizes alpaca/sharegpt/chatml formats into a unified `{"messages": [...]}` structure. Also supports reverse conversion via `messages_to_format()`.
-**Trainer:** `trainer/sft.py` (`SFTTrainerWrapper`) and `trainer/dpo.py` (`DPOTrainerWrapper`) wrap HuggingFace's SFTTrainer/DPOTrainer with auto quantization (BitsAndBytes), LoRA (PEFT), and batch size estimation. Heavy ML imports are lazy (inside methods) so CLI stays fast for non-training commands. Both trainers enable Rich progress bars for HuggingFace Hub model downloads via `_enable_hf_transfer_progress()`.
+**Trainer:** `trainer/sft.py` (`SFTTrainerWrapper`), `trainer/dpo.py` (`DPOTrainerWrapper`), and `trainer/grpo.py` (`GRPOTrainerWrapper`) wrap HuggingFace's SFTTrainer/DPOTrainer/GRPOTrainer with auto quantization (BitsAndBytes), LoRA (PEFT), and batch size estimation. Heavy ML imports are lazy (inside methods) so CLI stays fast for non-training commands. All trainers enable Rich progress bars for HuggingFace Hub model downloads via `_enable_hf_transfer_progress()`.
+
+**GRPO (Group Relative Policy Optimization):** `trainer/grpo.py` implements reasoning model training (DeepSeek-R1 style). Generates multiple completions per prompt, scores them with reward functions, and optimizes using group-relative advantages. `trainer/rewards.py` provides built-in reward functions (`accuracy` — checks final answer, `format` — checks `` blocks) and supports custom rewards via Python files. Config: `task: grpo`, `grpo_beta`, `num_generations`, `reward_fn`.
**Monitoring:** `monitoring/callback.py` is a HuggingFace `TrainerCallback` that streams metrics to `monitoring/display.py` (Rich Live panel at 2Hz) and optionally to the experiment tracker.
@@ -141,4 +143,5 @@ Test suite lives in `tests/`:
| `test_errors.py` | Friendly error messages, --verbose flag, error mapping |
| `test_doctor.py` | `soup doctor` command, version checking, dependency table |
| `test_quickstart.py` | `soup quickstart` demo, data/config creation, --dry-run |
+| `test_grpo.py` | GRPO config, rewards, data prep, template, sweep shortcuts |
| `test_progress.py` | Rich download progress bar, `_enable_hf_transfer_progress` |
diff --git a/README.md b/README.md
index e17d266..73f4307 100644
--- a/README.md
+++ b/README.md
@@ -63,9 +63,10 @@ pip install git+https://github.com/MakazhanAlpamys/Soup.git
soup init
# Or use a template
-soup init --template chat # conversational fine-tune
-soup init --template code # code generation
-soup init --template medical # domain expert
+soup init --template chat # conversational fine-tune
+soup init --template code # code generation
+soup init --template medical # domain expert
+soup init --template reasoning # GRPO reasoning training
```
### 3. Train
@@ -142,6 +143,55 @@ training:
quantization: 4bit
```
+## GRPO Training (Reasoning)
+
+Train reasoning models with Group Relative Policy Optimization (DeepSeek-R1 style):
+
+```yaml
+base: meta-llama/Llama-3.1-8B-Instruct
+task: grpo
+
+data:
+ train: ./data/reasoning_train.jsonl
+ format: sharegpt
+ max_length: 4096
+
+training:
+ epochs: 3
+ lr: 1e-5
+ grpo_beta: 0.1
+ num_generations: 4
+ reward_fn: accuracy # or 'format', or path to custom .py
+ lora:
+ r: 64
+ alpha: 16
+ quantization: 4bit
+```
+
+```bash
+# Create a reasoning config
+soup init --template reasoning
+
+# Train
+soup train --config soup.yaml
+```
+
+**Built-in reward functions:**
+- `accuracy` — checks if the final answer matches expected (supports `####` and `\boxed{}` formats)
+- `format` — checks for structured `...` reasoning blocks
+
+**Custom reward functions** — point to a Python file:
+```python
+# my_reward.py
+def reward_fn(completions, **kwargs):
+ """Score each completion. Return list of floats."""
+ return [1.0 if "correct" in c[-1]["content"] else 0.0 for c in completions]
+```
+```yaml
+training:
+ reward_fn: ./my_reward.py
+```
+
## Chat with your model
```bash
@@ -463,6 +513,7 @@ soup eval --model ./output --benchmarks mmlu --run-id run_20260223_143052_a1b2
| LoRA / QLoRA fine-tuning | ✅ |
| SFT (Supervised Fine-Tune) | ✅ |
| DPO (Direct Preference Optimization) | ✅ |
+| GRPO (Reasoning / DeepSeek-R1 style) | ✅ |
| Auto batch size | ✅ |
| Auto GPU detection (CUDA/MPS/CPU) | ✅ |
| Live terminal dashboard | ✅ |
@@ -493,7 +544,7 @@ soup eval --model ./output --benchmarks mmlu --run-id run_20260223_143052_a1b2
## All Commands
```
-soup init [--template chat|code|medical] Create config
+soup init [--template chat|code|medical|reasoning] Create config
soup train --config soup.yaml Start training
soup chat --model ./output Interactive chat
soup push --model ./output --repo user/name Upload to HuggingFace
diff --git a/pyproject.toml b/pyproject.toml
index 5abe1ce..be0b3e0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "soup-cli"
-version = "0.4.1"
+version = "0.4.2"
description = "Fine-tune LLMs in one command. No SSH, no config hell."
readme = "README.md"
license = "MIT"
diff --git a/soup_cli/__init__.py b/soup_cli/__init__.py
index 8c313a6..b1398d3 100644
--- a/soup_cli/__init__.py
+++ b/soup_cli/__init__.py
@@ -1,3 +1,3 @@
"""Soup CLI — Fine-tune LLMs in one command."""
-__version__ = "0.4.1"
+__version__ = "0.4.2"
diff --git a/soup_cli/commands/init.py b/soup_cli/commands/init.py
index c7016e8..d586595 100644
--- a/soup_cli/commands/init.py
+++ b/soup_cli/commands/init.py
@@ -17,7 +17,7 @@ def init(
None,
"--template",
"-t",
- help="Use a template: chat, code, medical",
+ help="Use a template: chat, code, medical, reasoning",
),
output: str = typer.Option(
"soup.yaml",
@@ -62,7 +62,7 @@ def _interactive_wizard() -> str:
"Base model",
default="meta-llama/Llama-3.1-8B-Instruct",
)
- task = Prompt.ask("Task", choices=["sft", "dpo"], default="sft")
+ task = Prompt.ask("Task", choices=["sft", "dpo", "grpo"], default="sft")
data_path = Prompt.ask("Training data path", default="./data/train.jsonl")
data_format = Prompt.ask(
"Data format", choices=["alpaca", "sharegpt", "chatml"], default="alpaca",
@@ -72,6 +72,18 @@ def _interactive_wizard() -> str:
quantization = "4bit" if use_qlora == "yes" else "none"
+ grpo_block = ""
+ if task == "grpo":
+ reward_fn = Prompt.ask(
+ "Reward function", choices=["accuracy", "format", "custom"], default="accuracy",
+ )
+ if reward_fn == "custom":
+ reward_fn = Prompt.ask("Path to reward .py file", default="./reward.py")
+ grpo_block = f""" grpo_beta: 0.1
+ num_generations: 4
+ reward_fn: {reward_fn}
+"""
+
return f"""# Soup training config
# Docs: https://github.com/MakazhanAlpamys/Soup
@@ -92,6 +104,6 @@ training:
alpha: 16
target_modules: auto
quantization: {quantization}
-
+{grpo_block}
output: ./output
"""
diff --git a/soup_cli/commands/sweep.py b/soup_cli/commands/sweep.py
index d023717..bb393b7 100644
--- a/soup_cli/commands/sweep.py
+++ b/soup_cli/commands/sweep.py
@@ -287,6 +287,9 @@ def _set_nested_param(config_dict: dict, key: str, value) -> dict:
"val_split": "data.val_split",
"max_length": "data.max_length",
"dpo_beta": "training.dpo_beta",
+ "grpo_beta": "training.grpo_beta",
+ "num_generations": "training.num_generations",
+ "reward_fn": "training.reward_fn",
}
full_key = shortcuts.get(key, key)
diff --git a/soup_cli/commands/train.py b/soup_cli/commands/train.py
index 5ca9178..fa00b81 100644
--- a/soup_cli/commands/train.py
+++ b/soup_cli/commands/train.py
@@ -154,6 +154,12 @@ def train(
trainer_wrapper = DPOTrainerWrapper(
cfg, device=device, report_to=report_to, deepspeed_config=ds_config_path,
)
+ elif cfg.task == "grpo":
+ from soup_cli.trainer.grpo import GRPOTrainerWrapper
+
+ trainer_wrapper = GRPOTrainerWrapper(
+ cfg, device=device, report_to=report_to, deepspeed_config=ds_config_path,
+ )
else:
trainer_wrapper = SFTTrainerWrapper(
cfg, device=device, report_to=report_to, deepspeed_config=ds_config_path,
diff --git a/soup_cli/config/schema.py b/soup_cli/config/schema.py
index aa4aa64..4c1e788 100644
--- a/soup_cli/config/schema.py
+++ b/soup_cli/config/schema.py
@@ -49,13 +49,24 @@ class TrainingConfig(BaseModel):
dpo_beta: float = Field(
default=0.1, gt=0, description="DPO beta — KL penalty coefficient"
)
+ # GRPO-specific
+ grpo_beta: float = Field(
+ default=0.1, gt=0, description="GRPO beta — KL penalty coefficient"
+ )
+ num_generations: int = Field(
+ default=4, ge=2, description="Number of generations per prompt for GRPO"
+ )
+ reward_fn: Optional[str] = Field(
+ default="accuracy",
+ description="Reward function: 'accuracy', 'format', or path to custom .py file",
+ )
class SoupConfig(BaseModel):
"""Root config for soup.yaml."""
base: str = Field(..., description="Base model name or path (HF model ID)")
- task: Literal["sft", "dpo"] = Field(default="sft", description="Training task type")
+ task: Literal["sft", "dpo", "grpo"] = Field(default="sft", description="Training task type")
data: DataConfig
training: TrainingConfig = Field(default_factory=TrainingConfig)
output: str = Field(default="./output", description="Output directory for trained model")
@@ -111,6 +122,34 @@ training:
target_modules: auto
quantization: 4bit
+output: ./output
+""",
+ "reasoning": """# Soup template: Reasoning / GRPO
+# Fine-tune a model for chain-of-thought reasoning with GRPO
+
+base: meta-llama/Llama-3.1-8B-Instruct
+task: grpo
+
+data:
+ train: ./data/reasoning_train.jsonl
+ format: sharegpt
+ val_split: 0.1
+ max_length: 4096
+
+training:
+ epochs: 3
+ lr: 1e-5
+ batch_size: auto
+ gradient_accumulation_steps: 8
+ lora:
+ r: 64
+ alpha: 16
+ target_modules: auto
+ quantization: 4bit
+ grpo_beta: 0.1
+ num_generations: 4
+ reward_fn: accuracy
+
output: ./output
""",
"medical": """# Soup template: Medical / Domain Expert
diff --git a/soup_cli/trainer/grpo.py b/soup_cli/trainer/grpo.py
new file mode 100644
index 0000000..22d18e9
--- /dev/null
+++ b/soup_cli/trainer/grpo.py
@@ -0,0 +1,277 @@
+"""GRPO (Group Relative Policy Optimization) trainer — wraps trl.GRPOTrainer."""
+
+import time
+from pathlib import Path
+from typing import Optional
+
+from rich.console import Console
+
+from soup_cli.config.schema import SoupConfig
+from soup_cli.utils.gpu import estimate_batch_size, model_size_from_name
+
+console = Console()
+
+
+class GRPOTrainerWrapper:
+ """High-level wrapper for GRPO training from SoupConfig.
+
+ GRPO generates multiple completions per prompt, scores them with a reward
+ function, and optimizes using group-relative advantages. This is the approach
+ used by DeepSeek-R1 for reasoning model training.
+
+ Data format: same as SFT (messages with prompt/response) or DPO-style prompts.
+ The reward_fn in config determines how completions are scored.
+ """
+
+ def __init__(
+ self,
+ config: SoupConfig,
+ device: str = "cuda",
+ report_to: str = "none",
+ deepspeed_config: Optional[str] = None,
+ ):
+ self.config = config
+ self.device = device
+ self.report_to = report_to
+ self.deepspeed_config = deepspeed_config
+ self.model = None
+ self.tokenizer = None
+ self.trainer = None
+
+ def setup(self, dataset: dict):
+ """Load model, tokenizer, apply LoRA, create GRPO trainer."""
+ from datasets import Dataset
+ from peft import LoraConfig, TaskType, get_peft_model, prepare_model_for_kbit_training
+ from transformers import (
+ AutoModelForCausalLM,
+ AutoTokenizer,
+ BitsAndBytesConfig,
+ )
+ from trl import GRPOConfig, GRPOTrainer
+
+ # Enable Rich progress bar for HuggingFace downloads
+ from soup_cli.trainer.sft import _enable_hf_transfer_progress
+
+ _enable_hf_transfer_progress()
+
+ cfg = self.config
+ tcfg = cfg.training
+
+ # --- Load reward function ---
+ from soup_cli.trainer.rewards import load_reward_fn
+
+ reward_fn = load_reward_fn(tcfg.reward_fn)
+
+ # --- Tokenizer ---
+ console.print(f"[dim]Loading tokenizer: {cfg.base}[/]")
+ self.tokenizer = AutoTokenizer.from_pretrained(cfg.base, trust_remote_code=True)
+ if self.tokenizer.pad_token is None:
+ self.tokenizer.pad_token = self.tokenizer.eos_token
+
+ # --- Quantization ---
+ bnb_config = None
+ if tcfg.quantization == "4bit":
+ import torch
+
+ bnb_config = BitsAndBytesConfig(
+ load_in_4bit=True,
+ bnb_4bit_quant_type="nf4",
+ bnb_4bit_compute_dtype=torch.bfloat16,
+ bnb_4bit_use_double_quant=True,
+ )
+ elif tcfg.quantization == "8bit":
+ bnb_config = BitsAndBytesConfig(load_in_8bit=True)
+
+ # --- Model ---
+ console.print(f"[dim]Loading model: {cfg.base}[/]")
+ model_kwargs = {"trust_remote_code": True, "device_map": "auto"}
+ if bnb_config:
+ model_kwargs["quantization_config"] = bnb_config
+
+ self.model = AutoModelForCausalLM.from_pretrained(cfg.base, **model_kwargs)
+
+ if tcfg.quantization in ("4bit", "8bit"):
+ self.model = prepare_model_for_kbit_training(self.model)
+
+ # --- LoRA ---
+ target_modules = tcfg.lora.target_modules
+ if target_modules == "auto":
+ target_modules = None # peft will auto-detect
+
+ lora_config = LoraConfig(
+ r=tcfg.lora.r,
+ lora_alpha=tcfg.lora.alpha,
+ lora_dropout=tcfg.lora.dropout,
+ target_modules=target_modules,
+ task_type=TaskType.CAUSAL_LM,
+ bias="none",
+ )
+ self.model = get_peft_model(self.model, lora_config)
+ trainable, total = self.model.get_nb_trainable_parameters()
+ pct = 100 * trainable / total
+ console.print(
+ f"[green]LoRA applied:[/] {trainable:,} trainable"
+ f" / {total:,} total ({pct:.2f}%)"
+ )
+
+ # --- Batch size ---
+ batch_size = tcfg.batch_size
+ if batch_size == "auto":
+ from soup_cli.utils.gpu import get_gpu_info
+
+ gpu_info = get_gpu_info()
+ model_size = model_size_from_name(cfg.base)
+ batch_size = estimate_batch_size(
+ model_params_b=model_size,
+ seq_length=cfg.data.max_length,
+ gpu_memory_bytes=gpu_info["memory_total_bytes"],
+ quantization=tcfg.quantization,
+ lora_r=tcfg.lora.r,
+ )
+ # GRPO generates N completions per prompt → more memory
+ batch_size = max(1, batch_size // tcfg.num_generations)
+ console.print(f"[green]Auto batch size (GRPO):[/] {batch_size}")
+
+ # --- Dataset ---
+ # GRPO expects prompts — extract from messages or use prompt field
+ train_data = _prepare_grpo_dataset(dataset["train"])
+ train_ds = Dataset.from_list(train_data)
+ eval_ds = None
+ if "val" in dataset and dataset["val"]:
+ eval_data = _prepare_grpo_dataset(dataset["val"])
+ eval_ds = Dataset.from_list(eval_data)
+
+ # --- Output dir ---
+ output_dir = Path(cfg.output)
+ if cfg.experiment_name:
+ output_dir = output_dir / cfg.experiment_name
+ output_dir.mkdir(parents=True, exist_ok=True)
+
+ # --- Calculate warmup steps from ratio ---
+ import math
+
+ total_steps = (
+ math.ceil(len(train_ds) / batch_size / tcfg.gradient_accumulation_steps)
+ * tcfg.epochs
+ )
+ warmup_steps = int(total_steps * tcfg.warmup_ratio)
+
+ # --- GRPO config ---
+ grpo_config = GRPOConfig(
+ output_dir=str(output_dir),
+ num_train_epochs=tcfg.epochs,
+ per_device_train_batch_size=batch_size,
+ gradient_accumulation_steps=tcfg.gradient_accumulation_steps,
+ learning_rate=tcfg.lr,
+ warmup_steps=warmup_steps,
+ weight_decay=tcfg.weight_decay,
+ max_grad_norm=tcfg.max_grad_norm,
+ optim=tcfg.optimizer,
+ lr_scheduler_type=tcfg.scheduler,
+ logging_steps=tcfg.logging_steps,
+ save_steps=tcfg.save_steps,
+ save_total_limit=3,
+ bf16=self.device == "cuda",
+ report_to=self.report_to,
+ remove_unused_columns=False,
+ deepspeed=self.deepspeed_config,
+ beta=tcfg.grpo_beta,
+ num_generations=tcfg.num_generations,
+ max_completion_length=cfg.data.max_length,
+ )
+
+ # --- Trainer ---
+ self.trainer = GRPOTrainer(
+ model=self.model,
+ args=grpo_config,
+ train_dataset=train_ds,
+ eval_dataset=eval_ds,
+ reward_funcs=reward_fn,
+ processing_class=self.tokenizer,
+ )
+
+ self._output_dir = str(output_dir)
+
+ def train(
+ self,
+ display: Optional[object] = None,
+ tracker: Optional[object] = None,
+ run_id: str = "",
+ resume_from_checkpoint: Optional[str] = None,
+ ) -> dict:
+ """Run GRPO training and return results summary."""
+ start = time.time()
+
+ # Add callback for live display and experiment tracking
+ if display:
+ from soup_cli.monitoring.callback import SoupTrainerCallback
+
+ self.trainer.add_callback(
+ SoupTrainerCallback(display, tracker=tracker, run_id=run_id)
+ )
+
+ self.trainer.train(resume_from_checkpoint=resume_from_checkpoint)
+ duration = time.time() - start
+
+ # Save final model (LoRA adapter)
+ self.trainer.save_model(self._output_dir)
+ self.tokenizer.save_pretrained(self._output_dir)
+
+ # Extract metrics
+ logs = self.trainer.state.log_history
+ train_losses = [entry["loss"] for entry in logs if "loss" in entry]
+
+ hours = int(duration // 3600)
+ minutes = int((duration % 3600) // 60)
+ duration_str = f"{hours}h {minutes}m" if hours > 0 else f"{minutes}m"
+
+ return {
+ "initial_loss": train_losses[0] if train_losses else 0,
+ "final_loss": train_losses[-1] if train_losses else 0,
+ "duration": duration_str,
+ "duration_secs": duration,
+ "output_dir": self._output_dir,
+ "total_steps": self.trainer.state.global_step,
+ }
+
+
+def _prepare_grpo_dataset(data: list[dict]) -> list[dict]:
+ """Convert dataset rows to GRPO format.
+
+ GRPO expects each row to have a 'prompt' field (list of messages or string).
+ Input can be:
+ - messages format: [{"role": "user", "content": "..."}, ...]
+ - DPO format: {"prompt": "...", "chosen": "...", "rejected": "..."}
+ - prompt field: {"prompt": "..."}
+
+ Returns list of dicts with 'prompt' as a message list for chat models.
+ """
+ prepared = []
+ for row in data:
+ if "prompt" in row and isinstance(row["prompt"], str):
+ # DPO or plain prompt format — convert to message list
+ entry = {"prompt": [{"role": "user", "content": row["prompt"]}]}
+ # Preserve 'answer' field if present (for accuracy reward)
+ if "answer" in row:
+ entry["answer"] = row["answer"]
+ prepared.append(entry)
+ elif "messages" in row:
+ # Messages format — use the user message(s) as prompt
+ messages = row["messages"]
+ prompt_msgs = [msg for msg in messages if msg["role"] != "assistant"]
+ entry = {"prompt": prompt_msgs}
+ prepared.append(entry)
+ elif "prompt" in row and isinstance(row["prompt"], list):
+ # Already in message list format
+ entry = {"prompt": row["prompt"]}
+ if "answer" in row:
+ entry["answer"] = row["answer"]
+ prepared.append(entry)
+ else:
+ # Fallback: treat any 'instruction' field as prompt
+ instruction = row.get("instruction", row.get("input", ""))
+ entry = {"prompt": [{"role": "user", "content": str(instruction)}]}
+ if "output" in row:
+ entry["answer"] = row["output"]
+ prepared.append(entry)
+ return prepared
diff --git a/soup_cli/trainer/rewards.py b/soup_cli/trainer/rewards.py
new file mode 100644
index 0000000..d42c0e4
--- /dev/null
+++ b/soup_cli/trainer/rewards.py
@@ -0,0 +1,137 @@
+"""Reward functions for GRPO training.
+
+Built-in reward functions:
+ - accuracy: checks if the model answer matches the expected answer
+ - format: checks if the response follows a structured format (e.g., ...)
+
+Custom reward functions can be loaded from a Python file with a
+`reward_fn(completions, **kwargs)` callable.
+"""
+
+import importlib.util
+import re
+from pathlib import Path
+
+from rich.console import Console
+
+console = Console()
+
+
+def accuracy_reward(completions: list[list[dict]], **kwargs) -> list[float]:
+ """Reward based on whether the final answer matches the expected answer.
+
+ Looks for the answer after the last '####' or in a \\boxed{} block.
+ Falls back to checking if the expected answer appears anywhere in the response.
+
+ Args:
+ completions: list of message lists, each containing a completion with 'content'.
+ **kwargs: must contain 'answer' — the expected answer for each prompt.
+
+ Returns:
+ List of float rewards (1.0 for correct, 0.0 for incorrect).
+ """
+ answers = kwargs.get("answer", [])
+ rewards = []
+ for completion, expected in zip(completions, answers):
+ content = completion[-1]["content"] if completion else ""
+ predicted = _extract_answer(content)
+ if predicted is not None and predicted.strip() == str(expected).strip():
+ rewards.append(1.0)
+ elif str(expected).strip().lower() in content.lower():
+ rewards.append(0.5)
+ else:
+ rewards.append(0.0)
+ return rewards
+
+
+def format_reward(completions: list[list[dict]], **kwargs) -> list[float]:
+ """Reward based on whether the response follows a structured reasoning format.
+
+ Checks for:
+ - ... block (chain-of-thought)
+ - A final answer section after the thinking block
+
+ Args:
+ completions: list of message lists.
+ **kwargs: unused.
+
+ Returns:
+ List of float rewards (0.0 to 1.0).
+ """
+ rewards = []
+ for completion in completions:
+ content = completion[-1]["content"] if completion else ""
+ score = 0.0
+ # Check for block
+ if re.search(r".*?", content, re.DOTALL):
+ score += 0.5
+ # Check for content after
+ after_think = re.split(r"", content)
+ if len(after_think) > 1 and after_think[-1].strip():
+ score += 0.5
+ rewards.append(score)
+ return rewards
+
+
+def _extract_answer(text: str) -> str | None:
+ """Extract the final answer from model output.
+
+ Supports:
+ - #### format (GSM8K style)
+ - \\boxed{} format (math style)
+ """
+ # Try #### format
+ parts = text.split("####")
+ if len(parts) > 1:
+ return parts[-1].strip()
+ # Try \\boxed{} format
+ match = re.search(r"\\boxed\{([^}]+)\}", text)
+ if match:
+ return match.group(1).strip()
+ return None
+
+
+# Registry of built-in reward functions
+BUILTIN_REWARDS: dict[str, callable] = {
+ "accuracy": accuracy_reward,
+ "format": format_reward,
+}
+
+
+def load_reward_fn(reward_fn_spec: str) -> callable:
+ """Load a reward function by name or from a custom Python file.
+
+ Args:
+ reward_fn_spec: Either a built-in name ('accuracy', 'format') or
+ a path to a .py file containing a `reward_fn` callable.
+
+ Returns:
+ A callable reward function with signature:
+ (completions: list[list[dict]], **kwargs) -> list[float]
+ """
+ # Built-in reward function
+ if reward_fn_spec in BUILTIN_REWARDS:
+ console.print(f"[dim]Using built-in reward function: {reward_fn_spec}[/]")
+ return BUILTIN_REWARDS[reward_fn_spec]
+
+ # Custom Python file
+ reward_path = Path(reward_fn_spec)
+ if reward_path.exists() and reward_path.suffix == ".py":
+ console.print(f"[dim]Loading custom reward function from: {reward_path}[/]")
+ spec = importlib.util.spec_from_file_location("custom_reward", reward_path)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+
+ if not hasattr(module, "reward_fn"):
+ raise ValueError(
+ f"Custom reward file {reward_path} must define a 'reward_fn' callable.\n"
+ f"Example:\n"
+ f" def reward_fn(completions, **kwargs):\n"
+ f" return [1.0] * len(completions)"
+ )
+ return module.reward_fn
+
+ raise ValueError(
+ f"Unknown reward function: '{reward_fn_spec}'\n"
+ f"Options: {', '.join(BUILTIN_REWARDS.keys())} or path to a .py file"
+ )
diff --git a/tests/test_grpo.py b/tests/test_grpo.py
new file mode 100644
index 0000000..4c01c56
--- /dev/null
+++ b/tests/test_grpo.py
@@ -0,0 +1,426 @@
+"""Tests for GRPO training — config, rewards, data preparation, template."""
+
+import textwrap
+
+import pytest
+
+from soup_cli.config.schema import TEMPLATES, SoupConfig
+
+# ─── Config Tests ───────────────────────────────────────────────────────────
+
+
+class TestGRPOConfig:
+ """Test GRPO task config validation."""
+
+ def test_grpo_task_accepted(self):
+ """GRPO task should be a valid task type."""
+ cfg = SoupConfig(
+ base="some-model",
+ task="grpo",
+ data={"train": "./data.jsonl"},
+ )
+ assert cfg.task == "grpo"
+
+ def test_grpo_beta_default(self):
+ """grpo_beta should default to 0.1."""
+ cfg = SoupConfig(
+ base="some-model",
+ task="grpo",
+ data={"train": "./data.jsonl"},
+ )
+ assert cfg.training.grpo_beta == 0.1
+
+ def test_grpo_beta_custom(self):
+ """Custom grpo_beta should be accepted."""
+ cfg = SoupConfig(
+ base="some-model",
+ task="grpo",
+ data={"train": "./data.jsonl"},
+ training={"grpo_beta": 0.04},
+ )
+ assert cfg.training.grpo_beta == pytest.approx(0.04)
+
+ def test_grpo_beta_must_be_positive(self):
+ """grpo_beta must be > 0."""
+ with pytest.raises(Exception):
+ SoupConfig(
+ base="some-model",
+ task="grpo",
+ data={"train": "./data.jsonl"},
+ training={"grpo_beta": 0},
+ )
+
+ def test_num_generations_default(self):
+ """num_generations should default to 4."""
+ cfg = SoupConfig(
+ base="some-model",
+ task="grpo",
+ data={"train": "./data.jsonl"},
+ )
+ assert cfg.training.num_generations == 4
+
+ def test_num_generations_custom(self):
+ """Custom num_generations should be accepted."""
+ cfg = SoupConfig(
+ base="some-model",
+ task="grpo",
+ data={"train": "./data.jsonl"},
+ training={"num_generations": 8},
+ )
+ assert cfg.training.num_generations == 8
+
+ def test_num_generations_minimum(self):
+ """num_generations must be >= 2."""
+ with pytest.raises(Exception):
+ SoupConfig(
+ base="some-model",
+ task="grpo",
+ data={"train": "./data.jsonl"},
+ training={"num_generations": 1},
+ )
+
+ def test_reward_fn_default(self):
+ """reward_fn should default to 'accuracy'."""
+ cfg = SoupConfig(
+ base="some-model",
+ task="grpo",
+ data={"train": "./data.jsonl"},
+ )
+ assert cfg.training.reward_fn == "accuracy"
+
+ def test_reward_fn_custom_path(self):
+ """reward_fn should accept a custom file path."""
+ cfg = SoupConfig(
+ base="some-model",
+ task="grpo",
+ data={"train": "./data.jsonl"},
+ training={"reward_fn": "./my_reward.py"},
+ )
+ assert cfg.training.reward_fn == "./my_reward.py"
+
+ def test_grpo_full_config(self):
+ """Full GRPO config should validate correctly."""
+ cfg = SoupConfig(
+ base="meta-llama/Llama-3.1-8B-Instruct",
+ task="grpo",
+ data={"train": "./data.jsonl", "format": "sharegpt", "max_length": 4096},
+ training={
+ "epochs": 3,
+ "lr": 1e-5,
+ "grpo_beta": 0.1,
+ "num_generations": 4,
+ "reward_fn": "format",
+ "lora": {"r": 64, "alpha": 16},
+ "quantization": "4bit",
+ },
+ )
+ assert cfg.task == "grpo"
+ assert cfg.training.reward_fn == "format"
+ assert cfg.training.num_generations == 4
+ assert cfg.data.max_length == 4096
+
+
+# ─── Reward Function Tests ──────────────────────────────────────────────────
+
+
+class TestAccuracyReward:
+ """Test the accuracy reward function."""
+
+ def test_exact_match(self):
+ from soup_cli.trainer.rewards import accuracy_reward
+
+ completions = [[{"role": "assistant", "content": "The answer is #### 42"}]]
+ rewards = accuracy_reward(completions, answer=["42"])
+ assert rewards == [1.0]
+
+ def test_boxed_match(self):
+ from soup_cli.trainer.rewards import accuracy_reward
+
+ completions = [[{"role": "assistant", "content": "So \\boxed{42} is the result"}]]
+ rewards = accuracy_reward(completions, answer=["42"])
+ assert rewards == [1.0]
+
+ def test_partial_match(self):
+ from soup_cli.trainer.rewards import accuracy_reward
+
+ completions = [[{"role": "assistant", "content": "The answer is 42 degrees"}]]
+ rewards = accuracy_reward(completions, answer=["42"])
+ assert rewards == [0.5]
+
+ def test_no_match(self):
+ from soup_cli.trainer.rewards import accuracy_reward
+
+ completions = [[{"role": "assistant", "content": "I don't know"}]]
+ rewards = accuracy_reward(completions, answer=["42"])
+ assert rewards == [0.0]
+
+ def test_multiple_completions(self):
+ from soup_cli.trainer.rewards import accuracy_reward
+
+ completions = [
+ [{"role": "assistant", "content": "#### 42"}],
+ [{"role": "assistant", "content": "Wrong answer"}],
+ [{"role": "assistant", "content": "The answer is 42"}],
+ ]
+ rewards = accuracy_reward(completions, answer=["42", "42", "42"])
+ assert rewards == [1.0, 0.0, 0.5]
+
+ def test_empty_completion(self):
+ from soup_cli.trainer.rewards import accuracy_reward
+
+ completions = [[]]
+ rewards = accuracy_reward(completions, answer=["42"])
+ assert rewards == [0.0]
+
+
+class TestFormatReward:
+ """Test the format reward function."""
+
+ def test_perfect_format(self):
+ from soup_cli.trainer.rewards import format_reward
+
+ content = "Let me think step by step...\nThe answer is 42."
+ completions = [[{"role": "assistant", "content": content}]]
+ rewards = format_reward(completions)
+ assert rewards == [1.0]
+
+ def test_think_only(self):
+ from soup_cli.trainer.rewards import format_reward
+
+ content = "Thinking..."
+ completions = [[{"role": "assistant", "content": content}]]
+ rewards = format_reward(completions)
+ assert rewards == [0.5]
+
+ def test_no_format(self):
+ from soup_cli.trainer.rewards import format_reward
+
+ completions = [[{"role": "assistant", "content": "Just a plain answer"}]]
+ rewards = format_reward(completions)
+ assert rewards == [0.0]
+
+ def test_multiple_completions(self):
+ from soup_cli.trainer.rewards import format_reward
+
+ completions = [
+ [{"role": "assistant", "content": "A\nB"}],
+ [{"role": "assistant", "content": "No format"}],
+ ]
+ rewards = format_reward(completions)
+ assert rewards == [1.0, 0.0]
+
+
+class TestExtractAnswer:
+ """Test answer extraction from model output."""
+
+ def test_hash_format(self):
+ from soup_cli.trainer.rewards import _extract_answer
+
+ assert _extract_answer("Some work\n#### 42") == "42"
+
+ def test_boxed_format(self):
+ from soup_cli.trainer.rewards import _extract_answer
+
+ assert _extract_answer("So \\boxed{42} is the answer") == "42"
+
+ def test_no_answer(self):
+ from soup_cli.trainer.rewards import _extract_answer
+
+ assert _extract_answer("Just plain text") is None
+
+ def test_multiple_hashes(self):
+ from soup_cli.trainer.rewards import _extract_answer
+
+ assert _extract_answer("#### step\n#### 42") == "42"
+
+
+class TestLoadRewardFn:
+ """Test reward function loading."""
+
+ def test_load_builtin_accuracy(self):
+ from soup_cli.trainer.rewards import accuracy_reward, load_reward_fn
+
+ fn = load_reward_fn("accuracy")
+ assert fn is accuracy_reward
+
+ def test_load_builtin_format(self):
+ from soup_cli.trainer.rewards import format_reward, load_reward_fn
+
+ fn = load_reward_fn("format")
+ assert fn is format_reward
+
+ def test_load_custom_file(self, tmp_path):
+ from soup_cli.trainer.rewards import load_reward_fn
+
+ custom_file = tmp_path / "my_reward.py"
+ custom_file.write_text(textwrap.dedent("""\
+ def reward_fn(completions, **kwargs):
+ return [1.0] * len(completions)
+ """))
+ fn = load_reward_fn(str(custom_file))
+ result = fn([[{"content": "test"}]])
+ assert result == [1.0]
+
+ def test_load_custom_file_missing_fn(self, tmp_path):
+ from soup_cli.trainer.rewards import load_reward_fn
+
+ custom_file = tmp_path / "bad_reward.py"
+ custom_file.write_text("x = 1\n")
+ with pytest.raises(ValueError, match="must define a 'reward_fn'"):
+ load_reward_fn(str(custom_file))
+
+ def test_load_unknown_name(self):
+ from soup_cli.trainer.rewards import load_reward_fn
+
+ with pytest.raises(ValueError, match="Unknown reward function"):
+ load_reward_fn("nonexistent")
+
+
+# ─── Data Preparation Tests ─────────────────────────────────────────────────
+
+
+class TestPrepareGRPODataset:
+ """Test GRPO dataset preparation."""
+
+ def test_from_prompt_string(self):
+ from soup_cli.trainer.grpo import _prepare_grpo_dataset
+
+ data = [{"prompt": "What is 2+2?", "answer": "4"}]
+ result = _prepare_grpo_dataset(data)
+ assert len(result) == 1
+ assert result[0]["prompt"] == [{"role": "user", "content": "What is 2+2?"}]
+ assert result[0]["answer"] == "4"
+
+ def test_from_messages(self):
+ from soup_cli.trainer.grpo import _prepare_grpo_dataset
+
+ data = [
+ {
+ "messages": [
+ {"role": "system", "content": "You are helpful."},
+ {"role": "user", "content": "Hello"},
+ {"role": "assistant", "content": "Hi!"},
+ ]
+ }
+ ]
+ result = _prepare_grpo_dataset(data)
+ assert len(result) == 1
+ # Should only include non-assistant messages as prompt
+ assert len(result[0]["prompt"]) == 2
+ assert result[0]["prompt"][0]["role"] == "system"
+ assert result[0]["prompt"][1]["role"] == "user"
+
+ def test_from_prompt_message_list(self):
+ from soup_cli.trainer.grpo import _prepare_grpo_dataset
+
+ data = [
+ {
+ "prompt": [{"role": "user", "content": "What is 2+2?"}],
+ "answer": "4",
+ }
+ ]
+ result = _prepare_grpo_dataset(data)
+ assert result[0]["prompt"] == [{"role": "user", "content": "What is 2+2?"}]
+ assert result[0]["answer"] == "4"
+
+ def test_from_alpaca_format(self):
+ from soup_cli.trainer.grpo import _prepare_grpo_dataset
+
+ data = [{"instruction": "Translate hello", "input": "", "output": "hola"}]
+ result = _prepare_grpo_dataset(data)
+ assert result[0]["prompt"] == [{"role": "user", "content": "Translate hello"}]
+ assert result[0]["answer"] == "hola"
+
+ def test_multiple_rows(self):
+ from soup_cli.trainer.grpo import _prepare_grpo_dataset
+
+ data = [
+ {"prompt": "Q1", "answer": "A1"},
+ {"prompt": "Q2", "answer": "A2"},
+ {"prompt": "Q3", "answer": "A3"},
+ ]
+ result = _prepare_grpo_dataset(data)
+ assert len(result) == 3
+
+
+# ─── Template Tests ──────────────────────────────────────────────────────────
+
+
+class TestReasoningTemplate:
+ """Test the reasoning/GRPO template."""
+
+ def test_reasoning_template_exists(self):
+ assert "reasoning" in TEMPLATES
+
+ def test_reasoning_template_valid_yaml(self):
+ import yaml
+
+ config = yaml.safe_load(TEMPLATES["reasoning"])
+ assert config["task"] == "grpo"
+ assert config["training"]["grpo_beta"] == 0.1
+ assert config["training"]["num_generations"] == 4
+ assert config["training"]["reward_fn"] == "accuracy"
+
+ def test_reasoning_template_valid_config(self):
+ import yaml
+
+ raw = yaml.safe_load(TEMPLATES["reasoning"])
+ cfg = SoupConfig(**raw)
+ assert cfg.task == "grpo"
+ assert cfg.training.grpo_beta == 0.1
+
+
+# ─── Train Command Routing Tests ─────────────────────────────────────────────
+
+
+class TestGRPOTrainRouting:
+ """Test that train command routes to GRPO trainer."""
+
+ def test_grpo_import_exists(self):
+ """GRPOTrainerWrapper should be importable."""
+ from soup_cli.trainer.grpo import GRPOTrainerWrapper
+
+ assert GRPOTrainerWrapper is not None
+
+ def test_grpo_wrapper_init(self):
+ """GRPOTrainerWrapper should initialize without error."""
+ from soup_cli.trainer.grpo import GRPOTrainerWrapper
+
+ cfg = SoupConfig(
+ base="some-model",
+ task="grpo",
+ data={"train": "./data.jsonl"},
+ )
+ wrapper = GRPOTrainerWrapper(cfg, device="cpu")
+ assert wrapper.config.task == "grpo"
+ assert wrapper.device == "cpu"
+ assert wrapper.model is None
+ assert wrapper.trainer is None
+
+
+# ─── Sweep Shortcut Tests ────────────────────────────────────────────────────
+
+
+class TestGRPOSweepParams:
+ """Test GRPO parameter shortcuts in sweep."""
+
+ def test_grpo_beta_shortcut(self):
+ from soup_cli.commands.sweep import _set_nested_param
+
+ config = {"training": {"grpo_beta": 0.1}}
+ _set_nested_param(config, "grpo_beta", 0.04)
+ assert config["training"]["grpo_beta"] == 0.04
+
+ def test_num_generations_shortcut(self):
+ from soup_cli.commands.sweep import _set_nested_param
+
+ config = {"training": {"num_generations": 4}}
+ _set_nested_param(config, "num_generations", 8)
+ assert config["training"]["num_generations"] == 8
+
+ def test_reward_fn_shortcut(self):
+ from soup_cli.commands.sweep import _set_nested_param
+
+ config = {"training": {"reward_fn": "accuracy"}}
+ _set_nested_param(config, "reward_fn", "format")
+ assert config["training"]["reward_fn"] == "format"