Compare commits

...

2 Commits

Author SHA1 Message Date
hy 48ceafdfd7
Merge 864bd67437 into e84156818a 2026-08-29 10:01:38 -04:00
liuhy495 864bd67437 feat: add generalized on-policy distillation trainer 2026-07-15 21:15:10 +08:00
3 changed files with 931 additions and 5 deletions

148
trainer/opd_utils.py Normal file
View File

@ -0,0 +1,148 @@
"""Core tensor utilities for Generalized Knowledge Distillation (GKD)."""
from typing import Dict, Optional, Tuple
import torch
import torch.nn.functional as F
from torch import Tensor
def build_completion_mask(
completion_ids: Tensor,
base_mask: Optional[Tensor],
eos_token_id: Optional[int],
pad_token_id: Optional[int] = None,
) -> Tensor:
"""Keep valid completion tokens up to and including the first EOS.
MiniMind's native ``generate`` pads finished rows with repeated EOS tokens
until every row is complete. Those repeated tokens are valid model inputs,
but they must not contribute to the distillation objective.
"""
if completion_ids.ndim != 2:
raise ValueError(f"completion_ids must be 2D, got {tuple(completion_ids.shape)}")
if base_mask is None:
mask = torch.ones_like(completion_ids, dtype=torch.bool)
else:
if base_mask.shape != completion_ids.shape:
raise ValueError(
f"base_mask shape {tuple(base_mask.shape)} does not match "
f"completion_ids shape {tuple(completion_ids.shape)}"
)
mask = base_mask.bool().clone()
# Some tokenizers intentionally use EOS as PAD. In that case the first EOS
# still belongs to the completion and is handled by the cumulative mask.
if pad_token_id is not None and pad_token_id != eos_token_id:
mask &= completion_ids.ne(pad_token_id)
if eos_token_id is not None:
eos_hits = completion_ids.eq(eos_token_id) & mask
eos_seen_before = eos_hits.long().cumsum(dim=1) - eos_hits.long()
mask &= eos_seen_before.eq(0)
return mask
def _masked_mean(values: Tensor, mask: Tensor) -> Tensor:
mask = mask.to(device=values.device, dtype=values.dtype)
return (values * mask).sum() / mask.sum().clamp_min(1.0)
def generalized_jsd_loss(
student_logits: Tensor,
teacher_logits: Tensor,
completion_mask: Tensor,
beta: float = 0.5,
temperature: float = 1.0,
) -> Tuple[Tensor, Dict[str, Tensor]]:
"""Compute the exact full-vocabulary generalized JSD from GKD.
With teacher distribution ``p`` and student distribution ``q``, the
mixture is ``m = beta * p + (1 - beta) * q`` and the objective is
``beta * KL(p || m) + (1 - beta) * KL(q || m)``. Its limiting cases are
forward KL at ``beta=0`` and reverse KL at ``beta=1``.
The teacher is always treated as a fixed target. Only completion tokens
selected by ``completion_mask`` contribute to the token-mean loss.
"""
if student_logits.ndim != 3 or teacher_logits.ndim != 3:
raise ValueError("student_logits and teacher_logits must be [batch, time, vocab]")
if student_logits.shape != teacher_logits.shape:
raise ValueError(
"student and teacher logits must have identical shapes, got "
f"{tuple(student_logits.shape)} and {tuple(teacher_logits.shape)}"
)
if completion_mask.shape != student_logits.shape[:2]:
raise ValueError(
f"completion_mask shape {tuple(completion_mask.shape)} does not match "
f"logits shape {tuple(student_logits.shape[:2])}"
)
if not 0.0 <= beta <= 1.0:
raise ValueError(f"beta must be in [0, 1], got {beta}")
if temperature <= 0:
raise ValueError(f"temperature must be positive, got {temperature}")
# Float32 log-softmax keeps the divergence stable under fp16/bf16 model
# autocast, while preserving gradients back to the student parameters.
student_logps = F.log_softmax(student_logits.float() / temperature, dim=-1)
teacher_logps = F.log_softmax(
teacher_logits.detach().float() / temperature, dim=-1
)
student_probs = student_logps.exp()
teacher_probs = teacher_logps.exp()
forward_kl = (teacher_probs * (teacher_logps - student_logps)).sum(dim=-1)
reverse_kl = (student_probs * (student_logps - teacher_logps)).sum(dim=-1)
if beta == 0.0:
per_token_loss = forward_kl
elif beta == 1.0:
per_token_loss = reverse_kl
else:
beta_tensor = student_logps.new_tensor(beta)
mixture_logps = torch.logsumexp(
torch.stack(
(
student_logps + torch.log1p(-beta_tensor),
teacher_logps + torch.log(beta_tensor),
)
),
dim=0,
)
teacher_to_mixture = (
teacher_probs * (teacher_logps - mixture_logps)
).sum(dim=-1)
student_to_mixture = (
student_probs * (student_logps - mixture_logps)
).sum(dim=-1)
per_token_loss = (
beta_tensor * teacher_to_mixture
+ (1.0 - beta_tensor) * student_to_mixture
)
mask = completion_mask.bool()
loss = _masked_mean(per_token_loss, mask)
top1_agreement = _masked_mean(
student_logps.detach()
.argmax(dim=-1)
.eq(teacher_logps.argmax(dim=-1))
.float(),
mask,
)
metrics = {
"divergence": loss.detach(),
"forward_kl": _masked_mean(forward_kl.detach(), mask),
"reverse_kl": _masked_mean(reverse_kl.detach(), mask),
"top1_agreement": top1_agreement.detach(),
"valid_tokens": mask.sum().detach(),
}
return loss, metrics
def use_on_policy_batch(lmbda: float, random_value: float) -> bool:
"""Return the Algorithm 1 branch for a pre-sampled value in ``[0, 1)``."""
if not 0.0 <= lmbda <= 1.0:
raise ValueError(f"lmbda must be in [0, 1], got {lmbda}")
if not 0.0 <= random_value < 1.0:
raise ValueError(f"random_value must be in [0, 1), got {random_value}")
return random_value < lmbda

View File

@ -52,7 +52,10 @@ class RolloutEngine(ABC):
tokenizer = None
@abstractmethod
def rollout(self, prompt_ids: Tensor, attention_mask: Tensor, num_generations: int, max_new_tokens: int, temperature: float = 0.8) -> RolloutResult:
def rollout(self, prompt_ids: Tensor, attention_mask: Tensor, num_generations: int,
max_new_tokens: int, temperature: float = 0.8,
top_p: Optional[float] = None, top_k: Optional[int] = None,
calculate_logps: bool = True) -> RolloutResult:
pass
@abstractmethod
@ -68,9 +71,15 @@ class TorchRolloutEngine(RolloutEngine):
self.device = device
self.autocast_ctx = autocast_ctx
def rollout(self, prompt_ids: Tensor, attention_mask: Tensor, num_generations: int, max_new_tokens: int, temperature: float = 0.8) -> RolloutResult:
def rollout(self, prompt_ids: Tensor, attention_mask: Tensor, num_generations: int,
max_new_tokens: int, temperature: float = 0.8,
top_p: Optional[float] = None, top_k: Optional[int] = None,
calculate_logps: bool = True) -> RolloutResult:
model = self.policy_model.module if isinstance(self.policy_model, DistributedDataParallel) else self.policy_model
ctx = self.autocast_ctx if self.autocast_ctx else nullcontext()
sampling_kwargs = {}
if top_p is not None: sampling_kwargs['top_p'] = top_p
if top_k is not None: sampling_kwargs['top_k'] = top_k
with torch.no_grad(), ctx:
output_ids = model.generate(
input_ids=prompt_ids.repeat_interleave(num_generations, dim=0),
@ -81,11 +90,20 @@ class TorchRolloutEngine(RolloutEngine):
num_return_sequences=1,
pad_token_id=self.tokenizer.pad_token_id,
eos_token_id=self.tokenizer.eos_token_id,
**sampling_kwargs,
).clone() # [B*num_gen, P+R]
prompt_len = prompt_ids.size(1)
completion_ids = output_ids[:, prompt_len:] # [B*num_gen, R]
full_mask = (output_ids != self.tokenizer.pad_token_id).long()
per_token_logps = compute_per_token_logps(self.policy_model, output_ids, completion_ids.size(1), attention_mask=full_mask)
if calculate_logps:
per_token_logps = compute_per_token_logps(
self.policy_model, output_ids, completion_ids.size(1), attention_mask=full_mask
)
else:
per_token_logps = torch.zeros(
output_ids.size(0), completion_ids.size(1),
device=output_ids.device, dtype=torch.float32
)
completions = self.tokenizer.batch_decode(completion_ids, skip_special_tokens=True)
return RolloutResult(output_ids, completion_ids, per_token_logps, completions,
prompt_ids.new_full((output_ids.size(0),), prompt_len),
@ -104,7 +122,10 @@ class SGLangRolloutEngine(RolloutEngine):
self.tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)
self.http = requests
def rollout(self, prompt_ids: Tensor, attention_mask: Tensor, num_generations: int, max_new_tokens: int, temperature: float = 0.8) -> RolloutResult:
def rollout(self, prompt_ids: Tensor, attention_mask: Tensor, num_generations: int,
max_new_tokens: int, temperature: float = 0.8,
top_p: Optional[float] = None, top_k: Optional[int] = None,
calculate_logps: bool = True) -> RolloutResult:
# 去除左侧 padding tokens,只保留有效 token
input_ids_list = []
for ids, mask in zip(prompt_ids, attention_mask):
@ -119,8 +140,10 @@ class SGLangRolloutEngine(RolloutEngine):
"max_new_tokens": max_new_tokens,
"stop_token_ids": [self.tokenizer.eos_token_id] if self.tokenizer.eos_token_id else [],
},
"return_logprob": True,
"return_logprob": calculate_logps,
}
if top_p is not None: payload['sampling_params']['top_p'] = top_p
if top_k is not None: payload['sampling_params']['top_k'] = top_k if top_k > 0 else -1
resp = self.http.post(f"{self.base_url}/generate", json=payload, timeout=self.timeout)
resp.raise_for_status()

755
trainer/train_opd.py Normal file
View File

@ -0,0 +1,755 @@
import os
import sys
__package__ = "trainer"
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
import datasets # noqa: F401 # Windows pyarrow/torch DLL conflict workaround (issue #771)
import argparse
import gc
import json
import math
import random
import warnings
from contextlib import nullcontext
from pathlib import Path
import torch
import torch.distributed as dist
from datasets import load_dataset
from torch import optim
from torch.nn.parallel import DistributedDataParallel
from torch.optim.lr_scheduler import CosineAnnealingLR
from torch.utils.data import DataLoader, Dataset, DistributedSampler
from transformers import AutoTokenizer
from model.model_minimind import MiniMindConfig, MiniMindForCausalLM
from trainer.opd_utils import (
build_completion_mask,
generalized_jsd_loss,
use_on_policy_batch,
)
from trainer.rollout_engine import create_rollout_engine
from trainer.trainer_utils import (
Logger,
SkipBatchSampler,
init_distributed_mode,
is_main_process,
lm_checkpoint,
setup_seed,
)
warnings.filterwarnings("ignore")
REPO_ROOT = Path(__file__).resolve().parents[1]
class OPDDataset(Dataset):
"""Raw conversational samples used by both GKD data branches."""
def __init__(self, data_path):
self.samples = load_dataset("json", data_files=str(data_path), split="train")
def __len__(self):
return len(self.samples)
def __getitem__(self, index):
sample = self.samples[index]
conversations = sample.get("conversations", sample.get("messages"))
if not isinstance(conversations, list) or not conversations:
raise ValueError(
"OPD data requires a non-empty 'conversations' or 'messages' list"
)
return {"conversations": [dict(message) for message in conversations]}
def collate_conversations(batch):
return {"conversations": [item["conversations"] for item in batch]}
def resolve_device(device):
if device != "auto":
return device
if torch.cuda.is_available():
return "cuda:0"
if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
return "mps"
return "cpu"
def resolve_path(value):
path = Path(value).expanduser()
if path.is_absolute():
return path.resolve()
return (REPO_ROOT / path).resolve()
def validate_args(parsed_args):
if not 0.0 <= parsed_args.lmbda <= 1.0:
raise ValueError("--lmbda must be in [0, 1]")
if not 0.0 <= parsed_args.beta <= 1.0:
raise ValueError("--beta must be in [0, 1]")
if parsed_args.temperature <= 0 or parsed_args.distill_temperature <= 0:
raise ValueError("sampling and distillation temperatures must be positive")
if not 0.0 < parsed_args.rollout_top_p <= 1.0:
raise ValueError("--rollout_top_p must be in (0, 1]")
if parsed_args.rollout_top_k < 0:
raise ValueError("--rollout_top_k must be zero (disabled) or positive")
if parsed_args.num_generations < 1 or parsed_args.max_gen_len < 1:
raise ValueError("--num_generations and --max_gen_len must be positive")
if parsed_args.max_seq_len < 1 or parsed_args.accumulation_steps < 1:
raise ValueError("--max_seq_len and --accumulation_steps must be positive")
if parsed_args.max_train_steps < 0:
raise ValueError("--max_train_steps must be zero or positive")
if parsed_args.log_interval < 1 or parsed_args.save_interval < 1:
raise ValueError("log/save intervals must be positive")
if parsed_args.rollout_top_p < 1.0 or parsed_args.rollout_top_k > 0:
Logger(
"[OPD WARNING] top-p/top-k truncation changes the behavior policy; "
"top_p=1 and top_k=0 match the GKD baseline"
)
def _torch_load(path):
try:
return torch.load(path, map_location="cpu", weights_only=True)
except TypeError:
return torch.load(path, map_location="cpu")
def _load_transformers_state_dict(model_dir):
single_safetensors = model_dir / "model.safetensors"
safetensors_index = model_dir / "model.safetensors.index.json"
pytorch_bin = model_dir / "pytorch_model.bin"
if single_safetensors.exists() or safetensors_index.exists():
try:
from safetensors.torch import load_file
except ImportError as exc:
raise ImportError(
"Loading a Transformers safetensors model requires safetensors"
) from exc
if single_safetensors.exists():
return load_file(str(single_safetensors), device="cpu")
if safetensors_index.exists():
index = json.loads(safetensors_index.read_text(encoding="utf-8"))
state_dict = {}
for shard_name in sorted(set(index["weight_map"].values())):
state_dict.update(load_file(str(model_dir / shard_name), device="cpu"))
return state_dict
if pytorch_bin.exists():
return _torch_load(pytorch_bin)
raise FileNotFoundError(
f"No model.safetensors, model.safetensors.index.json, or pytorch_model.bin in {model_dir}"
)
def resolve_model_source(source, config):
direct = Path(source).expanduser()
if not direct.is_absolute():
direct = REPO_ROOT / direct
if direct.exists():
return direct.resolve()
moe_suffix = "_moe" if config.use_moe else ""
weight_path = REPO_ROOT / "out" / f"{source}_{config.hidden_size}{moe_suffix}.pth"
if weight_path.exists():
return weight_path.resolve()
raise FileNotFoundError(
f"Model source {source!r} was not found directly or as {weight_path}"
)
def load_state_dict(source, config):
source = resolve_model_source(source, config)
state_dict = (
_load_transformers_state_dict(source) if source.is_dir() else _torch_load(source)
)
if isinstance(state_dict, dict) and isinstance(state_dict.get("model"), dict):
state_dict = state_dict["model"]
if not isinstance(state_dict, dict) or not state_dict:
raise ValueError(f"Invalid or empty state dict from {source}")
for prefix in ("module.", "_orig_mod."):
if all(key.startswith(prefix) for key in state_dict):
state_dict = {key[len(prefix):]: value for key, value in state_dict.items()}
if "lm_head.weight" not in state_dict and "model.embed_tokens.weight" in state_dict:
state_dict["lm_head.weight"] = state_dict["model.embed_tokens.weight"]
if "model.embed_tokens.weight" not in state_dict and "lm_head.weight" in state_dict:
state_dict["model.embed_tokens.weight"] = state_dict["lm_head.weight"]
return state_dict, source
def validate_directory_config(source, config, label):
source_path = Path(source).expanduser()
if not source_path.is_absolute():
source_path = REPO_ROOT / source_path
config_path = source_path / "config.json" if source_path.is_dir() else None
if not config_path or not config_path.exists():
return
saved = json.loads(config_path.read_text(encoding="utf-8"))
expected = {
"hidden_size": config.hidden_size,
"num_hidden_layers": config.num_hidden_layers,
"vocab_size": config.vocab_size,
"num_attention_heads": config.num_attention_heads,
"num_key_value_heads": config.num_key_value_heads,
}
mismatches = {
key: (saved.get(key), value)
for key, value in expected.items()
if saved.get(key) is not None and saved.get(key) != value
}
if mismatches:
raise ValueError(f"{label} config does not match CLI architecture: {mismatches}")
def load_native_model(config, source, device, label, model_dtype=None):
validate_directory_config(source, config, label)
state_dict, resolved_source = load_state_dict(source, config)
loaded_model = MiniMindForCausalLM(config)
loaded_model.load_state_dict(state_dict, strict=True)
del state_dict
gc.collect()
if model_dtype is None:
loaded_model = loaded_model.to(device)
else:
loaded_model = loaded_model.to(device=device, dtype=model_dtype)
Logger(f"Loaded {label} strictly from {resolved_source}")
return loaded_model
def load_tokenizer(tokenizer_path, vocab_size):
tokenizer = AutoTokenizer.from_pretrained(
str(resolve_path(tokenizer_path)), trust_remote_code=True
)
if len(tokenizer) != vocab_size:
raise ValueError(
f"Tokenizer size {len(tokenizer)} does not match model vocab {vocab_size}"
)
if tokenizer.pad_token_id is None or tokenizer.eos_token_id is None:
raise ValueError("OPD requires tokenizer.pad_token_id and tokenizer.eos_token_id")
tokenizer.padding_side = "left"
tokenizer.truncation_side = "left"
return tokenizer
def _parse_json_field(value):
if not isinstance(value, str):
return value
try:
return json.loads(value)
except json.JSONDecodeError:
return value
def render_sample(tokenizer, conversations):
"""Render one prompt and its optional fixed completion."""
messages = []
tools = None
for raw_message in conversations:
message = {key: value for key, value in dict(raw_message).items() if value is not None}
if message.get("tools"):
parsed_tools = _parse_json_field(message["tools"])
if isinstance(parsed_tools, list):
tools = parsed_tools
if message.get("tool_calls"):
message["tool_calls"] = _parse_json_field(message["tool_calls"])
messages.append(message)
has_completion = messages[-1].get("role") == "assistant" and any(
messages[-1].get(field) for field in ("content", "reasoning_content", "tool_calls")
)
prompt_messages = messages[:-1] if messages[-1].get("role") == "assistant" else messages
template_kwargs = {"tools": tools} if tools else {}
prompt_text = tokenizer.apply_chat_template(
prompt_messages,
tokenize=False,
add_generation_prompt=True,
**template_kwargs,
)
full_text = None
if has_completion:
full_text = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=False,
**template_kwargs,
)
return prompt_text, full_text
def tokenize_prompts(tokenizer, prompt_texts, max_seq_len, device):
encoded = tokenizer(
prompt_texts,
add_special_tokens=False,
return_tensors="pt",
padding=True,
truncation=True,
max_length=max_seq_len,
return_token_type_ids=False,
)
return {key: value.to(device) for key, value in encoded.items()}
def build_off_policy_batch(
tokenizer, prompt_texts, full_texts, max_seq_len, max_gen_len, device
):
"""Tokenize fixed prompt-completion pairs and mask all prompt tokens."""
sequences = []
label_masks = []
for prompt_text, full_text in zip(prompt_texts, full_texts):
if full_text is None:
raise ValueError(
"Off-policy GKD selected a sample without a fixed assistant completion; "
"use --lmbda 1 for prompt-only data"
)
prompt_ids = tokenizer(prompt_text, add_special_tokens=False).input_ids
full_ids = tokenizer(full_text, add_special_tokens=False).input_ids
if full_ids[:len(prompt_ids)] != prompt_ids:
raise ValueError(
"The rendered prompt is not a token prefix of the fixed conversation; "
"the tokenizer/chat template is incompatible with prompt-completion GKD"
)
completion_ids = full_ids[len(prompt_ids):]
prompt_ids = prompt_ids[-max_seq_len:]
completion_ids = completion_ids[:max_gen_len]
if not completion_ids:
raise ValueError("A fixed completion became empty after tokenization/truncation")
sequences.append(prompt_ids + completion_ids)
label_masks.append([False] * len(prompt_ids) + [True] * len(completion_ids))
max_length = max(len(sequence) for sequence in sequences)
input_ids = []
attention_mask = []
labels = []
for sequence, label_mask in zip(sequences, label_masks):
padding = max_length - len(sequence)
input_ids.append(sequence + [tokenizer.pad_token_id] * padding)
attention_mask.append([1] * len(sequence) + [0] * padding)
labels.append(
[token if keep else -100 for token, keep in zip(sequence, label_mask)]
+ [-100] * padding
)
return (
torch.tensor(input_ids, dtype=torch.long, device=device),
torch.tensor(attention_mask, dtype=torch.long, device=device),
torch.tensor(labels, dtype=torch.long, device=device),
)
def distributed_mean(value):
result = value.detach().float().clone()
if dist.is_initialized():
dist.all_reduce(result, op=dist.ReduceOp.SUM)
result /= dist.get_world_size()
return result.item()
def choose_on_policy(lmbda, device):
if dist.is_initialized():
draw = torch.zeros((), device=device)
if dist.get_rank() == 0:
draw.fill_(random.random())
dist.broadcast(draw, src=0)
random_value = draw.item()
else:
random_value = random.random()
return use_on_policy_batch(lmbda, random_value)
def save_student(lm_config, epoch, step, wandb):
if not is_main_process():
return
model.eval()
raw_model = model.module if isinstance(model, DistributedDataParallel) else model
raw_model = getattr(raw_model, "_orig_mod", raw_model)
moe_suffix = "_moe" if lm_config.use_moe else ""
output_path = resolve_path(args.save_dir) / (
f"{args.save_weight}_{lm_config.hidden_size}{moe_suffix}.pth"
)
state_dict = raw_model.state_dict()
torch.save({key: value.half().cpu() for key, value in state_dict.items()}, output_path)
lm_checkpoint(
lm_config,
weight=args.save_weight,
model=model,
optimizer=optimizer,
epoch=epoch,
step=step,
wandb=wandb,
save_dir=str(resolve_path(args.checkpoint_dir)),
scheduler=scheduler,
scaler=scaler,
)
model.train()
del state_dict
def gkd_train_epoch(
epoch, loader, iters, rollout_engine, teacher_model, lm_config,
start_step=0, wandb=None
):
teacher_model.eval().requires_grad_(False)
model.train()
on_policy_batches = 0
processed_batches = 0
for step, batch in enumerate(loader, start=start_step + 1):
if args.max_train_steps > 0 and processed_batches >= args.max_train_steps:
break
processed_batches += 1
on_policy = choose_on_policy(args.lmbda, args.device)
on_policy_batches += int(on_policy)
rendered = [render_sample(tokenizer, item) for item in batch["conversations"]]
prompt_texts = [item[0] for item in rendered]
full_texts = [item[1] for item in rendered]
if on_policy:
prompt_inputs = tokenize_prompts(
tokenizer, prompt_texts, args.max_seq_len, args.device
)
model.eval()
rollout_result = rollout_engine.rollout(
prompt_ids=prompt_inputs["input_ids"],
attention_mask=prompt_inputs["attention_mask"],
num_generations=args.num_generations,
max_new_tokens=args.max_gen_len,
temperature=args.temperature,
top_p=args.rollout_top_p,
top_k=args.rollout_top_k,
calculate_logps=False,
)
model.train()
input_ids = rollout_result.output_ids.to(args.device)
completion_ids = rollout_result.completion_ids.to(args.device)
completion_mask = build_completion_mask(
completion_ids,
rollout_result.completion_mask.to(args.device),
eos_token_id=tokenizer.eos_token_id,
pad_token_id=tokenizer.pad_token_id,
)
prompt_mask = prompt_inputs["attention_mask"].repeat_interleave(
args.num_generations, dim=0
)
attention_mask = torch.cat(
(prompt_mask, completion_mask.to(prompt_mask.dtype)), dim=1
)
with autocast_ctx:
student_output = model(
input_ids,
attention_mask=attention_mask,
logits_to_keep=completion_ids.size(1) + 1,
)
student_logits = student_output.logits[:, :-1, :]
with torch.no_grad(), autocast_ctx:
teacher_logits = teacher_model(
input_ids,
attention_mask=attention_mask,
logits_to_keep=completion_ids.size(1) + 1,
).logits[:, :-1, :]
avg_completion_length = completion_mask.sum(dim=1).float().mean()
debug_completion = rollout_result.completions[0]
else:
input_ids, attention_mask, labels = build_off_policy_batch(
tokenizer,
prompt_texts,
full_texts,
args.max_seq_len,
args.max_gen_len,
args.device,
)
completion_mask = labels[:, 1:].ne(-100)
with autocast_ctx:
student_output = model(input_ids, attention_mask=attention_mask)
student_logits = student_output.logits[:, :-1, :]
with torch.no_grad(), autocast_ctx:
teacher_logits = teacher_model(
input_ids, attention_mask=attention_mask
).logits[:, :-1, :]
avg_completion_length = completion_mask.sum(dim=1).float().mean()
debug_completion = full_texts[0]
gkd_loss, gkd_metrics = generalized_jsd_loss(
student_logits,
teacher_logits,
completion_mask,
beta=args.beta,
temperature=args.distill_temperature,
)
aux_loss = student_output.aux_loss if lm_config.use_moe else gkd_loss.new_zeros(())
total_loss = gkd_loss + aux_loss
scaler.scale(total_loss / args.accumulation_steps).backward()
should_step = step % args.accumulation_steps == 0 or processed_batches == iters
if should_step:
scaler.unscale_(optimizer)
if args.grad_clip > 0:
torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
scaler.step(optimizer)
scaler.update()
scheduler.step()
optimizer.zero_grad(set_to_none=True)
if args.debug_mode and is_main_process() and step % args.debug_interval == 0:
Logger(f"[OPD DEBUG] on_policy={on_policy}, prompt={prompt_texts[0]!r}")
Logger(f"[OPD DEBUG] completion={debug_completion!r}")
if step % args.log_interval == 0 or processed_batches == iters:
values = {
"loss/gkd": distributed_mean(gkd_loss),
"loss/aux": distributed_mean(aux_loss),
"loss/total": distributed_mean(total_loss),
"distill/forward_kl": distributed_mean(gkd_metrics["forward_kl"]),
"distill/reverse_kl": distributed_mean(gkd_metrics["reverse_kl"]),
"distill/top1_agreement": distributed_mean(gkd_metrics["top1_agreement"]),
"distill/valid_tokens": distributed_mean(gkd_metrics["valid_tokens"]),
"distill/on_policy": float(on_policy),
"distill/on_policy_fraction": on_policy_batches / processed_batches,
"rollout/avg_completion_length": distributed_mean(avg_completion_length),
"train/learning_rate": optimizer.param_groups[0]["lr"],
}
Logger(
f"Epoch:[{epoch + 1}/{args.epochs}]({step}/{start_step + iters}), "
f"GKD Loss: {values['loss/gkd']:.4f}, "
f"Forward KL: {values['distill/forward_kl']:.4f}, "
f"Reverse KL: {values['distill/reverse_kl']:.4f}, "
f"Top1 Agree: {values['distill/top1_agreement']:.4f}, "
f"On Policy: {int(on_policy)}, LR: {values['train/learning_rate']:.8f}"
)
if wandb and is_main_process():
wandb.log(values, step=epoch * iters + step)
if should_step and (step % args.save_interval == 0 or processed_batches == iters):
save_student(lm_config, epoch, step, wandb)
del input_ids, attention_mask, completion_mask
del student_output, student_logits, teacher_logits
del gkd_loss, gkd_metrics, aux_loss, total_loss
if on_policy:
del prompt_inputs, completion_ids, prompt_mask, rollout_result
else:
del labels
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="MiniMind Generalized/On-Policy Knowledge Distillation"
)
parser.add_argument("--data_path", type=str, default="dataset/rlaif.jsonl")
parser.add_argument("--student_model", type=str, default="full_sft")
parser.add_argument("--teacher_model", type=str, default="full_sft")
parser.add_argument("--tokenizer_path", type=str, default="model")
parser.add_argument("--save_dir", type=str, default="out")
parser.add_argument("--checkpoint_dir", type=str, default="checkpoints")
parser.add_argument("--save_weight", type=str, default="opd")
parser.add_argument("--epochs", type=int, default=1)
parser.add_argument("--batch_size", type=int, default=2)
parser.add_argument("--learning_rate", type=float, default=1e-6)
parser.add_argument("--device", type=str, default="auto")
parser.add_argument(
"--dtype", type=str, default="bfloat16",
choices=["float32", "float16", "bfloat16"]
)
parser.add_argument("--num_workers", type=int, default=4)
parser.add_argument("--accumulation_steps", type=int, default=1)
parser.add_argument("--grad_clip", type=float, default=1.0)
parser.add_argument("--log_interval", type=int, default=1)
parser.add_argument("--save_interval", type=int, default=100)
parser.add_argument("--max_seq_len", type=int, default=768)
parser.add_argument("--max_gen_len", type=int, default=256)
parser.add_argument("--max_train_steps", type=int, default=0)
parser.add_argument(
"--lmbda", type=float, default=1.0,
help="fraction of student-generated batches; 1.0 is pure OPD"
)
parser.add_argument(
"--beta", type=float, default=0.5,
help="generalized JSD coefficient; 0=forward KL, 1=reverse KL"
)
parser.add_argument("--temperature", type=float, default=1.0)
parser.add_argument("--distill_temperature", type=float, default=1.0)
parser.add_argument("--rollout_top_p", type=float, default=1.0)
parser.add_argument("--rollout_top_k", type=int, default=0)
parser.add_argument("--num_generations", type=int, default=1)
parser.add_argument("--student_hidden_size", type=int, default=768)
parser.add_argument("--student_num_layers", type=int, default=8)
parser.add_argument("--student_use_moe", type=int, default=0, choices=[0, 1])
parser.add_argument("--teacher_hidden_size", type=int, default=768)
parser.add_argument("--teacher_num_layers", type=int, default=8)
parser.add_argument("--teacher_use_moe", type=int, default=1, choices=[0, 1])
parser.add_argument("--from_resume", type=int, default=0, choices=[0, 1])
parser.add_argument("--seed", type=int, default=42)
parser.add_argument("--use_wandb", action="store_true")
parser.add_argument("--wandb_project", type=str, default="MiniMind-OPD")
parser.add_argument("--wandb_run_name", type=str, default="")
parser.add_argument(
"--wandb_mode", type=str, default="cloud",
choices=["cloud", "local", "disabled"]
)
parser.add_argument("--wandb_logdir", type=str, default="swanlog")
parser.add_argument("--use_compile", type=int, default=0, choices=[0, 1])
parser.add_argument("--debug_mode", action="store_true")
parser.add_argument("--debug_interval", type=int, default=20)
args = parser.parse_args()
validate_args(args)
local_rank = init_distributed_mode()
if dist.is_initialized():
args.device = f"cuda:{local_rank}"
else:
args.device = resolve_device(args.device)
rank = dist.get_rank() if dist.is_initialized() else 0
setup_seed(args.seed + rank)
resolve_path(args.save_dir).mkdir(parents=True, exist_ok=True)
resolve_path(args.checkpoint_dir).mkdir(parents=True, exist_ok=True)
max_position_embeddings = max(32768, args.max_seq_len + args.max_gen_len)
student_config = MiniMindConfig(
hidden_size=args.student_hidden_size,
num_hidden_layers=args.student_num_layers,
use_moe=bool(args.student_use_moe),
max_position_embeddings=max_position_embeddings,
)
teacher_config = MiniMindConfig(
hidden_size=args.teacher_hidden_size,
num_hidden_layers=args.teacher_num_layers,
use_moe=bool(args.teacher_use_moe),
max_position_embeddings=max_position_embeddings,
)
if student_config.vocab_size != teacher_config.vocab_size:
raise ValueError("GKD requires student and teacher to share the same vocabulary")
tokenizer = load_tokenizer(args.tokenizer_path, student_config.vocab_size)
device_type = args.device.split(":", 1)[0]
mixed_dtype = {
"float32": torch.float32,
"float16": torch.float16,
"bfloat16": torch.bfloat16,
}[args.dtype]
if device_type == "mps" and mixed_dtype == torch.bfloat16:
Logger("[OPD WARNING] MPS uses float16 for the frozen teacher")
mixed_dtype = torch.float16
teacher_dtype = mixed_dtype if device_type in {"cuda", "mps"} else torch.float32
autocast_ctx = (
torch.cuda.amp.autocast(dtype=mixed_dtype)
if device_type == "cuda" and mixed_dtype != torch.float32
else nullcontext()
)
model = load_native_model(
student_config, args.student_model, args.device, "student"
)
teacher_model = load_native_model(
teacher_config,
args.teacher_model,
args.device,
"teacher",
model_dtype=teacher_dtype,
).eval().requires_grad_(False)
Logger(f"Student params: {sum(p.numel() for p in model.parameters()) / 1e6:.3f}M")
Logger(f"Teacher params: {sum(p.numel() for p in teacher_model.parameters()) / 1e6:.3f}M")
checkpoint = (
lm_checkpoint(
student_config,
weight=args.save_weight,
save_dir=str(resolve_path(args.checkpoint_dir)),
)
if args.from_resume == 1 else None
)
train_ds = OPDDataset(resolve_path(args.data_path))
train_sampler = (
DistributedSampler(train_ds, shuffle=True, seed=args.seed)
if dist.is_initialized() else None
)
count_loader = DataLoader(
train_ds,
batch_size=args.batch_size,
sampler=train_sampler,
collate_fn=collate_conversations,
)
full_iters = len(count_loader)
iters = min(full_iters, args.max_train_steps) if args.max_train_steps > 0 else full_iters
optimizer_steps_per_epoch = math.ceil(iters / args.accumulation_steps)
total_optimizer_steps = max(1, optimizer_steps_per_epoch * args.epochs)
optimizer = optim.AdamW(model.parameters(), lr=args.learning_rate)
scheduler = CosineAnnealingLR(
optimizer, T_max=total_optimizer_steps, eta_min=args.learning_rate / 10
)
scaler = torch.cuda.amp.GradScaler(
enabled=(device_type == "cuda" and mixed_dtype == torch.float16)
)
start_epoch, start_step = 0, 0
if checkpoint:
model.load_state_dict(checkpoint["model"], strict=True)
optimizer.load_state_dict(checkpoint["optimizer"])
if "scheduler" in checkpoint:
scheduler.load_state_dict(checkpoint["scheduler"])
if "scaler" in checkpoint:
scaler.load_state_dict(checkpoint["scaler"])
start_epoch = checkpoint["epoch"]
start_step = checkpoint.get("step", 0)
Logger(f"Resuming GKD from epoch={start_epoch}, step={start_step}")
if args.use_compile == 1:
model = torch.compile(model)
Logger("torch.compile enabled")
if dist.is_initialized():
model = DistributedDataParallel(model, device_ids=[local_rank])
rollout_engine = create_rollout_engine(
engine_type="torch",
policy_model=model,
tokenizer=tokenizer,
device=args.device,
autocast_ctx=autocast_ctx,
)
wandb = None
if args.use_wandb and is_main_process():
import swanlab as wandb
wandb_id = checkpoint.get("wandb_id") if checkpoint else None
wandb.init(
project=args.wandb_project,
name=args.wandb_run_name or (
f"GKD-L{args.lmbda}-B{args.beta}-BS{args.batch_size}x"
f"{args.accumulation_steps}-LR{args.learning_rate}"
),
config=vars(args),
mode=args.wandb_mode,
logdir=str(resolve_path(args.wandb_logdir)),
id=wandb_id if args.wandb_mode == "cloud" else None,
resume="must" if wandb_id and args.wandb_mode == "cloud" else None,
)
optimizer.zero_grad(set_to_none=True)
for epoch in range(start_epoch, args.epochs):
if train_sampler:
train_sampler.set_epoch(epoch)
setup_seed(args.seed + epoch + rank)
indices = torch.randperm(len(train_ds)).tolist()
skip = start_step if epoch == start_epoch and start_step > 0 else 0
batch_sampler = SkipBatchSampler(train_sampler or indices, args.batch_size, skip)
loader = DataLoader(
train_ds,
batch_sampler=batch_sampler,
num_workers=args.num_workers,
pin_memory=(device_type == "cuda"),
collate_fn=collate_conversations,
)
if skip > 0:
Logger(f"Epoch [{epoch + 1}/{args.epochs}]: skipping {skip} completed batches")
gkd_train_epoch(
epoch,
loader,
iters,
rollout_engine,
teacher_model,
student_config,
start_step=skip,
wandb=wandb,
)
start_step = 0
if wandb and is_main_process() and hasattr(wandb, "finish"):
wandb.finish()
if dist.is_initialized():
dist.barrier()
dist.destroy_process_group()