Merge pull request #3079 from aftersomemath:sysid-pr
PiperOrigin-RevId: 868229512 Change-Id: I790bc08fc8b0745583a2f92d9ee2c5a19ba558ea
This commit is contained in:
@@ -0,0 +1,428 @@
|
||||
# Copyright 2026 DeepMind Technologies Limited
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ==============================================================================
|
||||
"""Parameter distribution report section."""
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from mujoco.sysid._src import parameter
|
||||
from mujoco.sysid.report.sections.base import ReportSection
|
||||
from mujoco.sysid.report.utils import plotly_script_tag
|
||||
import numpy as np
|
||||
from plotly import subplots as plt_subplots
|
||||
import plotly.graph_objects as go
|
||||
|
||||
|
||||
class ParameterDistribution(ReportSection):
|
||||
"""Displays the identified parameters relative to their bounds and nominal values.
|
||||
|
||||
Visualization: One horizontal track per parameter with markers for Nominal,
|
||||
Identified, and Bounds.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
title: str,
|
||||
opt_params: parameter.ParameterDict,
|
||||
initial_params: parameter.ParameterDict,
|
||||
confidence_intervals: np.ndarray | None = None,
|
||||
height_per_param: int = 60,
|
||||
anchor: str = "",
|
||||
collapsible: bool = True,
|
||||
):
|
||||
super().__init__(collapsible=collapsible)
|
||||
self._title = title
|
||||
self._anchor = anchor
|
||||
|
||||
self._opt_params = opt_params
|
||||
self._initial_params = initial_params
|
||||
self._nominal_params = initial_params.copy()
|
||||
self._nominal_params.reset()
|
||||
|
||||
self._x_nominal = self._nominal_params.as_vector()
|
||||
self._x_hat = self._opt_params.as_vector()
|
||||
self._x_initial = self._initial_params.as_vector()
|
||||
self._confidence_intervals = confidence_intervals
|
||||
self._height_per_param = height_per_param
|
||||
|
||||
@property
|
||||
def template_filename(self) -> str:
|
||||
return "plot_generic.html"
|
||||
|
||||
@property
|
||||
def title(self) -> str:
|
||||
return self._title
|
||||
|
||||
@property
|
||||
def anchor(self) -> str:
|
||||
return self._anchor
|
||||
|
||||
def header_includes(self) -> set[str]:
|
||||
return {plotly_script_tag()}
|
||||
|
||||
def get_context(self) -> dict[str, Any]:
|
||||
fig = self._build_figure()
|
||||
|
||||
config = {
|
||||
"displayModeBar": True,
|
||||
"displaylogo": False,
|
||||
"responsive": True,
|
||||
"toImageButtonOptions": {
|
||||
"format": "svg",
|
||||
"filename": f"{self._title}_distribution",
|
||||
"height": max(400, len(self._x_hat) * 100),
|
||||
"width": 1200,
|
||||
"scale": 1,
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
"title": self._title,
|
||||
"plot_div": fig.to_html(
|
||||
full_html=False, include_plotlyjs=False, config=config
|
||||
),
|
||||
"caption": (
|
||||
"<b>Visualization Guide:</b><br><b>Error Bars ( — )</b>: 95%"
|
||||
" Confidence Interval. Smaller is better.<br><span"
|
||||
" style='color:green'>◆ High Confidence</span>: Interval is <"
|
||||
" 0.5% of the parameter range (error bar may be"
|
||||
" invisible).<br><span style='color:blue'>◆ Identified</span>:"
|
||||
" Standard confidence interval.<br><span style='color:red'>◆"
|
||||
" Unconstrained</span>: Interval is infinite or larger than range"
|
||||
" (Red error bar).<br><span style='color:red'>x Nominal</span>,"
|
||||
" <span style='color:blue'>- - Bounds</span>: Reference"
|
||||
" values.<br>* parameter is frozen."
|
||||
),
|
||||
}
|
||||
|
||||
def _build_figure(self) -> go.Figure:
|
||||
plot_items = []
|
||||
non_frozen_idx = 0
|
||||
|
||||
for param_name, param in self._opt_params.parameters.items():
|
||||
# Get bounds for this specific parameter
|
||||
p_min, p_max = param.get_bounds()
|
||||
|
||||
if param.size == 1:
|
||||
flat_value = (
|
||||
param.value if np.isscalar(param.value) else param.value.item()
|
||||
)
|
||||
lb = p_min.item()
|
||||
ub = p_max.item()
|
||||
|
||||
if param.frozen:
|
||||
plot_items.append({
|
||||
"name": param_name,
|
||||
"value": flat_value,
|
||||
"is_frozen": True,
|
||||
"lb": lb,
|
||||
"ub": ub,
|
||||
"nominal": flat_value if self._x_nominal is not None else None,
|
||||
})
|
||||
else:
|
||||
conf = (
|
||||
self._confidence_intervals[non_frozen_idx]
|
||||
if self._confidence_intervals is not None
|
||||
else None
|
||||
)
|
||||
plot_items.append({
|
||||
"name": param_name,
|
||||
"value": self._x_hat[non_frozen_idx],
|
||||
"is_frozen": False,
|
||||
"lb": lb,
|
||||
"ub": ub,
|
||||
"nominal": (
|
||||
self._x_nominal[non_frozen_idx]
|
||||
if self._x_nominal is not None
|
||||
else None
|
||||
),
|
||||
"conf": conf,
|
||||
})
|
||||
non_frozen_idx += 1
|
||||
else:
|
||||
for i in range(param.size):
|
||||
if param.shape == (param.size,):
|
||||
element_name = f"{param_name}[{i}]"
|
||||
val_frozen = param.value[i]
|
||||
lb = p_min[i]
|
||||
ub = p_max[i]
|
||||
else:
|
||||
multi_idx = np.unravel_index(i, param.shape)
|
||||
idx_str = ",".join(str(x) for x in multi_idx)
|
||||
element_name = f"{param_name}[{idx_str}]"
|
||||
val_frozen = param.value[multi_idx]
|
||||
lb = p_min[i]
|
||||
ub = p_max[i]
|
||||
|
||||
if param.frozen:
|
||||
plot_items.append({
|
||||
"name": element_name,
|
||||
"value": val_frozen,
|
||||
"is_frozen": True,
|
||||
"lb": lb,
|
||||
"ub": ub,
|
||||
"nominal": val_frozen if self._x_nominal is not None else None,
|
||||
})
|
||||
else:
|
||||
conf = (
|
||||
self._confidence_intervals[non_frozen_idx]
|
||||
if self._confidence_intervals is not None
|
||||
else None
|
||||
)
|
||||
plot_items.append({
|
||||
"name": element_name,
|
||||
"value": self._x_hat[non_frozen_idx],
|
||||
"is_frozen": False,
|
||||
"lb": lb,
|
||||
"ub": ub,
|
||||
"nominal": (
|
||||
self._x_nominal[non_frozen_idx]
|
||||
if self._x_nominal is not None
|
||||
else None
|
||||
),
|
||||
"conf": conf,
|
||||
})
|
||||
non_frozen_idx += 1
|
||||
|
||||
n_params = len(plot_items)
|
||||
|
||||
n_cols = 8 if n_params > 1 else 1
|
||||
n_rows = math.ceil(n_params / n_cols)
|
||||
|
||||
# Prepare subplot titles with conditional formatting
|
||||
subplot_titles = []
|
||||
for p in plot_items:
|
||||
if p["is_frozen"]:
|
||||
subplot_titles.append(f"<span style='color: grey;'>{p['name']}*</span>")
|
||||
else:
|
||||
subplot_titles.append(p["name"])
|
||||
|
||||
# Create subplots
|
||||
fig = plt_subplots.make_subplots(
|
||||
rows=n_rows,
|
||||
cols=n_cols,
|
||||
subplot_titles=subplot_titles,
|
||||
vertical_spacing=0.05,
|
||||
horizontal_spacing=0.02,
|
||||
)
|
||||
|
||||
# Add dummy trace for Bounds legend
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=[None],
|
||||
y=[None],
|
||||
mode="lines",
|
||||
line=dict(color="blue", width=3, dash="dash"),
|
||||
name="Bounds",
|
||||
)
|
||||
)
|
||||
|
||||
shown_legends = set()
|
||||
|
||||
for i, item in enumerate(plot_items):
|
||||
row = (i // n_cols) + 1
|
||||
col = (i % n_cols) + 1
|
||||
|
||||
val = item["value"]
|
||||
lb, ub = item["lb"], item["ub"]
|
||||
|
||||
# 1. Bounds lines (For items with valid bounds)
|
||||
if lb is not None and ub is not None:
|
||||
# Lower Bound (Horizontal line at y=lb)
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=[-1, 1],
|
||||
y=[lb, lb],
|
||||
mode="lines",
|
||||
line=dict(color="blue", width=3, dash="dash"),
|
||||
showlegend=False,
|
||||
hoverinfo="skip",
|
||||
),
|
||||
row=row,
|
||||
col=col,
|
||||
)
|
||||
# Upper Bound (Horizontal line at y=ub)
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=[-1, 1],
|
||||
y=[ub, ub],
|
||||
mode="lines",
|
||||
line=dict(color="blue", width=3, dash="dash"),
|
||||
showlegend=False,
|
||||
hoverinfo="skip",
|
||||
),
|
||||
row=row,
|
||||
col=col,
|
||||
)
|
||||
|
||||
# Calculate margin based on bounds
|
||||
dist = ub - lb
|
||||
if dist > 0:
|
||||
margin = dist * 0.2
|
||||
y_range_min = lb
|
||||
y_range_max = ub
|
||||
else:
|
||||
margin = abs(val) * 0.2 if val != 0 else 1.0
|
||||
y_range_min = val
|
||||
y_range_max = val
|
||||
else:
|
||||
# Fallback
|
||||
margin = abs(val) * 0.2 if val != 0 else 1.0
|
||||
y_range_min = val
|
||||
y_range_max = val
|
||||
|
||||
# 2. Add Traces (Frozen vs Optimized)
|
||||
if item["is_frozen"]:
|
||||
# Frozen Parameter
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=[0],
|
||||
y=[val],
|
||||
mode="markers",
|
||||
marker=dict(
|
||||
symbol="circle", size=10, color="gray", opacity=0.7
|
||||
),
|
||||
name="Frozen",
|
||||
showlegend=False,
|
||||
hoverinfo="skip",
|
||||
),
|
||||
row=row,
|
||||
col=col,
|
||||
)
|
||||
else:
|
||||
# Optimized Parameter
|
||||
|
||||
# Determine Confidence Status
|
||||
trace_name = "Identified"
|
||||
marker_color = "blue"
|
||||
legend_group = "identified"
|
||||
|
||||
error_y = None
|
||||
|
||||
if item.get("conf") is not None:
|
||||
interval = item["conf"]
|
||||
min_bound = item["lb"]
|
||||
max_bound = item["ub"]
|
||||
|
||||
error_val = interval
|
||||
|
||||
rng = 0
|
||||
if min_bound is not None and max_bound is not None:
|
||||
rng = max_bound - min_bound
|
||||
|
||||
if not np.isfinite(interval) or (
|
||||
rng > 0 and 2.0 * interval > 1.0 * rng
|
||||
):
|
||||
# Unconstrained
|
||||
trace_name = "Unconstrained"
|
||||
marker_color = "red"
|
||||
legend_group = "unconstrained"
|
||||
|
||||
if rng > 0:
|
||||
error_val = rng
|
||||
else:
|
||||
error_val = interval # or large value call fallback
|
||||
|
||||
elif rng > 0 and interval <= 0.005 * rng:
|
||||
trace_name = "High Confidence"
|
||||
marker_color = "green"
|
||||
legend_group = "high_conf"
|
||||
|
||||
error_bar_color = marker_color
|
||||
error_y = dict(
|
||||
type="data",
|
||||
array=[error_val],
|
||||
visible=True,
|
||||
thickness=1.5,
|
||||
width=3,
|
||||
color=error_bar_color,
|
||||
)
|
||||
|
||||
show_leg = trace_name not in shown_legends
|
||||
if show_leg:
|
||||
shown_legends.add(trace_name)
|
||||
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=[0],
|
||||
y=[val],
|
||||
mode="markers",
|
||||
marker=dict(symbol="diamond", size=12, color=marker_color),
|
||||
name=trace_name,
|
||||
showlegend=show_leg,
|
||||
legendgroup=legend_group,
|
||||
hovertemplate=(
|
||||
f"{trace_name}: %{{y}} ±"
|
||||
f" {item.get('conf', 0):.4g}<extra></extra>"
|
||||
),
|
||||
error_y=error_y,
|
||||
),
|
||||
row=row,
|
||||
col=col,
|
||||
)
|
||||
|
||||
# Nominal Value (if exists)
|
||||
if item["nominal"] is not None:
|
||||
show_leg_nom = "Nominal" not in shown_legends
|
||||
if show_leg_nom:
|
||||
shown_legends.add("Nominal")
|
||||
|
||||
fig.add_trace(
|
||||
go.Scatter(
|
||||
x=[0],
|
||||
y=[item["nominal"]],
|
||||
mode="markers",
|
||||
marker=dict(symbol="x", size=10, color="red"),
|
||||
name="Nominal",
|
||||
showlegend=show_leg_nom,
|
||||
legendgroup="nominal",
|
||||
hovertemplate="Nominal: %{y}<extra></extra>",
|
||||
),
|
||||
row=row,
|
||||
col=col,
|
||||
)
|
||||
|
||||
fig.update_yaxes(
|
||||
range=[y_range_min - margin, y_range_max + margin],
|
||||
showgrid=True,
|
||||
zeroline=False,
|
||||
row=row,
|
||||
col=col,
|
||||
)
|
||||
fig.update_xaxes(
|
||||
range=[-1, 1],
|
||||
showgrid=False,
|
||||
zeroline=False,
|
||||
showticklabels=False,
|
||||
row=row,
|
||||
col=col,
|
||||
)
|
||||
|
||||
# Global Layout
|
||||
total_height = max(
|
||||
400, n_rows * 180
|
||||
) # Increased height per row for better spacing
|
||||
fig.update_layout(
|
||||
height=total_height,
|
||||
template="plotly_white",
|
||||
margin=dict(l=60, r=60, t=150, b=60), # Increased side margins
|
||||
autosize=True,
|
||||
legend=dict(
|
||||
orientation="h", yanchor="bottom", y=1.02, xanchor="center", x=0.5
|
||||
),
|
||||
hovermode="closest",
|
||||
)
|
||||
|
||||
return fig
|
||||
Reference in New Issue
Block a user