Identification¶
Once the graph is restricted to the edges that earn an arrow, the question becomes whether a causal effect can be computed from observational data at all.
The criterion¶
A set Z satisfies the backdoor criterion relative to (X, Y) when
- no node in
Zis a descendant ofX, and Zblocks every path betweenXandYthat starts with an arrow intoX
(Pearl, Causality, 2nd ed., Def. 3.3.1).
Both halves matter. The descendant condition rules out conditioning on a mediator or a collider downstream of the treatment. And blocking only the backdoor paths — never the causal path itself — is what a plain d-separation test does not give you for free.
How the second half is tested
To use a generic d-separation routine, the edges leaving the treatment
are removed first. That deletes the causal path, and anything X causes,
while leaving every backdoor path exactly as it was.
This is not the same mutilation as mutilated_graph, which removes the
edges into the treatment to build the interventional graph
GX̄. The two are easy to confuse and mean opposite things.
One call¶
from causalphylo import find_adjustment_set
verdict = find_adjustment_set(G, treatment="X", outcome="D")
find_adjustment_set takes the full graph and does the restriction itself —
causal_subgraph, then acyclic_core — so no caller can skip it. It returns a
frozen Identification:
| Field | Meaning |
|---|---|
identifiable |
Whether the effect is identifiable |
adjustment_set |
The minimal valid set, as a tuple |
reason |
An IdentificationReason, always set |
search_truncated |
Whether the subset search hit its bound |
n_near_miss_confounders |
Confounders the graph hints at but does not license |
The reasons¶
import networkx as nx
from causalphylo import find_adjustment_set
REG = {"edge_type": "activates", "evidence_class": "regulatory"}
GEN = {"edge_type": "associated_with", "evidence_class": "genetic_association"}
LIT = {"edge_type": "associated_with", "evidence_class": "literature"}
identifiable_by_adjustment¶
A confounder is present and adjustable.
G = nx.DiGraph()
G.add_edge("U", "X", **REG); G.add_edge("U", "D", **GEN); G.add_edge("X", "D", **GEN)
find_adjustment_set(G, "X", "D")
# identifiable=True adjustment_set=('U',) reason='identifiable_by_adjustment'
identifiable_trivially¶
No confounder appears in the graph, so the empty set satisfies the criterion.
G = nx.DiGraph()
G.add_edge("X", "D", **GEN); G.add_edge("W", "X", **REG); G.add_edge("W", "D", **LIT)
find_adjustment_set(G, "X", "D")
# identifiable=True adjustment_set=() reason='identifiable_trivially'
# n_near_miss_confounders=1
A trivial verdict is not a clean bill of health
A knowledge graph is open-world. A missing edge is missing knowledge, not evidence of no confounding.
So a trivial verdict carries n_near_miss_confounders: how many nodes
reach the treatment and carry some edge to the outcome that fell short of
admissibility. Above, W regulates X and is associated with D — but
only by literature, so it is exactly the kind of confounder the graph hints
at and cannot license.
A target with many near misses is fragile. This is the same move as an E-value in observational epidemiology: not "there is no confounding", but "here is how much it would take to break this".
no_causal_path¶
No admissible directed path from treatment to outcome. An association on its own produces this, because it never became an arrow.
G = nx.DiGraph(); G.add_edge("X", "D", **LIT)
find_adjustment_set(G, "X", "D")
# identifiable=False reason='no_causal_path'
cyclic_component¶
Treatment or outcome sits in a feedback loop, where d-separation is undefined.
G = nx.DiGraph()
G.add_edge("X", "D", **GEN); G.add_edge("X", "Y", **REG); G.add_edge("Y", "X", **REG)
find_adjustment_set(G, "X", "D")
# identifiable=False reason='cyclic_component'
no_valid_adjustment_set¶
Confounders exist, but no admissible subset blocks every backdoor path.
treatment_absent / outcome_absent¶
The node is not in the admissible subgraph — it may exist in your graph and have had no admissible edge.
G = nx.DiGraph(); G.add_edge("X", "D", **GEN)
find_adjustment_set(G, "ZZZ", "D") # reason='treatment_absent'
find_adjustment_set(G, "X", "ZZZ") # reason='outcome_absent'
Search bounds, reported not hidden¶
The subset search is bounded, because the number of candidate subsets grows as C(pool, size):
>>> from causalphylo import MAX_ADJUSTMENT_SET_SIZE, MAX_CANDIDATE_POOL
>>> MAX_ADJUSTMENT_SET_SIZE, MAX_CANDIDATE_POOL
(3, 20)
Reaching a bound sets search_truncated. It is never silently swallowed:
G = nx.DiGraph(); G.add_edge("X", "D", **GEN)
for i in range(25): # 25 confounders, pool bound is 20
G.add_edge(f"U{i}", "X", **REG); G.add_edge(f"U{i}", "D", **GEN)
find_adjustment_set(G, "X", "D")
# identifiable=False reason='no_valid_adjustment_set' search_truncated=True
A False with search_truncated=True means not found within the bound — not
does not exist. Treat the two differently.
Working at a lower level¶
from causalphylo import causal_subgraph, acyclic_core, satisfies_backdoor
dag = acyclic_core(causal_subgraph(G)).dag
satisfies_backdoor(dag, "X", "D", frozenset({"U"})) # True
satisfies_backdoor(dag, "X", "D", frozenset()) # False
satisfies_backdoor expects a DAG containing both nodes, which
find_adjustment_set guarantees before calling it. Reaching for it directly
means taking on that guarantee yourself.