statspai.dag¶
dag ¶
DAG (Directed Acyclic Graph) module for causal reasoning.
Declare causal graphs, compute adjustment sets, check for collider bias,
enumerate paths, detect bad controls, and visualize causal structures —
the Python equivalent of R's dagitty and ggdag.
import statspai as sp g = sp.dag('X -> Y; Z -> X; Z -> Y') g.adjustment_sets('X', 'Y') [{'Z'}] g.backdoor_paths('X', 'Y') [['X', 'Z', 'Y']] g.bad_controls('X', 'Y') {} print(g.summary('X', 'Y').splitlines()[0]) DAG Summary: effect of X on Y g.do('X') # interventional graph DAG(3 nodes, 2 edges) sp.dag_example('discrimination') # classic textbook DAG DAG(4 nodes, 5 edges)
BayesNet ¶
A fitted or hand-specified discrete causal Bayesian network.
Build one with :func:statspai.bayes_net.
Attributes:
| Name | Type | Description |
|---|---|---|
dag |
DAG
|
The graph. |
states |
dict
|
|
nodes |
list of str
|
Nodes in a topological order. |
unseen |
dict
|
|
Examples:
>>> import statspai as sp
>>> net = sp.bayes_net(
... "C -> X; C -> Y; X -> Y",
... cpts={
... "C": {"bear": 0.5, "bull": 0.5},
... "X": {"bear": {"debt": 0.8, "equity": 0.2},
... "bull": {"debt": 0.2, "equity": 0.8}},
... "Y": {("bear", "debt"): {"fail": 0.3, "success": 0.7},
... ("bull", "debt"): {"fail": 0.9, "success": 0.1},
... ("bear", "equity"): {"fail": 0.7, "success": 0.3},
... ("bull", "equity"): {"fail": 0.6, "success": 0.4}},
... },
... )
>>> round(net.prob({"Y": "success"}, evidence={"X": "debt"}), 4)
0.58
>>> round(net.prob({"Y": "success"}, do={"X": "debt"}), 4)
0.4
parents ¶
Parents of node, in the order of its table's leading axes.
cpt ¶
The conditional probability table of node.
Returns:
| Type | Description |
|---|---|
DataFrame
|
One column per state of node; one row per configuration of
its parents (a single row labelled |
Examples:
summary ¶
Nodes, their states and parents, and the size of the tables.
Examples:
do ¶
The network after an ideal intervention.
Each intervened node loses its parents and takes its value with probability one; every other kernel is unchanged.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
interventions
|
mapping
|
|
None
|
Returns:
| Type | Description |
|---|---|
BayesNet
|
|
Examples:
>>> import statspai as sp
>>> net = sp.bayes_net("Z -> X; Z -> Y; X -> Y", cpts={
... "Z": {0: 0.5, 1: 0.5},
... "X": {0: {0: 0.9, 1: 0.1}, 1: {0: 0.1, 1: 0.9}},
... "Y": {(0, 0): {0: 0.9, 1: 0.1}, (0, 1): {0: 0.5, 1: 0.5},
... (1, 0): {0: 0.5, 1: 0.5}, (1, 1): {0: 0.1, 1: 0.9}}})
>>> net.do(X=1).parents("X")
()
query ¶
query(variables: Union[str, Sequence[str]], evidence: Optional[Mapping[str, Any]] = None, do: Optional[Mapping[str, Any]] = None) -> DataFrame
The joint distribution of variables given evidence, under an intervention if one is named.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
variables
|
str or sequence of str
|
|
required |
evidence
|
mapping
|
|
None
|
do
|
mapping
|
|
None
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
One row per combination of states, with a |
Examples:
>>> import statspai as sp
>>> net = sp.bayes_net("Z -> X; Z -> Y; X -> Y", cpts={
... "Z": {0: 0.5, 1: 0.5},
... "X": {0: {0: 0.9, 1: 0.1}, 1: {0: 0.1, 1: 0.9}},
... "Y": {(0, 0): {0: 0.9, 1: 0.1}, (0, 1): {0: 0.5, 1: 0.5},
... (1, 0): {0: 0.5, 1: 0.5}, (1, 1): {0: 0.1, 1: 0.9}}})
>>> seen = net.query("Y", evidence={"X": 1})
>>> done = net.query("Y", do={"X": 1})
>>> round(seen.loc[seen.Y == 1, "prob"].item(), 2) # association
0.86
>>> round(done.loc[done.Y == 1, "prob"].item(), 2) # effect
0.7
prob ¶
expectation ¶
expectation(variable: str, evidence: Optional[Mapping[str, Any]] = None, do: Optional[Mapping[str, Any]] = None, values: Optional[Mapping[Any, float]] = None) -> float
Expected value of a node.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
variable
|
str
|
|
required |
evidence
|
mapping
|
As in :meth: |
None
|
do
|
mapping
|
As in :meth: |
None
|
values
|
mapping
|
|
None
|
Examples:
>>> import statspai as sp
>>> net = sp.bayes_net("X -> Y", cpts={
... "X": {"debt": 0.5, "equity": 0.5},
... "Y": {"debt": {"fail": 0.6, "success": 0.4},
... "equity": {"fail": 0.66, "success": 0.34}}})
>>> payoff = {"fail": -1000, "success": 99000}
>>> round(net.expectation("Y", do={"X": "debt"}, values=payoff))
39000
counterfactual ¶
counterfactual(variables: Union[str, Sequence[str]], evidence: Mapping[str, Any], do: Mapping[str, Any]) -> DataFrame
P(variables_{do} | evidence): what the variables would have
been under the intervention, for units on which the evidence was
observed without it.
Abduction, action and prediction are done in one pass on a twin network: the descendants of the intervened nodes are duplicated, the copies get the intervention, and both worlds share every other node and so the same roots.
This is defined only when every node that is not a root is a deterministic function of its parents, so that the network is a structural causal model with its exogenous variables as roots. Conditional probability tables alone do not fix a counterfactual: two structural models can imply the same tables and disagree about it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
variables
|
str or sequence of str
|
|
required |
evidence
|
mapping
|
What was observed in the factual world. |
required |
do
|
mapping
|
The hypothetical intervention. |
required |
Returns:
| Type | Description |
|---|---|
DataFrame
|
As :meth: |
Examples:
Monty Hall as a structural model. For a player who stayed and lost, switching would certainly have won:
>>> import statspai as sp
>>> doors = [1, 2, 3]
>>> def host(car, first, coin):
... free = [d for d in doors if d not in (car, first)]
... return free[0] if len(free) == 1 or coin == "tails" else free[1]
>>> def second(first, host, strategy):
... if strategy == "stay":
... return first
... return next(d for d in doors if d not in (first, host))
>>> net = sp.bayes_net(
... "car -> host; first -> host; coin -> host; first -> second; "
... "host -> second; strategy -> second; second -> win; car -> win",
... cpts={"car": {d: 1 / 3 for d in doors},
... "first": {d: 1 / 3 for d in doors},
... "coin": {"tails": 0.5, "heads": 0.5},
... "strategy": {"stay": 0.5, "switch": 0.5},
... "host": host, "second": second,
... "win": lambda second, car: second == car})
>>> cf = net.counterfactual("win", evidence={"strategy": "stay", "win": False},
... do={"strategy": "switch"})
>>> float(cf.loc[cf.win == True, "prob"].item())
1.0
simulate ¶
Draw n rows from the network, or from the network under an intervention.
Examples:
SCM
dataclass
¶
Structural Causal Model.
Each node has:
- parents: iterable of parent node names
- equation: callable(parents_dict, noise) -> value
- noise: callable() -> float (a draw from the exogenous noise
distribution; defaults to standard normal)
Examples:
>>> import statspai as sp
>>> scm = sp.SCM()
>>> scm.add(
... "X", [], lambda pa, u: u, lambda rng: rng.normal()
... )
SCM(...)
>>> scm.add(
... "Y", ["X"], lambda pa, u: 2 * pa["X"] + u
... )
SCM(...)
>>> sim = scm.simulate(n=100, seed=0)
>>> bool(sim["Y"].shape == (100,))
True
counterfactual ¶
counterfactual(evidence: Mapping[str, float], intervention: Mapping[str, float], n_samples: int = 2000, seed: int | None = None, tol: float = 0.01) -> SampleMap
Compute E[Y(intervention) | evidence] via abduction-action.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
evidence
|
dict
|
Observed values of some subset of nodes (factual world). |
required |
intervention
|
dict
|
Values to set for do-intervened variables. |
required |
n_samples
|
int
|
Number of accepted noise draws for rejection-sampling. |
2000
|
tol
|
float
|
Tolerance for matching continuous evidence. |
1e-2
|
Returns:
| Type | Description |
|---|---|
dict[str, ndarray]
|
Counterfactual samples for every node. |
CounterfactualIdentification
dataclass
¶
Bases: ResultProtocolMixin
Outcome of :func:identify_counterfactual.
Attributes:
| Name | Type | Description |
|---|---|---|
identifiable |
bool
|
Whether the query is a function of interventional distributions. |
estimand |
str
|
The formula, in terms |
query |
str
|
The query as understood. |
experiments |
list of str
|
The distinct interventional distributions the formula uses. |
observational |
dict
|
For each of those, its formula in the observational distribution,
or |
from_observational_data |
bool
|
True when every term has an observational formula, so that
:meth: |
explanation |
str
|
|
Examples:
>>> import statspai as sp
>>> g = sp.dag("Z -> X; Z -> Y; X -> Y")
>>> res = sp.identify_counterfactual(
... g, [("Y", 1, {"X": 0})], given=[("X", 1)])
>>> isinstance(res, sp.CounterfactualIdentification), res.identifiable
(True, True)
summary ¶
evaluate ¶
Value of the estimand in a fully specified model.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
net
|
BayesNet
|
A :func: |
required |
Returns:
| Type | Description |
|---|---|
float
|
|
Examples:
>>> import statspai as sp
>>> g = sp.dag("Z -> X; Z -> Y; X -> Y")
>>> net = sp.bayes_net(g, cpts={
... "Z": {0: 0.5, 1: 0.5},
... "X": {0: {0: 0.8, 1: 0.2}, 1: {0: 0.3, 1: 0.7}},
... "Y": {(0, 0): {0: 0.9, 1: 0.1}, (0, 1): {0: 0.6, 1: 0.4},
... (1, 0): {0: 0.5, 1: 0.5}, (1, 1): {0: 0.2, 1: 0.8}}})
>>> res = sp.identify_counterfactual(
... g, [("Y", 1, {"X": 0})], given=[("X", 1)])
>>> round(res.evaluate(net), 4) # sum_z P(z | X=1) P(Y=1 | X=0, z)
0.3333
estimate ¶
Value of the estimand on categorical observational data.
Every interventional term is replaced by its observational formula
(:func:statspai.identify) evaluated on the data's frequencies.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
One categorical column per variable the formulas use. |
required |
Returns:
| Type | Description |
|---|---|
float
|
|
Raises:
| Type | Description |
|---|---|
IdentificationFailure
|
When a term needs an experiment. |
Examples:
>>> import numpy as np, pandas as pd, statspai as sp
>>> rng = np.random.default_rng(0)
>>> n = 40000
>>> z = rng.integers(0, 2, n)
>>> x = (rng.random(n) < np.where(z == 1, 0.7, 0.2)).astype(int)
>>> y = (rng.random(n) < 0.1 + 0.3 * x + 0.4 * z).astype(int)
>>> df = pd.DataFrame({"Z": z, "X": x, "Y": y})
>>> g = sp.dag("Z -> X; Z -> Y; X -> Y")
>>> ett0 = sp.identify_counterfactual(
... g, [("Y", 1, {"X": 0})], given=[("X", 1)]).estimate(df)
>>> seen = df.loc[df.X == 1, "Y"].mean()
>>> round(float(seen - ett0), 1) # effect of treatment on the treated
0.3
RuleCheck
dataclass
¶
Result of checking one do-calculus rule on a DAG.
Attributes:
| Name | Type | Description |
|---|---|---|
applicable |
bool
|
Whether the rule's d-separation condition holds. |
rule |
int
|
Which rule was checked (1, 2, or 3). |
reason |
str
|
Human-readable statement of the rule's d-separation condition. |
transformed |
str
|
The resulting interventional expression; unchanged if not applicable. |
Examples:
>>> import statspai as sp
>>> rc = sp.RuleCheck(applicable=True, rule=1, reason="(Y ⊥ Z | X,W)",
... transformed="P(Y | do(X))")
>>> bool(rc.applicable), rc.rule
(True, 1)
>>> rc.transformed
'P(Y | do(X))'
DAG ¶
A directed acyclic graph for causal reasoning.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
spec
|
str
|
Edge specification. Supported formats:
A statement that is none of these, an undirected edge |
''
|
latent
|
iterable of str
|
Names of unobserved nodes. They take part in every path, but are never offered as adjustment variables, and identification treats them as unmeasured. |
None
|
Examples:
>>> import statspai as sp
>>> g = sp.DAG('Z -> X; Z -> Y; X -> Y')
>>> g.adjustment_sets('X', 'Y')
[{'Z'}]
add_edge ¶
Add parent -> child; an edge that closes a cycle raises.
add_bidirected ¶
Add a latent common cause of a and b.
The latent is named _L_<a>_<b>. When that name is already taken
by a node that is not the latent of this same pair (A_B <-> C
and A <-> B_C both spell _L_A_B_C), a numeric suffix keeps
the two latents apart.
is_collider ¶
Check if node is a collider on path.
A node is a collider on a path if both its neighbours on the
path are parents of it (arrows point into it: → node ←).
all_paths ¶
Enumerate all simple paths between x and y.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
str
|
Start and end nodes. |
required |
y
|
str
|
Start and end nodes. |
required |
directed_only
|
bool
|
If True, only follow directed edges parent→child. If False (default), traverse edges in either direction (needed for finding backdoor paths). |
False
|
Returns:
| Type | Description |
|---|---|
list of list of str
|
Each inner list is an ordered path from x to y. |
causal_paths ¶
All directed (causal) paths from exposure to outcome.
These are the paths through which the treatment actually causes changes in the outcome.
backdoor_paths ¶
All backdoor paths from exposure to outcome.
A backdoor path is a path that starts with an arrow into the
exposure (exposure <- ...). A path that leaves the exposure
along an arrow and is not directed all the way, such as
X -> M -> C <- Y, is neither causal nor backdoor: it is closed
at its collider until something on or below the collider is
conditioned on. :meth:noncausal_paths returns both kinds.
Examples:
noncausal_paths ¶
Every path from exposure to outcome that is not directed from one to the other: the backdoor paths, then the paths that leave the exposure along an arrow and meet a collider.
format_path ¶
is_path_open ¶
Check if a path is open (active) given a conditioning set.
Rules (Pearl 2009): - A non-collider on the path blocks if conditioned on. - A collider on the path blocks unless it or a descendant is conditioned on.
path_status ¶
Classify every path between exposure and outcome.
Returns:
| Type | Description |
|---|---|
list of dict
|
Each dict has keys |
Examples:
>>> import statspai as sp
>>> g = sp.dag('Z -> X; Z -> Y; X -> Y')
>>> status = g.path_status('X', 'Y')
>>> sorted((s['type'], s['path'], s['open']) for s in status)
[('backdoor', ['X', 'Z', 'Y'], True), ('causal', ['X', 'Y'], True)]
>>> status = g.path_status('X', 'Y', conditioned={'Z'})
>>> sorted((s['type'], s['path'], s['open']) for s in status)
[('backdoor', ['X', 'Z', 'Y'], False), ('causal', ['X', 'Y'], True)]
classify_variable ¶
Classify the role(s) of node relative to exposure → outcome.
Returns a set that may include:
'confounder', 'mediator', 'collider',
'instrument', 'ancestor_of_treatment',
'ancestor_of_outcome'.
A 'confounder' lies on a backdoor path that has no collider,
so the path carries association until something on it is adjusted
for. A 'collider' is one on any path that is not causal. A
mediator is not a confounder for sitting on a path such as
X -> M -> C <- Y, which does not enter the exposure.
Examples:
bad_controls ¶
Identify variables that should not be conditioned on.
Returns a dict mapping variable names to the reason they are bad controls. Based on Cinelli, Forney & Pearl (2022) and the "bad controls" discussion in Cunningham (2021, ch. 3).
Categories of bad controls:
- descendant_of_treatment: conditioning on a descendant of exposure blocks part of the causal effect (over-control bias).
- collider: conditioning opens a previously closed backdoor path (collider bias / selection bias).
- mediator: conditioning on a mediator blocks the indirect causal effect (over-control / mediation bias).
- M-bias: conditioning on a pre-treatment variable that is a collider on a backdoor path, opening a non-causal path.
Examples:
do ¶
Return the interventional graph G_{\overline{X}}: the graph with all incoming edges to the intervention node(s) removed.
This implements Pearl's do-operator at the graphical level.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
intervention
|
str or set of str
|
The node(s) being intervened on. |
required |
Returns:
| Type | Description |
|---|---|
DAG
|
A new DAG with incoming edges to intervention removed. |
Examples:
instrumental_variables ¶
Instruments for the total effect of exposure on outcome.
Each entry is {'instrument': z, 'conditioning': [w, ...]}: z
is d-connected to the exposure given W and d-separated from the
outcome given W once the first edges of the causal paths are
removed, with W free of descendants of the causal paths. By
default only ancestral instruments (W within the ancestors of
the outcome or the instrument), as R
dagitty::instrumentalVariables lists them;
ancestral=False adds conditional instruments that need a
collider in W. See :mod:statspai.dag.instruments.
Examples:
frontdoor_sets ¶
Find sets satisfying Pearl's frontdoor criterion.
A set M satisfies the frontdoor criterion relative to (X, Y) if:
- M intercepts all directed paths from X to Y.
- There is no unblocked backdoor path from X to M.
- All backdoor paths from M to Y are blocked by X.
Returns:
| Type | Description |
|---|---|
list of set
|
Valid frontdoor adjustment sets (possibly empty). |
Examples:
d_separated ¶
Test if x and y are d-separated given conditioned.
Uses the Bayes-Ball algorithm (Shachter 1998).
adjustment_sets ¶
adjustment_sets(exposure: str, outcome: str, method: str = 'backdoor', minimal: bool = True) -> List[Set[str]]
Find valid adjustment sets for estimating the causal effect of exposure on outcome.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
exposure
|
str
|
Treatment and outcome nodes. |
required |
outcome
|
str
|
Treatment and outcome nodes. |
required |
method
|
str
|
|
'backdoor'
|
minimal
|
bool
|
If True, return the minimal sufficient adjustment sets: valid
sets none of whose proper subsets is valid. They need not all
be the same size; they are the sets R
|
True
|
Returns:
| Type | Description |
|---|---|
list of set
|
Each set is a valid adjustment set (possibly empty). An empty list means no set of observed variables satisfies the criterion. |
Notes
Whether any valid set exists is decided exactly, at any size: if one does, the observed non-descendants of the exposure that are ancestors of the exposure or the outcome form one [@vanderzander2019separators]. Sets of up to six variables are then enumerated. When none that small exists, that ancestral set is pruned one variable at a time and the result returned, so a graph that needs seven or more adjusters gets one irreducible set rather than all of them.
Examples:
latent_projection ¶
Project the unobserved nodes out of the graph.
The result has the observed nodes only. It keeps a -> b when
the graph has a directed path from a to b whose interior
is unobserved, and adds a <-> b when a and b descend
from a common unobserved node along paths whose interiors are
unobserved (Verma and Pearl's projection). Every causal query
about the observed variables has the same answer in the two
graphs, which is how :func:statspai.identify handles a named
latent with parents of its own.
Examples:
implied_independencies ¶
Conditional independencies among observed variables that the graph implies.
For every pair of observed, non-adjacent nodes, lists each
minimal set of observed variables that d-separates them (no
proper subset does). These are the testable implications of the
graph: if one fails in the data, the graph is wrong. Same list as
dagitty::impliedConditionalIndependencies.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
max_cond
|
int
|
Largest conditioning set to consider. The search is exponential in the number of observed nodes; set this on graphs with more than about 15 of them. |
None
|
Returns:
| Type | Description |
|---|---|
list of (x, y, set)
|
|
Examples:
test_implications ¶
test_implications(data: 'pd.DataFrame', max_cond: Optional[int] = None, test: str = 'auto') -> 'pd.DataFrame'
Test the implied conditional independencies on data.
A small p-value is evidence against the graph; a large one is not evidence for it. With enough data every such test rejects a graph that is only approximately right, so read the size of the departure next to the p-value.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
One column per observed node that appears in an implication. |
required |
max_cond
|
int
|
Passed to :meth: |
None
|
test
|
('auto', 'fisher-z', 'chi-square', 'g-test')
|
|
'auto'
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
One row per implication, with |
Examples:
>>> import numpy as np, pandas as pd, statspai as sp
>>> rng = np.random.default_rng(0)
>>> z = rng.normal(size=500)
>>> x = z + rng.normal(size=500)
>>> y = x + rng.normal(size=500)
>>> df = pd.DataFrame({'Z': z, 'X': x, 'Y': y})
>>> out = sp.dag('Z -> X -> Y').test_implications(df)
>>> bool(out['p_value'].iloc[0] > 0.05) # Y _||_ Z | X holds
True
Categorical data are tested with the chi-square statistic:
equivalent_dags ¶
Every DAG in the Markov equivalence class of this one.
Two DAGs imply the same conditional independencies exactly when they have the same adjacencies and the same unshielded colliders. No data set, however large, can tell the members of the class apart, so a causal claim that depends on the direction of an edge that flips inside the class rests on background knowledge alone.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
max_edges
|
int
|
The search enumerates the |
20
|
Returns:
| Type | Description |
|---|---|
list of DAG
|
The members of the class, this graph included, in a deterministic order. Latent declarations are carried over. |
Examples:
>>> import statspai as sp
>>> g = sp.dag("T -> X; T -> Y; X -> Y; Z -> Y")
>>> len(g.equivalent_dags())
2
>>> g.equivalence_class()["undirected"]
[('T', 'X')]
See Also
equivalence_class : the class as one partially directed graph.
equivalence_class ¶
The Markov equivalence class as a partially directed graph.
Returns:
| Type | Description |
|---|---|
dict
|
|
Examples:
plot ¶
plot(exposure: Optional[str] = None, outcome: Optional[str] = None, conditioned: Optional[Set[str]] = None, positions: Optional[Dict[str, Tuple[float, float]]] = None, figsize: Tuple[float, float] = (8, 6), seed: int = 42, title: Optional[str] = None, style: str = 'ggdag', node_size: float = 0.22, font_size: int = 12, ax: Any = None) -> Tuple[Any, Any]
Plot the DAG with publication-quality styling.
When exposure and outcome are provided, nodes are colour-coded
by causal role (like R's ggdag):
- Exposure: green
- Outcome: blue
- Confounder: orange
- Mediator: purple
- Collider / bad control: red
- Unobserved (latent): grey dashed outline
- Adjusted / conditioned: hatched fill
Bidirected edges (latent common causes) are rendered as curved dashed arcs rather than routing through hidden nodes.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
exposure
|
str
|
Treatment and outcome nodes for role colouring. |
None
|
outcome
|
str
|
Treatment and outcome nodes for role colouring. |
None
|
conditioned
|
set of str
|
Nodes being conditioned on (shown with hatched fill). |
None
|
positions
|
dict
|
|
None
|
figsize
|
tuple
|
Figure size (width, height) in inches. |
(8, 6)
|
seed
|
int
|
Random seed for layout jitter. |
42
|
title
|
str
|
Plot title. Auto-generated if exposure/outcome given. |
None
|
style
|
str
|
|
'ggdag'
|
node_size
|
float
|
Radius of node circles in data coordinates. |
0.22
|
font_size
|
int
|
Font size for node labels. |
12
|
ax
|
matplotlib Axes
|
Axes to draw on. If |
None
|
Returns:
| Type | Description |
|---|---|
(fig, ax) : matplotlib figure and axes
|
|
summary ¶
Print a rich text summary of the DAG for identification analysis.
Shows all paths, their status, adjustment sets, bad controls,
and variable roles — a one-stop diagnostic like dagitty's
adjustmentSets + paths.
Examples:
IdentificationResult
dataclass
¶
Bases: ResultProtocolMixin
Outcome of an identification query.
Attributes:
| Name | Type | Description |
|---|---|---|
identifiable |
bool
|
True iff P(Y | do(X)) is identifiable from the observed distribution. |
estimand |
str
|
Do-free formula when identifiable; a structured hedge otherwise. |
c_components |
list[set[str]]
|
The c-components of the ancestral semi-Markovian graph G[An(Y)]. |
hedge |
tuple[frozenset, frozenset] | None
|
Witness C-forest pair (F, F') that proves non-identifiability. |
explanation |
str
|
Human-readable proof / refutation. |
Examples:
>>> import statspai as sp
>>> g = sp.dag("Z -> X; Z -> Y; X -> Y")
>>> res = sp.identify(g, treatment="X", outcome="Y")
>>> isinstance(res, sp.IdentificationResult)
True
>>> bool(res.identifiable)
True
>>> "Y" in res.estimand
True
summary ¶
Return a formatted multi-line summary of the ID query.
Every StatsPAI result object exposes .summary() (CLAUDE.md §3.3);
this one renders the identifiability verdict, the do-free estimand or
the hedge witnessing non-identifiability, and the c-components of the
ancestral semi-Markovian graph.
Examples:
estimate ¶
Evaluate the estimand on categorical data: P(Y | do(X)).
Each probability in the formula is replaced by the matching frequency in the data and the sums are carried out. Only the observed variables are used, so this works where a latent confounder rules out fitting the whole graph (the front door, for one).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
One column for every variable in the estimand, each treated as categorical. |
required |
n_boot
|
int
|
Nonparametric bootstrap replications. When positive, |
0
|
alpha
|
float
|
Level of the bootstrap interval. |
0.05
|
seed
|
int
|
Seed for the bootstrap. |
None
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
One row per combination of treatment and outcome values, with
|
Notes
A term such as P(y | x', m) is undefined where (x', m)
never occurs. When the answer for a treatment value depends on
such a term, the data do not determine it: those rows are
returned as missing and an
:class:~statspai.exceptions.AssumptionWarning says which. This
is the positivity condition of the estimand, checked on the data.
When the formula keeps a variable free that is neither treatment nor outcome (the algorithm may intervene on variables that cannot affect the outcome, and the result does not depend on their value in the population), it is averaged over that variable's observed distribution.
Examples:
>>> import numpy as np, pandas as pd, statspai as sp
>>> rng = np.random.default_rng(0)
>>> n = 20000
>>> u = rng.random(n) < 0.5 # unobserved
>>> x = rng.random(n) < np.where(u, 0.8, 0.2)
>>> m = rng.random(n) < np.where(x, 0.9, 0.2)
>>> y = rng.random(n) < 0.1 + 0.5 * m + 0.3 * u
>>> df = pd.DataFrame({"X": x, "M": m, "Y": y})
>>> g = sp.dag("X -> M -> Y; U -> X; U -> Y", latent=["U"])
>>> res = sp.identify(g, "X", "Y")
>>> est = res.estimate(df)
>>> p1 = est.query("X == True and Y == True")["prob"].item()
>>> p0 = est.query("X == False and Y == True")["prob"].item()
>>> round(p1 - p0, 2) # true effect 0.5 * (0.9 - 0.2)
0.35
LLMDAGResult
dataclass
¶
Bases: ResultProtocolMixin
Merged LLM-oracle / CI-test DAG returned by :func:llm_dag.
Attributes:
| Name | Type | Description |
|---|---|---|
edges |
list of (str, str)
|
Final merged edge set after |
oracle_edges |
list of (str, str)
|
Raw |
ci_rejects, ci_asserts |
list of (str, str)
|
Variable pairs the CI-test skeleton rejected / asserted. |
disagreements |
list of (str, str, str)
|
|
provenance |
dict
|
Bookkeeping: oracle error (if any), merge strategy, ci_test, alpha. |
Examples:
>>> import statspai as sp
>>> # An oracle is any callable: (variables, descriptions) -> edges.
>>> def domain_oracle(variables, descriptions):
... return [("smoking", "cancer")] # e.g. an LLM API call
>>> res = sp.llm_dag(
... variables=["smoking", "cancer", "tar"],
... oracle=domain_oracle,
... merge_strategy="oracle_only",
... )
>>> res.edges # final merged edges
>>> res.disagreements # oracle vs CI-test conflicts
LLMCausalAssessResult
dataclass
¶
Bases: ResultProtocolMixin
Output of :func:llm_causal_assess.
Attributes:
| Name | Type | Description |
|---|---|---|
level1_accuracy, level2_accuracy |
float or None
|
Per-level accuracy ( |
per_item |
DataFrame
|
One row per question: |
llm_identifier |
str
|
Label of the assessed model. |
Examples:
>>> import statspai as sp
>>> import pandas as pd
>>> # llm_client wraps any model API: prompt (str) -> response (str).
>>> def llm_client(question):
... return "the answer is Y" # e.g. an Anthropic API call
>>> level1 = pd.DataFrame({
... "question": ["Does X cause Y?"],
... "answer": ["Y"],
... })
>>> res = sp.llm_causal_assess(
... level1_items=level1, llm_client=llm_client,
... llm_identifier="my-model",
... )
>>> res.level1_accuracy
>>> print(res.summary())
PairwiseBenchmarkResult
dataclass
¶
Bases: ResultProtocolMixin
Output of :func:pairwise_causal_benchmark.
Attributes:
| Name | Type | Description |
|---|---|---|
accuracy |
float
|
Fraction of pairs whose predicted direction matches the truth. |
precision_forward, recall_forward |
float
|
Precision / recall for the |
per_pair |
DataFrame
|
One row per pair: |
Examples:
>>> import statspai as sp
>>> import pandas as pd
>>> # llm_client wraps any model API: prompt (str) -> response (str).
>>> def llm_client(prompt):
... return "yes" # e.g. an Anthropic API call
>>> gt = pd.DataFrame({
... "A": ["smoking"], "B": ["cancer"], "a_causes_b": [True],
... })
>>> res = sp.pairwise_causal_benchmark(
... gt, llm_client=llm_client,
... )
>>> res.accuracy, res.precision_forward
>>> print(res.summary())
SWIGGraph ¶
Single World Intervention Graph for a DAG under do(X=x).
Attributes:
| Name | Type | Description |
|---|---|---|
parent |
DAG
|
Source DAG. |
intervention |
dict[str, str]
|
Variable → value label. |
nodes |
set[str]
|
All SWIG nodes (split halves + potential outcomes). |
edges |
dict[str, set[str]]
|
Adjacency map on SWIG nodes. |
Examples:
identify_counterfactual ¶
identify_counterfactual(dag: Any, event: Any, given: Any = None) -> CounterfactualIdentification
Decide whether a counterfactual probability is identifiable, and from what (ID* and IDC*, Shpitser and Pearl).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
dag
|
DAG
|
The causal graph. Latent variables are declared with
|
required |
event
|
list of tuple
|
The counterfactual events whose joint probability is wanted. Each
is |
required |
given
|
list of tuple
|
Events to condition on, written the same way. They may refer to
other worlds than |
None
|
Returns:
| Type | Description |
|---|---|
CounterfactualIdentification
|
|
Notes
Identifiable here means: computable from the collection of all
interventional distributions, that is from experiments. That is the
most one can ask of a counterfactual without stating the structural
functions. from_observational_data then says whether those
experiments are themselves replaceable by observational data in this
graph.
Examples:
The effect of treatment on the treated is the standard example.
P(Y_{X=0} = y | X = 1) is identified whenever the effect of X on Y
has no unblocked confounding, and not otherwise:
>>> import statspai as sp
>>> ok = sp.identify_counterfactual(
... sp.dag("Z -> X; Z -> Y; X -> Y"), [("Y", 1, {"X": 0})], given=[("X", 1)])
>>> ok.identifiable, ok.from_observational_data
(True, True)
>>> print(ok.estimand.split(" / ")[0])
[sum_{Z} [P(X=1 | do(Z)) * P(Y=1 | do(X=0, Z)) * P(Z)]]
>>> bow = sp.identify_counterfactual(
... sp.dag("X -> Y; X <-> Y"), [("Y", 1, {"X": 0})], given=[("X", 1)])
>>> bow.identifiable
False
A probability of necessity, P(Y_{X=0} = 0 | X = 1, Y = 1), is not
identified even without confounding: it needs the joint of Y in
two worlds, which no experiment reveals.
>>> pn = sp.identify_counterfactual(
... sp.dag("X -> Y"), [("Y", 0, {"X": 0})], given=[("X", 1), ("Y", 1)])
>>> pn.identifiable
False
References
[@shpitser2008complete]
apply_rules ¶
apply_rules(dag: Any, Y: NodeInput, X: NodeInput, Z: NodeInput, W: NodeInput = None) -> list[RuleCheck]
rule1 ¶
rule1(dag: Any, Y: NodeInput, X: NodeInput, Z: NodeInput, W: NodeInput = None) -> RuleCheck
Check Rule 1: can we insert or delete observation of Z?
Rule 1 licenses P(y | do(x), z, w) = P(y | do(x), w) when
(Y ⊥ Z | X, W) holds in the graph with edges into X deleted.
Examples:
rule2 ¶
rule2(dag: Any, Y: NodeInput, X: NodeInput, Z: NodeInput, W: NodeInput = None) -> RuleCheck
Check Rule 2: can do(Z) be swapped for observing Z?
Rule 2 licenses P(y | do(x), do(z), w) = P(y | do(x), z, w) when
(Y ⊥ Z | X, W) holds in the graph with edges into X and edges
out of Z deleted (action/observation exchange).
Examples:
rule3 ¶
rule3(dag: Any, Y: NodeInput, X: NodeInput, Z: NodeInput, W: NodeInput = None) -> RuleCheck
Check Rule 3: can we delete do(Z)?
Rule 3 licenses P(y | do(x), do(z), w) = P(y | do(x), w) when
(Y ⊥ Z | X, W) holds in the graph with edges into X and into
Z(W) deleted (insertion/deletion of actions).
Examples:
dag ¶
dag(spec: str = '', latent: Optional[Iterable[str]] = None) -> DAG
Create a causal DAG from a string specification.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
spec
|
str
|
Edge specification. Examples:
|
''
|
latent
|
iterable of str
|
Names of unobserved nodes, as an alternative to |
None
|
Returns:
| Type | Description |
|---|---|
DAG
|
|
Examples:
>>> import statspai as sp
>>> g = sp.dag('Z -> X -> Y; Z -> Y')
>>> g.adjustment_sets('X', 'Y')
[{'Z'}]
No valid adjustment set exists with an unobserved confounder:
A named latent is never offered as an adjustment variable:
dag_example ¶
dag_example(name: str) -> DAG
Load a classic textbook DAG by name.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
One of: |
required |
Returns:
| Type | Description |
|---|---|
DAG
|
The example DAG. Call |
Examples:
dag_example_positions ¶
Return hand-tuned node positions for a named example DAG.
The returned {node: (x, y)} mapping can be passed to
DAG.plot(..., positions=...) for a reproducible layout.
Examples:
dag_examples ¶
dag_simulate ¶
Run a classic DAG simulation from Cunningham (2021, ch. 3).
Available simulations:
'discrimination'— Gender discrimination / occupational sorting. True effect of discrimination on wage is -1. Conditioning on occupation alone flips the sign (collider bias).'movie_star'— Beauty–Talent collider. Beauty and Talent are independent in the population, but conditioning on Star status induces a spurious negative correlation.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
|
required |
n
|
int
|
Number of observations (default 10000). |
10000
|
seed
|
int
|
Random seed for reproducibility. |
42
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
Simulated dataset. |
Examples:
>>> import statspai as sp
>>> df = sp.dag_simulate('discrimination')
>>> df.shape
(10000, 5)
>>> import statsmodels.formula.api as smf
>>> # Biased: wrong sign due to collider
>>> biased = smf.ols('wage ~ female + occupation', data=df).fit()
>>> bool(biased.params['female'] > 0)
True
>>> # Correct: includes ability
>>> fit = smf.ols('wage ~ female + occupation + ability', data=df).fit()
>>> bool(fit.params['female'] < 0)
True
identify ¶
identify(dag: Any, treatment: NodeInput, outcome: NodeInput) -> IdentificationResult
Run Shpitser-Pearl ID algorithm on dag.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
dag
|
DAG
|
|
required |
treatment
|
str | Iterable[str]
|
Set of variables X being intervened on. |
required |
outcome
|
str | Iterable[str]
|
Set of outcome variables Y. |
required |
Returns:
| Type | Description |
|---|---|
IdentificationResult
|
|
Examples:
>>> import statspai as sp
>>> # Backdoor confounder -- P(Y | do(X)) is identifiable
>>> g = sp.dag("Z -> X; Z -> Y; X -> Y")
>>> res = sp.identify(g, treatment="X", outcome="Y")
>>> bool(res.identifiable)
True
>>> # Bow arc (X <-> Y latent confounding) -- NOT identifiable
>>> g2 = sp.dag("X -> Y; X <-> Y")
>>> res2 = sp.identify(g2, treatment="X", outcome="Y")
>>> bool(res2.identifiable)
False
>>> "hedge" in res2.estimand
True
llm_causal_assess ¶
llm_causal_assess(level1_items: Optional[DataFrame] = None, level2_items: Optional[DataFrame] = None, *, llm_client: Callable[[str], str], llm_identifier: str = 'llm') -> LLMCausalAssessResult
Combined Level-1 + Level-2 LLM causal-reasoning assessment.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
level1_items
|
DataFrame
|
Columns: |
None
|
level2_items
|
DataFrame
|
Columns: |
None
|
llm_client
|
Callable[[str], str]
|
|
required |
llm_identifier
|
Callable[[str], str]
|
|
required |
Returns:
| Type | Description |
|---|---|
LLMCausalAssessResult
|
|
Examples:
>>> import statspai as sp
>>> import pandas as pd
>>> def stub_client(question):
... # echo the last token so substring matching scores it correct
... return "The cause is " + question.split()[-1]
>>> level1 = pd.DataFrame({
... "question": ["Does X cause Y", "Does A cause B"],
... "answer": ["Y", "B"],
... })
>>> res = sp.llm_causal_assess(
... level1_items=level1, llm_client=stub_client, llm_identifier="stub",
... )
>>> res.level1_accuracy
1.0
pairwise_causal_benchmark ¶
pairwise_causal_benchmark(ground_truth: DataFrame, *, llm_client: Callable[[str], str], llm_identifier: str = 'llm', pair_a_col: str = 'A', pair_b_col: str = 'B', truth_col: str = 'a_causes_b', prompt_template: str = "Does variable {a} causally influence variable {b}? Answer 'yes' or 'no'.") -> PairwiseBenchmarkResult
Benchmark an LLM on pairwise causal-direction identification.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
ground_truth
|
DataFrame
|
One row per pair with columns |
required |
llm_client
|
callable(str) -> str
|
Function taking a prompt and returning a string. |
required |
llm_identifier
|
str
|
|
'llm'
|
prompt_template
|
str
|
|
``"Does variable {a} causally ..."``
|
Returns:
| Type | Description |
|---|---|
PairwiseBenchmarkResult
|
|
Examples:
>>> import statspai as sp
>>> import pandas as pd
>>> def stub_client(prompt):
... # toy oracle: only "smoking" prompts get a "yes"
... return "yes" if "smoking" in prompt.lower() else "no"
>>> gt = pd.DataFrame({
... "A": ["smoking", "ice_cream"],
... "B": ["cancer", "drowning"],
... "a_causes_b": [True, False],
... })
>>> res = sp.pairwise_causal_benchmark(gt, llm_client=stub_client)
>>> res.accuracy
1.0
recommend_estimator ¶
recommend_estimator(dag: Any, exposure: str, outcome: str, candidate_instruments: Optional[Sequence[str]] = None) -> EstimatorRecommendation
Inspect a DAG and recommend a statspai estimator.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
dag
|
DAG
|
|
required |
exposure
|
str
|
|
required |
outcome
|
str
|
|
required |
candidate_instruments
|
list of str
|
Variable names to check as potential IVs. If omitted, all observed nodes other than exposure/outcome are considered. |
None
|
Examples: