Files
AstrAI/tests/trainer/test_online_e2e.py
T
ViperEkura 350e4a1849 feat: add online ppo with value-model critic and gae advantages
- 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
2026-09-05 01:59:50 +08:00

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