Skip to content

Product Prior

Product of independent marginal distributions with latent space transformations.

Overview

Product defines a prior as a product of independent 1D marginals, each with a bijective map to a chosen latent space. It extends the abstract base class TransformedPrior, which defines the forward/inverse interface.

Product supports two latent-space modes:

  • "hypercube": Maps to/from a bounded hypercube (used with Flow)
  • "standard_normal": Maps to/from standard normal space (used with GaussianFullCov)

Supported Distributions

Type Parameters Description
uniform low, high Uniform distribution over [low, high]
normal mean, std Gaussian distribution
cosine low, high Distribution with pdf ∝ cos(θ) — use for inclination-like angles
sine low, high Distribution with pdf ∝ sin(θ) — use for declination-like angles
uvol low, high Uniform-in-volume (pdf ∝ r²) — use for a radial coordinate in 3D
triangular a, c, b Triangular distribution (min a, mode c, max b)
lognormal mean, std Log-normal distribution. Only supported with "standard_normal" mode; raises ValueError with "hypercube" mode
fixed value Fixed (non-inferred) parameter — excluded from the latent space

Fixed Parameters

Use the fixed distribution type to hold a parameter constant. Fixed parameters are excluded from the inferred parameter space but still appear in the full parameter vector passed to the simulator:

simulator:
  _target_: falcon.priors.Product
  priors:
    - ['normal', 0.0, 1.0]     # Inferred
    - ['fixed', 3.14]           # Held constant
    - ['uniform', -1.0, 1.0]   # Inferred

Usage

from falcon.priors import Product

prior = Product(
    priors=[
        ('normal', 0.0, 1.0),
        ('uniform', -10.0, 10.0),
    ]
)

# Sample from prior — output shape is (1000, prior.full_param_dim)
samples = prior.simulate_batch(1000)

# Transform to/from latent space
z = prior.inverse(samples, mode="standard_normal")
x = prior.forward(z, mode="standard_normal")

param_dim vs full_param_dim

prior.param_dim is the number of free (non-fixed) parameters — the dimension of the latent space seen by estimators. prior.full_param_dim is the total output dimension including fixed parameters. The simulator always receives the full vector.

YAML Configuration

With Flow estimator

simulator:
  _target_: falcon.priors.Product
  priors:
    - ['uniform', -100.0, 100.0]
    - ['uniform', -100.0, 100.0]

With GaussianFullCov estimator

simulator:
  _target_: falcon.priors.Product
  priors:
    - ['normal', 0.0, 1.0]
    - ['normal', 0.0, 1.0]

Class Reference

TransformedPrior

Bases: ABC

Base class for priors that support latent space transformations.

Subclasses must implement forward() and inverse() with a mode parameter: - forward(z, mode): latent space -> parameter space - inverse(x, mode): parameter space -> latent space

Modes
  • "hypercube": Maps to/from bounded hypercube. Use with Flow estimator.
  • "standard_normal": Maps to/from N(0, I). Use with Gaussian estimator.

This base class is used for type checking in estimators like Gaussian that require the transformation interface.

param_dim abstractmethod property

param_dim

Dimension of the parameter space.

forward abstractmethod

forward(z, mode='hypercube')

Transform from latent space to parameter space.

Source code in src/falcon/priors/product.py
@abstractmethod
def forward(self, z, mode: str = "hypercube"):
    """Transform from latent space to parameter space."""
    pass

inverse abstractmethod

inverse(x, mode='hypercube')

Transform from parameter space to latent space.

Source code in src/falcon/priors/product.py
@abstractmethod
def inverse(self, x, mode: str = "hypercube"):
    """Transform from parameter space to latent space."""
    pass

simulate_batch abstractmethod

simulate_batch(batch_size)

Sample from the prior distribution.

Source code in src/falcon/priors/product.py
@abstractmethod
def simulate_batch(self, batch_size: int):
    """Sample from the prior distribution."""
    pass

Product

Product(priors=[], hypercube_range=[-2, 2], marginalize=None)

Bases: TransformedPrior

Maps between target distributions and a latent space (hypercube or standard normal).

Supports bi-directional transformation with mode selection at call time
  • forward(z, mode): latent space -> target distribution
  • inverse(x, mode): target distribution -> latent space
Modes
  • "hypercube": Maps to/from hypercube domain (default [-2, 2]). Use with Flow estimator.
  • "standard_normal": Maps to/from N(0, I). Use with Gaussian estimator.
Supported distribution types and their required parameters
  • "uniform": Linear mapping. Parameters: low, high.
  • "cosine": Uses acos transform for pdf ∝ sin(angle). Parameters: low, high.
  • "sine": Uses asin transform. Parameters: low, high.
  • "uvol": Uniform-in-volume. Parameters: low, high.
  • "normal": Normal distribution. Parameters: mean, std.
  • "triangular": Triangular distribution. Parameters: a (min), c (mode), b (max).
  • "fixed": Fixed value (excluded from latent space). Parameters: value.

Two roles exclude a parameter from the latent space, i.e. from what the estimator infers. They differ in what the simulator receives: - "fixed": pinned to a constant, so it cannot influence the data at all. - marginalize=[i, ...]: keeps its declared distribution and is redrawn from it on every call, so it still varies and still broadens the likelihood -- the estimator learns the posterior marginalized over it. Use for nuisance parameters. Note that forward() cannot recover a marginalized value from the latent vector, so the corresponding column of any generated sample is a prior draw, not a posterior draw; the marginals of the remaining parameters are unaffected.

Example

prior = Product([ ("uniform", -100.0, 100.0), ("fixed", 5.0), # Fixed parameter, not in latent space ("normal", 0.0, 1.0), ("uniform", 0.0, 1.0), # Nuisance, marginalized over ], marginalize=[3])

Latent space has dim=2 (only free params)

Output space has dim=4 (includes fixed and marginalized params)

For Gaussian estimator (standard normal latent space)

z = prior.inverse(theta, mode="standard_normal") # theta: (..., 4) -> z: (..., 2) theta = prior.forward(z, mode="standard_normal") # z: (..., 2) -> theta: (..., 4)

For Flow estimator (hypercube latent space)

u = prior.inverse(theta, mode="hypercube") theta = prior.forward(u, mode="hypercube")

Initialize Product.

Parameters:

Name Type Description Default
priors

List of tuples (dist_type, param1, param2, ...).

[]
hypercube_range

Range for hypercube mode (default: [-2, 2]).

[-2, 2]
marginalize

Optional list of parameter indices to marginalize over. A marginalized param keeps its declared distribution and is sampled normally into the full/simulator vector (so it still affects the data), but is excluded from the latent/inference space -- the estimator learns the posterior marginalized over it. Like "fixed" in that it is absent from the latent space, but drawn from its own prior (a fresh random draw in forward()) rather than held at a constant value. Composes with everything downstream because the latent space is simply smaller.

None
Source code in src/falcon/priors/product.py
def __init__(self, priors=[], hypercube_range=[-2, 2], marginalize=None):
    """
    Initialize Product.

    Args:
        priors: List of tuples (dist_type, param1, param2, ...).
        hypercube_range: Range for hypercube mode (default: [-2, 2]).
        marginalize: Optional list of parameter indices to marginalize over.
            A marginalized param keeps its declared distribution and is
            sampled normally into the full/simulator vector (so it still
            affects the data), but is excluded from the latent/inference
            space -- the estimator learns the posterior marginalized over
            it. Like "fixed" in that it is absent from the latent space,
            but drawn from its own prior (a fresh random draw in forward())
            rather than held at a constant value. Composes with everything
            downstream because the latent space is simply smaller.
    """
    self.priors = priors
    self.hypercube_range = hypercube_range

    # Separate fixed, marginalized, and free parameters. Fixed and
    # marginalized params are both excluded from the latent space: fixed
    # take a constant value, marginalized are drawn from their own prior.
    self._marginalize_set = set(marginalize or [])
    self._free_indices = []
    self._fixed_indices = []
    self._fixed_values = {}
    for i, prior in enumerate(priors):
        dist_type = prior[0]
        if dist_type == "fixed":
            self._fixed_indices.append(i)
            self._fixed_values[i] = prior[1]
        elif i in self._marginalize_set:
            continue  # sampled but not in latent; handled in forward/simulate
        else:
            self._free_indices.append(i)

    for i in self._marginalize_set:
        if not 0 <= i < len(priors):
            raise ValueError(f"marginalize index {i} out of range [0,{len(priors)})")
        if priors[i][0] == "fixed":
            raise ValueError(f"marginalize index {i} is a 'fixed' param; use one or the other")

    self._param_dim = len(self._free_indices)  # Latent space dimension
    self._full_param_dim = len(priors)  # Full output dimension

forward

forward(z, mode='hypercube')

Map from latent space to target distribution.

Parameters:

Name Type Description Default
z

Tensor of shape (..., param_dim) in latent space (free params only).

required
mode

"hypercube" or "standard_normal".

'hypercube'

Returns:

Type Description

Tensor of shape (..., full_param_dim) in target distribution space.

Source code in src/falcon/priors/product.py
def forward(self, z, mode="hypercube"):
    """
    Map from latent space to target distribution.

    Args:
        z: Tensor of shape (..., param_dim) in latent space (free params only).
        mode: "hypercube" or "standard_normal".

    Returns:
        Tensor of shape (..., full_param_dim) in target distribution space.
    """
    # Handle case with no free parameters
    if self._param_dim == 0:
        batch_shape = z.shape[:-1] if z.dim() > 1 else (1,)
        result = torch.zeros(*batch_shape, self._full_param_dim, dtype=z.dtype, device=z.device)
        for idx, val in self._fixed_values.items():
            result[..., idx] = val
        for idx in self._marginalize_set:
            result[..., idx] = self._sample_marginal(idx, batch_shape, z.dtype, z.device)
        return result

    if mode == "standard_normal":
        # Try direct transforms first (avoids CDF precision issues at tails)
        transformed = [None] * self._full_param_dim
        use_direct = True
        z_idx = 0
        for i, prior in enumerate(self.priors):
            dist_type, *params = prior
            if dist_type == "fixed":
                transformed[i] = torch.full(z.shape[:-1], params[0], dtype=z.dtype, device=z.device)
            elif i in self._marginalize_set:
                transformed[i] = self._sample_marginal(i, z.shape[:-1], z.dtype, z.device)
            else:
                x_i = self._from_standard_normal(z[..., z_idx], dist_type, *params)
                if x_i is None:
                    use_direct = False
                    break
                transformed[i] = x_i
                z_idx += 1
        if use_direct:
            return torch.stack(transformed, dim=-1)
        # Fall through to CDF approach if any distribution lacks direct transform
        u = self._normal_to_uniform(z)
    elif mode == "hypercube":
        u = self._hypercube_to_uniform(z)
    else:
        raise ValueError(f"Unknown mode: {mode}. Use 'hypercube' or 'standard_normal'.")

    # Map [0, 1] to target distributions (CDF approach)
    epsilon = 1e-10  # Supports ~6 sigma tails in float64
    u = torch.clamp(u, epsilon, 1.0 - epsilon)

    transformed = []
    u_idx = 0
    for i, prior in enumerate(self.priors):
        dist_type, *params = prior
        if dist_type == "fixed":
            x_i = torch.full(u.shape[:-1], params[0], dtype=u.dtype, device=u.device)
        elif i in self._marginalize_set:
            x_i = self._sample_marginal(i, u.shape[:-1], u.dtype, u.device)
        else:
            x_i = self._forward_transform(u[..., u_idx], dist_type, *params)
            u_idx += 1
        transformed.append(x_i)

    return torch.stack(transformed, dim=-1)

inverse

inverse(x, mode='hypercube')

Map from target distribution to latent space.

Parameters:

Name Type Description Default
x

Tensor of shape (..., full_param_dim) in target distribution space.

required
mode

"hypercube" or "standard_normal".

'hypercube'

Returns:

Type Description

Tensor of shape (..., param_dim) in latent space (free params only).

Source code in src/falcon/priors/product.py
def inverse(self, x, mode="hypercube"):
    """
    Map from target distribution to latent space.

    Args:
        x: Tensor of shape (..., full_param_dim) in target distribution space.
        mode: "hypercube" or "standard_normal".

    Returns:
        Tensor of shape (..., param_dim) in latent space (free params only).
    """
    # Handle case with no free parameters
    if self._param_dim == 0:
        batch_shape = x.shape[:-1] if x.dim() > 1 else (1,)
        return torch.zeros(*batch_shape, 0, dtype=x.dtype, device=x.device)

    if mode == "standard_normal":
        # Try direct transforms first (avoids CDF precision issues at tails)
        transformed = []
        use_direct = True
        for i, prior in enumerate(self.priors):
            dist_type, *params = prior
            if dist_type == "fixed" or i in self._marginalize_set:
                continue  # excluded from the latent space
            z_i = self._to_standard_normal(x[..., i], dist_type, *params)
            if z_i is None:
                use_direct = False
                break
            transformed.append(z_i)
        if use_direct:
            return torch.stack(transformed, dim=-1)
        # Fall through to CDF approach if any distribution lacks direct transform

    # Map target distributions to [0, 1] (CDF approach, free params only)
    uniform = []
    for i, prior in enumerate(self.priors):
        dist_type, *params = prior
        if dist_type == "fixed" or i in self._marginalize_set:
            continue  # excluded from the latent space
        u_i = self._inverse_transform(x[..., i], dist_type, *params)
        uniform.append(u_i)

    u = torch.stack(uniform, dim=-1)

    # Clamp to avoid numerical issues at boundaries
    epsilon = 1e-10  # Supports ~6 sigma tails in float64
    u = torch.clamp(u, epsilon, 1.0 - epsilon)

    # Convert [0, 1] to latent space
    if mode == "hypercube":
        return self._uniform_to_hypercube(u)
    elif mode == "standard_normal":
        return self._uniform_to_normal(u)
    else:
        raise ValueError(f"Unknown mode: {mode}. Use 'hypercube' or 'standard_normal'.")

simulate_batch

simulate_batch(batch_size)

Generate samples from the target distributions.

Parameters:

Name Type Description Default
batch_size

Number of samples.

required

Returns:

Type Description

numpy array of shape (batch_size, full_param_dim) in target distribution space.

Source code in src/falcon/priors/product.py
def simulate_batch(self, batch_size):
    """
    Generate samples from the target distributions.

    Args:
        batch_size: Number of samples.

    Returns:
        numpy array of shape (batch_size, full_param_dim) in target distribution space.
    """
    # Free and marginalized params are both drawn from their own prior; only
    # fixed params take a constant. (Marginalized params are absent from the
    # latent space but still fed to the simulator, so they must be sampled.)
    # Note this is _param_dim + len(marginalize), not _param_dim.
    num_sampled = len(self.priors) - len(self._fixed_indices)
    u = torch.rand(batch_size, num_sampled, dtype=torch.float64)

    transformed = []
    u_idx = 0
    for i, prior in enumerate(self.priors):
        dist_type, *params = prior
        if dist_type == "fixed":
            x_i = torch.full((batch_size,), params[0], dtype=torch.float64)
        else:
            x_i = self._forward_transform(u[..., u_idx], dist_type, *params)
            u_idx += 1
        transformed.append(x_i)

    return torch.stack(transformed, dim=-1).numpy()