123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629 |
- from functools import cached_property
- import numpy as np
- from scipy import linalg
- from scipy.stats import _multivariate
- __all__ = ["Covariance"]
- class Covariance:
- """
- Representation of a covariance matrix
- Calculations involving covariance matrices (e.g. data whitening,
- multivariate normal function evaluation) are often performed more
- efficiently using a decomposition of the covariance matrix instead of the
- covariance metrix itself. This class allows the user to construct an
- object representing a covariance matrix using any of several
- decompositions and perform calculations using a common interface.
- .. note::
- The `Covariance` class cannot be instantiated directly. Instead, use
- one of the factory methods (e.g. `Covariance.from_diagonal`).
- Examples
- --------
- The `Covariance` class is is used by calling one of its
- factory methods to create a `Covariance` object, then pass that
- representation of the `Covariance` matrix as a shape parameter of a
- multivariate distribution.
- For instance, the multivariate normal distribution can accept an array
- representing a covariance matrix:
- >>> from scipy import stats
- >>> import numpy as np
- >>> d = [1, 2, 3]
- >>> A = np.diag(d) # a diagonal covariance matrix
- >>> x = [4, -2, 5] # a point of interest
- >>> dist = stats.multivariate_normal(mean=[0, 0, 0], cov=A)
- >>> dist.pdf(x)
- 4.9595685102808205e-08
- but the calculations are performed in a very generic way that does not
- take advantage of any special properties of the covariance matrix. Because
- our covariance matrix is diagonal, we can use ``Covariance.from_diagonal``
- to create an object representing the covariance matrix, and
- `multivariate_normal` can use this to compute the probability density
- function more efficiently.
- >>> cov = stats.Covariance.from_diagonal(d)
- >>> dist = stats.multivariate_normal(mean=[0, 0, 0], cov=cov)
- >>> dist.pdf(x)
- 4.9595685102808205e-08
- """
- def __init__(self):
- message = ("The `Covariance` class cannot be instantiated directly. "
- "Please use one of the factory methods "
- "(e.g. `Covariance.from_diagonal`).")
- raise NotImplementedError(message)
- @staticmethod
- def from_diagonal(diagonal):
- r"""
- Return a representation of a covariance matrix from its diagonal.
- Parameters
- ----------
- diagonal : array_like
- The diagonal elements of a diagonal matrix.
- Notes
- -----
- Let the diagonal elements of a diagonal covariance matrix :math:`D` be
- stored in the vector :math:`d`.
- When all elements of :math:`d` are strictly positive, whitening of a
- data point :math:`x` is performed by computing
- :math:`x \cdot d^{-1/2}`, where the inverse square root can be taken
- element-wise.
- :math:`\log\det{D}` is calculated as :math:`-2 \sum(\log{d})`,
- where the :math:`\log` operation is performed element-wise.
- This `Covariance` class supports singular covariance matrices. When
- computing ``_log_pdet``, non-positive elements of :math:`d` are
- ignored. Whitening is not well defined when the point to be whitened
- does not lie in the span of the columns of the covariance matrix. The
- convention taken here is to treat the inverse square root of
- non-positive elements of :math:`d` as zeros.
- Examples
- --------
- Prepare a symmetric positive definite covariance matrix ``A`` and a
- data point ``x``.
- >>> import numpy as np
- >>> from scipy import stats
- >>> rng = np.random.default_rng()
- >>> n = 5
- >>> A = np.diag(rng.random(n))
- >>> x = rng.random(size=n)
- Extract the diagonal from ``A`` and create the `Covariance` object.
- >>> d = np.diag(A)
- >>> cov = stats.Covariance.from_diagonal(d)
- Compare the functionality of the `Covariance` object against a
- reference implementations.
- >>> res = cov.whiten(x)
- >>> ref = np.diag(d**-0.5) @ x
- >>> np.allclose(res, ref)
- True
- >>> res = cov.log_pdet
- >>> ref = np.linalg.slogdet(A)[-1]
- >>> np.allclose(res, ref)
- True
- """
- return CovViaDiagonal(diagonal)
- @staticmethod
- def from_precision(precision, covariance=None):
- r"""
- Return a representation of a covariance from its precision matrix.
- Parameters
- ----------
- precision : array_like
- The precision matrix; that is, the inverse of a square, symmetric,
- positive definite covariance matrix.
- covariance : array_like, optional
- The square, symmetric, positive definite covariance matrix. If not
- provided, this may need to be calculated (e.g. to evaluate the
- cumulative distribution function of
- `scipy.stats.multivariate_normal`) by inverting `precision`.
- Notes
- -----
- Let the covariance matrix be :math:`A`, its precision matrix be
- :math:`P = A^{-1}`, and :math:`L` be the lower Cholesky factor such
- that :math:`L L^T = P`.
- Whitening of a data point :math:`x` is performed by computing
- :math:`x^T L`. :math:`\log\det{A}` is calculated as
- :math:`-2tr(\log{L})`, where the :math:`\log` operation is performed
- element-wise.
- This `Covariance` class does not support singular covariance matrices
- because the precision matrix does not exist for a singular covariance
- matrix.
- Examples
- --------
- Prepare a symmetric positive definite precision matrix ``P`` and a
- data point ``x``. (If the precision matrix is not already available,
- consider the other factory methods of the `Covariance` class.)
- >>> import numpy as np
- >>> from scipy import stats
- >>> rng = np.random.default_rng()
- >>> n = 5
- >>> P = rng.random(size=(n, n))
- >>> P = P @ P.T # a precision matrix must be positive definite
- >>> x = rng.random(size=n)
- Create the `Covariance` object.
- >>> cov = stats.Covariance.from_precision(P)
- Compare the functionality of the `Covariance` object against
- reference implementations.
- >>> res = cov.whiten(x)
- >>> ref = x @ np.linalg.cholesky(P)
- >>> np.allclose(res, ref)
- True
- >>> res = cov.log_pdet
- >>> ref = -np.linalg.slogdet(P)[-1]
- >>> np.allclose(res, ref)
- True
- """
- return CovViaPrecision(precision, covariance)
- @staticmethod
- def from_cholesky(cholesky):
- r"""
- Representation of a covariance provided via the (lower) Cholesky factor
- Parameters
- ----------
- cholesky : array_like
- The lower triangular Cholesky factor of the covariance matrix.
- Notes
- -----
- Let the covariance matrix be :math:`A`and :math:`L` be the lower
- Cholesky factor such that :math:`L L^T = A`.
- Whitening of a data point :math:`x` is performed by computing
- :math:`L^{-1} x`. :math:`\log\det{A}` is calculated as
- :math:`2tr(\log{L})`, where the :math:`\log` operation is performed
- element-wise.
- This `Covariance` class does not support singular covariance matrices
- because the Cholesky decomposition does not exist for a singular
- covariance matrix.
- Examples
- --------
- Prepare a symmetric positive definite covariance matrix ``A`` and a
- data point ``x``.
- >>> import numpy as np
- >>> from scipy import stats
- >>> rng = np.random.default_rng()
- >>> n = 5
- >>> A = rng.random(size=(n, n))
- >>> A = A @ A.T # make the covariance symmetric positive definite
- >>> x = rng.random(size=n)
- Perform the Cholesky decomposition of ``A`` and create the
- `Covariance` object.
- >>> L = np.linalg.cholesky(A)
- >>> cov = stats.Covariance.from_cholesky(L)
- Compare the functionality of the `Covariance` object against
- reference implementation.
- >>> from scipy.linalg import solve_triangular
- >>> res = cov.whiten(x)
- >>> ref = solve_triangular(L, x, lower=True)
- >>> np.allclose(res, ref)
- True
- >>> res = cov.log_pdet
- >>> ref = np.linalg.slogdet(A)[-1]
- >>> np.allclose(res, ref)
- True
- """
- return CovViaCholesky(cholesky)
- @staticmethod
- def from_eigendecomposition(eigendecomposition):
- r"""
- Representation of a covariance provided via eigendecomposition
- Parameters
- ----------
- eigendecomposition : sequence
- A sequence (nominally a tuple) containing the eigenvalue and
- eigenvector arrays as computed by `scipy.linalg.eigh` or
- `numpy.linalg.eigh`.
- Notes
- -----
- Let the covariance matrix be :math:`A`, let :math:`V` be matrix of
- eigenvectors, and let :math:`W` be the diagonal matrix of eigenvalues
- such that `V W V^T = A`.
- When all of the eigenvalues are strictly positive, whitening of a
- data point :math:`x` is performed by computing
- :math:`x^T (V W^{-1/2})`, where the inverse square root can be taken
- element-wise.
- :math:`\log\det{A}` is calculated as :math:`tr(\log{W})`,
- where the :math:`\log` operation is performed element-wise.
- This `Covariance` class supports singular covariance matrices. When
- computing ``_log_pdet``, non-positive eigenvalues are ignored.
- Whitening is not well defined when the point to be whitened
- does not lie in the span of the columns of the covariance matrix. The
- convention taken here is to treat the inverse square root of
- non-positive eigenvalues as zeros.
- Examples
- --------
- Prepare a symmetric positive definite covariance matrix ``A`` and a
- data point ``x``.
- >>> import numpy as np
- >>> from scipy import stats
- >>> rng = np.random.default_rng()
- >>> n = 5
- >>> A = rng.random(size=(n, n))
- >>> A = A @ A.T # make the covariance symmetric positive definite
- >>> x = rng.random(size=n)
- Perform the eigendecomposition of ``A`` and create the `Covariance`
- object.
- >>> w, v = np.linalg.eigh(A)
- >>> cov = stats.Covariance.from_eigendecomposition((w, v))
- Compare the functionality of the `Covariance` object against
- reference implementations.
- >>> res = cov.whiten(x)
- >>> ref = x @ (v @ np.diag(w**-0.5))
- >>> np.allclose(res, ref)
- True
- >>> res = cov.log_pdet
- >>> ref = np.linalg.slogdet(A)[-1]
- >>> np.allclose(res, ref)
- True
- """
- return CovViaEigendecomposition(eigendecomposition)
- def whiten(self, x):
- """
- Perform a whitening transformation on data.
- "Whitening" ("white" as in "white noise", in which each frequency has
- equal magnitude) transforms a set of random variables into a new set of
- random variables with unit-diagonal covariance. When a whitening
- transform is applied to a sample of points distributed according to
- a multivariate normal distribution with zero mean, the covariance of
- the transformed sample is approximately the identity matrix.
- Parameters
- ----------
- x : array_like
- An array of points. The last dimension must correspond with the
- dimensionality of the space, i.e., the number of columns in the
- covariance matrix.
- Returns
- -------
- x_ : array_like
- The transformed array of points.
- References
- ----------
- .. [1] "Whitening Transformation". Wikipedia.
- https://en.wikipedia.org/wiki/Whitening_transformation
- .. [2] Novak, Lukas, and Miroslav Vorechovsky. "Generalization of
- coloring linear transformation". Transactions of VSB 18.2
- (2018): 31-35. :doi:`10.31490/tces-2018-0013`
- Examples
- --------
- >>> import numpy as np
- >>> from scipy import stats
- >>> rng = np.random.default_rng()
- >>> n = 3
- >>> A = rng.random(size=(n, n))
- >>> cov_array = A @ A.T # make matrix symmetric positive definite
- >>> precision = np.linalg.inv(cov_array)
- >>> cov_object = stats.Covariance.from_precision(precision)
- >>> x = rng.multivariate_normal(np.zeros(n), cov_array, size=(10000))
- >>> x_ = cov_object.whiten(x)
- >>> np.cov(x_, rowvar=False) # near-identity covariance
- array([[0.97862122, 0.00893147, 0.02430451],
- [0.00893147, 0.96719062, 0.02201312],
- [0.02430451, 0.02201312, 0.99206881]])
- """
- return self._whiten(np.asarray(x))
- def colorize(self, x):
- """
- Perform a colorizing transformation on data.
- "Colorizing" ("color" as in "colored noise", in which different
- frequencies may have different magnitudes) transforms a set of
- uncorrelated random variables into a new set of random variables with
- the desired covariance. When a coloring transform is applied to a
- sample of points distributed according to a multivariate normal
- distribution with identity covariance and zero mean, the covariance of
- the transformed sample is approximately the covariance matrix used
- in the coloring transform.
- Parameters
- ----------
- x : array_like
- An array of points. The last dimension must correspond with the
- dimensionality of the space, i.e., the number of columns in the
- covariance matrix.
- Returns
- -------
- x_ : array_like
- The transformed array of points.
- References
- ----------
- .. [1] "Whitening Transformation". Wikipedia.
- https://en.wikipedia.org/wiki/Whitening_transformation
- .. [2] Novak, Lukas, and Miroslav Vorechovsky. "Generalization of
- coloring linear transformation". Transactions of VSB 18.2
- (2018): 31-35. :doi:`10.31490/tces-2018-0013`
- Examples
- --------
- >>> import numpy as np
- >>> from scipy import stats
- >>> rng = np.random.default_rng(1638083107694713882823079058616272161)
- >>> n = 3
- >>> A = rng.random(size=(n, n))
- >>> cov_array = A @ A.T # make matrix symmetric positive definite
- >>> cholesky = np.linalg.cholesky(cov_array)
- >>> cov_object = stats.Covariance.from_cholesky(cholesky)
- >>> x = rng.multivariate_normal(np.zeros(n), np.eye(n), size=(10000))
- >>> x_ = cov_object.colorize(x)
- >>> cov_data = np.cov(x_, rowvar=False)
- >>> np.allclose(cov_data, cov_array, rtol=3e-2)
- True
- """
- return self._colorize(np.asarray(x))
- @property
- def log_pdet(self):
- """
- Log of the pseudo-determinant of the covariance matrix
- """
- return np.array(self._log_pdet, dtype=float)[()]
- @property
- def rank(self):
- """
- Rank of the covariance matrix
- """
- return np.array(self._rank, dtype=int)[()]
- @property
- def covariance(self):
- """
- Explicit representation of the covariance matrix
- """
- return self._covariance
- @property
- def shape(self):
- """
- Shape of the covariance array
- """
- return self._shape
- def _validate_matrix(self, A, name):
- A = np.atleast_2d(A)
- m, n = A.shape[-2:]
- if m != n or A.ndim != 2 or not (np.issubdtype(A.dtype, np.integer) or
- np.issubdtype(A.dtype, np.floating)):
- message = (f"The input `{name}` must be a square, "
- "two-dimensional array of real numbers.")
- raise ValueError(message)
- return A
- def _validate_vector(self, A, name):
- A = np.atleast_1d(A)
- if A.ndim != 1 or not (np.issubdtype(A.dtype, np.integer) or
- np.issubdtype(A.dtype, np.floating)):
- message = (f"The input `{name}` must be a one-dimensional array "
- "of real numbers.")
- raise ValueError(message)
- return A
- class CovViaPrecision(Covariance):
- def __init__(self, precision, covariance=None):
- precision = self._validate_matrix(precision, 'precision')
- if covariance is not None:
- covariance = self._validate_matrix(covariance, 'covariance')
- message = "`precision.shape` must equal `covariance.shape`."
- if precision.shape != covariance.shape:
- raise ValueError(message)
- self._chol_P = np.linalg.cholesky(precision)
- self._log_pdet = -2*np.log(np.diag(self._chol_P)).sum(axis=-1)
- self._rank = precision.shape[-1] # must be full rank if invertible
- self._precision = precision
- self._cov_matrix = covariance
- self._shape = precision.shape
- self._allow_singular = False
- def _whiten(self, x):
- return x @ self._chol_P
- @cached_property
- def _covariance(self):
- n = self._shape[-1]
- return (linalg.cho_solve((self._chol_P, True), np.eye(n))
- if self._cov_matrix is None else self._cov_matrix)
- def _colorize(self, x):
- return linalg.solve_triangular(self._chol_P.T, x.T, lower=False).T
- def _dot_diag(x, d):
- # If d were a full diagonal matrix, x @ d would always do what we want.
- # Special treatment is needed for n-dimensional `d` in which each row
- # includes only the diagonal elements of a covariance matrix.
- return x * d if x.ndim < 2 else x * np.expand_dims(d, -2)
- class CovViaDiagonal(Covariance):
- def __init__(self, diagonal):
- diagonal = self._validate_vector(diagonal, 'diagonal')
- i_zero = diagonal <= 0
- positive_diagonal = np.array(diagonal, dtype=np.float64)
- positive_diagonal[i_zero] = 1 # ones don't affect determinant
- self._log_pdet = np.sum(np.log(positive_diagonal), axis=-1)
- psuedo_reciprocals = 1 / np.sqrt(positive_diagonal)
- psuedo_reciprocals[i_zero] = 0
- self._sqrt_diagonal = np.sqrt(diagonal)
- self._LP = psuedo_reciprocals
- self._rank = positive_diagonal.shape[-1] - i_zero.sum(axis=-1)
- self._covariance = np.apply_along_axis(np.diag, -1, diagonal)
- self._i_zero = i_zero
- self._shape = self._covariance.shape
- self._allow_singular = True
- def _whiten(self, x):
- return _dot_diag(x, self._LP)
- def _colorize(self, x):
- return _dot_diag(x, self._sqrt_diagonal)
- def _support_mask(self, x):
- """
- Check whether x lies in the support of the distribution.
- """
- return ~np.any(_dot_diag(x, self._i_zero), axis=-1)
- class CovViaCholesky(Covariance):
- def __init__(self, cholesky):
- L = self._validate_matrix(cholesky, 'cholesky')
- self._factor = L
- self._log_pdet = 2*np.log(np.diag(self._factor)).sum(axis=-1)
- self._rank = L.shape[-1] # must be full rank for cholesky
- self._covariance = L @ L.T
- self._shape = L.shape
- self._allow_singular = False
- def _whiten(self, x):
- res = linalg.solve_triangular(self._factor, x.T, lower=True).T
- return res
- def _colorize(self, x):
- return x @ self._factor.T
- class CovViaEigendecomposition(Covariance):
- def __init__(self, eigendecomposition):
- eigenvalues, eigenvectors = eigendecomposition
- eigenvalues = self._validate_vector(eigenvalues, 'eigenvalues')
- eigenvectors = self._validate_matrix(eigenvectors, 'eigenvectors')
- message = ("The shapes of `eigenvalues` and `eigenvectors` "
- "must be compatible.")
- try:
- eigenvalues = np.expand_dims(eigenvalues, -2)
- eigenvectors, eigenvalues = np.broadcast_arrays(eigenvectors,
- eigenvalues)
- eigenvalues = eigenvalues[..., 0, :]
- except ValueError:
- raise ValueError(message)
- i_zero = eigenvalues <= 0
- positive_eigenvalues = np.array(eigenvalues, dtype=np.float64)
- positive_eigenvalues[i_zero] = 1 # ones don't affect determinant
- self._log_pdet = np.sum(np.log(positive_eigenvalues), axis=-1)
- psuedo_reciprocals = 1 / np.sqrt(positive_eigenvalues)
- psuedo_reciprocals[i_zero] = 0
- self._LP = eigenvectors * psuedo_reciprocals
- self._LA = eigenvectors * np.sqrt(positive_eigenvalues)
- self._rank = positive_eigenvalues.shape[-1] - i_zero.sum(axis=-1)
- self._w = eigenvalues
- self._v = eigenvectors
- self._shape = eigenvectors.shape
- self._null_basis = eigenvectors * i_zero
- # This is only used for `_support_mask`, not to decide whether
- # the covariance is singular or not.
- self._eps = _multivariate._eigvalsh_to_eps(eigenvalues) * 10**3
- self._allow_singular = True
- def _whiten(self, x):
- return x @ self._LP
- def _colorize(self, x):
- return x @ self._LA.T
- @cached_property
- def _covariance(self):
- return (self._v * self._w) @ self._v.T
- def _support_mask(self, x):
- """
- Check whether x lies in the support of the distribution.
- """
- residual = np.linalg.norm(x @ self._null_basis, axis=-1)
- in_support = residual < self._eps
- return in_support
- class CovViaPSD(Covariance):
- """
- Representation of a covariance provided via an instance of _PSD
- """
- def __init__(self, psd):
- self._LP = psd.U
- self._log_pdet = psd.log_pdet
- self._rank = psd.rank
- self._covariance = psd._M
- self._shape = psd._M.shape
- self._psd = psd
- self._allow_singular = False # by default
- def _whiten(self, x):
- return x @ self._LP
- def _support_mask(self, x):
- return self._psd._support_mask(x)
|