Rate this Page
★ ★ ★ ★ ★

Source code for torchrl.trainers.algorithms.offline_to_online

# 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 pathlib

from collections.abc import Callable, Iterator, Mapping
from typing import Any

from tensordict import TensorDict, TensorDictBase
from tensordict.utils import NestedKey
from torch import optim

from torchrl.checkpoint import Checkpoint, CheckpointRotation
from torchrl.collectors import BaseCollector
from torchrl.data import ReplayBuffer
from torchrl.data.replay_buffers.offline_to_online import OfflineToOnlineReplayBuffer
from torchrl.data.utils import DEVICE_TYPING
from torchrl.objectives.common import LossModule
from torchrl.objectives.utils import TargetNetUpdater
from torchrl.record.loggers import Logger
from torchrl.trainers.algorithms.sac import SACTrainer
from torchrl.trainers.trainers import Trainer, TrainerHookBase

__all__ = [
    "OfflineToOnlineReplayBufferHook",
    "OfflineToOnlineAnnealHook",
    "OfflineToOnlineTrainer",
]


class OfflineToOnlineReplayBufferHook(TrainerHookBase):
    """Trainer hook driving an :class:`~torchrl.data.OfflineToOnlineReplayBuffer`.

    Routes freshly collected experience to the online buffer on ``pre_epoch`` and
    draws a mixed offline/online batch on ``process_optim_batch``. Online
    transitions are projected onto the offline dataset's key schema before being
    stored, so the offline/online concat in
    :meth:`OfflineToOnlineReplayBuffer.sample` does not raise on the policy
    outputs (``loc``/``scale``/``log_prob``) and ``collector`` subtree the
    offline dataset lacks.

    Keyword Args:
        batch_size (int, optional): batch size for :meth:`sample`; falls back to
            the buffer's configured ``batch_size``.
        device (device, optional): device the sampled batch is moved to.
        align_to_offline_keys (bool, optional): project stored online
            transitions onto the offline schema (default ``True``).
    """

    def __init__(
        self,
        replay_buffer: ReplayBuffer | OfflineToOnlineReplayBuffer,
        *,
        batch_size: int | None = None,
        device: DEVICE_TYPING | None = None,
        align_to_offline_keys: bool = True,
    ) -> None:
        self.replay_buffer = replay_buffer
        self.batch_size = batch_size
        self.device = device
        self.align_to_offline_keys = align_to_offline_keys
        self._offline_keys = None

    def aligned_keys(self) -> list | None:
        if not self.align_to_offline_keys:
            return None
        if self._offline_keys is None:
            mixed = isinstance(self.replay_buffer, OfflineToOnlineReplayBuffer)
            offline = self.replay_buffer.offline_buffer if mixed else self.replay_buffer
            if not len(offline):
                return None
            probe = offline[0]
            self._offline_keys = [
                key for key in probe.keys(True, True) if key != "index"
            ]
        return self._offline_keys

    def extend(self, batch: TensorDictBase | None) -> TensorDictBase | None:
        if batch is None:
            return None
        if ("collector", "mask") in batch.keys(True):
            batch = batch[batch.get(("collector", "mask"))]
        else:
            batch = batch.reshape(-1)
        keys = self.aligned_keys()
        if keys is not None:
            batch = batch.select(*keys, strict=False)
        elif "collector" in batch.keys():
            batch = batch.exclude("collector")
        batch = batch.cpu()
        self.replay_buffer.extend(batch)
        return batch

    def sample(self, batch: TensorDictBase) -> TensorDictBase:
        sample = self.replay_buffer.sample(self.batch_size)
        return sample.to(self.device) if self.device is not None else sample

    def state_dict(self) -> dict:
        if not isinstance(self.replay_buffer, OfflineToOnlineReplayBuffer):
            return self.replay_buffer.state_dict()
        return {
            "online_buffer": self.replay_buffer.online_buffer.state_dict(),
            "offline_fraction": self.replay_buffer._offline_fraction,
            "base_offline_fraction": self.replay_buffer._base_offline_fraction,
        }

    def load_state_dict(self, state_dict: dict) -> None:
        if not isinstance(self.replay_buffer, OfflineToOnlineReplayBuffer):
            self.replay_buffer.load_state_dict(state_dict)
            return
        self.replay_buffer.online_buffer.load_state_dict(state_dict["online_buffer"])
        self.replay_buffer._offline_fraction = state_dict.get(
            "offline_fraction", self.replay_buffer._offline_fraction
        )
        self.replay_buffer._base_offline_fraction = state_dict.get(
            "base_offline_fraction", self.replay_buffer._base_offline_fraction
        )

    def register(self, trainer, name: str = "replay_buffer") -> None:
        trainer.register_op("pre_epoch", self.extend)
        trainer.register_op("process_optim_batch", self.sample)
        trainer.register_module(name, self)


class OfflineToOnlineAnnealHook(TrainerHookBase):
    """Linearly decays the buffer's offline sampling fraction during training.

    Once per collected batch (``post_steps``) it calls
    :meth:`OfflineToOnlineReplayBuffer.anneal` with the trainer's current
    ``collected_frames``, so sampling shifts from offline-dominant to purely
    online over ``anneal_frames`` frames.
    """

    def __init__(
        self,
        trainer,
        replay_buffer: OfflineToOnlineReplayBuffer,
        anneal_frames: int,
    ) -> None:
        self.trainer = trainer
        self.replay_buffer = replay_buffer
        self.anneal_frames = anneal_frames

    def __call__(self) -> None:
        self.replay_buffer.anneal(self.trainer.collected_frames, self.anneal_frames)

    def state_dict(self) -> dict:
        return {}

    def load_state_dict(self, state_dict: dict) -> None:
        pass

    def register(self, trainer, name: str = "offline_to_online_anneal") -> None:
        trainer.register_op("post_steps", self)
        trainer.register_module(name, self)


class OfflinePretrainingCollector:
    """Run offline updates before online collection, deferring collector creation."""

    def __init__(
        self,
        trainer: OfflineToOnlineTrainer,
        collector: BaseCollector | Callable[[], BaseCollector] | None,
    ) -> None:
        self.trainer = trainer
        self.collector = collector if isinstance(collector, BaseCollector) else None
        self.factory = collector if self.collector is None else None
        self.seed = None

    @property
    def init_random_frames(self) -> int:
        return self.collector.init_random_frames

    def __iter__(self) -> Iterator[TensorDictBase]:
        trainer = self.trainer
        while (
            trainer.completed_steps < trainer.offline_steps
            and not trainer._stop_training
        ):
            trainer.update()
            if (
                trainer.completed_steps < trainer.offline_steps
                and not trainer._stop_training
                and trainer.completed_steps % max(trainer.save_trainer_interval, 1) == 0
            ):
                trainer.save_trainer(force_save=True)
        trainer.save_trainer(force_save=True)
        if trainer._stop_training or trainer.collected_frames >= trainer.total_frames:
            return
        collector = self.get_collector()
        collector.update_policy_weights_(
            TensorDict.from_module(trainer.loss_module.actor_network)
        )
        for batch in collector:
            if ("collector", "mask") in batch.keys(True):
                batch = batch[batch["collector", "mask"]]
            if batch.batch_size.numel():
                yield batch

    def get_collector(self) -> BaseCollector:
        """Create the collector when collection or environment metadata requires it."""
        if self.collector is None:
            self.collector = self.factory()
            if self.seed is not None:
                seed, kwargs = self.seed
                self.collector.set_seed(seed, **kwargs)
        return self.collector

    def update_policy_weights_(self, weights: TensorDictBase | None = None) -> None:
        if self.collector is not None:
            if weights is None:
                weights = TensorDict.from_module(self.trainer.loss_module.actor_network)
            self.collector.update_policy_weights_(weights)

    def set_seed(self, seed: int, **kwargs) -> int:
        if self.collector is not None:
            return self.collector.set_seed(seed, **kwargs)
        self.seed = seed, kwargs
        return seed

    def getattr_env(self, name: str) -> Any:
        return self.get_collector().getattr_env(name)

    def shutdown(self) -> None:
        if self.collector is not None:
            self.collector.shutdown()

    def state_dict(self) -> dict[str, Any]:
        return {
            "collector": self.collector.state_dict()
            if self.collector is not None
            else None,
            "seed": self.seed,
        }

    def load_state_dict(self, state: dict[str, Any]) -> None:
        self.seed = state.get("seed", self.seed)
        if state["collector"] is not None:
            self.get_collector().load_state_dict(state["collector"])


[docs] class OfflineToOnlineTrainer(SACTrainer): """Train from offline replay before optional online fine-tuning. See also :class:`~torchrl.trainers.algorithms.configs.OfflineToOnlineTrainerConfig` for the Hydra configuration counterpart. Builds on :class:`~torchrl.trainers.algorithms.SACTrainer` target updates, collector weight synchronization and logging. Offline updates precede online collection within the standard Trainer lifecycle. With a mixed buffer, online collection anneals the offline sampling fraction over ``anneal_frames``. Args: collector (BaseCollector or callable, optional): online collector or a factory called after offline training, unless loss initialization requires environment metadata first. Defaults to ``None``. total_frames (int): online frames to collect. Defaults to zero. frame_skip (int): frames skipped between policy updates. Defaults to one. optim_steps_per_batch (int): updates per collected batch. Defaults to one. loss_module (LossModule): actor-critic objective with an ``actor_network``. replay_buffer (ReplayBuffer or OfflineToOnlineReplayBuffer): regular replay storage or independently sampled offline and online buffers. Keyword Args: anneal_frames (int, optional): frames over which ``offline_fraction`` decays to 0. Defaults to ``total_frames``; pass ``<= 0`` to keep the fraction fixed. batch_size (int, optional): replay-buffer sampling batch size. offline_steps (int): gradient updates before online collection (default zero). device (device, optional): device for sampled training batches. compile_loss (bool): compile the loss module, retaining its checkpoint keys. A regular replay buffer retains offline and online transitions together. A mixed offline-to-online buffer instead controls their sampling fractions. The collector may be omitted for offline-only training or supplied as a factory, created when collection or environment metadata requires it. Losses are logged by update count during pretraining and by collected frames during online training. See :class:`~torchrl.trainers.algorithms.SACTrainer` for the remaining keyword arguments. .. note:: Experimental/prototype feature; the API may change. """ def __init__( self, *, collector: BaseCollector | Callable[[], BaseCollector] | None = None, total_frames: int = 0, frame_skip: int = 1, optim_steps_per_batch: int = 1, loss_module: LossModule | Callable[[TensorDictBase], TensorDictBase], replay_buffer: ReplayBuffer | OfflineToOnlineReplayBuffer, offline_steps: int = 0, device: DEVICE_TYPING | None = None, compile_loss: bool = False, anneal_frames: int | None = None, batch_size: int | None = None, optimizer: optim.Optimizer | None = None, 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, enable_logging: bool = True, log_rewards: bool = True, log_actions: bool = True, log_observations: bool = False, target_net_updater: TargetNetUpdater | None = None, async_collection: bool = False, log_timings: bool = False, auto_log_optim_steps: bool = True, done_key: NestedKey = "done", terminated_key: NestedKey = "terminated", reward_key: NestedKey = "reward", episode_reward_key: NestedKey = "reward_sum", action_key: NestedKey = "action", observation_key: NestedKey = "observation", ) -> None: if offline_steps < 0 or total_frames < 0: raise ValueError("Training budgets must be nonnegative.") if total_frames and collector is None: raise ValueError("Online training requires a collector.") self.offline_steps = offline_steps if ( offline_steps or total_frames == 0 or not isinstance(collector, BaseCollector) ): collector = OfflinePretrainingCollector(self, collector) if async_collection: raise ValueError( "OfflineToOnlineTrainer does not support async_collection." ) super().__init__( collector=collector, total_frames=total_frames, frame_skip=frame_skip, optim_steps_per_batch=optim_steps_per_batch, loss_module=loss_module, optimizer=optimizer, logger=logger, clip_grad_norm=clip_grad_norm, clip_norm=clip_norm, progress_bar=progress_bar, seed=seed, save_trainer_interval=save_trainer_interval, log_interval=log_interval, save_trainer_file=save_trainer_file, checkpoint=checkpoint, checkpoint_rotation=checkpoint_rotation, checkpoint_metadata=checkpoint_metadata, replay_buffer=None, enable_logging=enable_logging, log_rewards=log_rewards, log_actions=log_actions, log_observations=log_observations, target_net_updater=target_net_updater, async_collection=False, log_timings=log_timings, auto_log_optim_steps=auto_log_optim_steps, done_key=done_key, terminated_key=terminated_key, reward_key=reward_key, episode_reward_key=episode_reward_key, action_key=action_key, observation_key=observation_key, ) self.replay_buffer = replay_buffer self.anneal_frames = total_frames if anneal_frames is None else anneal_frames if device is None and isinstance(replay_buffer, OfflineToOnlineReplayBuffer): device = getattr(replay_buffer.online_buffer.storage, "device", "cpu") self.device = device self.batch_size = batch_size OfflineToOnlineReplayBufferHook( replay_buffer, batch_size=batch_size, device=device ).register(self) if ( isinstance(replay_buffer, OfflineToOnlineReplayBuffer) and self.anneal_frames > 0 ): OfflineToOnlineAnnealHook(self, replay_buffer, self.anneal_frames).register( self ) self.register_module("target_net_updater", target_net_updater) self.register_op("post_optim_complete_log", self.record_update) if compile_loss: self.loss_module.compile(fullgraph=True) @property def completed_steps(self) -> int: """Number of completed offline and online updates.""" return self._optim_count @property def checkpoint_step(self) -> int: """Use update counts across both phases when pretraining is configured.""" return self.completed_steps if self.offline_steps else super().checkpoint_step def record_update(self, step: int, losses: TensorDictBase) -> None: self.metrics = losses.detach() if ( self.auto_log_optim_steps and self.collected_frames == 0 and self.logger is not None ): if step % max(self._log_interval, 1) == 0: for key, value in self.metrics.items(): if value.ndim == 0: self.logger.log_scalar(key, value.item(), step=step)
[docs] def update(self) -> TensorDictBase: """Run one replay update through the standard optimization hooks.""" self.optim_steps(None, optim_steps_per_batch=1) return self.metrics
[docs] def shutdown(self) -> None: """Release the online collector, if it was initialized.""" self.collector.shutdown()