Rate this Page

Source code for torchrl.modules.tensordict_module.zoo.microduck_policy

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

"""Task-conditioned policy architecture for MicroDuck skills."""

from __future__ import annotations

import hashlib
import importlib.util
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Any, TYPE_CHECKING

import torch
from tensordict.nn import (
    NormalParamExtractor,
    TensorDictModule,
    TensorDictModuleBase,
    TensorDictSequential,
)
from torch import nn

from torchrl.modules.distributions import TanhNormal
from torchrl.modules.tensordict_module.actors import ProbabilisticActor
from torchrl.modules.tensordict_module.rnn import GRUModule

if TYPE_CHECKING:
    from torchrl.envs.custom.mujoco.microduck import MicroDuckTask

_has_huggingface_hub = importlib.util.find_spec("huggingface_hub") is not None

RECURRENT_STATE_KEY = "recurrent_state"


class _TaskConditionedEncoder(nn.Module):
    """Encode proprioception together with the active task index."""

    def __init__(
        self,
        observation_dim: int,
        num_tasks: int,
        hidden_size: int,
        *,
        device: torch.device | str = "cpu",
    ):
        super().__init__()
        self.observation = nn.Linear(observation_dim, hidden_size, device=device)
        self.task = nn.Embedding(num_tasks, hidden_size, device=device)

    def forward(self, observation: torch.Tensor, task_id: torch.Tensor) -> torch.Tensor:
        return torch.tanh(
            self.observation(observation) + self.task(task_id.squeeze(-1))
        )


class _GaussianHead(nn.Module):
    """Gaussian joint-target head used by the published skill policies."""

    def __init__(
        self,
        hidden_size: int,
        num_actions: int,
        *,
        initial_policy_scale: float,
        device: torch.device | str = "cpu",
    ):
        super().__init__()
        self.loc = nn.Linear(hidden_size, num_actions, device=device)
        nn.init.orthogonal_(self.loc.weight, gain=0.01)
        nn.init.zeros_(self.loc.bias)
        self.scale = nn.Parameter(torch.zeros(num_actions, device=device))
        self.param_extractor = NormalParamExtractor(
            scale_mapping=f"biased_softplus_{initial_policy_scale}"
        )

    def forward(self, features: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
        loc = self.loc(features)
        return self.param_extractor(torch.cat((loc, self.scale.expand_as(loc)), -1))


[docs] class MicroDuckSkillPolicy(ProbabilisticActor): """Recurrent policy shared by a library of MicroDuck skills. The policy combines a task-conditioned observation encoder, a single-layer GRU and a Gaussian head over normalized joint targets. It is an ordinary :class:`~torchrl.modules.ProbabilisticActor`: ``task_id`` selects the skill embedding, while ``recurrent_state`` and ``is_init`` make its memory explicit in the input TensorDict. Args: hidden_size: Hidden width of the GRU and policy head. num_tasks: Number of skills indexed by the task embedding. observation_dim: Width of the proprioceptive observation. num_actions: Number of robot joints controlled by the policy. initial_policy_scale: Initial exploration standard deviation. device: Device for the policy parameters. action_low: Lower normalized action bound. action_high: Upper normalized action bound. Examples: >>> from torchrl.modules.tensordict_module.zoo import MicroDuckSkillPolicy >>> skill_policy = MicroDuckSkillPolicy( ... hidden_size=32, ... num_tasks=2, ... observation_dim=56, ... num_actions=14, ... ) >>> type(skill_policy).__name__ 'MicroDuckSkillPolicy' Pair a trained policy with its ordered task library before deployment: >>> import torch >>> from torchrl.envs import MicroDuckEnv >>> from torchrl.modules.tensordict_module.zoo import MicroDuckSkills >>> task_library = torch.stack([ ... MicroDuckEnv.standing_task(), ... MicroDuckEnv.tracking_task(0.2), ... ]) >>> skills = MicroDuckSkills(skill_policy, task_library, action_scale=1.0) .. seealso:: :class:`MicroDuckSkills` packages the policy with the task metadata needed for deployment; :class:`~torchrl.envs.MicroDuckEnv` is the joint-level environment used to train it; :class:`~torchrl.envs.MicroDuckSkillController` adapts its inputs for high-level skill decisions; and :class:`~torchrl.modules.GRUModule` provides its recurrent core. """ def __init__( self, hidden_size: int, num_tasks: int, observation_dim: int, num_actions: int, *, initial_policy_scale: float = 1.0, device: torch.device | str = "cpu", action_low: float = -1.0, action_high: float = 1.0, ): if initial_policy_scale <= 0: raise ValueError("initial_policy_scale must be positive.") embed = TensorDictModule( _TaskConditionedEncoder( observation_dim, num_tasks, hidden_size, device=device, ), in_keys=["observation", "task_id"], out_keys=["embed"], ) gru = GRUModule( input_size=hidden_size, hidden_size=hidden_size, num_layers=1, in_keys=["embed", RECURRENT_STATE_KEY, "is_init"], out_keys=["features", ("next", RECURRENT_STATE_KEY)], device=device, ) actor_head = TensorDictModule( _GaussianHead( hidden_size, num_actions, initial_policy_scale=initial_policy_scale, device=device, ), in_keys=["features"], out_keys=["loc", "scale"], ) backbone = TensorDictSequential(embed, gru) super().__init__( # Preserve the nested backbone used by the published checkpoints. module=TensorDictSequential(backbone, actor_head), in_keys=["loc", "scale"], distribution_class=TanhNormal, distribution_kwargs={"low": action_low, "high": action_high}, return_log_prob=True, ) self.hidden_size = int(hidden_size) self.num_tasks = int(num_tasks) self.observation_dim = int(observation_dim) self.num_actions = int(num_actions)
[docs] @classmethod def from_config( cls, policy_kwargs: Mapping[str, Any], *, num_tasks: int, observation_dim: int, num_actions: int, device: torch.device | str = "cpu", ) -> MicroDuckSkillPolicy: """Build the policy architecture recorded in checkpoint metadata.""" kwargs = dict(policy_kwargs) policy_head = kwargs.pop("policy_head", "gaussian") if policy_head != "gaussian": raise ValueError( "MicroDuckSkillPolicy supports only the 'gaussian' head; got " f"{policy_head!r}." ) return cls( num_tasks=num_tasks, observation_dim=observation_dim, num_actions=num_actions, device=device, **kwargs, )
[docs] @dataclass class MicroDuckSkills: """A deployable MicroDuck skill policy and its environment metadata. ``policy`` maps a task-conditioned MicroDuck observation to normalized joint targets. ``task_library`` preserves the exact meaning and order of its task embeddings. ``action_scale`` records how the joint targets were applied during training. Keeping the three together prevents a high-level environment from silently pairing a policy with incompatible task ids or motor scaling. Args: policy: Trained task-conditioned TensorDict policy. task_library: Ordered, stacked :class:`~torchrl.envs.MicroDuckTask`. action_scale: Environment-side joint-target scale used during training. Examples: Download the pinned published skills and pass the resulting object to the high-level environment rather than unpacking policy metadata: >>> from torchrl.modules.tensordict_module.zoo import MicroDuckSkills >>> skills = MicroDuckSkills.from_pretrained() # doctest: +SKIP >>> skill_policy = skills.policy # doctest: +SKIP >>> task_library = skills.task_library # doctest: +SKIP Promote compatible joint-level dynamics to a high-level environment without separating the policy from that metadata: >>> from torchrl.envs import MicroDuckSkillEnv >>> base_env = make_microduck_game_env( # doctest: +SKIP ... action_scale=skills.action_scale ... ) >>> env = MicroDuckSkillEnv.from_env( # doctest: +SKIP ... base_env, skills, control_steps_per_decision=5 ... ) .. seealso:: :class:`MicroDuckSkillPolicy` is the neural policy stored here; :class:`~torchrl.envs.MicroDuckTask` describes one row of the ordered task library; :class:`~torchrl.envs.MicroDuckEnv` supplies the joint-level training dynamics; and :class:`~torchrl.envs.MicroDuckSkillEnv` deploys the complete artifact. """ policy: TensorDictModuleBase task_library: MicroDuckTask action_scale: float DEFAULT_REPO_ID = "torchrl/microduck-skills" DEFAULT_FILENAME = "walker.ckpt" DEFAULT_REVISION = "4191d7d25c4fd58a5c6e6395fcf8217459fdd073" @staticmethod def _checkpoint_payload( source: str | Path | Mapping[str, Any], ) -> Mapping[str, Any]: if isinstance(source, Mapping): return source # Runtime import avoids modules -> render -> envs -> modules while # torchrl.modules is still being initialized. from torchrl.render import load_checkpoint path = Path(source).expanduser().resolve() return load_checkpoint(path, weights_only=True)
[docs] @classmethod def from_checkpoint( cls, checkpoint: str | Path | Mapping[str, Any], *, device: torch.device | str = "cpu", freeze: bool = True, sha256: str | None = None, ) -> MicroDuckSkills: """Rebuild the policy and deployment metadata from a checkpoint. Args: checkpoint: Local checkpoint path or an already loaded payload. device: Device for the rebuilt policy. freeze: Load in evaluation mode and disable gradients. sha256: Expected SHA-256 digest for a path. This cannot be used with an already loaded payload. """ # Runtime import avoids envs importing the modules package while the # module-zoo namespace is being initialized. from torchrl.envs.custom.mujoco.microduck import MicroDuckEnv if sha256 is not None: if isinstance(checkpoint, Mapping): raise TypeError("sha256 cannot verify an already loaded payload.") path = Path(checkpoint).expanduser().resolve() digest = hashlib.sha256(path.read_bytes()).hexdigest() if digest != sha256.lower(): raise ValueError( f"The checkpoint {path} has SHA-256 {digest}, expected {sha256}." ) payload = cls._checkpoint_payload(checkpoint) config = payload.get("config") or {} env_config = config.get("env") or {} task_specs = env_config.get("tasks", []) if not isinstance(task_specs, list): raise TypeError( "MicroDuckSkills could not read the ordered task list from " "config.env.tasks." ) if not task_specs: raise ValueError("MicroDuckSkills requires at least one task.") task_library = torch.stack( [ getattr(MicroDuckEnv, spec["preset"])( **{key: value for key, value in spec.items() if key != "preset"} ) for spec in task_specs ] ) policy = MicroDuckSkillPolicy.from_config( payload.get("policy_kwargs") or {}, num_tasks=len(task_library), observation_dim=MicroDuckEnv.OBSERVATION_DIM, num_actions=MicroDuckEnv.NUM_JOINTS, device=device, ) policy.load_state_dict(payload["model_state_dict"]) if freeze: policy.eval().requires_grad_(False) return cls( policy=policy, task_library=task_library, action_scale=float(env_config.get("action_scale", 1.0)), )
[docs] @classmethod def from_pretrained( cls, repo_id: str | None = None, *, filename: str | None = None, revision: str | None = None, device: torch.device | str = "cpu", freeze: bool = True, sha256: str | None = None, **hub_kwargs: Any, ) -> MicroDuckSkills: """Download the pinned published skills and rebuild them. Args: repo_id: Hugging Face repository. Defaults to ``"torchrl/microduck-skills"``. filename: Checkpoint path in the repository. Defaults to the historical ``"walker.ckpt"`` artifact name. revision: Immutable repository revision. Defaults to the published six-skill policy revision. device: Device for the rebuilt policy. freeze: Load in evaluation mode and disable gradients. sha256: Expected checkpoint digest. This is useful in addition to an immutable Hub revision when reproducing published results. **hub_kwargs: Extra arguments for :func:`huggingface_hub.hf_hub_download`. Returns: A :class:`MicroDuckSkills` object containing the frozen policy, ordered task library and action scale. """ if not _has_huggingface_hub: raise ImportError( "huggingface_hub is required to load MicroDuckSkills from the " "hub. Install it with `pip install huggingface-hub`." ) from huggingface_hub import hf_hub_download path = hf_hub_download( repo_id=repo_id or cls.DEFAULT_REPO_ID, filename=filename or cls.DEFAULT_FILENAME, revision=revision or cls.DEFAULT_REVISION, **hub_kwargs, ) return cls.from_checkpoint(path, device=device, freeze=freeze, sha256=sha256)