humanoid_skateboard/rsl_rl/utils/utils.py

361 lines
15 KiB
Python

# Copyright (c) 2021-2025, ETH Zurich and NVIDIA CORPORATION
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
import git
import importlib
import os
import pathlib
import torch
import warnings
from tensordict import TensorDict
from typing import Callable
import numpy as np
class RunningMeanStd:
def __init__(self, epsilon: float = 1e-4, shape: Tuple[int, ...] = ()):
"""
Calculates the running mean and std of a data stream
https://en.wikipedia.org/wiki/Algorithms_for_calculating_variance#Parallel_algorithm
:param epsilon: helps with arithmetic issues
:param shape: the shape of the data stream's output
"""
self.mean = np.zeros(shape, np.float64)
self.var = np.ones(shape, np.float64)
self.count = epsilon
def update(self, arr: np.ndarray) -> None:
batch_mean = np.mean(arr, axis=0)
batch_var = np.var(arr, axis=0)
batch_count = arr.shape[0]
self.update_from_moments(batch_mean, batch_var, batch_count)
def update_from_moments(self, batch_mean: np.ndarray, batch_var: np.ndarray, batch_count: int) -> None:
delta = batch_mean - self.mean
tot_count = self.count + batch_count
new_mean = self.mean + delta * batch_count / tot_count
m_a = self.var * self.count
m_b = batch_var * batch_count
m_2 = m_a + m_b + np.square(delta) * self.count * batch_count / (self.count + batch_count)
new_var = m_2 / (self.count + batch_count)
new_count = batch_count + self.count
self.mean = new_mean
self.var = new_var
self.count = new_count
class Normalizer(RunningMeanStd):
def __init__(self, input_dim, epsilon=1e-4, clip_obs=10.0):
super().__init__(shape=input_dim)
self.epsilon = epsilon
self.clip_obs = clip_obs
def normalize(self, input):
return np.clip((input - self.mean) / np.sqrt(self.var + self.epsilon), -self.clip_obs, self.clip_obs)
def normalize_torch(self, input, device):
mean_torch = torch.tensor(self.mean, device=device, dtype=torch.float32)
std_torch = torch.sqrt(torch.tensor(self.var + self.epsilon, device=device, dtype=torch.float32))
return torch.clamp((input - mean_torch) / std_torch, -self.clip_obs, self.clip_obs)
def update_normalizer(self, rollouts, expert_loader):
policy_data_generator = rollouts.feed_forward_generator_amp(None, mini_batch_size=expert_loader.batch_size)
expert_data_generator = expert_loader.dataset.feed_forward_generator_amp(expert_loader.batch_size)
for expert_batch, policy_batch in zip(expert_data_generator, policy_data_generator):
self.update(torch.vstack(tuple(policy_batch) + tuple(expert_batch)).cpu().numpy())
def resolve_nn_activation(act_name: str) -> torch.nn.Module:
"""Resolves the activation function from the name.
Args:
act_name: The name of the activation function.
Returns:
The activation function.
Raises:
ValueError: If the activation function is not found.
"""
act_dict = {
"elu": torch.nn.ELU(),
"selu": torch.nn.SELU(),
"relu": torch.nn.ReLU(),
"crelu": torch.nn.CELU(),
"lrelu": torch.nn.LeakyReLU(),
"tanh": torch.nn.Tanh(),
"sigmoid": torch.nn.Sigmoid(),
"softplus": torch.nn.Softplus(),
"gelu": torch.nn.GELU(),
"swish": torch.nn.SiLU(),
"mish": torch.nn.Mish(),
"identity": torch.nn.Identity(),
}
act_name = act_name.lower()
if act_name in act_dict:
return act_dict[act_name]
else:
raise ValueError(f"Invalid activation function '{act_name}'. Valid activations are: {list(act_dict.keys())}")
def resolve_optimizer(optimizer_name: str) -> torch.optim.Optimizer:
"""Resolves the optimizer from the name.
Args:
optimizer_name: The name of the optimizer.
Returns:
The optimizer.
Raises:
ValueError: If the optimizer is not found.
"""
optimizer_dict = {
"adam": torch.optim.Adam,
"adamw": torch.optim.AdamW,
"sgd": torch.optim.SGD,
"rmsprop": torch.optim.RMSprop,
}
optimizer_name = optimizer_name.lower()
if optimizer_name in optimizer_dict:
return optimizer_dict[optimizer_name]
else:
raise ValueError(f"Invalid optimizer '{optimizer_name}'. Valid optimizers are: {list(optimizer_dict.keys())}")
def split_and_pad_trajectories(
tensor: torch.Tensor | TensorDict, dones: torch.Tensor
) -> tuple[torch.Tensor | TensorDict, torch.Tensor]:
"""Splits trajectories at done indices. Then concatenates them and pads with zeros up to the length of the longest
trajectory. Returns masks corresponding to valid parts of the trajectories.
Example:
Input: [[a1, a2, a3, a4 | a5, a6],
[b1, b2 | b3, b4, b5 | b6]]
Output:[[a1, a2, a3, a4], | [[True, True, True, True],
[a5, a6, 0, 0], | [True, True, False, False],
[b1, b2, 0, 0], | [True, True, False, False],
[b3, b4, b5, 0], | [True, True, True, False],
[b6, 0, 0, 0]] | [True, False, False, False]]
Assumes that the input has the following order of dimensions: [time, number of envs, additional dimensions]
"""
dones = dones.clone()
dones[-1] = 1
# Permute the buffers to have order (num_envs, num_transitions_per_env, ...), for correct reshaping
flat_dones = dones.transpose(1, 0).reshape(-1, 1)
# Get length of trajectory by counting the number of successive not done elements
done_indices = torch.cat((flat_dones.new_tensor([-1], dtype=torch.int64), flat_dones.nonzero()[:, 0]))
trajectory_lengths = done_indices[1:] - done_indices[:-1]
trajectory_lengths_list = trajectory_lengths.tolist()
# Extract the individual trajectories
if isinstance(tensor, TensorDict):
padded_trajectories = {}
for k, v in tensor.items():
# split the tensor into trajectories
trajectories = torch.split(v.transpose(1, 0).flatten(0, 1), trajectory_lengths_list)
# add at least one full length trajectory
trajectories = trajectories + (torch.zeros(v.shape[0], *v.shape[2:], device=v.device),)
# pad the trajectories to the length of the longest trajectory
padded_trajectories[k] = torch.nn.utils.rnn.pad_sequence(trajectories)
# remove the added tensor
padded_trajectories[k] = padded_trajectories[k][:, :-1]
padded_trajectories = TensorDict(
padded_trajectories, batch_size=[tensor.batch_size[0], len(trajectory_lengths_list)]
)
else:
# split the tensor into trajectories
trajectories = torch.split(tensor.transpose(1, 0).flatten(0, 1), trajectory_lengths_list)
# add at least one full length trajectory
trajectories = trajectories + (torch.zeros(tensor.shape[0], *tensor.shape[2:], device=tensor.device),)
# pad the trajectories to the length of the longest trajectory
padded_trajectories = torch.nn.utils.rnn.pad_sequence(trajectories)
# remove the added tensor
padded_trajectories = padded_trajectories[:, :-1]
# create masks for the valid parts of the trajectories
trajectory_masks = trajectory_lengths > torch.arange(0, tensor.shape[0], device=tensor.device).unsqueeze(1)
return padded_trajectories, trajectory_masks
def unpad_trajectories(trajectories, masks):
"""Does the inverse operation of split_and_pad_trajectories()"""
# Need to transpose before and after the masking to have proper reshaping
return (
trajectories.transpose(1, 0)[masks.transpose(1, 0)]
.view(-1, trajectories.shape[0], trajectories.shape[-1])
.transpose(1, 0)
)
def store_code_state(logdir, repositories) -> list:
git_log_dir = os.path.join(logdir, "git")
os.makedirs(git_log_dir, exist_ok=True)
file_paths = []
for repository_file_path in repositories:
try:
repo = git.Repo(repository_file_path, search_parent_directories=True)
t = repo.head.commit.tree
except Exception:
print(f"Could not find git repository in {repository_file_path}. Skipping.")
# skip if not a git repository
continue
# get the name of the repository
repo_name = pathlib.Path(repo.working_dir).name
diff_file_name = os.path.join(git_log_dir, f"{repo_name}.diff")
# check if the diff file already exists
if os.path.isfile(diff_file_name):
continue
# write the diff file
print(f"Storing git diff for '{repo_name}' in: {diff_file_name}")
with open(diff_file_name, "x", encoding="utf-8") as f:
content = f"--- git status ---\n{repo.git.status()} \n\n\n--- git diff ---\n{repo.git.diff(t)}"
f.write(content)
# add the file path to the list of files to be uploaded
file_paths.append(diff_file_name)
return file_paths
def string_to_callable(name: str) -> Callable:
"""Resolves the module and function names to return the function.
Args:
name: The function name. The format should be 'module:attribute_name'.
Raises:
ValueError: When the resolved attribute is not a function.
ValueError: When unable to resolve the attribute.
Returns:
The function loaded from the module.
"""
try:
mod_name, attr_name = name.split(":")
mod = importlib.import_module(mod_name)
callable_object = getattr(mod, attr_name)
# check if attribute is callable
if callable(callable_object):
return callable_object
else:
raise ValueError(f"The imported object is not callable: '{name}'")
except AttributeError as e:
msg = (
"We could not interpret the entry as a callable object. The format of input should be"
f" 'module:attribute_name'\nWhile processing input '{name}', received the error:\n {e}."
)
raise ValueError(msg)
def resolve_obs_groups(
obs: TensorDict, obs_groups: dict[str, list[str]], default_sets: list[str]
) -> dict[str, list[str]]:
"""Validates the observation configuration and defaults missing observation sets.
The input is an observation dictionary `obs` containing observation groups and a configuration dictionary
`obs_groups` where the keys are the observation sets and the values are lists of observation groups.
The configuration dictionary could for example look like:
{
"policy": ["group_1", "group_2"],
"critic": ["group_1", "group_3"]
}
This means that the 'policy' observation set will contain the observations "group_1" and "group_2" and the
'critic' observation set will contain the observations "group_1" and "group_3". This function will check that all
the observations in the 'policy' and 'critic' observation sets are present in the observation dictionary from the
environment.
Additionally, if one of the `default_sets`, e.g. "critic", is not present in the configuration dictionary,
this function will:
1. Check if a group with the same name exists in the observations and assign this group to the observation set.
2. If 1. fails, it will assign the observations from the 'policy' observation set to the default observation set.
Args:
obs: Observations from the environment in the form of a dictionary.
obs_groups: Observation sets configuration.
default_sets: Reserved observation set names used by the algorithm (besides 'policy').
If not provided in 'obs_groups', a default behavior gets triggered.
Returns:
The resolved observation groups.
Raises:
ValueError: If any observation set is an empty list.
ValueError: If any observation set contains an observation term that is not present in the observations.
"""
# check if policy observation set exists
if "policy" not in obs_groups.keys():
if "policy" in obs:
obs_groups["policy"] = ["policy"]
warnings.warn(
"The observation configuration dictionary 'obs_groups' must contain the 'policy' key."
" As an observation group with the name 'policy' was found, this is assumed to be the observation set."
" Consider adding the 'policy' key to the 'obs_groups' dictionary for clarity."
" This behavior will be removed in a future version."
)
else:
raise ValueError(
"The observation configuration dictionary 'obs_groups' must contain the 'policy' key."
f" Found keys: {list(obs_groups.keys())}"
)
# check all observation sets for valid observation groups
for set_name, groups in obs_groups.items():
# check if the list is empty
if len(groups) == 0:
msg = f"The '{set_name}' key in the 'obs_groups' dictionary can not be an empty list."
if set_name in default_sets:
if set_name not in obs:
msg += " Consider removing the key to default to the observations used for the 'policy' set."
else:
msg += (
f" Consider removing the key to default to the observation '{set_name}' from the environment."
)
raise ValueError(msg)
# check groups exist inside the observations from the environment
for group in groups:
if group not in obs:
raise ValueError(
f"Observation '{group}' in observation set '{set_name}' not found in the observations from the"
f" environment. Available observations from the environment: {list(obs.keys())}"
)
# fill missing observation sets
for default_set_name in default_sets:
if default_set_name not in obs_groups.keys():
if default_set_name in obs:
obs_groups[default_set_name] = [default_set_name]
warnings.warn(
f"The observation configuration dictionary 'obs_groups' must contain the '{default_set_name}' key."
f" As an observation group with the name '{default_set_name}' was found, this is assumed to be the"
f" observation set. Consider adding the '{default_set_name}' key to the 'obs_groups' dictionary for"
" clarity. This behavior will be removed in a future version."
)
else:
obs_groups[default_set_name] = obs_groups["policy"].copy()
warnings.warn(
f"The observation configuration dictionary 'obs_groups' must contain the '{default_set_name}' key."
f" As the configuration for '{default_set_name}' is missing, the observations from the 'policy' set"
f" are used. Consider adding the '{default_set_name}' key to the 'obs_groups' dictionary for"
" clarity. This behavior will be removed in a future version."
)
# print the final parsed observation sets
print("-" * 80)
print("Resolved observation sets: ")
for set_name, groups in obs_groups.items():
print("\t", set_name, ": ", groups)
print("-" * 80)
return obs_groups