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]
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]
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
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
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. |
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
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
|
Family-wise significance level used. |
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']
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.
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']
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
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) -> 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, uses all numeric columns. |
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
|
str
|
Conditional independence test: 'fisherz' (partial correlation) or 'hsic' (kernel-based, non-linear). |
'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
|
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 |
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
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']
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
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.
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).
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)
|
|
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.
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.