"""Kernel conditional-independence tests for nonlinear data: KCI and RCoT.
Selected through ``indep_test="kci"`` and ``indep_test="rcot"``. Each 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
from collections.abc import Iterable
from functools import lru_cache
import numpy as np
from scipy.linalg import cho_factor, cho_solve
from scipy.spatial.distance import pdist, squareform
from scipy.special import gammaincc, ndtri
from scipy.stats import qmc
from andrey.search.hc import tie_ranks
from . import backend, env
from .ci import _canonical_ranked, _checked_data
from .independence import hsic_test
from .seeding import resolve_seed
from .warning_policy import SampleSizeWarning, warn_once
__all__ = ["KCI", "RCoT"]
# The smallest sample size Strobl et al. (2019) evaluate; with two conditioning variables, fewer
# rows were measured to reject true independences more often than alpha.
_RCOT_MIN_ROWS = 500
# Each cache of RCoT's features or KCI's matrices holds about this many bytes.
_CACHE_BYTES = 2**28
# Rows from which the skeleton scans run KCI on every BLAS thread. The gain depends on the cores a
# shared node assigns: PC fits with 8 threads ran 0.42x to 1.56x the one-thread speed on 500 rows,
# where the eigendecomposition slows with threads, 0.72x to 2.45x on 1,000, and 1.48x to 2.88x on
# 2,000.
_KCI_BLAS_THREADS_FROM = 2000
# Smallest kernel matrix (rows squared) at which `auto` runs a KCI query with a conditioning set on
# a CUDA GPU. `qa/gpu_calibration_validate.py` on an A100-SXM4-80GB against 8 CPU threads found
# 31,250 (177 rows); per query with two conditioning variables, the GPU took 0.52x the CPU's speed
# at 100 rows, 1.4x at 200, and 9.5x at 1,000. KCI computes in float64, so `mps` (float32) is never
# used.
_KCI_GPU_THRESHOLDS: dict[str, int | None] = {"cuda": 31_250, "mps": None}
def _cache_entries(floats_per_entry: int) -> int:
"""How many entries of ``floats_per_entry`` float64 values fit in ``_CACHE_BYTES``."""
return max(16, _CACHE_BYTES // (8 * floats_per_entry))
def _standardize(data: np.ndarray) -> np.ndarray:
"""Z-score each column (``ddof=1``); a column of identical values becomes zero.
The rounded mean of identical values can differ from them, which leaves a standard deviation
near zero rather than zero, so constancy is read from the range. A varying column whose
variance overflows or underflows to zero raises.
"""
with np.errstate(over="ignore", under="ignore"): # either one raises below
std = data.std(axis=0, ddof=1)
if not np.isfinite(std).all():
raise ValueError("data values too large to standardize; rescale the columns")
std[np.ptp(data, axis=0) == 0] = np.inf
if (std == 0).any():
raise ValueError("data values too small to standardize; rescale the columns")
return (data - data.mean(axis=0)) / std
def _centered_gaussian_kernel(columns: np.ndarray, width: float) -> np.ndarray:
"""Doubly centered Gaussian kernel ``H K H``, ``K_ij = exp(-|a_i - a_j|^2 / (2 width^2))``."""
kernel = np.exp(squareform(pdist(columns, "sqeuclidean")) / (-2.0 * width * width))
col_mean = kernel.mean(axis=0)
return kernel - col_mean[None, :] - col_mean[:, None] + col_mean.mean()
def _kci_moments(a: np.ndarray, b: np.ndarray) -> tuple[float, float, float]:
"""KCI's statistic ``tr(A B) / n`` and the mean and variance of its null law.
Under the null the statistic is ``(1 / n) sum_k lam_k z_k^2``, ``lam`` the eigenvalues of
``w w'`` with ``w_t = vec(psi_t phi_t')``, ``A = Psi Psi'`` and ``B = Phi Phi'``. ``w' w`` has
entries ``A_st B_st``, so the mean ``tr(w w') / n`` and the variance ``2 tr((w w')^2) / n^2``
come from the elementwise product of ``A`` and ``B``.
"""
n = a.shape[0]
ab = a * b
statistic = float(ab.sum()) / n
mean = float(np.trace(ab)) / n
var = 2.0 * float(np.sum(ab * ab)) / n**2
return statistic, mean, var
def _gamma_pvalue(statistic: float, mean: float, var: float) -> float:
"""Upper tail at ``statistic`` of the gamma law with the given mean and variance."""
if not np.isfinite([statistic, mean, var]).all():
raise FloatingPointError("KCI statistic or null moments are not finite")
if not (mean > 0.0 and var > 0.0):
return 1.0 # a constant variable: no variation to depend on
shape = mean * mean / var
scale = var / mean
return float(gammaincc(shape, statistic / scale))
[docs]
class KCI:
"""Kernel conditional-independence test (KCI) with the gamma approximation of its null.
Each query compares Gaussian-kernel features of ``x`` and ``y`` after kernel ridge regression
on the conditioning set removes what it explains. The statistic is matched to a gamma law
through its null mean and variance, which gives the p-value in closed form.
Parameters
----------
data : ndarray of shape (n_samples, n_variables)
Observed data, one row per sample. Each column is standardized to unit variance.
ridge : float, default=1e-3
Regularization of the kernel ridge regression on the conditioning set. Smaller values fit
the conditioning set more closely.
rank : int or None, default=None
With a conditioning set, replace each kernel matrix by a pivoted incomplete Cholesky
factorization of at most this many columns, so a query's time grows with the square of the
number of rows instead of its cube. The p-value then approximates the exact one. ``None``
computes the exact kernels.
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, ``ridge`` is not positive and finite, or ``rank`` is
below 1.
Notes
-----
**How it works.** With ``Kx`` the centered Gaussian kernel matrix of a variable and
``n`` rows, an empty conditioning set gives ``T = tr(Kx Ky) / n``, with null mean
``tr(Kx) tr(Ky) / n^2`` and variance ``2 tr(Kx^2) tr(Ky^2) / n^4``: the gamma HSIC test of
:func:`~andrey.core.independence.hsic_test`. A conditioning set ``S`` joins ``x`` and ``S``
into one variable ``xs`` and residualizes with ``R = ridge (Ks + ridge I)^-1``:
``A = R Kxs R``, ``B = R Ky R``, ``T = tr(A B) / n``. Under the null, ``T`` is a weighted sum
of chi-squared variables whose mean ``sum_t A_tt B_tt / n`` and variance
``2 sum_st (A_st B_st)^2 / n^2`` come from the elementwise product of ``A`` and ``B``. The
gamma law with that mean and variance gives the p-value [1]_.
Every kernel is Gaussian, ``exp(-|a - b|^2 / (2 w^2))``, on standardized columns. Without a
conditioning set, ``w`` is 0.8 below 200 rows, 0.5 below 1,200, and 0.3 beyond. With ``k``
conditioning variables, the kernels of ``xs``, ``y``, and ``S`` share
``w = c sqrt(k)``, ``c`` 1.2 below 200 rows, 0.7 below 1,200, and 0.4 beyond, and ``S``
enters ``xs`` at half scale. ``x`` joins the conditioning set and ``y`` does not, so of the
two variables the one with the lower :func:`~andrey.search.hc.tie_ranks` rank, a rank
computed from each column's own data, takes the role of ``x``, and the conditioning set's
columns enter its kernels in rank order: the p-value depends on neither the argument order
nor the column order.
With ``rank``, each kernel of a conditional query is a pivoted incomplete Cholesky factor
``L`` with ``K ~ L L'`` [2]_, centered, and ``R = I - Ls (Ls' Ls + ridge I)^-1 Ls'``, so
``A`` and ``B`` are products of ``n x rank`` factors and no ``n x n`` system is solved. The
factorization stops before ``rank`` columns when ``L L'`` reproduces the kernel to rounding.
An exact query with a conditioning set runs on a CUDA GPU, in float64, under the ``cuda``
backend, or under ``auto`` from ``ANDREY_KCI_GPU_THRESHOLD`` rows squared. Its p-value then
matches the CPU's to rounding.
**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. From 5 to 20 variables with 500 to 2,000 rows, and
at 5 variables with 5,000 rows, its median errors (SHD) stayed below the empty graph's at every
size. The median fit took 4 to 18 seconds at 500 rows, 37 seconds to 11 minutes at 2,000 rows,
and 13 minutes at 5 variables with 5,000 rows; at 20 variables and 2,000 rows, on shared nodes,
4 of 40 fits passed a half-hour cap. Each query with a conditioning set costs two products of
``n x n`` matrices, plus an eigendecomposition for a conditioning set not seen before and two
more products for a new ``y`` with it; the test keeps up to 256 MB of each kind of matrix, at
least one. So time grows as the cube of the number of rows and memory as its square: thousands
of rows are practical, tens of thousands are not. The kernel widths follow the row count and the
size of the conditioning set, not the data. On simulated draws with up to three conditioning
variables and 500 rows, it rejected a true independence at rates between ``alpha`` and twice
``alpha``; a test that rejects too often makes PC and FCI keep edges.
References
----------
.. [1] Zhang, K., Peters, J., Janzing, D., and Scholkopf, B. (2011). Kernel-based conditional
independence test and application in causal discovery. UAI, 804-813.
.. [2] Fine, S., and Scheinberg, K. (2001). Efficient SVM training using low-rank kernel
representations. Journal of Machine Learning Research 2, 243-264.
"""
@property
def pin_blas(self) -> bool:
"""Whether the skeleton scans run the test on one BLAS thread: on fewer than 2,000 rows."""
return self._n < _KCI_BLAS_THREADS_FROM
def __init__(self, data: np.ndarray, *, ridge: float = 1e-3, rank: int | None = None) -> None:
if not 0.0 < ridge < np.inf:
raise ValueError(f"ridge must be positive and finite, got {ridge}")
if rank is not None and rank < 1:
raise ValueError(f"rank must be at least 1, got {rank}")
arr = _checked_data(data, "KCI", 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()
self._data = _standardize(np.ascontiguousarray(arr[:, np.argsort(self._tie_rank)]))
self._n = arr.shape[0]
self._ridge = float(ridge)
self._rank = None if rank is None else int(rank)
# R and R Ky R do not depend on x, so queries with the same conditioning set share them.
entries = max(1, _CACHE_BYTES // (8 * self._n * self._n))
self._resid = lru_cache(entries)(self._residual_maker)
self._resid_y = lru_cache(entries)(self._residualized_kernel)
def __call__(self, x: int, y: int, condition_set: Iterable[int] = ()) -> float:
"""Return the KCI p-value for ``x`` independent of ``y`` given ``condition_set``."""
x_int, y_int, cond = _canonical_ranked(x, y, condition_set, self._tie_rank)
data, n = self._data, self._n
if not (np.ptp(data[:, x_int]) > 0.0 and np.ptp(data[:, y_int]) > 0.0):
return 1.0 # a constant variable: no variation to depend on
if not cond:
return hsic_test(data[:, x_int], data[:, y_int])
width = self._width(len(cond))
z = data[:, cond]
if self._rank is not None:
return self._low_rank_pvalue(data[:, x_int], data[:, y_int], z, width, self._rank)
xs = np.column_stack([data[:, x_int], 0.5 * z])
dev = _kci_backend(n)
if dev != "numpy":
return _gamma_pvalue(
*_kci_moments_torch(xs, data[:, [y_int]], z, width, self._ridge, dev)
)
resid = self._resid(tuple(cond))
kxs = _centered_gaussian_kernel(xs, width)
return _gamma_pvalue(*_kci_moments(resid @ kxs @ resid, self._resid_y(y_int, tuple(cond))))
def _width(self, size: int) -> float:
"""The Gaussian kernels' width with ``size`` conditioning variables."""
n = self._n
return (1.2 if n < 200 else 0.7 if n < 1200 else 0.4) * np.sqrt(size)
def _residual_maker(self, cond: tuple[int, ...]) -> np.ndarray:
"""``R = ridge (Ks + ridge I)^-1`` of the conditioning set ``cond``."""
ks = _centered_gaussian_kernel(self._data[:, list(cond)], self._width(len(cond)))
eigval, eigvec = np.linalg.eigh(ks)
shrink = self._ridge / (np.clip(eigval, 0.0, None) + self._ridge)
return (eigvec * shrink) @ eigvec.T
def _residualized_kernel(self, y: int, cond: tuple[int, ...]) -> np.ndarray:
"""``B = R Ky R``: the kernel of column ``y`` with what ``cond`` explains removed."""
resid = self._resid(cond)
ky = _centered_gaussian_kernel(self._data[:, [y]], self._width(len(cond)))
return resid @ ky @ resid
def _low_rank_pvalue(
self, x: np.ndarray, y: np.ndarray, z: np.ndarray, width: float, rank: int
) -> float:
"""The p-value with each kernel replaced by its centered incomplete Cholesky factors.
With ``Ks ~ Ls Ls'``, ``R = ridge (Ks + ridge I)^-1 = I - Ls (Ls' Ls + ridge I)^-1 Ls'``,
so ``A = R Kxs R = P P'`` with ``P = R Lxs``, and ``B = Q Q'`` with ``Q = R Ly``.
"""
lxs = _centered_cholesky_factor(np.column_stack([x, 0.5 * z]), width, rank)
ly = _centered_cholesky_factor(y[:, None], width, rank)
ls = _centered_cholesky_factor(z, width, rank)
inner = ls.T @ ls + self._ridge * np.eye(ls.shape[1])
p = lxs - ls @ np.linalg.solve(inner, ls.T @ lxs)
q = ly - ls @ np.linalg.solve(inner, ls.T @ ly)
return _gamma_pvalue(*_kci_moments(p @ p.T, q @ q.T))
def _kci_backend(n: int) -> str:
"""Where a KCI query with a conditioning set on ``n`` rows runs: ``numpy``, or torch on ``cpu``
or ``cuda``."""
manual = env.KCI_GPU_THRESHOLD.read()
return backend.select(
("numpy", "cpu", "cuda"),
("cuda", "numpy"),
metric=n * n,
thresholds=_KCI_GPU_THRESHOLDS if manual is None else {"cuda": manual},
amortized=True,
op="kci",
)
def _kci_moments_torch(
xs: np.ndarray, y: np.ndarray, z: np.ndarray, width: float, ridge: float, dev: str
) -> tuple[float, float, float]:
"""KCI's statistic and null moments as :meth:`KCI.__call__` computes them, in float64 torch on
``dev``."""
torch = backend.torch()
n = xs.shape[0]
def kernel(points: np.ndarray):
p = torch.as_tensor(points, dtype=torch.float64, device=dev)
sq = torch.zeros((n, n), dtype=torch.float64, device=dev)
for c in range(p.shape[1]):
sq += (p[:, c, None] - p[None, :, c]) ** 2
k = torch.exp(sq / (-2.0 * width * width))
col_mean = k.mean(dim=0)
return k - col_mean[None, :] - col_mean[:, None] + col_mean.mean()
eigval, eigvec = torch.linalg.eigh(kernel(z))
resid = (eigvec * (ridge / (eigval.clamp(min=0.0) + ridge))) @ eigvec.T
del eigvec
ab = (resid @ kernel(xs) @ resid) * (resid @ kernel(y) @ resid)
statistic = float(ab.sum()) / n
mean = float(torch.trace(ab)) / n
var = 2.0 * float((ab * ab).sum()) / n**2
return statistic, mean, var
def _centered_cholesky_factor(points: np.ndarray, width: float, rank: int) -> np.ndarray:
"""Centered factor ``H L`` of a pivoted incomplete Cholesky ``K ~ L L'`` of the Gaussian kernel
of ``points``, with at most ``rank`` columns.
Each step takes the row with the largest remaining diagonal as the pivot; the factorization
stops early when every remaining diagonal entry is below ``1e-12`` (the kernel's diagonal is
1), where ``L L'`` equals ``K`` to rounding.
"""
n = points.shape[0]
rank = min(rank, n)
factor = np.zeros((n, rank))
diag = np.ones(n)
scale = -0.5 / (width * width)
for j in range(rank):
pivot = int(np.argmax(diag))
if diag[pivot] < 1e-12:
factor = factor[:, :j]
break
column = np.exp(scale * np.sum((points - points[pivot]) ** 2, axis=1))
factor[:, j] = (column - factor[:, :j] @ factor[pivot, :j]) / np.sqrt(diag[pivot])
diag = np.maximum(diag - factor[:, j] ** 2, 0.0)
return factor - factor.mean(axis=0)
def _hall_buckley_eagleson_pvalue(statistic: float, weights: np.ndarray) -> float:
"""Upper tail at ``statistic`` of ``sum_i weights_i z_i^2``, ``z_i`` standard normal.
Matches the first three cumulants with a shifted and scaled chi-squared law.
"""
k1 = float(np.sum(weights))
k2 = 2.0 * float(np.sum(weights**2))
k3 = 8.0 * float(np.sum(weights**3))
if not np.isfinite([statistic, k1, k2, k3]).all():
raise FloatingPointError("RCoT statistic or null weights are not finite")
if not (k2 > 0.0 and k3 > 0.0):
return 1.0 # a constant variable: no variation to depend on
dof = 8.0 * k2**3 / k3**2
chi2 = dof + np.sqrt(2.0 * dof) * (statistic - k1) / np.sqrt(k2)
return float(gammaincc(dof / 2.0, max(chi2, 0.0) / 2.0))
def _median_distance(values: np.ndarray) -> float:
"""The median pairwise distance between the first 500 rows of ``values``; 0 below 2 rows."""
dists = pdist(values[:500], "euclidean")
return float(np.median(dists)) if dists.size else 0.0
def _fourier_features(
values: np.ndarray, count: int, rng: np.random.Generator, scale: float | None = None
) -> np.ndarray:
"""``count`` random Fourier features of the rows of ``values`` for the Gaussian kernel
``exp(-|a - b|^2 / (2 s^2))``, ``s`` the median pairwise distance over the first 500 rows, or
``scale`` when given.
The frequencies and phases come from the points of a scrambled Halton sequence, seeded by
``rng``, with one coordinate per column and one for the phase: the normal quantiles of the
first coordinates divided by ``s``, and ``2 pi`` times the last. The average product of two
rows' features converges to their kernel value.
"""
if scale is None:
scale = _median_distance(values)
columns = values.shape[1]
points = qmc.Halton(d=columns + 1, scramble=True, seed=rng).random(count)
points = np.clip(points, 1e-12, 1.0 - 1e-12) # finite normal quantiles
weights = ndtri(points[:, :columns]).T
offsets = 2.0 * np.pi * points[:, columns]
if scale > 0.0:
weights = weights / scale
return np.sqrt(2.0) * np.cos(values @ weights + offsets)
[docs]
class RCoT:
"""Randomized conditional correlation test (RCoT), a fast approximation of KCI.
Each query maps ``x``, ``y``, and the conditioning set through random Fourier features of a
Gaussian kernel, removes from the ``x`` and ``y`` features what a linear ridge regression on
the conditioning-set features explains, and tests the cross-covariance of what is left. The
cost of a query grows linearly with the number of rows.
Parameters
----------
data : ndarray of shape (n_samples, n_variables)
Observed data, one row per sample. Each column is standardized to unit variance.
n_features : int, default=5
Random Fourier features sampled for ``x`` and for ``y``.
n_features_cond : int, default=100
Random Fourier features sampled for the conditioning set. More features remove more of the
conditioning set's effect, at a cost that grows with their square. The default is four
times the 25 of [1]_: with 25, two conditioning variables that drive both ``x`` and ``y``
leave enough of their effect to reject most true independences at 500 rows.
seed : int or None, default=None
Seed for the random features. ``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 a feature count is below 1.
Warns
-----
SampleSizeWarning
On the first query with a conditioning set, when ``data`` has fewer than 500 rows, the
smallest sample size [1]_ evaluates.
Notes
-----
**How it works.** A variable ``v`` with median pairwise distance ``s`` (over its first 500
rows) gets features ``sqrt(2) cos(v W + b)``, with frequencies ``W`` from the kernel's
spectral distribution, normal of standard deviation ``1 / s``, and phases ``b`` uniform on
``[0, 2 pi]``: the random Fourier features [5]_ of the Gaussian kernel
``exp(-|v - v'|^2 / (2 s^2))``. The median distance is that kernel's bandwidth, the median
heuristic [4]_ that [1]_ uses; [1]_ prints the kernel as ``exp(-|v - v'|^2 / s)``. The
frequencies and phases are sampled by randomized quasi-Monte Carlo, the quasi-Monte Carlo
feature maps of [6]_: the points of a scrambled Halton sequence, one coordinate per variable
and one for the phase, give the frequencies through the normal quantile function and the
phases as ``2 pi`` times the last coordinate. Each point is uniform on the unit cube, so the
features stay unbiased for the kernel, and the points cover the cube more evenly than
independent samples, so a few features approximate the kernel more closely [6]_. Every feature
is standardized [1]_. With ``A`` and ``B`` the features of ``x`` and ``y``, and ``C`` those of
the conditioning set, ``A`` and ``B`` are replaced by their residuals on ``C`` (ridge
``1e-10``). The statistic is ``n`` times the squared Frobenius norm of the residuals'
cross-covariance matrix, as [1]_ defines it. Under the null it is a weighted sum of
chi-squared variables whose weights are the eigenvalues of the covariance of the products of
residuals; the p-value matches the first three cumulants of that sum with a shifted and scaled
chi-squared law (Hall-Buckley-Eagleson) [2]_ [3]_. An empty conditioning set uses ``A`` and
``B``, centered.
The features of ``x`` and of ``y`` are sampled for each conditioning set, from generators
seeded by ``seed``, the variable, and the set, and those of the conditioning set from one
seeded by ``seed`` and the set; each column enters by its :func:`~andrey.search.hc.tie_ranks`
rank, computed from the column's own data. The queries of one pair with different
conditioning sets therefore use independent features, as in [1]_, where every test samples its
own. PC removes an edge when any conditioning set gives a p-value above ``alpha``, and features
shared by those queries would make them reject together, which keeps edges between
conditionally independent variables. Queries on the same variable and conditioning set share
its features, and the test object keeps them, and the residuals, for later queries. A p-value
does not depend on the order of the queries, of the arguments, or of the columns, and the same
seed reproduces it.
**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. From 5 to 50 variables with 500 to 5,000 rows, its
median errors (SHD) stayed below the empty graph's at every size, such as 9.5 against 50 at 50
variables and 5,000 rows. The median fit took 0.2 to 0.5 seconds at 5 variables, 0.4 to 1.2
seconds at 10, 0.8 to 3.4 seconds at 20, and 3 to 15 seconds at 50, and the slowest took 70
seconds. Measured on columns independent of each other, its rate of rejecting a true
conditional independence with two conditioning variables was about twice ``alpha`` at 100 rows
and near ``alpha`` from 500 rows. With three, four, or six conditioning variables the rate
averaged about 1.3 times ``alpha`` from 500 to 2,000 rows, with no row count from which it held
at ``alpha``. The p-value is random through the features: a different seed moves it, most near
the threshold. The regression on the conditioning-set features removes a strong nonlinear effect
of that set only approximately, and what remains reads as dependence: with two or more
conditioning variables that drive both ``x`` and ``y``, the test rejects a true independence
more often than ``alpha``, increasingly so as the rows grow (on simulated data with three such
variables and 2,000 rows, nearly always). RCoT tests conditional uncorrelatedness of
``n_features`` features per variable, which can miss a dependence the full kernel test finds,
such as one between an odd and an even function of the same cause on a few hundred rows.
Because the features are sampled for each conditioning set, PC tests a weak dependence against
every conditioning set with a new chance to miss it. On the datasets above, PC with RCoT missed
14 to 20% of the true edges at 500 rows and 1.5 to 4% at 5,000 rows, and 2 to 6% of the edges
it kept were not true edges.
References
----------
.. [1] Strobl, E. V., Zhang, K., and Visweswaran, S. (2019). Approximate kernel-based
conditional independence tests for fast non-parametric causal discovery. Journal of Causal
Inference 7(1), 20180017.
.. [2] Hall, P. (1983). Chi squared approximations to the distribution of a sum of independent
random variables. Annals of Probability 11(4), 1028-1036.
.. [3] Buckley, M. J., and Eagleson, G. K. (1988). An approximation to the distribution of
quadratic forms in normal random variables. Australian Journal of Statistics 30A, 150-159.
.. [4] Gretton, A., Fukumizu, K., Teo, C. H., Song, L., Scholkopf, B., and Smola, A. J.
(2007). A kernel statistical test of independence. Advances in Neural Information
Processing Systems 20, 585-592.
.. [5] Rahimi, A., and Recht, B. (2007). Random features for large-scale kernel machines.
Advances in Neural Information Processing Systems 20, 1177-1184.
.. [6] Yang, J., Sindhwani, V., Avron, H., and Mahoney, M. W. (2014). Quasi-Monte Carlo
feature maps for shift-invariant kernels. Proceedings of the 31st International Conference
on Machine Learning, PMLR 32(1), 485-493.
"""
pin_blas = True # read by pins_blas: the skeleton scans run it on one BLAS thread
def __init__(
self,
data: np.ndarray,
*,
n_features: int = 5,
n_features_cond: int = 100,
seed: int | None = None,
) -> None:
if n_features < 1 or n_features_cond < 1:
raise ValueError(
f"feature counts must be at least 1, got {n_features} and {n_features_cond}"
)
arr = _checked_data(data, "RCoT", 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()
self._data = _standardize(np.ascontiguousarray(arr[:, np.argsort(self._tie_rank)]))
self._n = arr.shape[0]
self._n_features = int(n_features)
self._n_features_cond = int(n_features_cond)
self._seed = resolve_seed(seed)
# Bandwidths, features, and residuals depend only on their columns, so queries share them.
n = self._n
self._scale = lru_cache(maxsize=None)(
lambda column: _median_distance(self._data[:, [column]])
)
self._residual = lru_cache(_cache_entries(n * self._n_features))(self._fit_residual)
self._cond_factor = lru_cache(_cache_entries(n * self._n_features_cond))(self._factor)
def __call__(self, x: int, y: int, condition_set: Iterable[int] = ()) -> float:
"""Return the RCoT p-value for ``x`` independent of ``y`` given ``condition_set``."""
x_int, y_int, cond = _canonical_ranked(x, y, condition_set, self._tie_rank)
if cond and self._n < _RCOT_MIN_ROWS:
warn_once(
f"RCoT on fewer than {_RCOT_MIN_ROWS} rows may reject a true conditional "
"independence more often than alpha.",
SampleSizeWarning,
)
n = self._n
a, b = self._residual(x_int, tuple(cond)), self._residual(y_int, tuple(cond))
cross_cov = a.T @ b / (n - 1)
statistic = n * float(np.sum(cross_cov**2))
products = (a[:, :, None] * b[:, None, :]).reshape(n, -1)
weights = np.clip(np.linalg.eigvalsh(products.T @ products / n), 0.0, None)
return _hall_buckley_eagleson_pvalue(statistic, weights)
def _fit_residual(self, column: int, cond: tuple[int, ...]) -> np.ndarray:
"""Standardized features of ``column``, sampled from a generator seeded by the seed,
``column``, and ``cond``, minus what a ridge regression on the features of ``cond``
explains; the features themselves when ``cond`` is empty."""
# The leading 1 (0 for a conditioning set) gives a variable's generator and a set's
# different seeds.
rng = np.random.default_rng([self._seed, 1, self._n_features, column, *cond])
values = self._data[:, [column]]
features = _standardize(
_fourier_features(values, self._n_features, rng, self._scale(column))
)
if not cond:
return features
c, factor = self._cond_factor(cond)
return features - c @ cho_solve(factor, c.T @ features)
def _factor(self, cond: tuple[int, ...]) -> tuple[np.ndarray, tuple]:
"""The conditioning set's standardized features ``C``, sampled from a generator seeded by
the seed and ``cond``, and the Cholesky factor of ``C'C + ridge (n - 1) I``, that is
``(n - 1)(Sigma_CC + ridge I)``."""
rng = np.random.default_rng([self._seed, 0, self._n_features_cond, *cond])
values = self._data[:, list(cond)]
c = _standardize(_fourier_features(values, self._n_features_cond, rng))
gram = c.T @ c + 1e-10 * (self._n - 1) * np.eye(c.shape[1])
return c, cho_factor(gram)