Source code for embodichain.learning.rl.gradients

# ----------------------------------------------------------------------------
# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ----------------------------------------------------------------------------

"""Gradient stabilization primitives for differentiable rollouts."""

from __future__ import annotations

import torch

__all__ = ["BatchedGradientNormStats", "clip_batched_gradient_norm"]


[docs] class BatchedGradientNormStats: """Accumulate row-wise adjoint norm and clipping statistics on-device. Args: device: Device on which hook-side counters are accumulated. """
[docs] def __init__(self, device: torch.device | str) -> None: self.device = torch.device(device) self.norm_sum = torch.zeros((), device=self.device) self.norm_max = torch.zeros((), device=self.device) self.finite_rows = torch.zeros((), device=self.device) self.rows = torch.zeros((), device=self.device) self.clipped_rows = torch.zeros((), device=self.device) self.nonfinite_rows = torch.zeros((), device=self.device)
[docs] def metrics(self) -> dict[str, float]: """Return statistics after backward has invoked registered hooks. Returns: Mean and maximum pre-clip norm plus clipped/non-finite fractions. """ rows = float(self.rows) finite_rows = float(self.finite_rows) return { "action_adjoint_preclip_mean_norm": ( float(self.norm_sum) / finite_rows if finite_rows > 0.0 else 0.0 ), "action_adjoint_preclip_max_norm": float(self.norm_max), "action_adjoint_clipped_fraction": ( float(self.clipped_rows) / rows if rows > 0.0 else 0.0 ), "action_adjoint_nonfinite_fraction": ( float(self.nonfinite_rows) / rows if rows > 0.0 else 0.0 ), }
[docs] def clip_batched_gradient_norm( gradient: torch.Tensor, max_norm: float, stats: BatchedGradientNormStats | None = None, ) -> torch.Tensor: """Clip each batch row without shortening the differentiable time horizon. Norms are computed with max-absolute-value scaling to avoid overflow in float32. A non-finite row is replaced with zeros while finite rows remain independent from one another. Args: gradient: Tensor whose first dimension identifies independent rows. max_norm: Maximum L2 norm per row. Zero disables clipping. stats: Optional on-device accumulator populated before clipping. Returns: A finite tensor with the same shape, dtype, and device as ``gradient``. Raises: ValueError: If ``max_norm`` is negative or ``gradient`` is not batched. """ if max_norm < 0.0: raise ValueError("max_norm cannot be negative.") if max_norm == 0.0: return gradient if gradient.ndim < 2: raise ValueError("gradient must have a leading batch dimension.") flat = gradient.flatten(start_dim=1) finite_rows = torch.isfinite(flat).all(dim=1, keepdim=True) finite_values = torch.where(finite_rows, flat, torch.zeros_like(flat)) max_abs = finite_values.abs().amax(dim=1, keepdim=True) safe_max_abs = max_abs.clamp_min(1.0e-12) scaled_norm = (finite_values / safe_max_abs).norm(dim=1, keepdim=True) raw_norm = max_abs * scaled_norm if stats is not None: with torch.no_grad(): detached_norm = raw_norm.detach().flatten() finite = finite_rows.detach().flatten() & torch.isfinite(detached_norm) finite_norm = torch.where( finite, detached_norm, torch.zeros_like(detached_norm), ) stats.norm_sum.add_(finite_norm.sum()) stats.finite_rows.add_(finite.sum()) stats.rows.add_(detached_norm.numel()) stats.clipped_rows.add_((finite & (detached_norm > max_norm)).sum()) stats.nonfinite_rows.add_((~finite).sum()) stats.norm_max.copy_(torch.maximum(stats.norm_max, finite_norm.max())) scale = ((float(max_norm) / safe_max_abs) / scaled_norm.clamp_min(1.0)).clamp( max=1.0 ) scale = torch.where(finite_rows, scale, torch.zeros_like(scale)) broadcast_shape = (-1,) + (1,) * (gradient.ndim - 1) safe_gradient = torch.where( finite_rows.view(broadcast_shape), gradient, torch.zeros_like(gradient), ) return safe_gradient * scale.view(broadcast_shape)