[recipe] feat: add Online Policy Distillation (OPD) recipe - #77
[recipe] feat: add Online Policy Distillation (OPD) recipe#77narutolhy wants to merge 2 commits into
Conversation
Add a lightweight OPD recipe that extends verl's RayPPOTrainer with external teacher knowledge distillation. The student trains with GRPO while also minimizing KL divergence against a teacher model's per-token distribution, queried via ZMQ. Key design decisions: - Zero modification to verl source code (pure inheritance) - OPDTrainer overrides _compute_ref_log_prob to query external teacher - Reuses verl's existing use_kl_loss mechanism for KL(student || teacher) - Teacher server: reuses GKD recipe's ZMQ-based teacher (proxy + worker) Files: - opd_trainer.py: OPDTrainer + TeacherClient - main_opd.py: Entry point with OPDTaskRunner - reward_gsm8k.py: GSM8K answer extraction and scoring - run_opd.sh: Launch script with configurable KL coefficient - README.md: Documentation with architecture and experimental results Tested with Qwen3-8B student + Qwen3-32B teacher on GSM8K (8x H100). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fix two bugs in OPD teacher log-prob extraction: 1. Off-by-one in response token alignment: teacher logprob row j predicts ids[j+1], so the teacher prediction for response token t (at ids[actual_prompt_len + t]) lives at row (actual_prompt_len - 1 + t), not (actual_prompt_len + t). The old code shifted the entire KL target by one position, distilling against the wrong tokens. 2. Replace arbitrary -10.0 fallback for out-of-top-k tokens with a principled estimate: log((1 - sum(top_k_probs)) / (vocab_size - k)). This spreads the residual probability mass uniformly over non-top-k tokens, giving a tighter lower bound that scales with the actual teacher distribution rather than a hardcoded constant. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces Online Policy Distillation (OPD), allowing a student model to match a teacher model's distribution while optimizing task rewards. Key additions include the OPDTrainer, which overrides reference log probability computation to query an external teacher via ZMQ, and a GSM8K-specific reward function. Feedback focuses on critical improvements to the TeacherClient: addressing a security risk by using weights_only=True in torch.load, batching ZMQ requests for efficiency, fixing an off-by-one error in log probability indexing, and implementing socket timeouts to prevent indefinite blocking.
| def _deserialize(msg): | ||
| """Deserialize data using torch.load (matching GKD teacher protocol).""" | ||
| buf = io.BytesIO(msg) | ||
| return torch.load(buf, weights_only=False) |
There was a problem hiding this comment.
Using weights_only=False with torch.load is a security risk as it can lead to arbitrary code execution when loading untrusted data. Since the data exchanged here consists of standard Python types and tensors, weights_only=True should be used instead.
| return torch.load(buf, weights_only=False) | |
| return torch.load(buf, weights_only=True) |
| teacher_log_probs = torch.zeros(batch_size, response_length, dtype=torch.float32) | ||
|
|
||
| for i in range(batch_size): | ||
| mask = attention_mask[i].bool() | ||
| ids = input_ids[i][mask].tolist() | ||
| if len(ids) < 2: | ||
| continue | ||
|
|
||
| # Query teacher for top-k log probs of the full sequence | ||
| request = _serialize({ | ||
| "prompt_token_ids": [ids], | ||
| "temperature": 0.0, | ||
| "max_tokens": 1, | ||
| "only_response": False, | ||
| }) | ||
| self.socket.send(request) | ||
| response = _deserialize(self.socket.recv()) | ||
|
|
||
| if response.get("status") != "ok" or len(response["teacher_topk_logprobs"]) == 0: | ||
| logger.warning(f"Teacher error for sample {i}: {response.get('reason', 'empty')}") | ||
| continue | ||
|
|
||
| topk_logps = response["teacher_topk_logprobs"][0] # (valid_len, k) | ||
| topk_indices = response["teacher_topk_indices"][0] # (valid_len, k) | ||
|
|
||
| # Extract log prob of actual next token at each response position. | ||
| # | ||
| # Teacher logprob layout: row j of topk_logps gives P(token | ids[0:j+1]), | ||
| # i.e. the distribution over ids[j+1]. So to get the teacher's prediction | ||
| # for the t-th response token ids[actual_prompt_len + t], we read from | ||
| # row (actual_prompt_len - 1 + t). | ||
| actual_prompt_len = int(attention_mask[i][:prompt_length].sum().item()) | ||
|
|
||
| # Compute a per-sample fallback for tokens outside teacher's top-k. | ||
| # Use the remaining probability mass spread uniformly over non-top-k tokens: | ||
| # fallback = log((1 - sum(top_k_probs)) / (vocab_size - k)) | ||
| k = topk_logps.shape[1] if topk_logps.dim() == 2 else 1 | ||
| vocab_size = 151936 # Qwen series vocab size; conservative default |
There was a problem hiding this comment.
There are several issues in this block:
- Efficiency: The teacher server is queried sequentially for each sample in the batch. This should be batched to avoid massive overhead during training.
- Correctness (Off-by-one): The current logic
src_idx = actual_prompt_len + tgets the log probability of the next token (ids[src_idx + 1]). For the first response token (t=0), it calculateslog_prob(ids[actual_prompt_len + 1] | ...ids[actual_prompt_len]), effectively skipping the first response token and shifting all log probabilities by one. - Robustness: Initializing with
zeros(logprob 0.0 => prob 1.0) is dangerous because if a query fails or a sample is skipped, it defaults to a perfect-match signal, which will provide incorrect training gradients.
I suggest batching the requests and fixing the indexing logic.
# Initialize with a small value to avoid 0.0 (logprob) which implies prob=1.0
teacher_log_probs = torch.full((batch_size, response_length), -100.0, dtype=torch.float32)
all_ids = []
valid_indices = []
for i in range(batch_size):
mask = attention_mask[i].bool()
ids = input_ids[i][mask].tolist()
if len(ids) >= 2:
all_ids.append(ids)
valid_indices.append(i)
if not all_ids:
return teacher_log_probs
# Query teacher for the entire batch at once
request = _serialize({
"prompt_token_ids": all_ids,
"temperature": 0.0,
"max_tokens": 1,
"only_response": False,
})
self.socket.send(request)
response = _deserialize(self.socket.recv())
if response.get("status") != "ok":
logger.warning(f"Teacher error: {response.get('reason', 'unknown')}")
return teacher_log_probs
all_topk_logps = response["teacher_topk_logprobs"]
all_topk_indices = response["teacher_topk_indices"]
for idx_in_batch, i in enumerate(valid_indices):
topk_logps = all_topk_logps[idx_in_batch]
topk_indices = all_topk_indices[idx_in_batch]
ids = all_ids[idx_in_batch]
actual_prompt_len = int(attention_mask[i][:prompt_length].sum().item())
for t in range(response_length):
# To get logprob of token at ids[j], we look at teacher distribution after ids[j-1]
src_idx = actual_prompt_len + t - 1
if src_idx < 0 or src_idx >= topk_logps.shape[0] or src_idx + 1 >= len(ids):
continue
next_token = ids[src_idx + 1]
match = (topk_indices[src_idx] == next_token).nonzero(as_tuple=True)
if len(match[0]) > 0:
teacher_log_probs[i, t] = topk_logps[src_idx, match[0][0]].item()| self.socket = self.context.socket(zmq.REQ) | ||
| self.socket.connect(f"tcp://{server_ip}:{server_port}") |
There was a problem hiding this comment.
The ZMQ REQ socket is synchronous and lacks timeouts. If the teacher server hangs or the network fails, the entire training process will block indefinitely. It is recommended to set RCVTIMEO and SNDTIMEO options.
| self.socket = self.context.socket(zmq.REQ) | |
| self.socket.connect(f"tcp://{server_ip}:{server_port}") | |
| self.socket = self.context.socket(zmq.REQ) | |
| self.socket.setsockopt(zmq.RCVTIMEO, 60000) # 60s timeout | |
| self.socket.setsockopt(zmq.SNDTIMEO, 60000) | |
| self.socket.connect(f"tcp://{server_ip}:{server_port}") |
Summary
RayPPOTrainerwith external teacher knowledge distillationArchitecture
Key Design
OPDTrainerinheritsRayPPOTrainerand overrides a single method:This plugs into verl's existing
use_kl_lossmechanism — no changes needed to actor loss computation.Files
opd/opd_trainer.pyOPDTrainer+TeacherClient(ZMQ)opd/main_opd.pyOPDTaskRunneropd/reward_gsm8k.pyopd/run_opd.shopd/README.mdExperimental Results
Qwen3-8B student + Qwen3-32B teacher on GSM8K, 500 steps, n=8:
Test plan
🤖 Generated with Claude Code