Source code for andrey.api.temporal

"""Temporal-family facades: VARMA-LiNGAM, Longitudinal LiNGAM, PCMCI, PCMCI+, LPCMCI, and Granger.

Each callable runs the native engine and returns one uniform ``StructureOutput`` wrapping a
:class:`~andrey.core.TemporalStructure` (a per-lag stack of graphs, plus the lossless coefficient
weights for the LiNGAM and Granger methods). The mapping onto the public core contract lives in
the private ``_temporal`` adapters.
"""

from __future__ import annotations

from types import SimpleNamespace
from typing import TYPE_CHECKING

import numpy as np

from ._adapt import (
    column_labels,
    column_namer,
    copies_named,
    data_matrix,
    int_in_range,
    open_unit_interval,
    shared_labels,
    with_labels,
)
from ._temporal import (
    _adapt_granger,
    _adapt_longitudinal_lingam,
    _adapt_lpcmci,
    _adapt_pcmci,
    _adapt_pcmciplus,
    _adapt_varma_lingam,
)

if TYPE_CHECKING:
    from collections.abc import Sequence

    import numpy.typing as npt

    from andrey.core import StructureOutput


[docs] def varma_lingam( data: npt.ArrayLike, *, order: tuple[int, int] = (1, 1), criterion: str | None = None, prune: bool = False, structural_ma: bool = False, max_iter: int = 100, ) -> StructureOutput: """Learn a temporal causal structure from one multivariate time series with VARMA-LiNGAM. VARMA-LiNGAM [1]_ fits a vector autoregressive moving-average model, then applies LiNGAM to the residuals to fix a causal order over the instantaneous (contemporaneous) effects [2]_. Use it when a single multivariate series carries both same-step and lagged causal influence. Parameters ---------- data : array-like of shape (n_samples, n_variables) A multivariate time series, one row per time step. Coerced to ``float64``. A DataFrame's column names become the structure's ``labels``. order : tuple of int, default=(1, 1) The ``(p, q)`` autoregressive and moving-average lag counts, each at least ``0`` and not both ``0``. criterion : {"aic", "bic", "hqic"} or None, default=None Order-selection criterion. When set, ``(p, q)`` is chosen by that information criterion over ``0 <= p <= order[0]`` and ``0 <= q <= order[1]``, excluding ``(0, 0)``; ``None`` uses ``order`` directly. prune : bool, default=False Re-estimate the weights on the causal order: adaptive Lasso sets small weights to zero and least squares refits the rest. With ``False`` every lagged weight is nonzero, so every lag graph links every pair of variables, self-loops included. structural_ma : bool, default=False How the moving-average weights are expressed: ``True`` maps them onto the independent noise terms, ``(I - B0) Theta_j (I - B0)^-1`` [1]_; ``False`` uses ``(I - B0) Theta_j``. ``B0`` is the lag-0 weight matrix and ``Theta_j`` the fitted moving-average coefficients. Ignored when ``prune=True``. max_iter : int, default=100 Most iterations of the likelihood fit, at least ``1``. With ``criterion`` set, every fit in the grid gets this limit. Returns ------- StructureOutput Wraps a ``temporal`` :class:`~andrey.core.TemporalStructure`. ``structure.lag_weights[k][i, j]`` is the autoregressive weight of edge ``i -> j`` at lag ``k``, lag 0 being instantaneous; ``structure.lag_weights_ma[k]`` holds the moving-average weights at lag ``k + 1``, in the same orientation. The lag graphs follow the autoregressive weights. ``ordering`` is the instantaneous causal order, and ``metadata["algorithm"]`` is ``"varmalingam"``. Raises ------ TypeError If ``order`` is not a pair of ints, or ``max_iter`` is not an int. ValueError If ``criterion`` is not one of ``"aic"``, ``"bic"``, ``"hqic"``, or ``None``, if ``order`` holds a negative count or is ``(0, 0)``, if ``max_iter`` is below 1, or if ``data`` has fewer than 2 columns. Notes ----- DirectLiNGAM orders the innovations of the fitted model. Each pairwise regression in its ordering takes the least-squares slope, so every residual it scores is uncorrelated with its regressor in the sample. With ``prune=True`` each variable is regressed on its causal predecessors at the same step, the lagged values of every variable, and the lagged independent noise terms. Adaptive Lasso selects the regressors, and an ordinary least-squares fit with an intercept on the selected ones gives their weights, so the kept weights are not shrunk toward zero. The noise terms come from a lag-0 matrix refit the same way. The regressions start at time step ``max(p, q)``: the first steps serve only as lagged values. No lagged value wraps around from the end of the series, so no step is regressed on a later one. **Column order.** The fit runs on the columns sorted by a rank computed from each column's own data, as for :func:`~andrey.hc`, so reordering the columns relabels the result and changes nothing else, unless two columns are identical. **Limitations.** Measured on simulated linear series with non-Gaussian noise, one autoregressive and one moving-average lag, from 3 to 20 variables with 100 to 1,000 time steps, with ``order=(1, 1)`` and ``prune=True``. Its median errors (missing and extra cross-links) stayed below the empty graph's from 300 time steps at every size; at 100 time steps they exceeded it from 10 variables on. The fit takes minutes at 20 variables and 1,000 time steps. It assumes linear effects and non-Gaussian noise. The likelihood fit can stop before it converges, and gives no warning; a larger ``max_iter`` does not prevent this. The result then depends on the versions of the numerical libraries, so the same series can give a different graph in another environment. With ``criterion`` set, it fits one model for every ``(p, q)`` in the grid. References ---------- .. [1] Kawahara, Y., Shimizu, S., and Washio, T. (2011). Analyzing relationships among ARMA processes based on non-Gaussianity of external influences. Neurocomputing 74(12-13), 2212-2221. .. [2] Hyvarinen, A., Zhang, K., Shimizu, S., and Hoyer, P. O. (2010). Estimation of a structural vector autoregression model using non-Gaussianity. JMLR 11, 1709-1731. Examples -------- >>> import numpy as np >>> from andrey import varma_lingam >>> rng = np.random.default_rng(0) >>> x = rng.uniform(-1, 1, 300) >>> y = np.r_[0.0, 0.8 * x[:-1]] + rng.uniform(-1, 1, 300) >>> out = varma_lingam(np.column_stack([x, y]), order=(1, 1), prune=True) >>> out.structure.lag(1).oriented_edges() [(0, 1, 'directed')] """ from andrey.temporal.varma import varma_lingam as _native X = data_matrix(data) if X.shape[1] < 2: raise ValueError(f"data needs at least 2 columns (variables), got {X.shape[1]}") if not isinstance(order, (tuple, list)) or len(order) != 2: raise TypeError(f"order must be a (p, q) pair of ints, got {order!r}") order = (int_in_range(order[0], "order[0]", 0), int_in_range(order[1], "order[1]", 0)) if order == (0, 0): raise ValueError("order must have p or q above 0, got (0, 0)") max_iter = int_in_range(max_iter, "max_iter", 1) labels = column_labels(data) causal_order, psis, omegas = _native( X, order=order, criterion=criterion, prune=prune, structural_ma=structural_ma, max_iter=max_iter, ) model = SimpleNamespace(adjacency_matrices_=(psis, omegas), causal_order_=causal_order) return with_labels(_adapt_varma_lingam(model), labels)
[docs] def longitudinal_lingam( data_list: Sequence[npt.ArrayLike], *, n_lags: int = 1, measure: str = "pwling" ) -> StructureOutput: """Learn a temporal causal structure from panel data with Longitudinal LiNGAM. Longitudinal LiNGAM [1]_ handles the same units measured at several time points: at each occasion it regresses out the past ``n_lags`` time points, then applies DirectLiNGAM to orient the instantaneous effects. Use it for repeated-measures / panel designs rather than one long series. Parameters ---------- data_list : sequence of array-like, each of shape (n_samples, n_variables) One dataset per time point, over the same units and identical shape. Each is coerced to ``float64``. DataFrames must name their columns alike; the names become the structure's ``labels``. n_lags : int, default=1 Number of past time points regressed out before the instantaneous fit, from ``1`` to the number of time points minus one. measure : {"pwling"}, default="pwling" DirectLiNGAM pairwise independence measure for the instantaneous fit. Returns ------- StructureOutput Wraps a ``temporal`` :class:`~andrey.core.TemporalStructure`. The first time point has no past, so it is not estimated. ``structure.times`` lists the estimated time points, ``1`` to ``T - 1``; ``structure.time_weights[t][k][i, j]`` is the weight of edge ``i -> j`` at lag ``k`` for the t-th of them (lag 0 is instantaneous), and ``structure.lag_weights`` repeats the last time point's. A lag that reaches before the first time point is ``NaN``. Lagged weights are not pruned, so every lagged graph links every pair of variables, self-loops included; judge lagged effects by weight size. ``metadata["causal_orders"]`` holds one causal order per time point, the first one empty; they need not agree, so ``ordering`` is ``None``. ``metadata["algorithm"]`` is ``"longitudinallingam"``. Raises ------ NotImplementedError If ``measure`` is not ``"pwling"``. TypeError If ``n_lags`` is not an int. ValueError If ``data_list`` has fewer than two time points, if ``n_lags`` is outside its range, if the per-time arrays differ in shape, or if the DataFrames' column names differ. Notes ----- At each time point DirectLiNGAM orders the residuals of the lag regressions. Each pairwise regression in its ordering takes the least-squares slope, so every residual it scores is uncorrelated with its regressor in the sample, and listing every unit twice leaves the causal orders unchanged. **Column order.** The fit runs on the variables sorted by a rank computed from each column's own data over all time points, as for :func:`~andrey.hc`, so reordering the columns relabels the result and changes nothing else, unless two columns are identical. **Limitations.** Measured on simulated panels with non-Gaussian noise, a dense same-step DAG and a dense lag-1 matrix drawn anew at every time point, and a hidden common cause at the first, from 2 to 5 variables at 20 or 50 time points with 100 to 1,000 units, with ``n_lags=1``. Its median errors (missing and extra cross-links) stayed far below the empty graph's at every size. It assumes linear effects and non-Gaussian noise. Each time point's fit uses only the units' rows at that time, so its accuracy depends on the number of units, not on the number of time points. References ---------- .. [1] Kadowaki, K., Shimizu, S., and Washio, T. (2013). Estimation of causal structures in longitudinal data using non-Gaussianity. IEEE International Workshop on Machine Learning for Signal Processing (MLSP), 1-6. Examples -------- >>> import numpy as np >>> from andrey import longitudinal_lingam >>> rng = np.random.default_rng(0) >>> data_list = [rng.uniform(-1, 1, (200, 3)) for _ in range(4)] >>> out = longitudinal_lingam(data_list, n_lags=1) >>> out.structure.type 'temporal' """ from andrey.temporal.longitudinal import LongitudinalLiNGAM panel = [data_matrix(X, name=f"data_list[{t}]") for t, X in enumerate(data_list)] if len(panel) < 2: raise ValueError(f"data_list must hold at least two time points, got {len(panel)}") n_lags = int_in_range(n_lags, "n_lags", 1, len(panel) - 1) labels = shared_labels(column_labels(X) for X in data_list) model = LongitudinalLiNGAM(n_lags=n_lags, measure=measure).fit(panel) return with_labels(_adapt_longitudinal_lingam(model), labels)
[docs] def pcmci( data: npt.ArrayLike, *, tau_max: int = 1, pc_alpha: float = 0.2, alpha_level: float = 0.05, indep_test: str = "student_t", ) -> StructureOutput: """Learn the lagged causal links of one multivariate time series with PCMCI. PCMCI [1]_ works in two steps. The PC1 step selects, for each variable, a few candidate parents among the lagged values of all variables. The momentary conditional independence (MCI) step then tests each lagged link given the selected parents of both its ends. Conditioning on the cause's own past removes the autocorrelation that makes a plain lagged-correlation test report false links. Use it on one series whose causal effects take at least one time step. Parameters ---------- data : array-like of shape (n_samples, n_variables) A multivariate time series, one row per time step. Coerced to ``float64``. A DataFrame's column names become the structure's ``labels``. tau_max : int, default=1 The largest lag tested, at least ``1``: links at lags ``1`` to ``tau_max`` are tested. pc_alpha : float, default=0.2 Significance level of the PC1 step, in ``(0, 1)``: a candidate parent is dropped when a test gives it a p-value above ``pc_alpha``. A larger value keeps more parents as conditions for the MCI step. alpha_level : float, default=0.05 Significance level of the MCI step, in ``(0, 1)``: a link is in the graph when its p-value is at most ``alpha_level``. indep_test : {"student_t", "fisherz"}, default="student_t" The partial-correlation test behind every p-value. Both report the same partial correlation; ``"student_t"`` takes its p-value from the Student-t distribution and ``"fisherz"`` from Fisher's z-transform. Returns ------- StructureOutput Wraps a ``temporal`` :class:`~andrey.core.TemporalStructure` with lags ``1`` to ``tau_max`` and no lag 0. ``structure.lag(k)`` is a ``digraph`` whose edge ``i -> j`` is the link from variable ``i`` at time ``t - k`` to variable ``j`` at time ``t``; a self-loop is a variable's dependence on its own past. The structure has no ``lag_weights``. ``metadata["p_values"][k - 1][i][j]`` and ``metadata["statistics"][k - 1][i][j]`` hold the MCI p-value and partial correlation of that link, for every pair, significant or not. ``metadata["parents"][j]`` lists the ``[i, k]`` pairs (variable ``i`` at lag ``k``) that PC1 selected for variable ``j``, strongest first. ``ordering`` is ``None``, and ``metadata["algorithm"]`` is ``"pcmci"``. Raises ------ TypeError If ``tau_max`` is not an int. ValueError If ``tau_max`` is below 1, if ``pc_alpha`` or ``alpha_level`` is not in ``(0, 1)``, if ``indep_test`` is neither ``"student_t"`` nor ``"fisherz"``, or if ``data`` is not a usable 2-D matrix with at least ``2 * tau_max * (n_variables + 1) + 3`` rows, the length at which every test keeps a degree of freedom. Also if a variable repeats another up to scale and shift, at the same step or up to ``2 * tau_max`` steps apart, before any test runs; one message names every group of copies, a lagged value as ``'x' at lag k``, and says to keep one column per group. Notes ----- Every test is a partial-correlation test. The default, ``"student_t"``, takes the p-value from the Student-t distribution with ``n - |S| - 2`` degrees of freedom, for ``|S|`` conditions, as the authors' reference implementation does. ``"fisherz"`` uses Fisher's z-transform instead; its p-values differ slightly, so a link close to ``alpha_level`` can flip. The partial correlation is the correlation of the least-squares residuals of the two tested values given the conditions. It is computed from the correlation matrix, and from the residuals themselves when the conditions are collinear to working precision, as lagged copies of a strongly autocorrelated series can be. All tests use the same time steps, the last ``n_samples - 2 * tau_max``, where every lag a test can condition on exists: the MCI test of a link at lag ``k`` conditions on the cause's parents, which reach back ``k + tau_max`` steps. PC1 runs in rounds ``p = 0, 1, ...``: each round tests every remaining candidate given the ``p`` strongest other candidates, one conditioning set per candidate, then drops those that test independent. A candidate's strength is the smallest absolute partial correlation it has had so far. Equal strengths are ordered by each variable's rank, as in :func:`~andrey.hc`; the :doc:`column-order guide </docs/guides/column-order>` says what the rank keeps. **Limitations.** Measured on simulated linear-Gaussian series with an autodependency in every variable and one cross-link per variable at lag 1 or 2, from 10 to 160 variables with 500 to 2,000 time steps, with ``tau_max=2``. It found nearly every true link at every size, but its false links grew with the number of variables: its median errors (lag-resolved SHD) stayed below the empty graph's at 10 variables, came close to it at 20, and exceeded it from 40 on. It assumes linear effects, a stationary series, and no hidden common causes. It does not test same-step (lag 0) links: an effect within one time step is missed and can show up as a false lagged link. An MCI test conditions on up to ``2 * n_variables * tau_max - 1`` lagged values, so the series needs many more time steps than that. The MCI step alone runs ``n_variables ** 2 * tau_max`` tests. References ---------- .. [1] Runge, J., Nowack, P., Kretschmer, M., Flaxman, S., and Sejdinovic, D. (2019). Detecting and quantifying causal associations in large nonlinear time series datasets. Science Advances 5(11), eaau4996. Examples -------- >>> import numpy as np >>> from andrey import pcmci >>> rng = np.random.default_rng(0) >>> x = rng.standard_normal(500) >>> y = np.r_[0.0, 0.8 * x[:-1]] + rng.standard_normal(500) >>> out = pcmci(np.column_stack([x, y]), tau_max=2, alpha_level=0.01) >>> out.structure.lag(1).oriented_edges() [(0, 1, 'directed')] >>> out.structure.lag(2).oriented_edges() [] """ from andrey.temporal.pcmci import design_column_name from andrey.temporal.pcmci import pcmci as _native tau_max = int_in_range(tau_max, "tau_max", 1) pc_alpha = open_unit_interval(pc_alpha, "pc_alpha") alpha_level = open_unit_interval(alpha_level, "alpha_level") X = data_matrix(data) # An MCI test conditions on up to 2 * n * tau_max - 1 lagged columns of the last # n_samples - 2 * tau_max steps; this keeps a degree of freedom in every test. min_rows = 2 * tau_max * (X.shape[1] + 1) + 3 if X.shape[0] < min_rows: raise ValueError( f"data needs at least {min_rows} rows (time steps) for tau_max={tau_max} over " f"{X.shape[1]} variables, got {X.shape[0]}" ) labels = column_labels(data) with copies_named(labels, design_column_name(X.shape[1], column_namer(labels))): result = _native( X, tau_max=tau_max, pc_alpha=pc_alpha, alpha_level=alpha_level, indep_test=indep_test ) return with_labels(_adapt_pcmci(result), labels)
[docs] def pcmciplus( data: npt.ArrayLike, *, tau_max: int = 1, pc_alpha: float = 0.01, collider_rule: str = "majority", indep_test: str = "student_t", ) -> StructureOutput: """Learn the lagged and same-step causal links of one multivariate time series with PCMCI+. PCMCI+ [1]_ extends PCMCI to links within one time step. It first selects, for each variable, a few candidate parents among the lagged values of all variables. It then tests every lagged candidate and every pair of variables at the same step, given subsets of the same-step neighbors plus the selected lagged parents of both ends, and orients the same-step links with the collider and orientation rules of the PC algorithm. Lagged links always point forward in time. Use it on one series whose causal effects can act within a time step. Parameters ---------- data : array-like of shape (n_samples, n_variables) A multivariate time series, one row per time step. Coerced to ``float64``. A DataFrame's column names become the structure's ``labels``. tau_max : int, default=1 The largest lag tested, at least ``1``: links at lags ``0`` to ``tau_max`` are tested. pc_alpha : float, default=0.01 Significance level of every test, in ``(0, 1)``: a link is removed when a test gives it a p-value above ``pc_alpha``. collider_rule : {"majority", "conservative", "none"}, default="majority" How an unshielded triple ``a - b - c`` with a same-step link ``b - c`` is judged a collider ``a -> b <- c``. ``"majority"`` and ``"conservative"`` test ``a`` and ``c`` again given every subset of their same-step neighbors and look at the subsets that separate them: ``"majority"`` calls the triple a collider when fewer than half hold ``b``, and ``"conservative"`` when none does; a triple neither rule can decide is ambiguous and orients nothing. ``"none"`` calls it a collider when ``b`` is not in the one separating set the skeleton search found. indep_test : {"student_t", "fisherz"}, default="student_t" The partial-correlation test behind every p-value. Both report the same partial correlation; ``"student_t"`` takes its p-value from the Student-t distribution and ``"fisherz"`` from Fisher's z-transform. Returns ------- StructureOutput Wraps a ``temporal`` :class:`~andrey.core.TemporalStructure` with lags ``0`` to ``tau_max``. ``structure.lag(0)`` is a ``cpdag`` of the same-step links: ``i -> j`` is oriented, and ``i -- j`` is a link whose direction the data cannot tell or whose orientations conflict; ``metadata["conflicts"]`` lists the latter as ``[i, j]`` pairs, ``i < j``. ``structure.lag(k)``, ``k >= 1``, is a ``digraph`` whose edge ``i -> j`` is the link from variable ``i`` at time ``t - k`` to variable ``j`` at time ``t``; a self-loop is a variable's dependence on its own past. The structure has no ``lag_weights``. ``metadata["p_values"][k][i][j]`` is the largest p-value among the skeleton-phase tests of the link between variable ``i`` at ``t - k`` and variable ``j`` at ``t`` (the first step's tests for a lagged link that step removed), and ``metadata["statistics"][k][i][j]`` the partial correlation of that test; lag ``0`` is symmetric with ``1`` and ``0`` on the diagonal. ``metadata["parents"][j]`` lists the ``[i, k]`` pairs (variable ``i`` at lag ``k``) the first step selected for variable ``j``, strongest first, and ``metadata["ambiguous_triples"]`` the ``[i, k, m, j]`` triples (variable ``i`` at lag ``k``, then ``m`` and ``j`` at lag ``0``) the collider rule left undecided. ``ordering`` is ``None``, and ``metadata["algorithm"]`` is ``"pcmciplus"``. Raises ------ TypeError If ``tau_max`` is not an int. ValueError If ``tau_max`` is below 1, if ``pc_alpha`` is not in ``(0, 1)``, if ``collider_rule`` or ``indep_test`` is not one of its choices, or if ``data`` is not a usable 2-D matrix with at least ``2 * tau_max * (n_variables + 1) + n_variables + 1`` rows, the length at which every test keeps a degree of freedom. Also if a variable repeats another up to scale and shift, at the same step or up to ``2 * tau_max`` steps apart, before any test runs; one message names every group of copies, a lagged value as ``'x' at lag k``, and says to keep one column per group. Notes ----- The method runs in four phases. The lagged phase selects the lagged parents of each variable as PCMCI's PC1 step does, with equal strengths ordered by each variable's rank. The skeleton phase starts from those lagged links and from a link between every two variables at the same step. Round ``p = 0, 1, ...`` tests each linked pair given each subset of ``p`` same-step neighbors of the later end, with the lagged parents of both ends added, and removes the link at the first p-value above ``pc_alpha``. A same-step pair is tested from both ends, first given the neighbors of its higher-numbered variable, and the neighbor sets stay fixed during a round, so the links found do not depend on the order of the variables. The collider phase orients the unshielded triples by ``collider_rule``, and the rule phase applies Meek's rules R1 to R3 to the same-step links until none applies. Each collider or rule pass collects its orientations before applying them, and marks a link that gets both orientations as a conflict. With the ``"majority"`` or ``"conservative"`` rule the orientations do not depend on the order of the variables either; ``"none"`` reads the one separating set the skeleton phase stored, which can change with the order. All tests use the same time steps, the last ``n_samples - 2 * tau_max``. The default test, ``"student_t"``, takes the p-value from the Student-t distribution with ``n - |S| - 2`` degrees of freedom, for ``|S|`` conditions. The partial correlation is the correlation of the least-squares residuals of the two tested values given the conditions. It is computed from the correlation matrix, and from the residuals themselves when the conditions are collinear to working precision, as lagged copies of a strongly autocorrelated series can be. **Limitations.** Measured on simulated linear-Gaussian series with strong autocorrelation and 1.5 cross-links per variable, 30% of them within a time step and the rest at lags 1 to 5, from 10 to 80 variables with 500 to 2,000 time steps, with ``tau_max=5``. Its median errors (endpoint SHD) stayed well below the empty graph's at every size. It assumes linear effects, a stationary series, and no hidden common causes. The skeleton and collider phases test every subset of a variable's same-step neighbors, so the number of tests grows exponentially with the number of same-step links at one variable. A test conditions on up to ``2 * n_variables * tau_max + n_variables - 2`` values, so the series needs many more time steps than that. References ---------- .. [1] Runge, J. (2020). Discovering contemporaneous and lagged causal relations in autocorrelated nonlinear time series datasets. Proceedings of the 36th Conference on Uncertainty in Artificial Intelligence (UAI), PMLR 124, 1388-1397. Examples -------- >>> import numpy as np >>> from andrey import pcmciplus >>> rng = np.random.default_rng(0) >>> x = rng.standard_normal(500) >>> y = 0.8 * x + rng.standard_normal(500) >>> z = np.r_[0.0, 0.8 * y[:-1]] + rng.standard_normal(500) >>> out = pcmciplus(np.column_stack([x, y, z])) >>> out.structure.lag(0).oriented_edges() [(0, 1, 'undirected')] >>> out.structure.lag(1).oriented_edges() [(1, 2, 'directed')] """ from andrey.temporal.pcmci import design_column_name from andrey.temporal.pcmciplus import pcmciplus as _native tau_max = int_in_range(tau_max, "tau_max", 1) pc_alpha = open_unit_interval(pc_alpha, "pc_alpha") X = data_matrix(data) # A test conditions on up to 2 * n * tau_max + n - 2 columns of the last # n_samples - 2 * tau_max steps; this keeps a degree of freedom in every test. min_rows = 2 * tau_max * (X.shape[1] + 1) + X.shape[1] + 1 if X.shape[0] < min_rows: raise ValueError( f"data needs at least {min_rows} rows (time steps) for tau_max={tau_max} over " f"{X.shape[1]} variables, got {X.shape[0]}" ) labels = column_labels(data) with copies_named(labels, design_column_name(X.shape[1], column_namer(labels))): result = _native( X, tau_max=tau_max, pc_alpha=pc_alpha, collider_rule=collider_rule, indep_test=indep_test, ) return with_labels(_adapt_pcmciplus(result), labels)
[docs] def lpcmci( data: npt.ArrayLike, *, tau_max: int = 1, pc_alpha: float = 0.05, n_preliminary_iterations: int = 1, indep_test: str = "student_t", ) -> StructureOutput: """Learn a time-series partial ancestral graph that allows hidden common causes with LPCMCI. LPCMCI [1]_ finds the lagged and same-step links of one multivariate time series when some variables that drive several observed ones are not measured. Like FCI it returns a partial ancestral graph: an arrowhead says an end is not a cause of the other, a tail that it is, and a circle leaves it open, so a link with arrowheads at both ends is a hidden common cause. It orients links while it removes them and conditions every test on the parents found so far, which keeps true links in strongly autocorrelated series. Use it on one series when an unmeasured variable may confound the measured ones. Parameters ---------- data : array-like of shape (n_samples, n_variables) A multivariate time series, one row per time step. Coerced to ``float64``. A DataFrame's column names become the structure's ``labels``. tau_max : int, default=1 The largest lag, at least ``1``: the graph covers the time steps ``t - tau_max`` to ``t``, and links at lags ``0`` to ``tau_max`` are tested. pc_alpha : float, default=0.05 Significance level of every test, in ``(0, 1)``: a link is removed when a test gives it a p-value above ``pc_alpha``. n_preliminary_iterations : int, default=1 How many preliminary rounds run before the final one, at least ``0``. Each round learns parents that the next one conditions on from its first test. indep_test : {"student_t", "fisherz"}, default="student_t" The partial-correlation test behind every p-value. Both report the same partial correlation; ``"student_t"`` takes its p-value from the Student-t distribution and ``"fisherz"`` from Fisher's z-transform. Returns ------- StructureOutput Wraps a ``temporal`` :class:`~andrey.core.TemporalStructure` with lags ``0`` to ``tau_max``. ``structure.lag(0)`` is a ``pag`` of the same-step links: ``i -> j``, ``i o-> j``, ``i <-> j`` (a hidden common cause), and ``i o-o j``. ``structure.lag(k)``, ``k >= 1``, is a ``digraph`` of the lagged links whose earlier end is a tail, the definite causes: its edge ``i -> j`` says variable ``i`` at time ``t - k`` causes variable ``j`` at time ``t``, and a self-loop is a variable's effect on its own future. The structure has no ``lag_weights``. ``metadata["links"][k][i][j]`` holds every link between variable ``i`` at ``t - k`` and variable ``j`` at ``t`` as three characters, ``""`` when there is none: the mark at ``i`` (``-`` tail, ``<`` arrowhead, ``o`` circle, ``x`` conflict), ``-``, and the mark at ``j`` (``>``, ``-``, ``o``, or ``x``), such as ``"o->"``, ``"<->"``, or ``"-->"``; lag ``0`` lists both orders. ``metadata["conflicts"]`` lists the ``[i, k, j]`` links with a conflict mark, which ``structure.lag(0)`` shows as a circle and ``structure.lag(k)`` leaves out. ``metadata["p_values"][k][i][j]`` is the largest p-value of the final round's tests of that pair, and ``metadata["statistics"][k][i][j]`` the partial correlation of that test; lag ``0`` is symmetric with ``1`` and ``0`` on the diagonal. ``metadata["parents"][j]`` lists the ``[i, k]`` pairs (variable ``i`` at lag ``k``) the last preliminary round found to be parents of variable ``j``. ``ordering`` is ``None``, and ``metadata["algorithm"]`` is ``"lpcmci"``. Raises ------ TypeError If ``tau_max`` or ``n_preliminary_iterations`` is not an int. ValueError If ``tau_max`` is below 1, ``n_preliminary_iterations`` below 0, ``pc_alpha`` not in ``(0, 1)``, ``indep_test`` neither ``"student_t"`` nor ``"fisherz"``, or if ``data`` is not a usable 2-D matrix with at least ``n_variables * (tau_max + 1) + 2 * tau_max + 1`` rows, the length at which every test keeps a degree of freedom. Also if a variable repeats another up to scale and shift, at the same step or up to ``2 * tau_max`` steps apart, before any test runs; one message names every group of copies, a lagged value as ``'x' at lag k``, and says to keep one column per group. Notes ----- The graph starts complete, with every lagged link ``o->`` (time order rules out the later end as a cause of the earlier one) and every same-step link ``o-o``. A round removes links in steps ``p = 0, 1, ...``: it tests a link given ``p`` of the neighbors of its later end that are not known non-causes of it, plus the parents of both ends, and tests the autodependency links first, then the cross links lag by lag. A same-step link is also tested from its other end. After each step that removes a link, orientation rules mark new tails and arrowheads on the lagged links and the round restarts at ``p = 0``, as in the paper, so that the separating sets found later stay weakly minimal. In these passes the rules R9' and R10' orient lagged links only, but their paths may run through same-step links, directed or not, as the paper's rules allow. Once nothing is left to test, the full set of rules runs, including the collider rule, which first tests the two links of a triple again given each stored separating set of the outer pair plus the parents of their ends. A preliminary round keeps only the parents it found and starts over from the complete graph. The final round also tests the links that may join two variables neither of which causes the other, given subsets of a wider neighborhood. Whether a node lies outside the separating set of two others is a majority vote over the distinct separating sets stored and found among the neighbors of the two. A mark that rules orient both ways is a conflict. The candidates of a step are ordered by statistics frozen when its group of links starts, so the order in which a group's links are tested changes nothing. Equal statistics are ordered by each variable's rank, as in :func:`~andrey.hc`; the :doc:`column-order guide </docs/guides/column-order>` says what the rank keeps. The collider rule stores every separating set its tests find, each made weakly minimal by dropping first the node whose removal leaves the largest p-value, so the stored sets do not depend on the order of the variables. All tests use the same time steps, the last ``n_samples - 2 * tau_max``, and every node a test conditions on lies within ``tau_max`` steps of the later end. A test's partial correlation is computed from the correlation matrix, and from the least-squares residuals when the conditions are collinear to working precision. The defaults are the authors' published defaults. The earlier end of a lagged link is searched with the empty set only, since time order already rules out the later end as its cause. **Limitations.** Measured on simulated linear-Gaussian series with strong autocorrelation, one cross-link per variable, 30% of them within a time step and the rest at lags 1 to 3, and 30% of the series unobserved, from 5 to 20 variables with 500 to 2,000 time steps, with ``tau_max=3``. Its median errors (endpoint SHD against the true PAG of the observed series) stayed below the empty graph's at every size. It assumes linear effects, a stationary series, and no selection bias; hidden common causes are allowed. The graph covers ``tau_max + 1`` time steps, and a hidden cause that acts through earlier steps can add links within that window. The removal and rule steps test subsets of a node's neighbors, so the number of tests grows exponentially with the number of links at one node. A test conditions on up to ``n_variables * (tau_max + 1) - 2`` values, so the series needs many more time steps than that. References ---------- .. [1] Gerhardus, A., and Runge, J. (2020). High-recall causal discovery for autocorrelated time series with latent confounders. Advances in Neural Information Processing Systems 33 (NeurIPS 2020), 12615-12625. Examples -------- >>> import numpy as np >>> from andrey import lpcmci >>> rng = np.random.default_rng(0) >>> hidden = rng.standard_normal(1000) >>> x, y = np.zeros(1000), np.zeros(1000) >>> for t in range(1, 1000): ... x[t] = 0.5 * x[t - 1] + 0.7 * hidden[t] + rng.standard_normal() ... y[t] = 0.6 * y[t - 1] + 0.6 * hidden[t] + rng.standard_normal() >>> out = lpcmci(np.column_stack([x, y]), tau_max=2) >>> out.metadata["links"][0][0][1] '<->' >>> out.structure.lag(1).oriented_edges() [(0, 0, 'directed'), (1, 1, 'directed')] """ from andrey.temporal.lpcmci import lpcmci as _native from andrey.temporal.pcmci import design_column_name tau_max = int_in_range(tau_max, "tau_max", 1) n_preliminary_iterations = int_in_range(n_preliminary_iterations, "n_preliminary_iterations", 0) pc_alpha = open_unit_interval(pc_alpha, "pc_alpha") X = data_matrix(data) # A test conditions on up to n * (tau_max + 1) - 2 nodes of the window, over the last # n_samples - 2 * tau_max steps; this keeps a degree of freedom in every test. min_rows = X.shape[1] * (tau_max + 1) + 2 * tau_max + 1 if X.shape[0] < min_rows: raise ValueError( f"data needs at least {min_rows} rows (time steps) for tau_max={tau_max} over " f"{X.shape[1]} variables, got {X.shape[0]}" ) labels = column_labels(data) with copies_named(labels, design_column_name(X.shape[1], column_namer(labels))): result = _native( X, tau_max=tau_max, pc_alpha=pc_alpha, n_preliminary_iterations=n_preliminary_iterations, indep_test=indep_test, ) return with_labels(_adapt_lpcmci(result), labels)
[docs] def granger(data: npt.ArrayLike, *, maxlag: int = 2, cv: int = 5) -> StructureOutput: """Learn the lagged causal graph of a multivariate time series by lasso Granger causality. A variable Granger-causes another when its past improves the prediction of the other's present [1]_. Each variable is regressed on the past ``maxlag`` steps of every variable by the lasso [2]_, with the penalty chosen by cross-validation; a nonzero coefficient is a lagged edge. Use it for one multivariate series whose effects take at least one time step. Parameters ---------- data : array-like of shape (n_samples, n_variables) A multivariate time series, one row per time step. Coerced to ``float64``. A DataFrame's column names become the structure's ``labels``. maxlag : int, default=2 Number of past time steps each regression uses, at least 1. cv : int, default=5 Number of cross-validation folds that choose each regression's penalty, at least 2. Returns ------- StructureOutput Wraps a ``temporal`` :class:`~andrey.core.TemporalStructure` with lags ``1`` to ``maxlag`` and no lag 0. ``structure.lag(k)`` is a ``digraph`` with the edge ``i -> j`` when ``x_i(t - k)`` has a nonzero coefficient in the regression of ``x_j(t)``, self-loops included; ``structure.lag_weights[k - 1][i, j]`` is that coefficient, in the data's units. ``ordering`` is ``None``, and ``metadata["algorithm"]`` is ``"granger"``. Raises ------ TypeError If ``maxlag`` or ``cv`` is not an int. ValueError If ``maxlag`` is less than 1, if ``cv`` is less than 2, if ``data`` has fewer than ``maxlag + cv`` rows, so that some fold would hold no time step, or if ``data`` has fewer than 2 columns. Notes ----- An edge says that the past of one variable helps predict another beyond the past of every variable in ``data``. Granger causality misses effects within one time step, and a common cause left out of ``data`` can produce edges. Each regression is fitted on standardized variables, so the edges do not depend on the units of any variable; the coefficients are converted back to the data's units. Cross-validation chooses the penalty that predicts best, which often keeps small coefficients on lags with no effect: judge the edges by the size of their weights. The folds are consecutive blocks of time steps, and the fit is deterministic. With ``andrey.config(num_workers=k)``, the regressions run on ``k`` threads and return the same coefficients. The penalty is the plain lasso of Arnold et al. [2]_. Shojaie and Michailidis [3]_ propose a truncating lasso penalty that also selects the lag order; it is not implemented here. **Limitations.** Measured on simulated linear-Gaussian VAR(2) series with no same-step links, standardized, from 10 to 160 variables with 1,000 time steps, with ``maxlag=2``. It found most true lagged edges but returned many false ones, so its median errors (missing and extra lagged edges) exceeded the empty graph's at every size: on half the series at 10 variables and on all of them from 20 on. It assumes linear effects and tests no same-step effects. Each variable's regression takes ``maxlag`` lags of every variable as inputs, so the work grows with ``maxlag`` and with the square of the number of variables. References ---------- .. [1] Granger, C. W. J. (1969). Investigating causal relations by econometric models and cross-spectral methods. Econometrica 37(3), 424-438. .. [2] Arnold, A., Liu, Y., and Abe, N. (2007). Temporal causal modeling with graphical Granger methods. KDD, 66-75. .. [3] Shojaie, A., and Michailidis, G. (2010). Discovering graphical Granger causality using the truncating lasso penalty. Bioinformatics 26(18), i517-i523. Examples -------- >>> import numpy as np >>> from andrey import granger >>> rng = np.random.default_rng(0) >>> x = rng.standard_normal(500) >>> y = np.r_[0.0, 0.8 * x[:-1]] + rng.standard_normal(500) >>> out = granger(np.column_stack([x, y]), maxlag=1) >>> round(float(out.structure.lag_weights[0][0, 1]), 1) 0.8 """ from andrey.temporal.granger import granger_lasso maxlag = int_in_range(maxlag, "maxlag", 1) cv = int_in_range(cv, "cv", 2) X = data_matrix(data, min_rows=maxlag + cv) # LassoCV needs a time step in every fold if X.shape[1] < 2: raise ValueError(f"data needs at least 2 columns (variables), got {X.shape[1]}") peak = np.abs(X).max(axis=0) # divide first, so tiny or huge units cannot underflow the std spread = (X / peak).std(axis=0) # data_matrix rejects a constant column coeff = granger_lasso((X / peak - (X / peak).mean(axis=0)) / spread, maxlag=maxlag, cv=cv) scale = spread * peak # coeff[i, j + d*k] weights standardized x_j(t-k-1) in standardized x_i(t): rescale to units. # Units far apart can overflow the ratio; a zero weight stays zero rather than becoming NaN. with np.errstate(over="ignore", invalid="ignore"): ratio = scale[:, None] / np.tile(scale, maxlag)[None, :] coeff = np.where(coeff == 0, 0.0, coeff * ratio) return with_labels(_adapt_granger(coeff, X.shape[1], maxlag), column_labels(data))