Source code for andrey.api.irregular

"""Facades for the pairwise direction tests (ANM and PNL) and the hidden-confounder methods.

ANM and PNL test both directions in a two-column table. RCD and BottomUpParceLiNGAM return a PAG
over the observed variables. RCD lists the pairs a hidden variable links in
``metadata["confounded_pairs"]``; BottomUpParceLiNGAM leaves the variables whose order such a
cause hides unordered.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

import numpy as np

from ._adapt import column_labels, data_matrix, int_in_range, open_unit_interval, with_labels
from ._irregular import (
    _adapt_anm,
    _adapt_bottom_up_parce,
    _adapt_pnl,
    _adapt_rcd,
)

if TYPE_CHECKING:
    import numpy.typing as npt

    from andrey.core import StructureOutput


[docs] def anm(data: npt.ArrayLike, *, alpha: float | None = None) -> StructureOutput: """Test the direction of a variable pair with the additive noise model (ANM). ANM [1]_ fits ``y = f(x) + e`` in each direction by Gaussian-process regression. An HSIC test [2]_ checks whether the residual ``e`` is independent of the proposed cause, which holds only in the causal direction. The two p-values determine the edge, and the two test statistics decide a tie. Parameters ---------- data : array-like of shape (n_samples, 2) The two variables, one per column: column 0 is ``x`` and column 1 is ``y``. Coerced to ``float64``. A DataFrame's column names become the structure's ``labels``. alpha : float or None, default=None ``None`` chooses the direction with the larger p-value, or with the smaller test statistic when the p-values are equal. A significance level such as ``0.05`` draws an edge only when exactly one p-value exceeds it. See Notes. Returns ------- StructureOutput Contains a two-node :class:`~andrey.core.GraphStructure` of kind ``dag`` with the chosen edge, or no edge. ``metadata["algorithm"]`` is ``"ANM"``; ``pval_forward`` is the independence p-value for ``x -> y`` and ``pval_backward`` is the p-value for ``y -> x``; ``stat_forward`` and ``stat_backward`` are the HSIC statistics of the same two tests, each divided by its mean under independence. Raises ------ ValueError If ``data`` does not have 2 columns, or if ``alpha`` is not in ``(0, 1)``. Notes ----- With ``alpha=None``, the edge points in the direction with the larger p-value. On a large sample, or a pair that fits neither direction, both p-values can be zero; equal p-values leave the choice to the HSIC statistics, each divided by its mean under independence, and the edge points in the direction with the smaller one, the weaker dependence between the proposed cause and its residual. Equal statistics give no edge. The rule therefore produces an edge even for unrelated variables, a deterministic pair whose p-values are both near zero, or a pair whose direction cannot be identified. Mooij et al. (2016) [3]_ compare p-values to avoid choosing a significance level and found the comparison among the best methods in their benchmark. With a significance level, only the p-values decide; a direction passes when its p-value exceeds ``alpha``: - only ``x -> y`` passes: the edge ``x -> y``; - only ``y -> x`` passes: the edge ``y -> x``; - both pass: no edge, since neither direction is rejected; - neither passes: no edge, since both directions are rejected. Hoyer et al. (2008) [1]_ use a level of 0.02. Both p-values and both statistics remain in ``metadata``. The direction is identifiable when ``f`` is nonlinear or the noise is non-Gaussian; a linear pair with Gaussian noise fits an additive noise model in both directions [1]_. Each column is standardized to zero mean and unit variance first, so the result does not depend on the units of either variable. The regression is a Gaussian process with a scaled RBF kernel plus a white-noise term, its hyperparameters fitted by maximum marginal likelihood. The HSIC test uses Gaussian kernels and a gamma approximation of its null distribution. The fit is deterministic. **Limitations.** Measured on simulated cause-effect pairs with a nonlinear additive-noise mechanism and Gaussian noise, from 200 to 8,000 rows. It named the true direction on three pairs in four at 200 rows and on every pair from 4,000 rows. The median fit took over three minutes at 8,000 rows, on 4 cores. Each regression and HSIC test builds matrices with one row and one column per sample, so memory grows with the square of ``n_samples`` and the regression's time with its cube. References ---------- .. [1] Hoyer, P. O., Janzing, D., Mooij, J. M., Peters, J., and Scholkopf, B. (2008). Nonlinear causal discovery with additive noise models. NeurIPS 21, 689-696. .. [2] Gretton, A., Fukumizu, K., Teo, C. H., Song, L., Scholkopf, B., and Smola, A. J. (2008). A kernel statistical test of independence. NeurIPS 20, 585-592. .. [3] Mooij, J. M., Peters, J., Janzing, D., Zscheischler, J., and Scholkopf, B. (2016). Distinguishing cause from effect using observational data: methods and benchmarks. JMLR 17(32), 1-102. Examples -------- >>> import numpy as np >>> from andrey import anm >>> rng = np.random.default_rng(0) >>> x = rng.uniform(-1, 1, 300) >>> y = x * x * x + 0.3 * rng.uniform(-1, 1, 300) >>> out = anm(np.column_stack([x, y])) >>> out.structure.oriented_edges() [(0, 1, 'directed')] >>> out.metadata["pval_forward"] > 0.05 >= out.metadata["pval_backward"] True """ from andrey.irregular.anm import anm as _native if np.ndim(data) != 2 or np.shape(data)[1] != 2: raise ValueError( f"anm expects an (n_samples, 2) table of two variables, got {np.shape(data)}" ) X = data_matrix(data) if alpha is not None: alpha = open_unit_interval(alpha, "alpha") labels = column_labels(data) X = X / np.abs(X).max(axis=0) # first, so a column in tiny or huge units cannot underflow Z = (X - X.mean(axis=0)) / X.std(axis=0) # data_matrix rejects a constant column pval_forward, pval_backward, stat_forward, stat_backward = _native(Z[:, 0], Z[:, 1]) return _adapt_anm( pval_forward, pval_backward, stat_forward=stat_forward, stat_backward=stat_backward, alpha=alpha, labels=labels, )
[docs] def pnl(data: npt.ArrayLike, *, alpha: float | None = None) -> StructureOutput: """Test the direction of a variable pair with the post-nonlinear (PNL) model. PNL [1]_ [2]_ fits ``y = f2(f1(x) + e)`` in each direction. An HSIC test checks whether the estimated noise ``e`` is independent of the proposed cause. The two p-values determine the edge, and the two test statistics decide a tie. Parameters ---------- data : array-like of shape (n_samples, 2) The two variables, one per column: column 0 is ``x`` and column 1 is ``y``. Coerced to ``float64``. A DataFrame's column names become the structure's ``labels``. alpha : float or None, default=None ``None`` chooses the direction with the larger p-value, or with the smaller test statistic when the p-values are equal. A significance level such as ``0.05`` draws an edge only when exactly one p-value exceeds it. See Notes. Returns ------- StructureOutput Contains a two-node :class:`~andrey.core.GraphStructure` of kind ``dag`` with the chosen edge, or no edge. ``metadata["algorithm"]`` is ``"PNL"``; ``pval_forward`` is the independence p-value for ``x -> y`` and ``pval_backward`` is the p-value for ``y -> x``; ``stat_forward`` and ``stat_backward`` are the HSIC statistics of the same two tests, each divided by its mean under independence. Raises ------ ValueError If ``data`` does not have 2 columns, or if ``alpha`` is not in ``(0, 1)``. Notes ----- With ``alpha=None``, the edge points in the direction with the larger p-value. On a large sample, or a pair that fits neither direction, both p-values can be zero; equal p-values leave the choice to the HSIC statistics, each divided by its mean under independence, and the edge points in the direction with the smaller one, the weaker dependence between the proposed cause and its estimated noise. Equal statistics give no edge. The rule therefore produces an edge even for unrelated variables or a pair whose direction cannot be identified, such as a linear Gaussian pair. Mooij et al. (2016) [4]_ compare p-values to avoid choosing a significance level and found the comparison among the best methods in their benchmark of additive-noise models. With a significance level, only the p-values decide; a direction passes when its p-value exceeds ``alpha``: - only ``x -> y`` passes: the edge ``x -> y``; - only ``y -> x`` passes: the edge ``y -> x``; - both pass: no edge, since neither direction is rejected; - neither passes: no edge, since both directions are rejected. Zhang and Hyvarinen (2010) [2]_ use a level of 0.01 for PNL; Hoyer et al. (2008) [3]_ use 0.02 for additive-noise models. The result depends on ``alpha``. Estimated noise can appear dependent on the true cause, so a standard level can reject the true direction; no theory establishes the right level (Mooij et al., 2016). Both p-values and both statistics remain in ``metadata``. PNL fits each transform as a sum of smooth basis functions, so the fit is deterministic but can miss a sharply varying nonlinearity. The kernel matrices of the two HSIC tests run on a CUDA GPU, in float64, under the ``cuda`` backend, or under ``auto`` from ``ANDREY_PNL_GPU_THRESHOLD`` rows squared once the GPU is running in the process; the fits stay on the CPU, and the p-values then match the CPU's to rounding. On the CPU, ``num_workers`` above 1 (``andrey.config`` or ``ANDREY_NUM_WORKERS``) fits the two directions on two threads, each limited to one BLAS thread; with one worker the BLAS threads are not limited. On a machine with several cores the fits then round differently, so the p-values and statistics with one worker and with several can differ in their last digits, and the direction can differ when the two p-values nearly tie. Each thread holds three ``n x n`` float64 matrices; when the threads asked for would hold more than 4 GiB, fewer run, with a ``PerformanceWarning``. **Limitations.** Measured on simulated cause-effect pairs with a post-nonlinear mechanism, a linear inner transform and an invertible nonlinear outer one, and Laplace noise, from 200 to 5,000 rows. It named the true direction on three pairs in four at 200 rows and on every pair from 1,000 rows. The median fit took 0.8 s at 5,000 rows, on 4 cores. Each HSIC test builds kernel matrices with one row and one column per sample, so time and memory grow with the square of the row count: each worker thread holds three such float64 matrices, 600 MB at 5,000 rows. References ---------- .. [1] Zhang, K., and Hyvarinen, A. (2009). On the identifiability of the post-nonlinear causal model. UAI, 647-655. .. [2] Zhang, K., and Hyvarinen, A. (2010). Distinguishing causes from effects using nonlinear acyclic causal models. JMLR Workshop and Conference Proceedings 6, 157-164. .. [3] Hoyer, P. O., Janzing, D., Mooij, J. M., Peters, J., and Scholkopf, B. (2008). Nonlinear causal discovery with additive noise models. NeurIPS 21, 689-696. .. [4] Mooij, J. M., Peters, J., Janzing, D., Zscheischler, J., and Scholkopf, B. (2016). Distinguishing cause from effect using observational data: methods and benchmarks. JMLR 17(32), 1-102. Examples -------- >>> import numpy as np >>> from andrey import pnl >>> rng = np.random.default_rng(2) >>> x = rng.uniform(-1, 1, 300) >>> y = np.tanh(x**3 + 0.3 * rng.uniform(-1, 1, 300)) >>> out = pnl(np.column_stack([x, y])) >>> out.structure.oriented_edges() [(0, 1, 'directed')] >>> out.metadata["pval_forward"] > 0.05 >= out.metadata["pval_backward"] True """ from andrey.irregular.pnl import pnl as _native if np.ndim(data) != 2 or np.shape(data)[1] != 2: raise ValueError( f"pnl expects an (n_samples, 2) table of two variables, got {np.shape(data)}" ) X = data_matrix(data) if alpha is not None: alpha = open_unit_interval(alpha, "alpha") labels = column_labels(data) pval_forward, pval_backward, stat_forward, stat_backward = _native(X[:, 0], X[:, 1]) return _adapt_pnl( pval_forward, pval_backward, stat_forward=stat_forward, stat_backward=stat_backward, alpha=alpha, labels=labels, )
[docs] def rcd( data: npt.ArrayLike, *, max_explanatory_vars: int = 2, cor_alpha: float = 0.01, ind_alpha: float = 0.01, shapiro_alpha: float = 0.01, ) -> StructureOutput: """Learn a linear non-Gaussian graph with hidden common causes by repetitive causal discovery. RCD [1]_ repeatedly regresses variables on candidate ancestors and tests the residuals for independence, to find each variable's ancestors, then its parents, and finally the pairs that share a hidden common cause. Parameters ---------- data : array-like of shape (n_samples, n_variables) Observations, one variable per column. A DataFrame's column names become the structure's ``labels``. max_explanatory_vars : int, default=2 Most variables regressed out together when testing whether one variable is an ancestor of another, at least 1. Larger values find more ancestors and take longer. cor_alpha : float, default=0.01 Significance level of the Pearson correlation tests, in ``(0, 1)``. ind_alpha : float, default=0.01 Significance level of the HSIC independence tests, in ``(0, 1)``. shapiro_alpha : float, default=0.01 Significance level of the Shapiro-Wilk tests that decide whether a residual is non-Gaussian, in ``(0, 1)``. Returns ------- StructureOutput A :class:`~andrey.core.GraphStructure` of kind ``pag``: ``i -> j`` for a direct cause and ``i <-> j`` for a pair with a hidden common cause. ``metadata["algorithm"]`` is ``"RCD"``; ``metadata["confounded_pairs"]`` lists the ``<->`` pairs as sorted ``[i, j]`` lists; ``weighted_adjacency`` holds the edge coefficients. Raises ------ TypeError If ``max_explanatory_vars`` is not an int. ValueError If ``data`` has fewer than 6 rows, if ``max_explanatory_vars`` is less than 1, or if a significance level is not in ``(0, 1)``. Notes ----- RCD assumes linear relations and non-Gaussian noise. It needs more rows than DirectLiNGAM: its tests compare residuals, and a weak hidden cause leaves only a small trace in them. **Limitations.** Measured on linear data with uniform noise, one node in five hidden, and 2,000 rows, from 4 to 24 observed variables: its median directed-edge errors were below the empty graph's at every size but 8 variables, where they equaled it. A fit took about 2 s at 4 variables and 18 s at 24, on two cores. Larger problems are untested; the work grows with the number of variables and with ``max_explanatory_vars``. References ---------- .. [1] Maeda, T. N., and Shimizu, S. (2020). RCD: Repetitive causal discovery of linear non-Gaussian acyclic models with latent confounders. AISTATS, PMLR 108, 735-745. Examples -------- >>> import numpy as np >>> import andrey >>> rng = np.random.default_rng(0) >>> u = lambda: rng.uniform(-1, 1, 2000) >>> h = u() # hidden: a common cause of x0 and x1 >>> x0, x1 = 1.5 * h + u(), 1.5 * h + u() >>> data = np.column_stack([x0, x1, x1 + u(), x0 + u()]) >>> out = andrey.rcd(data) >>> out.metadata["confounded_pairs"] [[0, 1]] """ from andrey.irregular.rcd import fit_rcd X = data_matrix(data, min_rows=6) # HSIC's null variance has (n - 4)(n - 5) max_explanatory_vars = int_in_range(max_explanatory_vars, "max_explanatory_vars", 1) model = fit_rcd( X, max_explanatory_num=max_explanatory_vars, cor_alpha=open_unit_interval(cor_alpha, "cor_alpha"), ind_alpha=open_unit_interval(ind_alpha, "ind_alpha"), shapiro_alpha=open_unit_interval(shapiro_alpha, "shapiro_alpha"), ) return with_labels(_adapt_rcd(model), column_labels(data))
[docs] def bottom_up_parce_lingam(data: npt.ArrayLike, *, alpha: float = 0.1) -> StructureOutput: """Learn a linear non-Gaussian causal order that tolerates hidden common causes. BottomUpParceLiNGAM [1]_ finds sinks one at a time, from the last variable in the causal order up. Where a hidden common cause hides the order, it leaves the variables unordered rather than guessing. Parameters ---------- data : array-like of shape (n_samples, n_variables) Observations, one variable per column. A DataFrame's column names become the structure's ``labels``. alpha : float, default=0.1 Significance level of the independence test that accepts a sink, Bonferroni-corrected across the remaining variables, in ``(0, 1)``. Returns ------- StructureOutput A :class:`~andrey.core.GraphStructure` of kind ``pag``: ``i -> j`` for a direct cause and ``i o-o j`` between variables whose order a hidden common cause leaves unresolved. ``metadata["algorithm"]`` is ``"BottomUpParceLiNGAM"``; ``metadata["causal_order"]`` gives the order, with the unordered variables nested in one list; ``weighted_adjacency`` holds the edge coefficients. Raises ------ ValueError If ``data`` has fewer than 6 rows or no more rows than columns, or if ``alpha`` is not in ``(0, 1)``. Notes ----- The search finds sinks from the bottom up and stops at the first variable it cannot separate, leaving it and every variable above it in one unordered block: the variables a hidden cause confounds, their observed ancestors, and any other confounded pairs. Candidates whose test statistics are equal after rounding are chosen by each column's rank, as in :func:`~andrey.hc`; the :doc:`column-order guide </docs/guides/column-order>` says what the rank keeps. An ``o-o`` pair need not share a cause, and edges among the block's variables stay unoriented. The edge weights come from an adaptive-Lasso regression of each variable on those before it, which needs more rows than columns. **Limitations.** Suited to a few variables. Measured on linear data with uniform noise, one node in five hidden, and 2,000 rows: its median directed-edge errors were below the empty graph's at 4 observed variables, and above it at every size from 8 to 24. From 16 variables it left none of the confounded pairs unordered. A fit took about 2 s at 4 variables and 96 s at 24, on two cores. References ---------- .. [1] Tashiro, T., Shimizu, S., Hyvarinen, A., and Washio, T. (2014). ParceLiNGAM: A causal ordering method robust against latent confounders. Neural Computation 26(1), 57-83. Examples -------- >>> import numpy as np >>> import andrey >>> rng = np.random.default_rng(0) >>> u = lambda: rng.uniform(-1, 1, 2000) >>> h = u() # hidden: a common cause of x0 and x1 >>> x0, x1 = 1.5 * h + u(), 1.5 * h + u() >>> data = np.column_stack([x0, x1, x1 + u(), x0 + u()]) >>> out = andrey.bottom_up_parce_lingam(data, alpha=0.01) >>> out.metadata["causal_order"] [[0, 1], 2, 3] """ from andrey.irregular.bottom_up_parce import bottom_up_parce_lingam as _native X = data_matrix(data, min_rows=6) # HSIC's null variance has (n - 4)(n - 5) if X.shape[0] <= X.shape[1]: raise ValueError( f"data needs more rows than columns, got {X.shape[0]} rows and {X.shape[1]} columns" ) model = _native(X, alpha=open_unit_interval(alpha, "alpha")) return with_labels(_adapt_bottom_up_parce(model), column_labels(data))