Source code for protomotions.agents.ppo.model
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""PPO model implementation with actor-critic architecture.
This module implements the neural network models for Proximal Policy Optimization.
The actor outputs a Gaussian policy distribution, and the critic estimates state values.
Key Classes:
- PPOActor: Policy network with Gaussian action distribution
- PPOModel: Complete actor-critic model for PPO
"""
import torch
from torch import distributions, nn
from tensordict import TensorDict
from protomotions.agents.common.common import ModuleContainer
from protomotions.agents.ppo.config import PPOActorConfig, PPOModelConfig, StdMode
from protomotions.agents.base_agent.model import (
BaseModel,
ProtoMotionsTensorDictModule,
)
[docs]
class PPOActor(ProtoMotionsTensorDictModule):
"""PPO policy network (actor).
Self-contained policy that computes distribution parameters, samples actions,
and computes log probabilities all in a single forward pass.
Args:
config: Actor configuration including network architecture and initial log std.
Attributes:
logstd: Log standard deviation parameter (typically fixed during training).
mu: Neural network that outputs action means.
in_keys: List of input keys from mu model.
out_keys: List of output keys (action, mean_action, neglogp).
"""
[docs]
def __init__(self, config: PPOActorConfig):
super().__init__()
self.config = config
shape = (config.num_out,)
if config.std_mode == StdMode.PER_MOTION:
assert config.num_motions is not None, (
"Training setup must fill num_motions before actor construction"
)
if config.num_motions <= 0:
raise ValueError(
"Per-motion std requires num_motions from training setup"
)
shape = (config.num_motions, config.num_out)
self.logstd = nn.Parameter(
torch.full(shape, config.actor_logstd, dtype=torch.float32),
requires_grad=self.config.std_mode != StdMode.FIXED,
)
self.project_logstd()
self.mu = self._build_submodule(self.config.mu_model)
self.in_keys = self.config.in_keys
self.out_keys = self.config.out_keys
for key in ["action", "mean_action", "neglogp"]:
assert (
key in self.out_keys
), f"PPOActor output key {key} not in out_keys {self.out_keys}"
@torch.no_grad()
def project_logstd(self):
"""Project learned exploration after each update; fixed std remains unchanged."""
if self.logstd.requires_grad:
self.logstd.clamp_(self.config.min_logstd, self.config.max_logstd)
[docs]
def experience_buffer_keys(self):
keys = super().experience_buffer_keys() + ["action_std"]
if self.config.std_mode == StdMode.PER_MOTION:
keys.append("global_motion_id")
return list(dict.fromkeys(keys))
[docs]
def forward_deployable(
self,
tensordict: TensorDict,
log_internals: bool = False,
) -> TensorDict:
"""Compute the deterministic policy without sampling exploration noise."""
if isinstance(self.mu, ProtoMotionsTensorDictModule):
tensordict = self.mu(tensordict, log_internals=log_internals)
else:
tensordict = self.mu(tensordict)
tensordict["mean_action"] = tensordict[self.config.mu_key]
return tensordict
@property
def deployable_in_keys(self) -> list:
"""The mean path reads the mu model's inputs, not the actor's full set."""
return list(self.mu.in_keys)
[docs]
def forward(
self,
tensordict: TensorDict,
log_internals: bool = False,
) -> TensorDict:
"""Compute the mean, sample an action and return its negative log probability."""
tensordict = self.forward_deployable(tensordict, log_internals)
mu = tensordict["mean_action"]
logstd = self.logstd
if self.config.std_mode == StdMode.PER_MOTION:
logstd = logstd[tensordict["global_motion_id"]]
std = logstd.exp().expand_as(mu)
# Sample action from distribution
# mu * 0 + std broadcasts std to match mu's batch shape while preserving
# the computational graph when std is learnable
dist = distributions.Normal(mu, mu * 0 + std)
action = dist.sample()
# Compute negative log probability
neglogp = -dist.log_prob(action).sum(dim=-1)
# Store all outputs
tensordict["action"] = action
tensordict["mean_action"] = mu
tensordict["neglogp"] = neglogp
tensordict["action_std"] = std
return tensordict
[docs]
class PPOModel(BaseModel):
"""Complete PPO model with actor and critic networks.
Pure forward function that computes all model outputs in TensorDict.
The forward pass adds action distribution parameters and value estimates.
Args:
config: Model configuration specifying actor and critic architectures.
Attributes:
_actor: Policy network.
_critic: Value network.
"""
config: PPOModelConfig
[docs]
def experience_buffer_keys(self):
return list(
dict.fromkeys(
super().experience_buffer_keys() + self._actor.experience_buffer_keys()
)
)
[docs]
def __init__(self, config: PPOModelConfig):
super().__init__(config)
# create networks
self._actor: PPOActor = self._build_submodule(self.config.actor)
self._critic: ModuleContainer = self._build_submodule(self.config.critic)
# Set in_keys from actor (actor inherits from mu model)
actor_critic_in_keys = list(set(self._actor.in_keys + self._critic.in_keys))
actor_critic_out_keys = list(set(self._actor.out_keys + self._critic.out_keys))
for key in actor_critic_out_keys:
assert (
key in self.config.out_keys
), f"PPOModel output key {key} not in out_keys {self.config.out_keys}"
for key in actor_critic_in_keys:
assert (
key in self.config.in_keys
), f"PPOModel input key {key} not in in_keys {self.config.in_keys}"
self.in_keys = self.config.in_keys
self.out_keys = self.config.out_keys
[docs]
def forward(
self,
tensordict: TensorDict,
log_internals: bool = False,
) -> TensorDict:
"""Forward pass through actor and critic.
Computes all outputs:
- action: Sampled action
- mean_action: Deterministic action (mean)
- neglogp: Negative log probability of sampled action
- value: State value estimate
Args:
tensordict: TensorDict containing observations.
Returns:
TensorDict with all model outputs added.
"""
# Actor forward: adds action, mean_action, neglogp
tensordict = self._actor(tensordict, log_internals=log_internals)
# Critic forward: adds value estimate
tensordict = self._critic(tensordict)
return tensordict
[docs]
def forward_deployable(self, tensordict: TensorDict) -> TensorDict:
"""The deployable graph is the actor's mean path; the critic is not exported."""
return self._actor.forward_deployable(tensordict)
@property
def deployable_in_keys(self) -> list:
return list(self._actor.deployable_in_keys)
@property
def deployable_action_key(self) -> str:
return self._actor.deployable_action_key