"""Gaussian-process regression followed by a distance-correlation test (GPDC), for nonlinear data.
Selected through ``indep_test="gpdc"``. The test follows the :class:`~andrey.core.ci.CITest`
protocol: built once over the data and called as ``test(x, y, condition_set)``, returning a p-value
where a larger value means more independent.
"""
from __future__ import annotations
import warnings
from collections.abc import Iterable
import numpy as np
from sklearn.exceptions import ConvergenceWarning
from sklearn.gaussian_process import GaussianProcessRegressor
from sklearn.gaussian_process.kernels import RBF, WhiteKernel
from andrey.search.hc import tie_ranks
from .ci import _canonical_ranked, _checked_data
from .seeding import resolve_seed
__all__ = ["GPDC"]
def _centered_distances(ranks: np.ndarray) -> np.ndarray:
"""The doubly centered matrix of pairwise distances between the entries of ``ranks``."""
dist = np.abs(ranks[:, None] - ranks[None, :]).astype(np.float64)
col_mean = dist.mean(axis=0)
return dist - col_mean[None, :] - col_mean[:, None] + col_mean.mean()
def _squared_distance_correlation(a: np.ndarray, b: np.ndarray) -> float:
"""Squared distance correlation from the doubly centered distance matrices ``a`` and ``b``.
Zero when either variable is constant.
"""
var_a, var_b = float(np.mean(a * a)), float(np.mean(b * b))
if not (var_a > 0.0 and var_b > 0.0):
return 0.0
return float(np.mean(a * b)) / np.sqrt(var_a * var_b)
[docs]
class GPDC:
"""Gaussian-process regression, then a distance-correlation test of the residuals (GPDC).
Each query regresses ``x`` and ``y`` on the conditioning set with a Gaussian process, ranks the
two residuals, and tests their distance correlation, which is zero only when they are
independent. The null distribution of the ranked statistic depends only on the number of rows,
so it is drawn once and reused by every query.
Parameters
----------
data : ndarray of shape (n_samples, n_variables)
Observed data, one row per sample. Each column is standardized to unit variance.
n_null : int, default=1000
Draws of the null distribution of the distance correlation.
seed : int or None, default=None
Seed for the null draws and the tie-breaking. ``None`` uses ``ANDREY_SEED``, or ``0`` when
it is unset.
Raises
------
ValueError
If ``data`` is not 2-D, has fewer than 2 rows, contains NaN or infinity, or has values
too large or too small to standardize, or ``n_null`` is below 1.
Notes
-----
**How it works.** For a conditioning set ``z``, ``x`` and ``y`` are each fitted by a
Gaussian-process regression on ``z`` with a radial-basis-function kernel plus white noise,
whose length scale and noise level maximize the marginal likelihood [2]_; the residuals are the
observations minus the fitted means. An empty conditioning set uses ``x`` and ``y`` themselves.
A fit depends only on the variable and the conditioning set, so its residuals are kept on the
test object and reused by every later query on the same pair of them.
The residuals are replaced by their ranks, which gives them uniform marginals; tied values
are ordered by a random key per row and variable, drawn once from ``seed``, so that every
ranking stays equally likely under the null. Each variable takes its keys, and its place among
the regressors of a conditioning set, by its :func:`~andrey.search.hc.tie_ranks` rank, a rank
computed from each column's own data, so a p-value does not depend on the column order. The
statistic is their squared distance correlation, from the doubly centered matrices of their
pairwise distances [3]_. After ranking, the null is the same statistic between the ranks and a
random permutation of them, drawn ``n_null`` times [1]_. With ``b`` of the ``n_null`` draws at
least as large as the statistic, the p-value is ``(b + 1) / (n_null + 1)``: the observed
statistic counts as one more draw, so the p-value is never zero [4]_.
**Limitations.** Measured in PC on simulated data from sparse random graphs (mean degree 2),
each variable a weighted sum of ``z + sin(z)``, or of ``cos(z)``, over its parents plus Gaussian
noise, with 20 datasets per size on 4 cores. At 5 variables with 500 to 2,000 rows, and 10 and
20 with 500 and 1,000, its median errors (SHD) stayed below the empty graph's at every size. The
median fit took 12 seconds to 2 minutes at 500 rows, and 15 minutes at 20 variables with 1,000
rows. The test assumes ``x`` and ``y`` depend on the conditioning set additively, a function of
it plus noise; a dependence through the noise's scale, such as ``x = z e``, stays in the
residuals and reads as conditional dependence. The null ignores the degrees of freedom the
regressions use, as [1]_ does. Each regression costs the cube of the number of rows per
optimizer step, so thousands of rows take seconds per query. The p-value is a multiple of
``1 / (n_null + 1)``, and is at least that.
References
----------
.. [1] Runge, J., Nowack, P., Kretschmer, M., Flaxman, S., and Sejdinovic, D. (2019).
Detecting and quantifying causal associations in large nonlinear time series datasets.
Science Advances 5(11), eaau4996.
.. [2] Rasmussen, C. E., and Williams, C. K. I. (2006). Gaussian Processes for Machine
Learning. MIT Press.
.. [3] Szekely, G. J., Rizzo, M. L., and Bakirov, N. K. (2007). Measuring and testing
dependence by correlation of distances. Annals of Statistics 35(6), 2769-2794.
.. [4] Phipson, B., and Smyth, G. K. (2010). Permutation p-values should never be zero:
calculating exact p-values when permutations are randomly drawn. Statistical Applications
in Genetics and Molecular Biology 9(1), Article 39.
"""
pin_blas = True # read by pins_blas: the skeleton scans run it on one BLAS thread
def __init__(self, data: np.ndarray, *, n_null: int = 1000, seed: int | None = None) -> None:
if n_null < 1:
raise ValueError(f"n_null must be at least 1, got {n_null}")
arr = _checked_data(data, "GPDC", min_rows=2)
# The test holds data column j at index _tie_rank[j], in C order, so a query reads the
# same values in the same order whatever the column order or memory layout.
self._tie_rank = tie_ranks(arr).tolist()
arr = np.ascontiguousarray(arr[:, np.argsort(self._tie_rank)])
with np.errstate(over="ignore", under="ignore"): # either one raises below
std = arr.std(axis=0)
if not np.isfinite(std).all():
raise ValueError("data values too large to standardize; rescale the columns")
std[np.ptp(arr, axis=0) == 0] = np.inf # identical values; their std is rounding, not 0
if (std == 0).any():
raise ValueError("data values too small to standardize; rescale the columns")
self._data = (arr - arr.mean(axis=0)) / std
self._n = arr.shape[0]
self._n_null = int(n_null)
self._seed = resolve_seed(seed)
self._tie_keys = np.random.default_rng(self._seed).random(arr.shape)
self._null: np.ndarray | None = None
self._residuals: dict[tuple[int, tuple[int, ...]], np.ndarray] = {}
def __call__(self, x: int, y: int, condition_set: Iterable[int] = ()) -> float:
"""Return the GPDC p-value for ``x`` independent of ``y`` given ``condition_set``."""
x_int, y_int, cond = _canonical_ranked(x, y, condition_set, self._tie_rank)
rx, ry = self._residual(x_int, cond), self._residual(y_int, cond)
if not (np.ptp(rx) > 0.0 and np.ptp(ry) > 0.0):
return 1.0 # a constant variable: no variation to depend on
statistic = _squared_distance_correlation(
_centered_distances(self._ranks(rx, x_int)), _centered_distances(self._ranks(ry, y_int))
)
exceed = np.count_nonzero(self._null_distribution() >= statistic)
return (exceed + 1) / (self._n_null + 1)
def _residual(self, column: int, cond: list[int]) -> np.ndarray:
"""``column`` minus its Gaussian-process fit on ``cond``, kept for later queries."""
target = self._data[:, column]
if not cond:
return target
key = (column, tuple(cond))
residual = self._residuals.get(key)
if residual is None:
regressor = GaussianProcessRegressor(kernel=RBF() + WhiteKernel(), alpha=0.0)
features = self._data[:, cond]
with warnings.catch_warnings():
# A length scale or noise level at its bound is a valid fit (a target unrelated to
# the conditioning set, or explained by it exactly), not a failure.
warnings.simplefilter("ignore", ConvergenceWarning)
regressor.fit(features, target)
residual = target - regressor.predict(features)
self._residuals[key] = residual
return residual
def _ranks(self, values: np.ndarray, column: int) -> np.ndarray:
"""Ranks 0 to n - 1, ties broken by the random key of ``column``."""
return np.argsort(np.lexsort((self._tie_keys[:, column], values)))
def _null_distribution(self) -> np.ndarray:
"""``n_null`` distance correlations of the ranks with a random permutation of them."""
if self._null is None:
rng = np.random.default_rng([self._seed, self._n])
a = _centered_distances(np.arange(self._n))
draws = np.empty(self._n_null)
for i in range(self._n_null):
perm = rng.permutation(self._n)
draws[i] = _squared_distance_correlation(a, a[np.ix_(perm, perm)])
self._null = draws
return self._null