"""Conditional-independence test substrate: partial-correlation, chi-square, and mixed-data tests.
The constraint-based discovery family (PC / FCI / CDNOD) consumes a conditional-independence test
through the :class:`CITest` contract: an object constructed once over the data and called as
``test(x, y, condition_set)`` returning a p-value, where a larger p-value means more independent.
:class:`PartialCorrelationTest` is the base of the two partial-correlation tests. It precomputes
the sample correlation matrix once, then answers each query from the conditioned partial
correlation of columns ``x`` and ``y`` given ``condition_set``. Empty and single-element
conditioning sets -- the bulk of a constraint-based search -- use the closed-form (first-order)
partial correlation and skip the matrix inversion entirely; larger sets invert the correlation
submatrix. numpy float64 is the oracle. A query is canonicalized (``x``, ``y`` ordered, the
conditioning set sorted unique) so its p-value does not depend on argument order, and nothing is
memoized: a test object holds the correlation matrix and the sample size, whatever it is asked.
The scalar path, the batched path (:meth:`~PartialCorrelationTest.batched_call`), the all-pairs
and first-order closed forms, the many-query path
(:meth:`~PartialCorrelationTest.statistic_pvalues`), and the path for queries already canonical,
which the skeleton search and GFCI's separating-set search take, return the same p-values exactly.
The two partial-correlation tests differ only in how a partial correlation ``r`` and its degrees of
freedom become a p-value. :class:`FisherZ` applies the normal distribution to Fisher's z-transform
of ``r``; :class:`StudentT` applies the Student-t distribution to ``t = r * sqrt(df / (1 - r**2))``.
:class:`ChiSquare` implements the Pearson chi-square and G-square tests on category codes: one
contingency table of ``x`` and ``y`` per observed configuration of the conditioning set. Its
batched path, all-pairs marginal path, and first-order path count many queries' tables together
and return the scalar p-values exactly.
:class:`DGTest` and :class:`CGTest` test mixed data, continuous and categorical columns marked by a
mask, by the likelihood ratio of the degenerate Gaussian and the conditional Gaussian models of
:mod:`andrey.core.mixed`. :class:`CGTest` has the same three many-query paths, fitting their
queries' sets together, with the scalar p-values exactly; :class:`DGTest` answers one query at a
time.
The registry at the end maps each ``indep_test`` name to a factory. The nonlinear tests live in
their own modules (KCI and RCoT in :mod:`andrey.core.ci_kernel`, CMIknn in
:mod:`andrey.core.ci_cmiknn`, GPDC in :mod:`andrey.core.ci_gpdc`) and are imported when first
built.
References
----------
Spirtes, P., Glymour, C., and Scheines, R. (2000). Causation, Prediction, and Search, 2nd ed.
MIT Press.
Tsamardinos, I., Brown, L. E., and Aliferis, C. F. (2006). The max-min hill-climbing Bayesian
network structure learning algorithm. Machine Learning 65(1), 31-78.
https://doi.org/10.1007/s10994-006-6889-7
Andrews, B., Ramsey, J., and Cooper, G. F. (2018). Scoring Bayesian networks of mixed variables.
International Journal of Data Science and Analytics 6(1), 3-18.
https://doi.org/10.1007/s41060-017-0085-7
Andrews, B., Ramsey, J., and Cooper, G. F. (2019). Learning high-dimensional directed acyclic
graphs with mixed data-types. Proceedings of Machine Learning Research 104, 4-21.
"""
from __future__ import annotations
import importlib
import math
from collections.abc import Callable, Iterable, Sequence
from functools import partial
from typing import Any, Protocol, Self, cast, runtime_checkable
import numpy as np
import numpy.typing as npt
from scipy.special import chdtrc, ndtr, stdtr
from . import categorical, stats
from .mixed import ConditionalGaussian, DegenerateGaussian
from .score import copies_error, copies_message, copy_groups
__all__ = [
"CITest",
"FisherZ",
"StudentT",
"register_indep_test",
"available_indep_tests",
"make_indep_test",
]
# Partial correlations are clamped just inside (-1, 1) so Fisher's z-transform stays finite on
# deterministic or tiny-sample data.
_ONE_MINUS_EPS = 1.0 - np.finfo(np.float64).eps
# A correlation submatrix is singular when its smallest eigenvalue is at most this fraction of its
# largest.
_SINGULAR_RTOL = 1e-12
# How far the inverse bound in _invert_nonsingular stays above that threshold; the factor absorbs
# the rounding of the computed inverse and of the eigenvalues.
_BOUND_MARGIN = 1e4
class SingularCorrelationError(ValueError):
"""A ``[x, y, *S]`` correlation submatrix is singular to working precision.
Its smallest eigenvalue is at most ``_SINGULAR_RTOL`` times its largest, so its inverse cannot
give the partial correlation. :meth:`PartialCorrelationTest.residual_statistic_pvalues` gives it
from the data instead. When the data hold columns that repeat others up to scale and shift,
``groups`` holds them as :func:`~andrey.core.score.copy_groups` returns them, and the message
gives :func:`~andrey.core.score.copies_message` of those groups as the cause; otherwise
``groups`` is empty.
"""
groups: tuple[tuple[int, ...], ...] = ()
[docs]
@runtime_checkable
class CITest(Protocol):
"""A conditional-independence test callable over fixed data.
An implementer is constructed once over the data matrix and invoked per query as
``test(x, y, condition_set)``, returning the test's p-value for ``x`` independent of ``y`` given
``condition_set``. A larger p-value indicates more independence.
"""
def __call__(self, x: int, y: int, condition_set: Iterable[int] = ()) -> float: ...
class PartialCorrelationTest:
"""Base of the partial-correlation tests; a subclass maps ``(r, df)`` to a p-value.
``__init__`` records the sample size and precomputes the full column correlation matrix once.
Each call forms the partial correlation ``r`` of ``x`` and ``y`` given the conditioning set --
directly (``|S| == 0``), by the first-order closed form (``|S| == 1``), or by inverting the
``[x, y, *S]`` correlation submatrix (``|S| >= 2``) -- clamps it just inside ``(-1, 1)``, and
reports the subclass's p-value for ``r`` on ``n - |S| - k`` degrees of freedom. The ``|S| <= 1``
closed forms do not invert a submatrix, so on exactly-collinear columns they return a finite
(near-1) partial correlation where an always-invert path would instead raise on a singular
matrix.
Queries are canonicalized (``x``, ``y`` ordered, ``condition_set`` sorted unique), so the
p-value is independent of argument order. No query is memoized: the skeleton tests each
conditioning set of a pair once, so retained results would grow with the number of subsets
visited. Later queries are recomputed.
The scalar path, :meth:`batched_call`, :meth:`statistic_pvalues`, :meth:`marginal_pvalues`,
:meth:`first_order_pvalues`, and ``_canonical_pvalues``, which the skeleton search and GFCI's
separating-set search call with queries canonical as built, use identical partial-correlation,
clip, and p-value arithmetic.
Their p-values match exactly, so repeated queries take the same ``p > alpha`` decision across
call paths, including at the threshold.
"""
# The data's groups of copies, set by the first singular inversion.
_copies: tuple[tuple[int, ...], ...]
def __init__(self, data: np.ndarray) -> None:
arr = np.asarray(data, dtype=np.float64)
if arr.ndim != 2:
raise ValueError(
f"data must be a 2-D (n_samples, n_features) array, got ndim={arr.ndim}"
)
if not np.isfinite(arr).all():
raise ValueError("data contains NaN or Inf; cannot run a partial-correlation test")
self._n: int = arr.shape[0]
self._corr: np.ndarray = np.asarray(stats.corrcoef(arr, rowvar=False), dtype=np.float64)
def _degrees_of_freedom(self, size: int) -> int:
"""The degrees of freedom of a test given ``size`` conditioners; raises when unusable."""
raise NotImplementedError
@staticmethod
def _pvalue(r: np.ndarray | float, df: int) -> np.ndarray | float:
"""The p-value of the clamped partial correlation(s) ``r`` on ``df`` degrees of freedom."""
raise NotImplementedError
def __call__(self, x: int, y: int, condition_set: Iterable[int] = ()) -> float:
"""Return the p-value for ``x`` independent of ``y`` given ``condition_set``.
``condition_set`` is canonicalized to sorted unique ints; ``x`` and ``y`` are ordered so the
p-value is independent of argument order. A larger p-value means more independence.
"""
return self.statistic_pvalue(x, y, condition_set)[1]
def statistic_pvalue(
self, x: int, y: int, condition_set: Iterable[int] = ()
) -> tuple[float, float]:
"""Return the partial correlation and p-value of ``x`` and ``y`` given ``condition_set``.
The p-value is the one :meth:`__call__` returns. The partial correlation is clamped just
inside ``(-1, 1)`` and is symmetric in ``x`` and ``y``.
"""
x_int, y_int, cond = _canonical(x, y, condition_set)
df = self._degrees_of_freedom(len(cond))
r = self._partial_correlation(x_int, y_int, cond)
return r, float(self._pvalue(r, df))
def _partial_correlation(self, x_int: int, y_int: int, cond: list[int]) -> float:
"""Partial correlation of columns ``x_int`` and ``y_int`` given ``cond``, clamped.
The arguments are canonical (see :func:`_canonical`). The result lies just inside
``(-1, 1)``.
"""
corr = self._corr
if not cond:
r = float(corr[x_int, y_int]) # marginal correlation; no inversion
elif len(cond) == 1: # first-order partial correlation, closed form
z_idx = cond[0]
rxy = float(corr[x_int, y_int])
rxz = float(corr[x_int, z_idx])
ryz = float(corr[y_int, z_idx])
r = (rxy - rxz * ryz) / math.sqrt((1.0 - rxz * rxz) * (1.0 - ryz * ryz))
else:
var = (x_int, y_int, *cond)
inv = self._invert(corr[np.ix_(var, var)][None])[0]
r = -float(inv[0, 1]) / math.sqrt(abs(float(inv[0, 0]) * float(inv[1, 1])))
return (
-_ONE_MINUS_EPS if r < -_ONE_MINUS_EPS else _ONE_MINUS_EPS if r > _ONE_MINUS_EPS else r
)
def copies(self) -> tuple[tuple[int, ...], ...]:
"""The groups of data columns that repeat one another up to scale and shift.
:func:`~andrey.core.score.copy_groups` of the test's correlation matrix, found once per
test; a single column's correlation is a scalar and has none.
"""
if not hasattr(self, "_copies"):
self._copies = copy_groups(np.atleast_2d(self._corr), self._n)
return self._copies
def _invert(self, stack: np.ndarray) -> np.ndarray:
""":func:`_invert_nonsingular` of ``stack``; a singular error names the data's copies."""
try:
return _invert_nonsingular(stack)
except SingularCorrelationError as error:
copies = self.copies()
if not copies:
raise
named = SingularCorrelationError(
f"correlation matrix is singular because {copies_message(copies)}"
)
named.groups = copies
raise named from error
def batched_call(self, x: int, y: int, condition_sets: Iterable[Iterable[int]]) -> list[float]:
"""Return p-values for one ``(x, y)`` pair over many conditioning sets.
The result list matches the order of ``condition_sets``. Each set is canonicalized as in
:meth:`__call__`; queries are grouped by set size so each size is evaluated in one
vectorized shot, and the p-values match :meth:`__call__` exactly. A single-set
batch takes the scalar path. Nothing is retained once the list is returned.
"""
condition_sets = list(condition_sets)
n = len(condition_sets)
if n == 0:
return []
if n == 1:
return [self(x, y, condition_sets[0])]
x_int, y_int = (int(x), int(y)) if x < y else (int(y), int(x))
results: list[float | None] = [None] * n
groups: dict[int, list[tuple[int, list[int]]]] = {}
for i, condition_set in enumerate(condition_sets):
cond = sorted(set(int(c) for c in condition_set))
assert x_int not in cond and y_int not in cond, "x, y cannot be in condition_set."
groups.setdefault(len(cond), []).append((i, cond))
for members in groups.values():
pvals = self._pvalues(x_int, y_int, [cond for _, cond in members])
for (idx, _), p in zip(members, pvals):
results[idx] = float(p)
return cast("list[float]", results)
def _pvalues(self, x_int: int, y_int: int, conds: list[list[int]]) -> np.ndarray:
"""P-values for stacked queries on one pair sharing one conditioning-set size.
``conds`` are sorted-unique int lists of equal length.
"""
size = len(conds[0])
cond_array = np.asarray(conds, dtype=np.intp).reshape(len(conds), size)
x = np.full(len(conds), x_int, dtype=np.intp)
return self.statistic_pvalues(x, np.full_like(x, y_int), cond_array)[1]
def statistic_pvalues(
self, x: np.ndarray, y: np.ndarray, conditions: np.ndarray
) -> tuple[np.ndarray, np.ndarray]:
"""Partial correlations and p-values of many queries sharing one conditioning-set size.
``x`` and ``y`` are ``(N,)`` int arrays and ``conditions`` the ``(N, m)`` int array whose
row ``i`` is the conditioning set of query ``i``: distinct columns that exclude ``x[i]``
and ``y[i]``. Entry ``i`` of each result matches ``statistic_pvalue(x[i], y[i],
conditions[i])`` exactly. Empty and single conditioners use the closed-form partial
correlation; larger sets build the ``(N, m + 2, m + 2)`` stack of correlation submatrices
and invert it once. Raises :class:`ValueError` when the degrees of freedom are unusable or
any submatrix is singular.
"""
x = np.asarray(x, dtype=np.intp)
y = np.asarray(y, dtype=np.intp)
lo, hi = np.minimum(x, y), np.maximum(x, y)
conds = np.sort(np.asarray(conditions, dtype=np.intp), axis=1)
return self._canonical_statistic_pvalues(lo, hi, conds)
def _canonical_pvalues(
self, x: np.ndarray, y: np.ndarray, conditions: np.ndarray
) -> np.ndarray:
"""P-values of many canonical queries sharing one conditioning-set size.
``x`` and ``y`` are ``(N,)`` int arrays with ``x[i] < y[i]``, and ``conditions`` the
``(N, m)`` int array whose row ``i`` is sorted and distinct and excludes ``x[i]`` and
``y[i]``, as the skeleton searches build their queries. Nothing is reordered or checked;
entry ``i`` matches ``self(x[i], y[i], conditions[i])`` exactly.
"""
return self._canonical_statistic_pvalues(x, y, conditions)[1]
def _canonical_statistic_pvalues(
self, lo: np.ndarray, hi: np.ndarray, conds: np.ndarray
) -> tuple[np.ndarray, np.ndarray]:
""":meth:`statistic_pvalues` of canonical queries, ``lo[i] < hi[i]`` and each row of
``conds`` sorted."""
df = self._degrees_of_freedom(conds.shape[1])
corr = self._corr
if conds.shape[1] == 0:
r = corr[lo, hi]
elif conds.shape[1] == 1:
z_idx = conds[:, 0]
rxz = corr[lo, z_idx]
ryz = corr[hi, z_idx]
r = (corr[lo, hi] - rxz * ryz) / np.sqrt((1.0 - rxz * rxz) * (1.0 - ryz * ryz))
else:
var = np.empty((len(lo), 2 + conds.shape[1]), dtype=np.intp)
var[:, 0] = lo
var[:, 1] = hi
var[:, 2:] = conds
inv = self._invert(corr[var[:, :, None], var[:, None, :]])
r = -inv[:, 0, 1] / np.sqrt(np.abs(inv[:, 0, 0] * inv[:, 1, 1]))
r = np.clip(r, -_ONE_MINUS_EPS, _ONE_MINUS_EPS)
return r, np.asarray(self._pvalue(r, df))
def residual_statistic_pvalues(
self, data: np.ndarray, x: np.ndarray, y: np.ndarray, conditions: np.ndarray
) -> tuple[np.ndarray, np.ndarray]:
"""Partial correlations and p-values of many queries, from least-squares residuals.
``data`` is the matrix the test was built from, and ``x``, ``y``, and ``conditions`` are
as in :meth:`statistic_pvalues`. Each column is centered and scaled to unit variance; the
partial correlation is the correlation of the residuals of ``x`` and of ``y`` after a
least-squares fit on the conditioning columns, clamped just inside ``(-1, 1)``, and the
p-value uses the degrees of freedom of :meth:`statistic_pvalues`. Without rounding it
equals the partial correlation :meth:`statistic_pvalues` takes from the inverse of the
correlation submatrix. It stays defined when the conditioning columns are collinear, where
that inverse does not exist, because the residuals of a least-squares fit do not depend
on which of the equivalent solutions it returns. Raises :class:`SingularCorrelationError`
when the residual norm of ``x`` or ``y`` is at most ``eps * max(n, k)`` times the column's
norm, for ``n`` rows, ``k`` conditioning columns, and the float64 machine epsilon
``eps``: the rank cutoff of the least-squares fit, below which the residual is rounding
and the column lies in the span of the conditioning columns.
"""
arr = np.asarray(data, dtype=np.float64)
x = np.asarray(x, dtype=np.intp)
y = np.asarray(y, dtype=np.intp)
conds = np.asarray(conditions, dtype=np.intp).reshape(len(x), -1)
df = self._degrees_of_freedom(conds.shape[1])
r = np.empty(len(x), dtype=np.float64)
# Columns are scaled to a squared norm of n, so a residual's squared norm at the cutoff is
# n * (eps * max(n, k))**2.
cutoff = len(arr) * (np.finfo(np.float64).eps * max(len(arr), conds.shape[1])) ** 2
for k in range(len(x)):
columns = arr[:, [int(x[k]), int(y[k]), *conds[k].tolist()]]
columns = columns - columns.mean(axis=0)
columns = columns / np.sqrt((columns * columns).mean(axis=0))
pair, fit = columns[:, :2], columns[:, 2:]
residual = pair - fit @ np.linalg.lstsq(fit, pair, rcond=None)[0]
spread = (residual * residual).sum(axis=0)
if not (spread > cutoff).all():
raise SingularCorrelationError(
"a tested column lies in the span of its conditioning columns; cannot run a "
"partial-correlation test"
)
r[k] = (residual[:, 0] @ residual[:, 1]) / math.sqrt(spread[0] * spread[1])
r = np.clip(r, -_ONE_MINUS_EPS, _ONE_MINUS_EPS)
return r, np.asarray(self._pvalue(r, df))
def marginal_pvalues(self) -> np.ndarray:
"""All-pairs marginal (``|S| == 0``) p-values as a ``(d, d)`` matrix.
Vectorizes the marginal closed form over the whole correlation matrix in one shot;
upper-triangle entry ``[x, y]`` matches ``self(x, y, ())`` exactly, using the same clip
and p-value arithmetic. The lower triangle can inherit rounding asymmetry from the
correlation matrix. The diagonal is self-correlation and is not meaningful.
"""
df = self._degrees_of_freedom(0)
r = np.clip(self._corr, -_ONE_MINUS_EPS, _ONE_MINUS_EPS)
return np.asarray(self._pvalue(r, df))
def first_order_pvalues(self, x: int, y: int, zs: Iterable[int]) -> np.ndarray:
"""First-order (``|S| == 1``) p-values for pair ``(x, y)`` over single conditioners ``zs``.
One vectorized evaluation of the first-order partial-correlation closed form. For
``x < y``, the ``i``-th entry matches ``self(x, y, (zs[i],))`` exactly. Pairs are evaluated
in their supplied order; ``zs`` must exclude ``x`` and ``y``.
"""
conds = [[int(z)] for z in zs]
if not conds:
return np.empty(0, dtype=np.float64)
return self._pvalues(int(x), int(y), conds)
@classmethod
def from_corr(cls, corr: np.ndarray, n_samples: int) -> Self:
"""Build a test from a precomputed correlation matrix and sample size, re-reading no data.
The parallel skeleton search seeds each worker with the parent's exact ``_corr`` float64
bytes, so every process feeds identical values into the closed forms and stays bit-identical
to the serial oracle. ``corr`` must be a square ``(d, d)`` sample-correlation matrix and
``n_samples`` its observation count.
"""
obj = cls.__new__(cls)
obj._n = int(n_samples)
obj._corr = np.asarray(corr, dtype=np.float64)
return obj
def to_seed(self) -> tuple[np.ndarray, int]:
"""The ``(correlation_matrix, n_samples)`` reconstructing this test via :meth:`from_corr`.
The parallel skeleton search seeds workers with this rather than pickling the whole test:
a worker rebuilds an equivalent test over the parent's exact correlation bytes.
"""
return self._corr, self._n
[docs]
class FisherZ(PartialCorrelationTest):
"""Fisher-Z partial-correlation conditional-independence test.
Applies Fisher's z-transform to the partial correlation ``r`` and reports
``p = 2 * (1 - Phi(sqrt(n - |S| - 3) * |z|))`` with ``z = atanh(r)`` and ``Phi`` the
standard-normal CDF. A query needs ``n >= |S| + 3``. See :class:`PartialCorrelationTest` for
the partial correlation, the query paths, and their exact agreement.
"""
def _degrees_of_freedom(self, size: int) -> int:
df = self._n - size - 3
if df < 0:
raise ValueError(
f"Fisher-Z degrees of freedom is negative (n={self._n}, |S|={size}); "
"need n > |S| + 3"
)
return df
@staticmethod
def _pvalue(r: np.ndarray | float, df: int) -> np.ndarray | float:
z = 0.5 * np.log((1.0 + r) / (1.0 - r))
return 2.0 * (1.0 - ndtr(np.sqrt(df) * np.abs(z)))
[docs]
class StudentT(PartialCorrelationTest):
"""Student-t partial-correlation conditional-independence test.
Reports the same partial correlation ``r`` as :class:`FisherZ` with an exact Student-t p-value
on ``df = n - |S| - 2`` degrees of freedom: ``t = r * sqrt(df / (1 - r**2))`` and
``p = 2 * P(T_df > |t|)``. Its p-values differ slightly from Fisher-Z's. ``r`` is clamped just
inside ``(-1, 1)``, so a deterministic relation gives a tiny finite p-value. A query needs
``n > |S| + 2``. See :class:`PartialCorrelationTest` for the query paths.
"""
def _degrees_of_freedom(self, size: int) -> int:
df = self._n - size - 2
if df <= 0:
raise ValueError(
f"Student-t degrees of freedom is not positive (n={self._n}, |S|={size}); "
"need n > |S| + 2"
)
return df
@staticmethod
def _pvalue(r: np.ndarray | float, df: int) -> np.ndarray | float:
t = r * np.sqrt(df / (1.0 - r * r))
return 2.0 * stdtr(df, -np.abs(t))
# A joint table of at most this many cells is counted densely; a larger one counts only the cells
# that occur, so memory stays proportional to the rows whatever the cardinalities.
_DENSE_CELLS = 1 << 20
# The batched chi-square paths count a test's table over every configuration of its conditioning
# set when it has at most max(_TABLE_ROWS * n, _TABLE_MIN) cells, and take the scalar path above.
_TABLE_ROWS = 4
_TABLE_MIN = 1 << 16
# The most row indices, table cells, or Gram-matrix entries one batched count holds.
_BATCH_CELLS = 1 << 22
class ChiSquare:
"""Pearson chi-square or G-square test of conditional independence on category codes.
``__init__`` re-encodes each column to the codes ``0..k-1`` (see
:func:`~andrey.core.categorical.encode`), so any finite values work, each distinct value of a
column one category. Each call counts the joint table of ``x`` and ``y`` in every configuration
of ``condition_set`` that occurs in the data, and compares it with the table expected under
independence within that configuration, ``n_sx * n_sy / n_s``. The statistic is Pearson's
``sum (O - E)**2 / E`` or, with ``g_square``, ``2 * sum O * log(O / E)``, summed over the
configurations [1]_ [2]_.
The degrees of freedom are ``(r_x - 1) * (r_y - 1)`` summed over the configurations, where
``r_x`` and ``r_y`` count the values of ``x`` and ``y`` present in that configuration, so a
value absent there adds no degree of freedom [2]_. With zero degrees of freedom the p-value is
``1``; otherwise it is the chi-square survival function of the statistic.
Queries are canonicalized as :class:`FisherZ`'s are, and nothing is memoized.
:meth:`batched_call`, :meth:`first_order_pvalues`, and :meth:`marginal_pvalues` answer many
queries at once and return the p-values of :meth:`__call__` exactly. They count the tables of
many queries together, one ``bincount`` over the queries' cell indices, or, for the marginal
tables of all pairs, one product of the category-indicator matrix with itself, whose integer
counts are exact in float64. Every step after the counting is elementwise on the tables,
except the sum of a query's statistic over its cells, which is ``np.sum`` over that query's
cells in the order :meth:`__call__` sums them, so the sum, and a decision on the p-value, does
not depend on how the queries are batched.
Parameters
----------
data : ndarray, shape (n_samples, n_features)
Finite values; each distinct value of a column is one category.
g_square : bool, default=False
Use the G-square (likelihood-ratio) statistic instead of Pearson's chi-square.
References
----------
.. [1] Spirtes, P., Glymour, C., and Scheines, R. (2000). Causation, Prediction, and Search,
2nd ed. MIT Press.
.. [2] Tsamardinos, I., Brown, L. E., and Aliferis, C. F. (2006). The max-min hill-climbing
Bayesian network structure learning algorithm. Machine Learning 65(1), 31-78.
"""
def __init__(self, data: np.ndarray, *, g_square: bool = False) -> None:
arr = np.asarray(data)
if arr.ndim != 2:
raise ValueError(
f"data must be a 2-D (n_samples, n_features) array, got ndim={arr.ndim}"
)
if arr.dtype.kind == "f" and not np.isfinite(arr).all():
raise ValueError("data contains NaN or Inf; cannot run a chi-square test")
self._n: int = arr.shape[0]
self._codes, self._cardinalities = categorical.encode(arr)
# Each column's codes as one contiguous row, for the batched counts; int32 halves the
# memory those counts stream through.
self._rows = self._codes.T.astype(np.int32 if self._n < 2**31 else np.int64, order="C")
self._g_square = bool(g_square)
def __call__(self, x: int, y: int, condition_set: Iterable[int] = ()) -> float:
"""Return the p-value for ``x`` independent of ``y`` given ``condition_set``.
``condition_set`` is canonicalized to sorted unique ints; ``x`` and ``y`` are ordered so the
p-value is independent of argument order. A larger p-value means more independence.
"""
x_int, y_int, cond = _canonical(x, y, condition_set)
codes = self._codes
kx = int(self._cardinalities[x_int])
ky = int(self._cardinalities[y_int])
layer, m = categorical.configurations(codes, self._cardinalities, cond)
xs = layer * kx + codes[:, x_int] # (configuration, x) cell of each row
ys = layer * ky + codes[:, y_int]
n_sx = np.bincount(xs, minlength=m * kx).reshape(m, kx)
n_sy = np.bincount(ys, minlength=m * ky).reshape(m, ky)
n_s = n_sx.sum(axis=1)
cells = xs * ky + codes[:, y_int]
if m * kx * ky <= _DENSE_CELLS:
table = np.bincount(cells, minlength=m * kx * ky)
occupied = np.flatnonzero(table)
observed = table[occupied]
else:
occupied, observed = np.unique(cells, return_counts=True)
s, rest = np.divmod(occupied, kx * ky)
sx, sy = np.divmod(rest, ky)
expected = n_sx[s, sx] * n_sy[s, sy] / n_s[s]
if self._g_square:
statistic = 2.0 * float(np.sum(observed * np.log(observed / expected)))
else:
# Each configuration's expected counts sum to its observed ones, so Pearson's sum over
# every cell, empty ones included, equals this sum over the occupied cells.
statistic = float(np.sum(observed * (observed / expected))) - float(self._n)
dof = int(np.sum(((n_sx > 0).sum(axis=1) - 1) * ((n_sy > 0).sum(axis=1) - 1)))
if dof == 0:
return 1.0
return float(chdtrc(dof, max(statistic, 0.0)))
def batched_call(self, x: int, y: int, condition_sets: Iterable[Iterable[int]]) -> list[float]:
"""Return p-values for one ``(x, y)`` pair over many conditioning sets.
The result list matches the order of ``condition_sets``, and each p-value equals
:meth:`__call__`'s. A conditioning set is canonicalized as in :meth:`__call__`. The tables
of the sets are counted together (see :class:`ChiSquare`); a set with more than
``max(4 * n, 65536)`` possible cells, and a single-set batch, take the scalar path.
"""
condition_sets = list(condition_sets)
if len(condition_sets) <= 1:
return [self(x, y, condition_set) for condition_set in condition_sets]
x_int, y_int = (int(x), int(y)) if x < y else (int(y), int(x))
conds = [_canonical(x_int, y_int, condition_set)[2] for condition_set in condition_sets]
return self._pvalues(x_int, y_int, conds).tolist()
def first_order_pvalues(self, x: int, y: int, zs: Iterable[int]) -> np.ndarray:
"""First-order (``|S| == 1``) p-values for pair ``(x, y)`` over single conditioners ``zs``.
Entry ``i`` equals ``self(x, y, (zs[i],))``; ``zs`` must exclude ``x`` and ``y``.
"""
x_int, y_int = (int(x), int(y)) if x < y else (int(y), int(x))
return self._pvalues(x_int, y_int, [[int(z)] for z in zs])
def marginal_pvalues(self) -> np.ndarray:
"""All-pairs marginal (``|S| == 0``) p-values as a symmetric ``(d, d)`` matrix.
Entry ``[x, y]`` equals ``self(x, y, ())``; the diagonal is ``0``. With the categories of
all columns stacked, column ``v``'s category ``c`` at ``offsets[v] + c``, the Gram matrix of
the rows' category indicators holds every pair's table: entry ``[i, j]`` counts the rows in
categories ``i`` and ``j``. Its rows are computed for a few columns ``x`` at a time,
accumulated over blocks of rows. The counts are integers below ``2**53``, so the float64
products and sums are exact in any order. A column whose rows of the Gram matrix alone
exceed the block size takes the scalar path.
"""
d = self._codes.shape[1]
out = np.zeros((d, d))
cards = self._cardinalities
offsets = np.cumsum(cards) - cards
total = int(cards.sum())
x = 0
while x < d - 1:
if self._n == 0 or int(cards[x]) * total > _BATCH_CELLS:
out[x, x + 1 :] = [self(x, y) for y in range(x + 1, d)]
x += 1
continue
stop = x + 1
while stop < d - 1 and int(cards[x : stop + 1].sum()) * total <= _BATCH_CELLS:
stop += 1
self._marginal_block(out, x, stop, offsets, total)
x = stop
return out + out.T
def _marginal_block(
self, out: np.ndarray, start: int, stop: int, offsets: np.ndarray, total: int
) -> None:
"""Fill ``out[x, y]`` for the columns ``start <= x < stop`` and every ``y > x``."""
n, d = self._codes.shape
cards = self._cardinalities
first = int(offsets[start])
gram = np.zeros((int(offsets[stop - 1] + cards[stop - 1]) - first, total))
step = max(1, _BATCH_CELLS // total)
for row in range(0, n, step):
codes = self._codes[row : row + step]
indicators = np.zeros((len(codes), total))
indicators[np.arange(len(codes))[:, None], codes + offsets] = 1.0
gram += indicators[:, first : first + len(gram)].T @ indicators
xs, ys = np.triu_indices(d, 1)
keep = (xs >= start) & (xs < stop)
xs, ys = xs[keep], ys[keep]
shapes = cards[xs] * (int(cards.max()) + 1) + cards[ys]
for shape in np.unique(shapes):
pairs = np.flatnonzero(shapes == shape)
kx, ky = int(cards[xs[pairs[0]]]), int(cards[ys[pairs[0]]])
for at in range(0, pairs.size, max(1, _BATCH_CELLS // (kx * ky))):
part = pairs[at : at + max(1, _BATCH_CELLS // (kx * ky))]
rows = offsets[xs[part], None, None] - first + np.arange(kx)[:, None]
cols = offsets[ys[part], None, None] + np.arange(ky)
tables = gram[rows, cols].astype(np.int64)
out[xs[part], ys[part]] = self._table_pvalues(tables, np.arange(part.size + 1))
def _pvalues(self, x: int, y: int, conds: list[list[int]]) -> np.ndarray:
"""P-values of the pair ``x < y`` given each canonical conditioning set in ``conds``.
The sets whose tables have at most ``max(4 * n, 65536)`` possible cells are counted in
blocks, grouped by size, one ``bincount`` per block; the others take :meth:`__call__`.
"""
out = np.empty(len(conds))
n = self._n
cards = self._cardinalities
kx, ky = int(cards[x]), int(cards[y])
cells = kx * ky
limit = max(_TABLE_ROWS * n, _TABLE_MIN)
by_size: dict[int, list[int]] = {}
configurations = [math.prod(int(cards[c]) for c in cond) for cond in conds]
for i, cond in enumerate(conds):
if n == 0 or configurations[i] * cells > limit:
out[i] = self(x, y, cond)
else:
by_size.setdefault(len(cond), []).append(i)
if not by_size:
return out
target = self._rows[x] * ky + self._rows[y] # each row's (x, y) cell
rows_per_block = max(1, _BATCH_CELLS // n)
for size, members in by_size.items():
start = 0
while start < len(members):
stop, held = start + 1, configurations[members[start]] * cells
while stop < len(members) and stop - start < rows_per_block:
held += configurations[members[stop]] * cells
if held > _BATCH_CELLS:
break
stop += 1
block = members[start:stop]
out[block] = self._block_pvalues(
target, cells, kx, ky, [conds[i] for i in block], size
)
start = stop
return out
def _block_pvalues(
self, target: np.ndarray, cells: int, kx: int, ky: int, conds: list[list[int]], size: int
) -> np.ndarray:
"""P-values of one block of conditioning sets of equal ``size``, counted in one bincount.
Row ``r`` of set ``i`` falls in cell ``target[r]`` of configuration ``offset[i] + code``,
with ``code`` the mixed-radix code of its conditioning categories, the first column most
significant: the order :func:`~andrey.core.categorical.configurations` numbers them in.
The configurations that do not occur are then dropped.
"""
sets = np.array(conds, dtype=np.intp).reshape(len(conds), size)
radix = self._cardinalities[sets]
q = radix.prod(axis=1)
offset = np.cumsum(q) - q
total = int(q.sum())
dtype = self._rows.dtype if total * cells < 2**31 else np.dtype(np.int64)
if size:
index = self._rows[sets[:, 0]].astype(dtype, copy=False)
for j in range(1, size):
index *= radix[:, j, None].astype(dtype)
index += self._rows[sets[:, j]]
index += offset[:, None].astype(dtype)
else:
index = np.repeat(offset[:, None].astype(dtype), self._n, axis=1)
index *= dtype.type(cells)
index += target
tables = np.bincount(index.ravel(), minlength=total * cells).reshape(total, kx, ky)
occurs = tables.reshape(total, cells).any(axis=1)
held = np.add.reduceat(occurs.astype(np.int64), offset)
bounds = np.concatenate(([0], np.cumsum(held)))
return self._table_pvalues(tables[occurs], bounds)
def _table_pvalues(self, tables: np.ndarray, bounds: np.ndarray) -> np.ndarray:
"""P-values of the tests whose ``(kx, ky)`` tables are stacked in ``tables``.
Test ``i`` owns ``tables[bounds[i]:bounds[i + 1]]``, one table per configuration that
occurs, in :meth:`__call__`'s order. Each step repeats :meth:`__call__`'s arithmetic on the
same integer counts, and each test's statistic is ``np.sum`` over its own cells in the order
:meth:`__call__` visits them, so every p-value equals :meth:`__call__`'s.
"""
m, kx, ky = tables.shape
n_sx = tables.sum(axis=2)
n_sy = tables.sum(axis=1)
n_s = n_sx.sum(axis=1)
flat = tables.reshape(-1)
occupied = np.flatnonzero(flat)
observed = flat[occupied]
s, rest = np.divmod(occupied, kx * ky)
sx, sy = np.divmod(rest, ky)
expected = n_sx[s, sx] * n_sy[s, sy] / n_s[s]
if self._g_square:
values = observed * np.log(observed / expected)
else:
values = observed * (observed / expected)
cuts = np.searchsorted(occupied, bounds * (kx * ky)).tolist()
sums = np.array([np.sum(values[a:b]) for a, b in zip(cuts[:-1], cuts[1:])])
statistic = 2.0 * sums if self._g_square else sums - float(self._n)
per_table = ((n_sx > 0).sum(axis=1) - 1) * ((n_sy > 0).sum(axis=1) - 1)
dof = np.add.reduceat(per_table, bounds[:-1])
out = np.ones(len(dof))
live = dof != 0
out[live] = chdtrc(dof[live], np.maximum(statistic[live], 0.0))
return out
# A conditional covariance's eigenvalue, on the scale of unit-variance columns, at or below this
# marks a direction the conditioning set determines.
_RANK_TOL = 1e-10
class DGTest:
"""Degenerate Gaussian likelihood-ratio test of conditional independence on mixed data.
The columns are embedded as :class:`~andrey.core.mixed.DegenerateGaussian` describes: a
categorical variable with ``k`` categories becomes the indicators of ``k - 1`` of them, and
every embedded column is treated as Gaussian [1]_. The test of ``x`` and ``y`` given ``S`` is
the likelihood ratio of the linear model of ``x``'s columns on ``y``'s and ``S``'s against the
one on ``S``'s alone [2]_: ``-n * sum log(1 - rho_i**2)``, with ``rho_i`` the canonical
correlations of ``x``'s and ``y``'s columns after the linear regression on ``S``'s. Its degrees
of freedom are the product of the two variables' numbers of columns, and its p-value is the
chi-square survival function. A direction of ``x`` or ``y`` that ``S`` determines exactly
carries no information and adds no degree of freedom; with none left, the p-value is ``1``. On
continuous columns only, the statistic is ``-n log(1 - r**2)`` with ``r`` the partial
correlation.
The statistic does not depend on the columns' units, and queries are canonicalized as
:class:`FisherZ`'s are, so the p-value does not depend on the argument order.
Parameters
----------
data : ndarray, shape (n_samples, n_features)
Finite values; categorical columns hold their categories as numbers, each distinct value of
a column one category.
categorical : array-like of bool, shape (n_features,), optional
Marks the categorical columns; ``None`` marks none.
References
----------
.. [1] Andrews, B., Ramsey, J., and Cooper, G. F. (2019). Learning high-dimensional directed
acyclic graphs with mixed data-types. Proceedings of Machine Learning Research 104, 4-21.
.. [2] Andrews, B., Ramsey, J., and Cooper, G. F. (2018). Scoring Bayesian networks of mixed
variables. International Journal of Data Science and Analytics 6(1), 3-18.
"""
def __init__(self, data: np.ndarray, categorical: npt.ArrayLike | None = None) -> None:
self._dg = DegenerateGaussian(data, categorical)
sd = np.sqrt(np.diag(self._dg.cov))
self._corr = self._dg.cov / np.outer(sd, sd)
def __call__(self, x: int, y: int, condition_set: Iterable[int] = ()) -> float:
"""Return the p-value for ``x`` independent of ``y`` given ``condition_set``.
``condition_set`` is canonicalized to sorted unique ints; ``x`` and ``y`` are ordered so the
p-value is independent of argument order. A larger p-value means more independence.
"""
x_int, y_int, cond = _canonical(x, y, condition_set)
dg = self._dg
cx, cy, cs = dg.columns([x_int]), dg.columns([y_int]), dg.columns(cond)
both = np.concatenate([cx, cy])
block = self._corr[np.ix_(both, both)]
if cs.size:
# Regress on the conditioning columns through their nonzero eigen-directions, so
# collinear conditioning columns need no inverse.
w, u = np.linalg.eigh(self._corr[np.ix_(cs, cs)])
keep = w > _RANK_TOL * w[-1]
cross = self._corr[np.ix_(both, cs)] @ u[:, keep]
block = block - (cross / w[keep]) @ cross.T
px = cx.size
wx = _whitener(block[:px, :px])
wy = _whitener(block[px:, px:])
dof = wx.shape[1] * wy.shape[1]
if dof == 0:
return 1.0
rho = np.linalg.svd(wx.T @ block[:px, px:] @ wy, compute_uv=False)
rho = np.minimum(rho, _ONE_MINUS_EPS)
statistic = -dg.n * float(np.sum(np.log1p(-(rho * rho))))
return float(chdtrc(dof, max(statistic, 0.0)))
def _whitener(cov: np.ndarray) -> np.ndarray:
"""Map ``cov``'s columns onto unit-variance, uncorrelated ones, dropping null directions."""
w, u = np.linalg.eigh(cov)
keep = w > _RANK_TOL
return u[:, keep] / np.sqrt(w[keep])
# The most pairs one marginal pass of the conditional Gaussian test fits together.
_CG_PAIRS = 4096
class CGTest:
"""Conditional Gaussian likelihood-ratio test of conditional independence on mixed data.
The variables are fit as :class:`~andrey.core.mixed.ConditionalGaussian` describes: the rows
split into the cells of the categorical variables' joint categories, the continuous variables
Gaussian within each cell, with a covariance shrunk toward that of all rows [1]_. With ``l(A)``
the log-likelihood of the set ``A`` and ``k(A)`` its parameter count, the test of ``x`` and
``y`` given ``S`` compares the model of ``x`` given ``y`` and ``S`` with the model of ``x``
given ``S`` [1]_:
``2 * (l(x, y, S) - l(y, S) - l(x, S) + l(S))``
on ``k(x, y, S) - k(y, S) - k(x, S) + k(S)`` degrees of freedom, a chi-square survival function
p-value. The expression is symmetric in ``x`` and ``y``. On categorical columns only, the
statistic is the G-square statistic of :class:`ChiSquare`, with ``(r_x - 1) * (r_y - 1)``
degrees of freedom for every joint category of ``S``, whether it occurs or not; on continuous
columns only, it is ``-n log(1 - r**2)`` with ``r`` the partial correlation, on one degree of
freedom.
Log-likelihoods are memoized by set, and queries are canonicalized as :class:`FisherZ`'s are.
:meth:`batched_call`, :meth:`first_order_pvalues`, and :meth:`marginal_pvalues` fit the sets of
many queries in one :meth:`~andrey.core.mixed.ConditionalGaussian.loglik_many` call and return
:meth:`__call__`'s p-values exactly.
Parameters
----------
data : ndarray, shape (n_samples, n_features)
Finite values; categorical columns hold their categories as numbers, each distinct value of
a column one category.
categorical : array-like of bool, shape (n_features,), optional
Marks the categorical columns; ``None`` marks none.
References
----------
.. [1] Andrews, B., Ramsey, J., and Cooper, G. F. (2018). Scoring Bayesian networks of mixed
variables. International Journal of Data Science and Analytics 6(1), 3-18.
"""
def __init__(self, data: np.ndarray, categorical: npt.ArrayLike | None = None) -> None:
self._cg = ConditionalGaussian(data, categorical)
self._d: int = np.shape(data)[1]
def __call__(self, x: int, y: int, condition_set: Iterable[int] = ()) -> float:
"""Return the p-value for ``x`` independent of ``y`` given ``condition_set``.
``condition_set`` is canonicalized to sorted unique ints; ``x`` and ``y`` are ordered so the
p-value is independent of argument order. A larger p-value means more independence.
"""
x_int, y_int, cond = _canonical(x, y, condition_set)
cg = self._cg
sets = ([x_int, y_int, *cond], [y_int, *cond], [x_int, *cond], cond)
ll = [cg.loglik(s) for s in sets]
k = [cg.dof(s) for s in sets]
statistic = 2.0 * (ll[0] - ll[1] - ll[2] + ll[3])
dof = k[0] - k[1] - k[2] + k[3]
return float(chdtrc(dof, max(statistic, 0.0)))
def batched_call(self, x: int, y: int, condition_sets: Iterable[Iterable[int]]) -> list[float]:
"""Return p-values for one ``(x, y)`` pair over many conditioning sets.
The result list matches the order of ``condition_sets``, and each p-value equals
:meth:`__call__`'s: the four sets of every query are fit in one
:meth:`~andrey.core.mixed.ConditionalGaussian.loglik_many` call, which gives each set the
value it gets alone.
"""
x_int, y_int = (int(x), int(y)) if x < y else (int(y), int(x))
queries = [(x_int, y_int, _canonical(x_int, y_int, s)[2]) for s in condition_sets]
return self._pvalues(queries).tolist()
def first_order_pvalues(self, x: int, y: int, zs: Iterable[int]) -> np.ndarray:
"""First-order (``|S| == 1``) p-values for pair ``(x, y)`` over single conditioners ``zs``.
Entry ``i`` equals ``self(x, y, (zs[i],))``; ``zs`` must exclude ``x`` and ``y``.
"""
return np.asarray(self.batched_call(x, y, [(int(z),) for z in zs]), dtype=np.float64)
def marginal_pvalues(self) -> np.ndarray:
"""All-pairs marginal (``|S| == 0``) p-values as a symmetric ``(d, d)`` matrix.
Entry ``[x, y]`` equals ``self(x, y, ())``; the diagonal is ``0``. The pairs are fit
together, a few thousand per :meth:`~andrey.core.mixed.ConditionalGaussian.loglik_many`
call.
"""
d = self._d
out = np.zeros((d, d))
xs, ys = np.triu_indices(d, 1)
for at in range(0, xs.size, _CG_PAIRS):
part = slice(at, at + _CG_PAIRS)
queries = [(x, y, []) for x, y in zip(xs[part].tolist(), ys[part].tolist())]
out[xs[part], ys[part]] = self._pvalues(queries)
return out + out.T
def _pvalues(self, queries: list[tuple[int, int, list[int]]]) -> np.ndarray:
"""P-values of canonical ``(x, y, conditioning set)`` queries, their sets fit together."""
cg = self._cg
sets = [s for x, y, c in queries for s in ([x, y, *c], [y, *c], [x, *c], c)]
ll = cg.loglik_many(sets)
k = [cg.dof(s) for s in sets]
quads = range(0, len(sets), 4)
statistic = [2.0 * (ll[i] - ll[i + 1] - ll[i + 2] + ll[i + 3]) for i in quads]
dof = [k[i] - k[i + 1] - k[i + 2] + k[i + 3] for i in quads]
return np.asarray(chdtrc(dof, np.maximum(statistic, 0.0)), dtype=np.float64).reshape(-1)
# The module and class of each nonlinear test. Building one imports only its own module: GPDC's
# loads scikit-learn, which takes a second or more.
_NONLINEAR_CLASSES: dict[str, tuple[str, str]] = {
"kci": ("ci_kernel", "KCI"),
"rcot": ("ci_kernel", "RCoT"),
"cmiknn": ("ci_cmiknn", "CMIknn"),
"gpdc": ("ci_gpdc", "GPDC"),
}
NONLINEAR_INDEP_TESTS: tuple[str, ...] = tuple(_NONLINEAR_CLASSES)
def _nonlinear(name: str, data: np.ndarray, **options: Any) -> CITest:
"""Build the nonlinear test ``name`` over ``data`` with ``options``."""
module, cls = _NONLINEAR_CLASSES[name]
test = getattr(importlib.import_module(f"{__package__}.{module}"), cls)
return test(data, **options)
# The indep_test registry: a name maps to a factory building a :class:`CITest` over the data. The
# constraint facades resolve their ``indep_test`` argument through :func:`make_indep_test`, and
# :func:`register_indep_test` lets a downstream test or plugin add another under the same Protocol.
# The discrete tests read category codes and the mixed tests a mask of the categorical columns as
# well; the facades check both before a fit.
SHIPPED_INDEP_TESTS: tuple[str, ...] = (
"fisherz",
"student_t",
"chisq",
"gsq",
"degen_gauss",
"cond_gauss",
*NONLINEAR_INDEP_TESTS,
)
DISCRETE_INDEP_TESTS: tuple[str, ...] = ("chisq", "gsq")
MIXED_INDEP_TESTS: tuple[str, ...] = ("degen_gauss", "cond_gauss")
_CI_REGISTRY: dict[str, Callable[..., CITest]] = {
"fisherz": FisherZ,
"student_t": StudentT,
"chisq": ChiSquare,
"gsq": partial(ChiSquare, g_square=True),
"degen_gauss": DGTest,
"cond_gauss": CGTest,
**{name: partial(_nonlinear, name) for name in NONLINEAR_INDEP_TESTS},
}
[docs]
def register_indep_test(name: str, factory: Callable[[np.ndarray], CITest]) -> None:
"""Register a :class:`CITest` factory under ``name`` for selection via ``indep_test``.
``factory`` maps a ``(n_samples, n_features)`` data matrix to a ``CITest`` (a callable
``test(x, y, condition_set) -> p-value``), and takes the keyword settings
:func:`make_indep_test` passes, if any. Overwrites any existing entry for ``name``. A variant
of a shipped test with other settings is ``partial(make_indep_test, "cmiknn", k_cmi=5)``.
"""
_CI_REGISTRY[name] = factory
[docs]
def available_indep_tests() -> tuple[str, ...]:
"""The ``indep_test`` names currently resolvable, sorted (shipped plus any registered)."""
return tuple(sorted(_CI_REGISTRY))
[docs]
def make_indep_test(
name: str, data: np.ndarray, *, categorical: npt.ArrayLike | None = None, **options: Any
) -> CITest:
"""Construct the registered ``indep_test`` ``name`` over ``data``.
``options`` are keyword settings for the test's class, such as
``make_indep_test("cmiknn", data, k_cmi=5)``; a setting the test lacks raises ``TypeError``.
``categorical``, a boolean mask of the categorical columns, reaches the mixed tests
(:data:`MIXED_INDEP_TESTS`) only; another test given one raises ``ValueError``. Raises
:class:`NotImplementedError` when ``name`` is not registered, listing what is available. A
:class:`PartialCorrelationTest` raises :class:`~andrey.core.score.CopiedColumnsError`, naming
every group of :meth:`~PartialCorrelationTest.copies`, when ``data`` holds columns that repeat
others up to scale and shift: its tests cannot tell such columns apart.
"""
try:
factory = _CI_REGISTRY[name]
except KeyError:
raise NotImplementedError(
f"unknown indep_test {name!r}; registered: {available_indep_tests()}"
) from None
if name in MIXED_INDEP_TESTS:
return factory(data, categorical, **options)
if categorical is not None:
raise ValueError(f"indep_test {name!r} reads no column types; categorical must be None")
test = factory(data, **options)
if isinstance(test, PartialCorrelationTest) and test.copies():
raise copies_error(test.copies())
return test
def _checked_data(data: np.ndarray, test_name: str, min_rows: int = 0) -> np.ndarray:
"""``data`` as a float64 ``(n_samples, n_features)`` matrix; raises on NaN or infinity, or on
fewer than ``min_rows`` rows."""
arr = np.asarray(data, dtype=np.float64)
if arr.ndim != 2:
raise ValueError(f"data must be a 2-D (n_samples, n_features) array, got ndim={arr.ndim}")
if arr.shape[0] < min_rows:
raise ValueError(f"{test_name} needs at least {min_rows} rows, got {arr.shape[0]}")
if not np.isfinite(arr).all():
raise ValueError(f"data contains NaN or Inf; cannot run {test_name} test")
return arr
def pins_blas(test: CITest) -> bool:
"""Whether a skeleton search runs ``test`` with BLAS pinned to one thread.
A test that sets ``pin_blas`` decides for itself. Otherwise a test whose p-values come from
matrix algebra, the partial-correlation and the mixed-data tests, is pinned: it then gives the
same bits whatever the host's thread count, and the partial-correlation tests match the
one-thread parallel skeleton workers; the chi-square tests call no BLAS. The nonlinear tests set
it: KCI is unpinned from 2,000 rows, where its kernel products run faster on every BLAS thread
and its p-values are reproducible for a fixed thread count; RCoT, CMIknn, and GPDC are pinned.
"""
pin = getattr(test, "pin_blas", None)
if pin is not None:
return bool(pin)
return callable(getattr(test, "to_seed", None)) or isinstance(test, (DGTest, CGTest))
def _invert_nonsingular(stack: np.ndarray) -> np.ndarray:
"""Invert a ``(N, k, k)`` stack of correlation submatrices; raise if any is singular.
A submatrix is singular when its smallest eigenvalue is at most ``_SINGULAR_RTOL`` times its
largest; :class:`ValueError` reports it. ``np.linalg.inv`` alone does not decide this: it raises
only on an exactly zero pivot, which OpenBLAS reaches on a duplicated column while Apple
Accelerate can return a finite inverse instead. The eigenvalues decide on every backend.
Most rows are settled from the inverse without eigenvalues. Each submatrix ``A`` is a principal
submatrix of a sample correlation matrix: unit diagonal, positive semidefinite up to rounding.
So ``lambda_max(A) <= trace(A) <= k``. The eigenvalue of smallest magnitude is
``1 / ||A^-1||_2 >= 1 / (k * max |A^-1_ij|)``, and when that is far above the rounding it is
positive, so it is ``lambda_min(A)``. With ``c = _BOUND_MARGIN * _SINGULAR_RTOL``, a row whose
inverse has every entry below ``1 / (c * k**2)`` thus has ``lambda_min > c * lambda_max`` and
passes the eigenvalue test. The bound reads every entry of the computed inverse, not only its
trace or diagonal, so a NaN, an infinity, or a single huge entry fails it. The LU inverse is
backward stable on every LAPACK, so the computed inverse of a nearly singular matrix has
entries of order ``1 / lambda_min`` and fails the bound. ``_BOUND_MARGIN`` absorbs the rounding
of the inverse and the eigenvalues.
The rows that fail the bound take the eigenvalue test. When ``np.linalg.inv`` raises, the
whole stack takes it, and the inversion raises again if no row is singular.
"""
try:
inv = np.linalg.inv(stack)
except np.linalg.LinAlgError:
inv = None
if inv is None:
_raise_if_singular(stack)
return np.linalg.inv(stack)
size = stack.shape[-1]
limit = 1.0 / (_BOUND_MARGIN * _SINGULAR_RTOL * size * size)
unsettled = ~(np.abs(inv).max(axis=(1, 2)) < limit) # NaN fails the comparison too
if unsettled.any():
_raise_if_singular(stack[unsettled])
return inv
def _raise_if_singular(stack: np.ndarray) -> None:
"""Raise :class:`ValueError` if a matrix of the ``(N, k, k)`` stack is singular.
A matrix is singular when its smallest eigenvalue is at most ``_SINGULAR_RTOL`` times its
largest.
"""
w = np.linalg.eigvalsh(stack) # (N, k), ascending per row
if (w[:, 0] <= w[:, -1] * _SINGULAR_RTOL).any():
raise SingularCorrelationError(
"correlation matrix is singular; cannot run a partial-correlation test"
)
def _canonical(x: int, y: int, condition_set: Iterable[int]) -> tuple[int, int, list[int]]:
"""Canonicalize a query to ordered ints and a sorted-unique conditioning set."""
cond = sorted(set(int(c) for c in condition_set))
x_int, y_int = (int(x), int(y)) if x < y else (int(y), int(x))
assert x_int not in cond and y_int not in cond, "x, y cannot be in condition_set."
return x_int, y_int, cond
def _canonical_ranked(
x: int, y: int, condition_set: Iterable[int], rank: Sequence[int]
) -> tuple[int, int, list[int]]:
""":func:`_canonical` of a query to a test that holds data column ``j`` at index ``rank[j]``."""
return _canonical(rank[x], rank[y], [rank[c] for c in condition_set])