Source code for andrey.api.constraint

"""Constraint-based facade adapters.

The underlying searches use :mod:`andrey.core` for the conditional-independence tests (Fisher-Z and
Student-t on continuous data, chi-square and G-square on categorical data, degenerate and
conditional Gaussian likelihood ratios on mixed data, and the nonlinear tests) and orientation.
``pc`` and ``cdnod`` produce CPDAGs; ``fci`` and ``gfci`` produce PAGs. GFCI also uses GES with a
BIC, BDeu, or mixed-data score.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

import numpy as np

from andrey.api._adapt import (
    MAX_CATEGORIES,
    categorical_metadata,
    check_categories,
    checked_data,
    column_labels,
    column_namer,
    copies_named,
    data_kind,
    int_in_range,
    open_unit_interval,
    structure_output,
)
from andrey.core.knowledge import Knowledge, resolve

if TYPE_CHECKING:
    from collections.abc import Sequence

    import numpy.typing as npt

    from andrey.core import StructureOutput


[docs] def pc( data: npt.ArrayLike, *, alpha: float = 0.05, indep_test: str = "fisherz", collider_order: str | None = None, categorical: Sequence[str | int] | None = None, max_categories: int = MAX_CATEGORIES, max_depth: int | None = None, knowledge: Knowledge | None = None, ) -> StructureOutput: """Learn a CPDAG from observational data with the PC algorithm. PC [1]_ [2]_ uses conditional-independence tests to remove edges from a complete graph. These tests check whether variables are independent given other variables. It then orients colliders, where arrows meet at a variable, and applies Meek's rules [4]_. The result is a CPDAG, representing an equivalence class of graphs whose unresolved edges stay undirected. Parameters ---------- data : array-like of shape (n_samples, n_variables) Observed data, one row per sample. Coerced to ``float64``. A DataFrame's column names become the structure's ``labels``. alpha : float, default=0.05 Significance level of the conditional-independence test. Smaller values give sparser graphs. indep_test : str, default="fisherz" Conditional-independence test. ``"fisherz"`` and ``"student_t"`` test partial correlation, for continuous data: ``"fisherz"`` applies Fisher's z-transform and the normal distribution; ``"student_t"`` applies the Student-t distribution, which is exact for Gaussian data at any sample size. ``"chisq"`` (Pearson's chi-square) and ``"gsq"`` (G-square, the likelihood ratio) test categorical data, coded by whole numbers. ``"degen_gauss"`` (degenerate Gaussian) and ``"cond_gauss"`` (conditional Gaussian) test mixed data, continuous and categorical columns, by a likelihood ratio. Four more tests detect nonlinear dependence in continuous data, at a far higher cost per test: ``"kci"`` is the kernel test; ``"rcot"`` its random-feature approximation; ``"cmiknn"`` a nearest-neighbor conditional mutual information test; and ``"gpdc"`` Gaussian-process regression with a distance correlation test. See Notes. collider_order : {"p_value", "column"} or None, default=None The order in which colliders are oriented, which decides between two colliders that would orient one edge in opposite directions. ``"p_value"`` orients first the collider whose endpoints were separated with the largest p-value, so the colliders do not depend on the column order. ``"column"`` orients them in column order and keeps the first of two that conflict. ``None`` follows ``andrey.config.tie_break``: ``"p_value"`` under ``"rank"``, ``"column"`` under ``"position"``. See Notes. categorical : sequence of str or int, optional The categorical columns of ``data``, for a mixed-data test: column labels (strings) or 0-based positions. ``None`` infers them: a DataFrame's ``category``, ``bool``, and text columns, and every column of at most ``max_categories`` distinct whole numbers, named in a ``DataTypeWarning``. ``[]`` marks none. The other tests take no column types and reject it. See *Data types* in the :doc:`getting-started guide </docs/guides/getting-started>`. max_categories : int, default=10 The most distinct whole numbers a column may hold to be inferred as categorical, at least ``2``. With a continuous test, columns within it get a one-time ``DataTypeWarning`` that they may be category codes. max_depth : int or None, default=None The maximum conditioning-set size of every conditional-independence test, at least ``0``: ``0`` runs only unconditional tests, and ``None`` sets no limit. A smaller cap runs fewer tests and can keep edges that a larger conditioning set would remove. knowledge : Knowledge or None, default=None Background knowledge, a :class:`~andrey.Knowledge`: required and forbidden edges, the pairs allowed to be adjacent, and temporal tiers, all honored. The adjacency search starts from the allowed pairs and never removes a required pair; the tests can remove any other allowed pair. A pair the knowledge makes non-adjacent is tested for a separating set, so colliders over it are oriented as usual; when the search finds none within ``max_depth``, no rule reads its unshielded triples as colliders or non-colliders. The result directs every edge the knowledge decides, then Meek's rules direct the edges that follow, giving the maximally oriented PDAG (MPDAG) :class:`~andrey.Knowledge` describes; knowledge that orients the edges the search kept into a directed cycle raises ``ValueError``. Returns ------- StructureOutput Wraps a ``cpdag`` :class:`~andrey.core.GraphStructure`; ``metadata["algorithm"]`` is ``"PC"``. Directed marks (``->``) are oriented edges, undirected (``--``) the ones the equivalence class leaves open. Under a mixed-data setting, ``metadata["categorical"]`` lists the categorical columns it read, by name or 0-based position. Raises ------ NotImplementedError If ``indep_test`` names no available test. TypeError If ``categorical`` is not a list of column names and positions, or ``max_categories`` is not an int. Also if ``knowledge`` is neither ``None`` nor a :class:`~andrey.Knowledge`, or a given ``max_depth`` is not an int. ValueError If ``data`` has too few samples for a test or a singular correlation matrix, if ``alpha`` is not in ``(0, 1)``, if ``max_categories`` is less than ``2``, with ``"chisq"`` or ``"gsq"``, if ``data`` holds a value that is not a whole number, or, with ``"degen_gauss"`` or ``"cond_gauss"``, if ``categorical`` names a column ``data`` lacks, or if a categorical column holds a number that is not whole or a missing value. Also if ``categorical`` is given with another test, or if ``collider_order`` is not ``"p_value"`` or ``"column"``. With ``"fisherz"`` or ``"student_t"``, also if a column repeats another up to scale and shift, before any test runs; one message names every group of copies and says to keep one column per group. Also if a given ``max_depth`` is negative, or if ``knowledge`` names a column ``data`` lacks or contradicts itself, as :class:`~andrey.Knowledge` lists. Warns ----- PerformanceWarning When a conditioning pass of size two or greater requires at least ten million CI tests; each message warns once per process. The warning does not limit the search. DataTypeWarning When a mixed-data test infers the categorical columns, or finds none and so runs as the linear-Gaussian form, or when a continuous setting meets columns that look like category codes; each message warns once per process. See Also -------- andrey.fci : Allows unmeasured common causes and selection bias. andrey.direct_lingam : Orients every edge when the noise is non-Gaussian. Notes ----- **When to use it.** PC suits data with no unmeasured common causes. Its default Fisher-Z test assumes continuous data, where each variable depends linearly on its causes with roughly Gaussian noise; for categorical data, use ``"chisq"`` or ``"gsq"``, for mixed data ``"degen_gauss"`` or ``"cond_gauss"``, and for nonlinear effects in continuous data ``"kci"``, ``"rcot"``, ``"cmiknn"``, or ``"gpdc"``, at a far higher cost per test. It is fastest on sparse graphs, where every variable has few neighbors [5]_. **When not to.** When unmeasured common causes are plausible, use :func:`~andrey.fci`. When the noise is non-Gaussian and every edge should be oriented, use :func:`~andrey.direct_lingam`. **Choosing alpha.** A test removes an edge when it cannot reject independence at ``alpha``. A larger ``alpha`` therefore keeps more edges, true and false. On the Sachs data in the getting-started guide, raising ``alpha`` from 0.05 to 0.2 found more known links, but a smaller share of the links found were known. No value is right in general: it trades missed edges against false ones, so a result is best reported with how the graph changes with ``alpha``. **How it works.** Edge removal is order-independent (PC-stable) [3]_. At each conditioning-set size, every test uses the adjacencies from the start of that size. Edges found independent are removed together. A removed pair's separating set combines all conditioning sets that separated it. Two colliders conflict when they would orient one edge in opposite directions, so the order in which they are oriented can change the result [3]_. A collider is skipped when it would give an edge an arrowhead at both ends, so of two conflicting colliders the one oriented first is kept. With ``collider_order="p_value"`` (the default), each collider ``x -> y <- z`` ranks by the largest p-value among the conditioning sets that separated ``x`` and ``z``, and colliders are oriented from the highest rank to the lowest. When two conflicting colliders rank equal to nine decimals, the edge they dispute stays undirected, and Meek's rules do not orient it. The colliders therefore do not depend on the column order. This adapts the conflict rule of Ramsey [10]_, which ranks a collider by separating sets that additional tests choose; Andrey ranks it by the tests that removed the edge, so orienting runs no new test. With ``collider_order="column"``, colliders are oriented by middle node, then by endpoint pair, in column order, so the result can depend on the column order. After the colliders, Meek's rules run in rounds: each round collects every orientation they imply on the graph as it stands and applies them together, except that an edge two of them would direct both ways stays undirected, and so do orientations that would together close a directed cycle. The rounds repeat until one orients nothing, so Meek's rules do not depend on the column order. Such conflicts arise only when no DAG on the skeleton has exactly the colliders found, as test errors can cause; otherwise the rounds give the CPDAG that firing the rules one at a time gives. Under ``andrey.config(tie_break="position")``, the rules fire one at a time in column order instead, and of two firings that would orient one edge in opposite directions the first decides. The :doc:`column-order guide </docs/guides/column-order>` lists what reordering the columns does to each method. **Limitations.** The benchmark numbers below were measured with ``collider_order="column"``; the default is not benchmarked yet. On simulated linear-Gaussian data from sparse random graphs (mean degree 2), from 20 to 400 variables with ten rows per variable, its median errors (SHD) stayed below the empty graph's at every size, 357 against 400 at 400 variables. The median fit took 0.0076 s at 20 variables and 0.82 s at 400, on 16 cores. On the 223-variable ANDES network with 1,000 simulated rows, its median errors passed the empty graph's (372 against 338). It is practical to roughly 1,000 variables on sparse data with ten rows per variable; runtime grows combinatorially with the largest neighborhood, so sparser graphs can stay feasible beyond that. On simulated mixed data (conditional linear Gaussian networks on sparse random graphs, the first half of the variables categorical with 2 to 5 categories), from 10 to 40 variables with 2,000 rows, its median errors stayed below the empty graph's at every size: at 40 variables, 18.5 with ``"degen_gauss"`` and 9.5 with ``"cond_gauss"``, against 40. On simulated categorical networks with 2 or 3 categories per variable, of the same sizes and rows, they were 14 with ``"chisq"`` and 16 with ``"gsq"`` at 40 variables. The median fit at 40 variables took 0.38 s with ``"degen_gauss"``, 0.42 s with ``"cond_gauss"``, and 0.18 s with either categorical test, on 4 cores. **Categorical data.** ``"chisq"`` and ``"gsq"`` take each distinct value of a column as one category, so the codes need not start at 0 or be consecutive; a DataFrame's text, ``bool``, and ``category`` columns are read the same way. A test compares the counts of ``x`` and ``y`` with independence within each configuration of the conditioning set that occurs in the data [2]_. Each configuration adds ``(r_x - 1) * (r_y - 1)`` degrees of freedom, counting only the values of ``x`` and ``y`` present in it [6]_; with none, the p-value is 1. A test needs several rows per cell of its table: with many categories or a large conditioning set, most cells are empty, and the p-value is unreliable. **Mixed data.** ``"degen_gauss"`` and ``"cond_gauss"`` test data with continuous and categorical columns. Each distinct value of a categorical column is one category, and a DataFrame column of text or categories needs no coding. ``"degen_gauss"``, the degenerate Gaussian test [7]_, replaces a categorical column of ``k`` categories by ``k - 1`` indicator columns and treats every column as Gaussian, so it suits effects that are linear in the continuous variables and add up across categories. ``"cond_gauss"``, the conditional Gaussian test [8]_, fits the continuous variables in each joint category of the categorical ones with a Gaussian of that category's own mean and covariance, so a category can change a variance as well as a mean. Each category's covariance is shrunk toward that of all rows by one pseudo-row [9]_, so a joint category with few rows takes mostly the covariance of all rows; to tell its own apart, a test needs several rows in each joint category of its categorical variables. Each compares the likelihoods of the models with and without the dependence, a chi-square statistic on the difference in their numbers of parameters. With no categorical column, both are the likelihood-ratio form of the partial-correlation test, which agrees with ``"fisherz"`` as the rows grow. References ---------- .. [1] Spirtes, P., and Glymour, C. (1991). An algorithm for fast recovery of sparse causal graphs. Social Science Computer Review 9(1), 62-72. .. [2] Spirtes, P., Glymour, C., and Scheines, R. (2000). Causation, Prediction, and Search, 2nd ed. MIT Press. .. [3] Colombo, D., and Maathuis, M. H. (2014). Order-independent constraint-based causal structure learning. JMLR 15, 3921-3962. .. [4] Meek, C. (1995). Causal inference and causal explanation with background knowledge. UAI, 403-410. .. [5] Kalisch, M., and Buhlmann, P. (2007). Estimating high-dimensional directed acyclic graphs with the PC-algorithm. JMLR 8, 613-636. .. [6] 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. .. [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. .. [8] 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. .. [9] Friedman, J. H. (1989). Regularized discriminant analysis. Journal of the American Statistical Association 84(405), 165-175. .. [10] Ramsey, J. (2016). Improving accuracy and scalability of the PC algorithm by maximizing P-value. arXiv:1610.00378, Section 5. Examples -------- >>> import numpy as np >>> import pandas as pd >>> from andrey import pc >>> rng = np.random.default_rng(0) >>> x = rng.standard_normal(500) >>> y = rng.standard_normal(500) >>> z = x + y + 0.5 * rng.standard_normal(500) >>> pc(pd.DataFrame({"x": x, "y": y, "z": z})).structure.oriented_edges() [('x', 'z', 'directed'), ('y', 'z', 'directed')] On categorical data, with a chi-square test: >>> a = rng.integers(0, 2, 2000) >>> b = rng.integers(0, 2, 2000) >>> c = np.where(rng.random(2000) < 0.8, a + b, rng.integers(0, 3, 2000)) >>> data = pd.DataFrame({"a": a, "b": b, "c": c}) >>> pc(data, indep_test="chisq").structure.oriented_edges() [('a', 'c', 'directed'), ('b', 'c', 'directed')] On mixed data, with a category column and the degenerate Gaussian test: >>> g = rng.integers(0, 3, 2000) >>> u = rng.standard_normal(2000) >>> v = np.array([-1.0, 0.0, 1.0])[g] + u + 0.5 * rng.standard_normal(2000) >>> data = pd.DataFrame({"g": pd.Categorical(g), "u": u, "v": v}) >>> pc(data, indep_test="degen_gauss").structure.oriented_edges() [('g', 'v', 'directed'), ('u', 'v', 'directed')] """ from andrey.constraint.pc import pc as _pc from andrey.constraint.pc import resolve_collider_order, resolve_in_column_order X, mask = checked_data( data, categorical, "indep_test", indep_test, max_categories=max_categories ) alpha = open_unit_interval(alpha, "alpha") labels = column_labels(data) constraints = resolve(knowledge, X.shape[1], labels, method="pc") max_depth = None if max_depth is None else int_in_range(max_depth, "max_depth", 0) with copies_named(labels): cpdag = _pc( X, alpha=alpha, indep_test=indep_test, collider_order=resolve_collider_order(collider_order), categorical=mask, max_depth=max_depth, constraints=constraints, in_column_order=resolve_in_column_order(), ) return structure_output( cpdag, labels=labels, metadata={"algorithm": "PC", **categorical_metadata(mask, data)} )
[docs] def fci( data: npt.ArrayLike, *, alpha: float = 0.05, indep_test: str = "fisherz", collider_rule: str = "sepsets", categorical: Sequence[str | int] | None = None, max_categories: int = MAX_CATEGORIES, possible_dsep: str | None = None, max_depth: int | None = None, knowledge: Knowledge | None = None, ) -> StructureOutput: """Learn a PAG from observational data with FCI, sound under latent confounders. FCI extends PC to handle unmeasured common causes (latent confounders) and selection bias [1]_ [2]_. It uses conditional-independence tests to remove edges, then orients their endpoints. The result is a PAG, whose circles (``o``) mark endpoints the data cannot determine. Use it when unmeasured confounding is plausible. Parameters ---------- data : array-like of shape (n_samples, n_variables) Observed data, one row per sample. Coerced to ``float64``. A DataFrame's column names become the structure's ``labels``. alpha : float, default=0.05 Significance level of the conditional-independence test. Smaller values give sparser graphs. indep_test : str, default="fisherz" Conditional-independence test. ``"fisherz"`` and ``"student_t"`` test partial correlation, for continuous data: ``"fisherz"`` applies Fisher's z-transform and the normal distribution; ``"student_t"`` applies the Student-t distribution, which is exact for Gaussian data at any sample size. ``"chisq"`` (Pearson's chi-square) and ``"gsq"`` (G-square, the likelihood ratio) test categorical data, coded by whole numbers. ``"degen_gauss"`` (degenerate Gaussian) and ``"cond_gauss"`` (conditional Gaussian) test mixed data, continuous and categorical columns. Four more tests detect nonlinear dependence in continuous data, at a far higher cost per test: ``"kci"`` is the kernel test; ``"rcot"`` its random-feature approximation; ``"cmiknn"`` a nearest-neighbor conditional mutual information test; and ``"gpdc"`` Gaussian-process regression with a distance correlation test. See Notes. collider_rule : {"sepsets", "majority"}, default="sepsets" How FCI decides whether ``z`` is a collider on a triple ``x - z - y`` where ``x`` and ``y`` are not adjacent. ``"sepsets"`` makes it a collider when ``z`` is not in the separating set that removed the edge ``x - y``. ``"majority"`` tests ``x`` and ``y`` given every subset of ``x``'s neighbors and every subset of ``y``'s neighbors, and makes it a collider when fewer than half of the separating subsets (``p >= alpha``) contain ``z`` [4]_; at exactly half the triple stays unoriented. Majority runs up to ``2**degree`` extra tests per triple. categorical : sequence of str or int, optional The categorical columns of ``data``, for a mixed-data test: column labels (strings) or 0-based positions. ``None`` infers them: a DataFrame's ``category``, ``bool``, and text columns, and every column of at most ``max_categories`` distinct whole numbers, named in a ``DataTypeWarning``. ``[]`` marks none. The other tests take no column types and reject it. See *Data types* in the :doc:`getting-started guide </docs/guides/getting-started>`. max_categories : int, default=10 The most distinct whole numbers a column may hold to be inferred as categorical, at least ``2``. With a continuous test, columns within it get a one-time ``DataTypeWarning`` that they may be category codes. possible_dsep : {"order_free", "fast"} or None, default=None The order of FCI's possible-d-separation step [2]_ [3]_, which removes edges the first step kept, one at a time, so each removal changes what the step tests after it. ``"order_free"`` visits the pairs and nodes by a rank computed from each column's own data, so the result does not depend on the column order unless two columns are identical. ``"fast"`` visits them in column order. ``None`` follows ``andrey.config.tie_break``: ``"order_free"`` under ``"rank"``, ``"fast"`` under ``"position"``. See Notes and Limitations. max_depth : int or None, default=None The maximum conditioning-set size of every conditional-independence test, at least ``0``: ``0`` runs only unconditional tests, and ``None`` sets no limit. A smaller cap runs fewer tests and can keep edges that a larger conditioning set would remove. It caps the tests of the adjacency search, the possible-d-separation step, the majority rule, and the discriminating-path rule. knowledge : Knowledge or None, default=None Background knowledge, a :class:`~andrey.Knowledge`: required and forbidden edges, the pairs allowed to be adjacent, and temporal tiers, all honored. The adjacency search starts from the allowed pairs and never removes a required pair; the tests can remove any other allowed pair. A pair the knowledge makes non-adjacent is tested for a separating set, so colliders over it are oriented as usual; when the search finds none within ``max_depth``, no rule reads its unshielded triples as colliders or non-colliders. In the PAG, a forbidden ``a -> b`` on an adjacent pair puts an arrowhead at ``a`` and can leave a circle at ``b``, and a required one is ``a -> b``; knowledge that puts an arrowhead at ``a`` on an edge ``a - b`` while its required edges lead from ``a`` to ``b`` raises ``ValueError``. Returns ------- StructureOutput Wraps a ``pag`` :class:`~andrey.core.GraphStructure`; ``metadata["algorithm"]`` is ``"FCI"``. Endpoints carry arrowheads, tails, or circle marks (``o``); a bidirected edge (``<->``) marks a probable latent common cause. Under a mixed-data setting, ``metadata["categorical"]`` lists the categorical columns it read, by name or 0-based position. Raises ------ NotImplementedError If ``indep_test`` names no available test. TypeError If ``categorical`` is not a list of column names and positions, or ``max_categories`` is not an int. Also if ``knowledge`` is neither ``None`` nor a :class:`~andrey.Knowledge`, or a given ``max_depth`` is not an int. ValueError If ``data`` has too few samples for a test or a singular correlation matrix, if ``alpha`` is not in ``(0, 1)``, if ``collider_rule`` or ``possible_dsep`` is not a supported choice, or if ``data`` or ``categorical`` does not suit the test, as for :func:`~andrey.pc`. With ``"fisherz"`` or ``"student_t"``, also if a column repeats another up to scale and shift, before any test runs; one message names every group of copies and says to keep one column per group. Also if a given ``max_depth`` is negative, or if ``knowledge`` names a column ``data`` lacks or contradicts itself, as :class:`~andrey.Knowledge` lists. Warns ----- DataTypeWarning When a mixed-data test infers the categorical columns, or finds none and so runs as the linear-Gaussian form, or when a continuous setting meets columns that look like category codes; each message warns once per process. See Also -------- andrey.pc : Orients more of the graph, with fewer tests, when every common cause is measured. Notes ----- **When to use it.** FCI suits data that may be affected by unmeasured common causes or sample selection. Its default Fisher-Z test assumes continuous data with linear effects and roughly Gaussian noise; for categorical data, use ``"chisq"`` or ``"gsq"``, and for mixed data ``"degen_gauss"`` or ``"cond_gauss"``, which read the data as :func:`~andrey.pc` describes. Its PAG claims less than PC's CPDAG: an arrowhead at a node rules out that node causing the node at the edge's other end, and a circle leaves the mark open. **When not to.** When every common cause is measured, :func:`~andrey.pc` orients more of the graph with fewer tests. FCI's possible-d-separation step runs tests PC does not. **Choosing alpha.** A larger ``alpha`` keeps more edges, true and false. **How it works.** FCI runs in three steps. First, it removes edges by conditional-independence tests, as PC does, and orients colliders from the separating sets. Next, it tests each remaining pair given subsets of its possible-d-separation set: nodes that can separate the pair when some causes are unmeasured. It removes pairs found independent and orients colliders again. Last, it applies Zhang's orientation rules R1-R10 [3]_ until no mark changes. An endpoint no rule resolves stays a circle. The rules run in rounds: each round collects the marks every rule implies on the graph as it stands and sets them together, except that an endpoint two of them would mark one as an arrowhead and one as a tail stays a circle. The rounds repeat until no mark changes. Rule R4 decides an endpoint by every shortest discriminating path, so paths whose tests disagree leave it a circle. The orientation rules therefore do not depend on the column order, and with ``possible_dsep="order_free"`` neither does the result, unless two columns are identical. With ``"fast"`` the result can depend on the column order through the possible-d-separation step. A fixed column order gives reproducible results. Under ``andrey.config(tie_break="position")``, the rules instead fire one at a time in column order, and of two firings that conflict the first decides. ``possible_dsep`` then defaults to ``"fast"``. The :doc:`column-order guide </docs/guides/column-order>` lists what reordering the columns does to each method. Rule R5 turns a cycle of circle-circle edges into undirected edges only if the whole cycle, including the closing edge, is uncovered, that is, no two nodes two steps apart are adjacent. It considers every circle path, up to a fixed search bound, so a covered route through a node never hides an uncovered route through the same node. Rule R8 checks each pair of endpoints in both orders, so whether it fires does not depend on the node labels. **Possible-d-separation set.** FCI builds each possible-d-separation set [2]_ [3]_ with a search that walks out from one endpoint of the pair and keeps a node when a node it was reached from has a possibly directed path, one with no arrowhead pointing back along it, to the endpoint or to the node. FCI checks a node when the search reaches it, against the nodes it was reached from so far, so the set depends on the order the search visits nodes. The set can be smaller than the published definition of the set [2]_, so FCI can keep an edge that the published set would remove, even with exact tests. FCI keeps this search because the published set grows quickly with the graph, the search's set is the one FCI has been measured with, and on simulated data the published set did not give more accurate PAGs. FCI tests every subset of the set, except the empty set and the subsets of either endpoint's neighbors, which the first step already tested. It visits the pairs one at a time, removes a pair as soon as a subset separates it, and records that subset, so each removal changes the sets of the pairs after it. With ``possible_dsep="order_free"``, the pairs, the nodes of each search, and the subsets follow a tie rank computed from each column's own data, as for :func:`~andrey.hc`, which moves with its column when the columns are reordered; with ``"fast"``, they follow the column order. **Separating sets.** When several conditioning sets of the same size make a pair independent in the first step, FCI records their union as the pair's separating set. FCI keeps the union because it does not depend on the order of the tests, and on simulated data it gave more accurate PAGs than the first set found. **Limitations.** Measured with ``possible_dsep="fast"`` on simulated linear-Gaussian data from random graphs with mean degree 2 or 4 and one variable in ten or one in five hidden, from 20 to 800 variables counting the hidden ones, with 10 or 50 rows per variable, on 4 cores. Its median endpoint errors, each mismatched tail, arrowhead, or circle, stayed below the empty graph's at every size with mean degree 4, and up to 400 variables with mean degree 2, where they passed it at 800 (2,582 against 1,738 with one variable in ten hidden and ten rows per variable). With those settings the median fit took 0.0064 s at 20 variables and 114 s at 800; with one variable in five hidden and 50 rows per variable it took 78 s at 400 and 4,829 s at 800, where two of five fits ran past 7,200 s. The default, ``"order_free"``, took 0.99 to 1.05 times as long as ``"fast"`` up to 200 variables and 0.78 to 1.42 times at 400 and 800, and its median endpoint errors were within 2% of ``"fast"``'s. On some graphs from 400 variables its order needs more tests than the column order: on one with 400 variables, mean degree 4, one variable in five hidden, and 50 rows per variable, it took 34 s against 23 to 29 s for ``"fast"`` over five column orders. ``possible_dsep="fast"`` gives the column-order mode. On the 223-variable ANDES network with 1,000 simulated rows, its median endpoint errors passed the empty graph's (696 against 676). Rule R5 stops searching a pair after a fixed number of partial paths and leaves its circles, so on graphs with many circle-circle edges it can miss an undirected cycle. With the mixed-data tests, it is experimental and not benchmarked yet; its tests run it on 5 variables with 3,000 rows. References ---------- .. [1] Spirtes, P., Meek, C., and Richardson, T. (1995). Causal inference in the presence of latent variables and selection bias. UAI, 499-506. .. [2] Spirtes, P., Glymour, C., and Scheines, R. (2000). Causation, Prediction, and Search, 2nd ed. MIT Press. .. [3] Zhang, J. (2008). On the completeness of orientation rules for causal discovery in the presence of latent confounders and selection bias. Artificial Intelligence 172(16-17), 1873-1896. .. [4] Colombo, D., and Maathuis, M. H. (2014). Order-independent constraint-based causal structure learning. JMLR 15, 3921-3962. Examples -------- >>> import numpy as np >>> from andrey import fci >>> rng = np.random.default_rng(0) >>> x = rng.standard_normal(500) >>> y = x + 0.1 * rng.standard_normal(500) >>> fci(np.column_stack([x, y])).structure.kind 'pag' """ from andrey.constraint.fci import fci as _fci from andrey.constraint.pc import resolve_in_column_order, resolve_possible_dsep X, mask = checked_data( data, categorical, "indep_test", indep_test, max_categories=max_categories ) alpha = open_unit_interval(alpha, "alpha") labels = column_labels(data) constraints = resolve(knowledge, X.shape[1], labels, method="fci") max_depth = None if max_depth is None else int_in_range(max_depth, "max_depth", 0) with copies_named(labels): pag = _fci( X, alpha=alpha, indep_test=indep_test, collider_rule=collider_rule, categorical=mask, possible_dsep=resolve_possible_dsep(possible_dsep), max_depth=max_depth, constraints=constraints, in_column_order=resolve_in_column_order(), ) return structure_output( pag, labels=labels, metadata={"algorithm": "FCI", **categorical_metadata(mask, data)} )
[docs] def gfci( data: npt.ArrayLike, *, score_func: str = "local_score_BIC", lambda_value: float = 1.0, indep_test: str = "fisherz", alpha: float = 0.05, collider_rule: str = "sepsets", categorical: Sequence[str | int] | None = None, max_categories: int = MAX_CATEGORIES, possible_dsep: str | None = None, max_depth: int | None = None, knowledge: Knowledge | None = None, ) -> StructureOutput: """Learn a PAG from observational data with GFCI, a score-then-FCI hybrid. GFCI [1]_ starts from the adjacencies of a score-based GES search, removes those that conditional-independence tests reject, and orients the endpoints with FCI's rules to account for possible latent confounding. Its scored skeleton is usually more accurate than FCI's test-only one. Parameters ---------- data : array-like of shape (n_samples, n_variables) Observed data, one row per sample. Coerced to ``float64``. A DataFrame's column names become the structure's ``labels``. score_func : str, default="local_score_BIC" Local score driving the GES skeleton phase, as for :func:`~andrey.ges`: ``"local_score_BIC"``, the linear-Gaussian BIC; ``"local_score_BDeu"`` or ``"local_score_BIC_categorical"`` for categorical data, coded by whole numbers; or ``"local_score_degen_gauss"`` or ``"local_score_cond_gauss"``, the degenerate or conditional Gaussian BIC for mixed data. lambda_value : float, default=1.0 Multiplier on the BIC penalty of ``log(n_samples)`` per parameter in the GES phase. Larger values give sparser graphs; ``1.0`` is the standard BIC. Must be finite and non-negative. BDeu ignores it. indep_test : str, default="fisherz" Conditional-independence test for the FCI phase. ``"fisherz"`` (Fisher's z) and ``"student_t"`` (Student-t) test partial correlation, for continuous data. ``"chisq"`` (Pearson's chi-square) and ``"gsq"`` (G-square, the likelihood ratio) test categorical data, coded by whole numbers, and ``"degen_gauss"`` and ``"cond_gauss"`` mixed data. ``"kci"``, ``"rcot"``, ``"cmiknn"``, and ``"gpdc"`` also detect nonlinear dependence in continuous data, at a far higher cost per test. Each as for :func:`~andrey.pc`. alpha : float, default=0.05 Significance level of the conditional-independence test. Smaller values give sparser graphs. collider_rule : {"sepsets", "majority"}, default="sepsets" How GFCI decides whether ``z`` is a collider on a triple ``x - z - y`` where the tests removed the GES edge between ``x`` and ``y``; on the other triples whose ends are not adjacent, the GES CPDAG decides (see Notes). ``"sepsets"`` makes it a collider when ``z`` is not in the recorded separating set of ``x`` and ``y``. ``"majority"`` tests ``x`` and ``y`` given every subset of ``x``'s neighbors and every subset of ``y``'s neighbors, and makes it a collider when fewer than half of the separating subsets (``p >= alpha``) contain ``z`` [3]_; at exactly half the triple stays unoriented. Majority runs ``2**degree(x) + 2**degree(y)`` tests per triple. categorical : sequence of str or int, optional The categorical columns of ``data``, for the mixed-data score and test: DataFrame column names or 0-based positions. ``None`` infers them: a DataFrame's ``category``, ``bool``, and text columns, and every column of at most ``max_categories`` distinct whole numbers, named in a ``DataTypeWarning``. ``[]`` marks none. The other settings reject it. See *Data types* in the :doc:`getting-started guide </docs/guides/getting-started>`. max_categories : int, default=10 The most distinct whole numbers a column may hold to be inferred as categorical, at least ``2``. With a continuous setting, columns within it get a one-time ``DataTypeWarning`` that they may be category codes. possible_dsep : {"order_free", "fast"} or None, default=None The order of GFCI's possible-d-separation step [2]_ [4]_, which removes edges the adjacency search kept, one at a time, so each removal changes what the step tests after it. ``"order_free"`` visits the pairs and nodes by a rank computed from each column's own data, so the result does not depend on the column order unless two columns are identical. ``"fast"`` visits them in column order. ``None`` follows ``andrey.config.tie_break``: ``"order_free"`` under ``"rank"``, ``"fast"`` under ``"position"``. See Notes and Limitations. max_depth : int or None, default=None The maximum conditioning-set size of every conditional-independence test, at least ``0``: ``0`` runs only unconditional tests, and ``None`` sets no limit. A smaller cap runs fewer tests and can keep edges that a larger conditioning set would remove. It caps the tests of the adjacency search, the separating sets for the pairs GES leaves non-adjacent, the possible-d-separation step, the majority rule, and the discriminating-path rule, but not the GES phase. knowledge : Knowledge or None, default=None Background knowledge, a :class:`~andrey.Knowledge`: required and forbidden edges, the pairs allowed to be adjacent, and temporal tiers, all honored. The GES phase honors it as :func:`~andrey.ges` does, and the test phase starts from the adjacencies GES found and never removes a required pair. In the PAG, a forbidden ``a -> b`` on an adjacent pair puts an arrowhead at ``a`` and can leave a circle at ``b``, and a required one is ``a -> b``. Returns ------- StructureOutput Wraps a ``pag`` :class:`~andrey.core.GraphStructure`; ``metadata["algorithm"]`` is ``"GFCI"``. Endpoints carry arrowheads, tails, or circle marks (``o``); a bidirected edge (``<->``) marks a probable latent common cause. Under a mixed-data setting, ``metadata["categorical"]`` lists the categorical columns it read, by name or 0-based position. Raises ------ NotImplementedError If ``score_func`` names no available score, or ``indep_test`` no available test. TypeError If ``categorical`` is not a list of column names and positions, or ``max_categories`` is not an int. Also if ``knowledge`` is neither ``None`` nor a :class:`~andrey.Knowledge`, or a given ``max_depth`` is not an int. ValueError If ``alpha`` is not in ``(0, 1)``, if ``lambda_value`` is negative or non-finite, if ``collider_rule`` or ``possible_dsep`` is not a supported choice, if ``score_func`` and ``indep_test`` read different data (continuous, categorical, or mixed), or if ``data`` or ``categorical`` does not suit them, as for :func:`~andrey.pc`. With the BIC score, also if a column repeats another up to scale and shift given more than two rows or, with more rows than columns, is a linear function of other columns. One message names every group of copies and says to keep one column per group. Also if a given ``max_depth`` is negative, or if ``knowledge`` names a column ``data`` lacks or contradicts itself, as :class:`~andrey.Knowledge` lists. Warns ----- DataTypeWarning When the mixed-data score and test infers the categorical columns, or finds none and so runs as the linear-Gaussian form, or when a continuous setting meets columns that look like category codes; each message warns once per process. Notes ----- GFCI first runs :func:`~andrey.ges` and keeps its CPDAG. FCI's adjacency search then starts from the CPDAG's adjacencies instead of a complete graph: it removes an edge ``x - y`` at the smallest size at which a subset of ``x``'s or ``y``'s neighbors makes the two independent, and records the union of those subsets as their separating set. On a triple ``x - z - y`` whose ends are not adjacent, ``z`` is a collider when the CPDAG has ``x -> z <- y``; when the CPDAG had the edge ``x - y``, the separating set decides, as ``collider_rule`` describes. FCI's possible-d-separation step can remove more edges, after which the colliders are decided again. No step adds an edge, so the PAG's adjacencies are a subset of the GES CPDAG's. Zhang's rules R1-R10 [2]_ then orient the endpoints, in rounds as in :func:`~andrey.fci`; an endpoint no rule resolves stays a circle. A rule that needs the separating set of a pair GES left non-adjacent uses the union of the smallest subsets of the two endpoints' CPDAG neighbors that make the pair independent, which does not depend on the column order. Under ``andrey.config(tie_break="position")``, the rules fire one at a time in column order, such a pair's separating set is the first subset found in column order, and ``possible_dsep`` defaults to ``"fast"``. Rule R5 turns a cycle of circle-circle edges into undirected edges only when the whole cycle, closing edge included, is uncovered, that is, no two nodes two steps apart on it are adjacent. It considers every circle path, up to a fixed search bound, and rule R8 checks each pair of endpoints in both orders. GFCI shares FCI's possible-d-separation step [2]_ [4]_, in the order ``possible_dsep`` selects, and its separating sets; the Notes of :func:`~andrey.fci` describe both and the reasons for keeping them. **Limitations.** Measured with ``possible_dsep="fast"`` on simulated linear-Gaussian data from random graphs with mean degree 2 or 4 and one variable in ten or one in five hidden, from 20 to 800 variables counting the hidden ones, with 10 or 50 rows per variable, on 4 cores. Its median endpoint errors, each mismatched tail, arrowhead, or circle, stayed below the empty graph's wherever its fits finished (197 against 444 at 200 variables with mean degree 2, one variable in ten hidden, and ten rows per variable). With those settings the median fit took 0.53 s at 100 variables, 2.6 s at 200, and 132 s at 800. Every fit at 800 variables finished within 7,200 s in four of the eight settings; with mean degree 4, one variable in five hidden, and 50 rows per variable, none did, and three of five fits at 400 ran past 3,600 s. The default, ``"order_free"``, took 1.00 to 1.02 times as long as ``"fast"`` up to 200 variables and 0.87 to 1.03 times at 400 and 800, and its median endpoint errors were within 2% of ``"fast"``'s. On some graphs from 400 variables its order needs more tests than the column order: on one with 400 variables, mean degree 2, one variable in five hidden, and 50 rows per variable, it took 407 s against 120 to 336 s for ``"fast"`` over five column orders. ``possible_dsep="fast"`` gives the column-order mode. It runs a full :func:`~andrey.ges` search before any test, so it takes at least as long as GES on the same data. Its BIC score and Fisher-Z tests assume linear relations with Gaussian noise. With fewer rows than columns the BIC score is unreliable. A parent set of ``n_samples - 1`` or more variables fits its child exactly, so the GES search never selects one, nor any parent set that leaves its child a residual variance of at most ``1e-12`` times its variance. Rule R5 has the search bound described under :func:`~andrey.fci`. With the mixed-data score and test, it is experimental and not benchmarked yet; its tests run it on 5 variables with 3,000 rows. References ---------- .. [1] Ogarrio, J. M., Spirtes, P., and Ramsey, J. (2016). A hybrid causal search algorithm for latent variable models. PGM, PMLR 52, 368-379. .. [2] Zhang, J. (2008). On the completeness of orientation rules for causal discovery in the presence of latent confounders and selection bias. Artificial Intelligence 172(16-17), 1873-1896. .. [3] Colombo, D., and Maathuis, M. H. (2014). Order-independent constraint-based causal structure learning. JMLR 15, 3921-3962. .. [4] Spirtes, P., Glymour, C., and Scheines, R. (2000). Causation, Prediction, and Search, 2nd ed. MIT Press. Examples -------- >>> import numpy as np >>> from andrey import gfci >>> rng = np.random.default_rng(0) >>> x = rng.standard_normal(500) >>> y = x + 0.1 * rng.standard_normal(500) >>> gfci(np.column_stack([x, y])).structure.kind 'pag' """ from andrey.constraint.pc import resolve_in_column_order, resolve_possible_dsep from andrey.search.gfci import gfci as _gfci # Both phases read the same data, so the score and the test must agree on its type. if data_kind(score_func) != data_kind(indep_test): raise ValueError( "gfci's score and test must read the data alike: score_func='local_score_BDeu' or " "'local_score_BIC_categorical' with indep_test 'chisq' or 'gsq' for categories, " "'local_score_degen_gauss' or 'local_score_cond_gauss' with 'degen_gauss' or " "'cond_gauss' for mixed data, or a BIC score with 'fisherz'; got " f"score_func={score_func!r}, indep_test={indep_test!r}" ) X, mask = checked_data( data, categorical, "indep_test", indep_test, max_categories=max_categories ) alpha = open_unit_interval(alpha, "alpha") labels = column_labels(data) constraints = resolve(knowledge, X.shape[1], labels, method="gfci") max_depth = None if max_depth is None else int_in_range(max_depth, "max_depth", 0) with copies_named(labels): pag = _gfci( X, score_func=score_func, lambda_value=lambda_value, alpha=alpha, indep_test=indep_test, collider_rule=collider_rule, categorical=mask, possible_dsep=resolve_possible_dsep(possible_dsep), max_depth=max_depth, constraints=constraints, in_column_order=resolve_in_column_order(), ) return structure_output( pag, labels=labels, metadata={"algorithm": "GFCI", **categorical_metadata(mask, data)} )
[docs] def cdnod( data: npt.ArrayLike, domain_index: npt.ArrayLike, *, alpha: float = 0.05, indep_test: str = "fisherz", collider_order: str | None = None, categorical: Sequence[str | int] | None = None, max_categories: int = MAX_CATEGORIES, dependence_threshold: float | None = None, max_depth: int | None = None, knowledge: Knowledge | None = None, ) -> StructureOutput: """Learn a partially directed graph over the data variables from multi-domain data with CDNOD. CDNOD [1]_ adds a domain/context index as an extra causal parent, so distribution shifts across regimes are modeled, then runs PC-style discovery. It applies to samples pooled across domains, regimes, or time blocks. The index is appended as a trailing pseudo-node and removed from the result. Parameters ---------- data : array-like of shape (n_samples, n_variables) Observed data, one row per sample. Coerced to ``float64``. A DataFrame's column names become the structure's ``labels``. domain_index : array-like of shape (n_samples,) or (n_samples, 1) Domain or context index of each row of ``data``, such as ``0`` and ``1`` for two domains, or the time step for data that drift over time. Coerced to ``float64``; whole numbers with ``"chisq"``, ``"gsq"``, ``"degen_gauss"``, or ``"cond_gauss"``. See Notes for more than two domains. When every row has the same value, the data come from one domain, and the result equals :func:`~andrey.pc`'s. alpha : float, default=0.05 Significance level of the conditional-independence test. Smaller values give sparser graphs. indep_test : str, default="fisherz" Conditional-independence test. ``"fisherz"`` (Fisher's z) and ``"student_t"`` (Student-t) test partial correlation, for continuous data. ``"chisq"`` (Pearson's chi-square) and ``"gsq"`` (G-square, the likelihood ratio) test categorical data, coded by whole numbers, and ``"degen_gauss"`` and ``"cond_gauss"`` mixed data. ``"kci"``, ``"rcot"``, ``"cmiknn"``, and ``"gpdc"`` also detect nonlinear dependence in continuous data, at a far higher cost per test. Each as for :func:`~andrey.pc`; see Notes for the index. collider_order : {"p_value", "column"} or None, default=None The order in which colliders are oriented, as for :func:`~andrey.pc`: ``"p_value"`` by the largest p-value that separated each collider's endpoints, so the colliders do not depend on the column order, and ``"column"`` in column order. ``None`` follows ``andrey.config.tie_break``, which also sets whether Meek's rules run in order-free rounds or fire one at a time in column order, as for :func:`~andrey.pc`. Under either order, the edges out of the index are directed first, and no collider puts an arrowhead at the index. categorical : sequence of str or int, optional The categorical columns of ``data``, for a mixed-data test: column labels (strings) or 0-based positions. ``None`` infers them: a DataFrame's ``category``, ``bool``, and text columns, and every column of at most ``max_categories`` distinct whole numbers, named in a ``DataTypeWarning``. ``[]`` marks none. The domain index is categorical too, so ``data`` may have no categorical column. The other tests take no column types and reject it. See *Data types* in the :doc:`getting-started guide </docs/guides/getting-started>`. max_categories : int, default=10 The most distinct whole numbers a column may hold to be inferred as categorical, at least ``2``. With a continuous test, columns within it get a one-time ``DataTypeWarning`` that they may be category codes. dependence_threshold : float or None, default=None Runs the independent-change step at this threshold, in ``(0, 1)``, on its module dependence measure: two modules, the distributions of a candidate cause and of its effect given it, whose measure is at most the threshold count as changing independently. ``None`` skips the step. See Notes. max_depth : int or None, default=None The maximum conditioning-set size of every conditional-independence test, at least ``0``: ``0`` runs only unconditional tests, and ``None`` sets no limit. A smaller cap runs fewer tests and can keep edges that a larger conditioning set would remove. knowledge : Knowledge or None, default=None Background knowledge, a :class:`~andrey.Knowledge`: required and forbidden edges, the pairs allowed to be adjacent, and temporal tiers, all honored. The adjacency search starts from the allowed pairs and never removes a required pair; the tests can remove any other allowed pair. A pair the knowledge makes non-adjacent is tested for a separating set, so colliders over it are oriented as usual; when the search finds none within ``max_depth``, no rule reads its unshielded triples as colliders or non-colliders. The result directs every edge the knowledge decides, then Meek's rules direct the edges that follow, as :class:`~andrey.Knowledge` describes; knowledge that orients the edges the search kept into a directed cycle raises ``ValueError``. The domain index is unconstrained. Returns ------- StructureOutput Wraps a :class:`~andrey.core.GraphStructure` over the ``d`` data variables; ``metadata["algorithm"]`` is ``"CDNOD"``. Directed marks (``->``) are oriented edges, undirected (``--``) the ones left open. The graph is partially directed, not the CPDAG of an observational equivalence class: the index orients edges that the true DAG's CPDAG leaves undirected, and the graph stands for the DAGs that agree on every direction CD-NOD can identify [1]_. Andrey has no kind for such a graph, so it is tagged ``cpdag``, and :mod:`andrey.metrics` scores a ``cpdag`` against the truth's CPDAG; score the result against the graph CD-NOD can identify instead. Under a mixed-data setting, ``metadata["categorical"]`` lists the categorical columns it read, by name or 0-based position. Raises ------ NotImplementedError If ``indep_test`` names no available test. TypeError If ``categorical`` is not a list of column names and positions, or ``max_categories`` is not an int. Also if ``knowledge`` is neither ``None`` nor a :class:`~andrey.Knowledge`, or a given ``max_depth`` is not an int. ValueError If ``domain_index`` does not hold one value per row of ``data`` or holds ``NaN`` / ``inf``, if ``data`` has too few rows for a test, if ``alpha`` or ``dependence_threshold`` is not in ``(0, 1)``, if ``data`` or ``categorical`` does not suit the test, as for :func:`~andrey.pc`, or, with a categorical or mixed-data test, if ``domain_index`` holds a value that is not a whole number. Also if ``collider_order`` is not ``"p_value"`` or ``"column"``. With ``"fisherz"`` or ``"student_t"``, also if a column of ``data``, or ``domain_index`` with more than one domain, repeats another up to scale and shift, before any test runs; one message names every group of copies and says to keep one column per group. Also if a given ``max_depth`` is negative, or if ``knowledge`` names a column ``data`` lacks or contradicts itself, as :class:`~andrey.Knowledge` lists. Warns ----- DataTypeWarning When a mixed-data test infers the categorical columns, or finds none and so runs as the linear-Gaussian form, or when a continuous setting meets columns that look like category codes; each message warns once per process. Notes ----- The index is added as a variable that can only be a cause. When a variable's distribution changes with the index, the edge from the index into it is directed, and Meek's rules can then orient edges that PC leaves undirected on the same data. ``"fisherz"`` and ``"student_t"`` test the index as a number, by linear correlation, so they find a change in a variable's mean that follows the index's values. With two domains the coding does not matter. With three domains coded ``0, 1, 2``, a shift in domain ``1`` alone can go undetected, and recoding the domains can change the result. A change in a variable's noise variance alone, or in the size of an effect whose cause has mean zero, leaves the mean unchanged, so the index keeps no edge to that variable. The paper uses a kernel conditional-independence test [1]_ [2]_ that detects such changes; Andrey has none. ``"chisq"``, ``"gsq"``, ``"degen_gauss"``, and ``"cond_gauss"`` take each index value as a category, so they find a shift in any domain, whatever the coding. With ``"degen_gauss"`` or ``"cond_gauss"``, the index is categorical, so every data column may be continuous. With ``dependence_threshold`` set, the independent-change step [1]_ orients undirected edges between two variables that both keep an edge to the index. For a candidate direction ``x -> y``, it measures how strongly the change of ``P(x, Z)`` across the index values and the change of ``P(y | x, Z)`` depend on each other, where ``Z`` is a set of neighbors that blocks their common causes. The measure is the normalized HSIC of the two modules' kernel embeddings at each row's index value, with the Gaussian kernel widths of [2]_. ``x -> y`` is oriented when that measure is at most the threshold and the measure for ``y -> x`` is above it; when both are above it, the edge stays undirected. An orientation that would close a directed cycle is skipped, so that edge stays undirected unless Meek's rules orient it. Meek's rules run before and after the step. The measure compares the index values as separate domains, so the step needs many domains, each with many rows: with two domains, the two changes always depend on each other, and nothing is oriented; a per-row index, such as a time step, makes every row its own domain, and the step cannot measure a change. The paper leaves the threshold open. For two modules that change independently, the measure sits near ``1 / (K - 1)`` over ``K`` domains, so the threshold has to rise as the number of domains falls; the 0.06 below is the value the measurements used, not a default. The step is off by default, because with ``"fisherz"`` or ``"student_t"`` only a variable whose mean follows the index reaches it, and such a shared trend makes the changes depend on each other. **Limitations.** Measured on simulated linear-Gaussian data from sparse random graphs (mean degree 2), with 2,000 rows pooled from four domains of equal size, in which one variable in three shifts its mean with the domain, from 10 to 50 variables with 20 datasets per size. Its median errors (SHD against the CPDAG the domain index makes identifiable) stayed below the empty graph's at every size (12 against 50 at 50 variables). The median fit took 0.18 s at 50 variables, on 4 cores. The independent-change step is not benchmarked yet; its tests run it on at most 3 variables with 600 rows from 20 domains. It runs the PC search with the index as one more variable, so it costs about as much as :func:`~andrey.pc` on the data plus one column. Its partial-correlation tests assume linear relations with Gaussian noise and find only a change in a variable's mean that is linear in the index: on two variables from two domains with 600 rows, a mean shift of the cause oriented the edge on 20 of 20 seeds, and a change of its variance alone, or of the weight of the effect on a mean-zero cause alone, on none. Without the independent-change step, an edge between two variables that both change with the index stays undirected unless the collider or Meek's rules orient it. With ``"fisherz"`` or ``"student_t"``, the step reaches only variables whose means follow the index. On two variables from 20 domains with 600 rows, both means rising linearly by about 1 across the domains, the cause's noise scale drawn per domain, and the effect's weight on it drawn per domain from (0.5, 2.5), at the threshold 0.06 it oriented the edge on 1 of 20 seeds. Given the true skeleton of ten complete three-variable DAGs from 20 domains with 600 rows, no mean trend, and weights in (-2, 2) drawn per domain, it oriented 18 of their 30 edges correctly and 4 against the truth. The paper fits the kernel widths, the index kernel, and the ridge of the step by Gaussian-process likelihood; Andrey fixes them. With a mixed-data test, it is experimental and not benchmarked yet; its tests run it on 5 variables with 3,000 rows. References ---------- .. [1] Huang, B., Zhang, K., Zhang, J., Ramsey, J., Sanchez-Romero, R., Glymour, C., and Scholkopf, B. (2020). Causal discovery from heterogeneous/nonstationary data. JMLR 21(89), 1-53. .. [2] Zhang, K., Peters, J., Janzing, D., and Scholkopf, B. (2011). Kernel-based conditional independence test and application in causal discovery. UAI, 804-813. Examples -------- >>> import numpy as np >>> from andrey import cdnod >>> rng = np.random.default_rng(0) >>> domain_index = np.repeat([0.0, 1.0], 300) >>> x = 2.0 * domain_index + rng.standard_normal(600) >>> y = x + 0.5 * rng.standard_normal(600) >>> cdnod(np.column_stack([x, y]), domain_index).structure.oriented_edges() [(0, 1, 'directed')] """ from andrey.constraint.cdnod import cdnod as _cdnod from andrey.constraint.pc import resolve_collider_order, resolve_in_column_order # A mixed-data test takes the index as categorical, so every data column may be continuous. X, mask = checked_data( data, categorical, "indep_test", indep_test, max_categories=max_categories, index_categorical=True, ) alpha = open_unit_interval(alpha, "alpha") labels = column_labels(data) constraints = resolve(knowledge, X.shape[1], labels, method="cdnod") max_depth = None if max_depth is None else int_in_range(max_depth, "max_depth", 0) c = np.asarray(domain_index, dtype=np.float64) if data_kind(indep_test) != "continuous": check_categories(c, None, f"indep_test={indep_test!r}", name="domain_index") if dependence_threshold is not None: dependence_threshold = open_unit_interval(dependence_threshold, "dependence_threshold") # The search appends the index as column d, so a copy of it is named as the index. d, column = X.shape[1], column_namer(labels) with copies_named(labels, lambda j: "domain_index" if j == d else column(j)): cpdag = _cdnod( X, c, alpha=alpha, indep_test=indep_test, collider_order=resolve_collider_order(collider_order), categorical=mask, dependence_threshold=dependence_threshold, max_depth=max_depth, constraints=constraints, in_column_order=resolve_in_column_order(), ) return structure_output( cpdag, labels=labels, metadata={"algorithm": "CDNOD", **categorical_metadata(mask, data)} )