Source code for wbc_mjlab.env.mdp.sampling

"""RSI adaptive sampling helpers for ``MotionCommand``.

``RsiCfg.strategy`` selects the episode failure signal:

- ``binary_failure`` — failure on early termination (BeyondMimic RSI).
- ``similarity_ema`` — failure from mean per-step tracking similarity (Zest RSI).
"""

from __future__ import annotations

import math
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Literal

import numpy as np
import torch
from mjlab.utils.lab_api.math import sample_uniform

if TYPE_CHECKING:
  from wbc_mjlab.env.mdp.commands import MotionCommand

AdaptiveSimilarityTerm = Literal[
  "joint_pos",
  "anchor_pos",
  "anchor_ori",
  "body_pos",
  "body_ori",
  "body_lin_vel",
  "body_ang_vel",
]

AdaptiveSamplingStrategy = Literal["binary_failure", "similarity_ema"]

DEFAULT_SIMILARITY_STDS: dict[AdaptiveSimilarityTerm, float] = {
  "joint_pos": 1.0,
  "anchor_pos": 0.3,
  "anchor_ori": 0.4,
  "body_pos": 0.3,
  "body_ori": 0.4,
  "body_lin_vel": 1.0,
  "body_ang_vel": 3.14,
}


[docs] @dataclass class AdaptiveSimilarityTermCfg: """One exp-kernel term in the per-step RSI similarity score ``s_k``.""" term: AdaptiveSimilarityTerm weight: float = 1.0 std: float | None = None
[docs] @dataclass class TrackingSimilarityState: """Robot vs reference errors for one similarity step.""" tracked_joint_pos_error: torch.Tensor anchor_pos_error: torch.Tensor anchor_ori_error: torch.Tensor body_pos_error: torch.Tensor body_ori_error: torch.Tensor body_lin_vel_error: torch.Tensor body_ang_vel_error: torch.Tensor
[docs] def joint_pos_similarity_preset() -> tuple[AdaptiveSimilarityTermCfg, ...]: """Hand-tuned RSI similarity: joint position only.""" return (AdaptiveSimilarityTermCfg(term="joint_pos"),)
[docs] @dataclass class RsiCfg: """Reference-state initialization (RSI) and adaptive bin sampling.""" sampling_mode: Literal["adaptive", "uniform", "start"] = "adaptive" strategy: AdaptiveSamplingStrategy = "similarity_ema" similarity_terms: tuple[AdaptiveSimilarityTermCfg, ...] = field( default_factory=joint_pos_similarity_preset ) bin_width_s: float = 4.0 uniform_ratio: float = 0.15 alpha: float = 0.005 temperature_base: float = 1.0 # Optional curriculum fidelity (off in ``BASE_RSI_CFG``; enable per task). similarity_norm_by_remaining_clip: bool = False min_bin_span_ratio: float = 0.0 persist_failure_levels: bool = False failure_levels_filename: str = "rsi_bin_stats.npz" # When True, per-step similarity follows weighted ``motion_*`` reward terms # (same kernels and weights as training) instead of ``similarity_terms``. similarity_from_rewards: bool = False tracking_reward_prefix: str = "motion_"
[docs] def keybody_similarity_preset() -> tuple[AdaptiveSimilarityTermCfg, ...]: """Hand-tuned RSI similarity: anchor + keybody tracking terms.""" return ( AdaptiveSimilarityTermCfg(term="joint_pos", weight=1.0), AdaptiveSimilarityTermCfg(term="anchor_pos", weight=0.5), AdaptiveSimilarityTermCfg(term="anchor_ori", weight=0.5), AdaptiveSimilarityTermCfg(term="body_pos", weight=1.0), AdaptiveSimilarityTermCfg(term="body_ori", weight=1.0), )
def compile_similarity_terms( term_cfgs: tuple[AdaptiveSimilarityTermCfg, ...], ) -> tuple[list[tuple[AdaptiveSimilarityTerm, float, float]], float]: terms: list[tuple[AdaptiveSimilarityTerm, float, float]] = [] weight_sum = 0.0 for term_cfg in term_cfgs: if term_cfg.weight <= 0.0: continue std = term_cfg.std if term_cfg.std is not None else DEFAULT_SIMILARITY_STDS[term_cfg.term] terms.append((term_cfg.term, term_cfg.weight, std)) weight_sum += term_cfg.weight return terms, weight_sum def bin_index_for_frame( *, segment_start_idx: torch.Tensor, time_steps: torch.Tensor, trajectory_ids: torch.Tensor, bin_width_frames: int, bins_per_trajectory: int, ) -> torch.Tensor: seg_start = segment_start_idx[trajectory_ids] local_frames = torch.clamp(time_steps - seg_start, min=0) return torch.clamp(local_frames // bin_width_frames, max=bins_per_trajectory - 1) def resolve_tracking_reward_indices( reward_manager, *, name_prefix: str = "motion_", ) -> tuple[list[int], float]: """Reward-term indices and weight sum used for reward-aligned RSI similarity.""" indices: list[int] = [] weight_sum = 0.0 for idx, (name, term_cfg) in enumerate( zip(reward_manager._term_names, reward_manager._term_cfgs, strict=False) ): if not name.startswith(name_prefix) or term_cfg.weight <= 0.0: continue indices.append(idx) weight_sum += term_cfg.weight return indices, weight_sum def step_tracking_reward_similarity( step_reward: torch.Tensor, indices: list[int], weight_sum: float, ) -> torch.Tensor: """Weighted mean of per-step tracking reward values in ``[0, 1]``.""" if not indices or weight_sum <= 0.0: return torch.zeros(step_reward.shape[0], device=step_reward.device) tracking_sum = step_reward[:, indices].sum(dim=1) return (tracking_sum / weight_sum).clamp(0.0, 1.0) def step_tracking_similarity( terms: list[tuple[AdaptiveSimilarityTerm, float, float]], weight_sum: float, state: TrackingSimilarityState, *, num_envs: int, device: torch.device | str, ) -> torch.Tensor: """Weighted mean of exp tracking kernels (per-step ``s_k``).""" if not terms: return torch.ones(num_envs, device=device) weight_sum = max(weight_sum, 1.0e-6) similarity = torch.zeros(num_envs, device=device) for term, weight, std in terms: std = max(std, 1.0e-6) if term == "joint_pos": error = torch.sum(torch.square(state.tracked_joint_pos_error), dim=-1) kernel = torch.exp(-error / std**2) elif term == "anchor_pos": error = torch.sum(torch.square(state.anchor_pos_error), dim=-1) kernel = torch.exp(-error / std**2) elif term == "anchor_ori": kernel = torch.exp(-state.anchor_ori_error**2 / std**2) elif term == "body_pos": kernel = torch.exp(-state.body_pos_error.mean(-1) / std**2) elif term == "body_ori": kernel = torch.exp(-state.body_ori_error.mean(-1) / std**2) elif term == "body_lin_vel": kernel = torch.exp(-state.body_lin_vel_error.mean(-1) / std**2) else: kernel = torch.exp(-state.body_ang_vel_error.mean(-1) / std**2) similarity += weight * kernel return similarity / weight_sum def build_bin_valid_mask( segment_length: torch.Tensor, *, bins_per_trajectory: int, bin_width_frames: int, min_bin_span_ratio: float, device: torch.device | str, ) -> torch.Tensor: """Mark valid (trajectory, bin) cells for adaptive sampling.""" num_trajectories = int(segment_length.shape[0]) mask = torch.zeros( num_trajectories, bins_per_trajectory, dtype=torch.bool, device=device ) min_span_frames = 0 if min_bin_span_ratio > 0.0: min_span_frames = max(1, int(round(min_bin_span_ratio * bin_width_frames))) for traj_idx in range(num_trajectories): seg_len = int(segment_length[traj_idx].item()) if min_span_frames > 0 and seg_len < min_span_frames: continue n_bins = max(1, int(math.ceil(seg_len / float(bin_width_frames)))) for bin_idx in range(min(n_bins, bins_per_trajectory)): if min_span_frames > 0: bin_start = bin_idx * bin_width_frames span = min(bin_width_frames, seg_len - bin_start) if span < min_span_frames: continue mask[traj_idx, bin_idx] = True return mask def update_failure_ema( bin_failure_levels: torch.Tensor, *, strategy: AdaptiveSamplingStrategy, bins_per_trajectory: int, alpha: float, traj_ids: torch.Tensor, start_bins: torch.Tensor, episode_terminated: torch.Tensor | None, episode_similarity_sum: torch.Tensor | None, episode_step_count: torch.Tensor | None, similarity_denom: torch.Tensor | None = None, norm_by_remaining_clip: bool = False, ) -> None: """EMA update of per-(trajectory, bin) failure level.""" if strategy == "binary_failure": assert episode_terminated is not None if not torch.any(episode_terminated): return traj_ids = traj_ids[episode_terminated] start_bins = start_bins[episode_terminated] episode_failure = torch.ones(traj_ids.shape[0], device=bin_failure_levels.device) else: assert episode_similarity_sum is not None and episode_step_count is not None if norm_by_remaining_clip: assert similarity_denom is not None denom = torch.clamp(similarity_denom.float(), min=1.0) mean_similarity = episode_similarity_sum / denom else: episode_length = torch.clamp(episode_step_count.float(), min=1.0) mean_similarity = episode_similarity_sum / episode_length episode_failure = 1.0 - torch.clamp(mean_similarity, 0.0, 1.0) bin_count = bin_failure_levels.numel() flat_idx = traj_ids * bins_per_trajectory + start_bins failure_sum = torch.zeros(bin_count, device=bin_failure_levels.device) failure_count = torch.zeros(bin_count, device=bin_failure_levels.device) failure_sum.scatter_add_(0, flat_idx, episode_failure) failure_count.scatter_add_(0, flat_idx, torch.ones_like(episode_failure)) update_mask = failure_count > 0 if not torch.any(update_mask): return mean_failure = failure_sum[update_mask] / failure_count[update_mask] flat_levels = bin_failure_levels.view(-1) flat_levels[update_mask] = ( (1.0 - alpha) * flat_levels[update_mask] + alpha * mean_failure )
[docs] def sample_adaptive_bins( bin_failure_levels: torch.Tensor, valid_bin_indices: torch.Tensor, *, segment_length: torch.Tensor, segment_start_idx: torch.Tensor, bin_width_frames: int, temperature_base: float, uniform_ratio: float, num_samples: int, device: torch.device | str, ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: """Draw (trajectory, bin, frame) from failure-weighted bin distribution.""" valid = valid_bin_indices temperature = temperature_base / math.log(1.0 + max(1, valid.shape[0])) logits = bin_failure_levels[valid[:, 0], valid[:, 1]] / temperature probs_valid = torch.softmax(logits, dim=0) num_valid = max(1, valid.shape[0]) probs_valid = (1.0 - uniform_ratio) * probs_valid + uniform_ratio / float(num_valid) sampled_valid = torch.multinomial(probs_valid, num_samples, replacement=True) traj_ids = valid[sampled_valid, 0] bins = valid[sampled_valid, 1] seg_lengths = segment_length[traj_ids].float() bin_starts = bins.float() * float(bin_width_frames) bin_spans = torch.minimum( torch.full_like(seg_lengths, float(bin_width_frames)), torch.clamp(seg_lengths - bin_starts, min=1.0), ) local_frames = ( bin_starts + sample_uniform(0.0, 1.0, (num_samples,), device=device) * bin_spans ).long() local_frames = torch.clamp(local_frames, max=(seg_lengths.long() - 1).clamp(min=0)) time_steps = segment_start_idx[traj_ids] + local_frames return traj_ids, bins, time_steps, probs_valid
def compute_sampling_prob_matrix( bin_failure_levels: torch.Tensor, bin_valid_mask: torch.Tensor, *, temperature_base: float, uniform_ratio: float, ) -> torch.Tensor: """Per-(trajectory, bin) sampling probability (same policy as ``sample_adaptive_bins``).""" valid = bin_valid_mask.nonzero(as_tuple=False) probs = torch.zeros_like(bin_failure_levels) if valid.numel() == 0: return probs num_valid = max(1, int(valid.shape[0])) temperature = temperature_base / math.log(1.0 + num_valid) logits = bin_failure_levels[valid[:, 0], valid[:, 1]] / temperature probs_valid = torch.softmax(logits, dim=0) probs_valid = (1.0 - uniform_ratio) * probs_valid + uniform_ratio / float(num_valid) probs[valid[:, 0], valid[:, 1]] = probs_valid return probs def trajectory_conditional_prob_row( prob_matrix: torch.Tensor, valid_mask: torch.Tensor, traj_id: int, ) -> list[float]: """Renormalize global bin probabilities over valid bins on one trajectory.""" row = prob_matrix[traj_id] mask = valid_mask[traj_id] total = float(row[mask].sum().item()) if total <= 0.0: return row.detach().cpu().tolist() out = row.clone() out[mask] = row[mask] / total return out.detach().cpu().tolist() def compute_assist_gain_matrix( bin_failure_levels: torch.Tensor, *, eta: float, beta_max: float, enabled: bool, ) -> torch.Tensor: """Per-bin assistive wrench scale β from failure levels (Zest curriculum).""" if not enabled: return torch.zeros_like(bin_failure_levels) similarity = 1.0 - bin_failure_levels return torch.clamp(1.0 - similarity / max(eta, 1.0e-6), 0.0, beta_max) def rsi_failure_signal_label( strategy: AdaptiveSamplingStrategy, *, similarity_from_rewards: bool, ) -> str: if strategy == "binary_failure": return "EMA(1 if early terminated)" if similarity_from_rewards: return "EMA(1 − mean motion-reward similarity)" return "EMA(1 − mean tracking similarity)"
[docs] def save_rsi_bin_stats(path: str | Path, command: MotionCommand) -> Path: """Write adaptive RSI bin failure levels to *path* (``.npz``).""" out = Path(path) out.parent.mkdir(parents=True, exist_ok=True) rsi = command.cfg.rsi levels = command.bin_failure_levels.detach().cpu().numpy() np.savez( out, failure_levels=levels, failure=levels, valid_mask=command.bin_valid_mask.detach().cpu().numpy(), visit_counts=command.bin_visit_counts.detach().cpu().numpy(), bin_width_s=np.array([rsi.bin_width_s], dtype=np.float64), alpha=np.array([rsi.alpha], dtype=np.float64), uniform_ratio=np.array([rsi.uniform_ratio], dtype=np.float64), num_trajectories=np.array([command.motion.num_trajectories], dtype=np.int64), bins_per_trajectory=np.array([command.bins_per_trajectory], dtype=np.int64), ) return out
[docs] def load_rsi_bin_stats( path: str | Path, command: MotionCommand, *, strict: bool = False, ) -> bool: """Restore bin failure levels from *path*. Returns False if the file is missing.""" src = Path(path) if not src.is_file(): return False data = np.load(src) if "failure_levels" in data: failure_levels = data["failure_levels"] elif "failure" in data: failure_levels = data["failure"] else: msg = f"RSI bin stats missing failure_levels array: {src}" if strict: raise ValueError(msg) print(f"[WARN] {msg}") return False valid = data["valid_mask"] if failure_levels.shape != command.bin_failure_levels.shape: msg = ( f"RSI bin stats shape mismatch: file {failure_levels.shape}, " f"env {tuple(command.bin_failure_levels.shape)}" ) if strict: raise ValueError(msg) print(f"[WARN] {msg}") return False command.bin_failure_levels.copy_( torch.as_tensor(failure_levels, dtype=torch.float32, device=command.device) ) if valid.shape == command.bin_valid_mask.shape: command.bin_valid_mask.copy_( torch.as_tensor(valid, dtype=torch.bool, device=command.device) ) command._valid_bin_indices = command.bin_valid_mask.nonzero(as_tuple=False) if "visit_counts" in data and data["visit_counts"].shape == command.bin_visit_counts.shape: command.bin_visit_counts.copy_( torch.as_tensor(data["visit_counts"], dtype=torch.float32, device=command.device) ) return True