- register online_ppo train type backed by PPOStrategy: token-level clipped surrogate over GAE advantages plus masked value regression against rollout-pinned returns, with explained-variance metrics - fold the reference-KL penalty (k3 estimator) into per-token rewards before GAE and pin advantages/returns on RolloutResult so replayed gradient steps optimize fixed targets - add self-contained ValueModel critic with a zero-initialized value head and backbone warm-started from policy weights; AutoRegressiveLM stays untouched and trunk parity is pinned by tests - step the critic's own optimizer outside the policy-version lock with the same max_grad_norm clipping as the policy - persist critic state as value_model.pt/value_optimizer.pt checkpoint extras; resume restores it, fails loudly when missing, and the train.sh completeness check requires the extras for online_ppo configs - extract shared rollout sequence/logprob helpers from GRPO (behavior unchanged) and add ppo_gamma/ppo_gae_lambda/ppo_vf_coef CLI options
212 lines
6.6 KiB
Python
212 lines
6.6 KiB
Python
"""End-to-end integration tests for online GRPO/DPO rollout."""
|
|
|
|
import os
|
|
from functools import partial
|
|
|
|
import pytest
|
|
import torch
|
|
from torch.utils.data import Dataset
|
|
|
|
import astrai.trainer.train_context as train_context
|
|
from astrai.config import TrainConfig
|
|
from astrai.model.transformer import AutoRegressiveLM
|
|
from astrai.model.value import ValueModel
|
|
from astrai.serialization import Checkpoint
|
|
from astrai.trainer.rollout import BaseRewardModel
|
|
from astrai.trainer.schedule import SchedulerFactory
|
|
from astrai.trainer.trainer import Trainer
|
|
from tests.helpers import CHAT_TEMPLATE
|
|
|
|
|
|
class InstructionDataset(Dataset):
|
|
"""Toy instruction/input dataset for online RL rollout.
|
|
|
|
Each sample has an ``instruction`` and an optional ``input``; the
|
|
RolloutGenerator renders both through the tokenizer's chat template
|
|
so the prompt matches the SFT-trained format.
|
|
"""
|
|
|
|
_SAMPLES = [
|
|
{"instruction": "Hello", "input": ""},
|
|
{"instruction": "Tell me a story", "input": "about dragons"},
|
|
{"instruction": "Summarize", "input": "the article"},
|
|
{"instruction": "Translate", "input": "to French: hi"},
|
|
]
|
|
|
|
def __len__(self):
|
|
return len(self._SAMPLES)
|
|
|
|
def __getitem__(self, idx):
|
|
return dict(self._SAMPLES[idx])
|
|
|
|
|
|
class LengthRewardModel(BaseRewardModel):
|
|
"""Rewards each response by its (non-pad) token count.
|
|
|
|
Gives the group-normalized advantage a non-degenerate signal.
|
|
"""
|
|
|
|
def score(self, prompts, responses):
|
|
B = len(prompts)
|
|
G = len(responses[0]) if B else 0
|
|
rewards = torch.zeros(B, G)
|
|
for i in range(B):
|
|
for g in range(G):
|
|
rewards[i, g] = float(len(responses[i][g]))
|
|
return rewards
|
|
|
|
|
|
def instruction_collate_fn(batch):
|
|
"""Stack a list of instruction/input dicts into a batch dict of lists."""
|
|
return {
|
|
"instruction": [b["instruction"] for b in batch],
|
|
"input": [b.get("input", "") for b in batch],
|
|
}
|
|
|
|
|
|
def _model_fn(model_config):
|
|
return AutoRegressiveLM(model_config).to(dtype=torch.float32)
|
|
|
|
|
|
def _value_model_fn(model_config):
|
|
return ValueModel(model_config).to(dtype=torch.float32)
|
|
|
|
|
|
def _optimizer_fn(m):
|
|
return torch.optim.AdamW(m.parameters(), lr=1e-4)
|
|
|
|
|
|
def _scheduler_fn(optim):
|
|
return SchedulerFactory.create(
|
|
"cosine", optim, warmup_steps=1, lr_decay_steps=4, min_rate=0.05
|
|
)
|
|
|
|
|
|
_ONLINE_STRATEGIES = [
|
|
pytest.param(
|
|
"online_grpo",
|
|
{"clip_eps": 0.2, "kl_coef": 0.01, "group_size": 2},
|
|
None,
|
|
id="grpo",
|
|
),
|
|
pytest.param("online_dpo", {"beta": 0.1, "group_size": 2}, None, id="dpo"),
|
|
pytest.param(
|
|
"online_ppo",
|
|
{
|
|
"clip_eps": 0.2,
|
|
"kl_coef": 0.01,
|
|
"group_size": 2,
|
|
"gamma": 1.0,
|
|
"gae_lambda": 0.95,
|
|
"vf_coef": 0.5,
|
|
},
|
|
True,
|
|
id="ppo",
|
|
),
|
|
]
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.parametrize(
|
|
("strategy", "strategy_kwargs", "with_critic"), _ONLINE_STRATEGIES
|
|
)
|
|
def test_online_rollout_end_to_end(
|
|
base_test_env, strategy, strategy_kwargs, with_critic, monkeypatch
|
|
):
|
|
"""Run one epoch of online RL rollout with KV-cache-backed generation."""
|
|
created_reference_models = []
|
|
create_ref_model = train_context.create_ref_model
|
|
|
|
def track_reference_model(*args, **kwargs):
|
|
created_reference_models.append(strategy)
|
|
return create_ref_model(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(train_context, "create_ref_model", track_reference_model)
|
|
|
|
test_dir = base_test_env["test_dir"]
|
|
device = base_test_env["device"]
|
|
tokenizer = base_test_env["tokenizer"]
|
|
model_config = base_test_env["transformer_config"]
|
|
|
|
tokenizer.set_chat_template(CHAT_TEMPLATE)
|
|
tokenizer.save_pretrained(test_dir)
|
|
|
|
config_kwargs = dict(
|
|
strategy=strategy,
|
|
model_fn=partial(_model_fn, model_config),
|
|
dataset=InstructionDataset(),
|
|
optimizer_fn=_optimizer_fn,
|
|
scheduler_fn=_scheduler_fn,
|
|
ckpt_dir=os.path.join(test_dir, "ckpt"),
|
|
n_epoch=1,
|
|
batch_per_device=2,
|
|
ckpt_interval=100,
|
|
grad_accum_steps=1,
|
|
random_seed=42,
|
|
device_type=device,
|
|
nprocs=1,
|
|
parallel_mode="none",
|
|
strategy_kwargs=strategy_kwargs,
|
|
rollout_interval=1,
|
|
rollout_max_policy_lag=0,
|
|
rollout_temperature=1.0,
|
|
rollout_top_k=0,
|
|
rollout_top_p=1.0,
|
|
rollout_max_tokens=4,
|
|
reward_model_fn=LengthRewardModel,
|
|
collate_fn=instruction_collate_fn,
|
|
)
|
|
if with_critic:
|
|
config_kwargs["critic_model_fn"] = partial(_value_model_fn, model_config)
|
|
config_kwargs["critic_optimizer_fn"] = _optimizer_fn
|
|
train_config = TrainConfig(**config_kwargs)
|
|
|
|
trainer = Trainer(train_config)
|
|
trainer.train(param_path=test_dir)
|
|
|
|
checkpoint_dir = os.path.join(test_dir, "ckpt", "epoch_0_step_2")
|
|
assert os.path.isdir(checkpoint_dir)
|
|
checkpoint = Checkpoint.load(checkpoint_dir)
|
|
assert checkpoint.meta["policy_version"] == 2
|
|
assert len(created_reference_models) == 1
|
|
if with_critic:
|
|
assert "value_model" in checkpoint.extra
|
|
assert "value_optimizer" in checkpoint.extra
|
|
else:
|
|
assert "value_model" not in checkpoint.extra
|
|
|
|
|
|
def _minimal_online_config(**overrides):
|
|
"""A TrainConfig for online GRPO that only needs field overrides."""
|
|
defaults = dict(
|
|
strategy="online_grpo",
|
|
model_fn=lambda: torch.nn.Linear(2, 2),
|
|
dataset=InstructionDataset(),
|
|
optimizer_fn=lambda m: torch.optim.SGD(m.parameters(), lr=0.0),
|
|
scheduler_fn=lambda o: SchedulerFactory.create(
|
|
"cosine", o, warmup_steps=1, lr_decay_steps=4, min_rate=0.05
|
|
),
|
|
reward_model_fn=LengthRewardModel,
|
|
)
|
|
defaults.update(overrides)
|
|
return TrainConfig(**defaults)
|
|
|
|
|
|
def test_online_config_rejects_contradictory_policy_lag():
|
|
"""rollout_max_policy_lag below rollout_interval - 1 guarantees a fatal
|
|
RolloutVersionError mid-training; it must fail at config time instead."""
|
|
with pytest.raises(ValueError, match="rollout_max_policy_lag=0"):
|
|
_minimal_online_config(rollout_interval=3, rollout_max_policy_lag=0)
|
|
|
|
# lag == interval - 1 (including the derived default) stays valid.
|
|
config = _minimal_online_config(rollout_interval=3, rollout_max_policy_lag=2)
|
|
assert config.rollout_max_policy_lag == 2
|
|
config = _minimal_online_config(rollout_interval=3)
|
|
assert config.rollout_max_policy_lag is None
|
|
|
|
# Offline strategies never consult the rollout window.
|
|
config = _minimal_online_config(
|
|
strategy="sft", rollout_interval=3, rollout_max_policy_lag=0
|
|
)
|
|
assert config.rollout_max_policy_lag == 0
|