sysid: named ic construction, bug fixes, docstrings, README, more tests

Co-authored-by: Kevin Zakka <kevinarmandzakka@gmail.com>
This commit is contained in:
Levi Burner
2026-02-10 15:15:17 -05:00
parent 210cf86486
commit e89dae359e
15 changed files with 768 additions and 1630 deletions
+10 -11
View File
@@ -35,7 +35,16 @@ def save_results(
opt_result: scipy_optimize.OptimizeResult,
residual_fn,
):
"""Save optimization results and confidence intervals to disk."""
"""Save optimization results and confidence intervals to disk.
Args:
experiment_results_folder: Directory where results are written.
models_sequences: Model/sequence groups; identified XMLs are saved here.
initial_params: Parameters before optimization.
opt_params: Parameters after optimization.
opt_result: Scipy OptimizeResult from the optimizer.
residual_fn: Residual function used to compute confidence intervals.
"""
experiment_results_folder = pathlib.Path(experiment_results_folder)
if not experiment_results_folder.exists():
experiment_results_folder.mkdir(parents=True, exist_ok=True)
@@ -70,13 +79,3 @@ def save_results(
model_sequences.spec.to_file(
(experiment_results_folder / f"{model_sequences.name}.xml").as_posix()
)
# Log nominal compared to initial.
x0 = initial_params.as_vector()
x_nominal = initial_params.as_nominal_vector()
logging.info(
"Initial Parameters\n%s",
initial_params.compare_parameters(
x0, opt_result.x, measured_params=x_nominal
),
)
+63 -8
View File
@@ -111,7 +111,12 @@ def is_position_actuator(actuator) -> bool:
def get_actuator_pd_gains(
model: mujoco.MjModel, actuator_name: str
) -> tuple[float, float]:
"""Return the (P, D) gains of a position actuator."""
"""Return the (P, D) gains of a position actuator.
Args:
model: MuJoCo model.
actuator_name: Name of the actuator.
"""
actuator_id = mujoco.mj_name2id(
model, mujoco.mjtObj.mjOBJ_ACTUATOR.value, actuator_name
)
@@ -128,7 +133,13 @@ def apply_pgain(
actuator_name: str,
value: float | np.ndarray,
) -> mujoco.MjSpec:
"""Set the proportional gain for a position actuator."""
"""Set the proportional gain for a position actuator.
Args:
spec: MuJoCo model specification.
actuator_name: Name of the actuator.
value: Proportional gain value.
"""
# TODO(b/0): assert scalar
actuator = _get_obj_or_raise(spec, "actuator", actuator_name)
assert isinstance(actuator, mujoco.MjsActuator)
@@ -144,7 +155,13 @@ def apply_dgain(
actuator_name: str,
value: float | np.ndarray,
) -> mujoco.MjSpec:
"""Set the derivative gain for a position actuator."""
"""Set the derivative gain for a position actuator.
Args:
spec: MuJoCo model specification.
actuator_name: Name of the actuator.
value: Derivative gain value.
"""
# TODO(b/0): assert scalar
actuator = _get_obj_or_raise(spec, "actuator", actuator_name)
assert isinstance(actuator, mujoco.MjsActuator)
@@ -159,7 +176,13 @@ def apply_pdgain(
actuator_name: str,
value: np.ndarray,
) -> mujoco.MjSpec:
"""Set both proportional and derivative gains for a position actuator."""
"""Set both proportional and derivative gains for a position actuator.
Args:
spec: MuJoCo model specification.
actuator_name: Name of the actuator.
value: 2-element array ``[P_gain, D_gain]``.
"""
if value.size != 2:
raise ValueError(f"pdgain must be a 2-element array, got {value.size}.")
apply_pgain(spec, actuator_name, value[0])
@@ -174,7 +197,16 @@ def apply_body_mass_ipos(
ipos: np.ndarray | None = None,
rot_inertia_scale: bool = False,
) -> mujoco.MjSpec:
"""Apply mass and center-of-mass position to a body."""
"""Apply mass and center-of-mass position to a body.
Args:
spec: MuJoCo model specification.
body_name: Name of the body.
mass: Optional new mass value.
ipos: Optional new center-of-mass position.
rot_inertia_scale: If True, scale rotational inertia proportionally to
mass change.
"""
# TODO(b/0): assert mass and ipos shapes
body = _infer_inertial(spec, body_name)
mass_original = body.mass
@@ -200,7 +232,18 @@ def scale_body_inertia(
def pi_from_theta(theta: np.ndarray) -> np.ndarray:
"""Convert base parameters θ to inertial parameters π."""
"""Convert base parameters θ to inertial parameters π.
Args:
theta: 10-D array [alpha, d1, d2, d3, s12, s23, s13, t1, t2, t3] where:
alpha: Scale parameter (log of U[3,3])
[d1, d2, d3]: Log of diagonal elements
[s12, s23, s13]: Shear parameters from upper triangle
[t1, t2, t3]: Translation parameters from last column
Returns:
10-D array π = [m, hx, hy, hz, Ixx, Iyy, Izz, Ixy, Iyz, Ixz].
"""
alpha, d1, d2, d3, s12, s23, s13, t1, t2, t3 = theta
exp_alpha = np.exp(alpha)
exp_d1 = np.exp(d1)
@@ -361,7 +404,13 @@ def apply_body_theta_inertia(
body_name: str,
theta: np.ndarray,
) -> mujoco.MjSpec:
"""Apply base-parameter inertia θ to a body in the spec."""
"""Apply base-parameter inertia θ to a body in the spec.
Args:
spec: MuJoCo model specification.
body_name: Name of the body.
theta: 10-element array [alpha, d1, d2, d3, s12, s23, s13, t1, t2, t3].
"""
if theta.size != 10:
raise ValueError(f"theta must be a 10-element array, got {theta.size}.")
pi = pi_from_theta(theta)
@@ -392,7 +441,13 @@ def apply_body_theta_inertia(
def apply_body_inertia(spec: mujoco.MjSpec, name: str, param: Parameter):
"""Apply inertia parameters to a body based on the parameter type."""
"""Apply inertia parameters to a body based on the parameter type.
Args:
spec: MuJoCo model specification.
name: Name of the body.
param: Parameter with an ``inertia_type`` attribute.
"""
if not hasattr(param, "inertia_type"):
raise ValueError(
f"Parameter {param.name} does not have inertia_type attribute."
+28 -3
View File
@@ -44,7 +44,7 @@ def _scipy_least_squares(
jac_arg: str | Callable[..., Any]
if use_mujoco_jac:
# This is the default step sized for finite difference used in
# This is the default step size for finite difference used in
# scipy's least_squares and mujoco's minimize finite difference
# https://github.com/scipy/scipy/blob/91e18f3bd355477b
# 8b7747ec82d70ac98ffd2422/scipy/optimize/_numdiff.py#L404
@@ -143,6 +143,7 @@ def optimize(
initial_params: parameter.ParameterDict,
residual_fn: Callable[..., Any],
optimizer: Literal["scipy", "mujoco", "scipy_parallel_fd"] = "mujoco",
verbose: bool = True,
**optimizer_kwargs,
) -> tuple[parameter.ParameterDict, scipy_optimize.OptimizeResult]:
"""Run nonlinear least-squares optimization on the residual.
@@ -153,11 +154,12 @@ def optimize(
returned by :func:`build_residual_fn`.
optimizer: Backend — ``"mujoco"`` (default), ``"scipy"``, or
``"scipy_parallel_fd"`` (scipy with MuJoCo finite-difference Jacobian).
verbose: If True, log parameter comparison table after optimization.
**optimizer_kwargs: Forwarded to the backend (e.g. ``max_iters``,
``verbose``, ``loss``).
Returns:
``(opt_params, opt_result)`` — the optimised ParameterDict and a
``(opt_params, opt_result)`` — the optimized ParameterDict and a
``scipy.optimize.OptimizeResult`` with at least ``x``, ``jac``, ``grad``.
"""
x0 = initial_params.as_vector()
@@ -187,6 +189,16 @@ def optimize(
opt_params.update_from_vector(opt_result.x)
if verbose:
logging.info(
"\n%s",
opt_params.compare_parameters(
initial_params.as_vector(),
opt_params.as_vector(),
measured_params=initial_params.as_nominal_vector(),
),
)
return opt_params, opt_result
@@ -197,7 +209,20 @@ def calculate_intervals(
lambda_zero_thresh=1e-15,
v_zero_thresh=1e-8,
):
"""Calculate confidence intervals from the Jacobian at the optimum."""
"""Calculate confidence intervals from the Jacobian at the optimum.
Args:
residuals_star: List of residual arrays at the optimum.
J: Jacobian matrix at the optimum, shape ``(n_residuals, n_params)``.
alpha: Significance level for the confidence intervals.
lambda_zero_thresh: Threshold below which eigenvalues are treated as zero.
v_zero_thresh: Threshold below which eigenvector elements are treated as
zero.
Returns:
``(Sigma_X, intervals)`` — the parameter covariance matrix and the
half-width confidence intervals for each parameter.
"""
if J is None or J.size == 0:
return np.empty((0, 0)), np.empty((0,))
+27 -5
View File
@@ -105,6 +105,11 @@ class Parameter:
return self.nominal.flatten()
def update_from_vector(self, vector: np.ndarray) -> None:
"""Update the current value from a flat vector.
Args:
vector: Flat array of length ``self.size``.
"""
vector_array = np.atleast_1d(vector)
if len(vector_array) != self.size:
raise ValueError(
@@ -125,7 +130,11 @@ class Parameter:
self.value = self.nominal.copy()
def sample(self, rng: np.random.Generator | None = None) -> np.ndarray:
"""Sample a random value uniformly within bounds."""
"""Sample a random value uniformly within bounds.
Args:
rng: Optional numpy random generator. Uses default if None.
"""
if rng is None:
rng = np.random.default_rng()
return rng.uniform(self.min_value.flatten(), self.max_value.flatten())
@@ -197,7 +206,7 @@ class ParameterDict:
"""An ordered collection of :class:`Parameter` objects.
Behaves like a ``dict[str, Parameter]`` with convenience methods for
vectorised access (``as_vector`` / ``update_from_vector``), serialisation,
vectorized access (``as_vector`` / ``update_from_vector``), serialization,
and tabular comparison of parameter estimates.
Frozen parameters are silently skipped by vector/bounds methods so that the
@@ -271,7 +280,12 @@ class ParameterDict:
return np.concatenate(vectors) if vectors else np.array([])
def update_from_vector(self, vector: np.ndarray) -> None:
"""Update all non-frozen parameters from a flat vector."""
"""Update all non-frozen parameters from a flat vector.
Args:
vector: Flat array whose length equals the total size of non-frozen
parameters.
"""
start = 0
for param in self.parameters.values():
if not param.frozen:
@@ -333,14 +347,22 @@ class ParameterDict:
param.reset()
def sample(self, rng: np.random.Generator | None = None) -> np.ndarray:
"""Sample parameter values within bounds for non-frozen parameters."""
"""Sample parameter values within bounds for non-frozen parameters.
Args:
rng: Optional numpy random generator. Uses default if None.
"""
if rng is None:
rng = np.random.default_rng()
lower_bounds, upper_bounds = self.get_bounds()
return rng.uniform(lower_bounds, upper_bounds)
def randomize(self, rng: np.random.Generator | None = None) -> None:
"""Randomize parameter values for non-frozen parameters."""
"""Randomize parameter values for non-frozen parameters.
Args:
rng: Optional numpy random generator. Uses default if None.
"""
for param in self.parameters.values():
if not param.frozen:
param.value = param.sample(rng)
-617
View File
@@ -19,627 +19,10 @@ from __future__ import annotations
from collections.abc import Sequence
from matplotlib.lines import Line2D
import matplotlib.pyplot as plt
import mujoco
from mujoco.sysid._src import parameter
import numpy as np
def plot_sensor_comparison(
model: mujoco.MjModel,
predicted_times: np.ndarray | None = None,
predicted_data: np.ndarray | None = None,
real_data: np.ndarray | None = None,
real_times: np.ndarray | None = None,
preid_data: np.ndarray | None = None,
preid_times: np.ndarray | None = None,
commanded_data: np.ndarray | None = None,
commanded_times: np.ndarray | None = None,
size_factor: float = 1.0,
title_prefix: str = "",
sensor_ids: list[int] | None = None,
):
"""Plots sensor trajectories from simulation and real data.
Args:
model: The model object providing sensor information.
predicted_times: Optional 1D array of timestamps corresponding to
simulation data.
predicted_data: Optional 2D array of simulation sensor data with shape
(num_timesteps, sensor_data_dimension).
real_data: Optional 2D array of real sensor data with the same shape as
predicted_data.
real_times: A 1D array of timestamps corresponding to real data. If None
and real_data is provided, the first available timestamp array is used.
preid_data: Optional 2D array of pre-identification sensor data.
preid_times: A 1D array of timestamps for pre-identification data.
commanded_data: Optional 2D array of commanded sensor data.
commanded_times: A 1D array of timestamps for commanded data.
size_factor: A scaling factor for the figure size.
title_prefix: Optional prefix for subplot titles.
sensor_ids: Optional list of sensor indices to plot.
"""
# Define a more appealing color palette
predicted_color = "#1f77b4" # Steel blue
real_color = "#ff7f0e" # Safety orange
preid_color = "#2ca02c" # Forest green
commanded_color = "#9467bd" # Purple
# Determine the reference time array to use
reference_times = None
if predicted_times is not None:
reference_times = predicted_times
elif real_times is not None:
reference_times = real_times
elif preid_times is not None:
reference_times = preid_times
elif commanded_times is not None:
reference_times = commanded_times
else:
raise ValueError("At least one time array must be provided")
# Set times for data sources that don't have their own time arrays
if real_data is not None and real_times is None:
real_times = reference_times
if preid_data is not None and preid_times is None:
preid_times = reference_times
if commanded_data is not None and commanded_times is None:
commanded_times = reference_times
if predicted_data is not None and predicted_times is None:
predicted_times = reference_times
if sensor_ids is None:
sensor_ids = list(range(model.nsensor))
assert predicted_data is not None
n_plots = predicted_data.shape[1]
fig, axes = plt.subplots(
n_plots,
1,
figsize=(10 * size_factor, 2.5 * n_plots * size_factor),
sharex=True,
)
if n_plots == 1:
axes = [axes]
axes = list(axes) # pyright: ignore[reportArgumentType]
# Set an overall title for the figure.
fig.suptitle(title_prefix + " Sensors", fontsize=14) # , y=1.02)
# Loop over each sensor.
plot_i = 0
sensor_dim = 1
j = 0
dim_str = ""
for sensor_id in sensor_ids:
sensor = model.sensor(sensor_id)
sensor_name = sensor.name
sensor_dim = int(sensor.dim[0])
sensor_addr = int(sensor.adr[0])
for j in range(sensor_dim):
ax = axes[plot_i]
plot_i += 1
dim_str = "" if sensor_dim == 1 else f" {j}"
if predicted_data is not None:
assert predicted_times is not None
predicted_signal = predicted_data[
:, sensor_addr : sensor_addr + sensor_dim
]
ax.plot(
predicted_times,
predicted_signal[:, j],
lw=2,
color=predicted_color,
alpha=0.8,
label="Sim" + dim_str,
)
if real_data is not None:
assert real_times is not None
real_signal = real_data[:, sensor_addr : sensor_addr + sensor_dim]
ax.plot(
real_times,
real_signal[:, j],
lw=2,
color=real_color,
linestyle="--",
alpha=0.7,
label="Real" + dim_str,
)
if preid_data is not None:
assert preid_times is not None
preid_signal = preid_data[:, sensor_addr : sensor_addr + sensor_dim]
ax.plot(
preid_times,
preid_signal[:, j],
lw=2,
color=preid_color,
linestyle=":",
alpha=0.6,
label="Pre-ID" + dim_str,
)
if commanded_data is not None:
assert commanded_times is not None
commanded_signal = commanded_data[
:, sensor_addr : sensor_addr + sensor_dim
]
ax.plot(
commanded_times,
commanded_signal[:, j],
lw=2,
color=commanded_color,
linestyle="-.",
alpha=0.6,
label="Commanded" + dim_str,
)
# Place the sensor name in a white box in the top-left corner.
ax.text(
0.02,
0.9,
sensor_name + dim_str,
transform=ax.transAxes,
fontsize=10,
weight="bold",
verticalalignment="top",
horizontalalignment="left",
bbox=dict(facecolor="white", alpha=0.8, edgecolor="none"),
)
# Enable a dashed grid.
ax.grid(True, linestyle="--", alpha=0.7)
# Loop over "extra" sensors from the user
for _ in range(plot_i, n_plots):
sensor_name = "user_sensor"
dim_str = "" if sensor_dim == 1 else f" {j}"
ax = axes[plot_i]
plot_i += 1
if predicted_data is not None:
assert predicted_times is not None
predicted_signal = predicted_data[:, plot_i - 1]
ax.plot(
predicted_times,
predicted_signal,
lw=2,
color=predicted_color,
alpha=0.8,
label="Sim",
)
if real_data is not None:
assert real_times is not None
real_signal = real_data[:, plot_i - 1]
ax.plot(
real_times,
real_signal,
lw=2,
color=real_color,
linestyle="--",
alpha=0.7,
label="Real",
)
if preid_data is not None:
assert preid_times is not None
preid_signal = preid_data[:, plot_i - 1]
ax.plot(
preid_times,
preid_signal,
lw=2,
color=preid_color,
linestyle=":",
alpha=0.6,
label="Pre-ID",
)
if commanded_data is not None:
assert commanded_times is not None
commanded_signal = commanded_data[:, plot_i - 1]
ax.plot(
commanded_times,
commanded_signal,
lw=2,
color=commanded_color,
linestyle="-.",
alpha=0.6,
label="Commanded",
)
# Place the sensor name in a white box in the top-left corner.
ax.text(
0.02,
0.9,
sensor_name + dim_str,
transform=ax.transAxes,
fontsize=10,
weight="bold",
verticalalignment="top",
horizontalalignment="left",
bbox=dict(facecolor="white", alpha=0.8, edgecolor="none"),
)
# Enable a dashed grid.
ax.grid(True, linestyle="--", alpha=0.7)
# Add a unified, figure-level legend if any data is provided.
legend_handles = []
if predicted_data is not None:
legend_handles.append(
Line2D([0], [0], color=predicted_color, lw=2, label="Simulation")
)
if real_data is not None:
legend_handles.append(
Line2D([0], [0], color=real_color, lw=2, linestyle="--", label="Real")
)
if preid_data is not None:
legend_handles.append(
Line2D([0], [0], color=preid_color, lw=2, linestyle=":", label="Pre-ID")
)
if commanded_data is not None:
legend_handles.append(
Line2D(
[0],
[0],
color=commanded_color,
lw=2,
linestyle="-.",
label="Commanded",
)
)
if legend_handles:
fig.legend(
handles=legend_handles,
loc="upper center",
bbox_to_anchor=(0.5, 0.935),
ncol=len(legend_handles),
fancybox=True,
shadow=True,
fontsize=10,
title="Data Source",
)
fig.supxlabel("Time (s)", fontsize=8)
plt.tight_layout(rect=(0, 0.03, 1, 0.9))
def plot_objective(
objective: Sequence[float],
figsize: tuple[float, float] = (8, 5),
):
"""Plot the objective value over optimization iterations."""
plt.figure(figsize=figsize)
plt.plot(objective, linewidth=2, marker="o", markersize=4)
final_value = objective[-1]
if abs(final_value) < 1e-3 or abs(final_value) > 1e3:
final_str = f"{final_value:.2e}"
else:
final_str = f"{final_value:.4f}"
plt.title(f"Objective Over Time (Final: {final_str})", fontsize=14, pad=10)
plt.grid(True, linestyle="--", alpha=0.6)
plt.xlabel("Iteration", fontsize=12)
plt.ylabel("Objective", fontsize=12)
plt.xticks(fontsize=10)
plt.yticks(fontsize=10)
plt.tight_layout()
def plot_candidate(
candidate: Sequence[np.ndarray],
bounds: (
tuple[Sequence[float] | np.ndarray, Sequence[float] | np.ndarray] | None
) = None,
param_names: Sequence[str] | None = None,
figsize: tuple[float, float] = (12, 2.5),
dims_per_page: int = 6,
log_diff: bool = True,
bound_eps: float = 1e-3,
):
"""Plot candidate parameter values and their diffs over iterations."""
values = np.array(candidate) # shape: (n_iter, n_dim)
n_iter, n_dim = values.shape
diffs = np.diff(values, axis=0)
mins = np.full(n_dim, -np.inf)
maxs = np.full(n_dim, np.inf)
if bounds is not None:
mins = np.array(bounds[0])
maxs = np.array(bounds[1])
assert mins.shape == (n_dim,) and maxs.shape == (n_dim,)
if param_names is not None:
assert len(param_names) == n_dim
# TODO(b/0) support pages, they are currently broken because
# saving to disk overwrites the pages
# n_pages = math.ceil(n_dim / dims_per_page)
n_pages = 1
for _page in range(n_pages):
# start = page * dims_per_page
# end = min((page + 1) * dims_per_page, n_dim)
start = 0
end = n_dim
dims_in_page = end - start
fig, axes = plt.subplots(
dims_in_page,
2,
figsize=(figsize[0], figsize[1] * dims_in_page),
sharex="col",
)
if dims_in_page == 1:
axes = np.expand_dims(axes, 0)
for i, dim in enumerate(range(start, end)):
label = param_names[dim] if param_names is not None else f"Dim {dim}"
ax_val, ax_diff = axes[i]
vals = values[:, dim]
ax_val.set_ylabel(label, fontsize=10)
ax_val.grid(True, linestyle="--", alpha=0.6)
ax_val.tick_params(labelsize=9)
if bounds is not None:
lower, upper = mins[dim], maxs[dim]
ax_val.axhspan(lower, upper, color="gray", alpha=0.08)
ax_val.plot(
[0, n_iter - 1],
[lower, lower],
color="gray",
linestyle="--",
alpha=0.3,
linewidth=1,
)
ax_val.plot(
[0, n_iter - 1],
[upper, upper],
color="gray",
linestyle="--",
alpha=0.3,
linewidth=1,
)
near_lower = np.abs(vals - lower) < bound_eps
near_upper = np.abs(vals - upper) < bound_eps
near_bound = near_lower | near_upper
for t in range(1, n_iter):
is_near_prev = near_bound[t - 1]
is_near_curr = near_bound[t]
color = "#d62728" if is_near_prev and is_near_curr else "#1f77b4"
ax_val.plot(
[t - 1, t], [vals[t - 1], vals[t]], color=color, linewidth=2
)
ax_val.plot(t, vals[t], marker="o", markersize=3, color=color)
# Overlay triangle markers for near-bound points
for t in range(n_iter):
if near_lower[t]:
ax_val.plot(t, vals[t], marker="v", markersize=6, color="#d62728")
elif near_upper[t]:
ax_val.plot(t, vals[t], marker="^", markersize=6, color="#d62728")
else:
ax_val.plot(vals, linewidth=2, marker="o", markersize=3)
# Annotate final value
final_val = vals[-1]
final_str = (
f"{final_val:.2e}"
if abs(final_val) < 1e-3 or abs(final_val) > 1e3
else f"{final_val:.4f}"
)
ax_val.text(
n_iter - 1,
final_val,
final_str,
ha="right",
va="bottom",
fontsize=9,
color="blue",
)
# Annotate final value.
final_val = values[-1, dim]
final_str = (
f"{final_val:.2e}"
if abs(final_val) < 1e-3 or abs(final_val) > 1e3
else f"{final_val:.4f}"
)
ax_val.text(
n_iter - 1,
final_val,
final_str,
ha="right",
va="bottom",
fontsize=9,
color="blue",
)
# Plot diffs
if log_diff:
eps = 1e-12
ax_diff.plot(
np.log10(np.abs(diffs[:, dim]) + eps),
linewidth=2,
marker="x",
markersize=4,
color="tab:orange",
)
ax_diff.set_ylabel("log Δ", fontsize=9)
else:
ax_diff.plot(
diffs[:, dim],
linewidth=2,
marker="x",
markersize=4,
color="tab:orange",
)
ax_diff.grid(True, linestyle="--", alpha=0.6)
ax_diff.tick_params(labelsize=9)
# Set common labels/titles
axes[-1, 0].set_xlabel("Iteration", fontsize=12)
axes[-1, 1].set_xlabel("Iteration", fontsize=12)
axes[0, 0].set_title("Candidate Value", fontsize=12)
axes[0, 1].set_title("Δ Candidate (Diff)", fontsize=12)
fig.suptitle(
f"Candidate Values and Changes (Dims {start}-{end - 1})", fontsize=14
)
fig.tight_layout(rect=(0, 0, 1, 0.96))
def plot_candidate_heatmap(
candidate: Sequence[np.ndarray],
param_names: Sequence[str] | None = None,
bounds: (
tuple[Sequence[float] | np.ndarray, Sequence[float] | np.ndarray] | None
) = None,
normalize: bool = True,
figsize: tuple[float, float] = (10, 6),
cmap: str = "RdBu",
show_colorbar: bool = True,
bound_eps: float = 1e-3,
):
"""Plot a heatmap of candidate parameter values over iterations."""
data = np.array(candidate).T # shape: (n_dim, n_iter)
n_dim = data.shape[0]
if normalize and bounds is not None:
min_bounds, max_bounds = bounds
assert len(min_bounds) == len(max_bounds) == n_dim
norm_data = np.empty_like(data)
for i in range(n_dim):
min_val = min_bounds[i]
max_val = max_bounds[i]
denom = max_val - min_val if max_val > min_val else 1.0
norm_data[i] = (data[i] - min_val) / denom
else:
norm_data = data
fig, ax = plt.subplots(figsize=figsize)
im = ax.imshow(norm_data, aspect="auto", cmap=cmap)
ax.set_xlabel("Iteration", fontsize=12)
ax.set_ylabel("Parameter", fontsize=12)
# Y-axis labels.
if param_names is not None:
assert len(param_names) == n_dim
ax.set_yticks(np.arange(n_dim))
ax.set_yticklabels(param_names, fontsize=10)
else:
ax.set_yticks(np.arange(n_dim))
ax.set_yticklabels([f"Dim {i}" for i in range(n_dim)], fontsize=10)
# Plot Xs where values are at bounds.
if bounds is not None:
min_bounds, max_bounds = bounds
for dim in range(n_dim):
min_val = min_bounds[dim]
max_val = max_bounds[dim]
for iter_idx, val in enumerate(data[dim]):
if abs(val - min_val) < bound_eps or abs(val - max_val) < bound_eps:
ax.plot(iter_idx, dim, "kx", markersize=6, markeredgewidth=1.5)
if show_colorbar:
cbar = fig.colorbar(im, ax=ax)
label = "Normalized Value" if normalize else "Value"
cbar.set_label(label, fontsize=12)
ax.set_title("Candidate Heatmap", fontsize=14)
fig.tight_layout()
def parameter_confidence(
all_exp_names: Sequence[str],
all_params: Sequence[parameter.ParameterDict],
all_intervals: Sequence[np.ndarray],
cols: int = 5,
gt_params: parameter.ParameterDict | None = None,
):
"""Plot parameter estimates with confidence intervals."""
named_estimates = {}
# Create an entry for every non-frozen parameter
for params in all_params:
param_names = params.get_non_frozen_parameter_names()
for name in param_names:
if name not in named_estimates:
named_estimates[name] = {
"x": [],
"intervals": [],
"min_bounds": [],
"max_bounds": [],
"plot_labels": [],
}
for exp_name, params, intervals in zip(
all_exp_names, all_params, all_intervals, strict=True
):
param_names = params.get_non_frozen_parameter_names()
xs = params.as_vector()
bounds = params.get_bounds()
assert xs.shape[0] == len(param_names)
if gt_params is not None:
for name in param_names:
if name in gt_params:
named_estimates[name]["xgt"] = gt_params[name].value[0]
else:
assert name[-1] == "]"
left_bracket_i = name[::-1].find("[")
index = int(name[-left_bracket_i:-1])
named_estimates[name]["xgt"] = gt_params[
name[: -left_bracket_i - 1]
].value[index]
for i, (name, x, interval) in enumerate(
zip(param_names, xs, intervals, strict=True)
):
named_estimates[name]["x"].append(x)
named_estimates[name]["intervals"].append(interval)
named_estimates[name]["min_bounds"].append(bounds[0][i])
named_estimates[name]["max_bounds"].append(bounds[1][i])
named_estimates[name]["plot_labels"].append(exp_name)
rows = len(named_estimates) // cols + 1
fig, axs = plt.subplots(
rows, cols, figsize=(20, 2 * (len(named_estimates) // cols + 1))
)
if rows == 1:
axs = [axs]
for i, name in enumerate(named_estimates):
x_list = named_estimates[name]["x"]
intervals = named_estimates[name]["intervals"]
plot_labels = named_estimates[name]["plot_labels"]
row = i % rows
col = i // rows
min_bound = np.min(named_estimates[name]["min_bounds"])
max_bound = np.min(named_estimates[name]["max_bounds"])
for j, (x, interval, plot_label) in enumerate(
zip(x_list, intervals, plot_labels, strict=True)
):
if not np.isfinite(interval) or 2.0 * interval > 2.0 * (
max_bound - min_bound
):
interval = 2.0 * (max_bound - min_bound)
eb = axs[row][col].errorbar(x, -j, xerr=interval)
eb[-1][0].set_linestyle("--")
else:
axs[row][col].errorbar(x, -j, xerr=interval)
axs[row][col].scatter(x, -j, marker="x", label=plot_label)
axs[row][col].set_xlim([min_bound, max_bound])
axs[row][col].yaxis.set_ticklabels([])
axs[row][col].set_title(name)
axs[row][col].grid(True)
axs[row][col].legend(
fontsize=5, loc="upper right", bbox_to_anchor=(1.4, 1.0)
)
if gt_params is not None:
axs[row][col].axvline(named_estimates[name]["xgt"], color="b", ls="--")
fig.tight_layout()
def render_rollout(
model: mujoco.MjModel | Sequence[mujoco.MjModel],
data: mujoco.MjData,
+43 -6
View File
@@ -68,7 +68,13 @@ def apply_bias(
sensor_name: str,
bias: parameter.Parameter,
) -> timeseries.TimeSeries:
"""Apply a bias to a sensor in a timeseries."""
"""Apply a bias to a sensor in a timeseries.
Args:
ts: Input timeseries.
sensor_name: Name of the sensor to modify.
bias: Parameter whose ``.value`` is added to the sensor columns.
"""
indices = ts.get_indices(sensor_name)[1]
data_out = ts.data.copy()
data_out[..., indices] += bias.value
@@ -80,7 +86,13 @@ def apply_gain(
sensor_name: str,
gain: parameter.Parameter,
) -> timeseries.TimeSeries:
"""Apply a gain to a sensor in a timeseries."""
"""Apply a gain to a sensor in a timeseries.
Args:
ts: Input timeseries.
sensor_name: Name of the sensor to modify.
gain: Parameter whose ``.value`` multiplies the sensor columns.
"""
indices = ts.get_indices(sensor_name)[1]
data_out = ts.data.copy()
data_out[..., indices] *= gain.value
@@ -92,7 +104,13 @@ def apply_delay(
sensor_name: str,
delay: parameter.Parameter,
) -> timeseries.TimeSeries:
"""Apply a delay to a sensor in a timeseries."""
"""Apply a delay to a sensor in a timeseries.
Args:
ts: Input timeseries.
sensor_name: Name of the sensor to delay.
delay: Parameter whose ``.value`` is the delay in seconds.
"""
indices = ts.get_indices(sensor_name)[1]
ts_sensor = timeseries.TimeSeries(
@@ -205,7 +223,15 @@ def apply_resample_and_delay(
sensor_delays: dict[str, float] | None = None,
predicted_data: bool = True,
) -> timeseries.TimeSeries:
"""Resample a timeseries and apply per-sensor delays."""
"""Resample a timeseries and apply per-sensor delays.
Args:
ts: Input timeseries to resample.
times: Target timestamps.
default_delay: Default delay applied to all columns.
sensor_delays: Optional per-sensor delay overrides.
predicted_data: If True, negate delays (shift predicted to match measured).
"""
delays = _build_per_column_delays(
ts, default_delay, sensor_delays, predicted_data
)
@@ -234,7 +260,13 @@ def prepare_sensor_weights(
n_sensors: int,
model: mujoco.MjModel,
) -> np.ndarray:
"""Prepare sensor weights array from a dict or numpy array."""
"""Prepare sensor weights array from a dict or numpy array.
Args:
sensor_weights: Mapping from sensor name to weight, or a flat array.
n_sensors: Total number of sensor columns.
model: MuJoCo model for resolving sensor names to indices.
"""
if isinstance(sensor_weights, np.ndarray):
if sensor_weights.ndim != 1 or sensor_weights.shape[0] != n_sensors:
raise ValueError(
@@ -284,5 +316,10 @@ def normalize_residual(
residual: np.ndarray,
measured_data: np.ndarray,
) -> np.ndarray:
"""Normalize the residual by the standard deviation of the measured data."""
"""Normalize the residual by the standard deviation of the measured data.
Args:
residual: Residual array, shape ``(n_timesteps, n_sensors)``.
measured_data: Measured data array, same shape as *residual*.
"""
return residual / (np.linalg.norm(measured_data, axis=0) / np.sqrt(2))
+23 -4
View File
@@ -51,7 +51,12 @@ class SignalTransform:
self.normalize = normalize
def delay(self, pattern: str, param: parameter.Parameter) -> None:
"""Register a delay for sensors matching *pattern* (fnmatch)."""
"""Register a delay for sensors matching *pattern* (fnmatch).
Args:
pattern: fnmatch pattern matched against sensor names.
param: Parameter whose ``.value`` is the delay in seconds.
"""
self._delays.append((pattern, param.name, param))
def gain(
@@ -79,7 +84,13 @@ class SignalTransform:
param: parameter.Parameter,
target: str = "both",
) -> None:
"""Register an additive bias for sensors matching *pattern*."""
"""Register an additive bias for sensors matching *pattern*.
Args:
pattern: fnmatch pattern matched against sensor names.
param: Parameter whose ``.value`` is the additive bias.
target: One of ``"predicted"``, ``"measured"``, or ``"both"``.
"""
if target not in ("predicted", "measured", "both"):
raise ValueError(
f"target must be 'predicted', 'measured', or 'both', got {target!r}"
@@ -87,11 +98,19 @@ class SignalTransform:
self._biases.append((pattern, param.name, target))
def enable_sensors(self, sensor_names: list[str]) -> None:
"""Only include these sensors in the returned residual/timeseries."""
"""Only include these sensors in the returned residual/timeseries.
Args:
sensor_names: Sensor names to keep in the output.
"""
self._enabled_sensors = list(sensor_names)
def set_sensor_weights(self, weights: Mapping[str, float]) -> None:
"""Set per-sensor weights for the weighted diff."""
"""Set per-sensor weights for the weighted diff.
Args:
weights: Mapping from sensor name to weight.
"""
self._sensor_weights = weights
# Private methods.
+33 -12
View File
@@ -220,22 +220,26 @@ class TimeSeries:
nq = model.nq
nv = model.nv
# Bodies
# Bodies with free joints, named by body rather than joint.
for body_id in range(model.nbody):
b = model.body(body_id)
body_name = b.name
start_index = model.body_dofadr[body_id]
dof_adr = model.body_dofadr[body_id]
if start_index >= 0 and b.dofnum[0] == 6:
qpos_indices = np.arange(start_index, start_index + 7)
if dof_adr >= 0 and b.dofnum[0] == 6:
# Use the body's first joint qposadr for qpos. Free joints have
# 7 qpos elements but only 6 dofs, so dofadr and qposadr diverge
# for subsequent entries.
first_jnt = model.body_jntadr[body_id]
qpos_adr = model.jnt_qposadr[first_jnt]
qpos_indices = np.arange(qpos_adr, qpos_adr + 7)
qpos_map[f"{body_name}_qpos"] = (SignalType.MjStateQPos, qpos_indices)
qvel_indices = np.arange(start_index + nq, start_index + nq + 6)
qvel_indices = np.arange(dof_adr + nq, dof_adr + nq + 6)
qvel_map[f"{body_name}_qvel"] = (SignalType.MjStateQVel, qvel_indices)
# Joints
# Joints, excluding free joints which are handled above.
for jnt_id in range(model.njnt):
jnt_name = model.joint(jnt_id).name
start_index = model.jnt_qposadr[jnt_id]
jnt_type = model.jnt_type[jnt_id]
qpos_width = 1
@@ -246,9 +250,11 @@ class TimeSeries:
elif jnt_type == mujoco.mjtJoint.mjJNT_FREE:
continue
qpos_indices = np.arange(start_index, start_index + qpos_width)
qpos_adr = model.jnt_qposadr[jnt_id]
qpos_indices = np.arange(qpos_adr, qpos_adr + qpos_width)
qpos_map[f"{jnt_name}_qpos"] = (SignalType.MjStateQPos, qpos_indices)
qvel_indices = np.arange(start_index + nq, start_index + nq + qvel_width)
dof_adr = model.jnt_dofadr[jnt_id]
qvel_indices = np.arange(dof_adr + nq, dof_adr + nq + qvel_width)
qvel_map[f"{jnt_name}_qvel"] = (SignalType.MjStateQVel, qvel_indices)
# Actuators
@@ -445,7 +451,11 @@ class TimeSeries:
return cls(times=times, data=data, signal_mapping=signal_mapping)
def get_indices(self, obs_name: str) -> tuple[SignalType, np.ndarray]:
"""Look up the signal type and column indices for a named observation."""
"""Look up the signal type and column indices for a named observation.
Args:
obs_name: Name of the observation signal.
"""
assert self.signal_mapping is not None
if obs_name not in self.signal_mapping:
raise ValueError(
@@ -462,7 +472,14 @@ class TimeSeries:
str, tuple[SignalType, np.ndarray | list[int] | int]
],
) -> TimeSeries:
"""Construct a TimeSeries, normalising index entries to ``np.ndarray``."""
"""Construct a TimeSeries, normalizing index entries to ``np.ndarray``.
Args:
times: 1-D timestamp array.
data: Data array with first axis corresponding to time.
signal_mapping: Dict mapping signal names to ``(type, indices)`` tuples.
Index entries are coerced to ``np.ndarray``.
"""
normalized: SignalMappingType = {}
for key in signal_mapping:
signal_type, indices = signal_mapping[key]
@@ -560,7 +577,11 @@ class TimeSeries:
)
def save_to_csv(self, path: str | pathlib.Path) -> None:
"""Save the time series data to a CSV file."""
"""Save the time series data to a CSV file.
Args:
path: Path where the CSV file will be written.
"""
np.savetxt(
path,
np.concatenate([self.times[:, None], self.data], axis=1),
+81 -7
View File
@@ -302,12 +302,23 @@ class SystemTrajectory:
height=height,
)
def _map_states(from_array, to_array, from_names, to_mapping, map_offset):
i = 0
for name in from_names:
_, indices = to_mapping[name]
width = indices.shape[0]
to_array[indices - map_offset] = from_array[i:i+width]
i += width
return to_array
def create_initial_state(
model: mujoco.MjModel,
qpos: np.ndarray,
qvel: np.ndarray | None = None,
act: np.ndarray | None = None,
qpos_names: Sequence[str] | None = None,
qvel_names: Sequence[str] | None = None,
act_names: Sequence[str] | None = None,
) -> np.ndarray:
"""Build a ``mjSTATE_FULLPHYSICS`` initial-state vector from components.
@@ -316,14 +327,46 @@ def create_initial_state(
qpos: Joint positions, shape ``(nq,)``.
qvel: Joint velocities, shape ``(nv,)``. Defaults to zero.
act: Actuator activations, shape ``(na,)``. Defaults to zero.
qpos_names: Names to map elements of qpos to specific MuJoCo states.
If None, assumes qpos is in MuJoCo's order.
qvel_names: Names to map elements of qvel to specific MuJoCo states.
If None, assumes qvel is in MuJoCo's order.
act_names: Actuator names to map elements of act to specific MuJoCo
actuators. If None, assumes act is in MuJoCo's order.
Returns:
Flat state vector suitable for ``mujoco.rollout``.
"""
data = mujoco.MjData(model)
initial_state = np.empty((
mujoco.mj_stateSize(model, mujoco.mjtState.mjSTATE_FULLPHYSICS.value),
))
if qpos_names is not None and len(qpos_names) != qpos.shape[0]:
raise ValueError(
f"Expected qpos to have shape {len(qpos_names)}, got {qpos.shape[0]}"
)
if qvel is None and qvel_names is not None:
raise ValueError("Expected qvel to not be None when qvel_names is not None")
if qvel_names is not None and qvel is not None and len(qvel_names) != qvel.shape[0]:
raise ValueError(
f"Expected qvel to have shape {len(qvel_names)}, got {qvel.shape[0]}"
)
if act_names is not None and len(act_names) != act.shape[0]:
raise ValueError(
f"Expected act to have shape {len(act_names)}, got {act.shape[0]}"
)
if (qpos_names is not None
or qvel_names is not None
or act_names is not None):
qpos_map, qvel_map, act_map, _ = timeseries.TimeSeries.compute_all_state_mappings(model)
if qpos_names is not None:
qpos = _map_states(qpos, np.copy(data.qpos), qpos_names, qpos_map, 0)
if qvel_names is not None:
indices_offset = data.qpos.shape[0]
qvel = _map_states(qvel, np.copy(data.qvel), qvel_names, qvel_map, indices_offset)
if act_names is not None:
indices_offset = data.qpos.shape[0] + data.qvel.shape[0]
act = _map_states(act, np.copy(data.act), act_names, act_map, indices_offset)
if qpos.shape[0] != model.nq:
raise ValueError(
f"Expected qpos to have shape {model.nq}, got {qpos.shape[0]}."
@@ -341,6 +384,10 @@ def create_initial_state(
f"Expected act to have shape {model.na}, got {act.shape[0]}."
)
data.act[:] = act
initial_state = np.empty((
mujoco.mj_stateSize(model, mujoco.mjtState.mjSTATE_FULLPHYSICS.value),
))
mujoco.mj_getState(
model, data, initial_state, mujoco.mjtState.mjSTATE_FULLPHYSICS.value
)
@@ -433,7 +480,14 @@ class ModelSequences:
def timeseries2array(
control_signal: timeseries.TimeSeries | Sequence[timeseries.TimeSeries],
) -> tuple[np.ndarray, np.ndarray]:
"""Convert control TimeSeries to stacked arrays, dropping the last step."""
"""Convert control TimeSeries to stacked arrays, dropping the last step.
Args:
control_signal: Control TimeSeries or sequence of TimeSeries.
Returns:
``(control_array, control_times)`` with the last time step removed.
"""
if isinstance(control_signal, timeseries.TimeSeries):
control = control_signal.data
control_times = control_signal.times
@@ -457,7 +511,11 @@ def timeseries2array(
def sequence2array(
initial_states: np.ndarray | Sequence[np.ndarray],
) -> np.ndarray:
"""Stack a sequence of initial-state vectors into a single array."""
"""Stack a sequence of initial-state vectors into a single array.
Args:
initial_states: Single state array or sequence of state arrays.
"""
if isinstance(initial_states, np.ndarray):
return initial_states
return np.stack(initial_states, axis=0)
@@ -474,12 +532,28 @@ def arrays2traj(
state_mapping: timeseries.SignalMappingType,
ctrl_mapping: timeseries.SignalMappingType,
) -> Sequence[SystemTrajectory]:
"""Convert raw rollout arrays into a list of SystemTrajectory objects."""
"""Convert raw rollout arrays into a list of SystemTrajectory objects.
Args:
models: Single model or sequence of models (one per batch element).
initial_states: Initial state array(s).
control: Control array, shape ``(nbatch, nsteps, nu)``.
control_times: Control timestamps, shape ``(nbatch, nsteps)``.
state: State array, shape ``(nbatch, nsteps, nstate)``.
sensordata: Sensor data array, shape ``(nbatch, nsteps, nsensordata)``.
signal_mapping: Signal mapping for sensor data.
state_mapping: Signal mapping for state data.
ctrl_mapping: Signal mapping for control data.
"""
nbatch = state.shape[0]
# TODO(kevin): When is np.tile necessary?
# TODO(kevin): When is np.tile/atleast_2d/etc necessary?
# initial_states = np.tile(initial_states, (nbatch, 1))
# control = np.tile(control, (nbatch, 1, 1))
# control_times = np.tile(control_times, (nbatch, 1))
initial_states = np.atleast_2d(initial_states)
if control.ndim == 2:
control = control[np.newaxis, :, :]
control_times = np.atleast_2d(control_times)
if isinstance(models, mujoco.MjModel):
models_list = [models] * nbatch