# 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 abc
import contextlib
import itertools
import json
import math
import pathlib
import signal
import sys
import time
import warnings
import weakref
from collections import defaultdict, OrderedDict
from collections.abc import Callable, Collection, Mapping, Sequence
from copy import deepcopy
from textwrap import indent
from typing import Any, Literal
import numpy as np
import torch
from packaging import version
from tensordict import NestedKey, NonTensorData, pad, TensorDict, TensorDictBase
from tensordict._tensorcollection import TensorCollection
from tensordict.nn import TensorDictModule
from tensordict.utils import expand_right
from torch import nn, optim
from torchrl._utils import (
_CKPT_BACKEND,
implement_for,
KeyDependentDefaultDict,
logger as torchrl_logger,
rl_warnings,
timeit,
VERBOSE,
)
from torchrl.checkpoint import (
Checkpoint,
CheckpointRotation,
GlobalRNGState,
resolve_checkpoint_path,
StopOnSignal,
)
from torchrl.collectors import BaseCollector, Evaluator
from torchrl.collectors.utils import split_trajectories
from torchrl.data.replay_buffers import (
PrioritizedSampler,
TensorDictPrioritizedReplayBuffer,
TensorDictReplayBuffer,
)
from torchrl.data.utils import DEVICE_TYPING
from torchrl.envs.common import EnvBase
from torchrl.envs.utils import ExplorationType, set_exploration_type
from torchrl.objectives.common import LossModule
from torchrl.objectives.utils import TargetNetUpdater
from torchrl.record.loggers import Logger
_TORCH_GRAD_SCALER_HAS_DEVICE = version.parse(torch.__version__).release >= (2, 3)
if _TORCH_GRAD_SCALER_HAS_DEVICE:
from torch.amp import GradScaler
else:
from torch.cuda.amp import GradScaler
try:
from tqdm import tqdm
_has_tqdm = True
except ImportError:
_has_tqdm = False
try:
from torchsnapshot import Snapshot, StateDict
_has_ts = True
except ImportError:
_has_ts = False
REPLAY_BUFFER_CLASS = {
"prioritized": TensorDictPrioritizedReplayBuffer,
"circular": TensorDictReplayBuffer,
}
# Mapping of metric names to logger methods - controls how different metrics are logged
LOGGER_METHODS = {
"grad_norm": "log_scalar",
"loss": "log_scalar",
}
# Format strings for different data types in progress bar display
TYPE_DESCR = {float: "4.4f", int: ""}
REWARD_KEY = ("next", "reward")
_OPTIM_STEPS_UNSET = object()
@implement_for("torch", "2.3")
def _make_grad_scaler(device_type: str, enabled: bool) -> GradScaler:
return GradScaler(device_type, enabled=enabled)
@implement_for("torch", None, "2.3")
def _make_grad_scaler(device_type: str, enabled: bool) -> GradScaler: # noqa: F811
return GradScaler(enabled=enabled)
# On Windows, a memory-mapped checkpoint keeps the file locked for as long as
# the loaded tensors are alive, so the checkpoint could neither be deleted nor
# safely re-saved. Only default to ``mmap=True`` on other platforms.
_MMAP_CKPT_DEFAULT = sys.platform != "win32"
@implement_for("torch", "2.4")
def _torch_load_defaults() -> dict[str, Any]:
return {"weights_only": True, "mmap": _MMAP_CKPT_DEFAULT}
@implement_for("torch", None, "2.4")
def _torch_load_defaults() -> dict[str, Any]: # noqa: F811
# The weights-only unpickler of torch < 2.4 does not allow
# ``torch.device``, which TensorDict state-dicts carry as their
# ``__device`` metadata entry.
return {"weights_only": False, "mmap": _MMAP_CKPT_DEFAULT}
class _TrainerCheckpointState:
"""State-dict view over Trainer progress counters."""
def __init__(self, trainer: Trainer) -> None:
self.trainer = trainer
def state_dict(self) -> dict[str, Any]:
return dict(self.trainer._get_state())
def load_state_dict(self, state_dict: Mapping[str, Any]) -> None:
self.trainer.collected_frames = state_dict["collected_frames"]
self.trainer._last_log = state_dict["_last_log"]
self.trainer._last_save = state_dict["_last_save"]
self.trainer._optim_count = state_dict["_optim_count"]
class _ExecutionCheckpointState:
"""Semantic state view over a private learner execution backend."""
def __init__(self, trainer: Trainer) -> None:
self.trainer = trainer
def state_dict(self) -> dict[str, Any]:
backend = self.trainer._execution_backend
if backend is None or not backend.is_alive():
raise RuntimeError(
"Remote learner checkpointing requires a running learner backend."
)
return {
"backend": backend.state_dict(),
"controller": self.trainer._execution_controller_state(),
}
def load_state_dict(self, state_dict: Mapping[str, Any]) -> None:
backend = self.trainer._execution_backend
if backend is None:
raise RuntimeError("Remote learner execution backend is missing.")
if not backend.is_alive():
backend.start()
backend.load_state_dict(state_dict["backend"])
self.trainer._load_execution_controller_state(state_dict.get("controller", {}))
self.trainer._published_model_version = -1
def _state_dict_to_td(sd: dict) -> TensorDict:
"""Convert a state dict to a :class:`~tensordict.TensorDict`.
Tensor values are stored directly; everything else is wrapped in
:class:`~tensordict.NonTensorData` so that :meth:`~tensordict.TensorDict.dumps`
can persist the whole state without pickle dependencies.
"""
return TensorDict(
{
k: v if isinstance(v, torch.Tensor) else NonTensorData(v)
for k, v in sd.items()
},
[],
)
def _td_to_state_dict(td: TensorDict) -> dict:
"""Inverse of :func:`_state_dict_to_td`.
Unwraps :class:`~tensordict.NonTensorData` back to plain Python values and
leaves tensors (including :class:`~tensordict.MemoryMappedTensor`) as-is.
"""
return {k: v.data if isinstance(v, NonTensorData) else v for k, v in td.items()}
[docs]
class TrainerHookBase:
"""An abstract hooking class for torchrl Trainer class."""
@abc.abstractmethod
def state_dict(self) -> dict[str, Any]:
raise NotImplementedError
@abc.abstractmethod
def load_state_dict(self, state_dict: dict[str, Any]) -> None:
raise NotImplementedError
[docs]
@abc.abstractmethod
def register(self, trainer: Trainer, name: str):
"""Registers the hook in the trainer at a default location.
Args:
trainer (Trainer): the trainer where the hook must be registered.
name (str): the name of the hook.
.. note::
To register the hook at another location than the default, use
:meth:`~torchrl.trainers.Trainer.register_op`.
"""
raise NotImplementedError
class OptimizationStepper(TrainerHookBase):
"""Performs a single optimization step in a Trainer.
The optimization stepper encapsulates the logic executed for each ``sub_batch``
during training. This is useful for algorithms that require multiple optimizers,
delayed updates (e.g. TD3, where critics are updated every step while the actor
and target networks are updated less frequently), or multiple backward passes
within one training iteration.
The :class:`~torchrl.trainers.Trainer` calls :meth:`step` inside its optimization
loop and handles post-optimization hooks (e.g. target network updates, priority
updates, schedulers) and logging around this call.
Subclasses should return a :class:`~tensordict.TensorDictBase` of detached scalar
values suitable for logging.
"""
_trainer: Trainer
def step(self, trainer: Trainer, sub_batch: TensorDictBase) -> TensorDictBase:
"""Perform one optimization step on a ``sub_batch``.
Args:
trainer (Trainer): The trainer executing the optimization loop.
sub_batch (TensorDictBase): Batch used for this optimization step.
Returns:
A TensorDict containing detached scalar metrics for logging.
"""
raise NotImplementedError
def _step(self, context: Any, sub_batch: TensorDictBase) -> TensorDictBase:
"""Run this stepper against a private optimization context."""
return self.step(context, sub_batch)
def state_dict(self) -> dict[str, Any]:
return {}
def load_state_dict(self, state_dict: dict[str, Any]) -> None:
return
def register(self, trainer: Trainer, name: str = "optimization_stepper") -> None:
"""Register the stepper with a Trainer for checkpointing."""
# Register as a module so it is included in Trainer checkpoints.
# This is not a hook stage (i.e., it is not registered via ``register_op``).
trainer.register_module(name, self)
self._trainer = trainer
class DefaultOptimizationStepper(OptimizationStepper):
"""Default optimization step implementation.
This stepper computes losses via ``trainer.loss_module(sub_batch)`` and applies a
single optimizer update with ``trainer.optimizer`` (including gradient clipping
when configured).
Optionally, a subset of loss entries can be selected via ``loss_components``.
In that case, only the selected keys contribute to the backward pass.
"""
def __init__(self, loss_components: Sequence[str] | None = None) -> None:
if loss_components is not None and not loss_components:
raise ValueError(
"loss_components list cannot be empty. "
"Set to None to act on all components of the loss."
)
self.loss_components = (
set(loss_components) if loss_components is not None else None
)
@staticmethod
def _compute_and_clip_grad_norm(
optimizer: optim.Optimizer,
clip_grad_norm: bool,
clip_norm: float | None,
) -> float:
params = []
for param_group in optimizer.param_groups:
params += param_group["params"]
if clip_grad_norm and clip_norm is not None:
gn = nn.utils.clip_grad_norm_(params, clip_norm)
else:
gn = sum([p.grad.pow(2).sum() for p in params if p.grad is not None]).sqrt()
if clip_norm is not None:
nn.utils.clip_grad_value_(params, clip_norm)
return float(gn)
def step(self, trainer: Trainer, sub_batch: TensorDictBase) -> TensorDictBase:
losses_td = trainer.compute_loss(sub_batch)
if trainer.optimizer is None:
raise RuntimeError(
"DefaultOptimizationStepper requires an optimizer. "
"Pass `optimizer=` to Trainer or use a custom "
"OptimizationStepper that owns its optimizer(s)."
)
if self.loss_components is not None:
items = [
item for key, item in losses_td.items() if key in self.loss_components
]
else:
items = [item for key, item in losses_td.items() if key.startswith("loss")]
loss = sum(items)
loss.backward()
gn = self._compute_and_clip_grad_norm(
trainer.optimizer,
trainer.clip_grad_norm,
trainer.clip_norm,
)
losses_td["grad_norm"] = torch.tensor(gn)
trainer.optimizer.step()
trainer.optimizer.zero_grad()
return losses_td
[docs]
class MixedPrecisionOptimizationStepper(OptimizationStepper):
"""Optimization step with mixed precision and gradient accumulation.
This stepper wraps each forward/backward pass in ``torch.amp.autocast``
and optionally scales gradients with ``torch.amp.GradScaler`` (for fp16).
It also implements *gradient accumulation*: gradients are accumulated for
``gradient_accumulation_steps`` micro-batches before the optimizer is
stepped and zeroed.
It can be used with any :class:`~torchrl.trainers.Trainer`; LLM trainers
such as :class:`~torchrl.trainers.algorithms.GRPOTrainer` construct it by
default.
Args:
optimizer (optim.Optimizer): The optimizer to use.
Keyword Args:
mixed_precision (bool, optional): Whether to enable mixed-precision
training. Default: ``False``.
autocast_dtype (torch.dtype, optional): The dtype to use inside
``autocast``. Default: ``torch.bfloat16``.
gradient_accumulation_steps (int, optional): Number of micro-batches
over which gradients are accumulated before a step. Default: ``1``.
clip_norm (float, optional): Maximum gradient norm for clipping.
Default: ``1.0``.
device_type (str, optional): Device type passed to ``autocast`` and
``GradScaler`` (e.g. ``"cuda"`` or ``"cpu"``). Defaults to the
device type of the optimizer's first parameter.
.. note::
``GradScaler`` is only enabled when ``mixed_precision=True`` *and*
``autocast_dtype=torch.float16``. With bfloat16 (the recommended
dtype for modern GPUs) the scaler is a no-op and is not created.
"""
def __init__(
self,
optimizer: optim.Optimizer,
*,
mixed_precision: bool = False,
autocast_dtype: torch.dtype = torch.bfloat16,
gradient_accumulation_steps: int = 1,
clip_norm: float | None = 1.0,
device_type: str | None = None,
) -> None:
if gradient_accumulation_steps < 1:
raise ValueError("gradient_accumulation_steps must be >= 1")
self.optimizer = optimizer
self.mixed_precision = mixed_precision
self.autocast_dtype = autocast_dtype
self.gradient_accumulation_steps = gradient_accumulation_steps
self.clip_norm = clip_norm
if device_type is None:
params = [p for group in optimizer.param_groups for p in group["params"]]
device_type = params[0].device.type if params else "cpu"
self.device_type = device_type
# GradScaler is only useful for fp16; bf16 doesn't need it.
self._use_scaler = mixed_precision and (autocast_dtype == torch.float16)
self.scaler = _make_grad_scaler(self.device_type, self._use_scaler)
# Internal micro-batch counter (reset after every optimizer step).
self._micro_step: int = 0
self._optimizer_step_count: int = 0
self._skipped_nonfinite_steps: int = 0
@property
def optimizer_step_count(self) -> int:
"""Number of completed optimizer steps.
Discounts gradient-accumulation micro-steps and steps skipped by the
GradScaler on overflow or by the non-finite guards. Read by hooks that
act on an optimizer-step cadence (e.g.
:class:`~torchrl.trainers.UpdateWeights` with
``interval_unit="optim_steps"``).
"""
return self._optimizer_step_count
# ------------------------------------------------------------------
# Checkpointing (optimizer + scaler state)
# ------------------------------------------------------------------
def state_dict(self) -> dict[str, Any]:
if self._micro_step % self.gradient_accumulation_steps != 0:
raise RuntimeError(
f"Cannot save stepper state mid-accumulation. (micro_step={self._micro_step}, "
f"accumulation_steps={self.gradient_accumulation_steps}). "
"Adjust your save_interval to align with the gradient accumulation window."
)
sd: dict[str, Any] = {
"optimizer": self.optimizer.state_dict(),
"micro_step": self._micro_step,
"optimizer_step_count": self._optimizer_step_count,
"skipped_nonfinite_steps": self._skipped_nonfinite_steps,
}
if self._use_scaler:
sd["scaler"] = self.scaler.state_dict()
return sd
def load_state_dict(self, state_dict: dict[str, Any]) -> None:
self.optimizer.load_state_dict(state_dict["optimizer"])
self._micro_step = state_dict.get("micro_step", 0)
self._optimizer_step_count = state_dict.get("optimizer_step_count", 0)
self._skipped_nonfinite_steps = state_dict.get("skipped_nonfinite_steps", 0)
if self._use_scaler and "scaler" in state_dict:
self.scaler.load_state_dict(state_dict["scaler"])
# ------------------------------------------------------------------
# Core step
# ------------------------------------------------------------------
[docs]
def step(self, trainer: Trainer, sub_batch: TensorDictBase) -> TensorDictBase:
"""Perform one forward pass and scaled backward pass.
The optimizer is only stepped and zeroed every
``gradient_accumulation_steps`` calls.
Args:
trainer (Trainer): The owning :class:`~torchrl.trainers.Trainer`.
sub_batch (TensorDictBase): Mini-batch used for this step.
Returns:
A :class:`~tensordict.TensorDict` with scalar metrics (losses,
grad_norm) suitable for logging.
"""
# ---- forward pass (optionally under autocast) ----
with torch.amp.autocast(
self.device_type,
enabled=self.mixed_precision,
dtype=self.autocast_dtype,
):
losses_td = trainer.compute_loss(sub_batch)
# Sum all loss_* keys and normalise by accumulation steps.
loss_items = [v for k, v in losses_td.items() if k.startswith("loss")]
if not loss_items:
raise RuntimeError(
"The loss module returned no 'loss_*' keys. "
"Make sure your loss module prefixes scalar outputs with 'loss'."
)
loss = sum(loss_items) / self.gradient_accumulation_steps
# ---- non-finite loss guard ----
# A single non-finite loss would poison the gradients accumulated so
# far, so the whole accumulation window is dropped and restarted.
if not torch.isfinite(loss.detach()).all():
self._skipped_nonfinite_steps += 1
torchrl_logger.warning(
f"Skipping optimization step because the loss is non-finite: "
f"{loss.detach()}. Skipped non-finite steps: "
f"{self._skipped_nonfinite_steps}."
)
self.optimizer.zero_grad(set_to_none=True)
self._micro_step = 0
return self._reduce_metrics(losses_td)
# ---- backward pass ----
if self._use_scaler:
self.scaler.scale(loss).backward()
else:
loss.backward()
self._micro_step += 1
# ---- optimizer step every `gradient_accumulation_steps` micro-batches ----
grad_norm = None
if self._micro_step % self.gradient_accumulation_steps == 0:
if self._use_scaler:
self.scaler.unscale_(self.optimizer)
grad_norm = 0.0
if self.clip_norm is not None:
params = [
p for group in self.optimizer.param_groups for p in group["params"]
]
grad_norm = float(nn.utils.clip_grad_norm_(params, self.clip_norm))
if self._use_scaler:
scale_before = self.scaler.get_scale()
self.scaler.step(self.optimizer)
self.scaler.update()
# If scale dropped, an overflow occurred and optimizer.step() was skipped.
if self.scaler.get_scale() >= scale_before:
self._optimizer_step_count += 1
elif self.clip_norm is not None and not math.isfinite(grad_norm):
# Without a GradScaler (e.g. bf16 or full precision) nothing
# skips overflowing updates, so guard the step explicitly.
self._skipped_nonfinite_steps += 1
torchrl_logger.warning(
f"Skipping optimizer step because the gradient norm is "
f"non-finite: {grad_norm}. Skipped non-finite steps: "
f"{self._skipped_nonfinite_steps}."
)
grad_norm = 0.0
else:
self.optimizer.step()
self._optimizer_step_count += 1
self.optimizer.zero_grad(set_to_none=True)
metrics = self._reduce_metrics(losses_td)
if grad_norm is not None:
metrics["grad_norm"] = torch.tensor(grad_norm)
return metrics
@staticmethod
def _reduce_metrics(losses_td: TensorDictBase) -> TensorDictBase:
"""Detach the loss output and reduce non-scalar entries to their mean.
Steppers must return scalar metrics suitable for logging; loss modules
such as :class:`~torchrl.objectives.llm.GRPOLoss` also emit per-token
diagnostics (KL divergences) that are reduced here.
"""
metrics = {}
for key, value in losses_td.detach().items():
metrics[key] = value.float().mean() if value.numel() > 1 else value
return TensorDict(metrics, [])
[docs]
class Trainer:
"""A generic Trainer class.
A trainer is responsible for collecting data and training the model.
To keep the class as versatile as possible, Trainer does not construct any
of its specific operations: they all must be hooked at specific points in
the training loop.
To build a Trainer, one needs an iterable data source (a :obj:`collector`), a
loss module and an optimizer.
Args:
collector (Sequence[TensorDictBase]): An iterable returning batches of
data in a TensorDict form of shape [batch x time steps].
total_frames (int): Total number of frames to be collected during
training.
loss_module (LossModule): A module that reads TensorDict batches
(possibly sampled from a replay buffer) and return a loss
TensorDict where every key points to a different loss component.
optimizer (optim.Optimizer): An optimizer that trains the parameters
of the model.
logger (Logger, optional): a Logger that will handle the logging.
optim_steps_per_batch (int, optional): number of optimization steps
per collection of data. An trainer works as follows: a main loop
collects batches of data (epoch loop), and a sub-loop (training
loop) performs model updates in between two collections of data.
If `None`, the trainer will use the number of workers as the number of optimization steps.
clip_grad_norm (bool, optional): If True, the gradients will be clipped
based on the total norm of the model parameters. If False,
all the partial derivatives will be clamped to
(-clip_norm, clip_norm). Default is ``True``.
clip_norm (Number, optional): value to be used for clipping gradients.
Default is None (no clip norm).
progress_bar (bool, optional): If True, a progress bar will be
displayed using tqdm. If tqdm is not installed, this option
won't have any effect. Default is ``True``
seed (int, optional): Seed to be used for the collector, pytorch and
numpy. Default is ``None``.
save_trainer_interval (int, optional): How often the trainer should be
saved to disk, in frame count. Default is 10000.
log_interval (int, optional): How often the values should be logged,
in frame count. Default is 10000.
save_trainer_file (path, optional): path where to save the trainer.
Default is None (no saving)
checkpoint (Checkpoint, optional): unified checkpoint object used for
scheduled saves and restores. The trainer registers any missing
standard components on this object, including the process-global
RNG state under ``"rng"``, which :meth:`load_from_file` restores
after every other component. When omitted, the legacy
``CKPT_BACKEND`` path is retained during the compatibility window.
checkpoint_rotation (CheckpointRotation, optional): retention policy used
for scheduled unified checkpoints. Requires ``checkpoint`` and cannot
be combined with ``save_trainer_file``.
checkpoint_metadata (Callable, optional): function called with the trainer
before each rotated save. Its returned mapping is added to the
checkpoint manifest metadata.
async_collection (bool, optional): Whether to collect data asynchronously.
This will only work if the replay buffer is registered within the data collector.
If using this, the UTD ratio (Update to Data) will be logged under the key "utd_ratio".
Default is False.
log_timings (bool, optional): If True, automatically register a LogTiming hook to log
timing information for all hooks to the logger (e.g., wandb, tensorboard).
Timing metrics will be logged with prefix "time/" (e.g., "time/hook/UpdateWeights").
Default is False.
auto_log_optim_steps (bool, optional): If True, automatically log ``optim_steps`` and the
keys of the averaged loss TensorDict at the end of every optimization loop, in addition
to anything ``post_optim_complete_log`` hooks return. Set to False to fully delegate
this logging to user-registered hooks. Default is True.
replay_buffer (optional): Replay owner used by a remote learner backend.
target_net_updater (TargetNetUpdater, optional): Target updater serialized
with the learner object graph.
batch_size (int, optional): Global learner batch size. Defaults to the
replay buffer batch size.
learner_backend (str): Optimization placement, ``"local"`` or ``"ray"``.
Defaults to ``"local"``.
learner_backend_options (dict, optional): Backend-specific options.
learner_poll_interval (float): Remote replay polling interval. Defaults
to ``0.05`` seconds.
"""
@classmethod
def __new__(cls, *args, **kwargs):
# Training state trackers (used for logging and checkpointing)
cls._optim_count: int = 0 # Total number of optimization steps completed
cls._collected_frames: int = 0 # Total number of frames collected (deprecated)
cls._last_log: dict[
str, Any
] = {} # Tracks when each metric was last logged (for log_interval control)
cls._last_save: int = (
0 # Tracks when trainer was last saved (for save_interval control)
)
cls.collected_frames = 0 # Total number of frames collected (current)
cls._app_state = None # Application state for checkpointing
return super().__new__(cls)
def __init__(
self,
*,
collector: BaseCollector,
total_frames: int,
frame_skip: int,
optim_steps_per_batch: int,
loss_module: LossModule | Callable[[TensorDictBase], TensorDictBase],
optimizer: optim.Optimizer | None = None,
optimization_stepper: OptimizationStepper | None = None,
replay_buffer: Any | None = None,
target_net_updater: TargetNetUpdater | None = None,
batch_size: int | None = None,
learner_backend: Literal["local", "ray"] = "local",
learner_backend_options: dict[str, Any] | None = None,
learner_poll_interval: float = 0.05,
logger: Logger | None = None,
clip_grad_norm: bool = True,
clip_norm: float | None = None,
progress_bar: bool = True,
seed: int | None = None,
save_trainer_interval: int = 10000,
log_interval: int = 10000,
save_trainer_file: str | pathlib.Path | None = None,
checkpoint: Checkpoint | None = None,
checkpoint_rotation: CheckpointRotation | None = None,
checkpoint_metadata: Callable[[Trainer], Mapping[str, Any]] | None = None,
num_epochs: int = 1,
async_collection: bool = False,
log_timings: bool = False,
auto_log_optim_steps: bool = True,
) -> None:
# objects
self.frame_skip = frame_skip
self.collector = collector
self.loss_module = loss_module
self.optimizer = optimizer
self.replay_buffer = replay_buffer
self.target_net_updater = target_net_updater
self.batch_size = batch_size
self.learner_backend = learner_backend
self.learner_backend_options = dict(learner_backend_options or {})
if learner_backend not in ("local", "ray"):
raise ValueError("learner_backend must be 'local' or 'ray'.")
if learner_backend == "ray":
if isinstance(optim_steps_per_batch, bool) or not isinstance(
optim_steps_per_batch, int
):
raise TypeError(
"optim_steps_per_batch must be an integer with "
"learner_backend='ray'."
)
if optim_steps_per_batch <= 0:
raise ValueError(
"optim_steps_per_batch must be positive with "
"learner_backend='ray'."
)
if learner_poll_interval <= 0:
raise ValueError("learner_poll_interval must be positive.")
self.learner_poll_interval = float(learner_poll_interval)
self._execution_backend = None
self._published_model_version = -1
self.logger = logger
self.async_collection = async_collection
# Logging frequency control - how often to log each metric (in frames)
self._log_interval = log_interval
# seeding
self.seed = seed
if seed is not None:
self.set_seed()
# constants
self.optim_steps_per_batch = optim_steps_per_batch
self.total_frames = total_frames
self.num_epochs = num_epochs
self.clip_grad_norm = clip_grad_norm
self.clip_norm = clip_norm
if progress_bar and not _has_tqdm:
warnings.warn(
"tqdm library not found. "
"Consider installing tqdm to use the Trainer progress bar."
)
self.progress_bar = progress_bar and _has_tqdm
self.save_trainer_interval = save_trainer_interval
self.save_trainer_file = save_trainer_file
self.checkpoint = checkpoint
if checkpoint_rotation is not None and checkpoint is None:
raise ValueError("checkpoint_rotation requires checkpoint.")
if checkpoint_rotation is not None and save_trainer_file is not None:
raise ValueError(
"checkpoint_rotation and save_trainer_file cannot both be set."
)
if checkpoint_metadata is not None and checkpoint_rotation is None:
raise ValueError("checkpoint_metadata requires checkpoint_rotation.")
if checkpoint_metadata is not None and not callable(checkpoint_metadata):
raise TypeError("checkpoint_metadata must be callable.")
self.checkpoint_rotation = checkpoint_rotation
self.checkpoint_metadata = checkpoint_metadata
self._checkpoint_state = _TrainerCheckpointState(self)
self._execution_checkpoint_state = _ExecutionCheckpointState(self)
self._checkpoint_skip_warnings: set[str] = set()
self.auto_log_optim_steps = auto_log_optim_steps
self._log_dict = defaultdict(list)
self._stop_training = False
self._stop_reason = None
# Hook collections for different stages of the training loop
self._batch_process_ops = (
[]
) # Process collected batches (e.g., reward normalization)
self._post_steps_ops = [] # After optimization steps (e.g., weight updates)
# Logging hook collections - different points in training loop where logging can occur
self._post_steps_log_ops = (
[]
) # After optimization steps (e.g., validation rewards)
self._pre_steps_log_ops = (
[]
) # Before optimization steps (e.g., rewards, frame counts)
self._post_optim_log_ops = (
[]
) # After each optimization step (e.g., gradient norms)
self._pre_epoch_log_ops = (
[]
) # Before each epoch logging (e.g., epoch-specific metrics)
self._post_epoch_log_ops = (
[]
) # After each epoch logging (e.g., epoch completion metrics)
self._post_optim_complete_log_ops = (
[]
) # After all optimization steps for a batch (e.g., logging average_losses)
# Regular hook collections for non-logging operations
self._pre_epoch_ops = (
[]
) # Before each epoch (e.g., epoch setup, cache clearing)
self._post_epoch_ops = (
[]
) # After each epoch (e.g., epoch cleanup, weight syncing)
# Optimization-related hook collections
self._pre_optim_ops = [] # Before optimization steps (e.g., cache clearing)
self._post_loss_ops = (
[]
) # After loss computation, operates on batch (e.g., priority updates)
self._process_loss_ops = (
[]
) # Transform loss values before optimizer (e.g., scaling, clipping)
self._optimizer_ops = [] # During optimization (e.g., gradient clipping)
self._process_optim_batch_ops = (
[]
) # Process batches for optimization (e.g., subsampling)
self._post_optim_ops = [] # After optimization (e.g., weight syncing)
self._setup_ops = [] # Before training starts (e.g., warmups, lazy init)
self._shutdown_ops = [] # At training end (e.g., final eval, publish)
self._modules = {}
self.optimization_stepper = optimization_stepper
if self.optimization_stepper is not None and self.learner_backend == "local":
self.optimization_stepper.register(self, name="optimization_stepper")
if (
self.optimizer is not None
and self.optimization_stepper is None
and self.learner_backend == "local"
):
# Only auto-create the OptimizerHook when no stepper is
# provided. When a stepper is present it may access
# trainer.optimizer directly, so creating the hook would leave a
# dead hook in _optimizer_ops that never fires.
with warnings.catch_warnings():
warnings.simplefilter("ignore", FutureWarning)
optimizer_hook = OptimizerHook(self.optimizer)
optimizer_hook.register(self)
if log_timings:
log_timing_hook = LogTiming(prefix="time", percall=True, erase=False)
log_timing_hook.register(self)
if self.learner_backend == "ray":
if replay_buffer is None:
raise ValueError(
"replay_buffer is required with learner_backend='ray'."
)
if not isinstance(loss_module, LossModule):
raise TypeError(
"learner_backend='ray' requires an ordinary LossModule object."
)
global_batch_size = batch_size
if global_batch_size is None:
global_batch_size = getattr(replay_buffer, "batch_size", None)
if global_batch_size is None:
raise ValueError(
"batch_size is required when replay_buffer.batch_size is unset."
)
# Kept lazy to break the intentional Trainer/stepper import cycle.
from torchrl.trainers._ray_execution import _RayTrainerExecution
prepare_weight_sync = getattr(collector, "_learner_weight_sync", None)
if prepare_weight_sync is None:
raise TypeError(
"learner_backend='ray' requires a collector that exposes "
"TorchRL WeightSyncScheme receivers."
)
self._execution_backend = _RayTrainerExecution(
loss_module=loss_module,
optimizer=optimizer,
optimization_stepper=optimization_stepper,
target_net_updater=target_net_updater,
replay_buffer=replay_buffer,
global_batch_size=global_batch_size,
options=self.learner_backend_options,
seed=seed,
clip_grad_norm=clip_grad_norm,
clip_norm=clip_norm,
update_replay_priority=not getattr(
optimization_stepper, "updates_replay_priority", False
),
weight_sync_factory=prepare_weight_sync,
)
if self.checkpoint is not None:
self._sync_checkpoint_components()
[docs]
def compute_loss(
self, sub_batch: TensorDictBase, method: str | None = None
) -> TensorDictBase | tuple[Any, ...]:
"""Evaluate the configured loss through the active execution boundary."""
if method is None:
return self.loss_module(sub_batch)
return getattr(self.loss_module, method)(sub_batch)
def register_module(self, module_name: str, module: Any) -> None:
if module_name in self._modules:
raise RuntimeError(
f"{module_name} is already registered, choose a different name."
)
self._modules[module_name] = module
if self.checkpoint is not None:
self._sync_checkpoint_components()
@staticmethod
def _is_checkpointable(component: Any) -> bool:
return (
callable(getattr(component, "dump", None))
and callable(getattr(component, "load", None))
) or (
callable(getattr(component, "state_dict", None))
and callable(getattr(component, "load_state_dict", None))
)
def _checkpoint_policy(self) -> Any | None:
for name in ("actor_network", "value_network", "local_value_network"):
policy = getattr(self.loss_module, name, None)
if policy is not None:
return policy
return None
def _checkpoint_optimizer(self) -> Any | None:
"""Return the optimizer owned by the Trainer or a legacy hook."""
optimizer = self.optimizer
if self._is_checkpointable(optimizer):
return optimizer
optimizer_hook = self._modules.get("optimizer")
hook_optimizer = getattr(optimizer_hook, "optimizer", optimizer_hook)
if self._is_checkpointable(hook_optimizer):
return hook_optimizer
return None
def _sync_checkpoint_components(
self, checkpoint: Checkpoint | None = None
) -> Checkpoint:
if checkpoint is None:
checkpoint = self.checkpoint
if checkpoint is None:
checkpoint = Checkpoint()
def register(name: str, component: Any) -> None:
if name in checkpoint or component is None:
return
if self._is_checkpointable(component):
checkpoint.register(name, component)
elif name not in self._checkpoint_skip_warnings:
torchrl_logger.warning(
"Skipping non-checkpointable Trainer component %r (%s).",
name,
type(component).__name__,
)
self._checkpoint_skip_warnings.add(name)
if self.learner_backend == "ray":
# These adapters refer to already-live services, so Checkpoint's
# deterministic name-based load order is safe. Driver copies of
# loss/optimizer state are not authoritative in this mode and are
# intentionally omitted. Policy weights are republished after the
# complete checkpoint has loaded.
register("replay_buffer", self.replay_buffer)
register("collector", self.collector)
register("trainer_state", self._checkpoint_state)
register("logger", self.logger)
register("exploration", getattr(self, "greedy_module", None))
register("exploration", getattr(self, "exploration_module", None))
for name, module in self._modules.items():
register(f"trainer_module.{name}", module)
register("rng", GlobalRNGState())
register("learner_execution", self._execution_checkpoint_state)
return checkpoint
register("policy", self._checkpoint_policy())
register("loss_module", self.loss_module)
register("optimizer", self._checkpoint_optimizer())
register("collector", self.collector)
replay_buffer = getattr(self, "replay_buffer", None)
if not self._is_checkpointable(replay_buffer):
replay_buffer_hook = self._modules.get("replay_buffer")
hook_replay_buffer = getattr(
replay_buffer_hook, "replay_buffer", replay_buffer_hook
)
replay_buffer = (
hook_replay_buffer
if self._is_checkpointable(hook_replay_buffer)
else replay_buffer_hook
)
register("replay_buffer", replay_buffer)
register("logger", self.logger)
register("exploration", getattr(self, "greedy_module", None))
register("exploration", getattr(self, "exploration_module", None))
register("target_updater", getattr(self, "target_net_updater", None))
register("trainer_state", self._checkpoint_state)
for name, module in self._modules.items():
if name in ("optimizer", "replay_buffer") and name in checkpoint:
continue
register(f"trainer_module.{name}", module)
register("rng", GlobalRNGState())
return checkpoint
def _wrap_hook_with_timing(
self, op: Callable, hook_name: str | None = None
) -> Callable:
"""Wrap a hook with timing measurement.
Args:
op: The hook/operation to wrap
hook_name: Optional name for the hook. If not provided, will be inferred from op.
Returns:
A wrapped version of the hook that measures execution time.
"""
if hook_name is None:
hook_name = getattr(
op,
"__name__",
op.__class__.__name__ if hasattr(op, "__class__") else "unknown_hook",
)
def timed_hook(*args, **kwargs):
with timeit(f"hook/{hook_name}"):
return op(*args, **kwargs)
# Preserve original attributes for debugging
timed_hook.__wrapped__ = op
timed_hook.__name__ = hook_name
return timed_hook
def _get_state(self):
if _CKPT_BACKEND == "torchsnapshot":
state = StateDict(
collected_frames=self.collected_frames,
_last_log=self._last_log,
_last_save=self._last_save,
_optim_count=self._optim_count,
)
else:
state = OrderedDict(
collected_frames=self.collected_frames,
_last_log=self._last_log,
_last_save=self._last_save,
_optim_count=self._optim_count,
)
return state
@property
def app_state(self):
optimizer = self._checkpoint_optimizer()
self._app_state = {
"state": StateDict(**self._get_state()),
"collector": self.collector,
"loss_module": self.loss_module,
**({"optimizer": optimizer} if optimizer is not None else {}),
**{k: item for k, item in self._modules.items() if k != "optimizer"},
}
return self._app_state
def state_dict(self) -> dict:
state = self._get_state()
state_dict: OrderedDict[str, Any] = OrderedDict(
collector=self.collector.state_dict(),
loss_module=self.loss_module.state_dict(),
state=state,
)
optimizer = self._checkpoint_optimizer()
if optimizer is not None:
state_dict["optimizer"] = optimizer.state_dict()
for key, item in self._modules.items():
# The standard optimizer component is emitted above, while a
# legacy OptimizerHook may also be registered under this name.
if key == "optimizer":
continue
state_dict[key] = item.state_dict()
return state_dict
def load_state_dict(self, state_dict: dict) -> None:
model_state_dict = state_dict["loss_module"]
collector_state_dict = state_dict["collector"]
self.loss_module.load_state_dict(model_state_dict)
self.collector.load_state_dict(collector_state_dict)
optimizer = self._checkpoint_optimizer()
optimizer_state_dict = state_dict.get("optimizer")
if optimizer is not None and optimizer_state_dict:
optimizer.load_state_dict(optimizer_state_dict)
for key, item in self._modules.items():
if key == "optimizer":
continue
item.load_state_dict(state_dict[key])
self.collected_frames = state_dict["state"]["collected_frames"]
self._last_log = state_dict["state"]["_last_log"]
self._last_save = state_dict["state"]["_last_save"]
self._optim_count = state_dict["state"]["_optim_count"]
[docs]
def request_stop(self, reason: str | None = None) -> None:
"""Signal that training should stop at the next loop boundary."""
self._stop_training = True
self._stop_reason = reason
[docs]
@contextlib.contextmanager
def stop_on_signal(
self, signals: Collection[int] = (signal.SIGINT, signal.SIGTERM)
):
"""Stop training cleanly when the process receives a termination signal.
Wrap :meth:`train` in this context. The first signal calls
:meth:`request_stop`, so the loop finishes the current batch, writes a
final checkpoint when a save destination is configured, shuts the
collector down and returns. A second signal raises
:class:`KeyboardInterrupt`. Previous handlers are restored on exit.
Args:
signals (Collection[int], optional): signal numbers to handle.
Defaults to ``SIGINT`` and ``SIGTERM``.
Examples:
>>> with trainer.stop_on_signal(): # doctest: +SKIP
... trainer.train()
"""
with StopOnSignal(
signals,
on_request=lambda name: self.request_stop(f"received {name}"),
) as stop:
yield stop
def _save_trainer(self) -> None:
if self.checkpoint is not None:
checkpoint = self._sync_checkpoint_components()
if self.checkpoint_rotation is not None:
self.checkpoint_rotation.save(
checkpoint,
step=self.collected_frames,
metadata=self._checkpoint_manifest_metadata(),
)
else:
checkpoint.save(self.save_trainer_file)
return
warnings.warn(
"The default Trainer checkpoint format will change from the legacy "
"CKPT_BACKEND format to torchrl.checkpoint in v0.15. Pass "
"checkpoint=Checkpoint(...) to opt in now.",
FutureWarning,
stacklevel=2,
)
warnings.warn(
"The legacy CKPT_BACKEND trainer checkpoint path is deprecated and "
"will be removed in v0.16. Use checkpoint=Checkpoint(...).",
DeprecationWarning,
stacklevel=2,
)
if _CKPT_BACKEND == "torchsnapshot":
if not _has_ts:
raise ImportError(
"torchsnapshot not found. Consider installing torchsnapshot or "
"using the torch checkpointing backend (`CKPT_BACKEND=torch`)"
)
Snapshot.take(app_state=self.app_state, path=self.save_trainer_file)
elif _CKPT_BACKEND == "torch":
# Write to a temporary file and atomically swap it in: an
# interrupted save cannot destroy the previous checkpoint, and
# tensors still memory-mapped from a previous
# ``load_from_file(mmap=True)`` keep reading from the old inode
# instead of from a truncated file.
file = pathlib.Path(self.save_trainer_file)
tmp_file = file.with_name(file.name + ".tmp")
try:
torch.save(self.state_dict(), tmp_file)
tmp_file.replace(file)
except BaseException:
tmp_file.unlink(missing_ok=True)
raise
elif _CKPT_BACKEND == "memmap":
state = self.state_dict()
path = pathlib.Path(self.save_trainer_file)
path.mkdir(parents=True, exist_ok=True)
# Persist all module state dicts using TensorDict memmap.
# Non-tensor values (scalars, bools, nested dicts) are wrapped in
# NonTensorData automatically by _state_dict_to_td, so no pickle
# dependency is needed.
for key in dict.fromkeys(
("loss_module", "collector", "optimizer", *self._modules)
):
if key not in state:
continue
sd = state[key]
if sd:
_state_dict_to_td(sd).dumps(str(path / key))
# Persist non-tensor training counters as JSON.
with open(path / "state.json", "w") as f:
json.dump(dict(state["state"]), f)
else:
raise NotImplementedError(
f"CKPT_BACKEND should be one of {_CKPT_BACKEND.backends}, got {_CKPT_BACKEND}."
)
def _has_checkpoint_destination(self) -> bool:
return (
self.checkpoint_rotation is not None or self.save_trainer_file is not None
)
def _checkpoint_manifest_metadata(self) -> dict[str, Any]:
metadata = {}
if self.checkpoint_metadata is not None:
custom_metadata = self.checkpoint_metadata(self)
if not isinstance(custom_metadata, Mapping):
raise TypeError("checkpoint_metadata must return a mapping.")
metadata.update(custom_metadata)
metadata.update(
collected_frames=self.collected_frames,
optim_steps=self._optim_count,
)
return metadata
def _save_interval_elapsed(self) -> bool:
return (self.collected_frames - self._last_save) > self.save_trainer_interval
def _save_due(self, force_save: bool = False) -> bool:
"""Whether a destination is configured and a save is due now."""
return self._has_checkpoint_destination() and (
force_save or self._save_interval_elapsed()
)
def save_trainer(self, force_save: bool = False) -> None:
if not self._has_checkpoint_destination():
return
if self._save_interval_elapsed():
self._last_save = self.collected_frames
elif not force_save:
return
self._save_trainer()
def _save_trainer_at_boundary(self, *, force_save: bool = False) -> None:
"""Save at a training-loop boundary, pausing free-running collection."""
if self._save_due(force_save):
with self._collection_paused():
self.save_trainer(force_save=force_save)
@contextlib.contextmanager
def _collection_paused(self):
"""Pause an asynchronous collector while a checkpoint is written."""
pause = (
getattr(self.collector, "pause", None) if self.async_collection else None
)
with contextlib.ExitStack() as stack:
if pause is not None:
try:
stack.enter_context(pause())
except NotImplementedError:
if "collector.pause" not in self._checkpoint_skip_warnings:
torchrl_logger.warning(
"%s does not implement pause(); asynchronous checkpoints "
"are written while collection continues.",
type(self.collector).__name__,
)
self._checkpoint_skip_warnings.add("collector.pause")
yield
def _resolve_checkpoint_path(self, file: str | pathlib.Path) -> str | pathlib.Path:
"""Map a rotation directory to its newest checkpoint; pass other inputs through."""
if not isinstance(file, (str, pathlib.PurePath)):
return file
path = pathlib.Path(file).expanduser()
if not path.is_dir() or (path / "state.json").exists():
# A file, an archive, or a legacy memmap trainer directory.
return file
return resolve_checkpoint_path(path)
[docs]
def load_from_file(self, file: str | pathlib.Path, **kwargs) -> Trainer:
"""Loads a file and its state-dict in the trainer.
Keyword arguments are passed to the :func:`~torch.load` function for
legacy torch checkpoints and unified components explicitly saved with
the torch state-dict payload format. Unified checkpoints additionally
accept ``strict`` to control missing or incompatible components.
Arguments are ignored when ``CKPT_BACKEND=memmap``.
.. note::
Unified state-dict components use TensorDict storage by default and
do not invoke the pickle loader. For explicit torch payloads and
``CKPT_BACKEND=torch`` checkpoints, ``weights_only=True`` is the
default for safer deserialization. Pass ``weights_only=False``
explicitly only if the state dict contains custom objects. On
torch < 2.4 the default is ``weights_only=False`` because the
weights-only unpickler of those versions cannot deserialize the
``torch.device`` instances contained in TensorDict state-dicts.
.. note::
Explicit torch payloads and ``CKPT_BACKEND=torch`` checkpoints use
``mmap=True`` by default. Pass ``mmap=False`` for legacy pre-zipfile
``torch.save`` files or file-like objects. On Windows the default
is ``mmap=False`` because a mapped checkpoint keeps the file locked,
preventing deletion or re-save.
.. note::
Unified checkpoint tensors are mapped to CPU by default. Pass an
explicit ``map_location`` to select another device mapping.
.. note::
After restoring an independently registered policy component, the
trainer synchronizes the collector once so local policy copies and
remote workers observe the restored learner weights.
.. note::
``file`` may also be a :class:`~torchrl.checkpoint.CheckpointRotation`
directory, in which case its newest checkpoint is restored.
"""
file = self._resolve_checkpoint_path(file)
if Checkpoint.is_checkpoint(file):
checkpoint = self.checkpoint
if checkpoint is None:
checkpoint = Checkpoint()
map_location = kwargs.pop("map_location", "cpu")
strict = kwargs.pop("strict", None)
for key, value in _torch_load_defaults().items():
kwargs.setdefault(key, value)
checkpoint = self._sync_checkpoint_components(checkpoint)
load_kwargs = {
"map_location": map_location,
"tensor_load_kwargs": kwargs,
"strict": strict,
}
registered = set(checkpoint.components)
load_rng = (
"rng" in registered and "rng" in Checkpoint.manifest(file)["components"]
)
registered.discard("rng")
if self.learner_backend == "ray":
# Service owners must be restored before learner actors create
# rank-aware clients. The learner state is deliberately last.
ordered = [
name
for name in ("replay_buffer", "collector")
if name in registered
]
ordered.extend(
sorted(
registered.difference(
{"replay_buffer", "collector", "learner_execution"}
)
)
)
if "learner_execution" in registered:
ordered.append("learner_execution")
loaded = set()
for name in ordered:
result = checkpoint.load(file, components=[name], **load_kwargs)
loaded.update(result.loaded)
else:
result = checkpoint.load(file, components=registered, **load_kwargs)
loaded = set(result.loaded)
if "learner_execution" in loaded:
self._publish_execution_weights(force=True)
elif "policy" in loaded:
# The collector payload is restored before the independently
# registered policy component. Synchronize once more after all
# components have loaded so local copies, remote workers, and
# weight-transport caches observe the restored learner policy.
policy = checkpoint.components["policy"]
if policy is self._checkpoint_policy():
self.collector.update_policy_weights_()
else:
self.collector.update_policy_weights_(policy)
if load_rng:
result = checkpoint.load(file, components=["rng"], **load_kwargs)
loaded.update(result.loaded)
elif _CKPT_BACKEND == "torchsnapshot":
snapshot = Snapshot(path=file)
snapshot.restore(app_state=self.app_state)
elif _CKPT_BACKEND == "torch":
for key, value in _torch_load_defaults().items():
kwargs.setdefault(key, value)
if isinstance(file, pathlib.Path):
# Older torch versions require a string path when mmap is set.
file = str(file)
loaded_dict: OrderedDict = torch.load(file, **kwargs)
self.load_state_dict(loaded_dict)
elif _CKPT_BACKEND == "memmap":
path = pathlib.Path(file)
state: dict = {}
for key in dict.fromkeys(
("loss_module", "collector", "optimizer", *self._modules)
):
key_path = path / key
if key_path.exists():
state[key] = _td_to_state_dict(
TensorDict.load_memmap(str(key_path))
)
else:
state[key] = {}
with open(path / "state.json") as f:
state["state"] = json.load(f)
self.load_state_dict(state)
return self
def set_seed(self):
seed = self.collector.set_seed(self.seed, static_seed=False)
torch.manual_seed(seed)
np.random.seed(seed)
@property
def collector(self) -> BaseCollector:
return self._collector
@collector.setter
def collector(self, collector: BaseCollector) -> None:
self._collector = collector
def register_op(
self,
dest: Literal[
"batch_process",
"pre_optim_steps",
"process_optim_batch",
"post_loss",
"process_loss",
"optimizer",
"post_steps",
"post_optim",
"pre_steps_log",
"post_steps_log",
"post_optim_log",
"pre_epoch_log",
"post_epoch_log",
"post_optim_complete_log",
"pre_epoch",
"post_epoch",
"setup",
"shutdown",
],
op: Callable,
**kwargs,
) -> None:
if self.learner_backend == "ray" and dest not in {
"batch_process",
"post_steps",
"pre_steps_log",
"post_steps_log",
"setup",
"shutdown",
}:
raise RuntimeError(
f"The {dest!r} hook stage executes inside the local optimization "
"loop and is unavailable with learner_backend='ray'. Move this "
"behavior into an OptimizationStepper or a learner-owned component."
)
# Wrap hook with timing for performance monitoring
# Get hook name from registered modules if available
hook_name = None
for name, module in self._modules.items():
if module is op or (callable(module) and module.__call__ is op):
hook_name = name
break
timed_op = self._wrap_hook_with_timing(op, hook_name)
if dest == "batch_process":
_check_input_output_typehint(
op, input=TensorDictBase, output=TensorDictBase
)
self._batch_process_ops.append((timed_op, kwargs))
elif dest == "pre_optim_steps":
_check_input_output_typehint(op, input=None, output=None)
self._pre_optim_ops.append((timed_op, kwargs))
elif dest == "process_optim_batch":
_check_input_output_typehint(
op, input=TensorDictBase, output=TensorDictBase
)
self._process_optim_batch_ops.append((timed_op, kwargs))
elif dest == "post_loss":
warnings.warn(
"The 'post_loss' hook stage will be replaced by OptimizationStepper "
"in a future release. Use 'post_optim' for post-optimization hooks "
"(e.g. priority updates), or provide a custom OptimizationStepper.",
FutureWarning,
stacklevel=2,
)
_check_input_output_typehint(
op, input=TensorDictBase, output=TensorDictBase
)
self._post_loss_ops.append((timed_op, kwargs))
elif dest == "process_loss":
warnings.warn(
"The 'process_loss' hook stage will be replaced by OptimizationStepper "
"in a future release. Move loss-transformation logic into a custom "
"OptimizationStepper.",
FutureWarning,
stacklevel=2,
)
_check_input_output_typehint(
op, input=TensorDictBase, output=TensorDictBase
)
self._process_loss_ops.append((timed_op, kwargs))
elif dest == "optimizer":
warnings.warn(
"The 'optimizer' hook stage will be replaced by OptimizationStepper "
"in a future release. Use DefaultOptimizationStepper for equivalent "
"behaviour.",
FutureWarning,
stacklevel=2,
)
_check_input_output_typehint(
op, input=[TensorDictBase, bool, float, int], output=TensorDictBase
)
self._optimizer_ops.append((timed_op, kwargs))
elif dest == "post_steps":
_check_input_output_typehint(op, input=None, output=None)
self._post_steps_ops.append((timed_op, kwargs))
elif dest == "post_optim":
_check_input_output_typehint(op, input=None, output=None)
self._post_optim_ops.append((timed_op, kwargs))
elif dest == "pre_steps_log":
_check_input_output_typehint(
op, input=TensorDictBase, output=tuple[str, float]
)
self._pre_steps_log_ops.append((timed_op, kwargs))
elif dest == "post_steps_log":
_check_input_output_typehint(
op, input=TensorDictBase, output=tuple[str, float]
)
self._post_steps_log_ops.append((timed_op, kwargs))
elif dest == "post_optim_log":
_check_input_output_typehint(
op, input=TensorDictBase, output=tuple[str, float]
)
self._post_optim_log_ops.append((timed_op, kwargs))
elif dest == "pre_epoch_log":
_check_input_output_typehint(
op, input=TensorDictBase, output=tuple[str, float]
)
self._pre_epoch_log_ops.append((timed_op, kwargs))
elif dest == "post_epoch_log":
_check_input_output_typehint(
op, input=TensorDictBase, output=tuple[str, float]
)
self._post_epoch_log_ops.append((timed_op, kwargs))
elif dest == "post_optim_complete_log":
_check_input_output_typehint(
op, input=[int, TensorDictBase | None], output=tuple[str, float]
)
self._post_optim_complete_log_ops.append((timed_op, kwargs))
elif dest == "pre_epoch":
_check_input_output_typehint(op, input=None, output=None)
self._pre_epoch_ops.append((timed_op, kwargs))
elif dest == "post_epoch":
_check_input_output_typehint(op, input=None, output=None)
self._post_epoch_ops.append((timed_op, kwargs))
elif dest == "setup":
_check_input_output_typehint(op, input=None, output=None)
self._setup_ops.append((timed_op, kwargs))
elif dest == "shutdown":
_check_input_output_typehint(op, input=None, output=None)
self._shutdown_ops.append((timed_op, kwargs))
else:
raise RuntimeError(
f"The hook collection {dest} is not recognised. Choose from:"
f"(batch_process, pre_optim_steps, process_optim_batch, post_loss, "
f"process_loss, optimizer, post_steps, post_optim, pre_steps_log, "
f"post_steps_log, post_optim_log, pre_epoch_log, post_epoch_log, "
f"post_optim_complete_log, setup, shutdown, pre_epoch, post_epoch)"
)
register_hook = register_op
# Process batch
def _process_batch_hook(self, batch: TensorDictBase) -> TensorDictBase:
for op, kwargs in self._batch_process_ops:
out = op(batch, **kwargs)
if isinstance(out, TensorDictBase):
batch = out
return batch
def _post_steps_hook(self) -> None:
for op, kwargs in self._post_steps_ops:
op(**kwargs)
def _post_optim_log(self, batch: TensorDictBase) -> None:
"""Execute logging hooks that run AFTER EACH optimization step.
These hooks log metrics that are computed after each individual optimization step,
such as gradient norms, individual loss components, or step-specific metrics.
Called after each optimization step within the optimization loop.
"""
for op, kwargs in self._post_optim_log_ops:
result = op(batch, **kwargs)
if result is not None:
self._log(**result)
def _pre_optim_hook(self):
for op, kwargs in self._pre_optim_ops:
op(**kwargs)
def _process_optim_batch_hook(self, batch):
for op, kwargs in self._process_optim_batch_ops:
out = op(batch, **kwargs)
if isinstance(out, TensorDictBase):
batch = out
return batch
def _post_loss_hook(self, batch):
for op, kwargs in self._post_loss_ops:
out = op(batch, **kwargs)
if isinstance(out, TensorDictBase):
batch = out
return batch
def _process_loss_hook(
self, sub_batch: TensorDictBase, losses_td: TensorDictBase
) -> TensorDictBase:
"""Apply registered loss transformation hooks before optimization.
Unlike ``post_loss`` hooks which operate on the batch (e.g., for priority updates),
``process_loss`` hooks transform the loss TensorDict itself. These hooks receive
both the sub_batch and the losses, and should return the modified losses.
Use cases include loss scaling, clipping, or applying importance weights.
Args:
sub_batch: The batch of data used to compute the losses.
losses_td: The TensorDict containing loss components from the loss module.
Returns:
The (possibly modified) losses TensorDict.
"""
for op, kwargs in self._process_loss_ops:
out = op(sub_batch, losses_td, **kwargs)
if isinstance(out, TensorDictBase):
losses_td = out
return losses_td
def _optimizer_hook(self, batch):
for i, (op, kwargs) in enumerate(self._optimizer_ops):
out = op(batch, self.clip_grad_norm, self.clip_norm, i, **kwargs)
if isinstance(out, TensorDictBase):
batch = out
return batch.detach()
def _post_optim_hook(self):
for op, kwargs in self._post_optim_ops:
op(**kwargs)
def _pre_epoch_log_hook(self, batch: TensorDictBase) -> None:
"""Execute logging hooks that run BEFORE each epoch of optimization.
These hooks log metrics that should be computed before starting a new epoch
of optimization steps. Called once per epoch within the optimization loop.
"""
for op, kwargs in self._pre_epoch_log_ops:
result = op(batch, **kwargs)
if result is not None:
self._log(**result)
def _pre_epoch_hook(self, batch: TensorDictBase, **kwargs) -> None:
"""Execute regular hooks that run BEFORE each epoch of optimization.
These hooks perform non-logging operations before starting a new epoch
of optimization steps. Called once per epoch within the optimization loop.
"""
for op, kwargs in self._pre_epoch_ops:
batch = op(batch, **kwargs)
return batch
def _post_epoch_log_hook(self, batch: TensorDictBase) -> None:
"""Execute logging hooks that run AFTER each epoch of optimization.
These hooks log metrics that should be computed after completing an epoch
of optimization steps. Called once per epoch within the optimization loop.
"""
for op, kwargs in self._post_epoch_log_ops:
result = op(batch, **kwargs)
if result is not None:
self._log(**result)
def _post_optim_complete_log_hook(
self, optim_steps: int, average_losses: TensorDictBase | None
) -> None:
"""Execute logging hooks that run AFTER all steps in the optimization loop.
These hooks log metrics that use the total step count and averaged loss TensorDict.
Called once per optimization loop.
"""
for op, kwargs in self._post_optim_complete_log_ops:
result = op(optim_steps, average_losses, **kwargs)
if result is not None:
self._log(**result)
if self.auto_log_optim_steps:
if average_losses is not None:
self._log(optim_steps=optim_steps, **average_losses)
else:
self._log(optim_steps=optim_steps)
def _post_epoch_hook(self) -> None:
"""Execute regular hooks that run AFTER each epoch of optimization.
These hooks perform non-logging operations after completing an epoch
of optimization steps. Called once per epoch within the optimization loop.
"""
for op, kwargs in self._post_epoch_ops:
op(**kwargs)
def _pre_steps_log_hook(self, batch: TensorDictBase) -> None:
"""Execute logging hooks that run BEFORE optimization steps.
These hooks typically log metrics from the collected batch data,
such as rewards, frame counts, or other batch-level statistics.
Called once per batch collection, before any optimization occurs.
"""
for op, kwargs in self._pre_steps_log_ops:
result = op(batch, **kwargs)
if result is not None:
self._log(**result)
def _post_steps_log_hook(self, batch: TensorDictBase) -> None:
"""Execute logging hooks that run AFTER optimization steps.
These hooks typically log metrics that depend on the optimization results,
such as validation rewards, evaluation metrics, or post-training statistics.
Called once per batch collection, after all optimization steps are complete.
"""
for op, kwargs in self._post_steps_log_ops:
result = op(batch, **kwargs)
if result is not None:
self._log(**result)
def _setup_hook(self) -> None:
for op, kwargs in self._setup_ops:
op(**kwargs)
def _shutdown_hook(self) -> None:
for op, kwargs in self._shutdown_ops:
op(**kwargs)
def train(self):
if self.learner_backend == "ray":
return self._train_with_execution_backend()
if self.progress_bar:
self._pbar = tqdm(total=self.total_frames, initial=self.collected_frames)
self._pbar_str = {}
setup_complete = False
try:
if self.async_collection:
self.collector.start()
while self.collector.getattr_rb("write_count") == 0:
time.sleep(0.1)
# Create async iterator that monitors write_count progress
iterator = self._async_iterator()
else:
iterator = self.collector
self._setup_hook()
setup_complete = True
for batch in iterator:
if not self.async_collection and batch is not None:
batch = self._process_batch_hook(batch)
current_frames = (
batch.get(("collector", "mask"), torch.tensor(batch.numel()))
.sum()
.item()
* self.frame_skip
)
self.collected_frames += current_frames
else:
# Batch is None: either async collection, or a synchronous
# collector that writes directly to the replay buffer (e.g.
# LLM collectors created with a replay_buffer). Frames are
# tracked via the buffer write count in both cases.
batch = None
cf = self.collected_frames
if self.replay_buffer is not None:
self.collected_frames = self._replay_write_count()
else:
self.collected_frames = self.collector.getattr_rb("write_count")
current_frames = self.collected_frames - cf
# LOGGING POINT 1: Pre-optimization logging (e.g., rewards, frame counts)
self._pre_steps_log_hook(batch)
if self.collected_frames >= self.collector.init_random_frames:
self.optim_steps(batch)
self._post_steps_hook()
# LOGGING POINT 2: Post-optimization logging (e.g., validation rewards, evaluation metrics)
self._post_steps_log_hook(batch)
if self._stop_training:
if self._stop_reason and VERBOSE:
torchrl_logger.info(
f"Trainer stopping early: {self._stop_reason}"
)
self._save_trainer_at_boundary(force_save=True)
break
if self.progress_bar:
self._pbar.update(current_frames)
self._pbar_description()
if self.collected_frames >= self.total_frames:
self._save_trainer_at_boundary(force_save=True)
break
self._save_trainer_at_boundary()
finally:
try:
if setup_complete:
self._shutdown_hook()
finally:
self.collector.shutdown()
def _train_with_execution_backend(self) -> None:
"""Run collection while the private backend owns optimization state."""
backend = self._execution_backend
if backend is None:
raise RuntimeError("The configured learner execution backend is missing.")
if self.progress_bar:
self._pbar = tqdm(total=self.total_frames, initial=self.collected_frames)
self._pbar_str = {}
setup_complete = False
try:
if hasattr(self.replay_buffer, "start"):
self.replay_buffer.start()
backend.start()
self._publish_execution_weights(force=True)
self._setup_hook()
setup_complete = True
previous_write_count = self._replay_write_count()
if self.async_collection:
self.collector.start()
iterator = itertools.repeat(None)
else:
iterator = iter(self.collector)
for batch in iterator:
previous_frames = self.collected_frames
if batch is not None:
batch = self._process_batch_hook(batch)
current_frames = int(
batch.get(("collector", "mask"), torch.tensor(batch.numel()))
.sum()
.item()
)
replay_batch = batch
if ("collector", "mask") in replay_batch.keys(True):
replay_batch = replay_batch[
replay_batch.get(("collector", "mask"))
]
else:
replay_batch = replay_batch.reshape(-1)
self.replay_buffer.extend(replay_batch)
self.collected_frames += current_frames * self.frame_skip
self._pre_steps_log_hook(batch)
else:
write_count = self._replay_write_count()
current_frames = max(0, write_count - previous_write_count)
previous_write_count = write_count
self.collected_frames += current_frames * self.frame_skip
if (
self.collected_frames
>= getattr(self.collector, "init_random_frames", 0)
and len(self.replay_buffer) >= backend.global_batch_size
and current_frames > 0
):
num_steps = self.optim_steps_per_batch * self.num_epochs
receipt = backend.step(num_steps)
self._optim_count += receipt.optim_steps
metrics = receipt.metrics.flatten_keys(".").to_dict()
if self.auto_log_optim_steps:
metrics["optim_steps"] = self._optim_count
self._log(**metrics)
self._publish_execution_weights()
self._post_steps_hook()
if batch is not None:
self._post_steps_log_hook(batch)
if self.progress_bar and self.collected_frames > previous_frames:
self._pbar.update(self.collected_frames - previous_frames)
self._pbar_description()
if self._stop_training or self.collected_frames >= self.total_frames:
self._save_execution_checkpoint(
force_save=True, resume_collection=False
)
break
self._save_execution_checkpoint()
if self.async_collection and current_frames == 0:
time.sleep(self.learner_poll_interval)
finally:
if setup_complete:
self._shutdown_hook()
try:
self.collector.shutdown()
finally:
backend.shutdown()
def _save_execution_checkpoint(
self, *, force_save: bool = False, resume_collection: bool = True
) -> None:
if not self._save_due(force_save):
return
if self.async_collection:
pause = getattr(self.collector, "pause", None)
if not callable(pause):
raise RuntimeError(
"Asynchronous remote checkpointing requires a collector "
"with a pause() boundary."
)
with pause(resume=resume_collection):
self.save_trainer(force_save=force_save)
else:
self.save_trainer(force_save=force_save)
def _publish_execution_weights(self, *, force: bool = False) -> None:
backend = self._execution_backend
model_version = backend.model_version
if not force and model_version <= self._published_model_version:
return
model_weights_key, auxiliary_weights = self._execution_weight_publication()
published_version = backend.publish_weights(
expected_version=model_version,
model_weights_key=model_weights_key,
auxiliary_weights=auxiliary_weights,
)
if published_version != model_version:
raise RuntimeError(
f"Published model version {published_version} does not match "
f"learner version {model_version}."
)
self._published_model_version = published_version
def _execution_weight_publication(
self,
) -> tuple[NestedKey | None, TensorDictBase | None]:
return None, None
@staticmethod
def _compose_execution_weight_publication(
auxiliary_module: nn.Module | None,
) -> tuple[NestedKey | None, TensorDictBase | None]:
"""Compose learner policy weights with a controller-owned module."""
if auxiliary_module is None:
return None, None
auxiliary_weights = TensorDict(
{"module": TensorDict({"1": TensorDict.from_module(auxiliary_module)})}
)
return ("module", "0"), auxiliary_weights
def _execution_controller_state(self) -> dict[str, Any]:
return {
"published_model_version": self._published_model_version,
"learner_generation": self._execution_backend.generation,
}
def _load_execution_controller_state(self, state_dict: Mapping[str, Any]) -> None:
# A restored backend always starts a fresh generation and republishes
# its semantic model version before collection resumes.
del state_dict
self._published_model_version = -1
def _replay_write_count(self) -> int:
write_count = self.replay_buffer.write_count
if callable(write_count):
write_count = write_count()
return int(write_count)
def _async_iterator(self):
"""Create an iterator for async collection that monitors replay buffer write_count.
This iterator yields None batches and terminates when total_frames is reached
based on the replay buffer's write_count rather than using a fixed range.
This ensures the training loop properly consumes the entire collector output.
"""
while True:
current_write_count = self.collector.getattr_rb("write_count")
# Check if we've reached the target frames
if current_write_count >= self.total_frames:
break
else:
yield None
def __del__(self):
try:
self.collector.shutdown()
except Exception:
pass
def shutdown(self):
if VERBOSE:
torchrl_logger.info("shutting down collector")
self.collector.shutdown()
[docs]
def optim_steps(
self,
batch: TensorDictBase,
*,
optim_steps_per_batch: int | None | object = _OPTIM_STEPS_UNSET,
num_epochs: int | object = _OPTIM_STEPS_UNSET,
) -> None:
"""Run the configured optimization loop for one collected batch.
Keyword overrides are applied only to this call and do not change the
trainer configuration. They are useful for algorithms that need a
one-time optimization schedule while retaining the standard Trainer
hooks and logging behavior.
"""
average_losses = None
self._pre_optim_hook()
if optim_steps_per_batch is _OPTIM_STEPS_UNSET:
optim_steps_per_batch = self.optim_steps_per_batch
if num_epochs is _OPTIM_STEPS_UNSET:
num_epochs = self.num_epochs
j = -1
for _ in range(num_epochs):
# LOGGING POINT 3: Pre-epoch logging (e.g., epoch-specific metrics)
self._pre_epoch_log_hook(batch)
# Regular pre-epoch operations (e.g., epoch setup)
batch_processed = self._pre_epoch_hook(batch)
if optim_steps_per_batch is None:
prog = itertools.count()
else:
prog = range(optim_steps_per_batch)
for j in prog:
self._optim_count += 1
try:
sub_batch = self._process_optim_batch_hook(batch_processed)
except StopIteration:
break
if sub_batch is None:
break
if self.optimization_stepper is not None:
losses_detached = self.optimization_stepper.step(self, sub_batch)
self._post_optim_hook()
else:
losses_td = self.loss_module(sub_batch)
self._post_loss_hook(sub_batch)
losses_td = self._process_loss_hook(sub_batch, losses_td)
losses_detached = self._optimizer_hook(losses_td)
self._post_optim_hook()
del losses_td
# LOGGING POINT 4: Post-optimization step logging (e.g., gradient norms, step-specific metrics)
self._post_optim_log(sub_batch)
if average_losses is None:
average_losses: TensorDictBase = losses_detached
else:
for key, item in losses_detached.items():
val = average_losses.get(key)
average_losses.set(key, val * j / (j + 1) + item / (j + 1))
del sub_batch, losses_detached
# LOGGING POINT 5: Post-epoch logging (e.g., epoch completion metrics)
self._post_epoch_log_hook(batch)
# Regular post-epoch operations (e.g., epoch cleanup)
self._post_epoch_hook()
if j >= 0:
# LOGGING POINT 6: After all optimization for this batch (e.g., logging average_losses)
self._post_optim_complete_log_hook(self._optim_count, average_losses)
def _log(self, log_pbar=False, **kwargs) -> None:
"""Main logging method that handles both logger output and progress bar updates.
This method is called from various hooks throughout the training loop to log metrics.
It maintains a history of logged values and controls logging frequency based on log_interval.
Args:
log_pbar: If True, the value will also be displayed in the progress bar
**kwargs: Key-value pairs to log, where key is the metric name and value is the metric value
"""
collected_frames = self.collected_frames
for key, item in kwargs.items():
# Store all values in history regardless of logging frequency
self._log_dict[key].append(item)
# Check if enough frames have passed since last logging for this key
if (collected_frames - self._last_log.get(key, 0)) > self._log_interval:
self._last_log[key] = collected_frames
_log = True
else:
_log = False
# Determine logging method (defaults to "log_scalar")
method = LOGGER_METHODS.get(key, "log_scalar")
# Log to external logger (e.g., tensorboard, wandb) if conditions are met
if _log and self.logger is not None:
if (
method == "log_scalar"
and isinstance(item, torch.Tensor)
and item.ndim > 0
):
continue
getattr(self.logger, method)(key, item, step=collected_frames)
# Update progress bar if requested and method is scalar
if method == "log_scalar" and self.progress_bar and log_pbar:
if isinstance(item, torch.Tensor):
item = item.item()
self._pbar_str[key] = item
def _pbar_description(self) -> None:
"""Update the progress bar description with current metric values.
This method formats and displays the current values of metrics that have
been marked for progress bar display (log_pbar=True) in the logging hooks.
"""
if self.progress_bar:
self._pbar.set_description(
", ".join(
[
f"{key}: {self._pbar_str[key]:{TYPE_DESCR.get(type(self._pbar_str[key]), '4.4f')}}"
for key in sorted(self._pbar_str.keys())
]
)
)
def __repr__(self) -> str:
loss_str = indent(f"loss={self.loss_module}", 4 * " ")
collector_str = indent(f"collector={self.collector}", 4 * " ")
optimizer_str = indent(f"optimizer={self.optimizer}", 4 * " ")
logger = indent(f"logger={self.logger}", 4 * " ")
string = "\n".join(
[
loss_str,
collector_str,
optimizer_str,
logger,
]
)
string = f"Trainer(\n{string})"
return string
def _get_list_state_dict(hook_list):
out = []
for item, kwargs in hook_list:
if hasattr(item, "state_dict"):
out.append((item.state_dict(), kwargs))
else:
out.append((None, kwargs))
return out
def _load_list_state_dict(list_state_dict, hook_list):
for i, ((state_dict_item, kwargs), (item, _)) in enumerate(
zip(list_state_dict, hook_list)
):
if state_dict_item is not None:
item.load_state_dict(state_dict_item)
hook_list[i] = (item, kwargs)
[docs]
class SelectKeys(TrainerHookBase):
"""Selects keys in a TensorDict batch.
Args:
keys (iterable of strings): keys to be selected in the tensordict.
Examples:
>>> trainer = make_trainer()
>>> key1 = "first key"
>>> key2 = "second key"
>>> td = TensorDict(
... {
... key1: torch.randn(3),
... key2: torch.randn(3),
... },
... [],
... )
>>> trainer.register_op("batch_process", SelectKeys([key1]))
>>> td_out = trainer._process_batch_hook(td)
>>> assert key1 in td_out.keys()
>>> assert key2 not in td_out.keys()
"""
def __init__(self, keys: Sequence[str]):
if isinstance(keys, str):
raise RuntimeError(
"Expected keys to be an iterable of str, got str instead"
)
self.keys = keys
def __call__(self, batch: TensorDictBase) -> TensorDictBase:
return batch.select(*self.keys)
def state_dict(self) -> dict[str, Any]:
return {}
def load_state_dict(self, state_dict: dict[str, Any]) -> None:
pass
[docs]
def register(self, trainer, name="select_keys") -> None:
trainer.register_op("batch_process", self)
trainer.register_module(name, self)
[docs]
class ReplayBufferTrainer(TrainerHookBase):
"""Replay buffer hook provider.
Args:
replay_buffer (TensorDictReplayBuffer): replay buffer to be used.
batch_size (int, optional): batch size when sampling data from the
latest collection or from the replay buffer. If none is provided,
the replay buffer batch-size will be used (preferred option for
unchanged batch-sizes).
memmap (bool, optional): if ``True``, a memmap tensordict is created.
Default is ``False``.
device (device, optional): device where the samples must be placed.
Default to ``None``.
flatten_tensordicts (bool, optional): if ``True``, the tensordicts will be
flattened (or equivalently masked with the valid mask obtained from
the collector) before being passed to the replay buffer. Otherwise,
no transform will be achieved other than padding (see :obj:`max_dims` arg below).
Defaults to ``False``.
max_dims (sequence of int, optional): if :obj:`flatten_tensordicts` is set to False,
this will be a list of the length of the batch_size of the provided
tensordicts that represent the maximum size of each. If provided,
this list of sizes will be used to pad the tensordict and make their shape
match before they are passed to the replay buffer. If there is no
maximum value, a -1 value should be provided.
iterate (bool, optional): if ``True``, the replay buffer will be iterated over
in a loop. Defaults to ``False`` (call to :meth:`~torchrl.data.ReplayBuffer.sample` will be used).
Examples:
>>> rb_trainer = ReplayBufferTrainer(replay_buffer=replay_buffer, batch_size=N)
>>> trainer.register_op("batch_process", rb_trainer.extend)
>>> trainer.register_op("process_optim_batch", rb_trainer.sample)
>>> trainer.register_op("post_loss", rb_trainer.update_priority)
"""
def __init__(
self,
replay_buffer: TensorDictReplayBuffer,
batch_size: int | None = None,
memmap: bool = False,
device: DEVICE_TYPING | None = None,
flatten_tensordicts: bool = False,
max_dims: Sequence[int] | None = None,
iterate: bool = False,
) -> None:
self.replay_buffer = replay_buffer
if hasattr(replay_buffer, "update_tensordict_priority"):
self._update_priority = self.replay_buffer.update_tensordict_priority
else:
if isinstance(replay_buffer.sampler, PrioritizedSampler):
raise ValueError(
"Prioritized sampler not supported for replay buffer trainer if not within a TensorDictReplayBuffer"
)
self._update_priority = None
self.batch_size = batch_size
self.memmap = memmap
self.device = device
self.flatten_tensordicts = flatten_tensordicts
self.max_dims = max_dims
self.iterate = iterate
if iterate:
self.replay_buffer_iter = iter(self.replay_buffer)
def extend(self, batch: TensorDictBase) -> TensorDictBase:
if self.flatten_tensordicts:
if ("collector", "mask") in batch.keys(True):
batch = batch[batch.get(("collector", "mask"))]
else:
if "truncated" in batch["next"]:
batch["next", "truncated"][..., -1] = True
batch = batch.reshape(-1)
else:
if self.max_dims is not None:
pads = []
for d in range(batch.ndimension()):
pad_value = (
0
if self.max_dims[d] == -1
else self.max_dims[d] - batch.batch_size[d]
)
pads += [0, pad_value]
batch = pad(batch, pads)
batch = batch.cpu()
self.replay_buffer.extend(batch)
return batch
def sample(self, batch: TensorDictBase) -> TensorDictBase:
if self.iterate:
try:
sample = next(self.replay_buffer_iter)
except StopIteration:
# reset the replay buffer
self.replay_buffer_iter = iter(self.replay_buffer)
raise
else:
sample = self.replay_buffer.sample(batch_size=self.batch_size)
return sample.to(self.device) if self.device is not None else sample
def update_priority(self, batch: TensorDictBase) -> None:
if self._update_priority is not None:
self._update_priority(batch)
def state_dict(self) -> dict[str, Any]:
return {
"replay_buffer": self.replay_buffer.state_dict(),
}
def load_state_dict(self, state_dict) -> None:
self.replay_buffer.load_state_dict(state_dict["replay_buffer"])
[docs]
def register(self, trainer: Trainer, name: str = "replay_buffer"):
trainer.register_op("batch_process", self.extend)
trainer.register_op("process_optim_batch", self.sample)
trainer.register_op("post_loss", self.update_priority)
trainer.register_module(name, self)
[docs]
class OptimizerHook(TrainerHookBase):
"""Add an optimizer for one or more loss components.
.. deprecated::
``OptimizerHook`` will be replaced by
:class:`~torchrl.trainers.DefaultOptimizationStepper` in a future release.
Args:
optimizer (optim.Optimizer): An optimizer to apply to the loss_components.
loss_components (Sequence[str], optional): The keys in the loss TensorDict
for which the optimizer should be appled to the respective values.
If omitted, the optimizer is applied to all components with the
names starting with `loss_`.
Examples:
>>> optimizer_hook = OptimizerHook(optimizer, ["loss_actor"])
>>> trainer.register_op("optimizer", optimizer_hook)
"""
def __init__(
self,
optimizer: optim.Optimizer,
loss_components: Sequence[str] | None = None,
):
warnings.warn(
"OptimizerHook will be replaced by DefaultOptimizationStepper "
"in a future release.",
FutureWarning,
stacklevel=2,
)
if loss_components is not None and not loss_components:
raise ValueError(
"loss_components list cannot be empty. "
"Set to None to act on all components of the loss."
)
self.optimizer = optimizer
self.loss_components = loss_components
if self.loss_components is not None:
self.loss_components = set(self.loss_components)
def _grad_clip(self, clip_grad_norm: bool, clip_norm: float) -> float:
params = []
for param_group in self.optimizer.param_groups:
params += param_group["params"]
if clip_grad_norm and clip_norm is not None:
gn = nn.utils.clip_grad_norm_(params, clip_norm)
else:
gn = sum([p.grad.pow(2).sum() for p in params if p.grad is not None]).sqrt()
if clip_norm is not None:
nn.utils.clip_grad_value_(params, clip_norm)
return float(gn)
def __call__(
self,
losses_td: TensorDictBase,
clip_grad_norm: bool,
clip_norm: float,
index: int,
) -> TensorDictBase:
loss_components = (
[item for key, item in losses_td.items() if key in self.loss_components]
if self.loss_components is not None
else [item for key, item in losses_td.items() if key.startswith("loss")]
)
loss = sum(loss_components)
loss.backward()
grad_norm = self._grad_clip(clip_grad_norm, clip_norm)
losses_td[f"grad_norm_{index}"] = torch.tensor(grad_norm)
self.optimizer.step()
self.optimizer.zero_grad()
return losses_td
def state_dict(self) -> dict[str, Any]:
state_dict = getattr(self.optimizer, "state_dict", None)
return state_dict() if callable(state_dict) else {}
def load_state_dict(self, state_dict: dict[str, Any]) -> None:
load_state_dict = getattr(self.optimizer, "load_state_dict", None)
if state_dict and callable(load_state_dict):
load_state_dict(state_dict)
[docs]
def register(self, trainer, name="optimizer") -> None:
trainer.register_op("optimizer", self)
trainer.register_module(name, self)
[docs]
class ClearCudaCache(TrainerHookBase):
"""Clears cuda cache at a given interval.
Examples:
>>> clear_cuda = ClearCudaCache(100)
>>> trainer.register_op("pre_optim_steps", clear_cuda)
"""
def __init__(self, interval: int):
self.interval = interval
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
if self.count % self.interval == 0:
torch.cuda.empty_cache()
def state_dict(self) -> dict[str, Any]:
return {"count": self.count}
def load_state_dict(self, state_dict: dict[str, Any]) -> None:
self.count = state_dict["count"]
[docs]
def register(self, trainer: Trainer, name: str = "clear_cuda_cache"):
trainer.register_module(name, self)
trainer.register_op("pre_optim_steps", self)
class LogTiming(TrainerHookBase):
"""Hook to log timing information collected by timeit context managers.
This hook extracts timing data from the global timeit registry and logs it
to the trainer's logger (e.g., wandb, tensorboard). It's useful for profiling
different parts of the training loop.
Args:
prefix (str, optional): Prefix to add to timing metric names.
Default is "time".
percall (bool, optional): If True, log average time per call.
If False, log total time. Default is True.
erase (bool, optional): If True, reset timing data after each log.
Default is False.
Examples:
>>> # Log timing data after each optimization step
>>> log_timing = LogTiming(prefix="time", percall=True)
>>> trainer.register_op("post_optim_log", log_timing)
>>> # Log timing data after each batch collection
>>> log_timing = LogTiming(prefix="time", erase=True)
>>> trainer.register_op("post_steps_log", log_timing)
Note:
This hook works with timing data collected using the `timeit` context manager.
For example, hooks registered with `register_op` are automatically wrapped
with timing measurement.
"""
def __init__(
self,
prefix: str = "time",
percall: bool = True,
erase: bool = False,
):
self.prefix = prefix
self.percall = percall
self.erase = erase
def __call__(self, batch: TensorDictBase | None = None) -> dict:
"""Extract timing data and return as a dict for logging.
Args:
batch: The batch (unused, but required by hook signature)
Returns:
Dictionary of timing metrics with the format {metric_name: value}
"""
timing_dict = timeit.todict(percall=self.percall, prefix=self.prefix)
if self.erase:
timeit.erase()
return timing_dict
def state_dict(self) -> dict[str, Any]:
"""Return state dict for checkpointing."""
return {
"prefix": self.prefix,
"percall": self.percall,
"erase": self.erase,
}
def load_state_dict(self, state_dict: dict[str, Any]) -> None:
"""Load state dict from checkpoint."""
self.prefix = state_dict.get("prefix", "time")
self.percall = state_dict.get("percall", True)
self.erase = state_dict.get("erase", False)
def register(self, trainer: Trainer, name: str | None = None):
if name is None:
name = "log_timing"
trainer.register_module(name, self)
trainer.register_op("post_steps_log", self)
[docs]
class LogScalar(TrainerHookBase):
"""Generic scalar logger hook for any tensor values in the batch.
This hook can log any scalar values from the collected batch data, including
rewards, action norms, done states, and any other metrics. It automatically
handles masking and computes both mean and standard deviation.
Args:
key (NestedKey): the key where to find the value in the input batch.
Can be a string for simple keys or a tuple for nested keys.
Default is `torchrl.trainers.trainers.REWARD_KEY` (= `("next", "reward")`).
logname (str, optional): name of the metric to be logged. If None, will use
the key as the log name. Default is None.
log_pbar (bool, optional): if ``True``, the value will be logged on
the progression bar. Default is ``False``.
include_std (bool, optional): if ``True``, also log the standard deviation
of the values. Default is ``True``.
reduction (str, optional): reduction method to apply. Can be "mean", "sum",
"min", "max". Default is "mean".
Examples:
>>> # Log training rewards
>>> log_reward = LogScalar(("next", "reward"), "r_training", log_pbar=True)
>>> trainer.register_op("pre_steps_log", log_reward)
>>> # Log action norms
>>> log_action_norm = LogScalar("action", "action_norm", include_std=True)
>>> trainer.register_op("pre_steps_log", log_action_norm)
>>> # Log done states (as percentage)
>>> log_done = LogScalar(("next", "done"), "done_percentage", reduction="mean")
>>> trainer.register_op("pre_steps_log", log_done)
"""
def __init__(
self,
key: NestedKey = REWARD_KEY,
logname: str | None = None,
log_pbar: bool = False,
include_std: bool = True,
reduction: str = "mean",
):
self.key = key
self.logname = logname if logname is not None else str(key)
self.log_pbar = log_pbar
self.include_std = include_std
self.reduction = reduction
# Validate reduction method
if reduction not in ["mean", "sum", "min", "max"]:
raise ValueError(
f"reduction must be one of ['mean', 'sum', 'min', 'max'], got {reduction}"
)
def _apply_reduction(self, tensor: torch.Tensor) -> torch.Tensor:
"""Apply the specified reduction to the tensor."""
if self.reduction == "mean":
return tensor.float().mean()
elif self.reduction == "sum":
return tensor.sum()
elif self.reduction == "min":
return tensor.min()
elif self.reduction == "max":
return tensor.max()
else:
raise ValueError(f"Unknown reduction: {self.reduction}")
def __call__(self, batch: TensorDictBase) -> dict:
# Get the tensor from the batch
tensor = batch.get(self.key)
# Apply mask if available
if ("collector", "mask") in batch.keys(True):
mask = batch.get(("collector", "mask"))
tensor = tensor[mask]
# Compute the main statistic
main_value = self._apply_reduction(tensor).item()
# Prepare the result dictionary
result = {
self.logname: main_value,
"log_pbar": self.log_pbar,
}
# Add standard deviation if requested
if self.include_std and tensor.numel() > 1:
std_value = tensor.float().std().item()
result[f"{self.logname}_std"] = std_value
return result
def state_dict(self) -> dict[str, Any]:
return {}
def load_state_dict(self, state_dict: dict[str, Any]) -> None:
pass
[docs]
def register(self, trainer: Trainer, name: str | None = None):
if name is None:
name = f"log_{self.logname}"
trainer.register_op("pre_steps_log", self)
trainer.register_module(name, self)
[docs]
class RewardNormalizer(TrainerHookBase):
"""Reward normalizer hook.
Args:
decay (:obj:`float`, optional): exponential moving average decay parameter.
Default is 0.999
scale (:obj:`float`, optional): the scale used to multiply the reward once
normalized. Defaults to 1.0.
eps (:obj:`float`, optional): the epsilon jitter used to prevent numerical
underflow. Defaults to ``torch.finfo(DEFAULT_DTYPE).eps``
where ``DEFAULT_DTYPE=torch.get_default_dtype()``.
reward_key (str or tuple, optional): the key where to find the reward
in the input batch. Defaults to ``("next", "reward")``
Examples:
>>> reward_normalizer = RewardNormalizer()
>>> trainer.register_op("batch_process", reward_normalizer.update_reward_stats)
>>> trainer.register_op("process_optim_batch", reward_normalizer.normalize_reward)
"""
def __init__(
self,
decay: float = 0.999,
scale: float = 1.0,
eps: float | None = None,
log_pbar: bool = False,
reward_key=None,
):
self._normalize_has_been_called = False
self._update_has_been_called = False
self._reward_stats = OrderedDict()
self._reward_stats["decay"] = decay
self.scale = scale
if eps is None:
eps = torch.finfo(torch.get_default_dtype()).eps
self.eps = eps
if reward_key is None:
reward_key = REWARD_KEY
self.reward_key = reward_key
@torch.no_grad()
def update_reward_stats(self, batch: TensorDictBase) -> None:
reward = batch.get(self.reward_key)
if ("collector", "mask") in batch.keys(True):
reward = reward[batch.get(("collector", "mask"))]
if self._update_has_been_called and not self._normalize_has_been_called:
# We'd like to check that rewards are normalized. Problem is that the trainer can collect data without calling steps...
# raise RuntimeError(
# "There have been two consecutive calls to update_reward_stats without a call to normalize_reward. "
# "Check that normalize_reward has been registered in the trainer."
# )
pass
decay = self._reward_stats.get("decay", 0.999)
sum = self._reward_stats["sum"] = (
decay * self._reward_stats.get("sum", 0.0) + reward.sum()
)
ssq = self._reward_stats["ssq"] = (
decay * self._reward_stats.get("ssq", 0.0) + reward.pow(2).sum()
)
count = self._reward_stats["count"] = (
decay * self._reward_stats.get("count", 0.0) + reward.numel()
)
self._reward_stats["mean"] = sum / count
if count > 1:
var = self._reward_stats["var"] = (ssq - sum.pow(2) / count) / (count - 1)
else:
var = self._reward_stats["var"] = torch.zeros_like(sum)
self._reward_stats["std"] = var.clamp_min(self.eps).sqrt()
self._update_has_been_called = True
def normalize_reward(self, tensordict: TensorDictBase) -> TensorDictBase:
tensordict = tensordict.to_tensordict() # make sure it is not a SubTensorDict
reward = tensordict.get(self.reward_key)
if reward.device is not None:
reward = reward - self._reward_stats["mean"].to(reward.device)
reward = reward / self._reward_stats["std"].to(reward.device)
else:
reward = reward - self._reward_stats["mean"]
reward = reward / self._reward_stats["std"]
tensordict.set(self.reward_key, reward * self.scale)
self._normalize_has_been_called = True
return tensordict
def state_dict(self) -> dict[str, Any]:
return {
"_reward_stats": deepcopy(self._reward_stats),
"scale": self.scale,
"_normalize_has_been_called": self._normalize_has_been_called,
"_update_has_been_called": self._update_has_been_called,
}
def load_state_dict(self, state_dict: dict[str, Any]) -> None:
# deepcopy to decouple the normalizer stats from the caller's tensors
# (which may e.g. be mmap-backed by a checkpoint file)
for key, value in state_dict.items():
setattr(self, key, deepcopy(value))
[docs]
def register(self, trainer: Trainer, name: str = "reward_normalizer"):
trainer.register_op("batch_process", self.update_reward_stats)
trainer.register_op("process_optim_batch", self.normalize_reward)
trainer.register_module(name, self)
def mask_batch(batch: TensorDictBase) -> TensorDictBase:
"""Batch masking hook.
If a tensordict contained padded trajectories but only single events are
needed, this hook can be used to select the valid events from the original
tensordict.
Args:
batch:
Examples:
>>> trainer = mocking_trainer()
>>> trainer.register_op("batch_process", mask_batch)
"""
if ("collector", "mask") in batch.keys(True):
mask = batch.get(("collector", "mask"))
return batch[mask]
return batch
[docs]
class BatchSubSampler(TrainerHookBase):
"""Data subsampler for online RL sota-implementations.
This class subsamples a part of a whole batch of data just collected from the
environment.
Args:
batch_size (int): sub-batch size to collect. The provided batch size
must be equal to the total number of items in the output tensordict,
which will have size [batch_size // sub_traj_len, sub_traj_len].
sub_traj_len (int, optional): length of the trajectories that
sub-samples must have in online settings. Default is -1 (i.e.
takes the full length of the trajectory)
min_sub_traj_len (int, optional): minimum value of :obj:`sub_traj_len`, in
case some elements of the batch contain few steps.
Default is -1 (i.e. no minimum value)
Examples:
>>> td = TensorDict(
... {
... key1: torch.stack([torch.arange(0, 10), torch.arange(10, 20)], 0),
... key2: torch.stack([torch.arange(0, 10), torch.arange(10, 20)], 0),
... },
... [2, 10],
... )
>>> trainer.register_op(
... "process_optim_batch",
... BatchSubSampler(batch_size=batch_size, sub_traj_len=sub_traj_len),
... )
>>> td_out = trainer._process_optim_batch_hook(td)
>>> assert td_out.shape == torch.Size([batch_size // sub_traj_len, sub_traj_len])
"""
def __init__(
self, batch_size: int, sub_traj_len: int = 0, min_sub_traj_len: int = 0
) -> None:
self.batch_size = batch_size
self.sub_traj_len = sub_traj_len
self.min_sub_traj_len = min_sub_traj_len
def __call__(self, batch: TensorDictBase) -> TensorDictBase:
"""Sub-sampled part of a batch randomly.
If the batch has one dimension, a random subsample of length
self.bach_size will be returned. If the batch has two or more
dimensions, the last batch dimension represents time. All leading
batch dimensions represent independent trajectories. The resulting
subsample contains consecutive samples across time.
"""
if batch.ndimension() == 1:
return batch[torch.randperm(batch.shape[0])[: self.batch_size]]
batch = batch.reshape(-1, batch.shape[-1])
sub_traj_len = self.sub_traj_len if self.sub_traj_len > 0 else batch.shape[1]
if ("collector", "mask") in batch.keys(True):
# if a valid mask is present, it's important to sample only
# valid steps
traj_len = batch.get(("collector", "mask")).sum(-1)
sub_traj_len = max(
self.min_sub_traj_len,
min(sub_traj_len, traj_len.min().int().item()),
)
else:
traj_len = (
torch.ones(batch.shape[0], device=batch.device, dtype=torch.bool)
* batch.shape[1]
)
len_mask = traj_len >= sub_traj_len
valid_trajectories = torch.arange(batch.shape[0], device=batch.device)[len_mask]
batch_size = self.batch_size // sub_traj_len
if batch_size == 0:
raise RuntimeError(
"Resulting batch size is zero. The batch size given to "
"BatchSubSampler must be equal to the total number of elements "
"that will result in a batch provided to the loss function."
)
traj_idx = valid_trajectories[
torch.randint(
valid_trajectories.numel(), (batch_size,), device=batch.device
)
]
if sub_traj_len < batch.shape[1]:
_traj_len = traj_len[traj_idx]
seq_idx = (
torch.rand_like(_traj_len, dtype=torch.float)
* (_traj_len - sub_traj_len)
).int()
seq_idx = seq_idx.unsqueeze(-1).expand(-1, sub_traj_len)
elif sub_traj_len == batch.shape[1]:
seq_idx = torch.zeros(
batch_size, sub_traj_len, device=batch.device, dtype=torch.long
)
else:
raise ValueError(
f"sub_traj_len={sub_traj_len} is not allowed. Accepted values "
f"are in the range [1, {batch.shape[1]}]."
)
seq_idx = seq_idx + torch.arange(sub_traj_len, device=seq_idx.device)
td = batch[traj_idx].clone()
td = td.apply(
lambda t: t.gather(
dim=1,
index=expand_right(seq_idx, (batch_size, sub_traj_len, *t.shape[2:])),
),
batch_size=(batch_size, sub_traj_len),
)
if ("collector", "mask") in batch.keys(True) and not td.get(
("collector", "mask")
).all():
raise RuntimeError("Sampled invalid steps")
return td
def state_dict(self) -> dict[str, Any]:
return {}
def load_state_dict(self, state_dict: dict[str, Any]) -> None:
pass
[docs]
def register(self, trainer: Trainer, name: str = "batch_subsampler"):
trainer.register_op(
"process_optim_batch",
self,
)
trainer.register_module(name, self)
[docs]
class LogValidationReward(TrainerHookBase):
"""Recorder hook for :class:`~torchrl.trainers.Trainer`.
Args:
record_interval (int): total number of optimization steps
between two calls to the recorder for testing.
record_frames (int): number of frames to be recorded during
testing.
frame_skip (int): frame_skip used in the environment. It is
important to let the trainer know the number of frames skipped at
each iteration, otherwise the frame count can be underestimated.
For logging, this parameter is important to normalize the reward.
Finally, to compare different runs with different frame_skip,
one must normalize the frame count and rewards. Defaults to ``1``.
policy_exploration (ProbabilisticTDModule): a policy
instance used for
(1) updating the exploration noise schedule;
(2) testing the policy on the recorder.
Given that this instance is supposed to both explore and render
the performance of the policy, it should be possible to turn off
the explorative behavior by calling the
`set_exploration_type(ExplorationType.DETERMINISTIC)` context manager.
environment (EnvBase): An environment instance to be used
for testing.
exploration_type (ExplorationType, optional): exploration mode to use for the
policy. By default, no exploration is used and the value used is
``ExplorationType.DETERMINISTIC``. Set to ``ExplorationType.RANDOM`` to enable exploration
log_keys (sequence of str or tuples or str, optional): keys to read in the tensordict
for logging. Defaults to ``[("next", "reward")]``.
out_keys (Dict[str, str], optional): a dictionary mapping the ``log_keys``
to their name in the logs. Defaults to ``{("next", "reward"): "r_evaluation"}``.
suffix (str, optional): suffix of the video to be recorded.
log_pbar (bool, optional): if ``True``, the reward value will be logged on
the progression bar. Default is `False`.
"""
ENV_DEPREC = (
"the environment should be passed under the 'environment' key"
" and not the 'recorder' key."
)
def __init__(
self,
*,
record_interval: int,
record_frames: int,
frame_skip: int = 1,
policy_exploration: TensorDictModule,
environment: EnvBase = None,
exploration_type: ExplorationType = ExplorationType.RANDOM,
log_keys: list[str | tuple[str]] | None = None,
out_keys: dict[str | tuple[str], str] | None = None,
suffix: str | None = None,
log_pbar: bool = False,
recorder: EnvBase = None,
) -> None:
if environment is None and recorder is not None:
warnings.warn(self.ENV_DEPREC)
environment = recorder
elif environment is not None and recorder is not None:
raise ValueError("environment and recorder conflict.")
self.policy_exploration = policy_exploration
self.environment = environment
self.record_frames = record_frames
self.frame_skip = frame_skip
self._count = 0
self.record_interval = record_interval
self.exploration_type = exploration_type
if log_keys is None:
log_keys = [("next", "reward")]
if out_keys is None:
out_keys = KeyDependentDefaultDict(lambda x: x)
out_keys[("next", "reward")] = "r_evaluation"
self.log_keys = log_keys
self.out_keys = out_keys
self.suffix = suffix
self.log_pbar = log_pbar
@torch.inference_mode()
def __call__(self, batch: TensorDictBase) -> dict:
out = None
if self._count % self.record_interval == 0:
with set_exploration_type(self.exploration_type):
if isinstance(self.policy_exploration, torch.nn.Module):
self.policy_exploration.eval()
self.environment.eval()
td_record = self.environment.rollout(
policy=self.policy_exploration,
max_steps=self.record_frames,
auto_reset=True,
auto_cast_to_device=True,
break_when_any_done=False,
).clone()
td_record = split_trajectories(td_record)
if isinstance(self.policy_exploration, torch.nn.Module):
self.policy_exploration.train()
self.environment.train()
self.environment.transform.dump(suffix=self.suffix)
out = {}
for key in self.log_keys:
value = td_record.get(key).float()
if key == ("next", "reward"):
mask = td_record["mask"]
mean_value = value[mask].mean() / self.frame_skip
total_value = value.sum(dim=td_record.ndim - 1).mean()
out[self.out_keys[key]] = mean_value
out["total_" + self.out_keys[key]] = total_value
continue
out[self.out_keys[key]] = value
out["log_pbar"] = self.log_pbar
self._count += 1
self.environment.close()
return out
def state_dict(self) -> dict:
return {
"_count": self._count,
"recorder_state_dict": self.environment.state_dict(),
}
def load_state_dict(self, state_dict: dict) -> None:
self._count = state_dict["_count"]
self.environment.load_state_dict(state_dict["recorder_state_dict"])
[docs]
def register(self, trainer: Trainer, name: str = "recorder"):
trainer.register_module(name, self)
trainer.register_op(
"post_steps_log",
self,
)
def _resolve_module(trainer: Trainer, path: str):
"""Resolve a module from a trainer using a string path.
Args:
trainer (Trainer): The trainer instance to resolve from.
path (str): A dot-separated path to the module (e.g., "loss_module.actor_network").
Returns:
The resolved module.
Raises:
AttributeError: If the path cannot be resolved.
Examples:
>>> module = _resolve_module(trainer, "loss_module.actor_network")
>>> module = _resolve_module(trainer, "collector.policy")
"""
obj = trainer
for attr in path.split("."):
obj = getattr(obj, attr)
return obj
[docs]
class EvaluatorHook(TrainerHookBase):
"""Schedule asynchronous evaluation from a :class:`~torchrl.trainers.Trainer`.
The hook snapshots the training policy when an evaluation is triggered, polls
completed results after each collected batch, and logs them under the
``evaluation/`` namespace. If several evaluation intervals elapse while an
evaluation is running, they are coalesced into one evaluation with the latest
policy weights when the evaluator becomes available.
Args:
evaluator (Evaluator): Evaluator service used to run rollouts.
Keyword Args:
every_frames (int): Number of collected frames between evaluations.
policy (str or Callable, optional): Dot-separated path resolved from the
trainer, or a callable receiving the trainer and returning an
:class:`~torch.nn.Module` or :class:`~tensordict.TensorDictBase`.
Defaults to ``"loss_module.actor_network"``.
run_at_start (bool, optional): Whether to evaluate the initial policy.
Defaults to ``False``.
run_at_end (bool, optional): Whether to request a final evaluation with
the latest policy weights. A final evaluation is skipped when the
latest completed evaluation already used the same frame count.
Defaults to ``True``.
wait_at_end (bool, optional): Whether shutdown waits for pending and final
evaluations so their metrics are logged. When ``False``, outstanding
work is handed to :meth:`Evaluator.shutdown` and may be cancelled by
the evaluator backend. Defaults to ``True``.
wait_at_end_timeout (float or None, optional): Maximum seconds to wait for
a pending evaluation during shutdown. ``None`` waits without a time
limit. Defaults to ``60.0``.
Examples:
>>> from torchrl.collectors import Evaluator
>>> from torchrl.trainers import EvaluatorHook
>>> evaluator = Evaluator(make_eval_env, eval_policy, max_steps=1_000) # doctest: +SKIP
>>> EvaluatorHook(evaluator, every_frames=10_000).register(trainer) # doctest: +SKIP
.. note::
Checkpoints contain only the next due frame and the last completed
evaluation frame. In-flight evaluator work is intentionally not
serialized and is discarded when resuming from a checkpoint.
"""
def __init__(
self,
evaluator: Evaluator,
*,
every_frames: int,
policy: str
| Callable[[Trainer], nn.Module | TensorDictBase] = (
"loss_module.actor_network"
),
run_at_start: bool = False,
run_at_end: bool = True,
wait_at_end: bool = True,
wait_at_end_timeout: float | None = 60.0,
):
if isinstance(every_frames, bool) or not isinstance(every_frames, int):
raise TypeError("every_frames must be an integer.")
if every_frames <= 0:
raise ValueError("every_frames must be positive.")
if not isinstance(policy, str) and not callable(policy):
raise TypeError("policy must be a string path or a callable.")
self.evaluator = evaluator
self.every_frames = every_frames
self.policy = policy
self.run_at_start = run_at_start
self.run_at_end = run_at_end
self.wait_at_end = wait_at_end
self.wait_at_end_timeout = wait_at_end_timeout
self._next_due_frame = 0 if run_at_start else every_frames
self._last_completed_frame: int | None = None
self._last_triggered_frame: int | None = None
self._trainer: Trainer | None = None
def _policy_weights(self) -> TensorDictBase:
source = self.policy
if isinstance(source, str):
source = _resolve_module(self._trainer, source)
elif not isinstance(source, nn.Module):
source = source(self._trainer)
if isinstance(source, nn.Module):
return Evaluator.extract_weights(source)
if isinstance(source, TensorDictBase):
return source.detach().clone().cpu()
raise TypeError(
"EvaluatorHook policy must resolve to an nn.Module or TensorDictBase, "
f"got {type(source).__name__}."
)
def _trigger(self, step: int) -> bool:
accepted = self.evaluator.trigger_eval(self._policy_weights(), step=step)
if accepted:
self._last_triggered_frame = step
return accepted
@staticmethod
def _evaluation_name(name: str) -> str:
_, separator, suffix = name.partition("/")
return f"evaluation/{suffix if separator else name}"
def _log_result(self, result: Mapping[str, Any]) -> None:
if not result:
return
normalized = {
self._evaluation_name(name): value for name, value in result.items()
}
step_value = normalized.get("evaluation/step", self._last_triggered_frame)
if isinstance(step_value, torch.Tensor):
step_value = step_value.item()
if step_value is None:
step_value = self._trainer.collected_frames
step = int(step_value)
self._last_completed_frame = step
if step >= self._next_due_frame:
intervals = (step - self._next_due_frame) // self.every_frames + 1
self._next_due_frame += intervals * self.every_frames
scalar_metrics = {}
for name, value in normalized.items():
if name.endswith("/video"):
if self._trainer.logger is not None:
self._trainer.logger.log_video(name, value, step=step)
continue
self._trainer._log_dict[name].append(value)
self._trainer._last_log[name] = step
scalar_metrics[name] = value
if scalar_metrics and self._trainer.logger is not None:
self._trainer.logger.log_metrics(scalar_metrics, step=step)
def _poll(self) -> None:
while True:
result = self.evaluator.poll()
if result is None:
return
self._log_result(result)
def _schedule(self) -> None:
self._poll()
frames = int(self._trainer.collected_frames)
if frames < self._next_due_frame or self.evaluator.pending:
return
self._trigger(frames)
def _setup(self) -> None:
try:
self._schedule()
except BaseException:
self.evaluator.shutdown()
raise
def _drain(self) -> None:
self._poll()
while self.evaluator.pending:
result = self.evaluator.wait(timeout=self.wait_at_end_timeout)
if result is not None:
self._log_result(result)
elif self.evaluator.pending:
raise RuntimeError(
"Evaluator.wait() returned without completing pending work."
)
self._poll()
def _shutdown(self) -> None:
try:
self._poll()
if self.wait_at_end:
self._drain()
frames = int(self._trainer.collected_frames)
if self.run_at_end and self._last_completed_frame != frames:
if not self.evaluator.pending and self._trigger(frames):
if self.wait_at_end:
self._drain()
finally:
self.evaluator.shutdown()
def state_dict(self) -> dict[str, Any]:
return {
"next_due_frame": self._next_due_frame,
"last_completed_frame": self._last_completed_frame,
}
def load_state_dict(self, state_dict: dict[str, Any]) -> None:
self._next_due_frame = int(state_dict["next_due_frame"])
last_completed_frame = state_dict.get("last_completed_frame")
self._last_completed_frame = (
None if last_completed_frame is None else int(last_completed_frame)
)
self._last_triggered_frame = None
[docs]
def register(self, trainer: Trainer, name: str = "evaluator_hook") -> None:
self._trainer = trainer
trainer.register_module(name, self)
trainer.register_op("setup", self._setup)
trainer.register_op("post_steps", self._schedule)
trainer.register_op("shutdown", self._shutdown)
[docs]
class UpdateWeights(TrainerHookBase):
"""A collector weights update hook class.
This hook must be used whenever the collector policy weights sit on a
different device than the policy weights being trained by the Trainer.
In that case, those weights must be synced across devices at regular
intervals. If the devices match, this will result in a no-op.
Args:
collector (BaseCollector, optional): A data collector where the policy
weights must be synced. Not required when a ``sender`` is given.
update_weights_interval (int, optional): Interval where the sync must
take place, counted in units of ``interval_unit``. Default: ``1``.
policy_weights_getter (Callable, optional): A callable that returns the policy
weights to sync. Used for backward compatibility. If both this and
weight_update_map are provided, weight_update_map takes precedence.
weight_update_map (dict[str, str], optional): A mapping from destination paths
(keys in collector's weight_sync_schemes) to source paths on the trainer.
Example: ``{"policy": "loss_module.actor_network", "replay_buffer.transforms[0]": "loss_module.critic_network"}``.
trainer (Trainer, optional): The trainer instance, required when using
weight_update_map to resolve source paths, or when
``interval_unit="optim_steps"`` (to read the optimizer step count).
Keyword Args:
sender (optional): A weight-sync sender object exposing an
``update_weights()`` method (e.g. the sender returned by a
:class:`~torchrl.weight_update.weight_sync_schemes.WeightSyncScheme`'s
``create_sender()``). When provided, weights are pushed through the
sender instead of ``collector.update_policy_weights_()``. This is
used by LLM trainers whose inference engine (vLLM, SGLang) is fed
by a standalone sender.
interval_unit (str, optional): Unit of ``update_weights_interval``:
``"batches"`` (default) counts collected batches and registers the
hook at the ``post_steps`` stage; ``"optim_steps"`` counts
optimizer steps and registers the hook at the ``post_optim`` stage,
enabling weight pushes in the middle of an optimization loop.
Examples:
>>> # Legacy usage with policy_weights_getter
>>> update_weights = UpdateWeights(
... trainer.collector, T,
... policy_weights_getter=lambda: TensorDict.from_module(policy)
... )
>>> trainer.register_op("post_steps", update_weights)
>>> # New usage with weight_update_map
>>> update_weights = UpdateWeights(
... trainer.collector, T,
... weight_update_map={
... "policy": "loss_module.actor_network",
... "replay_buffer.transforms[0]": "loss_module.critic_network"
... },
... trainer=trainer
... )
>>> trainer.register_op("post_steps", update_weights)
>>> # Sender-based usage with optimizer-step cadence (LLM trainers)
>>> update_weights = UpdateWeights(
... update_weights_interval=10,
... trainer=trainer,
... sender=sender,
... interval_unit="optim_steps",
... )
>>> update_weights.register(trainer)
"""
def __init__(
self,
collector: BaseCollector | None = None,
update_weights_interval: int = 1,
policy_weights_getter: Callable[[Any], Any] | None = None,
weight_update_map: dict[str, str] | None = None,
trainer: Trainer | None = None,
*,
sender: Any | None = None,
interval_unit: Literal["batches", "optim_steps"] = "batches",
):
self.collector = collector
self.update_weights_interval = update_weights_interval
self.counter = 0
self.policy_weights_getter = policy_weights_getter
self.weight_update_map = weight_update_map
self.trainer = trainer
self.sender = sender
self.interval_unit = interval_unit
self._last_update_count = 0
# Validate inputs
if update_weights_interval < 1:
raise ValueError("update_weights_interval must be >= 1")
if interval_unit not in ("batches", "optim_steps"):
raise ValueError(
f"interval_unit must be 'batches' or 'optim_steps', got {interval_unit!r}"
)
if sender is None and collector is None:
raise ValueError("either a collector or a sender must be provided")
if sender is not None and (
policy_weights_getter is not None or weight_update_map is not None
):
raise ValueError(
"sender is mutually exclusive with policy_weights_getter and "
"weight_update_map"
)
if weight_update_map is not None and trainer is None:
raise ValueError("trainer must be provided when using weight_update_map")
if interval_unit == "optim_steps" and trainer is None:
raise ValueError(
"trainer must be provided when interval_unit='optim_steps'"
)
def _optimizer_step_count(self) -> int:
"""Read the optimizer step count from the trainer.
Prefers the stepper's ``optimizer_step_count`` (which discounts
gradient-accumulation micro-steps and skipped steps) and falls back to
the trainer's raw optimization-loop counter.
"""
stepper = getattr(self.trainer, "optimization_stepper", None)
count = getattr(stepper, "optimizer_step_count", None)
if count is None:
count = self.trainer._optim_count
return count
def __call__(self):
if self.interval_unit == "optim_steps":
count = self._optimizer_step_count()
if count - self._last_update_count < self.update_weights_interval:
return
self._last_update_count = count
else:
self.counter += 1
if self.counter % self.update_weights_interval != 0:
return
if self.sender is not None:
self.sender.update_weights()
# New approach: use weight_update_map if provided
elif self.weight_update_map is not None:
self._update_with_map()
# Legacy approach: use policy_weights_getter
else:
weights = (
self.policy_weights_getter()
if self.policy_weights_getter is not None
else None
)
if weights is not None:
self.collector.update_policy_weights_(weights)
else:
self.collector.update_policy_weights_()
def _update_with_map(self):
"""Update weights using the weight_update_map."""
from torchrl.weight_update.weight_sync_schemes import WeightStrategy
weights_dict = {}
for destination, source_path in self.weight_update_map.items():
# Resolve the source module from the trainer
source_module = _resolve_module(self.trainer, source_path)
# Get the scheme for this destination to know the extraction strategy
if (
hasattr(self.collector, "_weight_sync_schemes")
and self.collector._weight_sync_schemes
and destination in self.collector._weight_sync_schemes
):
scheme = self.collector._weight_sync_schemes[destination]
strategy = WeightStrategy(extract_as=scheme.strategy_str)
weights = strategy.extract_weights(source_module)
else:
# Fallback: use TensorDict extraction if no scheme found
weights = TensorDict.from_module(source_module)
weights_dict[destination] = weights
# Send all weights atomically
self.collector.update_policy_weights_(weights_dict=weights_dict)
[docs]
def register(self, trainer: Trainer, name: str = "update_weights"):
if self.trainer is None:
self.trainer = trainer
trainer.register_module(name, self)
stage = "post_optim" if self.interval_unit == "optim_steps" else "post_steps"
trainer.register_op(
stage,
self,
)
def state_dict(self) -> dict:
return {
"counter": self.counter,
"last_update_count": self._last_update_count,
}
def load_state_dict(self, state_dict) -> None:
self.counter = state_dict.get("counter", 0)
self._last_update_count = state_dict.get("last_update_count", 0)
[docs]
class CountFramesLog(TrainerHookBase):
"""A frame counter hook.
Args:
frame_skip (int): frame skip of the environment. This argument is
important to keep track of the total number of frames, not the
apparent one.
log_pbar (bool, optional): if ``True``, the reward value will be logged on
the progression bar. Default is `False`.
Examples:
>>> count_frames = CountFramesLog(frame_skip=frame_skip)
>>> trainer.register_op("pre_steps_log", count_frames)
"""
@classmethod
def __new__(cls, *args, **kwargs):
cls.frame_count = 0
return super().__new__(cls)
def __init__(self, frame_skip: int, log_pbar: bool = False):
self.frame_skip = frame_skip
self.log_pbar = log_pbar
def __call__(self, batch: TensorDictBase) -> dict:
if ("collector", "mask") in batch.keys(True):
current_frames = (
batch.get(("collector", "mask")).sum().item() * self.frame_skip
)
else:
current_frames = batch.numel() * self.frame_skip
self.frame_count += current_frames
return {"n_frames": self.frame_count, "log_pbar": self.log_pbar}
[docs]
def register(self, trainer: Trainer, name: str = "count_frames_log"):
trainer.register_module(name, self)
trainer.register_op(
"pre_steps_log",
self,
)
def state_dict(self) -> dict:
return {"frame_count": self.frame_count}
def load_state_dict(self, state_dict) -> None:
self.frame_count = state_dict["frame_count"]
def _check_input_output_typehint(
func: Callable, input: type | list[type], output: type
):
# Placeholder for a function that checks the types input / output against expectations
return
def flatten_dict(d):
"""Flattens a dictionary with sub-dictionaries accessed through point-separated (:obj:`"var1.var2"`) fields."""
out = {}
for key, item in d.items():
if isinstance(item, dict):
item = flatten_dict(item)
for _key, _item in item.items():
out[".".join([key, _key])] = _item
else:
out[key] = item
return out
[docs]
class TargetNetUpdaterHook(TrainerHookBase):
"""A hook for target parameters update.
Examples:
>>> # define a loss module
>>> loss_module = SACLoss(actor_network, qvalue_network)
>>> # define a target network updater
>>> target_net_updater = SoftUpdate(loss_module)
>>> # define a target network updater hook
>>> target_net_updater_hook = TargetNetUpdaterHook(target_net_updater)
>>> # register the target network updater hook
>>> trainer.register_op("post_optim", target_net_updater_hook)
"""
def __init__(self, target_params_updater: TargetNetUpdater):
if not isinstance(target_params_updater, TargetNetUpdater):
raise ValueError(
f"Expected a target network updater, got {type(target_params_updater)=}"
)
self.target_params_updater = target_params_updater
def __call__(self, tensordict: TensorCollection | None = None):
self.target_params_updater.step()
return tensordict
[docs]
def register(self, trainer: Trainer, name: str):
trainer.register_op("post_steps", self)
[docs]
class ValueEstimatorHook(TrainerHookBase):
"""A hook that computes value estimates over a collected batch.
Wraps a value estimator module such as :class:`~torchrl.objectives.value.GAE`
and applies it to the whole collected batch at the ``pre_epoch`` stage, so
that advantage and value-target entries are available when the loss module
consumes sub-batches during optimization.
In async-collection mode the training loop has no batch to hand to the
``pre_epoch`` stage (``batch`` is ``None``); the hook then passes the batch
through untouched, and value estimates are expected to be computed
elsewhere (e.g. by a replay-buffer transform).
Args:
value_estimator (Callable[[TensorDictBase], TensorDictBase]): the value
estimator to apply to the collected batch, e.g. an instance of
:class:`~torchrl.objectives.value.GAE`.
Examples:
>>> gae = GAE(gamma=0.99, lmbda=0.95, value_network=critic, average_gae=True)
>>> value_estimator_hook = ValueEstimatorHook(gae)
>>> value_estimator_hook.register(trainer)
"""
def __init__(
self, value_estimator: Callable[[TensorDictBase], TensorDictBase]
) -> None:
self.value_estimator = value_estimator
def __call__(self, batch: TensorDictBase | None) -> TensorDictBase | None:
if batch is None:
return batch
return self.value_estimator(batch)
def state_dict(self) -> dict[str, Any]:
if hasattr(self.value_estimator, "state_dict"):
return {"value_estimator": self.value_estimator.state_dict()}
return {}
def load_state_dict(self, state_dict: dict[str, Any]) -> None:
if "value_estimator" in state_dict and hasattr(
self.value_estimator, "load_state_dict"
):
self.value_estimator.load_state_dict(state_dict["value_estimator"])
[docs]
def register(self, trainer: Trainer, name: str = "value_estimator"):
trainer.register_op("pre_epoch", self)
trainer.register_module(name, self)
[docs]
class LRSchedulerHook(TrainerHookBase):
"""A hook that steps a learning-rate scheduler during training.
Args:
scheduler (torch.optim.lr_scheduler.LRScheduler): the scheduler to step.
interval (Literal["batch", "optim"], optional): ``"batch"`` to step the
scheduler once per collected batch, or ``"optim"`` to step it after
every optimization step. With ``"optim"``, the number of scheduler
steps per collected batch scales with ``num_epochs`` and the number
of sub-batches per batch. Defaults to ``"batch"``.
Once registered with a trainer, the hook only steps the scheduler when at
least one optimization step has run since its last call, so the learning
rate is not decayed during warmup phases (e.g. while
``collector.init_random_frames`` has not been reached).
Examples:
>>> scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=100)
>>> lr_scheduler_hook = LRSchedulerHook(scheduler)
>>> lr_scheduler_hook.register(trainer)
"""
def __init__(
self,
scheduler: torch.optim.lr_scheduler.LRScheduler,
interval: Literal["batch", "optim"] = "batch",
) -> None:
if interval not in ("batch", "optim"):
raise ValueError(f"interval must be 'batch' or 'optim', got {interval}")
self.scheduler = scheduler
self.interval = interval
self._trainer_ref: weakref.ReferenceType[Trainer] | None = None
self._last_optim_count: int = 0
def __call__(self, batch: TensorDictBase | None = None) -> TensorDictBase | None:
trainer = self._trainer_ref() if self._trainer_ref is not None else None
if trainer is not None:
optim_count = trainer._optim_count
if optim_count == self._last_optim_count:
# no optimization step has run since the last call (e.g. during
# the init_random_frames warmup): keep the learning rate as is
return batch
self._last_optim_count = optim_count
self.scheduler.step()
return batch
def state_dict(self) -> dict[str, Any]:
return {
"scheduler": self.scheduler.state_dict(),
"last_optim_count": self._last_optim_count,
}
def load_state_dict(self, state_dict: dict[str, Any]) -> None:
self.scheduler.load_state_dict(state_dict["scheduler"])
self._last_optim_count = state_dict.get("last_optim_count", 0)
[docs]
def register(self, trainer: Trainer, name: str = "lr_scheduler"):
self._trainer_ref = weakref.ref(trainer)
dest = "post_optim" if self.interval == "optim" else "post_steps"
trainer.register_op(dest, self)
trainer.register_module(name, self)
[docs]
class UTDRHook(TrainerHookBase):
"""Hook for logging Update-to-Data (UTD) ratio during async collection.
The UTD ratio measures how many optimization steps are performed per
collected data sample, providing insight into training efficiency during
asynchronous data collection. This metric is particularly useful for
off-policy algorithms where data collection and training happen concurrently.
The UTD ratio is calculated as: (batch_size * update_count) / write_count
where:
- batch_size: Size of batches sampled from replay buffer
- update_count: Total number of optimization steps performed
- write_count: Total number of samples written to replay buffer
Args:
trainer (Trainer): The trainer instance to monitor for UTD calculation.
Must have async_collection=True for meaningful results.
Note:
This hook is only meaningful when async_collection is enabled, as it
relies on the replay buffer's write_count to track data collection progress.
"""
def __init__(self, trainer: Trainer):
self.trainer = trainer
def __call__(self, batch: TensorDictBase | None = None) -> dict:
if (
hasattr(self.trainer, "replay_buffer")
and self.trainer.replay_buffer is not None
):
write_count = self.trainer.replay_buffer.write_count
batch_size = self.trainer.replay_buffer.batch_size
else:
write_count = self.trainer.collector.getattr_rb("write_count")
batch_size = self.trainer.collector.getattr_rb("batch_size")
if not write_count:
return {}
if batch_size is None and rl_warnings():
warnings.warn("Batch size is not set. Using 1.")
batch_size = 1
update_count = self.trainer._optim_count
utd_ratio = batch_size * update_count / write_count
return {
"utd_ratio": utd_ratio,
"write_count": write_count,
"update_count": update_count,
"log_pbar": False,
}
[docs]
def register(self, trainer: Trainer, name: str = "utdr_hook"):
"""Register the UTD ratio hook with the trainer.
Args:
trainer (Trainer): The trainer to register with.
name (str): Name to use when registering the hook module.
"""
trainer.register_op("pre_steps_log", self)
trainer.register_module(name, self)
[docs]
def state_dict(self) -> dict[str, Any]:
"""Return state dictionary for checkpointing."""
return {}
[docs]
def load_state_dict(self, state_dict: dict[str, Any]) -> None:
"""Load state from dictionary."""
[docs]
class EarlyStopping(TrainerHookBase):
"""Early stopping hook for :class:`~torchrl.trainers.Trainer`.
This hook monitors a scalar metric and stops training when that metric
does not improve according to a configured criterion.
By default, the hook monitors ``"r_evaluation"``.
Args:
monitor (NestedKey, optional): Metric name to monitor.
Defaults to ``"r_evaluation"``.
mode (Literal["min", "max"], optional): One of ``"min"`` or ``"max"``.
In ``"max"`` mode, larger metric values are considered better.
Defaults to ``"max"``.
min_delta (float, optional): Minimum absolute improvement required to
qualify as better. Defaults to ``0.0``.
patience (int, optional): Maximum number of non-improving frames
allowed before stopping. Defaults to ``100_000``.
wait_for (int, optional): Number of initial frames to ignore before
checking the stopping criterion. Defaults to ``1_000_000``.
check_finite (bool, optional): If ``True``, non-finite metric values
(NaN or inf) trigger early stopping. Defaults to ``True``.
Examples:
>>> LogScalar(("next", "reward"), "r_training").register(trainer)
>>> EarlyStopping(monitor="r_training", patience=10_000).register(trainer)
"""
def __init__(
self,
*,
monitor: NestedKey = "r_evaluation",
mode: Literal["min", "max"] = "max",
min_delta: float = 0.0,
patience: int = 100_000,
wait_for: int = 1_000_000,
check_finite: bool = True,
) -> None:
if mode not in {"min", "max"}:
raise ValueError(f"mode must be either 'min' or 'max', got {mode}.")
if patience < 0:
raise ValueError(f"patience must be >= 0, got {patience}.")
if wait_for < 0:
raise ValueError(f"wait_for must be >= 0, got {wait_for}.")
self.monitor = monitor
self.mode = mode
self.min_delta = float(min_delta)
self.patience = int(patience)
self.wait_for = int(wait_for)
self.check_finite = check_finite
self.best_score: float | None = None
self.stop_reason: str | None = None
self._trainer: Trainer | None = None
self._last_improvement_frame: int | None = None
def _resolve_metric(self, trainer: Trainer) -> float:
metric_values = trainer._log_dict.get(self.monitor, None)
if not metric_values:
raise RuntimeError(
"EarlyStopping could not find monitored metric "
f"'{self.monitor}' in trainer._log_dict."
)
metric = metric_values[-1]
if isinstance(metric, torch.Tensor):
if metric.numel() != 1:
raise RuntimeError(
"EarlyStopping expects scalar metrics, "
f"got shape {tuple(metric.shape)} for '{self.monitor}'."
)
metric = float(metric.item())
else:
metric = float(metric)
return metric
def _is_improvement(self, score: float, best_score: float) -> bool:
if self.mode == "max":
return score > (best_score + self.min_delta)
return score < (best_score - self.min_delta)
def _stop(self, trainer: Trainer, reason: str) -> None:
self.stop_reason = reason
trainer.request_stop(reason)
def __call__(self, batch: TensorDictBase | None = None) -> None:
if self._trainer is not None and self._trainer._stop_training:
return
if self._trainer is None:
raise RuntimeError("EarlyStopping is not attached to a trainer.")
trainer = self._trainer
score = self._resolve_metric(trainer)
current_frame = int(trainer.collected_frames)
if current_frame < self.wait_for:
return
if self.check_finite and not math.isfinite(score):
self._stop(
trainer,
f"Monitored metric '{self.monitor}' became non-finite ({score}).",
)
return
if self.best_score is None:
self.best_score = score
self._last_improvement_frame = current_frame
return
if self._is_improvement(score, self.best_score):
self.best_score = score
self._last_improvement_frame = current_frame
else:
if current_frame - self._last_improvement_frame >= self.patience:
self._stop(
trainer,
f"Monitored metric '{self.monitor}' did not improve for "
f"{current_frame - self._last_improvement_frame} frames.",
)
def state_dict(self) -> dict[str, Any]:
return {
"best_score": self.best_score,
"stop_reason": self.stop_reason,
"_last_improvement_frame": self._last_improvement_frame,
}
def load_state_dict(self, state_dict: dict[str, Any]) -> None:
self.best_score = state_dict.get("best_score", None)
self.stop_reason = state_dict.get("stop_reason", None)
self._last_improvement_frame = state_dict.get("_last_improvement_frame", None)
[docs]
def register(self, trainer: Trainer, name: str = "early_stopping") -> None:
self._trainer = trainer
trainer.register_op("post_steps_log", self)
trainer.register_module(name, self)