Source code for latents.gfa.config

"""Configuration classes for GFA model fitting and simulation."""

from __future__ import annotations

from collections.abc import Sequence
from dataclasses import dataclass

import numpy as np


def _validate_random_seed(seed: int | Sequence[int] | None) -> None:
    """Validate random_seed field value.

    Parameters
    ----------
    seed : int, sequence of int, or None
        The seed value to validate.

    Raises
    ------
    TypeError
        If seed is not an int, sequence of ints, or None.
    ValueError
        If seed is negative, sequence is empty, or sequence contains negatives.
    """
    if seed is None:
        return

    if isinstance(seed, int):
        if seed < 0:
            msg = f"random_seed must be non-negative, got {seed}"
            raise ValueError(msg)
    elif isinstance(seed, str):
        # Strings are Sequences but not valid seeds
        msg = f"random_seed must be an int, sequence of ints, or None, got {seed!r}"
        raise TypeError(msg)
    elif isinstance(seed, Sequence):
        if len(seed) == 0:
            msg = "random_seed sequence must not be empty"
            raise ValueError(msg)
        for i, val in enumerate(seed):
            if not isinstance(val, int):
                msg = (
                    f"random_seed sequence must contain integers, "
                    f"got {type(val).__name__} at index {i}"
                )
                raise TypeError(msg)
            if val < 0:
                msg = (
                    f"random_seed sequence values must be non-negative, "
                    f"got {val} at index {i}"
                )
                raise ValueError(msg)
    else:
        msg = (
            f"random_seed must be an int, sequence of ints, or None, "
            f"got {type(seed).__name__}"
        )
        raise TypeError(msg)


[docs] @dataclass(frozen=True, slots=True, kw_only=True) class GFASimConfig: """Experimental design parameters for GFA simulation. Defines the structure and reproducibility settings for generating synthetic GFA data. The probabilistic model specification (hyperprior) is provided separately to `simulate()`. All parameters except `random_seed` are required. Instances are immutable (frozen). Parameters ---------- n_samples : int Number of data points to generate. Must be >= 1. y_dims : np.ndarray Dimensionalities of each observed group, shape ``(n_groups,)``. Must be 1D array of positive integers. x_dim : int Number of latent dimensions. Must be >= 1. snr : float or ndarray Signal-to-noise ratio. Either a scalar (broadcast to all groups) or per-group array of shape ``(n_groups,)``. Must be > 0. random_seed : int, sequence of int, or None RNG seed for reproducibility. Accepts a single non-negative integer, a non-empty sequence of non-negative integers (for structured seeding in parallel experiments), or None for random initialization. Required for reproducible recipe saves. Examples -------- >>> config = GFASimConfig( ... n_samples=100, ... y_dims=np.array([10, 10, 10]), ... x_dim=5, ... snr=1.0, ... random_seed=42, ... ) >>> config.n_groups 3 >>> config.y_dim 30 """ n_samples: int y_dims: np.ndarray x_dim: int snr: float | np.ndarray = 1.0 random_seed: int | Sequence[int] | None = None @property def n_groups(self) -> int: """Number of observed groups.""" return len(self.y_dims) @property def y_dim(self) -> int: """Total observed dimensionality (sum of y_dims).""" return int(self.y_dims.sum()) def __post_init__(self) -> None: """Validate configuration values.""" # Validate n_samples if not isinstance(self.n_samples, int) or self.n_samples < 1: msg = f"n_samples must be an integer >= 1, got {self.n_samples!r}" raise ValueError(msg) # Validate y_dims: must be 1D array of positive integers if not isinstance(self.y_dims, np.ndarray): msg = f"y_dims must be a numpy array, got {type(self.y_dims).__name__}" raise TypeError(msg) if self.y_dims.ndim != 1: msg = f"y_dims must be 1D, got shape {self.y_dims.shape}" raise ValueError(msg) if len(self.y_dims) == 0: msg = "y_dims must have at least one element" raise ValueError(msg) if not np.issubdtype(self.y_dims.dtype, np.integer): msg = f"y_dims must have integer dtype, got {self.y_dims.dtype}" raise TypeError(msg) if not np.all(self.y_dims >= 1): msg = f"y_dims values must be >= 1, got {self.y_dims}" raise ValueError(msg) # Validate x_dim if not isinstance(self.x_dim, int) or self.x_dim < 1: msg = f"x_dim must be an integer >= 1, got {self.x_dim!r}" raise ValueError(msg) # Validate snr: scalar or array, must be > 0 snr = self.snr if isinstance(snr, np.ndarray): if snr.ndim != 1: msg = f"snr array must be 1D, got shape {snr.shape}" raise ValueError(msg) if len(snr) != 1 and len(snr) != len(self.y_dims): msg = ( f"snr array must have length 1 or n_groups={len(self.y_dims)}, " f"got {len(snr)}" ) raise ValueError(msg) if not np.all(snr > 0): msg = f"snr values must be > 0, got {snr}" raise ValueError(msg) else: # Scalar if not isinstance(snr, (int, float)) or snr <= 0: msg = f"snr must be > 0, got {snr}" raise ValueError(msg) _validate_random_seed(self.random_seed)
[docs] @dataclass(frozen=True, slots=True, kw_only=True) class GFAFitConfig: """Configuration for GFA model fitting. All parameters have sensible defaults. Create with keyword arguments only. Instances are immutable (frozen). Parameters ---------- x_dim_init : int Initial number of latent dimensions (before pruning). Must be >= 1. fit_tol : float Convergence tolerance for ELBO relative change. Must be > 0. max_iter : int Maximum EM iterations. Must be >= 1. prune_x : bool If True, remove latent dimensions that become inactive during fitting. Improves speed and memory for high initial `x_dim_init`. prune_tol : float Variance threshold for pruning. Latents with mean squared value below this are removed. Must be > 0. save_x : bool If True, save posterior latent estimates. Can be memory-intensive for large N. save_c_cov : bool If True, save loading covariances. Can be memory-intensive for large y_dim and x_dim. save_fit_progress : bool If True, track ELBO and runtime per iteration. random_seed : int, sequence of int, or None RNG seed for reproducibility. Accepts a single non-negative integer, a non-empty sequence of non-negative integers (for structured seeding in parallel experiments), or None for random initialization. min_var_frac : float Private variance floor as fraction of data variance. Must be in (0, 1). Examples -------- >>> config = GFAFitConfig(x_dim_init=10) >>> config.x_dim_init 10 >>> # Configs are immutable >>> config.x_dim_init = 20 # Raises FrozenInstanceError Traceback (most recent call last): ... dataclasses.FrozenInstanceError: cannot assign to field 'x_dim_init' """ # Model structure x_dim_init: int = 1 # Convergence fit_tol: float = 1e-8 max_iter: int = 1_000_000 # Pruning prune_x: bool = True prune_tol: float = 1e-7 # Output control save_x: bool = False save_c_cov: bool = False save_fit_progress: bool = True # Reproducibility random_seed: int | Sequence[int] | None = None # Numerical stability min_var_frac: float = 0.001 def __post_init__(self) -> None: """Validate configuration values.""" # Validate x_dim_init if not isinstance(self.x_dim_init, int) or self.x_dim_init < 1: msg = f"x_dim_init must be an integer >= 1, got {self.x_dim_init!r}" raise ValueError(msg) # Validate fit_tol if self.fit_tol <= 0: msg = f"fit_tol must be > 0, got {self.fit_tol}" raise ValueError(msg) # Validate max_iter if not isinstance(self.max_iter, int) or self.max_iter < 1: msg = f"max_iter must be an integer >= 1, got {self.max_iter!r}" raise ValueError(msg) # Validate prune_tol if self.prune_tol <= 0: msg = f"prune_tol must be > 0, got {self.prune_tol}" raise ValueError(msg) # Validate min_var_frac if not 0 < self.min_var_frac < 1: msg = f"min_var_frac must be in (0, 1), got {self.min_var_frac}" raise ValueError(msg) _validate_random_seed(self.random_seed)