| """Base class for all robotics policies. |
| |
| This module defines the abstract base class `BasePolicy`, which standardizes |
| the interface for environment interaction (`act`), model inference (`forward`), |
| training (`compute_loss`), and device/data management. |
| """ |
|
|
| from __future__ import annotations |
|
|
| from abc import ABC, abstractmethod |
| from typing import Any, Dict, List, Mapping, Optional, Tuple, Union |
|
|
| import numpy as np |
| import torch |
| import torch.nn as nn |
|
|
| from utils.bfn_utils import str_to_torch_dtype |
|
|
| __all__ = ["BasePolicy"] |
|
|
|
|
| class BasePolicy(nn.Module, ABC): |
| """Abstract base class for robotics policies. |
| |
| Provides shared functionality for: |
| 1. Device and dtype management. |
| 2. Data conversion (Numpy <-> Torch). |
| 3. Automatic batch dimension handling. |
| 4. Action clipping and normalization hooks. |
| |
| Subclasses must implement: |
| - `forward(obs, ...)`: The core PyTorch inference logic. |
| - `compute_loss(batch)`: The training logic (optional, raises NotImplementedError by default). |
| """ |
|
|
| def __init__( |
| self, |
| action_space: Any, |
| *, |
| device: str = "cpu", |
| dtype: str = "float32", |
| clip_actions: bool = True, |
| normalizer: Optional[Any] = None, |
| ): |
| """Initializes the BasePolicy. |
| |
| Args: |
| action_space: The Gym action space (used for clipping). |
| device: Default device to place tensors on ('cpu', 'cuda'). |
| dtype: Default dtype for tensor creation ('float32', 'float16'). |
| clip_actions: Whether to clamp output actions to the action_space bounds. |
| normalizer: Optional normalization module (e.g., LinearNormalizer). |
| """ |
| super().__init__() |
| self.action_space = action_space |
| self._device = torch.device(device) |
| self._dtype = str_to_torch_dtype(dtype) |
| self.clip_actions = clip_actions |
| self.normalizer = normalizer |
|
|
| |
|
|
| @property |
| def device(self) -> torch.device: |
| """Returns the current device of the policy. |
| |
| Infers device from the first parameter if available (robust to .to() calls), |
| otherwise falls back to the initialization value. |
| """ |
| try: |
| return next(self.parameters()).device |
| except StopIteration: |
| return self._device |
|
|
| @property |
| def dtype(self) -> torch.dtype: |
| """Returns the current dtype of the policy.""" |
| try: |
| return next(self.parameters()).dtype |
| except StopIteration: |
| return self._dtype |
|
|
| |
|
|
| def set_normalizer(self, normalizer: Any) -> None: |
| """Updates the normalizer used by the policy.""" |
| self.normalizer = normalizer |
|
|
| @abstractmethod |
| def forward( |
| self, |
| obs: Union[torch.Tensor, Dict[str, torch.Tensor]], |
| *, |
| deterministic: bool = False, |
| **kwargs: Any, |
| ) -> torch.Tensor: |
| """Core inference method returning a batch of actions. |
| |
| Args: |
| obs: Observations with leading batch dimension [B, ...]. |
| deterministic: Whether to sample deterministically (policy dependent). |
| **kwargs: Additional arguments (e.g., conditioning info). |
| |
| Returns: |
| Action tensor of shape [B, ActionDim]. |
| """ |
| raise NotImplementedError |
|
|
| def compute_loss(self, batch: Any) -> torch.Tensor: |
| """Computes training loss for the policy. |
| |
| Args: |
| batch: A batch of data (usually containing 'obs', 'action'). |
| |
| Returns: |
| Scalar loss tensor. |
| |
| Raises: |
| NotImplementedError: If the policy does not support internal training logic. |
| """ |
| raise NotImplementedError( |
| f"compute_loss is not implemented for {self.__class__.__name__}." |
| ) |
|
|
| @torch.inference_mode() |
| def act( |
| self, |
| obs: Any, |
| *, |
| deterministic: bool = False, |
| return_torch: bool = False, |
| **kwargs: Any, |
| ) -> Union[np.ndarray, torch.Tensor]: |
| """Convenience wrapper for environment interaction. |
| |
| Handles: |
| 1. conversion of numpy obs -> torch tensors. |
| 2. adding batch dimension if missing. |
| 3. inference via `forward`. |
| 4. clipping actions. |
| 5. conversion of torch output -> numpy action (optional). |
| |
| Args: |
| obs: Observation from the environment (Numpy array, Dict, or Tensor). |
| deterministic: Whether to use deterministic mode. |
| return_torch: If True, returns a Tensor on device; else returns Numpy array. |
| **kwargs: Passed to `forward`. |
| |
| Returns: |
| Action (Numpy array or Tensor). |
| """ |
| |
| obs_t = self._to_tensor(obs) |
|
|
| |
| |
| obs_t, batch_added = self._maybe_add_batch_dim(obs_t) |
|
|
| |
| action = self.forward(obs_t, deterministic=deterministic, **kwargs) |
|
|
| |
| action = self._clip_actions(action) |
|
|
| |
| if return_torch: |
| return action |
|
|
| |
| if batch_added: |
| action = action.squeeze(0) |
|
|
| return action.detach().cpu().numpy() |
|
|
| |
|
|
| def _to_tensor(self, data: Any) -> Any: |
| """Recursively converts input data to tensors on the correct device/dtype.""" |
| if isinstance(data, torch.Tensor): |
| return data.to(device=self.device, dtype=self.dtype) |
|
|
| if isinstance(data, Mapping): |
| return {k: self._to_tensor(v) for k, v in data.items()} |
|
|
| if isinstance(data, (list, tuple)): |
| return type(data)(self._to_tensor(v) for v in data) |
|
|
| |
| return torch.as_tensor(data, device=self.device, dtype=self.dtype) |
|
|
| def _maybe_add_batch_dim(self, obs: Any) -> Tuple[Any, bool]: |
| """Adds a leading batch dimension if the input appears to be unbatched. |
| |
| Note: This uses a heuristic. If the input is a Tensor, we assume it is |
| unbatched if it matches the observation space shape (not implemented here generic enough) |
| OR we rely on the caller context (usually `act` is single-step). |
| |
| Here, we unconditionally unsqueeze dim 0 for `act` convenience. |
| """ |
| batch_added = False |
|
|
| if isinstance(obs, torch.Tensor): |
| |
| |
| |
| obs = obs.unsqueeze(0) |
| batch_added = True |
|
|
| elif isinstance(obs, Mapping): |
| |
| |
| new_obs = {} |
| for k, v in obs.items(): |
| if isinstance(v, torch.Tensor): |
| new_obs[k] = v.unsqueeze(0) |
| batch_added = True |
| else: |
| new_obs[k] = v |
| obs = new_obs |
|
|
| return obs, batch_added |
|
|
| def _clip_actions(self, action: torch.Tensor) -> torch.Tensor: |
| """Clips actions to the environment bounds if `clip_actions` is True.""" |
| if not self.clip_actions: |
| return action |
|
|
| |
| if not hasattr(self.action_space, "low") or not hasattr( |
| self.action_space, "high" |
| ): |
| return action |
|
|
| |
| low = torch.as_tensor( |
| self.action_space.low, device=action.device, dtype=action.dtype |
| ) |
| high = torch.as_tensor( |
| self.action_space.high, device=action.device, dtype=action.dtype |
| ) |
|
|
| return torch.clamp(action, low, high) |
|
|