Files
Mujoco_WASM/python/mujoco/sysid/README.md
T
Kevin Zakka 146a5c08f7 System identification toolbox for MuJoCo.
This resulted from a lengthy collaboration with @kevinzakka, @jonathanembleyriches, @nimrod-gileadi, @gizemozd, @quagla, and @yuval.
2026-02-09 12:12:24 -05:00

638 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Practical System Identification
A toolbox for system identification built on top of MuJoCo.
## API Overview
The library solves a **box-constrained nonlinear least-squares** problem. Given
a parameter vector `θ`, simulated sensor readings `ȳ(θ)`, and recorded sensor
data `y`, the objective is:
```
min ½ ‖W (ȳ(θ) y)‖²
θ
subject to θ_min ≤ θ ≤ θ_max
```
where `W` is a diagonal weighting matrix and the box constraints enforce
physical plausibility (e.g., positive masses).
The optimizer uses the **Gauss-Newton** method. The residual Jacobian
`J = ∂r/∂θ` is computed by **finite differences**: each column of `J` requires
one perturbed simulation rollout, and these evaluations are independent across
parameters and parallelize naturally across threads. The Gauss-Newton
approximate Hessian is `H ≈ JᵀJ` and the gradient is `g = Jᵀr`, yielding the
update `Δθ = H⁻¹g`. Box constraints are handled by projected steps.
The pipeline has five stages:
```
Define Parameters ──> Package Data ──> Build Residual ──> Optimize ──> Save / Report
ParameterDict ModelSequences build_residual_fn optimize save_results
```
---
### What Can You Identify?
**Anything settable on `MjSpec` can be identified** via modifier callbacks. The
convenience functions handle common cases with correct bounds; for everything
else, write a `modifier` lambda that sets the quantity on the spec.
**Physics parameters** — these change the model before simulation:
| Target | Approach |
|---|---|
| Body mass | `body_inertia_param(..., InertiaType.Mass)` |
| Body mass + center of mass | `body_inertia_param(..., InertiaType.MassIpos)` |
| Full body inertia (10-D) | `body_inertia_param(..., InertiaType.Pseudo)` |
| Actuator P/D gains | `Parameter(..., modifier=lambda s, p: apply_pgain(s, "act1", p.value[0]))` |
| Contact friction / solref | `Parameter(..., modifier=lambda s, p: s.pair("cp").friction.__setitem__(0, p.value[0]))` |
| Joint damping / stiffness | `Parameter(..., modifier=lambda s, p: setattr(s.joint("j1"), "damping", p.value[0]))` |
**Measurement parameters** — real sensors aren't perfect. They may lag behind
the simulation clock, have an unknown scale factor, or sit at a nonzero offset.
These can't be set on `MjSpec` because they aren't physics — they're artifacts
of the measurement system. `SignalTransform` (Section 4) adjusts the simulated
or recorded signals *after* rollout to account for these:
| Target | Approach |
|---|---|
| Sensor delay | `transform.delay("*_pos", params["delay"])` |
| Sensor gain/scale | `transform.gain("*_torque", params["scale"])` |
| Sensor bias/offset | `transform.bias("*_vel", params["bias"])` |
#### Common recipes
**Identify link masses of a robot arm:**
```python
from mujoco.sysid import body_inertia_param, InertiaType, ParameterDict
params = ParameterDict()
for link in ["link1", "link2", "link3"]:
params.add(body_inertia_param(spec, model, link, inertia_type=InertiaType.Mass))
```
**Identify contact friction:**
```python
from mujoco.sysid import Parameter
params.add(Parameter(
"floor_friction",
nominal=1.0, min_value=0.1, max_value=3.0,
modifier=lambda s, p: s.pair("foot_floor").friction.__setitem__(0, p.value[0]),
))
```
---
### 1. Define Parameters
A **`Parameter`** is a named value (scalar or array) with bounds and an optional
**modifier callback** that knows how to apply itself to a MuJoCo spec. A
**`ParameterDict`** collects parameters into the single vector that the
optimizer sees — it handles flattening them into one array, writing optimizer
updates back, and enforcing bounds:
```python
from mujoco.sysid import Parameter, ParameterDict
params = ParameterDict()
params.add(Parameter(
"box_mass",
nominal=5.0, # starting value
min_value=1.0,
max_value=10.0,
modifier=lambda spec, p: setattr(spec.body("box"), "mass", p.value[0]),
))
params.add(Parameter(
"friction",
nominal=[1.6, 0.005],
min_value=[0.0, 0.0],
max_value=[3.0, 0.01],
frozen=True, # excluded from optimization
modifier=lambda spec, p: spec.pair("contact").friction.__setitem__(slice(0, 2), p.value),
))
```
**`frozen`**: A frozen parameter is completely invisible to the optimizer — it
is excluded from `as_vector()`, `update_from_vector()`, `get_bounds()`, and
`randomize()`. Its modifier callback is also **not called** during
`apply_param_modifiers`, so the model uses whatever value is already in the XML
spec for that quantity. The intended workflow: define all parameters you might
ever want to identify up front, then toggle `frozen` on and off as you
iteratively narrow which parameters matter.
Key `ParameterDict` methods:
| Method | Description |
|---|---|
| `as_vector()` | Flatten all non-frozen parameters into a 1-D array |
| `update_from_vector(x)` | Write a flat array back into the parameters |
| `get_bounds()` | Returns `(lower, upper)` bound arrays |
| `randomize(rng)` | Sample each non-frozen parameter uniformly within bounds |
| `reset()` | Restore every parameter to its nominal value |
| `copy()` | Deep copy (preserves modifier lambdas) |
| `save_to_disk(path)` / `load_from_disk(path)` | YAML serialization (schema + values) |
#### Body inertia parameterization
Rigid-body inertia is tricky to identify: mass, center-of-mass, and the rotational
inertia tensor are coupled, and naively optimizing the 6 independent entries of
the inertia tensor can produce physically impossible results (e.g. negative
eigenvalues). The library implements three parameterizations of increasing
fidelity:
| `InertiaType` | Params | What it identifies |
|---|---|---|
| `Mass` | 1 | Mass only. Optionally scales the existing rotational inertia proportionally (`scale_rot_inertia=True`). |
| `MassIpos` | 4 | Mass + center-of-mass position (3-D). Optionally scales rotational inertia. |
| `Pseudo` | 10 | Full inertia via the pseudo-inertia Cholesky factor from [Rucker & Wensing 2022](https://ieeexplore.ieee.org/document/9690029). The 10 parameters `θ = [α, d₁, d₂, d₃, s₁₂, s₂₃, s₁₃, t₁, t₂, t₃]` are the entries of a lower-triangular matrix whose product `LLᵀ` is the 4×4 pseudo-inertia matrix. Physical consistency (positive mass, positive-definite inertia tensor) is guaranteed by construction for any `θ`. |
Use `body_inertia_param` to create a `Parameter` with the right nominal values,
bounds, and modifier already wired up:
```python
from mujoco.sysid import body_inertia_param, InertiaType
param = body_inertia_param(
spec, model, "link1",
inertia_type=InertiaType.Pseudo,
)
params.add(param)
```
---
### 2. Package Data
Measured data is stored as **`TimeSeries`** objects (frozen dataclass: `times`,
`data`, optional `signal_mapping`):
```python
from mujoco.sysid import TimeSeries, SignalType
# For sensor/state observations:
sensordata = TimeSeries.from_names(times, sensor_data, model) # all sensors
sensordata = TimeSeries.from_names(times, data, model, names=["joint1_pos", "joint2_pos"])
# Explicit type disambiguation (useful when sensor/state names overlap):
sensordata = TimeSeries.from_names(times, data, model, names=[
("joint1_pos", SignalType.MjSensor), # sensor named "joint1_pos"
("joint1_qpos", SignalType.MjStateQPos), # joint state
])
# For control signals:
control = TimeSeries.from_control_names(times, control_data, model) # all actuators
control = TimeSeries.from_control_names(times, data, model, names=["motor1_ctrl"])
# For custom/raw data:
ts = TimeSeries.from_custom_map(times, data, ["signal1", "signal2"])
```
`signal_mapping` is a dict `{name: (SignalType, indices)}` that labels which
columns of `data` correspond to which sensor/actuator.
**`TimeSeries` factory methods:**
| Constructor | Use case |
|---|---|
| `TimeSeries.from_names(times, data, model, names=None)` | Sensor/state data. If `names=None`, maps all model sensors. |
| `TimeSeries.from_control_names(times, data, model, names=None)` | Control signals. If `names=None`, maps all actuators. |
| `TimeSeries.from_custom_map(times, data, signals)` | Custom data with explicit signal definitions. |
| `TimeSeries(times, data)` | Raw arrays, no signal mapping. |
| `TimeSeries(times, data, signal_mapping)` | Named signals with explicit mapping. |
**`TimeSeries` methods:** `resample(new_times=, target_dt=)`, `interpolate(t)`,
`get(t)`, `save_to_disk(path)`, `load_from_disk(path)`, `dt_statistics()`,
`remove_from_beginning(t)`, `slice_by_name(ts, names)`.
Bundle a spec with one or more data sequences into a **`ModelSequences`**:
```python
from mujoco.sysid import ModelSequences, create_initial_state
initial_state = create_initial_state(model, qpos, qvel, act)
ms = ModelSequences(
name="robot",
spec=spec,
sequence_name=["traj_1", "traj_2"], # or a single string
initial_state=[initial_state_1, initial_state_2],
control=[control_1, control_2],
sensordata=[sensordata_1, sensordata_2],
)
```
You can pass a single sequence (not wrapped in a list) and it will be
auto-promoted.
**Multiple `ModelSequences`:** Each `ModelSequences` carries its own `spec`, but
the optimizer applies the **same parameter vector `θ`** to all of them. This
enables joint optimization across different physical configurations. For example,
you might have the same robot arm recorded with and without a known payload
attached — two different specs (one has the payload body), two sets of recorded
data, but the inertial parameters of the arm links are shared. The residuals
from all `ModelSequences` are stacked and minimized jointly, giving a better-
conditioned problem than fitting each dataset independently.
---
### 3. Build the Residual Function
The **residual** is the vector of differences between simulated sensor readings
and recorded sensor data: `r(θ) = W(ȳ(θ) y)`. Each element measures how
much the simulation with parameters `θ` disagrees with reality for one sensor
at one timestep. The optimizer's job is to find the `θ` that makes this vector
as small as possible (in the least-squares sense).
**`build_residual_fn`** captures data and configuration, returning a closure
that the optimizer will call repeatedly:
```python
from mujoco.sysid import build_residual_fn
residual_fn = build_residual_fn(
models_sequences=[ms],
# Optional overrides:
modify_residual=..., # custom residual logic
custom_rollout=..., # custom simulation
sensor_weights=..., # per-sensor weighting
enabled_observations=..., # subset of sensors to use
)
```
#### How `residual_fn` works internally
The returned `residual_fn(x, params)` accepts `x` as either a **1-D vector**
(plain function evaluation) or a **2-D matrix** of shape `(n_params, n_fd)`
(batched finite-difference Jacobian evaluation, where each column is a
perturbed parameter vector). This is the key to parallelism.
For each column `i` of `x`:
1. `params.update_from_vector(x[:, i])` — writes the optimizer's current
candidate values back into the `Parameter` objects so that each
parameter's `.value` attribute reflects column `i` of `x`
2. `model_i = apply_param_modifiers(params, spec)` — iterates over every
non-frozen parameter and calls its `modifier(spec, param)` callback,
then compiles the mutated spec into an `MjModel`
3. Replicate `model_i` once per trajectory chunk (if you have `C` data
sequences, you get `C` copies)
This produces a flat list of `n_fd * C` models. All of them are rolled out in
a **single call** to `mujoco.rollout.rollout`:
```python
datas = [mujoco.MjData(models[0]) for _ in range(n_threads)] # one per thread
state, sensordata = mujoco.rollout.rollout(
models, # n_fd * C models
datas, # K thread-local scratch MjData objects
initial_states, # n_fd * C initial states
control, # n_fd * C control sequences
)
```
MuJoCo's rollout engine distributes the `n_fd * C` independent rollouts across
`K` threads using the `MjData` objects as thread-local scratch space (each
thread gets its own `MjData` to avoid data races). **This is why the Jacobian
computation is fast**: all `n_params + 1` perturbed rollouts (times `C`
trajectory chunks) execute in one batched, multithreaded call.
After rollout, residuals are computed per-trajectory (predicted vs. measured
sensor data), then stacked and returned.
#### Concrete example
Suppose you have `p = 10` parameters and `C = 3` trajectory chunks:
- **Function eval** (`x` is 1-D): `1 * 3 = 3` rollouts, distributed across
threads.
- **Jacobian eval** (`x` is `(10, 11)` — nominal + 10 perturbations): `11 * 3
= 33` rollouts in one batched call. On a 16-core machine this is ~2x wall
time of a single rollout.
#### Three tiers of customization
| Tier | What you provide | When to use |
|---|---|---|
| **Default** | Nothing extra (or `SignalTransform`) | Standard MuJoCo sensors, optional delays/gains |
| **Custom rollout** | `custom_rollout=fn` | Non-standard simulation (e.g. task-space control) |
| **Custom residual** | `modify_residual=fn` | State-based observations, exotic loss functions |
---
### 4. SignalTransform (Declarative Residual Configuration)
After simulation, the residual pipeline compares predicted sensor readings to
recorded data. But real sensors aren't ideal — position encoders may lag by a
few milliseconds, torque sensors may have an unknown scale factor, and velocity
estimates may sit at a nonzero offset. These aren't physics parameters (you
can't set "delay" on an `MjSpec`), so they need to be corrected *after* the
rollout, before the residual is computed.
**`SignalTransform`** lets you declare these corrections and which sensors to
use, without writing a custom residual callback. Internally it:
1. **Time-shifts** the predicted (or measured) signals by per-sensor delay
parameters, resampling onto a common time grid.
2. **Scales** sensor columns by gain parameters (`target="predicted"` scales
the simulation output, `target="measured"` scales the recording — useful
when the sensor's scale factor is unknown on either side).
3. **Offsets** sensor columns by bias parameters.
4. Computes the weighted difference and normalizes by RMS.
Patterns use `fnmatch` syntax, so `"*_pos"` matches all sensors whose name
ends in `_pos`:
```python
from mujoco.sysid import SignalTransform
transform = SignalTransform()
transform.delay("*_pos", params["delay_pos"]) # fnmatch pattern
transform.delay("*_torque", params["delay_torque"])
transform.gain("*_torque", params["torque_scale"], target="predicted")
transform.bias("*_vel", params["vel_bias"])
transform.enable_sensors(["joint1_pos", "joint2_pos", "joint1_torque"])
transform.set_sensor_weights({"joint1_torque": 0.5})
residual_fn = build_residual_fn(
models_sequences=[ms],
modify_residual=transform.apply, # drop-in replacement
)
```
`SignalTransform.apply` has the same signature as `ModifyResidualFn`, so it
plugs directly into `build_residual_fn`.
#### What this replaces
Without `SignalTransform`, you'd write the same logic by hand as a
`modify_residual` callback using the low-level `signal_modifier` functions
(Section 8):
```python
from mujoco.sysid._src import signal_modifier
def modify_residual(params, predicted, measured, model, return_pred_all, **kw):
# 1. Apply delays and resample onto a common time grid.
min_d, max_d = -0.02, 0.05 # must track delay bounds yourself
measured = signal_modifier.apply_delayed_ts_window(measured, predicted, min_d, max_d)
sensor_delays = {"joint1_pos": params["delay_pos"].value[0], ...}
predicted = signal_modifier.apply_resample_and_delay(
predicted, measured.times, default_delay=0.0, sensor_delays=sensor_delays,
)
# 2. Apply gains and biases.
predicted = signal_modifier.apply_gain(predicted, "joint1_torque", params["torque_scale"])
predicted = signal_modifier.apply_bias(predicted, "joint1_vel", params["vel_bias"])
# 3. Compute residual.
diff = signal_modifier.weighted_diff(predicted.data, measured.data, model, weights)
diff = signal_modifier.normalize_residual(diff, measured.data)
return diff, predicted, measured
```
`SignalTransform` does all of this — including tracking delay bounds, expanding
fnmatch patterns to sensor names, and handling the windowing/resampling
bookkeeping — from a few declarative lines.
---
### 5. Optimize
**`optimize`** runs box-constrained Gauss-Newton least-squares on the residual
function:
```python
from mujoco.sysid import optimize
opt_params, opt_result = optimize(
initial_params=params,
residual_fn=residual_fn,
optimizer="mujoco", # "mujoco", "scipy", or "scipy_parallel_fd"
max_iters=200,
)
```
#### Optimizer backends
| Backend | Jacobian | Description |
|---|---|---|
| `"mujoco"` (recommended) | Parallel FD, batched | `mujoco.minimize.least_squares`. Calls `residual_fn(x)` with `x` as a 2-D matrix `(n_params, n_params+1)` — the nominal point plus one perturbation per parameter — so the entire Jacobian is computed in a single batched, multithreaded rollout call. |
| `"scipy"` | Sequential 2-point FD | `scipy.optimize.least_squares`. Computes the Jacobian column-by-column (sequential). Slower for problems with many parameters. |
| `"scipy_parallel_fd"` | Parallel FD via MuJoCo, scipy outer loop | Scipy's trust-region solver but with `mujoco.minimize.jacobian_fd` for the Jacobian. Gives scipy's convergence control with MuJoCo's batched FD speed. |
All three backends solve box-constrained nonlinear least-squares using the
Gauss-Newton Hessian approximation `H ≈ JᵀJ`. They differ in how the
finite-difference Jacobian is computed and how the trust-region step is
handled: `"mujoco"` uses projected Gauss-Newton steps, while `"scipy"` and
`"scipy_parallel_fd"` use scipy's trust-region reflective algorithm (`trf`).
#### Return value
Returns `(opt_params, OptimizeResult)` where `opt_params` is a deep copy of
the input `ParameterDict` with `.value` set to the solution, and
`OptimizeResult` contains:
- `.x` — the solution vector
- `.jac` — the Jacobian at the solution (used for confidence intervals)
- `.grad` — the gradient at the solution
- `.extras` — (**mujoco backend only**) dict with `"objective"` (cost per
iteration) and `"candidate"` (parameter vector per iteration), when verbose
---
### 6. Save Results and Report
**`save_results`** writes everything to disk:
```python
from mujoco.sysid import save_results
save_results(
experiment_results_folder="results/exp01",
models_sequences=[ms],
initial_params=params,
opt_params=opt_params,
opt_result=opt_result,
residual_fn=residual_fn,
)
```
This creates:
- `params_x_0.yaml` — initial parameter values
- `params_x_hat.yaml` — optimized parameter values
- `results.pkl` — full `OptimizeResult`
- `confidence.pkl` — parameter covariance matrix `Σ_θ = σ²_r H⁻¹` and
per-parameter confidence intervals, computed from the eigendecomposition of
`H = JᵀJ` at the solution. Parameters in near-null-space directions of `H`
receive infinite confidence intervals, making identifiability issues
immediately visible.
- `{model_name}.xml` — identified MuJoCo XML for each model
**`default_report`** generates an HTML report with sensor comparisons, parameter
tables, and videos:
```python
from mujoco.sysid import default_report
default_report(
models_sequences=[ms],
initial_params=params,
opt_params=opt_params,
opt_result=opt_result,
residual_fn=residual_fn,
save_dir="results/exp01",
)
```
---
### 7. Model Modification
`apply_param_modifiers` is the default `build_model` implementation — it
iterates over all non-frozen parameters, calls each one's `modifier` callback
on the spec, and compiles. Most users never need to call it directly; it runs
automatically inside the residual pipeline.
The remaining functions are useful when writing a **custom `build_model`**
(e.g. the box case study manually mutates the spec instead of using modifier
callbacks):
| Function | Description |
|---|---|
| `apply_param_modifiers(params, spec)` | Run all modifier callbacks, return compiled `MjModel` |
| `apply_param_modifiers_spec(params, spec)` | Run all modifier callbacks, return the `MjSpec` |
| `apply_pgain(spec, name, value)` | Set proportional gain on a position actuator |
| `apply_dgain(spec, name, value)` | Set derivative gain on a position actuator |
| `apply_pdgain(spec, name, value)` | Set both P and D gains |
| `apply_body_inertia(spec, name, param)` | Apply Mass / MassIpos / Pseudo inertia |
| `body_inertia_param(spec, model, name, ...)` | Create a `Parameter` for body inertia |
| `remove_visuals(spec)` | Strip textures, materials, and visual-only geoms |
---
### 8. Signal Modification (Power-User API)
Low-level functions used internally by `SignalTransform` and the default
residual pipeline. Useful when writing a custom `modify_residual`:
| Function | Description |
|---|---|
| `get_sensor_indices(model, name)` | Column indices for a named sensor |
| `apply_gain(ts, name, param)` | Multiply sensor columns by `param.value` |
| `apply_bias(ts, name, param)` | Add `param.value` to sensor columns |
| `apply_delay(ts, name, param)` | Time-shift sensor columns |
| `apply_delayed_ts_window(ts, ts_ref, min_d, max_d)` | Crop `ts` to the valid time window |
| `apply_resample_and_delay(ts, times, default_delay, ...)` | Resample with per-sensor delays |
| `weighted_diff(pred, meas, model, weights)` | `measured - predicted`, optionally weighted |
| `normalize_residual(residual, measured)` | Divide by column-wise RMS of measured data |
---
### 9. Additional Utilities
| Function / Class | Module | Description |
|---|---|---|
| `create_initial_state(model, qpos, qvel, act)` | trajectory | Pack qpos/qvel/act into a flat state vector |
| `SystemTrajectory` | trajectory | Frozen dataclass holding a single rollout (model, control, sensordata, state) |
| `sysid_rollout(models, datas, control, initial_states)` | trajectory | Parallel MuJoCo rollout returning `SystemTrajectory` list |
| `render_rollout(model, data, state, framerate)` | plotting | Render state trajectories to pixel frames |
| `calculate_intervals(residuals, J, alpha)` | optimize | Confidence intervals from Jacobian at the solution |
| `sweep_parameter(params, name, values, residual_fn)` | optimize | 1-D parameter sweep returning cost curve |
| `plot_sensor_comparison(model, ...)` | plotting | Matplotlib overlay of predicted vs. measured sensors |
| `SignalType` | timeseries | Enum: `MjSensor`, `CustomObs`, `MjStateQPos`, `MjStateQVel`, `MjStateAct`, `MjCtrl` |
---
## Type Aliases
```python
ModifyResidualFn = Callable[
..., tuple[np.ndarray, TimeSeries, TimeSeries]
]
# (params, sensordata_predicted, sensordata_measured, model, return_pred_all, state=..., sensor_weights=...)
# Returns (residual_array, pred_timeseries, measured_timeseries)
CustomRolloutFn = Callable[..., Sequence[SystemTrajectory]]
# (models, datas, control_signal, initial_states, param_dicts, ...)
# Returns list of SystemTrajectory
BuildModelFn = Callable[[ParameterDict, MjSpec], MjModel]
# Default: apply_param_modifiers
```
---
## Skeleton Case Study
Pseudocode showing the five-stage pipeline. Replace the data-loading step with
your own hardware logs or simulation data. For a complete runnable example, see
`case_studies/box/`.
```python
import mujoco
import numpy as np
from mujoco.sysid import (
Parameter,
ParameterDict,
TimeSeries,
ModelSequences,
build_residual_fn,
create_initial_state,
optimize,
save_results,
)
# 1. Load model.
spec = mujoco.MjSpec.from_file("robot.xml")
model = spec.compile()
# 2. Define parameters with modifier callbacks.
params = ParameterDict()
params.add(Parameter(
"link1_mass",
nominal=2.0,
min_value=0.5,
max_value=5.0,
modifier=lambda spec, p: setattr(spec.body("link1"), "mass", p.value[0]),
))
# 3. Package recorded data.
# times: (N,) timestamps
# ctrl_array: (N, model.nu) control inputs
# sensor_array: (N, model.nsensordata) recorded sensor readings
# qpos_0, qvel_0: initial joint positions and velocities
control = TimeSeries.from_control_names(times, ctrl_array, model)
sensordata = TimeSeries.from_names(times, sensor_array, model)
initial_state = create_initial_state(model, qpos_0, qvel_0)
ms = ModelSequences(
name="robot",
spec=spec,
sequence_name="traj_1",
initial_state=initial_state,
control=control,
sensordata=sensordata,
)
# 4. Build the residual function and optimize.
# models_sequences is a list because you can jointly optimize across
# multiple ModelSequences with different specs (see Section 2).
residual_fn = build_residual_fn(models_sequences=[ms])
opt_params, opt_result = optimize(
initial_params=params,
residual_fn=residual_fn,
optimizer="mujoco",
)
# 5. Inspect results.
print(opt_params)
save_results("results/", [ms], params, opt_params, opt_result, residual_fn)
```