Rate this Page

Source code for torchrl.modules.value_norm

# 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.
"""Value normalisation for actor-critic algorithms.

Defines the abstract :class:`ValueNorm` interface and two concrete
implementations:

- :class:`PopArtValueNorm` — exponential-moving-average mean / mean-of-squares
  with debiasing (van Hasselt et al., *Multi-task Deep RL with PopArt*,
  AAAI 2019, https://arxiv.org/abs/1809.04474). Used by MAPPO
  (Yu et al. 2022) to stabilise the critic loss when reward scales drift.
- :class:`RunningValueNorm` — exact Welford running mean / variance with no
  decay. Cheaper and more stable when value targets are stationary; tends to
  be the better default for shorter / non-curriculum runs.
- :class:`PercentileValueNorm` — exponential-moving-average of a low / high
  quantile pair, rescaling by the clamped span between them (the DreamerV3
  "return normalisation", Hafner et al. 2023,
  https://arxiv.org/abs/2301.04104). Scale-only by default, which suits
  advantage scaling.

Plug any subclass into :class:`~torchrl.objectives.multiagent.MAPPOLoss` (or
your own actor-critic loss) via ``value_norm=...``.
"""
from __future__ import annotations

import math
import warnings
from abc import ABCMeta, abstractmethod

import torch
from torch import nn


[docs] class ValueNorm(nn.Module, metaclass=ABCMeta): """Abstract base class for value normalisers. A *value normaliser* keeps a running estimate of the location and scale of the value target seen during training. Critics use it to: - **normalize** the regression target before computing MSE, keeping the critic loss on a fixed scale across episodes / reward inflations; - **denormalize** the critic's output back to the real reward scale when forming bootstrapped value estimates inside GAE / TD. Subclasses must implement :meth:`update`, :meth:`normalize`, :meth:`denormalize`, and :meth:`scale`. The convention is that they all operate on tensors whose trailing dims match :attr:`shape` (the per-element value shape, usually ``(1,)``). """ shape: tuple[int, ...] def __init__( self, *, shape: int | tuple[int, ...] = 1, epsilon: float = 1e-5, device: torch.device | None = None, ) -> None: super().__init__() if isinstance(shape, int): shape = (shape,) self.shape = tuple(shape) self.epsilon = epsilon self._device = device if type(self).scale is ValueNorm.scale: warnings.warn( f"{type(self).__name__} does not implement scale(). " "ValueNorm.scale() will become abstract in v0.16; implement it " "in your subclass to keep it instantiable.", DeprecationWarning, stacklevel=2, ) # ------------------------------------------------------------------ API
[docs] @abstractmethod def update(self, value_target: torch.Tensor) -> None: """Fold a batch of value targets into the running stats."""
[docs] @abstractmethod def normalize(self, value_target: torch.Tensor) -> torch.Tensor: """Standardise ``value_target`` using the current running stats."""
[docs] @abstractmethod def denormalize(self, normalised_value: torch.Tensor) -> torch.Tensor: """Inverse of :meth:`normalize` — recover real-scale values."""
[docs] def scale(self) -> torch.Tensor: """Multiplicative scale currently applied by :meth:`normalize`. Exposed separately so consumers can rescale quantities that must not be re-centred, e.g. advantages (already centred by the value baseline), for which only the division by the scale applies. Deliberately not abstract until v0.16 so that subclasses written before it existed keep instantiating (with a ``DeprecationWarning``); this default raises ``NotImplementedError`` when called. """ raise NotImplementedError( f"{type(self).__name__} does not implement scale(). Implement it " "to use this normaliser where a multiplicative scale is required " "(e.g. advantage scaling)." )
# ------------------------------------------------------- shared helpers def _check_trailing_shape(self, value_target: torch.Tensor) -> tuple[int, ...]: if value_target.shape[-len(self.shape) :] != self.shape: raise ValueError( f"{type(self).__name__} was initialised with shape={self.shape} " f"but got a value_target with trailing shape " f"{tuple(value_target.shape[-len(self.shape) :])}." ) return tuple(range(value_target.ndim - len(self.shape)))
[docs] class PopArtValueNorm(ValueNorm): """PopArt-style EMA value normaliser. Maintains exponentially-weighted running estimates of the value-target mean and mean-of-squares, with debiasing (so the early-training estimates are unbiased even before the EMA has had time to wash out the zero initialisation). Equivalent to the value-normaliser used by the reference MAPPO implementation. Keyword Args: shape: per-element shape of the value tensor (everything except the leading batch / time / agent dims that get reduced). Defaults to ``1``. beta: exponential decay for the running stats. Higher = slower adaptation. Defaults to ``0.99999`` (the MAPPO default). epsilon: numerical stabiliser added to the running variance and used as a floor for the debiasing term. Defaults to ``1e-5``. device: device for the running-stats buffers. Example: >>> vn = PopArtValueNorm(shape=1) >>> target = torch.randn(64, 1) * 5.0 + 2.0 # mean 2, std 5 >>> for _ in range(100): ... vn.update(target) >>> normed = vn.normalize(target) # ~ N(0, 1) >>> recovered = vn.denormalize(normed) # back to real scale """ def __init__( self, *, shape: int | tuple[int, ...] = 1, beta: float = 0.99999, epsilon: float = 1e-5, device: torch.device | None = None, ) -> None: super().__init__(shape=shape, epsilon=epsilon, device=device) self.beta = beta # Both running buffers start at zero. The debiasing term tracks # \sum_{s<=t} beta^{t-s}, which also starts at zero; dividing the # zero-init buffers by the (clamped) debias gives an unbiased EMA. self.register_buffer("running_mean", torch.zeros(self.shape, device=device)) self.register_buffer("running_mean_sq", torch.zeros(self.shape, device=device)) self.register_buffer("debiasing_term", torch.zeros((), device=device)) def _running_stats(self) -> tuple[torch.Tensor, torch.Tensor]: debias = self.debiasing_term.clamp(min=self.epsilon) mean = self.running_mean / debias mean_sq = self.running_mean_sq / debias var = (mean_sq - mean.pow(2)).clamp(min=self.epsilon) return mean, var
[docs] @torch.no_grad() def update(self, value_target: torch.Tensor) -> None: value_target = value_target.detach() reduce_dims = self._check_trailing_shape(value_target) if reduce_dims: batch_mean = value_target.mean(dim=reduce_dims) batch_mean_sq = value_target.pow(2).mean(dim=reduce_dims) else: batch_mean = value_target batch_mean_sq = value_target.pow(2) self.running_mean.mul_(self.beta).add_(batch_mean, alpha=1.0 - self.beta) self.running_mean_sq.mul_(self.beta).add_(batch_mean_sq, alpha=1.0 - self.beta) self.debiasing_term.mul_(self.beta).add_(1.0 - self.beta)
[docs] def normalize(self, value_target: torch.Tensor) -> torch.Tensor: mean, var = self._running_stats() return (value_target - mean) / var.sqrt()
[docs] def denormalize(self, normalised_value: torch.Tensor) -> torch.Tensor: mean, var = self._running_stats() return normalised_value * var.sqrt() + mean
[docs] def scale(self) -> torch.Tensor: _, var = self._running_stats() return var.sqrt()
[docs] class RunningValueNorm(ValueNorm): """Exact running mean / variance (Welford's online algorithm). Unlike :class:`PopArtValueNorm`, this normaliser does not decay older samples — it accumulates the true sample mean and variance over every target it has ever seen. Useful when value targets are roughly stationary (no curriculum, no reward-shaping schedule), where the EMA's adaptivity is unnecessary and the exact running stats give a slightly tighter estimate. Keyword Args: shape: per-element shape of the value tensor. Defaults to ``1``. epsilon: numerical stabiliser added to the running variance. Defaults to ``1e-5``. device: device for the running-stats buffers. Example: >>> vn = RunningValueNorm(shape=1) >>> for _ in range(10): ... vn.update(torch.randn(64, 1) * 3.0 + 1.0) >>> normed = vn.normalize(torch.randn(8, 1)) """ def __init__( self, *, shape: int | tuple[int, ...] = 1, epsilon: float = 1e-5, device: torch.device | None = None, ) -> None: super().__init__(shape=shape, epsilon=epsilon, device=device) self.register_buffer("mean", torch.zeros(self.shape, device=device)) # m2 stores the running sum of squared deviations from the mean # (Welford's M2). var = m2 / count (population variance — we don't # apply Bessel's correction because the consumer is normalisation, # not statistical inference). self.register_buffer("m2", torch.zeros(self.shape, device=device)) self.register_buffer("count", torch.zeros((), device=device))
[docs] @torch.no_grad() def update(self, value_target: torch.Tensor) -> None: value_target = value_target.detach() reduce_dims = self._check_trailing_shape(value_target) if reduce_dims: # ``math.prod`` over plain Python ints avoids allocating a # CPU tensor and forcing a host-device sync inside update(). batch_count = float(math.prod(value_target.shape[d] for d in reduce_dims)) batch_mean = value_target.mean(dim=reduce_dims) batch_var = value_target.var(dim=reduce_dims, unbiased=False) else: batch_count = 1.0 batch_mean = value_target batch_var = torch.zeros_like(value_target) # Chan et al. parallel variance update. delta = batch_mean - self.mean total = self.count + batch_count new_mean = self.mean + delta * (batch_count / total) new_m2 = ( self.m2 + batch_var * batch_count + delta.pow(2) * (self.count * batch_count / total) ) self.mean.copy_(new_mean) self.m2.copy_(new_m2) self.count.fill_(total)
def _var(self) -> torch.Tensor: denom = self.count.clamp(min=1.0) return (self.m2 / denom).clamp(min=self.epsilon)
[docs] def normalize(self, value_target: torch.Tensor) -> torch.Tensor: return (value_target - self.mean) / self._var().sqrt()
[docs] def denormalize(self, normalised_value: torch.Tensor) -> torch.Tensor: return normalised_value * self._var().sqrt() + self.mean
[docs] def scale(self) -> torch.Tensor: return self._var().sqrt()
[docs] class PercentileValueNorm(ValueNorm): """DreamerV3-style EMA percentile-range value normaliser. Tracks exponential moving averages of a low and a high quantile of the value targets and rescales by the span between them, clamped from below: ``scale = max(min_scale, high - low)``. Following DreamerV3 (Hafner et al., *Mastering Diverse Domains through World Models*, 2023, https://arxiv.org/abs/2301.04104), the clamp scales large values down without amplifying small or noisy ones, which keeps fixed coefficients such as an entropy bonus comparable across reward scales. By default (``center=False``) :meth:`normalize` only divides by the span — the DreamerV3 recipe for advantages, which are already centred by the value baseline. With ``center=True`` the low-percentile EMA is also subtracted, mapping the tracked percentile range onto ``[0, 1]``. Keyword Args: shape: per-element shape of the value tensor (everything except the leading batch / time / agent dims that get reduced). Defaults to ``1``. quantiles: lower and upper quantiles tracked by the EMA. Defaults to ``(0.05, 0.95)``. rate: EMA update rate towards the batch quantiles; higher = faster adaptation. Defaults to ``0.01``. min_scale: lower bound of the normalisation scale. Defaults to ``1.0``. center: if ``True``, subtract the low-percentile EMA in :meth:`normalize`. Defaults to ``False``. epsilon: kept for interface parity with the other normalisers; unused because ``min_scale`` already bounds the divisor. device: device for the running-stats buffers. Example: >>> vn = PercentileValueNorm(shape=1, rate=1.0) >>> returns = torch.linspace(0.0, 100.0, steps=101).unsqueeze(-1) >>> vn.update(returns) >>> vn.scale() tensor([90.]) >>> vn.normalize(torch.tensor([45.0])) tensor([0.5000]) """ def __init__( self, *, shape: int | tuple[int, ...] = 1, quantiles: tuple[float, float] = (0.05, 0.95), rate: float = 0.01, min_scale: float = 1.0, center: bool = False, epsilon: float = 1e-5, device: torch.device | None = None, ) -> None: super().__init__(shape=shape, epsilon=epsilon, device=device) low, high = quantiles if not 0 <= low < high <= 1: raise ValueError( f"quantiles must satisfy 0 <= low < high <= 1, got {quantiles}." ) if not 0 <= rate <= 1: raise ValueError(f"rate must be in [0, 1], got {rate}.") if min_scale <= 0: raise ValueError(f"min_scale must be positive, got {min_scale}.") self.quantiles = (float(low), float(high)) self.rate = rate self.min_scale = min_scale self.center = center self.register_buffer("low", torch.zeros(self.shape, device=device)) self.register_buffer("high", torch.zeros(self.shape, device=device)) self.register_buffer( "_q", torch.tensor([float(low), float(high)], device=device), persistent=False, )
[docs] @torch.no_grad() def update(self, value_target: torch.Tensor) -> None: value_target = value_target.detach() self._check_trailing_shape(value_target) flat = value_target.reshape(-1, *self.shape).to(self.low.dtype) batch_low, batch_high = torch.quantile( flat, self._q.to(device=flat.device, dtype=flat.dtype), dim=0 ) self.low.lerp_(batch_low, self.rate) self.high.lerp_(batch_high, self.rate)
[docs] def normalize(self, value_target: torch.Tensor) -> torch.Tensor: if self.center: return (value_target - self.low) / self.scale() return value_target / self.scale()
[docs] def denormalize(self, normalised_value: torch.Tensor) -> torch.Tensor: if self.center: return normalised_value * self.scale() + self.low return normalised_value * self.scale()
[docs] def scale(self) -> torch.Tensor: return (self.high - self.low).clamp_min(self.min_scale)