Skip to content

Commit dabf0e5

Browse files
committed
feat(vfpe): opt-in composed standardization for scale-equivariant FMPE/NPSE (#1680)
FMPE and NPSE amortized posteriors are not equivariant under an exact reparameterization theta -> c*theta when parameters are far from O(1) scale: the flow/SDE integrate against a unit base, so calibration collapses despite default z_score_theta. PR #1681 only touched the z_score_x off-branch (no-op on the default path). This adds an OPT-IN per-dim composed standardization (compose_standardization=True): the estimator is trained and sampled in standardized z-space with an invertible affine transform theta = shift + scale * z composed at the boundaries (loss input standardized; sample output unstandardized; log_prob corrected by the affine Jacobian; prior-support rejection in original theta). Per-dim handles HETEROGENEOUS scales (mixed O(1) and O(1e-6) dims). Default OFF => behavior byte-identical to current sbi. MAP raises NotImplementedError in compose mode (the score potential is z-space; documented limitation). Files: estimators/base.py (buffers+helpers), flowmatching_estimator.py + score_estimator.py (loss standardize), vector_field_posterior.py (unstandardize samples + MAP guard), vector_field_potential.py (log_prob Jacobian), net_builders/vector_field_nets.py (opt-in builder kwarg). Adds tests/test_scale_equivariance.py (homogeneous + heterogeneous + opt-in-off regression). Validated on editable clone: FMPE/NPSE recover equivariance at s=1e-5 (SDE+ODE); canonical BoxUniform two_moons+SLCP recover (miscal ~0.02 vs default ~0.48); full non-slow pytest 6062 passed / 0 failed.
1 parent 9cf0d9e commit dabf0e5

7 files changed

Lines changed: 399 additions & 21 deletions

File tree

sbi/inference/posteriors/vector_field_posterior.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,18 @@ def sample(
235235
x = self._x_else_default_x(x)
236236
x = reshape_to_batch_event(x, self.vector_field_estimator.condition_shape)
237237
is_iid = x.shape[0] > 1
238+
# Composed standardization integrates the SDE/ODE in z-space; the iid and
239+
# guidance score combinations evaluate the (theta-space) prior on the z-space
240+
# state, which is not yet transformed. Guard explicitly instead of returning
241+
# silently-wrong samples. (Single-observation sampling is fully supported.)
242+
if self.vector_field_estimator._compose_standardization and (
243+
is_iid or guidance_method is not None
244+
):
245+
raise NotImplementedError(
246+
"compose_standardization does not yet support iid (x with batch>1) or "
247+
"guided sampling. Use a single observation, or disable "
248+
"compose_standardization."
249+
)
238250
self.potential_fn.set_x(
239251
x,
240252
x_is_iid=is_iid,
@@ -401,6 +413,13 @@ def _sample_via_diffusion(
401413
"This may indicate numerical instability in the vector field."
402414
)
403415

416+
# Composed standardization (opt-in): the estimator samples in z-space.
417+
# Unstandardize theta = shift + scale * z before returning so that
418+
# rejection (within_support on the prior) and the final samples are in
419+
# original theta space. No-op when the flag is off.
420+
if self.vector_field_estimator._compose_standardization:
421+
samples = self.vector_field_estimator.from_z(samples)
422+
404423
return samples
405424

406425
def sample_via_ode(
@@ -429,6 +448,13 @@ def sample_via_ode(
429448
torch.Size((num_samples,))
430449
)
431450

451+
# Composed standardization (opt-in): the ODE samples in z-space.
452+
# Unstandardize theta = shift + scale * z before returning so that
453+
# rejection (within_support on the prior) and the final samples are in
454+
# original theta space. No-op when the flag is off.
455+
if self.vector_field_estimator._compose_standardization:
456+
samples = self.vector_field_estimator.from_z(samples)
457+
432458
return samples
433459

434460
def log_prob(
@@ -457,6 +483,15 @@ def log_prob(
457483
x = self._x_else_default_x(x)
458484
x = reshape_to_batch_event(x, self.vector_field_estimator.condition_shape)
459485
is_iid = x.shape[0] > 1
486+
# See sample(): the iid score combination evaluates the theta-space prior on the
487+
# z-space state under composed standardization. Guard rather than return wrong
488+
# values; single-observation log_prob is exact (affine Jacobian correction).
489+
if self.vector_field_estimator._compose_standardization and is_iid:
490+
raise NotImplementedError(
491+
"compose_standardization does not yet support iid (x with batch>1) "
492+
"log_prob. Use a single observation, or disable "
493+
"compose_standardization."
494+
)
460495
self.potential_fn.set_x(x, x_is_iid=is_iid, **(ode_kwargs or {}))
461496

462497
theta = ensure_theta_batched(torch.as_tensor(theta))
@@ -647,6 +682,13 @@ def map(
647682
Returns:
648683
The MAP estimate.
649684
"""
685+
if self.vector_field_estimator._compose_standardization:
686+
raise NotImplementedError(
687+
"MAP is not yet supported with compose_standardization. "
688+
"The potential gradient is computed in standardized z-space, so "
689+
"gradient ascent in theta-space would be incorrect."
690+
)
691+
650692
if x is not None:
651693
raise ValueError(
652694
"Passing `x` directly to `.map()` has been deprecated."

sbi/inference/potentials/vector_field_potential.py

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,25 @@ def set_x(
118118
`IIDScoreFunction`.
119119
ode_kwargs: Additional keyword arguments for the neural ODE.
120120
"""
121+
# Composed standardization runs the SDE/ODE in z-space; the iid and guided
122+
# score combinations evaluate the (theta-space) prior on the z-space state,
123+
# which is not transformed. Guard at this chokepoint so the broken combination
124+
# cannot be reached even via direct potential use (not just via
125+
# VectorFieldPosterior.sample/log_prob).
126+
if bool(
127+
getattr(self.vector_field_estimator, "_compose_standardization", False)
128+
):
129+
if x_is_iid:
130+
raise NotImplementedError(
131+
"compose_standardization does not yet support iid (x with "
132+
"batch>1). Use a single observation, or disable "
133+
"compose_standardization."
134+
)
135+
if guidance_method is not None:
136+
raise NotImplementedError(
137+
"compose_standardization does not yet support guided sampling. "
138+
"Disable guidance, or disable compose_standardization."
139+
)
121140
super().set_x(x_o, x_is_iid)
122141
self.iid_method = iid_method or self.iid_method
123142
self.iid_params = iid_params
@@ -155,6 +174,17 @@ def __call__(
155174
)
156175
self.vector_field_estimator.eval()
157176

177+
# Composed standardization (opt-in): the flow operates in z-space. Map the
178+
# incoming theta -> z for the flow, and correct the log-prob with the
179+
# affine Jacobian: log p_theta(theta) = log p_z(z) - sum(log scale).
180+
# within_support below still uses the ORIGINAL theta. No-op when off.
181+
compose = self.vector_field_estimator._compose_standardization
182+
if compose:
183+
flow_input = self.vector_field_estimator.to_z(theta_density_estimator)
184+
log_abs_det = self.vector_field_estimator.log_abs_det()
185+
else:
186+
flow_input = theta_density_estimator
187+
158188
with torch.set_grad_enabled(track_gradients):
159189
if self.x_is_iid:
160190
assert self.prior is not None, (
@@ -166,21 +196,29 @@ def __call__(
166196
num_iid = self.x_o.shape[0] # number of iid samples
167197
iid_posteriors_prob = torch.sum(
168198
torch.stack(
169-
[
170-
flow.log_prob(theta_density_estimator).squeeze(-1)
171-
for flow in self.flows
172-
],
199+
[flow.log_prob(flow_input).squeeze(-1) for flow in self.flows],
173200
dim=0,
174201
),
175202
dim=0,
176203
)
204+
if compose:
205+
# NOTE: currently UNREACHABLE -- composed standardization
206+
# guards iid in `set_x` (the iid score combination would
207+
# evaluate the theta-space prior on a z-space state). Retained
208+
# for future iid support: each of the num_iid z-space flow
209+
# log-probs is corrected by the affine Jacobian. (The
210+
# prior-coordinate transform still needs doing before the guard
211+
# is lifted.)
212+
iid_posteriors_prob = iid_posteriors_prob - num_iid * log_abs_det
177213
# Apply the adjustment for iid observations i.e. we have to subtract
178214
# (num_iid-1) times the log prior.
179215
log_probs = iid_posteriors_prob - (num_iid - 1) * self.prior.log_prob(
180216
theta_density_estimator
181217
).squeeze(-1)
182218
else:
183-
log_probs = self.flow.log_prob(theta_density_estimator).squeeze(-1)
219+
log_probs = self.flow.log_prob(flow_input).squeeze(-1)
220+
if compose:
221+
log_probs = log_probs - log_abs_det
184222
# Force probability to be zero outside prior support.
185223
in_prior_support = within_support(self.prior, theta)
186224

sbi/neural_nets/estimators/base.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,38 @@ def __init__(
386386
embedding_net if embedding_net is not None else nn.Identity()
387387
)
388388

389+
# ---- Composed standardization (opt-in; default OFF) ----
390+
# When enabled, the estimator stays PURE z-space and an invertible per-dim
391+
# affine transform theta = shift + scale * z is applied ONLY at the
392+
# boundaries (loss input, sample output, log_prob input). Buffers default
393+
# to the identity transform so the flag-OFF path is byte-identical.
394+
self.register_buffer(
395+
"_theta_shift", torch.zeros(1, *self.input_shape, dtype=torch.float32)
396+
)
397+
self.register_buffer(
398+
"_theta_scale", torch.ones(1, *self.input_shape, dtype=torch.float32)
399+
)
400+
# Persisted as a buffer (not a plain attribute) so that compose mode survives
401+
# save/load: a checkpoint trained with composition reloads as compose-enabled.
402+
self.register_buffer(
403+
"_compose_standardization", torch.tensor(False), persistent=True
404+
)
405+
406+
def _load_from_state_dict(self, state_dict, prefix, *args, **kwargs):
407+
r"""Gracefully load checkpoints from before composed standardization existed.
408+
409+
Old checkpoints lack the ``_theta_shift``/``_theta_scale``/
410+
``_compose_standardization`` buffers. Rather than failing a strict load, inject
411+
the current (identity / disabled) defaults for any missing compose buffer so the
412+
legacy estimator loads as composition-OFF (its original behavior). Newer
413+
checkpoints carry the buffers and load normally.
414+
"""
415+
for name in ("_theta_shift", "_theta_scale", "_compose_standardization"):
416+
key = prefix + name
417+
if key not in state_dict and hasattr(self, name):
418+
state_dict[key] = getattr(self, name).clone()
419+
super()._load_from_state_dict(state_dict, prefix, *args, **kwargs)
420+
389421
@property
390422
def embedding_net(self) -> nn.Module:
391423
r"""Return the embedding network if it exists."""
@@ -420,6 +452,33 @@ def std_base(self) -> Tensor:
420452
(the initial noise at time t=T)."""
421453
return self._std_base
422454

455+
# -------------------------- COMPOSED STANDARDIZATION HELPERS ----------------
456+
457+
def to_z(self, theta: Tensor) -> Tensor:
458+
r"""Map original-space parameters to standardized z-space.
459+
460+
``z = (theta - shift) / scale``. Identity when composed standardization
461+
is disabled (shift=0, scale=1).
462+
"""
463+
return (theta - self._theta_shift) / self._theta_scale
464+
465+
def from_z(self, z: Tensor) -> Tensor:
466+
r"""Map standardized z-space parameters back to original space.
467+
468+
``theta = shift + scale * z``. Identity when composed standardization
469+
is disabled (shift=0, scale=1).
470+
"""
471+
return self._theta_shift + self._theta_scale * z
472+
473+
def log_abs_det(self) -> Tensor:
474+
r"""Log absolute determinant of the affine z->theta Jacobian.
475+
476+
``sum(log scale)`` over the input dimensions. Used to correct the
477+
flow log-prob (which is computed in z-space) back to theta-space:
478+
``log p_theta(theta) = log p_z(z) - sum(log scale)``.
479+
"""
480+
return torch.log(self._theta_scale).sum()
481+
423482
# -------------------------- ODE METHODS --------------------------
424483

425484
@abstractmethod

sbi/neural_nets/estimators/flowmatching_estimator.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,12 @@ def loss(
283283
Returns:
284284
Loss value.
285285
"""
286+
# Composed standardization (opt-in): the estimator is trained PURELY in
287+
# z-space. Standardize theta -> z at the very top; everything below is
288+
# unchanged. When the flag is off, this is a no-op (shift=0, scale=1).
289+
if self._compose_standardization:
290+
input = self.to_z(input)
291+
286292
# Randomly sample time steps
287293
if times is None:
288294
times = torch.rand(input.shape[:-1], device=input.device, dtype=input.dtype)

sbi/neural_nets/estimators/score_estimator.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,12 @@ def loss(
248248
MSE between target score and network output, scaled by the weight function.
249249
250250
"""
251+
# Composed standardization (opt-in): the estimator is trained PURELY in
252+
# z-space. Standardize theta -> z at the very top; everything below is
253+
# unchanged. When the flag is off, this is a no-op (shift=0, scale=1).
254+
if self._compose_standardization:
255+
input = self.to_z(input)
256+
251257
# Sample times from the Markov chain, use batch dimension
252258
if times is None:
253259
times = self.train_schedule(input.shape[0])

sbi/neural_nets/net_builders/vector_field_nets.py

Lines changed: 59 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ class _VectorFieldBaseConfig(_EstimatorConfigBase):
4141
sinusoidal_max_freq: Optional[float] = None
4242
fourier_scale: Optional[float] = None
4343

44+
# Composed standardization (opt-in scale-equivariance for FMPE / NPSE).
45+
# When True, the estimator is trained/sampled in standardized z-space with an
46+
# invertible per-dim affine transform applied only at the boundaries.
47+
compose_standardization: Optional[bool] = None
48+
4449
# MLP-specific
4550
layer_norm: Optional[bool] = None
4651
skip_connections: Optional[bool] = None
@@ -126,6 +131,7 @@ def build_vector_field_estimator(
126131
VectorFieldNet,
127132
] = "mlp",
128133
gaussian_baseline: bool = False,
134+
compose_standardization: bool = False,
129135
**kwargs,
130136
) -> Union[FlowMatchingEstimator, ConditionalScoreEstimator]:
131137
"""Builds a vector field estimator (flow matching or score matching) with the given
@@ -151,6 +157,12 @@ def build_vector_field_estimator(
151157
gaussian_baseline: If True, use analytical Gaussian baseline velocity
152158
derived from Bayes' rule. The network then only learns the residual.
153159
Only used when estimator_type="flow". Defaults to False.
160+
compose_standardization: If True (opt-in), compose an invertible per-dim
161+
affine standardization with the flow so the estimator becomes
162+
scale-equivariant. The estimator is trained and sampled in
163+
standardized z-space (per-dim mean/std computed from training theta),
164+
and theta = shift + scale * z is applied only at the boundaries.
165+
Defaults to False (behavior identical to current sbi).
154166
**kwargs: Additional arguments forwarded to the estimator and network
155167
constructors. Valid keys are defined by ``ScoreEstimatorConfig``
156168
and ``FlowEstimatorConfig``; validation happens in the upstream
@@ -225,22 +237,51 @@ def build_vector_field_estimator(
225237
else:
226238
mean_0, std_0 = 0, 1
227239

240+
# Composed standardization (opt-in): compute the per-dim affine transform
241+
# theta = shift + scale * z from the training theta. The estimator stays PURE
242+
# z-space, so we force the internal input-norm stats (mean_0/std_0) to 0/1 to
243+
# be consistent with unit-z input. The base distribution is left in z-space
244+
# (FMPE 0/1, VE sigma_max); we do NOT scale the base by `scale`.
245+
compose_shift = None
246+
compose_scale = None
247+
if compose_standardization:
248+
# Per-dim standardization (independent), matching the validated reference.
249+
compose_shift, compose_scale = z_standardization(batch_x, structured_dims=False)
250+
compose_scale = compose_scale.clamp_min(1e-20)
251+
# Force the internal input-norm stats to unit (the estimator now sees z).
252+
# Use PER-DIM tensors (not scalars) so the VE base buffers (mean_t/std_t,
253+
# computed via broadcast_to) stay contiguous and load_state_dict-safe.
254+
mean_0 = torch.zeros_like(compose_shift)
255+
std_0 = torch.ones_like(compose_scale)
256+
228257
z_score_y_bool, structured_y = z_score_parser(z_score_y)
229258
embedding_net_y = (
230259
nn.Sequential(standardizing_net(batch_y, structured_y), embedding_net)
231260
if z_score_y_bool
232261
else embedding_net
233262
)
234263

264+
def _wire_compose(estimator):
265+
"""Wire the per-dim affine standardization onto the estimator (opt-in)."""
266+
if compose_shift is not None and compose_scale is not None:
267+
shift = compose_shift.reshape(1, *estimator.input_shape).float()
268+
scale = compose_scale.reshape(1, *estimator.input_shape).float()
269+
estimator._theta_shift.copy_(shift)
270+
estimator._theta_scale.copy_(scale)
271+
estimator._compose_standardization.fill_(True)
272+
return estimator
273+
235274
if estimator_type == "flow":
236-
return FlowMatchingEstimator(
237-
net=vectorfield_net,
238-
input_shape=batch_x[0].shape,
239-
condition_shape=batch_y[0].shape,
240-
embedding_net=embedding_net_y,
241-
mean_0=mean_0,
242-
std_0=std_0,
243-
gaussian_baseline=gaussian_baseline,
275+
return _wire_compose(
276+
FlowMatchingEstimator(
277+
net=vectorfield_net,
278+
input_shape=batch_x[0].shape,
279+
condition_shape=batch_y[0].shape,
280+
embedding_net=embedding_net_y,
281+
mean_0=mean_0,
282+
std_0=std_0,
283+
gaussian_baseline=gaussian_baseline,
284+
)
244285
)
245286
elif estimator_type == "score":
246287
# Choose the appropriate score estimator based on SDE type
@@ -272,14 +313,16 @@ def build_vector_field_estimator(
272313
vp_keys = ["beta_min", "beta_max"]
273314
estimator_kwargs = {k: kwargs[k] for k in vp_keys if k in kwargs}
274315

275-
return estimator_cls(
276-
net=vectorfield_net,
277-
input_shape=batch_x[0].shape,
278-
condition_shape=batch_y[0].shape,
279-
embedding_net=embedding_net_y,
280-
mean_0=mean_0,
281-
std_0=std_0,
282-
**estimator_kwargs,
316+
return _wire_compose(
317+
estimator_cls(
318+
net=vectorfield_net,
319+
input_shape=batch_x[0].shape,
320+
condition_shape=batch_y[0].shape,
321+
embedding_net=embedding_net_y,
322+
mean_0=mean_0,
323+
std_0=std_0,
324+
**estimator_kwargs,
325+
)
283326
)
284327
else:
285328
raise ValueError(f"Unknown estimator type: {estimator_type}")

0 commit comments

Comments
 (0)