Source code for latents.observation.posteriors

"""Posterior distributions for observation model parameters."""

from __future__ import annotations

import sys
from itertools import combinations

import numpy as np

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

from latents._internal.numerics import stability_floor
from latents.base import ArrayContainer
from latents.observation.realizations import ObsParamsRealization


[docs] class LoadingPosterior(ArrayContainer): """Posterior distribution over loading matrices. Parameters ---------- mean : ndarray of float, shape (y_dim, x_dim) or None, default None Posterior mean. cov : ndarray of float, shape (y_dim, x_dim, x_dim) or None, default None Posterior covariances. moment : ndarray of float, shape (y_dim, x_dim, x_dim) or None, default None Posterior second moments. Attributes ---------- mean : ndarray of float, shape (y_dim, x_dim) or None Posterior mean. cov : ndarray of float, shape (y_dim, x_dim, x_dim) or None Posterior covariances. moment : ndarray of float, shape (y_dim, x_dim, x_dim) or None Posterior second moments. """ def __init__( self, mean: np.ndarray | None = None, cov: np.ndarray | None = None, moment: np.ndarray | None = None, ): if mean is not None and not isinstance(mean, np.ndarray): msg = "mean must be a numpy.ndarray." raise TypeError(msg) self.mean = mean if cov is not None and not isinstance(cov, np.ndarray): msg = "cov must be a numpy.ndarray." raise TypeError(msg) self.cov = cov if moment is not None and not isinstance(moment, np.ndarray): msg = "moment must be a numpy.ndarray." raise TypeError(msg) self.moment = moment
[docs] def get_groups( self, y_dims: np.ndarray, ) -> tuple[list[np.ndarray], list[np.ndarray], list[np.ndarray]]: """Get list of views into posterior parameters, one per group. Parameters ---------- y_dims : ndarray of int, shape (n_groups,) Dimensionalities of each observed group. Returns ------- group_means : list of ndarray or None List of views into mean, one per group. None if mean is None. group_covs : list of ndarray or None List of views into cov, one per group. None if cov is None. group_moments : list of ndarray or None List of views into moment, one per group. None if moment is None. """ group_means = None if self.mean is not None: if np.sum(y_dims) != self.mean.shape[0]: msg = "The sum of y_dims must equal the number of rows in mean." raise ValueError(msg) group_means = np.split(self.mean, np.cumsum(y_dims)[:-1], axis=0) group_covs = None if self.cov is not None: if np.sum(y_dims) != self.cov.shape[0]: msg = "The sum of y_dims must equal the size of the first axis of cov." raise ValueError(msg) group_covs = np.split(self.cov, np.cumsum(y_dims)[:-1], axis=0) group_moments = None if self.moment is not None: if np.sum(y_dims) != self.moment.shape[0]: msg = ( "The sum of y_dims must equal the size of the first axis of moment." ) raise ValueError(msg) group_moments = np.split(self.moment, np.cumsum(y_dims)[:-1], axis=0) return group_means, group_covs, group_moments
[docs] def compute_moment(self, in_place: bool = True) -> np.ndarray: """Compute posterior second moments E[C_i C_i^T] for each row i. Parameters ---------- in_place : bool, default True If True, store in self.moment and return reference. If False, return new array. Returns ------- ndarray Second moments, shape (y_dim, x_dim, x_dim). """ if in_place: if self.moment is None: self.moment = np.zeros_like(self.cov) # Outer product per row: # (y_dim, x_dim), (y_dim, x_dim) -> (y_dim, x_dim, x_dim) np.einsum("ij,ik->ijk", self.mean, self.mean, out=self.moment) self.moment += self.cov return self.moment return np.einsum("ij,ik->ijk", self.mean, self.mean) + self.cov
[docs] def compute_squared_norms(self, y_dims: np.ndarray) -> np.ndarray: """Compute expected squared norm of each column per group. Parameters ---------- y_dims : ndarray of int, shape (n_groups,) Dimensionalities of each observed group. Returns ------- ndarray Squared norms, shape (n_groups, x_dim). """ n_groups = len(y_dims) x_dim = self.moment.shape[1] _, _, moments = self.get_groups(y_dims) squared_norms = np.zeros((n_groups, x_dim)) for group_idx in range(n_groups): squared_norms[group_idx, :] = np.sum( moments[group_idx].diagonal(offset=0, axis1=1, axis2=2), axis=0 ) return squared_norms
[docs] def get_subset_dims( self, x_indices: np.ndarray, in_place: bool = True, ) -> Self: """Keep only specified latent dimensions. Parameters ---------- x_indices : ndarray of int Indices of latent dimensions to keep. in_place : bool, default True If True, modify self. If False, return new instance. Returns ------- Self Modified or new instance. """ new_mean = self.mean[:, x_indices] if self.mean is not None else None new_cov = ( self.cov[np.ix_(np.arange(self.cov.shape[0]), x_indices, x_indices)] if self.cov is not None else None ) new_moment = ( self.moment[np.ix_(np.arange(self.moment.shape[0]), x_indices, x_indices)] if self.moment is not None else None ) if in_place: self.mean = new_mean self.cov = new_cov self.moment = new_moment return self return self.__class__(mean=new_mean, cov=new_cov, moment=new_moment)
[docs] class ARDPosterior(ArrayContainer): """Posterior distribution over ARD parameters. Parameters ---------- a : ndarray of float, shape (n_groups,) or None, default None Shape parameters. b : ndarray of float, shape (n_groups, x_dim) or None, default None Rate parameters. mean : ndarray of float, shape (n_groups, x_dim) or None, default None Posterior mean a/b. Attributes ---------- a : ndarray of float, shape (n_groups,) or None Shape parameters. b : ndarray of float, shape (n_groups, x_dim) or None Rate parameters. mean : ndarray of float, shape (n_groups, x_dim) or None Posterior mean a/b. """ def __init__( self, a: np.ndarray | None = None, b: np.ndarray | None = None, mean: np.ndarray | None = None, ): if a is not None and not isinstance(a, np.ndarray): msg = "a must be a numpy.ndarray." raise TypeError(msg) self.a = a if b is not None and not isinstance(b, np.ndarray): msg = "b must be a numpy.ndarray." raise TypeError(msg) self.b = b if mean is not None and not isinstance(mean, np.ndarray): msg = "mean must be a numpy.ndarray." raise TypeError(msg) self.mean = mean
[docs] def compute_mean(self, in_place: bool = True) -> np.ndarray: """Compute posterior mean a/b. Parameters ---------- in_place : bool, default True If True, store in self.mean and return reference. If False, return new array. Returns ------- ndarray Posterior mean, shape (n_groups, x_dim). """ floor = stability_floor(self.b.dtype) if in_place: if self.mean is None: self.mean = np.zeros_like(self.b) # a: (n_groups,) -> (n_groups, 1) for broadcast with b: (n_groups, x_dim) self.mean[:] = self.a[:, np.newaxis] / np.maximum(self.b, floor) return self.mean return self.a[:, np.newaxis] / np.maximum(self.b, floor)
[docs] def get_subset_dims( self, x_indices: np.ndarray, in_place: bool = True, ) -> Self: """Keep only specified latent dimensions. Parameters ---------- x_indices : ndarray of int Indices of latent dimensions to keep. in_place : bool, default True If True, modify self. If False, return new instance. Returns ------- Self Modified or new instance. """ new_b = self.b[:, x_indices] if self.b is not None else None new_mean = self.mean[:, x_indices] if self.mean is not None else None if in_place: self.b = new_b self.mean = new_mean return self new_a = self.a.copy() if self.a is not None else None return self.__class__(a=new_a, b=new_b, mean=new_mean)
[docs] class ObsMeanPosterior(ArrayContainer): """Posterior distribution over observation mean parameters. Parameters ---------- mean : ndarray of float, shape (y_dim,) or None, default None Posterior mean. cov : ndarray of float, shape (y_dim,) or None, default None Posterior variance (diagonal). Attributes ---------- mean : ndarray of float, shape (y_dim,) or None Posterior mean. cov : ndarray of float, shape (y_dim,) or None Posterior variance (diagonal). """ def __init__( self, mean: np.ndarray | None = None, cov: np.ndarray | None = None, ): if mean is not None and not isinstance(mean, np.ndarray): msg = "mean must be a numpy.ndarray." raise TypeError(msg) self.mean = mean if cov is not None and not isinstance(cov, np.ndarray): msg = "cov must be a numpy.ndarray." raise TypeError(msg) self.cov = cov
[docs] def get_groups( self, y_dims: np.ndarray, ) -> tuple[list[np.ndarray], list[np.ndarray]]: """Get list of views into posterior parameters, one per group. Parameters ---------- y_dims : ndarray of int, shape (n_groups,) Dimensionalities of each observed group. Returns ------- group_means : list of ndarray or None List of views into mean, one per group. None if mean is None. group_covs : list of ndarray or None List of views into cov, one per group. None if cov is None. """ group_means = None if self.mean is not None: if np.sum(y_dims) != len(self.mean): msg = "The sum of y_dims must equal the length of mean." raise ValueError(msg) group_means = np.split(self.mean, np.cumsum(y_dims)[:-1], axis=0) group_covs = None if self.cov is not None: if np.sum(y_dims) != len(self.cov): msg = "The sum of y_dims must equal the length of cov." raise ValueError(msg) group_covs = np.split(self.cov, np.cumsum(y_dims)[:-1], axis=0) return group_means, group_covs
[docs] class ObsPrecPosterior(ArrayContainer): """Posterior distribution over observation precision parameters. Parameters ---------- a : float or None, default None Shape parameter (scalar, shared across dimensions). b : ndarray of float, shape (y_dim,) or None, default None Rate parameters. mean : ndarray of float, shape (y_dim,) or None, default None Posterior mean a/b. Attributes ---------- a : float or None Shape parameter (scalar, shared across dimensions). b : ndarray of float, shape (y_dim,) or None Rate parameters. mean : ndarray of float, shape (y_dim,) or None Posterior mean a/b. """ def __init__( self, a: float | None = None, b: np.ndarray | None = None, mean: np.ndarray | None = None, ): if a is not None and not isinstance(a, float): msg = "a must be a float." raise TypeError(msg) self.a = a if b is not None and not isinstance(b, np.ndarray): msg = "b must be a numpy.ndarray." raise TypeError(msg) self.b = b if mean is not None and not isinstance(mean, np.ndarray): msg = "mean must be a numpy.ndarray." raise TypeError(msg) self.mean = mean
[docs] def get_groups( self, y_dims: np.ndarray, ) -> tuple[list[np.ndarray], list[np.ndarray]]: """Get list of views into posterior parameters, one per group. Parameters ---------- y_dims : ndarray of int, shape (n_groups,) Dimensionalities of each observed group. Returns ------- group_means : list of ndarray or None List of views into mean, one per group. None if mean is None. group_bs : list of ndarray or None List of views into b, one per group. None if b is None. """ group_means = None if self.mean is not None: if np.sum(y_dims) != len(self.mean): msg = "The sum of y_dims must equal the length of mean." raise ValueError(msg) group_means = np.split(self.mean, np.cumsum(y_dims)[:-1], axis=0) group_bs = None if self.b is not None: if np.sum(y_dims) != len(self.b): msg = "The sum of y_dims must equal the length of b." raise ValueError(msg) group_bs = np.split(self.b, np.cumsum(y_dims)[:-1], axis=0) return group_means, group_bs
[docs] def compute_mean(self, in_place: bool = True) -> np.ndarray: """Compute posterior mean a/b. Parameters ---------- in_place : bool, default True If True, store in self.mean and return reference. If False, return new array. Returns ------- ndarray Posterior mean, shape (y_dim,). """ floor = stability_floor(self.b.dtype) if in_place: if self.mean is None: self.mean = np.zeros_like(self.b) self.mean[:] = self.a / np.maximum(self.b, floor) return self.mean return self.a / np.maximum(self.b, floor)
[docs] class ObsParamsPosterior: """Posterior distributions over all observation model parameters. Bundle of posteriors q(C), q(alpha), q(d), q(phi) with methods for sampling, computing point estimates, and analysis. Parameters ---------- x_dim : int or None, default None Number of latent dimensions. y_dims : ndarray of int, shape (n_groups,) or None, default None Dimensionalities of each observed group. C : LoadingPosterior or None, default None Posterior over loading matrices. alpha : ARDPosterior or None, default None Posterior over ARD parameters. d : ObsMeanPosterior or None, default None Posterior over observation means. phi : ObsPrecPosterior or None, default None Posterior over observation precisions. Attributes ---------- x_dim : int or None Number of latent dimensions. y_dims : ndarray of int, shape (n_groups,) or None Dimensionalities of each observed group. C : LoadingPosterior Posterior over loading matrices. alpha : ARDPosterior Posterior over ARD parameters. d : ObsMeanPosterior Posterior over observation means. phi : ObsPrecPosterior Posterior over observation precisions. Examples -------- After fitting a GFA model, access the observation posterior: >>> model = GFAModel() >>> model.fit(Y) >>> obs_post = model.obs_posterior Get posterior mean as a realization: >>> params = obs_post.posterior_mean >>> params.C.shape (20, 5) Sample from the posterior: >>> rng = np.random.default_rng(42) >>> sample = obs_post.sample(rng) """ def __init__( self, x_dim: int | None = None, y_dims: np.ndarray | None = None, C: LoadingPosterior | None = None, alpha: ARDPosterior | None = None, d: ObsMeanPosterior | None = None, phi: ObsPrecPosterior | None = None, ): if x_dim is not None and not isinstance(x_dim, int): msg = "x_dim must be an integer." raise TypeError(msg) self.x_dim = x_dim if y_dims is not None and not isinstance(y_dims, np.ndarray): msg = "y_dims must be a numpy.ndarray of integers." raise TypeError(msg) self.y_dims = y_dims if C is None: self.C = LoadingPosterior() elif not isinstance(C, LoadingPosterior): msg = "C must be a LoadingPosterior object." raise TypeError(msg) else: self.C = C if alpha is None: self.alpha = ARDPosterior() elif not isinstance(alpha, ARDPosterior): msg = "alpha must be an ARDPosterior object." raise TypeError(msg) else: self.alpha = alpha if d is None: self.d = ObsMeanPosterior() elif not isinstance(d, ObsMeanPosterior): msg = "d must be an ObsMeanPosterior object." raise TypeError(msg) else: self.d = d if phi is None: self.phi = ObsPrecPosterior() elif not isinstance(phi, ObsPrecPosterior): msg = "phi must be an ObsPrecPosterior object." raise TypeError(msg) else: self.phi = phi def __repr__(self) -> str: return ( f"{type(self).__name__}(x_dim={self.x_dim}, " f"y_dims={self.y_dims}, " f"C={self.C}, " f"alpha={self.alpha}, " f"d={self.d}, " f"phi={self.phi})" ) @property def posterior_mean(self) -> ObsParamsRealization: """Return posterior means as a realization. Returns ------- ObsParamsRealization Realization with posterior mean values. """ return ObsParamsRealization( C=self.C.mean.copy(), d=self.d.mean.copy(), phi=self.phi.mean.copy(), alpha=self.alpha.mean.copy(), y_dims=self.y_dims.copy(), x_dim=self.x_dim, )
[docs] def sample(self, rng: np.random.Generator) -> ObsParamsRealization: """Draw a sample from the posterior distributions. Samples from q(C), q(alpha), q(d), q(phi) independently. Parameters ---------- rng : numpy.random.Generator Random number generator. Returns ------- ObsParamsRealization Sampled parameter values. """ y_dim = int(self.y_dims.sum()) n_groups = len(self.y_dims) # Sample C: each row independently from N(mean_i, cov_i) C_sample = np.zeros_like(self.C.mean) for i in range(y_dim): C_sample[i, :] = rng.multivariate_normal(self.C.mean[i, :], self.C.cov[i]) # Sample alpha: Gamma(a, b) for each element # a has shape (n_groups,), b has shape (n_groups, x_dim) alpha_sample = np.zeros_like(self.alpha.mean) for g in range(n_groups): alpha_sample[g, :] = rng.gamma( shape=self.alpha.a[g], scale=1 / self.alpha.b[g, :] ) # Sample d: N(mean, cov) element-wise (diagonal covariance) d_sample = rng.normal(self.d.mean, np.sqrt(self.d.cov)) # Sample phi: Gamma(a, b) for each element phi_sample = rng.gamma(shape=self.phi.a, scale=1 / self.phi.b) return ObsParamsRealization( C=C_sample, d=d_sample, phi=phi_sample, alpha=alpha_sample, y_dims=self.y_dims.copy(), x_dim=self.x_dim, )
[docs] def is_initialized(self) -> bool: """Check if all posterior parameters have been initialized. Returns ------- bool True if all required arrays are non-None. """ return ( self.x_dim is not None and self.y_dims is not None and self.C.mean is not None and self.C.moment is not None and self.alpha.a is not None and self.alpha.b is not None and self.alpha.mean is not None and self.d.mean is not None and self.d.cov is not None and self.phi.a is not None and self.phi.b is not None and self.phi.mean is not None )
[docs] def get_subset_dims( self, x_indices: np.ndarray, in_place: bool = True, ) -> Self: """Keep only specified latent dimensions. Parameters ---------- x_indices : ndarray of int Indices of latent dimensions to keep. in_place : bool, default True If True, modify self. If False, return new instance. Returns ------- Self Modified or new instance. """ if in_place: self.x_dim = len(x_indices) self.C.get_subset_dims(x_indices, in_place=True) self.alpha.get_subset_dims(x_indices, in_place=True) return self return self.__class__( x_dim=len(x_indices), y_dims=self.y_dims.copy(), C=self.C.get_subset_dims(x_indices, in_place=False), alpha=self.alpha.get_subset_dims(x_indices, in_place=False), d=self.d.copy(), phi=self.phi.copy(), )
[docs] def copy(self) -> Self: """Return a deep copy. Returns ------- Self Deep copy of this instance. """ return self.__class__( x_dim=self.x_dim, y_dims=self.y_dims.copy(), C=self.C.copy(), alpha=self.alpha.copy(), d=self.d.copy(), phi=self.phi.copy(), )
[docs] def compute_snr(self, y_dims: np.ndarray | None = None) -> np.ndarray: """Compute signal-to-noise ratio for each group. Parameters ---------- y_dims : ndarray or None, default None Dimensionalities of observed groups. If None, uses self.y_dims. Returns ------- ndarray SNR for each group, shape (n_groups,). """ if y_dims is None: y_dims = self.y_dims _, _, C_moments = self.C.get_groups(y_dims) phi_means, _ = self.phi.get_groups(y_dims) return np.array( [ np.trace(np.sum(C_moments[group_idx], axis=0)) / np.sum(1 / phi_means[group_idx]) for group_idx in range(len(y_dims)) ] )
[docs] @staticmethod def get_dim_types(n_groups: int) -> np.ndarray: """Generate all dimension types for n_groups. Parameters ---------- n_groups : int Number of observed groups. Returns ------- ndarray Boolean array, shape (n_groups, 2^n_groups). Column j indicates which groups are involved in dimension type j. """ n_dim_types = 2**n_groups dim_types = np.empty((n_groups, n_dim_types)) for dim_idx in range(n_dim_types): dim_str = format(dim_idx, f"0{n_groups}b") dim_types[:, dim_idx] = np.array([int(b) for b in dim_str], dtype=bool) return dim_types
[docs] def compute_dimensionalities( self, cutoff_shared_var: float = 0.02, cutoff_snr: float = 0.001, ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: """Compute dimensionalities and variance explained by dimension type. Parameters ---------- cutoff_shared_var : float, default 0.02 Minimum fraction of shared variance for significance. cutoff_snr : float, default 0.001 Minimum SNR for any latents to be significant. Returns ------- num_dim : ndarray of int, shape (n_dim_types,) Number of each dimension type. sig_dims : ndarray of bool, shape (n_groups, x_dim) Significant dimensions. var_exp : ndarray of float, shape (n_groups, n_dim_types) Variance explained by type. dim_types : ndarray of bool, shape (n_groups, n_dim_types) Dimension type indicators. """ n_groups = len(self.y_dims) dim_types = self.get_dim_types(n_groups) n_dim_types = dim_types.shape[1] snr = self.compute_snr() alpha_inv = 1 / self.alpha.mean alpha_inv_rel = alpha_inv / np.sum(alpha_inv, axis=1, keepdims=True) # snr: (n_groups,) -> (n_groups, 1) for broadcast with # alpha_inv_rel: (n_groups, x_dim) sig_dims = (alpha_inv_rel > cutoff_shared_var) & (snr > cutoff_snr)[ :, np.newaxis ] # (n_groups, x_dim) num_dim = np.zeros(n_dim_types) var_exp = np.zeros((n_groups, n_dim_types)) for dim_idx in range(n_dim_types): # dim_types[:, dim_idx]: (n_groups,) -> (n_groups, 1) for broadcast with # sig_dims dims = np.all(sig_dims == dim_types[:, dim_idx, np.newaxis], axis=0) num_dim[dim_idx] = np.sum(dims) var_exp[:, dim_idx] = np.sum(alpha_inv_rel[:, dims], axis=1) return num_dim, sig_dims, var_exp, dim_types
[docs] @staticmethod def compute_dims_pairs( num_dim: np.ndarray, dim_types: np.ndarray, var_exp: np.ndarray, ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """Analyze shared dimensionalities between pairs of groups. Parameters ---------- num_dim : ndarray Number of each dimension type, shape (n_dim_types,). dim_types : ndarray Dimension type indicators, shape (n_groups, n_dim_types). var_exp : ndarray Variance explained by type, shape (n_groups, n_dim_types). Returns ------- pair_dims : ndarray of int, shape (n_pairs, 3) Dimensionalities per pair. pair_var_exp : ndarray of float, shape (n_pairs, 2) Variance explained per pair. pairs : ndarray of int, shape (n_pairs, 2) Pair indices. """ n_groups = dim_types.shape[0] group_idxs = [np.nonzero(dim_types[g, :])[0] for g in range(n_groups)] pairs = list(combinations(range(n_groups), 2)) num_pairs = len(pairs) pair_dims = np.zeros((num_pairs, 3), dtype=int) pair_var_exp = np.zeros((num_pairs, 2)) for pair_idx, pair in enumerate(pairs): pair_dims[pair_idx, 0] = num_dim[group_idxs[pair[0]]].sum() pair_dims[pair_idx, 2] = num_dim[group_idxs[pair[1]]].sum() shared_idxs = np.intersect1d(group_idxs[pair[0]], group_idxs[pair[1]]) pair_dims[pair_idx, 1] = num_dim[shared_idxs].sum() pair_var_exp[pair_idx, 0] = var_exp[pair[0], shared_idxs].sum() pair_var_exp[pair_idx, 1] = var_exp[pair[1], shared_idxs].sum() return pair_dims, pair_var_exp, np.array(pairs)