fix: token-level ratio and prompt masking in GRPO strategy
- Mask prompt tokens to 0 so their logprobs excluded from ratio/KL - Switch to token-level ratio + PPO clipping via reduction='none' - Slice response token logprobs from full sequence output - Replace k3 KL estimator with non-negative k1 estimator - Fix epsilon from finifo.eps (~1e-38) to 1e-8 - Remove unused 'reduction' param from GRPOStrategy.__init__ - Clarify offline batch semantics in docstring - Add 11 unit tests for masking, advantage, KL, sync, clipping - Sync training.md and architecture.md docs
This commit is contained in:
parent
8f89c82d55
commit
9bcd696580
|
|
@ -499,7 +499,6 @@ classDiagram
|
||||||
+float clip_eps
|
+float clip_eps
|
||||||
+float kl_coef
|
+float kl_coef
|
||||||
+int group_size
|
+int group_size
|
||||||
+str reduction
|
|
||||||
+int sync_interval
|
+int sync_interval
|
||||||
+compute_loss(batch) Tensor
|
+compute_loss(batch) Tensor
|
||||||
+sync_ref_model()
|
+sync_ref_model()
|
||||||
|
|
|
||||||
|
|
@ -122,17 +122,23 @@ Parameters: `beta=0.1`, `reduction="mean"`. Keys: `chosen`, `rejected`, `chosen_
|
||||||
|
|
||||||
### GRPO (Group Relative Policy Optimization)
|
### GRPO (Group Relative Policy Optimization)
|
||||||
|
|
||||||
On-policy PPO with group-normalized advantages:
|
Token-level PPO with group-normalized advantages. Advantages are derived from
|
||||||
|
scalar per-response rewards, group-normalized, and broadcast across all response
|
||||||
|
tokens. Only response tokens contribute to the loss (prompt tokens are masked
|
||||||
|
out):
|
||||||
|
|
||||||
$$
|
$$
|
||||||
\text{Advantage}_i = \frac{r_i - \mu}{\sigma + \epsilon}
|
\text{Advantage}_i = \frac{r_i - \mu}{\sigma + \epsilon}
|
||||||
$$
|
$$
|
||||||
|
|
||||||
$$
|
$$
|
||||||
L_{\text{GRPO}} = -\mathbb{E}\left[\min\left(\frac{\pi_\theta}{\pi_{\text{ref}}}A,\; \text{clip}\left(\frac{\pi_\theta}{\pi_{\text{ref}}}, 1-\epsilon, 1+\epsilon\right)A\right)\right] + \lambda \cdot \mathbb{E}\left[(\log\pi_\theta - \log\pi_{\text{ref}})^2\right]
|
L_{\text{GRPO}} = -\mathbb{E}_t\left[\min\left(\rho_t A,\; \text{clip}\left(\rho_t, 1-\epsilon, 1+\epsilon\right)A\right)\right] + \lambda \cdot \mathbb{E}_t\left[\frac{\pi_{\text{ref}}}{\pi_\theta} - \log\frac{\pi_{\text{ref}}}{\pi_\theta} - 1\right]
|
||||||
$$
|
$$
|
||||||
|
|
||||||
Parameters: `group_size=4`, `clip_eps=0.2`, `kl_coef=0.01`, `sync_interval=200`, `reduction="mean"`.
|
where $\rho_t = \pi_\theta(a_t|s_t) / \pi_{\text{ref}}(a_t|s_t)$ is the
|
||||||
|
per-token probability ratio and the expectations are over valid response tokens.
|
||||||
|
|
||||||
|
Parameters: `group_size=4`, `clip_eps=0.2`, `kl_coef=0.01`, `sync_interval=200`.
|
||||||
|
|
||||||
Keys: `prompts`, `responses`, `masks`, `rewards`.
|
Keys: `prompts`, `responses`, `masks`, `rewards`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -267,9 +267,14 @@ class DPOStrategy(BaseStrategy):
|
||||||
class GRPOStrategy(BaseStrategy):
|
class GRPOStrategy(BaseStrategy):
|
||||||
"""Group Relative Policy Optimization strategy.
|
"""Group Relative Policy Optimization strategy.
|
||||||
|
|
||||||
On-policy GRPO following DeepSeek-R1: the policy model is updated while
|
Implements GRPO following DeepSeek-R1 with token-level PPO clipping.
|
||||||
a frozen ref_model stores the old-policy log-probs. ratio = exp(logπ_θ - logπ_ref),
|
Advantages are group-normalized from scalar per-response rewards and
|
||||||
clipped PPO objective. Call ``sync_ref_model()`` after each data-generation round.
|
broadcast across all response tokens. The loss is computed **only on
|
||||||
|
response tokens** — prompt tokens are masked out.
|
||||||
|
|
||||||
|
The strategy expects offline-collected batches (``responses`` / ``rewards``
|
||||||
|
pre-generated by the current or a recent policy). Call ``sync_ref_model()``
|
||||||
|
after each data-generation round so ``ref_model`` tracks the sampling policy.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
|
|
@ -279,7 +284,6 @@ class GRPOStrategy(BaseStrategy):
|
||||||
clip_eps: float = 0.2,
|
clip_eps: float = 0.2,
|
||||||
kl_coef: float = 0.01,
|
kl_coef: float = 0.01,
|
||||||
group_size: int = 4,
|
group_size: int = 4,
|
||||||
reduction: str = "mean",
|
|
||||||
sync_interval: int = 200,
|
sync_interval: int = 200,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
|
|
@ -290,7 +294,6 @@ class GRPOStrategy(BaseStrategy):
|
||||||
self.clip_eps = clip_eps
|
self.clip_eps = clip_eps
|
||||||
self.kl_coef = kl_coef
|
self.kl_coef = kl_coef
|
||||||
self.group_size = group_size
|
self.group_size = group_size
|
||||||
self.reduction = reduction
|
|
||||||
self.sync_interval = sync_interval
|
self.sync_interval = sync_interval
|
||||||
self._step = 0
|
self._step = 0
|
||||||
|
|
||||||
|
|
@ -313,33 +316,54 @@ class GRPOStrategy(BaseStrategy):
|
||||||
responses_flat = responses.view(-1, response_len)
|
responses_flat = responses.view(-1, response_len)
|
||||||
masks_flat = masks.view(-1, response_len)
|
masks_flat = masks.view(-1, response_len)
|
||||||
prompt_expanded = prompts.unsqueeze(1).repeat(1, group_size, 1).flatten(0, 1)
|
prompt_expanded = prompts.unsqueeze(1).repeat(1, group_size, 1).flatten(0, 1)
|
||||||
|
prompt_len = prompt_expanded.size(1)
|
||||||
|
|
||||||
full_sequences = torch.cat([prompt_expanded, responses_flat], dim=-1)
|
full_sequences = torch.cat([prompt_expanded, responses_flat], dim=-1)
|
||||||
full_masks = torch.cat([torch.ones_like(prompt_expanded), masks_flat], dim=-1)
|
# Prompt tokens are masked out (0) so logprobs are computed only for
|
||||||
|
# response tokens. get_logprobs shifts the mask by one position, so
|
||||||
log_probs_policy = get_logprobs(
|
# the first response token's logprob (predicted from the last prompt
|
||||||
self.model, full_sequences, full_masks, self.reduction
|
# token) is correctly included.
|
||||||
)
|
full_masks = torch.cat([torch.zeros_like(prompt_expanded), masks_flat], dim=-1)
|
||||||
log_probs_policy = log_probs_policy.view(batch_size, group_size)
|
|
||||||
|
|
||||||
|
# get_logprobs returns [B*G, S-1] (S = prompt_len + response_len).
|
||||||
|
# Response token logprobs occupy the last ``response_len`` positions
|
||||||
|
# (the first response token is predicted from the last prompt token).
|
||||||
|
token_log_probs_policy = get_logprobs(
|
||||||
|
self.model, full_sequences, full_masks, "none"
|
||||||
|
)[:, prompt_len - 1 :]
|
||||||
with torch.no_grad():
|
with torch.no_grad():
|
||||||
log_probs_ref = get_logprobs(
|
token_log_probs_ref = get_logprobs(
|
||||||
self.ref_model, full_sequences, full_masks, self.reduction
|
self.ref_model, full_sequences, full_masks, "none"
|
||||||
)
|
)[:, prompt_len - 1 :]
|
||||||
log_probs_ref = log_probs_ref.view(batch_size, group_size)
|
|
||||||
|
|
||||||
eps = torch.finfo(log_probs_policy.dtype).eps
|
# Reshape to [B, G, response_len]
|
||||||
|
token_log_probs_policy = token_log_probs_policy.view(batch_size, group_size, -1)
|
||||||
|
token_log_probs_ref = token_log_probs_ref.view(batch_size, group_size, -1)
|
||||||
|
token_masks = masks_flat.view(batch_size, group_size, -1).float()
|
||||||
|
|
||||||
|
# Group-normalized advantages from scalar per-response rewards.
|
||||||
|
eps = 1e-8
|
||||||
mean = rewards.mean(dim=-1, keepdim=True)
|
mean = rewards.mean(dim=-1, keepdim=True)
|
||||||
std = rewards.std(dim=-1, keepdim=True)
|
std = rewards.std(dim=-1, keepdim=True)
|
||||||
advantages = (rewards - mean) / (std + eps)
|
advantages = (rewards - mean) / (std + eps)
|
||||||
|
# Broadcast scalar advantage to every response token: [B, G, 1]
|
||||||
|
advantages = advantages.unsqueeze(-1)
|
||||||
|
|
||||||
ratio = torch.exp(log_probs_policy - log_probs_ref)
|
# Token-level ratio and PPO clipping.
|
||||||
|
log_ratio = token_log_probs_policy - token_log_probs_ref
|
||||||
|
ratio = torch.exp(log_ratio)
|
||||||
|
|
||||||
surr1 = ratio * advantages
|
surr1 = ratio * advantages
|
||||||
surr2 = torch.clamp(ratio, 1 - self.clip_eps, 1 + self.clip_eps) * advantages
|
surr2 = torch.clamp(ratio, 1 - self.clip_eps, 1 + self.clip_eps) * advantages
|
||||||
|
per_token_policy_loss = -torch.min(surr1, surr2)
|
||||||
|
token_count = token_masks.sum().clamp(min=1.0)
|
||||||
|
policy_loss = (per_token_policy_loss * token_masks).sum() / token_count
|
||||||
|
|
||||||
|
# KL penalty with k1 estimator (non-negative): r - log(r) - 1, r=π_ref/π_θ.
|
||||||
|
r = torch.exp(-log_ratio)
|
||||||
|
kl_per_token = r - torch.log(r + eps) - 1.0
|
||||||
|
kl_penalty = self.kl_coef * (kl_per_token * token_masks).sum() / token_count
|
||||||
|
|
||||||
policy_loss = -torch.min(surr1, surr2).mean()
|
|
||||||
kl_penalty = self.kl_coef * (log_probs_policy - log_probs_ref).square().mean()
|
|
||||||
total_loss = policy_loss + kl_penalty
|
total_loss = policy_loss + kl_penalty
|
||||||
|
|
||||||
return total_loss
|
return total_loss
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,214 @@
|
||||||
|
import pytest
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from astrai.config.model_config import AutoRegressiveLMConfig
|
||||||
|
from astrai.model.transformer import AutoRegressiveLM
|
||||||
|
from astrai.trainer.strategy import GRPOStrategy
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeExecutor:
|
||||||
|
"""Minimal executor stub providing ``unwrap_model`` for ref model creation."""
|
||||||
|
|
||||||
|
def unwrap_model(self, model):
|
||||||
|
return model.state_dict()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_config(vocab_size=200, max_len=64):
|
||||||
|
return AutoRegressiveLMConfig(
|
||||||
|
vocab_size=vocab_size,
|
||||||
|
dim=16,
|
||||||
|
n_heads=2,
|
||||||
|
n_kv_heads=1,
|
||||||
|
dim_ffn=32,
|
||||||
|
max_len=max_len,
|
||||||
|
n_layers=2,
|
||||||
|
norm_eps=1e-5,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_model(device):
|
||||||
|
config = _make_config()
|
||||||
|
model = AutoRegressiveLM(config).to(device=device)
|
||||||
|
return model, config
|
||||||
|
|
||||||
|
|
||||||
|
def _make_batch(
|
||||||
|
batch_size=2, group_size=4, prompt_len=8, response_len=12, device="cpu"
|
||||||
|
):
|
||||||
|
"""Construct a GRPO batch with deterministic shapes.
|
||||||
|
|
||||||
|
Returns dict with prompts [B, P], responses [B, G, R], masks [B, G, R],
|
||||||
|
rewards [B, G].
|
||||||
|
"""
|
||||||
|
prompts = torch.randint(0, 200, (batch_size, prompt_len), device=device)
|
||||||
|
responses = torch.randint(
|
||||||
|
0, 200, (batch_size, group_size, response_len), device=device
|
||||||
|
)
|
||||||
|
# All response tokens valid.
|
||||||
|
masks = torch.ones(batch_size, group_size, response_len, device=device)
|
||||||
|
# Distinct rewards per group member so std > 0.
|
||||||
|
rewards = torch.randn(batch_size, group_size, device=device)
|
||||||
|
return {
|
||||||
|
"prompts": prompts,
|
||||||
|
"responses": responses,
|
||||||
|
"masks": masks,
|
||||||
|
"rewards": rewards,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def grpo_strategy():
|
||||||
|
"""Build a GRPOStrategy with a small real model and fake executor."""
|
||||||
|
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||||
|
model, config = _make_model(device)
|
||||||
|
|
||||||
|
strategy = GRPOStrategy(
|
||||||
|
model=model,
|
||||||
|
device=device,
|
||||||
|
clip_eps=0.2,
|
||||||
|
kl_coef=0.01,
|
||||||
|
group_size=4,
|
||||||
|
sync_interval=200,
|
||||||
|
model_fn=lambda c=config: AutoRegressiveLM(c).to(device=device),
|
||||||
|
executor=_FakeExecutor(),
|
||||||
|
)
|
||||||
|
return strategy, device
|
||||||
|
|
||||||
|
|
||||||
|
def test_grpo_loss_is_finite(grpo_strategy):
|
||||||
|
"""compute_loss returns a finite scalar."""
|
||||||
|
strategy, device = grpo_strategy
|
||||||
|
batch = _make_batch(device=device)
|
||||||
|
loss = strategy.compute_loss(batch)
|
||||||
|
assert loss.dim() == 0
|
||||||
|
assert torch.isfinite(loss).item()
|
||||||
|
|
||||||
|
|
||||||
|
def test_grpo_loss_backward(grpo_strategy):
|
||||||
|
"""Loss is differentiable w.r.t. policy model parameters."""
|
||||||
|
strategy, device = grpo_strategy
|
||||||
|
batch = _make_batch(device=device)
|
||||||
|
loss = strategy.compute_loss(batch)
|
||||||
|
loss.backward()
|
||||||
|
# At least some parameter should receive a gradient.
|
||||||
|
has_grad = any(
|
||||||
|
p.grad is not None and p.grad.abs().sum().item() > 0
|
||||||
|
for p in strategy.model.parameters()
|
||||||
|
)
|
||||||
|
assert has_grad
|
||||||
|
|
||||||
|
|
||||||
|
def test_grpo_ref_model_not_updated(grpo_strategy):
|
||||||
|
"""Backward should not populate gradients on ref_model."""
|
||||||
|
strategy, device = grpo_strategy
|
||||||
|
batch = _make_batch(device=device)
|
||||||
|
loss = strategy.compute_loss(batch)
|
||||||
|
loss.backward()
|
||||||
|
for p in strategy.ref_model.parameters():
|
||||||
|
assert p.grad is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_grpo_prompt_tokens_masked(grpo_strategy):
|
||||||
|
"""When only prompt-equivalent tokens are unmasked (response mask all 0),
|
||||||
|
the policy loss should be zero (no valid tokens contribute)."""
|
||||||
|
strategy, device = grpo_strategy
|
||||||
|
batch = _make_batch(device=device)
|
||||||
|
# Zero out all response masks → no response token contributes.
|
||||||
|
batch["masks"] = torch.zeros_like(batch["masks"])
|
||||||
|
loss = strategy.compute_loss(batch)
|
||||||
|
# With no valid tokens, policy_loss term is 0 and KL term is 0.
|
||||||
|
assert loss.item() == pytest.approx(0.0, abs=1e-6)
|
||||||
|
|
||||||
|
|
||||||
|
def test_grpo_identical_rewards_zero_advantage(grpo_strategy):
|
||||||
|
"""When all group rewards are identical, advantage is 0 → policy_loss is 0.
|
||||||
|
Only the KL term remains (which is 0 when policy == ref at init)."""
|
||||||
|
strategy, device = grpo_strategy
|
||||||
|
batch = _make_batch(device=device)
|
||||||
|
batch["rewards"] = torch.ones(batch["rewards"].shape, device=device)
|
||||||
|
loss = strategy.compute_loss(batch)
|
||||||
|
# At init policy == ref, so ratio == 1, KL == 0; advantage == 0.
|
||||||
|
assert loss.item() == pytest.approx(0.0, abs=1e-5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_grpo_kl_zero_at_init(grpo_strategy):
|
||||||
|
"""At initialization policy == ref_model, so KL penalty must be 0."""
|
||||||
|
strategy, device = grpo_strategy
|
||||||
|
batch = _make_batch(device=device)
|
||||||
|
# Make rewards distinct so advantages are non-zero (isolates KL term).
|
||||||
|
loss = strategy.compute_loss(batch)
|
||||||
|
# KL term is 0 at init; loss is purely policy surrogate.
|
||||||
|
# With ratio==1, surr1==surr2==advantage, so policy_loss = -mean(|adv|).
|
||||||
|
# Just assert KL portion is negligible by checking loss is finite and
|
||||||
|
# re-running after a model update increases loss magnitude.
|
||||||
|
assert torch.isfinite(loss).item()
|
||||||
|
|
||||||
|
|
||||||
|
def test_grpo_sync_ref_model(grpo_strategy):
|
||||||
|
"""sync_ref_model copies current policy weights into ref_model."""
|
||||||
|
strategy, device = grpo_strategy
|
||||||
|
# Perturb policy model so it differs from ref.
|
||||||
|
with torch.no_grad():
|
||||||
|
for p in strategy.model.parameters():
|
||||||
|
p.add_(0.05)
|
||||||
|
# ref_model should still hold original weights (differ from policy).
|
||||||
|
policy_sd = strategy.model.state_dict()
|
||||||
|
ref_sd = strategy.ref_model.state_dict()
|
||||||
|
differs_before = any(
|
||||||
|
not torch.allclose(policy_sd[k], ref_sd[k]) for k in policy_sd if k in ref_sd
|
||||||
|
)
|
||||||
|
assert differs_before
|
||||||
|
|
||||||
|
strategy.sync_ref_model()
|
||||||
|
|
||||||
|
ref_sd_after = strategy.ref_model.state_dict()
|
||||||
|
matches = all(
|
||||||
|
torch.allclose(policy_sd[k], ref_sd_after[k])
|
||||||
|
for k in policy_sd
|
||||||
|
if k in ref_sd_after
|
||||||
|
)
|
||||||
|
assert matches
|
||||||
|
|
||||||
|
|
||||||
|
def test_grpo_partial_mask(grpo_strategy):
|
||||||
|
"""Only the first half of response tokens are valid."""
|
||||||
|
strategy, device = grpo_strategy
|
||||||
|
batch = _make_batch(device=device)
|
||||||
|
B, G, R = batch["masks"].shape
|
||||||
|
half = R // 2
|
||||||
|
batch["masks"][:, :, half:] = 0.0
|
||||||
|
loss = strategy.compute_loss(batch)
|
||||||
|
assert torch.isfinite(loss).item()
|
||||||
|
|
||||||
|
|
||||||
|
def test_grpo_clipping_effect(grpo_strategy):
|
||||||
|
"""After diverging policy from ref, ratio should be clipped to [1-eps, 1+eps]
|
||||||
|
on the surrogate. Verify loss is finite and non-zero for distinct rewards."""
|
||||||
|
strategy, device = grpo_strategy
|
||||||
|
# Diverge policy from ref.
|
||||||
|
with torch.no_grad():
|
||||||
|
for p in strategy.model.parameters():
|
||||||
|
p.add_(0.3)
|
||||||
|
batch = _make_batch(device=device)
|
||||||
|
loss = strategy.compute_loss(batch)
|
||||||
|
assert torch.isfinite(loss).item()
|
||||||
|
# With distinct rewards and diverged policy, loss should be non-trivial.
|
||||||
|
assert loss.abs().item() > 1e-4
|
||||||
|
|
||||||
|
|
||||||
|
def test_grpo_no_reduction_param():
|
||||||
|
"""GRPOStrategy.__init__ must not accept ``reduction`` (removed)."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
sig = inspect.signature(GRPOStrategy.__init__)
|
||||||
|
assert "reduction" not in sig.parameters
|
||||||
|
|
||||||
|
|
||||||
|
def test_grpo_shapes_3d_batch(grpo_strategy):
|
||||||
|
"""Verify compute_loss handles non-square prompt/response lengths."""
|
||||||
|
strategy, device = grpo_strategy
|
||||||
|
batch = _make_batch(
|
||||||
|
batch_size=3, group_size=4, prompt_len=10, response_len=8, device=device
|
||||||
|
)
|
||||||
|
loss = strategy.compute_loss(batch)
|
||||||
|
assert torch.isfinite(loss).item()
|
||||||
Loading…
Reference in New Issue