Source code for latents.gfa.model

"""GFAModel class for Group Factor Analysis."""

from __future__ import annotations

import os
import sys

if sys.version_info >= (3, 11):
    from typing import Self
else:
    from typing_extensions import Self

from latents.data import ObsStatic
from latents.gfa.config import GFAFitConfig
from latents.gfa.inference import fit, infer_latents, infer_loadings, init_posteriors
from latents.gfa.tracking import (
    GFAFitFlags,
    GFAFitTracker,
    load_gfa_state,
    save_gfa_state,
)
from latents.observation import (
    ObsParamsHyperPrior,
    ObsParamsPosterior,
    ObsParamsPrior,
)
from latents.state import LatentsPosteriorStatic, LatentsPriorStatic


[docs] class GFAModel: """High-level interface for Group Factor Analysis. Parameters ---------- config : GFAFitConfig or None, default None Fitting configuration. If None, uses default GFAFitConfig(). obs_hyperprior : ObsParamsHyperPrior or None, default None Prior hyperparameters for observation model. If None, uses default. Attributes ---------- config : GFAFitConfig Fitting configuration (immutable after construction). obs_posterior : ObsParamsPosterior or None Posterior over observation model parameters. None until fit. latents_posterior : LatentsPosteriorStatic or None Posterior over latent variables. None until fit. tracker : GFAFitTracker or None Fitting progress tracker. None until fit. flags : GFAFitFlags or None Fitting status flags. None until fit. Examples -------- **Basic usage: fit and infer** >>> from latents.gfa import GFAModel, GFAFitConfig >>> from latents.callbacks import ProgressCallback >>> config = GFAFitConfig(x_dim_init=10) >>> model = GFAModel(config=config) >>> model.fit(Y, callbacks=[ProgressCallback()]) >>> X_new = model.infer_latents(Y_new) **Persistence: save and load** >>> model.save("fitted_model.safetensors") >>> loaded = GFAModel.load("fitted_model.safetensors") """ def __init__( self, config: GFAFitConfig | None = None, obs_hyperprior: ObsParamsHyperPrior | None = None, ): # Configuration (immutable) self.config = config or GFAFitConfig() # Prior specification (private, fixed at construction) self._obs_prior = ObsParamsPrior( hyperprior=obs_hyperprior or ObsParamsHyperPrior() ) self._latents_prior = LatentsPriorStatic() # Posterior estimates (populated by fit) self.obs_posterior: ObsParamsPosterior | None = None self.latents_posterior: LatentsPosteriorStatic | None = None # Fitting state (populated by fit) self.tracker: GFAFitTracker | None = None self.flags: GFAFitFlags | None = None def __repr__(self) -> str: fitted = self.obs_posterior is not None return f"GFAModel(fitted={fitted}, config={self.config})" @property def obs_hyperprior(self) -> ObsParamsHyperPrior: """Observation model hyperprior parameters.""" return self._obs_prior.hyperprior @property def obs_prior(self) -> ObsParamsPrior: """Observation model prior.""" return self._obs_prior @property def latents_prior(self) -> LatentsPriorStatic: """Latent variable prior.""" return self._latents_prior
[docs] def fit(self, Y: ObsStatic, callbacks: list | None = None) -> Self: """Fit model to data via variational inference. Resets tracker and flags. Warm-starts from posteriors if present, otherwise initializes from scratch. Parameters ---------- Y : ObsStatic Observed data. callbacks : list of Callback or None, default None List of callback objects for progress, logging, checkpointing, etc. See :mod:`~latents.callbacks` module. Returns ------- Self The fitted model (for method chaining). Examples -------- >>> from latents.gfa import GFAModel, GFAFitConfig >>> from latents.callbacks import ProgressCallback >>> config = GFAFitConfig(x_dim_init=10, max_iter=100) >>> model = GFAModel(config=config) >>> model.fit(Y, callbacks=[ProgressCallback()]) """ # Initialize posteriors if not present (cold start) if self.obs_posterior is None: self._init_posteriors(Y) # Fit (resets tracker/flags for fresh convergence tracking) self.obs_posterior, self.latents_posterior, self.tracker, self.flags = fit( Y, self.obs_posterior, self.latents_posterior, config=self.config, obs_hyperprior=self.obs_hyperprior, callbacks=callbacks, ) return self
[docs] def resume_fit( self, Y: ObsStatic, max_iter: int | None = None, callbacks: list | None = None, ) -> Self: """Resume an interrupted fit. Appends to tracker, preserves convergence baseline. Parameters ---------- Y : ObsStatic Observed data. max_iter : int or None, default None Maximum iterations for this resume run. If None, uses config.max_iter. callbacks : list of Callback or None, default None List of callback objects for progress, logging, checkpointing, etc. Returns ------- Self The model (for method chaining). Raises ------ ValueError If no fit to resume (posteriors not initialized). """ if self.obs_posterior is None: msg = "No fit to resume. Use fit() instead." raise ValueError(msg) if self.tracker is None or self.flags is None: msg = "No tracking state to resume. Use fit() instead." raise ValueError(msg) if self.flags.converged: # Already converged, nothing to do return self self.obs_posterior, self.latents_posterior, self.tracker, self.flags = fit( Y, self.obs_posterior, self.latents_posterior, config=self.config, obs_hyperprior=self.obs_hyperprior, tracker=self.tracker, flags=self.flags, max_iter=max_iter, callbacks=callbacks, ) return self
[docs] def clear_fit(self) -> Self: """Clear fit results for fresh initialization on next fit(). Returns ------- Self The model (for method chaining). """ self.obs_posterior = None self.latents_posterior = None self.tracker = None self.flags = None return self
[docs] def infer_latents(self, Y: ObsStatic) -> LatentsPosteriorStatic: """Infer latent posterior for new data given fitted parameters. Does not modify the model's stored latents_posterior. Parameters ---------- Y : ObsStatic Observed data. Returns ------- LatentsPosteriorStatic Posterior over latent variables for the given data. Raises ------ ValueError If model has not been fitted. Examples -------- >>> model.fit(Y_train) >>> X_test = model.infer_latents(Y_test) >>> X_test.mean.shape (5, 50) """ if self.obs_posterior is None: msg = "Model must be fitted before inferring latents." raise ValueError(msg) return infer_latents(Y, self.obs_posterior)
[docs] def recompute_latents(self, Y: ObsStatic) -> Self: """Recompute latents from data. Updates self.latents_posterior. Use this to restore latents after loading a model saved with save_x=False. Parameters ---------- Y : ObsStatic Observed data (typically the training data). Returns ------- Self The model (for method chaining). Raises ------ ValueError If model has not been fitted. """ if self.obs_posterior is None: msg = "Model must be fitted before recomputing latents." raise ValueError(msg) if self.latents_posterior is None: self.latents_posterior = LatentsPosteriorStatic() infer_latents(Y, self.obs_posterior, self.latents_posterior) return self
[docs] def recompute_loadings(self, Y: ObsStatic) -> Self: """Recompute loading posterior from data. Updates self.obs_posterior.C. Use this to restore C.cov after loading a model saved with save_c_cov=False. Note: At convergence, the recomputed values are essentially identical to the original fitted values. Pre-convergence, there may be non-negligible differences because the reconstruction uses the final latents posterior rather than the latents posterior from the previous iteration. Parameters ---------- Y : ObsStatic Observed data (typically the training data). Returns ------- Self The model (for method chaining). Raises ------ ValueError If model has not been fitted, or if latents are not available. """ if self.obs_posterior is None: msg = "Model must be fitted before recomputing loadings." raise ValueError(msg) if ( self.latents_posterior is None or not self.latents_posterior.is_initialized() ): msg = "Latents must be available. Call recompute_latents(Y) first." raise ValueError(msg) infer_loadings(Y, self.obs_posterior, self.latents_posterior) return self
def _init_posteriors(self, Y: ObsStatic) -> None: """Initialize posteriors from data.""" self.obs_posterior, self.latents_posterior = init_posteriors( Y, config=self.config, obs_hyperprior=self.obs_hyperprior )
[docs] def save(self, path: str | os.PathLike[str]) -> None: """Save model to a safetensors file. Uses safetensors format for secure serialization (no arbitrary code execution on load). Arrays are stored as tensors; scalars and config are stored as JSON in metadata. Parameters ---------- path : str or PathLike Output file path (conventionally ends in .safetensors). """ save_gfa_state( path, config=self.config, obs_hyperprior=self.obs_hyperprior, obs_posterior=self.obs_posterior, latents_posterior=self.latents_posterior, tracker=self.tracker, flags=self.flags, )
[docs] @classmethod def load(cls, path: str | os.PathLike[str]) -> GFAModel: """Load model from a safetensors file. Parameters ---------- path : str or PathLike Path to .safetensors file. Returns ------- GFAModel Loaded model, ready for inference or continued fitting. """ config, obs_hyperprior, obs_posterior, latents_posterior, tracker, flags = ( load_gfa_state(path) ) # Create model with loaded config and hyperprior model = cls(config=config, obs_hyperprior=obs_hyperprior) # Restore posteriors and tracking state model.obs_posterior = obs_posterior model.latents_posterior = latents_posterior model.tracker = tracker model.flags = flags return model