# Copyright (c) Meta Platforms, Inc. and affiliates.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
from __future__ import annotations
import contextlib
from dataclasses import dataclass
from typing import Literal, TYPE_CHECKING
import torch
from tensordict import NestedKey, TensorClass, TensorDictBase
from tensordict.nn import TensorDictModule
from tensordict.utils import _zip_strict
from torchrl.data import History
from torchrl.modules.llm.policies.transformers_wrapper import TransformersWrapper
from torchrl.objectives.common import LossModule
from torchrl.objectives.llm._utils import _runtime_device
if TYPE_CHECKING:
import transformers
[docs]
def k3_kl_token_estimate(
target_log_prob: torch.Tensor, log_prob: torch.Tensor
) -> torch.Tensor:
"""Per-token k3 estimate of the KL divergence to a target distribution.
Uses the k3 estimator ``exp(d) - 1 - d`` with
``d = target_log_prob - log_prob``, the same approximation used by
:class:`~torchrl.objectives.llm.GRPOLoss` and
:class:`~torchrl.objectives.llm.SFTLoss` for their KL regularizers, but
returned per token instead of reduced, so callers can apply their own
masking and reduction.
The estimator is direction-neutral: it estimates
``KL(sampling distribution || target distribution)``, and the direction
of the resulting KL is determined by which distribution's
log-probabilities are passed as which argument. The estimate is unbiased
only when the scored tokens are actual samples from the distribution
behind ``log_prob``; on tokens drawn from any other source, the (masked)
sum or mean of the result is still a nonnegative divergence between the
two distributions, but it no longer estimates either KL divergence.
Args:
target_log_prob (torch.Tensor): per-token log-probabilities of the
target (non-sampling) distribution, evaluated on the same tokens
as ``log_prob``.
log_prob (torch.Tensor): per-token log-probabilities of the
distribution the tokens were sampled from.
Returns:
A tensor of nonnegative per-token KL contributions with the same
shape as the inputs.
References:
- John Schulman, 2020. `"Approximating KL Divergence" <http://joschu.net/blog/kl-approx.html>`_
Examples:
>>> import torch
>>> from torchrl.objectives.llm import k3_kl_token_estimate
>>> log_prob = torch.full((4,), -1.0)
>>> k3_kl_token_estimate(log_prob, log_prob)
tensor([0., 0., 0., 0.])
"""
if target_log_prob.shape != log_prob.shape:
raise ValueError(
f"target_log_prob and log_prob must have the same shape, got "
f"{target_log_prob.shape=} and {log_prob.shape=}."
)
diff = target_log_prob - log_prob
return diff.expm1() - diff
def _distillation_loss(summed_kl: torch.Tensor, reduction: str) -> torch.Tensor:
"""Reduce per-sequence KL estimates into the distillation loss."""
if reduction == "mean":
return summed_kl.mean()
if reduction == "sum":
return summed_kl.sum()
if reduction == "none":
return summed_kl
raise ValueError(f"Invalid reduction: {reduction}.")
[docs]
class DistillationLossOutput(TensorClass["nocast"]):
"""Distillation Loss Output.
Attributes:
loss_distill (torch.Tensor): The differentiable distillation loss.
kl_to_teacher (torch.Tensor): The detached mean per-sequence KL
estimate, for logging.
.. note::
``kl_to_teacher`` is not differentiable; summing the output class
directly (``loss_output.sum(reduce=True)``) is not the intended way
to obtain the total loss here. Backpropagate through
``loss_distill`` instead.
"""
loss_distill: torch.Tensor
kl_to_teacher: torch.Tensor
[docs]
class DistillationLoss(LossModule):
r"""Token-level knowledge-distillation loss for LLM policies.
Distills a student policy toward a (typically stronger, frozen) teacher by
minimizing a KL divergence estimated from the per-token log-probabilities
the two models assign to the same tokens. The student log-probabilities
are computed by running ``actor_network`` on the input history; the
teacher log-probabilities are read from the input tensordict, where a
:class:`~torchrl.envs.llm.transforms.RetrieveLogProb` transform wrapping
the teacher (or an offline scoring pass) has written them.
Both distillation directions are supported through ``kl_direction``:
- ``"reverse"`` (default): minimizes ``KL(student || teacher)``. This is
the on-policy setting: the scored tokens must be sampled from the
student for the estimate to be that KL.
- ``"forward"``: minimizes ``KL(teacher || student)``. Here the scored
tokens must be sampled from the teacher (teacher-generated completions)
for the estimate to be that KL.
In both cases the KL is estimated per token with the k3 estimator
(:func:`~torchrl.objectives.llm.k3_kl_token_estimate`), the same
approximation used by the KL regularizers of
:class:`~torchrl.objectives.llm.GRPOLoss` and
:class:`~torchrl.objectives.llm.SFTLoss`. As in those losses, gradients
flow through the student log-probabilities only; the score-function term
of the sampling distribution is ignored.
.. note::
The k3 estimator is an unbiased KL estimate only when the scored
tokens are samples from the corresponding sampling distribution: the
student for ``"reverse"``, the teacher for ``"forward"``. On data
that comes from neither model (e.g. a plain text corpus), the
objective is still a valid nonnegative divergence pulling the student
toward the teacher, but its value is not the advertised KL and it can
have very high variance wherever the student assigns much more
probability than the teacher. For off-policy distillation, prefer
teacher-generated completions with ``kl_direction="forward"``.
.. note::
Because the score-function term of the sampling distribution is
dropped, the expected gradient of both directions matches the
gradient of the forward (mass-covering) KL. The usual mode-seeking
(reverse) vs mass-covering (forward) distinction therefore applies to
the logged value (``kl_to_teacher``), not to the direction the
optimization actually descends.
Args:
actor_network (TensorDictModule): the student network to train.
Usually a :class:`~torchrl.modules.llm.TransformersWrapper`
instance with ``generate=False``.
tokenizer (`Tokenizer`, optional): the tokenizer used to re-tokenize
the history when token masks are not present in the input.
Defaults to the tokenizer of ``actor_network``, when available.
tokenizer_kwargs (dict, optional): keyword arguments passed to
:meth:`~torchrl.data.llm.History.apply_chat_template` when
re-tokenizing.
kl_direction (Literal["reverse", "forward"], optional): the KL
direction to minimize (see above). Defaults to ``"reverse"``.
reduction (Literal["mean", "sum", "none"], optional): the reduction to
apply across sequences. Defaults to ``"mean"``.
normalize_by_seq_length (bool, optional): whether to normalize each
sequence's KL by its number of scored tokens. Defaults to ``True``.
assistant_only (bool, optional): if ``True`` (default), only assistant
(response) tokens contribute to the loss. If ``False``, all
attended tokens contribute. This flag must be paired consistently
with the ``assistant_only`` flag of the
:class:`~torchrl.envs.llm.transforms.RetrieveLogProb` transform
that wrote the teacher log-probabilities:
``RetrieveLogProb(assistant_only=True)`` zero-fills the teacher
log-probabilities of non-assistant tokens and can only be paired
with ``DistillationLoss(assistant_only=True)``; use
``RetrieveLogProb(assistant_only=False)`` when distilling on all
attended tokens.
device (torch.device | None, optional): fallback device used when neither
the input nor the student parameters and buffers provide one. This
does not move the student network; move the complete loss with
:meth:`~torch.nn.Module.to`. Defaults to ``None``.
.. note::
The input tensordict is expected to contain the following keys by
default:
- ``("history", "full")``: the chat history;
- ``("next", "teacher_log_probs", "full")``: the teacher per-token
log-probabilities on the same tokens, as written by a
:class:`~torchrl.envs.llm.transforms.RetrieveLogProb` constructed
with ``log_probs_full_key=("teacher_log_probs", "full")``.
These keys can be customized using the :meth:`set_keys` method.
The token masks are read from the fixed keys
``("masks", "all_assistant_mask")`` and
``("masks", "all_attention_mask")`` when present; otherwise the
history is re-tokenized with the tokenizer to recover them.
.. seealso:: :class:`~torchrl.envs.llm.transforms.RetrieveLogProb` to
compute the teacher log-probabilities, and
:class:`~torchrl.objectives.llm.SFTLoss` for supervised fine-tuning
with an optional KL regularizer.
References:
- Rishabh Agarwal, Nino Vieillard, Yongchao Zhou, Piotr Stanczyk,
Sabela Ramos, Matthieu Geist, Olivier Bachem, 2024.
`"On-Policy Distillation of Language Models: Learning from Self-Generated Mistakes" <https://arxiv.org/abs/2306.13649>`_
Examples:
>>> from torchrl.data.llm.history import History, _CHAT_TEMPLATES
>>> from torchrl.envs.llm.transforms import RetrieveLogProb
>>> from torchrl.modules.llm import TransformersWrapper
>>> from torchrl.modules.llm.policies.common import ChatHistory
>>> from torchrl.objectives.llm import DistillationLoss
>>> from transformers import AutoTokenizer, OPTConfig, OPTForCausalLM
>>> from tensordict import TensorDict, lazy_stack
>>> import torch
>>>
>>> chats = [
... [
... {"role": "system", "content": "You are a helpful assistant."},
... {"role": "user", "content": "Hello, how are you?"},
... {"role": "assistant", "content": "I'm doing well, thank you!"},
... ],
... [
... {"role": "system", "content": "You are a helpful assistant."},
... {"role": "user", "content": "What's the weather like?"},
... {"role": "assistant", "content": "I can't check the weather for you."},
... ],
... ]
>>> history = History.from_chats(chats)
>>> tokenizer = AutoTokenizer.from_pretrained("facebook/opt-125m")
>>> tokenizer.pad_token = tokenizer.eos_token
>>> tokenizer.chat_template = _CHAT_TEMPLATES["chatml_format"]
>>> student_model = OPTForCausalLM(OPTConfig())
>>> teacher_model = OPTForCausalLM(OPTConfig()).eval()
>>> student = TransformersWrapper(
... student_model,
... tokenizer=tokenizer,
... generate=False,
... chat_template_name="qwen",
... input_mode="history",
... pad_output=False,
... )
>>> teacher = TransformersWrapper(
... teacher_model,
... tokenizer=tokenizer,
... generate=False,
... return_log_probs=True,
... chat_template_name="qwen",
... input_mode="history",
... pad_output=False,
... )
>>> transform = RetrieveLogProb(
... teacher,
... log_probs_full_key=("teacher_log_probs", "full"),
... assistant_only=True,
... tokenizer_kwargs={"chat_template_name": "qwen"},
... tokenizer=tokenizer,
... )
>>> td = TensorDict(
... history=ChatHistory(
... full=history, prompt=history[..., :-1], response=history[..., -1:]
... ),
... next=TensorDict(
... done=torch.zeros(2, dtype=torch.bool),
... history=ChatHistory(prompt=history),
... ),
... batch_size=(2,),
... )
>>> data = lazy_stack(list(td.unbind(0)))
>>> with torch.no_grad():
... data = transform(data)
>>> loss = DistillationLoss(
... actor_network=student,
... tokenizer=tokenizer,
... kl_direction="reverse",
... tokenizer_kwargs={"chat_template_name": "qwen"},
... )
>>> loss_vals = loss(data)
>>> loss_vals.loss_distill.backward()
"""
@dataclass
class _AcceptedKeys:
"""Maintains default values for all configurable tensordict keys.
This class defines which tensordict keys can be set using
'.set_keys(key_name=key_value)' and their default values.
Attributes:
history (NestedKey): The input tensordict key where the chat
history is expected. Defaults to ``("history", "full")``.
teacher_log_prob (NestedKey): The input tensordict key where the
teacher per-token log-probabilities are expected.
Defaults to ``("next", "teacher_log_probs", "full")``.
log_probs (NestedKey): The key where the student model writes its
per-token log-probabilities. Defaults to ``("log_probs", "full")``.
"""
history: NestedKey = ("history", "full")
teacher_log_prob: NestedKey = ("next", "teacher_log_probs", "full")
log_probs: NestedKey = ("log_probs", "full")
default_keys = _AcceptedKeys
tensor_keys: _AcceptedKeys
def __init__(
self,
actor_network: TensorDictModule | TransformersWrapper,
tokenizer: transformers.AutoTokenizer | None = None, # noqa: F821
tokenizer_kwargs: dict | None = None,
kl_direction: Literal["reverse", "forward"] = "reverse",
reduction: Literal["mean", "sum", "none"] = "mean",
normalize_by_seq_length: bool = True,
assistant_only: bool = True,
device: torch.device | None = None,
):
super().__init__()
self.in_keys = []
self.actor_network = actor_network
if tokenizer is None:
tokenizer = getattr(actor_network, "tokenizer", None)
self.tokenizer = tokenizer
if tokenizer_kwargs is None:
tokenizer_kwargs = {}
tokenizer_kwargs.setdefault("return_assistant_tokens_mask", True)
tokenizer_kwargs.setdefault("tokenize", True)
tokenizer_kwargs.setdefault("return_tensors", "pt")
tokenizer_kwargs.setdefault("padding", False)
tokenizer_kwargs.setdefault("add_generation_prompt", False)
self.tokenizer_kwargs = tokenizer_kwargs
if kl_direction not in ("reverse", "forward"):
raise ValueError(
f"kl_direction must be 'reverse' or 'forward', got {kl_direction!r}."
)
self.kl_direction = kl_direction
if reduction not in ("mean", "sum", "none"):
raise ValueError(
f"reduction must be 'mean', 'sum' or 'none', got {reduction!r}."
)
self.reduction = reduction
self.normalize_by_seq_length = normalize_by_seq_length
self.assistant_only = assistant_only
self._set_in_keys()
self.register_buffer(
"_device_fallback", torch.empty(0, device=device), persistent=False
)
def _set_in_keys(self) -> None:
"""Sets the input keys for the loss module."""
self.in_keys = [self.tensor_keys.history, self.tensor_keys.teacher_log_prob]
self.out_keys = []
def _get_masks(
self, tensordict: TensorDictBase, history: History
) -> tuple[list[torch.Tensor], list[torch.Tensor], list[torch.Tensor] | None]:
"""Returns the loss masks, the attention masks and, when recoverable, the assistant masks."""
assistant_masks = tensordict.get(("masks", "all_assistant_mask"), as_list=True)
attention_mask = tensordict.get(("masks", "all_attention_mask"), as_list=True)
device = _runtime_device(
self.actor_network,
tensordict,
assistant_masks,
attention_mask,
fallback=self._device_fallback,
)
if (self.assistant_only and assistant_masks is None) or attention_mask is None:
if self.tokenizer is None:
raise ValueError(
"A tokenizer is required to recover the token masks because the "
"input tensordict has no ('masks', ...) entries. Pass a tokenizer "
"to the loss constructor or provide the masks in the input."
)
with torch.device(
device
) if device is not None else contextlib.nullcontext():
token_struct = history.apply_chat_template(
tokenizer=self.tokenizer, **self.tokenizer_kwargs
)
if assistant_masks is None and "assistant_masks" in token_struct:
assistant_masks = token_struct.get("assistant_masks", as_list=True)
if self.assistant_only and assistant_masks is None:
raise ValueError(
f"Assistant masks are not present in the token structure: {token_struct=}."
)
if attention_mask is None:
attention_mask = token_struct.get("attention_mask", as_list=True)
attention_mask = [mask.bool() for mask in attention_mask]
if assistant_masks is not None:
assistant_masks = [mask.bool() for mask in assistant_masks]
if not self.assistant_only:
return attention_mask, attention_mask, assistant_masks
return (
[
mask & a_mask
for mask, a_mask in _zip_strict(assistant_masks, attention_mask)
],
attention_mask,
assistant_masks,
)
@staticmethod
def _check_teacher_log_probs_consistency(
teacher_log_probs: list[torch.Tensor],
attention_masks: list[torch.Tensor],
assistant_masks: list[torch.Tensor],
) -> None:
"""Detects zero-filled teacher log-probs on non-assistant tokens.
``RetrieveLogProb(assistant_only=True)`` fills the log-probs of
non-assistant tokens with exact zeros. Feeding those into the loss
with ``assistant_only=False`` would distill the student toward a
distribution that assigns probability one to every non-assistant
token, and the k3 estimator can explode there.
"""
zeros = 0
total = 0
for tlp, att, ast in _zip_strict(
teacher_log_probs, attention_masks, assistant_masks
):
if tlp.shape != att.shape or tlp.shape != ast.shape:
return
non_assistant = att & ~ast
total += non_assistant.sum().item()
zeros += ((tlp == 0.0) & non_assistant).sum().item()
if total > 0 and zeros > 0.9 * total:
raise RuntimeError(
f"{zeros} out of {total} attended non-assistant tokens have a teacher "
"log-probability of exactly 0.0. This is the signature of teacher "
"log-probs written by RetrieveLogProb(assistant_only=True), which "
"zero-fills non-assistant positions, while this loss was constructed "
"with assistant_only=False and therefore includes those positions. "
"Either construct the loss with assistant_only=True, or recompute the "
"teacher log-probs with RetrieveLogProb(assistant_only=False)."
)
[docs]
def forward(self, tensordict: TensorDictBase) -> DistillationLossOutput:
history: History = tensordict[self.tensor_keys.history]
masks, attention_masks, assistant_masks = self._get_masks(tensordict, history)
token_dim = tensordict.ndim - 1
if any((~mask.any(dim=token_dim)).any() for mask in masks):
raise ValueError(
"Some sequences have no tokens selected for distillation. "
"Check the assistant/attention masks of the input."
)
input_loss = tensordict.select(self.tensor_keys.history)
device = _runtime_device(
self.actor_network,
tensordict,
masks,
attention_masks,
fallback=self._device_fallback,
)
with torch.device(device) if device is not None else contextlib.nullcontext():
output_loss = self.actor_network(input_loss)
log_probs = output_loss.get(self.tensor_keys.log_probs, as_list=True)
teacher_log_probs = tensordict.get(
self.tensor_keys.teacher_log_prob, default=None, as_list=True
)
if teacher_log_probs is None:
raise KeyError(
f"Teacher log-probs not found at {self.tensor_keys.teacher_log_prob} in the "
f"input tensordict (keys: {set(tensordict.keys(include_nested=True, leaves_only=True))}). "
"Use RetrieveLogProb(teacher, log_probs_full_key=('teacher_log_probs', 'full')) "
"to compute them, or point the teacher_log_prob key to your data with set_keys()."
)
if not all(
lp.shape == tlp.shape
for lp, tlp in _zip_strict(log_probs, teacher_log_probs)
):
raise ValueError(
f"Student and teacher log-probs have different shapes: "
f"{[lp.shape for lp in log_probs]} vs {[tlp.shape for tlp in teacher_log_probs]}. "
"Both models must score the same tokenized sequences."
)
if not all(
mask.shape == lp.shape for mask, lp in _zip_strict(masks, log_probs)
):
raise ValueError(
f"Masks and log-probs have different shapes: "
f"{[mask.shape for mask in masks]} vs {[lp.shape for lp in log_probs]}."
)
if not self.assistant_only and assistant_masks is not None:
self._check_teacher_log_probs_consistency(
teacher_log_probs, attention_masks, assistant_masks
)
kl_tokens = []
for lp, tlp, mask in _zip_strict(log_probs, teacher_log_probs, masks):
lp = lp.masked_fill(~mask, 0.0)
tlp = tlp.detach().masked_fill(~mask, 0.0)
if self.kl_direction == "reverse":
kl = k3_kl_token_estimate(tlp, lp)
else:
kl = k3_kl_token_estimate(lp, tlp)
kl_tokens.append(kl)
summed_kl = torch.stack([kl.sum(token_dim) for kl in kl_tokens])
if self.normalize_by_seq_length:
seq_lengths = torch.stack([mask.sum(token_dim) for mask in masks])
summed_kl = summed_kl / seq_lengths.clamp(min=1)
loss = _distillation_loss(summed_kl, self.reduction)
return DistillationLossOutput(
loss_distill=loss, kl_to_teacher=summed_kl.detach().mean()
)