Source code for process_improve.simulation.latent

r"""(c) Kevin Dunn, 2010-2026. MIT License.

Data with a known latent structure, for checking latent-variable methods.

:class:`LatentStructure` describes a linear process

.. math::

    \mathbf{X} = \mathbf{T}\mathbf{P}^\top + \mathbf{E}, \qquad
    \mathbf{Y} = \mathbf{T}\mathbf{B} + \mathbf{F},

in which the caller sets the number of latent variables, how strongly each one varies,
and which of them drive each response. :meth:`LatentStructure.sample` draws data from
it, optionally with cells missing completely at random. A second call with another seed
draws fresh rows from the same process, which is the ground truth a cross-validated
estimate can be checked against. The best that any linear model can do on new rows is
known in closed form (:meth:`LatentStructure.population_coefficients` and
:meth:`LatentStructure.population_r2`).

The loadings are columns of a Hadamard matrix scaled to unit length, so every entry is
:math:`\pm 1/\sqrt{K}`. Every X column then has the same variance, and autoscaling, which
PCA and PLS apply by default, is a uniform rescale that leaves the structure unchanged.
With loadings of unequal row norms, autoscaling makes the noise heteroscedastic, and a
PLS model of the autoscaled data then needs more components than there are latent
variables to reach the best linear predictor, so the number of latent variables the
process was built with would no longer be the right answer.
"""

from __future__ import annotations

from dataclasses import dataclass

import numpy as np
import pandas as pd
from scipy.linalg import hadamard
from sklearn.utils import Bunch

from .._random import check_random_state


[docs] @dataclass(frozen=True, eq=False) class LatentStructure: """A linear latent-variable process whose structure is known. Parameters ---------- x_sd : sequence of float Standard deviation of each latent variable. Its length is the number of latent variables in X. y_coefficients : array-like of shape (n_latent,) or (n_latent, n_targets) ``B``: how each latent variable drives each response. A zero row is variation in X that Y does not see (Y-orthogonal variation). A 1-D array means one response. n_features : int, default=16 Number of X variables, ``K``: a power of two, larger than the number of latent variables. noise_sd : float, default=0.3 Standard deviation of the noise added to every X cell. y_noise_sd : float, optional Standard deviation of the noise added to every Y cell; ``noise_sd`` when omitted. Examples -------- Two latent variables drive ``y``; a PLS model should need two components. >>> process = LatentStructure(x_sd=[3.0, 1.0], y_coefficients=[1.0, 1.0]) >>> train = process.sample(60, random_state=0) >>> fresh = process.sample(4000, random_state=1) # new rows from the same process >>> process.n_relevant 2 """ x_sd: tuple[float, ...] y_coefficients: np.ndarray n_features: int = 16 noise_sd: float = 0.3 y_noise_sd: float | None = None def __post_init__(self) -> None: """Coerce the inputs to arrays and check that the structure can be built.""" x_sd = tuple(float(value) for value in np.atleast_1d(np.asarray(self.x_sd, dtype=float))) coefficients = np.asarray(self.y_coefficients, dtype=float) coefficients = coefficients.reshape(-1, 1) if coefficients.ndim == 1 else coefficients object.__setattr__(self, "x_sd", x_sd) object.__setattr__(self, "y_coefficients", coefficients) n_latent = len(x_sd) if n_latent == 0 or not all(np.isfinite(x_sd)) or min(x_sd) < 0: raise ValueError(f"x_sd must hold one or more finite, non-negative values; got {x_sd}.") if coefficients.ndim != 2 or coefficients.shape[0] != n_latent: raise ValueError( f"y_coefficients must have one row per latent variable ({n_latent}); got shape {coefficients.shape}." ) K = int(self.n_features) if K < 2 or K & (K - 1) or n_latent >= K: # a Hadamard order is a power of two; column 0 is unused raise ValueError( f"n_features must be a power of two larger than the number of latent variables ({n_latent}); got {K}." ) noise = (self.noise_sd, self._y_noise_sd) if not all(np.isfinite(noise)) or min(noise) < 0: raise ValueError(f"noise_sd and y_noise_sd must be finite and non-negative; got {noise}.") @property def _y_noise_sd(self) -> float: return float(self.noise_sd if self.y_noise_sd is None else self.y_noise_sd) @property def n_latent(self) -> int: """Number of latent variables in X.""" return len(self.x_sd) @property def n_targets(self) -> int: """Number of responses in Y.""" return int(self.y_coefficients.shape[1]) @property def n_relevant(self) -> int: """Number of latent variables that drive at least one response (a nonzero row of ``B``).""" return int(np.any(self.y_coefficients != 0, axis=1).sum()) @property def loadings(self) -> pd.DataFrame: """The loadings ``P``: orthonormal columns with every entry equal to ``+-1/sqrt(K)``.""" K = int(self.n_features) # Column 0 of a Sylvester Hadamard matrix is all ones; the latent variables use the next ones. values = hadamard(K)[:, 1 : self.n_latent + 1] / np.sqrt(K) return pd.DataFrame(values, index=self._feature_names, columns=self._latent_names) @property def _feature_names(self) -> list[str]: return [f"x{k}" for k in range(int(self.n_features))] @property def _target_names(self) -> list[str]: return [f"y{m}" for m in range(self.n_targets)] @property def _latent_names(self) -> list[str]: return [f"t{a + 1}" for a in range(self.n_latent)] def _shrinkage(self) -> np.ndarray: """``lambda / (lambda + sigma^2)`` per latent variable: how much of each one X reveals.""" variances = np.square(self.x_sd) total = variances + self.noise_sd**2 return np.divide(variances, total, out=np.zeros_like(variances), where=total > 0)
[docs] def population_coefficients(self) -> pd.DataFrame: r"""Coefficients of the best linear predictor of Y from X, on an infinite sample. With :math:`\boldsymbol{\Sigma}_{XX} = \mathbf{P}\boldsymbol{\Lambda}\mathbf{P}^\top + \sigma^2\mathbf{I}` and orthonormal :math:`\mathbf{P}`, the regression :math:`\boldsymbol{\Sigma}_{XX}^{-1}\boldsymbol{\Sigma}_{XY}` reduces to :math:`\mathbf{P}\,\mathrm{diag}\{\lambda_j / (\lambda_j + \sigma^2)\}\,\mathbf{B}`: the noise in X shrinks each latent variable's contribution by the fraction of its variance that X reveals. Every variable has mean zero, so there is no intercept. Returns ------- pd.DataFrame of shape (n_features, n_targets) """ values = self.loadings.to_numpy() @ (self._shrinkage()[:, None] * self.y_coefficients) return pd.DataFrame(values, index=self._feature_names, columns=self._target_names)
[docs] def population_r2(self) -> pd.Series: r"""Fraction of each response's variance that the best linear predictor explains on new rows. No linear model does better on new rows from this process, so this is the value a cross-validated :math:`Q^2` estimates at best (an estimate from a finite sample can land slightly above it). The predictable part is :math:`\sum_j b_{jm}^2\,\lambda_j^2 / (\lambda_j + \sigma^2)`, out of a total variance :math:`\sum_j b_{jm}^2\,\lambda_j + \sigma_y^2`. Returns ------- pd.Series of length n_targets """ variances = np.square(self.x_sd) explained = (variances * self._shrinkage()) @ np.square(self.y_coefficients) total = variances @ np.square(self.y_coefficients) + self._y_noise_sd**2 ratio = np.divide(explained, total, out=np.zeros_like(explained), where=total > 0) return pd.Series(ratio, index=self._target_names, name="population_r2")
[docs] def sample( self, n_samples: int, *, missing_fraction: float = 0.0, random_state: int | np.random.Generator | None = None, ) -> Bunch: """Draw ``n_samples`` rows from the process. The scores, the X noise and the Y noise are drawn in that order, and the missing cells after them, so the same ``random_state`` gives the same values with or without missing cells. Parameters ---------- n_samples : int Number of rows, ``N``. missing_fraction : float, default=0.0 Fraction of the X and of the Y cells deleted completely at random (set to NaN), in ``[0, 1)``. A row never loses every X cell: one is restored at random, so every row can still be scored. random_state : int, numpy.random.Generator or None, default=None Seed or generator; see ``process_improve._random.check_random_state``. Returns ------- sklearn.utils.Bunch With ``X`` (DataFrame, ``N x K``), ``Y`` (DataFrame, ``N x M``) and ``scores`` (DataFrame, ``N x n_latent``, the true latent values ``T``). """ N = int(n_samples) if N < 1: raise ValueError(f"n_samples must be >= 1; got {n_samples}.") if not 0.0 <= missing_fraction < 1.0: raise ValueError(f"missing_fraction must lie in [0, 1); got {missing_fraction}.") rng = check_random_state(random_state) K, M = int(self.n_features), self.n_targets scores = rng.standard_normal((N, self.n_latent)) * np.asarray(self.x_sd) x = scores @ self.loadings.to_numpy().T + self.noise_sd * rng.standard_normal((N, K)) y = scores @ self.y_coefficients + self._y_noise_sd * rng.standard_normal((N, M)) if missing_fraction > 0: x_missing = rng.random((N, K)) < missing_fraction y_missing = rng.random((N, M)) < missing_fraction empty = np.flatnonzero(x_missing.all(axis=1)) x_missing[empty, rng.integers(K, size=empty.size)] = False x[x_missing] = np.nan y[y_missing] = np.nan return Bunch( X=pd.DataFrame(x, columns=self._feature_names), Y=pd.DataFrame(y, columns=self._target_names), scores=pd.DataFrame(scores, columns=self._latent_names), )