-
Notifications
You must be signed in to change notification settings - Fork 5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
ENH: stats.multivariate: introduce Covariance
class and subclasses
#88
base: main
Are you sure you want to change the base?
Changes from 3 commits
c46d557
9e1467c
e4238ac
2fab47b
b0120de
70f3d77
c13b697
2619d94
a5c5c24
56c8517
60d36e2
ceacd34
9e28528
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,206 @@ | ||
from functools import cached_property | ||
|
||
import numpy as np | ||
from scipy import linalg | ||
from . import _multivariate | ||
|
||
__all__ = ["Covariance", "CovViaDiagonal", "CovViaPrecision", | ||
"CovViaEigendecomposition", "CovViaCov", "CovViaPSD"] | ||
|
||
|
||
class Covariance(): | ||
""" | ||
Representation of a covariance matrix as needed by multivariate_normal | ||
""" | ||
|
||
def whiten(self, x): | ||
""" | ||
Right multiplication by the left square root of the precision matrix. | ||
""" | ||
return self._whiten(x) | ||
|
||
@cached_property | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
def log_pdet(self): | ||
""" | ||
Log of the pseudo-determinant of the covariance matrix | ||
""" | ||
return self._log_pdet | ||
|
||
@cached_property | ||
def rank(self): | ||
""" | ||
Rank of the covariance matrix | ||
""" | ||
return self._rank | ||
|
||
@cached_property | ||
def A(self): | ||
""" | ||
Explicit representation of the covariance matrix | ||
""" | ||
return self._A | ||
|
||
@cached_property | ||
def dimensionality(self): | ||
""" | ||
Dimensionality of the vector space | ||
""" | ||
return self._dimensionality | ||
|
||
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.number): | ||
message = (f"The input `{name}` must be a square, " | ||
"two-dimensional array of 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.number): | ||
message = (f"The input `{name}` must be a one-dimensional array " | ||
"of numbers.") | ||
raise ValueError(message) | ||
return A | ||
|
||
|
||
class CovViaDiagonal(Covariance): | ||
""" | ||
Representation of a covariance provided via the diagonal | ||
""" | ||
|
||
def __init__(self, diagonal): | ||
diagonal = self._validate_vector(diagonal, 'diagonal') | ||
|
||
i_positive = diagonal > 0 | ||
positive_diagonal = diagonal[i_positive] | ||
self._log_pdet = np.sum(np.log(positive_diagonal)) | ||
|
||
psuedo_reciprocals = np.zeros_like(diagonal, dtype=np.float64) | ||
psuedo_reciprocals[i_positive] = 1 / np.sqrt(positive_diagonal) | ||
|
||
self._LP = psuedo_reciprocals | ||
self._rank = positive_diagonal.shape[-1] | ||
self._A = np.diag(diagonal) | ||
self._dimensionality = diagonal.shape[-1] | ||
self._i_positive = i_positive | ||
self._allow_singular = True | ||
|
||
def _whiten(self, x): | ||
return x * self._LP | ||
|
||
def _support_mask(self, x): | ||
""" | ||
Check whether x lies in the support of the distribution. | ||
""" | ||
return np.all(x[..., ~self._i_positive] == 0, axis=-1) | ||
|
||
|
||
class CovViaPrecision(Covariance): | ||
""" | ||
Representation of a covariance provided via the precision matrix | ||
""" | ||
|
||
def __init__(self, precision, covariance=None): | ||
precision = self._validate_matrix(precision, 'precision') | ||
if covariance is not None: | ||
covariance = self._validate_matrix(precision, 'covariance') | ||
|
||
self._LP = np.linalg.cholesky(precision) | ||
self._log_pdet = -2*np.log(np.diag(self._LP)).sum(axis=-1) | ||
self._rank = precision.shape[-1] # must be full rank in invertible | ||
self._precision = precision | ||
self._covariance = covariance | ||
self._dimensionality = self._rank | ||
self._allow_singular = False | ||
|
||
def _whiten(self, x): | ||
return x @ self._LP | ||
|
||
@cached_property | ||
def _A(self): | ||
return (np.linalg.inv(self._precision) if self._covariance is None | ||
else self._covariance) | ||
|
||
|
||
class CovViaEigendecomposition(Covariance): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. One advantage of the eigendecomposition is that it supports singular covariance matrices. But we can compute the key For nonsingular matrices, this is what it looks like: import numpy as np
from scipy import linalg
n = 40
rng = np.random.default_rng(14)
x = rng.random(size=(n, 1))
A = rng.random(size=(n, n))
A = A @ A.T
# Eigendecomposition route
w, V = linalg.eigh(A)
z = x.T @ (V * w**(-0.5))
xSxT = (z**2).sum()
# LDL route
L, D, perm = linalg.ldl(A)
y = linalg.solve_triangular(L[perm], x[perm], lower=True).T * np.diag(D)**(-0.5)
xSxT2 = (y**2).sum()
print(xSxT, xSxT2) For singular matrices, we need to mask out the zero eigenvalues, so it looks a little more complicated. When the vector import numpy as np
from scipy import linalg
n = 10
rng = np.random.default_rng(1329348965454623456)
x = rng.random(size=(n, 1))
A = rng.random(size=(n, n))
A[0] = A[1] + A[2] # make it singular
x[0] = x[1] + x[2] # ensure x is in the subspace
A = A @ A.T
eps = 1e-10
# Eigendecomposition route
w, V = linalg.eigh(A)
mask = np.abs(w) < eps
w_ = w.copy()
w_[mask] = np.inf
z = x.T @ (V * w_**(-0.5))
xSxT = (z**2).sum()
# LDL route
L, D, perm = linalg.ldl(A)
d = np.diag(D).copy()[:, np.newaxis]
mask = np.abs(d) < eps
d_ = d.copy()
d[mask] = 0
d_[mask] = np.inf
y = linalg.solve_triangular(L[perm], x[perm], lower=True) * d_**(-0.5)
xSxT2 = (y**2).sum()
np.testing.assert_allclose(xSxT, xSxT2) And we can tell it's in the subspace if There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This needs some work. The pseudodeterminant calculation isn't just the pseudodeterminant of the class CovViaLDL(Covariance):
def __init__(self, ldl):
L, d, perm = ldl
d = self._validate_vector(d, 'd')
perm = self._validate_vector(perm, 'perm')
L = self._validate_matrix(L, 'L')
i_zero = d <= 0
positive_d = np.array(d, dtype=np.float64)
positive_d[i_zero] = 1 # ones don't affect determinant
self._log_pdet = np.sum(np.log(positive_d), axis=-1)
psuedo_reciprocals = 1 / np.sqrt(positive_d)
psuedo_reciprocals[i_zero] = 0
self._psuedo_reciprocals = psuedo_reciprocals
self._d = d
self._perm = perm
self._L = L
self._rank = d.shape[-1] - i_zero.sum(axis=-1)
self._dimensionality = d.shape[-1]
self._shape = L.shape
# This is only used for `_support_mask`, not to decide whether
# the covariance is singular or not.
self._eps = 1e-8
self._allow_singular = True
def _whiten(self, x):
L = self.L[self.perm]
x = self.x[self.perm]
return linalg.solve_triangular(L, x, lower=True) * self.psuedo_reciprocals
@cached_property
def _covariance(self):
return (self._L * self._d) @ self._L.T
@staticmethod
def from_ldl(ldl):
r"""
Representation of covariance provided via LDL (aka LDLT) decomposition
Parameters
----------
ldl : sequence
A sequence (nominally a tuple) containing the lower factor ``L``,
the diagonal multipliers ``d``, and row-permutation indices
``perm`` as computed by `scipy.linalg.ldl`.
Notes
-----
Let the covariance matrix be :math:`A`, and let :math:`L` be a lower
triangular matrix and :math:`D` be a diagonal matrix such that
:math:`L D L^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 CovViaLDL(ldl) |
||
""" | ||
Representation of a covariance provided via eigenvalues and eigenvectors | ||
""" | ||
|
||
def __init__(self, eigendecomposition): | ||
eigenvalues, eigenvectors = eigendecomposition | ||
eigenvalues = self._validate_vector(eigenvalues, 'eigenvalues') | ||
eigenvectors = self._validate_matrix(eigenvectors, 'eigenvectors') | ||
|
||
i_positive = eigenvalues > 0 | ||
positive_eigenvalues = eigenvalues[i_positive] | ||
self._log_pdet = np.sum(np.log(positive_eigenvalues)) | ||
|
||
psuedo_reciprocals = np.zeros_like(eigenvalues) | ||
psuedo_reciprocals[i_positive] = 1 / np.sqrt(positive_eigenvalues) | ||
|
||
self._LP = eigenvectors * psuedo_reciprocals | ||
self._rank = positive_eigenvalues.shape[-1] | ||
self._w = eigenvalues | ||
self._v = eigenvectors | ||
self._dimensionality = eigenvalues.shape[-1] | ||
self._null_basis = eigenvectors[..., ~i_positive] | ||
self._eps = _multivariate._eigvalsh_to_eps(eigenvalues) * 10**3 | ||
self._allow_singular = True | ||
|
||
def _whiten(self, x): | ||
return x @ self._LP | ||
|
||
@cached_property | ||
def _A(self): | ||
return (self._v * self._w) @ self._v.swapaxes(-2, -1) | ||
|
||
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 CovViaCov(Covariance): | ||
""" | ||
Representation of a covariance provided via the precision matrix | ||
""" | ||
|
||
def __init__(self, cov): | ||
cov = self._validate_matrix(cov, 'cov') | ||
|
||
self._factor = np.linalg.cholesky(cov.T[::-1, ::-1])[::-1, ::-1] | ||
self._log_pdet = 2*np.log(np.diag(self._factor)).sum(axis=-1) | ||
self._rank = cov.shape[-1] # must be full rank for cholesky | ||
self._A = cov | ||
self._dimensionality = self._rank | ||
self._allow_singular = False | ||
|
||
def _whiten(self, x): | ||
return linalg.solve_triangular(self._factor, x.T, lower=False).T | ||
|
||
|
||
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._A = psd._M | ||
self._dimensionality = psd._M.shape[-1] | ||
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) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This name is taken from KDE. Really, I think I'd just replace this method with
xTPx
, which would do the equivalent ofnp.sum(np.square(np.dot(dev, prec_U)), axis=-1)
.