149 lines
5.6 KiB
Python
149 lines
5.6 KiB
Python
"""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
|