Skip to content

Commit a200ec6

Browse files
authored
Sensitivity analysis MVP: MNPE/NPE posterior (#729)
## Summary Sensitivity analysis toolbox (MNPE/NPE) _running on synthetic data_. From an eval sweep's per-episode results, learn which environment conditions drive success: Fit a posterior over the varied factors, conditioned on the outcome, and render one summary figure. Inspired by [robolab](https://gitlab-master.nvidia.com/xuningy/robolab/-/tree/main?ref_type=heads), but the factors now come from a factors.yaml and are piped generically, allowing for arbitrary continuous/categorical factor mixes. <img width="667" height="447" alt="Screenshot from 2026-06-11 16-59-44" src="https://github.com/user-attachments/assets/1e29f108-75f4-4f87-b551-f21f0ca20f5c" /> ## Detailed description - `factors.yaml` declares the varied factors and ranges. This file will be auto generated by the Variation System and could be moved into one time write into the same output file. For now its hand crafted. - `SensitivitDataset` genericatlly handling n dimensional factors, categorical and continous - `SensitivityAnalyzer` auto-selects **MNPE** (mixed continuous + categorical) or **NPE** (continuous-only), trains on the full `(theta, x)`, and samples the joint posterior at a chosen observation. Continuous factors are normalized so factors on very different scales train on equal footing. - `generate_report` produces one figure, a density curve per continuous factor, a probability bar per categorical, saved by file extension (`.png`/`.pdf`). - A synthetic ground-truth simulator (`synthetic.py`) `python -m isaaclab_arena.analysis.sensitivity.synthetic --kind {mixed,continuous,rich}` runs the whole pipeline in one command. Next: Plug in real sim/ variation pipeline --------- Signed-off-by: Clemens Volk <cvolk@nvidia.com>
1 parent 6473d3c commit a200ec6

11 files changed

Lines changed: 1209 additions & 0 deletions

File tree

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
Sensitivity Analysis
2+
====================
3+
4+
The sensitivity-analysis toolbox answers a single question about a policy:
5+
*which environment conditions drive success?* Given the per-episode results of an
6+
evaluation sweep — where factors such as lighting, object mass, or table material were
7+
varied — it fits a posterior over those factors conditioned on the outcome (e.g. success
8+
rate) and renders one figure summarising which factor values are associated with success.
9+
10+
Two distinct ideas are at work. *Joint* means all factors are modelled together rather than
11+
one at a time, which is what captures interactions and confounds (see the next section).
12+
*Posterior* means the result is conditioned on the outcome: starting from the prior — the
13+
factor values the sweep actually drew, uniform over the declared ranges — it reweights them
14+
by how often each led to the chosen outcome. So the figure answers *given success, which
15+
factor values were in play?*, not merely *how were the factors distributed in the sweep?*
16+
17+
Why a joint posterior, not a success rate per factor?
18+
-----------------------------------------------------
19+
20+
The simplest analysis would chart a success rate for each factor independently. That hides
21+
the two things that matter most in a multi-factor sweep:
22+
23+
- **Factors interact.** How much light a policy needs can depend on the object — a matte
24+
object may succeed at low light while a shiny one needs far more. A per-factor
25+
"success vs light" curve averages over objects and reports one blurry gate that is wrong
26+
for both. The joint posterior keeps the interaction, so you can condition on a specific
27+
object and see its gate.
28+
- **Factors confound each other.** If bright-light episodes also happened to use an easy
29+
object, a per-factor light chart cannot tell which one drove success. Modelling all
30+
factors together attributes the effect to the factor that actually carries it.
31+
32+
The per-factor rate is a projection of the joint posterior — derivable from it, but not the
33+
other way around. The toolbox therefore always fits the joint — via simulation-based
34+
inference (MNPE or NPE) — and reads the per-factor marginals from it.
35+
36+
How it works
37+
------------
38+
39+
The toolbox is a thin analysis layer over `sbi <https://sbi.readthedocs.io>`_'s
40+
neural posterior estimators. The flow is:
41+
42+
1. **Per-episode input.** The analysis reads an ``episode_summary.jsonl`` — one row per
43+
episode, holding that episode's factor values and outcomes.
44+
2. **Schema.** A ``factors.yaml`` declares the *factors* — which ``arena_env_args`` columns
45+
were varied and whether each is continuous or categorical, plus the continuous ranges
46+
that were swept (so the analyzer's prior matches the simulation). It does **not** list
47+
outcomes — *which* outcome to condition on is chosen at analysis time, not saved here.
48+
3. **Inference.** ``SensitivityAnalyzer`` loads the pair, trains an estimator on the full
49+
``(theta, x)`` jointly — sbi's terms for the factor values (``theta``) and the per-episode
50+
outcomes (``x``) — and samples the joint posterior conditioned on a chosen observation
51+
(by default, success).
52+
4. **Report.** A probability density curve for each continuous factor and a probability bar
53+
chart for each categorical factor.
54+
55+
.. todo::
56+
57+
The eval-runner writer (``episode_writer``) that emits ``episode_summary.jsonl`` during
58+
evaluation is not part of this version — it lands in a follow-up. For now, run the analysis
59+
on synthetic data (see below) or on a JSONL produced externally.
60+
61+
Inputs
62+
------
63+
64+
**factors.yaml** declares only the factors that were varied (and the continuous ranges that
65+
were swept). Outcomes are not declared here — they're selected at analysis time (see below):
66+
67+
.. code-block:: yaml
68+
69+
factors:
70+
light_intensity:
71+
type: continuous
72+
range: [[0.0, 5000.0]] # the swept range; inferred from the data's min/max if omitted
73+
table_material:
74+
type: categorical
75+
choices: [oak, walnut, bamboo]
76+
77+
**episode_summary.jsonl** holds one JSON object per episode. It carries every measured
78+
outcome; the analysis picks which one(s) to condition on:
79+
80+
.. code-block:: json
81+
82+
{"job_name": "pi0_sweep", "episode_idx": 0,
83+
"arena_env_args": {"light_intensity": 3200.0, "table_material": "oak"},
84+
"outcomes": {"success": 1}}
85+
86+
Choice of estimator
87+
-------------------
88+
89+
``SensitivityAnalyzer`` picks the estimator from the schema automatically:
90+
91+
.. list-table::
92+
:header-rows: 1
93+
:widths: 25 25 50
94+
95+
* - Schema
96+
- Estimator
97+
- Notes
98+
* - Any categorical factor
99+
- MNPE
100+
- Mixed density estimator; handles continuous + categorical factors together.
101+
* - All continuous factors
102+
- NPE
103+
- Restricts to a Gaussian on a single factor, so a meaningful continuous-only
104+
analysis needs at least two continuous factors.
105+
106+
Continuous factors are normalised to ``[0, 1]`` before fitting and de-normalised when
107+
sampling, so factors on very different scales (e.g. light in the thousands, an offset in
108+
the hundredths) train on equal footing. Outcomes are binary (0/1); the default query
109+
conditions on success (1).
110+
111+
Running a report
112+
----------------
113+
114+
Point the report generator at a ``(factors.yaml, episode_summary.jsonl)`` pair. The output
115+
format follows the file extension (``.png``, ``.pdf``, …); reports are written under
116+
``eval/`` by default.
117+
118+
.. code-block:: bash
119+
120+
python -m isaaclab_arena.analysis.sensitivity.generate_report \
121+
--factors_yaml factors.yaml \
122+
--episode_summary episode_summary.jsonl \
123+
--outcome success \
124+
--output eval/sensitivity_report.png
125+
126+
``--outcome`` selects which per-episode outcome(s) to condition on (keys in the rows'
127+
``outcomes`` block); it defaults to ``success``. Pass ``--observation`` to set the value
128+
per outcome — since outcomes are binary, use ``1`` for success or ``0`` for failure; it
129+
defaults to ``1`` (success).
130+
131+
Trying it on synthetic data
132+
---------------------------
133+
134+
A synthetic simulator with a *known* ground truth lets you run the whole pipeline without
135+
Isaac Sim — useful for seeing the output shape and for validating the toolbox
136+
(the recovered posterior should reflect the planted relationship):
137+
138+
.. code-block:: bash
139+
140+
# mixed: three continuous + two categorical factors (MNPE)
141+
python -m isaaclab_arena.tests.sensitivity_synthetic --kind mixed --output eval/demo.png
142+
143+
``--kind`` also accepts ``continuous`` (continuous-only factors, which exercises the NPE path).
144+
145+
Reading the output
146+
------------------
147+
148+
.. todo::
149+
150+
Add a sample report figure here and walk through reading it.
151+
152+
Each panel is the posterior over one factor *conditioned on success*. Intuitively it answers
153+
"given the policy succeeded, which values of this factor were responsible?" More precisely,
154+
among the successful episodes it shows the probability density that the factor took each
155+
value. For a continuous factor, mass concentrated at one end of its range means success
156+
favoured that end — e.g. a curve rising toward bright light means successful episodes were
157+
almost all bright ones, i.e. the policy needs bright light to succeed.
158+
For a categorical factor, the tallest bar is the value most associated with success.
159+
160+
Current scope
161+
-------------
162+
163+
- Outcomes are treated as **binary** (0/1). Conditioning defaults to success; a continuous
164+
outcome is rejected with a clear error rather than silently averaged.
165+
- Continuous **vector** factors (``dim > 1``) are reserved for a future extension. The likely
166+
approach is to record scalar reductions (e.g. a norm or distance-to-reference) alongside the
167+
raw vector, so a pose or RGB factor becomes one or more analysable scalar columns.
168+
- The estimators run on CPU and do not require Isaac Sim, so a report can be generated
169+
anywhere the evaluation JSONL is available.
170+
- The analysis assumes the ``episode_summary.jsonl`` is a single coherent slice — one
171+
policy, task, and embodiment. **TODO:** add a filter (in the spirit of robolab's
172+
``--filter-policy`` / ``--filter-task``) to select that slice from a larger JSONL,
173+
rather than relying on the caller to pre-filter it.

docs/pages/concepts/policy/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,4 @@ More details
9191
:maxdepth: 1
9292

9393
concept_evaluation_types
94+
concept_sensitivity_analysis
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Copyright (c) 2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md).
2+
# All rights reserved.
3+
#
4+
# SPDX-License-Identifier: Apache-2.0
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Copyright (c) 2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md).
2+
# All rights reserved.
3+
#
4+
# SPDX-License-Identifier: Apache-2.0
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# Copyright (c) 2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md).
2+
# All rights reserved.
3+
#
4+
# SPDX-License-Identifier: Apache-2.0
5+
6+
from __future__ import annotations
7+
8+
import torch
9+
10+
from sbi.inference import MNPE, NPE
11+
from sbi.utils import BoxUniform
12+
13+
from isaaclab_arena.analysis.sensitivity.dataset import SensitivityDataset
14+
15+
16+
class SensitivityAnalyzer:
17+
"""Fits a neural posterior over all factors, conditioned on all outcomes.
18+
19+
Picks the sbi estimator from the schema:
20+
21+
- MNPE when any factor is categorical (it handles mixed continuous + categorical theta).
22+
- NPE when every factor is continuous.
23+
24+
Following sbi's convention, ``theta`` is the per-episode factor values (the inputs the
25+
posterior is inferred over) and ``x`` is the per-episode outcomes (the observations a query
26+
conditions on). It trains on the full (theta, x) and samples the joint posterior at a chosen
27+
observation. The single observation conditions on *all* outcome columns at once, so a
28+
query like "which factors produced success?" is answered for every factor jointly.
29+
30+
Continuous factors are normalized to [0, 1] before fitting and denormalized when
31+
sampling, so factors on very different scales (e.g. light in thousands, an offset in
32+
hundredths) train on equal footing. Categorical columns keep their integer codes.
33+
"""
34+
35+
def __init__(self, dataset: SensitivityDataset):
36+
self.dataset = dataset
37+
self.posterior = None
38+
continuous_factors = [factor for factor in dataset.schema.factors if factor.type == "continuous"]
39+
# theta is laid out continuous-first then categorical — built that way by
40+
# SensitivityDataset and defined by FactorSchema.factor_columns — so the leading
41+
# self._num_continuous columns are the continuous factors that _normalize/_denormalize slice.
42+
self._num_continuous = len(continuous_factors)
43+
for factor in continuous_factors:
44+
assert factor.range is not None, (
45+
f"Continuous factor {factor.name!r} has no range to normalize against. Declare a"
46+
" range in factors.yaml, or build the dataset via from_files()/from_file() so the"
47+
" range is inferred from the data before constructing the analyzer."
48+
)
49+
self._continuous_low = torch.tensor([factor.range[0][0] for factor in continuous_factors])
50+
self._continuous_high = torch.tensor([factor.range[0][1] for factor in continuous_factors])
51+
52+
def _select_inference_class(self):
53+
"""Choose the sbi inference class for this schema.
54+
55+
Returns MNPE when any factor is categorical (its mixed density estimator handles
56+
continuous + categorical theta together), and NPE when every factor is continuous.
57+
"""
58+
return MNPE if self.dataset.has_categorical_factors else NPE
59+
60+
def _normalized_prior(self):
61+
"""Uniform prior matching the normalized theta: continuous dims [0, 1], categoricals [0, k-1]."""
62+
low_bounds = [0.0] * self._num_continuous
63+
high_bounds = [1.0] * self._num_continuous
64+
for factor in self.dataset.schema.factors:
65+
if factor.type == "categorical":
66+
low_bounds.append(0.0)
67+
high_bounds.append(float(len(factor.choices) - 1))
68+
return BoxUniform(low=torch.tensor(low_bounds), high=torch.tensor(high_bounds))
69+
70+
def _normalize(self, theta: torch.Tensor) -> torch.Tensor:
71+
"""Scale the continuous (leading) theta columns to [0, 1]; leave categoricals untouched."""
72+
normalized = theta.clone()
73+
span = (self._continuous_high - self._continuous_low).clamp_min(1e-12)
74+
normalized[:, : self._num_continuous] = (theta[:, : self._num_continuous] - self._continuous_low) / span
75+
return normalized
76+
77+
def _denormalize(self, theta: torch.Tensor) -> torch.Tensor:
78+
"""Inverse of _normalize: map the continuous columns back to their original ranges."""
79+
denormalized = theta.clone()
80+
span = self._continuous_high - self._continuous_low
81+
denormalized[:, : self._num_continuous] = theta[:, : self._num_continuous] * span + self._continuous_low
82+
return denormalized
83+
84+
def fit(self, training_batch_size: int = 50):
85+
"""Train the estimator on the full (theta, x); store and return the fitted posterior."""
86+
print(
87+
f"[INFO] SensitivityAnalyzer: fitting {self._select_inference_class().__name__} on"
88+
f" {self.dataset.num_episodes} episodes"
89+
f" (theta dim={self.dataset.theta.shape[1]}, x dim={self.dataset.x.shape[1]})."
90+
)
91+
inference = self._select_inference_class()(prior=self._normalized_prior())
92+
inference.append_simulations(self._normalize(self.dataset.theta), self.dataset.x)
93+
density_estimator = inference.train(training_batch_size=training_batch_size)
94+
self.posterior = inference.build_posterior(density_estimator)
95+
return self.posterior
96+
97+
def sample_posterior(self, observation: torch.Tensor | None = None, num_samples: int = 5000) -> torch.Tensor:
98+
"""Sample the joint posterior over all factors at observation.
99+
100+
Defaults to the dataset's default observation (condition on success). Returns a
101+
(num_samples, total_factor_dim) tensor laid out like theta — continuous columns first
102+
(in original, denormalized units), then integer-coded categorical columns.
103+
"""
104+
assert self.posterior is not None, "Call fit() before sampling the posterior"
105+
if observation is None:
106+
observation = self.dataset.default_observation()
107+
with torch.no_grad():
108+
normalized_samples = self.posterior.sample((num_samples,), x=observation)
109+
return self._denormalize(normalized_samples)

0 commit comments

Comments
 (0)