statspai.causal_discovery¶
causal_discovery ¶
Causal Discovery: Learning causal structure from observational data.
Algorithms
-
NOTEARS : NO TEARS continuous optimisation for DAG learning (Zheng et al. 2018). Formulates structure learning as a smooth optimisation problem with an acyclicity constraint.
-
PC Algorithm : Constraint-based causal discovery using conditional independence tests (Spirtes, Glymour, Scheines 2000). Learns a CPDAG (completed partially directed acyclic graph).
References
Zheng, X., Aragam, B., Ravikumar, P., & Xing, E. P. (2018). DAGs with NO TEARS: Continuous Optimization for Structure Learning. Advances in Neural Information Processing Systems, 31. [@zheng2018dags]
Spirtes, P., Glymour, C., & Scheines, R. (2000). Causation, Prediction, and Search (2nd ed.). MIT Press. [@spirtes2000causation]
DYNOTEARSResult
dataclass
¶
Bases: ResultProtocolMixin
Output of :func:dynotears.
Examples:
>>> import statspai as sp
>>> import numpy as np
>>> import pandas as pd
>>> rng = np.random.default_rng(0)
>>> T = 60
>>> x = np.zeros(T); z = np.zeros(T); w = np.zeros(T)
>>> for t in range(1, T):
... x[t] = 0.6 * x[t - 1] + rng.normal(0, 0.3)
... z[t] = 0.5 * x[t - 1] + rng.normal(0, 0.3)
... w[t] = 0.4 * z[t] + rng.normal(0, 0.3)
>>> df = pd.DataFrame({"x": x, "z": z, "w": w})
>>> res = sp.dynotears(df, lag=1, threshold=0.1)
>>> res.variables
['x', 'z', 'w']
>>> res.lag
1
>>> edges = res.to_frame()
>>> bool(set(["lag", "from", "to", "coef"]).issubset(
... edges.columns
... )) if len(edges) else True
True
FCIResult
dataclass
¶
Bases: ResultProtocolMixin
Partial Ancestral Graph (PAG) learned by :func:fci.
Attributes:
| Name | Type | Description |
|---|---|---|
variables |
list of str
|
Variable names (graph nodes). |
skeleton |
DataFrame
|
Undirected adjacency matrix over |
pag_left, pag_right |
DataFrame
|
Edge marks on the i-side and j-side of each edge (i, j). |
edges |
list of tuple
|
Human-readable |
separating_sets |
dict
|
CI-test separating sets keyed by variable-name pairs. |
n_obs |
int
|
Number of complete observations used. |
alpha |
float
|
Significance level of the CI tests. |
ci_test |
str
|
Name of the conditional-independence test. |
n_removed_by_possible_dsep |
int
|
Edges of the PC skeleton that the Possible-D-SEP pass removed. |
Examples:
>>> import statspai as sp
>>> import numpy as np, pandas as pd
>>> rng = np.random.default_rng(0)
>>> n = 500
>>> x = rng.normal(size=n)
>>> m = x + rng.normal(size=n)
>>> y = m + rng.normal(size=n)
>>> data = pd.DataFrame({"X": x, "M": m, "Y": y})
>>> res = sp.fci(data)
>>> bool(res.skeleton.shape == (3, 3))
True
GESResult
dataclass
¶
Bases: ResultProtocolMixin
Result of :func:ges — a CPDAG (Markov equivalence class).
Attributes:
| Name | Type | Description |
|---|---|---|
adjacency |
ndarray
|
|
names |
list of str
|
Variable names, aligned with |
bic |
float
|
Total BIC of the recovered graph (lower is better). |
Examples:
>>> import statspai as sp
>>> import numpy as np, pandas as pd
>>> rng = np.random.default_rng(0)
>>> x = rng.normal(size=200)
>>> y = 2.0 * x + rng.normal(size=200)
>>> df = pd.DataFrame({"x": x, "y": y})
>>> res = sp.ges(df)
>>> isinstance(res, sp.GESResult)
True
>>> bool(res.to_frame().shape == (2, 2))
True
ICPResult
dataclass
¶
Bases: ResultProtocolMixin
Result of an Invariant Causal Prediction run (:func:icp).
Attributes:
| Name | Type | Description |
|---|---|---|
parents |
set of str
|
Provably-causal parents of |
accepted_subsets |
list of frozenset
|
All candidate subsets that passed the level-alpha invariance test. |
rejection_reason |
dict
|
Maps each rejected subset to a human-readable reason string. |
alpha |
float
|
Level at which each subset's invariance test was run. |
coefficients |
dict
|
|
method |
str
|
|
Examples:
>>> import statspai as sp
>>> import numpy as np
>>> import pandas as pd
>>> rng = np.random.default_rng(0)
>>> n = 400
>>> env = np.r_[np.zeros(n // 2, dtype=int), np.ones(n // 2, dtype=int)]
>>> x1 = 3.0 * env + rng.normal(size=n)
>>> y = 1.5 * x1 + rng.normal(size=n)
>>> x2 = y + rng.normal(size=n)
>>> X = pd.DataFrame({"X1": x1, "X2": x2})
>>> res = sp.icp(X, y, env)
>>> isinstance(res, sp.ICPResult)
True
>>> sorted(res.parents)
['X1']
IDAResult
dataclass
¶
Bases: ResultProtocolMixin
Possible effects of x on y across a Markov equivalence class.
Examples:
>>> import pandas as pd
>>> import statspai as sp
>>> cov = pd.DataFrame([[1.0, 0.5], [0.5, 1.0]], index=["a", "b"],
... columns=["a", "b"])
>>> cp = pd.DataFrame([[0, 1], [1, 0]], index=["a", "b"], columns=["a", "b"])
>>> r = sp.ida(cov, cp, x="a", y="b")
>>> [round(e, 2) for e in r.effects] # a -> b, or b -> a (no effect)
[0.5, 0.0]
min_abs_effect
property
¶
The smallest absolute effect: a lower bound on the causal effect's size that holds whichever DAG of the class is true.
LiNGAMResult
dataclass
¶
Bases: ResultProtocolMixin
Result of a :func:lingam (DirectLiNGAM) fit.
Attributes:
| Name | Type | Description |
|---|---|---|
order |
list of int
|
Causal order, most exogenous variable first (column indices). |
adjacency |
ndarray
|
|
names |
list of str
|
Variable names, aligned with the columns of |
residuals |
ndarray
|
|
Examples:
>>> import statspai as sp
>>> import numpy as np, pandas as pd
>>> rng = np.random.default_rng(1)
>>> n = 400
>>> x0 = rng.uniform(-1, 1, n) ** 3
>>> x1 = 1.5 * x0 + rng.exponential(0.4, n) - 0.4
>>> df = pd.DataFrame({"x0": x0, "x1": x1})
>>> res = sp.lingam(df)
>>> isinstance(res, sp.LiNGAMResult)
True
>>> res.to_frame().shape # adjacency as a labelled DataFrame
(2, 2)
>>> bool(len(res.edges(threshold=0.5)) >= 1)
True
LPCMCIResult
dataclass
¶
Bases: ResultProtocolMixin
Output of :func:lpcmci.
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(31)
>>> T = 200
>>> X = np.zeros(T); Y = np.zeros(T); Z = np.zeros(T)
>>> for t in range(1, T):
... X[t] = 0.5 * X[t - 1] + rng.normal(0, 0.5)
... Y[t] = 0.4 * X[t - 1] + 0.3 * Y[t - 1] + rng.normal(0, 0.5)
... Z[t] = 0.6 * Y[t - 1] + rng.normal(0, 0.5)
>>> df = pd.DataFrame({"X": X, "Y": Y, "Z": Z})
>>> res = sp.lpcmci(df, variables=["X", "Y", "Z"], tau_max=2, alpha=0.05)
>>> isinstance(res, sp.LPCMCIResult)
True
>>> res.edge_types[1, 0, 1] # X --> Y at lag 1
'-->'
>>> list(res.to_frame().columns)
['lag', 'from', 'to', 'type', 'p_value']
NOTEARS ¶
NOTEARS: Continuous optimization for DAG structure learning.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
|
required |
variables
|
list of str
|
|
None
|
lambda1
|
float
|
|
0.1
|
max_iter
|
int
|
|
100
|
h_tol
|
float
|
|
1e-08
|
rho_max
|
float
|
|
1e+16
|
w_threshold
|
float
|
|
0.3
|
random_state
|
int
|
|
42
|
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> from statspai.causal_discovery.notears import NOTEARS
>>> rng = np.random.default_rng(0)
>>> n = 500
>>> X = rng.normal(size=n)
>>> Z = 0.8 * X + rng.normal(size=n) * 0.5
>>> M = 0.7 * Z + rng.normal(size=n) * 0.5
>>> Y = 0.6 * M + rng.normal(size=n) * 0.5
>>> df = pd.DataFrame({'X': X, 'Z': Z, 'M': M, 'Y': Y})
>>> est = NOTEARS(data=df, variables=['X', 'Z', 'M', 'Y'])
>>> result = est.fit()
>>> bool(result['n_edges'] >= 0)
True
References
[@zheng2018dags]
PCAlgorithm ¶
PC Algorithm for causal discovery.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
|
required |
variables
|
list of str
|
|
None
|
alpha
|
float
|
|
0.05
|
max_cond_size
|
int
|
|
None
|
ci_test
|
str
|
|
'fisherz'
|
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> from statspai.causal_discovery.pc import PCAlgorithm
>>> rng = np.random.default_rng(0)
>>> n = 500
>>> X = rng.normal(size=n)
>>> Z = 0.8 * X + rng.normal(size=n) * 0.5
>>> M = 0.7 * Z + rng.normal(size=n) * 0.5
>>> Y = 0.6 * M + rng.normal(size=n) * 0.5
>>> df = pd.DataFrame({'X': X, 'Z': Z, 'M': M, 'Y': Y})
>>> est = PCAlgorithm(data=df, variables=['X', 'Z', 'M', 'Y'])
>>> result = est.fit()
>>> bool(result['n_edges'] >= 0)
True
References
[@spirtes2000causation]
PCMCIResult
dataclass
¶
Bases: ResultProtocolMixin
PCMCI output — lag-specific adjacency + discovered links.
Returned by :func:pcmci. Bundles the lag-specific p-value tensor
(p_matrix), the partial-correlation strengths (val_matrix),
the boolean adjacency decision tensor, and the effective sample
size. Call :meth:discovered_links for a tidy DataFrame of the
significant lagged links and :meth:summary for a one-screen report.
Examples:
A two-variable system where lagged GDP drives inflation:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(0)
>>> T = 120
>>> gdp = np.zeros(T)
>>> inflation = np.zeros(T)
>>> eg = rng.normal(0, 1, T)
>>> ei = rng.normal(0, 1, T)
>>> for t in range(1, T):
... gdp[t] = 0.5 * gdp[t - 1] + eg[t]
... inflation[t] = 0.4 * gdp[t - 1] + 0.3 * inflation[t - 1] + ei[t]
>>> df = pd.DataFrame({"gdp": gdp, "inflation": inflation})
>>> res = sp.pcmci(df, tau_max=2, pc_alpha=0.05)
>>> isinstance(res, sp.PCMCIResult)
True
>>> links = res.discovered_links()
>>> list(links.columns)
['source', 'target', 'lag', 'partial_corr', 'p_value']
>>> bool(((links["source"] == "gdp") &
... (links["target"] == "inflation")).any())
True
discovered_links ¶
Return a DataFrame of significant links sorted by strength.
edge_list ¶
edge_list(adjacency: ndarray, names: Sequence[str], threshold: float = 0.0, directed: bool = True) -> List[Tuple[str, str, float]]
Extract a sorted [(parent, child, weight), ...] list.
Edges with |w| ≤ threshold are dropped. Output is sorted by
descending |weight| for stable display.
plot_dag ¶
plot_dag(adjacency: ndarray, names: Sequence[str], *, directed: bool = True, threshold: float = 0.0, layout: str = 'circular', ax: Optional[Any] = None, edge_labels: bool = False, title: Optional[str] = None, figsize: Tuple[float, float] = (6.0, 6.0), node_color: str = '#e8f0fe', pos_edge_color: str = '#1f77b4', neg_edge_color: str = '#d62728', digits: int = 2) -> tuple[Any, Any]
Draw the DAG with Matplotlib + NetworkX.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
adjacency
|
(k, k) ndarray
|
|
required |
names
|
sequence of str
|
|
required |
directed
|
bool
|
|
True
|
threshold
|
float
|
Drop edges with |
0.0
|
layout
|
('circular', 'spring', 'kamada_kawai', 'shell', 'graphviz')
|
|
"circular"
|
ax
|
matplotlib Axes
|
|
None
|
edge_labels
|
bool
|
Annotate edges with the weight (rounded to |
False
|
title
|
str
|
|
None
|
figsize
|
(w, h)
|
|
(6.0, 6.0)
|
node_color
|
str
|
|
'#e8f0fe'
|
pos_edge_color
|
str
|
|
'#e8f0fe'
|
neg_edge_color
|
str
|
|
'#e8f0fe'
|
digits
|
int
|
|
2
|
Returns:
| Type | Description |
|---|---|
(fig, ax)
|
|
shd ¶
Structural Hamming Distance between two adjacency matrices.
Counts the number of edge insertions / deletions / reversals required
to transform :math:\hat A into the true DAG. Both inputs are
binarised at |·| > threshold first.
Reference: Tsamardinos, Brown, Aliferis (2006). "The max-min hill-climbing Bayesian network structure learning algorithm." Machine Learning 65(1): 31-78. DOI: 10.1007/s10994-006-6889-7.
to_dot ¶
to_dot(adjacency: ndarray, names: Sequence[str], directed: bool = True, threshold: float = 0.0, title: Optional[str] = None, digits: int = 2) -> str
Render a Graphviz DOT-format string for the DAG.
Edge labels are weights rounded to digits; positive edges are
drawn solid, negative edges dashed (matches the bnlearn convention).
to_networkx ¶
to_networkx(adjacency: ndarray, names: Sequence[str], directed: bool = True, threshold: float = 0.0) -> Any
Build a :class:networkx.DiGraph (or :class:networkx.Graph) from
an adjacency matrix. Edge weight equals the matrix entry.
Requires the optional networkx dependency.
bootstrap_edges ¶
bootstrap_edges(data: DataFrame, method: Any = 'pc', *, n_boot: int = 200, seed: Optional[int] = None, threshold: float = 0.5, **kwargs: Any) -> DataFrame
How often each edge survives a nonparametric bootstrap.
The algorithm is refitted on n_boot resamples of the rows (drawn
with replacement, same size). For each pair of variables the result
gives the share of resamples in which they are adjacent and, among
those, the share in which the arc points each way.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
|
required |
method
|
('pc', 'hill_climb', 'mmhc', 'ges', 'fci', 'lingam', 'notears')
|
The structure-learning algorithm. A callable receives a DataFrame
(and |
'pc'
|
n_boot
|
int
|
|
200
|
seed
|
int
|
|
None
|
threshold
|
float
|
Edges with |
0.5
|
**kwargs
|
Any
|
Passed to the algorithm, e.g. |
{}
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
One row per ordered pair that was adjacent at least once: |
Notes
This is bnlearn::boot.strength: strength and direction are
its two columns. Strength near one says the adjacency is robust to
sampling variation; it says nothing about whether the assumptions of
the algorithm (no hidden common causes, for pc and hill_climb)
hold. A direction near one half means the data do not orient the edge.
Examples:
>>> import numpy as np, pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(0)
>>> n = 600
>>> a = rng.normal(size=n)
>>> b = rng.normal(size=n)
>>> c = a + b + 0.5 * rng.normal(size=n)
>>> df = pd.DataFrame({"a": a, "b": b, "c": c})
>>> out = sp.bootstrap_edges(df, "pc", n_boot=30, seed=1)
>>> row = out[(out["from"] == "a") & (out["to"] == "c")].iloc[0]
>>> bool(row["strength"] > 0.9 and row["direction"] > 0.5)
True
nonlinear_icp ¶
nonlinear_icp(X: DataFrame | ndarray, y: ndarray, environment: ndarray, alpha: float = 0.05, **kw: Any) -> ICPResult
Alias for icp(..., method='nonlinear') -- Heinze-Deml et al. 2018.
The nonlinear variant swaps the linear mean/variance invariance test for a two-sample Kolmogorov-Smirnov test on the residual distributions, so it detects departures from invariance beyond the first two moments.
Examples:
>>> import statspai as sp
>>> import numpy as np
>>> import pandas as pd
>>> rng = np.random.default_rng(0)
>>> n = 400
>>> env = np.r_[np.zeros(n // 2, dtype=int), np.ones(n // 2, dtype=int)]
>>> x1 = 3.0 * env + rng.normal(size=n)
>>> y = 1.5 * x1 + rng.normal(size=n)
>>> x2 = y + rng.normal(size=n)
>>> X = pd.DataFrame({"X1": x1, "X2": x2})
>>> res = sp.nonlinear_icp(X, y, env, alpha=0.05)
>>> sorted(res.parents)
['X1']
mmpc ¶
mmpc(data: DataFrame, variables: Optional[Sequence[str]] = None, *, method: str = 'mmpc', data_type: str = 'auto', alpha: float = 0.05, max_cond: Optional[int] = None) -> Dict[str, Any]
Parents and children of every variable, by local constraint-based search.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
|
required |
variables
|
sequence of str
|
Columns to use; all of them by default. |
None
|
method
|
('mmpc', 'hiton')
|
|
'mmpc'
|
data_type
|
('auto', 'discrete', 'gaussian')
|
As in :func: |
'auto'
|
alpha
|
float
|
Level of the conditional independence tests. |
0.05
|
max_cond
|
int
|
Largest conditioning set. Unlimited by default; a cap bounds the cost when some variable has many neighbours, and keeps the tables of a discrete test from thinning out. |
None
|
Returns:
| Type | Description |
|---|---|
dict
|
|
Notes
The result is an undirected skeleton: which variables are adjacent,
not which way the arrows point. Under faithfulness and with no hidden
common causes it converges to the skeleton of the true graph. In a
finite sample each test can err; a non-empty asymmetric list is a
sign that some did.
On 24 simulated data sets (five to eight variables, Gaussian and
categorical, 500 to 3,000 rows) both methods return the skeleton of
bnlearn::mmpc and si.hiton.pc every time. That is not
guaranteed in general: the algorithms are the published ones, written
independently, and which subsets get tested depends on the order in
which candidates are admitted and ties are broken.
Examples:
>>> import numpy as np, pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(0)
>>> n = 2000
>>> a = rng.normal(size=n)
>>> b = a + rng.normal(size=n)
>>> c = b + rng.normal(size=n) # a -> b -> c
>>> out = sp.mmpc(pd.DataFrame({"a": a, "b": b, "c": c}))
>>> out["edges"]
[('a', 'b'), ('b', 'c')]
References
[@tsamardinos2006maxmin], [@aliferis2010local]
pc_algorithm ¶
pc_algorithm(data: DataFrame, variables: Optional[List[str]] = None, alpha: float = 0.05, max_cond_size: Optional[int] = None, ci_test: str = 'fisherz', forbidden: Optional[List[Tuple[str, str]]] = None, required: Optional[List[Tuple[str, str]]] = None, collider_conflict: str = 'first') -> Dict[str, Any]
Learn causal structure using the PC algorithm.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Observational data (n_samples x d_variables). |
required |
variables
|
list of str
|
Column names to use. If None: all numeric columns under
|
None
|
alpha
|
float
|
Significance level for conditional independence tests. Lower alpha = sparser graph (fewer edges). |
0.05
|
max_cond_size
|
int
|
Maximum conditioning set size. If None, goes up to d-2. |
None
|
ci_test
|
(fisherz, chi - square, g - test)
|
Conditional independence test.
|
'fisherz'
|
forbidden
|
list of (str, str)
|
Background knowledge: edges that must NOT appear in the final
graph (treated as undirected — both |
None
|
required
|
list of (str, str)
|
Background knowledge: directed edges |
None
|
collider_conflict
|
(first, last)
|
Which collider decides when two of them claim one edge in opposite
directions, which a finite sample can produce and a faithful
distribution cannot. |
'first'
|
Returns:
| Type | Description |
|---|---|
dict
|
'skeleton' : pd.DataFrame
Undirected adjacency matrix (0/1).
'cpdag' : pd.DataFrame
CPDAG adjacency matrix. cpdag[i,j] = 1 means i -> j.
If both cpdag[i,j] = 1 and cpdag[j,i] = 1, the edge
is undirected (i -- j).
'edges' : list of tuples
Directed edges as (parent, child) tuples.
'undirected_edges' : list of tuples
Undirected edges as (node1, node2) tuples.
'separating_sets' : dict
{(i, j): set} of separating sets for removed edges.
'variables' : list of str
'n_edges' : int
'n_obs' : int
'alpha' : float
'ci_test' : str
'orientation_conflicts' : list of tuples
Colliders |
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(0)
>>> n = 500
>>> X = rng.normal(size=n)
>>> Z = 0.8 * X + rng.normal(size=n) * 0.5
>>> M = 0.7 * Z + rng.normal(size=n) * 0.5
>>> Y = 0.6 * M + rng.normal(size=n) * 0.5
>>> df = pd.DataFrame({'X': X, 'Z': Z, 'M': M, 'Y': Y})
>>> result = sp.pc_algorithm(df, variables=['X', 'Z', 'M', 'Y'])
>>> bool(result['n_edges'] >= 0) # CPDAG edge count
True
partial_corr_pvalue ¶
Partial-correlation p-value for H0: X ⟂ Y | Z.
Residualises X and Y on Z via OLS, then applies a Fisher-z transform to the residual correlation with df = n - |Z| - 2.
Examples:
Two variables sharing a common driver z are marginally
correlated but conditionally independent given z:
>>> import numpy as np
>>> import statspai as sp
>>> rng = np.random.default_rng(0)
>>> z = rng.normal(size=200)
>>> x = z + rng.normal(size=200)
>>> y = z + rng.normal(size=200)
>>> bool(sp.partial_corr_pvalue(x, y) < 0.05) # marginally dependent
True
>>> bool(sp.partial_corr_pvalue(x, y, z) > 0.05) # independent given z
True