Source code for latents.gfa.simulation

"""Simulate data from the group factor analysis (GFA) generative model."""

from __future__ import annotations

import json
import os
from dataclasses import asdict, dataclass

import numpy as np
from safetensors import safe_open
from safetensors.numpy import save_file

from latents.data import ObsStatic
from latents.gfa.config import GFASimConfig
from latents.observation import (
    ObsParamsHyperPrior,
    ObsParamsHyperPriorStructured,
    ObsParamsPoint,
    ObsParamsPrior,
    ObsParamsRealization,
    adjust_snr,
)
from latents.state import LatentsPriorStatic, LatentsRealization


[docs] @dataclass class GFASimulationResult: """Complete output of a GFA simulation run. Bundles the specification (config + hyperprior) with the sampled outputs (obs_params, latents, observations). This is a run artifact analogous to a fitted :class:`GFAModel`. Parameters ---------- config : GFASimConfig Experimental design parameters used for simulation. hyperprior : ObsParamsHyperPrior or ObsParamsHyperPriorStructured Probabilistic model specification. obs_params : ObsParamsRealization Sampled observation model parameters (C, d, phi, alpha). latents : LatentsRealization Sampled latent variables. observations : ObsStatic Generated observed data. """ config: GFASimConfig hyperprior: ObsParamsHyperPrior | ObsParamsHyperPriorStructured obs_params: ObsParamsRealization latents: LatentsRealization observations: ObsStatic
[docs] def simulate( config: GFASimConfig, hyperprior: ObsParamsHyperPrior | ObsParamsHyperPriorStructured, ) -> GFASimulationResult: """Generate samples from the full group factor analysis model. Parameters ---------- config : GFASimConfig Experimental design parameters (n_samples, y_dims, x_dim, snr, seed). hyperprior : ObsParamsHyperPrior or ObsParamsHyperPriorStructured Probabilistic model specification. For structured sparsity patterns, use :class:`ObsParamsHyperPriorStructured` where ``a_alpha`` and ``b_alpha`` arrays specify group- and column-specific patterns. Use ``np.inf`` in ``a_alpha`` to force zero loadings. Returns ------- GFASimulationResult Complete simulation output including config, hyperprior, sampled parameters, latents, and observations. Examples -------- >>> config = GFASimConfig( ... n_samples=100, ... y_dims=np.array([10, 10]), ... x_dim=5, ... random_seed=42, ... ) >>> hyperprior = ObsParamsHyperPrior( ... a_alpha=1.0, b_alpha=1.0, a_phi=1.0, b_phi=1.0, beta_d=1.0 ... ) >>> result = simulate(config, hyperprior) >>> result.observations.data.shape (20, 100) """ rng = np.random.default_rng(config.random_seed) # Normalize snr to array snr = np.atleast_1d(config.snr) if snr.size == 1: snr = np.broadcast_to(snr, (config.n_groups,)) # Sample from the prior and adjust SNR prior = ObsParamsPrior(hyperprior=hyperprior) obs_params = prior.sample(config.y_dims, config.x_dim, rng) obs_params = adjust_snr(obs_params, snr) # Sample latent data from the static prior latents_prior = LatentsPriorStatic() latents = latents_prior.sample(config.x_dim, config.n_samples, rng) # Generate observed data observations = sample_observations(latents, obs_params, rng) return GFASimulationResult( config=config, hyperprior=hyperprior, obs_params=obs_params, latents=latents, observations=observations, )
[docs] def sample_observations( latents: LatentsRealization, obs_params: ObsParamsRealization | ObsParamsPoint, rng: np.random.Generator, ) -> ObsStatic: """Generate observed data via the GFA observation model. Parameters ---------- latents : LatentsRealization Sampled latent data. obs_params : ObsParamsRealization or ObsParamsPoint GFA observation model parameters, either as a full realization or as point estimates. rng : numpy.random.Generator NumPy random number generator. Returns ------- ObsStatic Generated observed data. """ # Number of data points n_samples = latents.n_samples # Dimensionality of each observed group y_dims = obs_params.y_dims # Number of observed groups n_groups = len(y_dims) # Split d, phi, and C according to observed groups y_boundaries = np.cumsum(y_dims)[:-1] ds = np.split(obs_params.d, y_boundaries) phis = np.split(obs_params.phi, y_boundaries) Cs = np.split(obs_params.C, y_boundaries, axis=0) # Initialize observed data list Y = ObsStatic(data=np.zeros((y_dims.sum(), n_samples)), dims=y_dims) Ys = Y.get_groups() # Generate observed data group by group for group_idx in range(n_groups): Ys[group_idx][:] = ( Cs[group_idx] @ latents.data + ds[group_idx][:, np.newaxis] + rng.multivariate_normal( np.zeros(y_dims[group_idx]), np.diag(1 / phis[group_idx]), size=n_samples, ).T ) return Y
# --- Serialization helpers --- def _serialize_hyperprior( hyperprior: ObsParamsHyperPrior | ObsParamsHyperPriorStructured, tensors: dict[str, np.ndarray], metadata: dict[str, str], ) -> None: """Serialize hyperprior to tensors and metadata dicts (in-place).""" if isinstance(hyperprior, ObsParamsHyperPriorStructured): metadata["hyperprior_type"] = "ObsParamsHyperPriorStructured" tensors["hyperprior.a_alpha"] = hyperprior.a_alpha tensors["hyperprior.b_alpha"] = hyperprior.b_alpha metadata["hyperprior.a_phi"] = str(hyperprior.a_phi) metadata["hyperprior.b_phi"] = str(hyperprior.b_phi) metadata["hyperprior.beta_d"] = str(hyperprior.beta_d) else: metadata["hyperprior_type"] = "ObsParamsHyperPrior" metadata["hyperprior"] = json.dumps(asdict(hyperprior)) def _deserialize_hyperprior( tensors: dict[str, np.ndarray], metadata: dict[str, str], ) -> ObsParamsHyperPrior | ObsParamsHyperPriorStructured: """Deserialize hyperprior from tensors and metadata dicts.""" hyperprior_type = metadata["hyperprior_type"] if hyperprior_type == "ObsParamsHyperPriorStructured": return ObsParamsHyperPriorStructured( a_alpha=tensors["hyperprior.a_alpha"], b_alpha=tensors["hyperprior.b_alpha"], a_phi=float(metadata["hyperprior.a_phi"]), b_phi=float(metadata["hyperprior.b_phi"]), beta_d=float(metadata["hyperprior.beta_d"]), ) return ObsParamsHyperPrior(**json.loads(metadata["hyperprior"])) def _serialize_config( config: GFASimConfig, tensors: dict[str, np.ndarray], metadata: dict[str, str], ) -> None: """Serialize GFASimConfig to tensors and metadata dicts (in-place).""" # y_dims and snr (if array) go to tensors; scalars go to metadata tensors["config.y_dims"] = config.y_dims metadata["config.n_samples"] = str(config.n_samples) metadata["config.x_dim"] = str(config.x_dim) if config.random_seed is not None: metadata["config.random_seed"] = json.dumps(config.random_seed) # snr: scalar or array if isinstance(config.snr, np.ndarray): tensors["config.snr"] = config.snr metadata["config.snr_is_array"] = "true" else: metadata["config.snr"] = str(config.snr) metadata["config.snr_is_array"] = "false" def _deserialize_config( tensors: dict[str, np.ndarray], metadata: dict[str, str], ) -> GFASimConfig: """Deserialize GFASimConfig from tensors and metadata dicts.""" # snr: check if array or scalar if metadata.get("config.snr_is_array") == "true": snr = tensors["config.snr"] else: snr = float(metadata["config.snr"]) return GFASimConfig( n_samples=int(metadata["config.n_samples"]), y_dims=tensors["config.y_dims"], x_dim=int(metadata["config.x_dim"]), snr=snr, random_seed=( json.loads(metadata["config.random_seed"]) if "config.random_seed" in metadata else None ), ) # --- Save/Load functions ---
[docs] def save_simulation(path: str | os.PathLike[str], result: GFASimulationResult) -> None: """Save complete simulation result to safetensors file. Saves the full snapshot: config, hyperprior, obs_params, latents, and observations. Use :func:`~latents.gfa.simulation.load_simulation` to restore. Parameters ---------- path : str or PathLike Output file path (conventionally ends in .safetensors). result : GFASimulationResult Complete simulation result to save. See Also -------- save_simulation_recipe : Save only config and hyperprior (smaller file). """ tensors: dict[str, np.ndarray] = {} metadata: dict[str, str] = {"file_type": "snapshot"} # Config and hyperprior _serialize_config(result.config, tensors, metadata) _serialize_hyperprior(result.hyperprior, tensors, metadata) # obs_params tensors["obs_params.C"] = result.obs_params.C tensors["obs_params.d"] = result.obs_params.d tensors["obs_params.phi"] = result.obs_params.phi tensors["obs_params.alpha"] = result.obs_params.alpha tensors["obs_params.y_dims"] = result.obs_params.y_dims metadata["obs_params.x_dim"] = str(result.obs_params.x_dim) # latents tensors["latents.data"] = result.latents.data # observations tensors["observations.data"] = result.observations.data tensors["observations.dims"] = result.observations.dims save_file(tensors, path, metadata=metadata)
[docs] def save_simulation_recipe( path: str | os.PathLike[str], config: GFASimConfig, hyperprior: ObsParamsHyperPrior | ObsParamsHyperPriorStructured, ) -> None: """Save simulation recipe (config + hyperprior only). Saves a minimal file that can regenerate the full simulation when passed to :func:`~latents.gfa.simulation.simulate`. Requires ``config.random_seed`` to be set. Parameters ---------- path : str or PathLike Output file path (conventionally ends in .safetensors). config : GFASimConfig Simulation configuration. Must have random_seed set. hyperprior : ObsParamsHyperPrior or ObsParamsHyperPriorStructured Probabilistic model specification. Raises ------ ValueError If config.random_seed is None. See Also -------- save_simulation : Save complete results (larger file). """ if config.random_seed is None: msg = "random_seed required for reproducible recipe" raise ValueError(msg) tensors: dict[str, np.ndarray] = {} metadata: dict[str, str] = {"file_type": "recipe"} _serialize_config(config, tensors, metadata) _serialize_hyperprior(hyperprior, tensors, metadata) save_file(tensors, path, metadata=metadata)
[docs] def load_simulation(path: str | os.PathLike[str]) -> GFASimulationResult: """Load complete simulation from file. Parameters ---------- path : str or PathLike Path to .safetensors file saved with :func:`~latents.gfa.simulation.save_simulation`. Returns ------- GFASimulationResult Complete simulation result. Raises ------ ValueError If file contains only a recipe. Use :func:`~latents.gfa.simulation.load_simulation_recipe` and :func:`~latents.gfa.simulation.simulate` to regenerate. See Also -------- load_simulation_recipe : Load recipe and regenerate via simulate(). """ with safe_open(path, framework="numpy") as f: metadata = f.metadata() tensors = {key: f.get_tensor(key) for key in f.keys()} # noqa: SIM118 if metadata.get("file_type") == "recipe": msg = ( "File contains only a recipe (config + hyperprior). " "Use load_simulation_recipe() and simulate() to regenerate." ) raise ValueError(msg) config = _deserialize_config(tensors, metadata) hyperprior = _deserialize_hyperprior(tensors, metadata) obs_params = ObsParamsRealization( C=tensors["obs_params.C"], d=tensors["obs_params.d"], phi=tensors["obs_params.phi"], alpha=tensors["obs_params.alpha"], y_dims=tensors["obs_params.y_dims"], x_dim=int(metadata["obs_params.x_dim"]), ) latents = LatentsRealization(data=tensors["latents.data"]) observations = ObsStatic( data=tensors["observations.data"], dims=tensors["observations.dims"], ) return GFASimulationResult( config=config, hyperprior=hyperprior, obs_params=obs_params, latents=latents, observations=observations, )
[docs] def load_simulation_recipe( path: str | os.PathLike[str], ) -> tuple[GFASimConfig, ObsParamsHyperPrior | ObsParamsHyperPriorStructured]: """Load simulation recipe from file. Works for both recipe-only files and full snapshots (extracts just the specification). Parameters ---------- path : str or PathLike Path to .safetensors file. Returns ------- config : GFASimConfig Simulation configuration. hyperprior : ObsParamsHyperPrior or ObsParamsHyperPriorStructured Probabilistic model specification. Examples -------- >>> config, hyperprior = load_simulation_recipe("simulation.safetensors") >>> result = simulate(config, hyperprior) # Regenerate from recipe """ with safe_open(path, framework="numpy") as f: metadata = f.metadata() tensors = {key: f.get_tensor(key) for key in f.keys()} # noqa: SIM118 config = _deserialize_config(tensors, metadata) hyperprior = _deserialize_hyperprior(tensors, metadata) return config, hyperprior