causalphylo 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- causalphylo/__init__.py +80 -0
- causalphylo/backdoor.py +247 -0
- causalphylo/graph_semantics.py +133 -0
- causalphylo/py.typed +0 -0
- causalphylo-0.1.0.dist-info/METADATA +160 -0
- causalphylo-0.1.0.dist-info/RECORD +8 -0
- causalphylo-0.1.0.dist-info/WHEEL +4 -0
- causalphylo-0.1.0.dist-info/licenses/LICENSE +31 -0
causalphylo/__init__.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CausalPhylo — identification over mixed-evidence knowledge graphs.
|
|
3
|
+
|
|
4
|
+
Two questions, kept separate on purpose.
|
|
5
|
+
|
|
6
|
+
:mod:`causalphylo.graph_semantics` asks which edges may carry a causal
|
|
7
|
+
claim at all. A graph assembled from public sources mixes mechanistic,
|
|
8
|
+
directed assertions with mere co-occurrence; running do-calculus over
|
|
9
|
+
the second kind assumes the very thing the method is meant to establish.
|
|
10
|
+
|
|
11
|
+
:mod:`causalphylo.backdoor` asks, on the admissible subgraph, whether a
|
|
12
|
+
causal effect is identifiable — Pearl's backdoor criterion, reporting
|
|
13
|
+
*why* identification failed rather than merely that it did.
|
|
14
|
+
|
|
15
|
+
Both operate on plain :class:`networkx.DiGraph` objects whose edges
|
|
16
|
+
carry ``edge_type`` and ``evidence_class`` attributes. The package has
|
|
17
|
+
no domain model of its own and no dependency beyond NetworkX.
|
|
18
|
+
|
|
19
|
+
``find_adjustment_set`` takes the full graph and restricts to the
|
|
20
|
+
admissible acyclic core itself, so no caller can forget that step.
|
|
21
|
+
|
|
22
|
+
>>> import networkx as nx
|
|
23
|
+
>>> from causalphylo import find_adjustment_set
|
|
24
|
+
>>> G = nx.DiGraph()
|
|
25
|
+
>>> G.add_edge("U", "X", edge_type="activates", evidence_class="regulatory")
|
|
26
|
+
>>> G.add_edge("U", "D", edge_type="associated_with",
|
|
27
|
+
... evidence_class="genetic_association")
|
|
28
|
+
>>> G.add_edge("X", "D", edge_type="associated_with",
|
|
29
|
+
... evidence_class="genetic_association")
|
|
30
|
+
>>> verdict = find_adjustment_set(G, "X", "D")
|
|
31
|
+
>>> verdict.identifiable
|
|
32
|
+
True
|
|
33
|
+
>>> verdict.adjustment_set
|
|
34
|
+
('U',)
|
|
35
|
+
>>> verdict.reason.value
|
|
36
|
+
'identifiable_by_adjustment'
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
from causalphylo.backdoor import (
|
|
42
|
+
MAX_ADJUSTMENT_SET_SIZE,
|
|
43
|
+
MAX_CANDIDATE_POOL,
|
|
44
|
+
Identification,
|
|
45
|
+
IdentificationReason,
|
|
46
|
+
confounding_sensitivity,
|
|
47
|
+
find_adjustment_set,
|
|
48
|
+
mutilated_graph,
|
|
49
|
+
satisfies_backdoor,
|
|
50
|
+
)
|
|
51
|
+
from causalphylo.graph_semantics import (
|
|
52
|
+
CAUSAL_EDGE_TYPES,
|
|
53
|
+
GENETIC_EVIDENCE_CLASSES,
|
|
54
|
+
AcyclicCore,
|
|
55
|
+
acyclic_core,
|
|
56
|
+
causal_subgraph,
|
|
57
|
+
cyclic_components,
|
|
58
|
+
is_causal_admissible,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
__version__ = "0.1.0"
|
|
62
|
+
|
|
63
|
+
__all__ = [
|
|
64
|
+
"CAUSAL_EDGE_TYPES",
|
|
65
|
+
"GENETIC_EVIDENCE_CLASSES",
|
|
66
|
+
"MAX_ADJUSTMENT_SET_SIZE",
|
|
67
|
+
"MAX_CANDIDATE_POOL",
|
|
68
|
+
"AcyclicCore",
|
|
69
|
+
"Identification",
|
|
70
|
+
"IdentificationReason",
|
|
71
|
+
"__version__",
|
|
72
|
+
"acyclic_core",
|
|
73
|
+
"causal_subgraph",
|
|
74
|
+
"confounding_sensitivity",
|
|
75
|
+
"cyclic_components",
|
|
76
|
+
"find_adjustment_set",
|
|
77
|
+
"is_causal_admissible",
|
|
78
|
+
"mutilated_graph",
|
|
79
|
+
"satisfies_backdoor",
|
|
80
|
+
]
|
causalphylo/backdoor.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pearl's backdoor criterion, and an honest verdict when it cannot be met.
|
|
3
|
+
|
|
4
|
+
Z satisfies the backdoor criterion relative to (X, Y) when no node in Z
|
|
5
|
+
is a descendant of X, and Z blocks every path between X and Y that
|
|
6
|
+
starts with an arrow into X (Pearl, *Causality*, 2nd ed., Def. 3.3.1).
|
|
7
|
+
Both halves matter: the descendant condition rules out conditioning on
|
|
8
|
+
a mediator or a collider downstream of the treatment, and blocking only
|
|
9
|
+
the *backdoor* paths -- never the causal path itself -- is what a
|
|
10
|
+
plain d-separation test does not do for free. To test the second half
|
|
11
|
+
with a generic d-separation routine, the edges leaving the treatment
|
|
12
|
+
are removed first: that deletes the causal path (and anything X causes)
|
|
13
|
+
while leaving every backdoor path, the ones entering X through an
|
|
14
|
+
arrow, exactly as they were.
|
|
15
|
+
|
|
16
|
+
This module reports *why* identification failed, not merely that it did.
|
|
17
|
+
The reasons are a closed enum so that a run's failures aggregate into a
|
|
18
|
+
distribution rather than a pile of prose.
|
|
19
|
+
|
|
20
|
+
A note on trivial identification. When no confounder appears in the
|
|
21
|
+
graph, the empty set satisfies the criterion and the effect is
|
|
22
|
+
identifiable -- but a knowledge graph is open-world, and a missing edge
|
|
23
|
+
is missing knowledge rather than evidence of no confounding. So a
|
|
24
|
+
trivial verdict is reported under its own reason and carries
|
|
25
|
+
``n_near_miss_confounders``: how many nodes regulate the treatment and
|
|
26
|
+
have *some* association with the outcome that fell short of admissibility.
|
|
27
|
+
A target with many near misses is fragile. This is the same move as an
|
|
28
|
+
E-value in observational epidemiology -- not "there is no confounding"
|
|
29
|
+
but "here is how much it would take to break this".
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from enum import StrEnum
|
|
36
|
+
from itertools import combinations
|
|
37
|
+
|
|
38
|
+
import networkx as nx
|
|
39
|
+
|
|
40
|
+
from causalphylo.graph_semantics import acyclic_core, causal_subgraph
|
|
41
|
+
|
|
42
|
+
# A larger bound buys little and costs combinatorially: the number of
|
|
43
|
+
# candidate subsets grows as C(pool, size). Reaching the bound is
|
|
44
|
+
# reported via Identification.search_truncated, never hidden.
|
|
45
|
+
MAX_ADJUSTMENT_SET_SIZE = 3
|
|
46
|
+
MAX_CANDIDATE_POOL = 20
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"MAX_ADJUSTMENT_SET_SIZE",
|
|
50
|
+
"MAX_CANDIDATE_POOL",
|
|
51
|
+
"Identification",
|
|
52
|
+
"IdentificationReason",
|
|
53
|
+
"confounding_sensitivity",
|
|
54
|
+
"find_adjustment_set",
|
|
55
|
+
"mutilated_graph",
|
|
56
|
+
"satisfies_backdoor",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class IdentificationReason(StrEnum):
|
|
61
|
+
"""Why identification succeeded or failed. A closed set."""
|
|
62
|
+
|
|
63
|
+
IDENTIFIABLE_BY_ADJUSTMENT = "identifiable_by_adjustment"
|
|
64
|
+
IDENTIFIABLE_TRIVIALLY = "identifiable_trivially"
|
|
65
|
+
NO_CAUSAL_PATH = "no_causal_path"
|
|
66
|
+
CYCLIC_COMPONENT = "cyclic_component"
|
|
67
|
+
NO_VALID_ADJUSTMENT_SET = "no_valid_adjustment_set"
|
|
68
|
+
TREATMENT_ABSENT = "treatment_absent"
|
|
69
|
+
OUTCOME_ABSENT = "outcome_absent"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class Identification:
|
|
74
|
+
"""The verdict on one (treatment, outcome) pair."""
|
|
75
|
+
|
|
76
|
+
identifiable: bool
|
|
77
|
+
adjustment_set: tuple[str, ...]
|
|
78
|
+
reason: IdentificationReason
|
|
79
|
+
search_truncated: bool = False
|
|
80
|
+
n_near_miss_confounders: int = 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def mutilated_graph(G: nx.DiGraph, treatment: str) -> nx.DiGraph:
|
|
84
|
+
"""G with every edge into ``treatment`` removed. G is not modified."""
|
|
85
|
+
mutilated = G.copy()
|
|
86
|
+
mutilated.remove_edges_from(list(G.in_edges(treatment)))
|
|
87
|
+
return mutilated
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def satisfies_backdoor(
|
|
91
|
+
G: nx.DiGraph,
|
|
92
|
+
treatment: str,
|
|
93
|
+
outcome: str,
|
|
94
|
+
adjustment: frozenset[str],
|
|
95
|
+
) -> bool:
|
|
96
|
+
"""Whether ``adjustment`` satisfies the backdoor criterion.
|
|
97
|
+
|
|
98
|
+
``G`` must be a DAG containing both nodes; callers reach this through
|
|
99
|
+
``find_adjustment_set``, which guarantees that.
|
|
100
|
+
"""
|
|
101
|
+
if treatment in adjustment or outcome in adjustment:
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
if adjustment & nx.descendants(G, treatment):
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
# The backdoor criterion asks whether Z blocks every path between
|
|
108
|
+
# X and Y that starts with an arrow *into* X -- it says nothing
|
|
109
|
+
# about the causal path itself. Testing that with a generic
|
|
110
|
+
# d-separation routine means removing X's *out*-edges first, so the
|
|
111
|
+
# direct causal edge (and any directed path through it) cannot
|
|
112
|
+
# register as "still connected" and mask an open backdoor path.
|
|
113
|
+
# This is a different mutilation from ``mutilated_graph``, which
|
|
114
|
+
# removes edges *into* the treatment for the interventional graph
|
|
115
|
+
# G_X-bar used elsewhere (e.g. the do-operator's truncated
|
|
116
|
+
# factorization); the two must not be confused.
|
|
117
|
+
backdoor_graph = G.copy()
|
|
118
|
+
backdoor_graph.remove_edges_from(list(G.out_edges(treatment)))
|
|
119
|
+
|
|
120
|
+
return bool(
|
|
121
|
+
nx.is_d_separator(
|
|
122
|
+
backdoor_graph,
|
|
123
|
+
{treatment},
|
|
124
|
+
{outcome},
|
|
125
|
+
set(adjustment),
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def confounding_sensitivity(
|
|
131
|
+
dag: nx.DiGraph,
|
|
132
|
+
full_graph: nx.DiGraph,
|
|
133
|
+
treatment: str,
|
|
134
|
+
outcome: str,
|
|
135
|
+
) -> int:
|
|
136
|
+
"""Count near-miss confounders of ``treatment`` and ``outcome``.
|
|
137
|
+
|
|
138
|
+
A near miss is a node that reaches the treatment in the causal DAG
|
|
139
|
+
and carries some edge to the outcome in the full graph that was not
|
|
140
|
+
admissible -- a literature association, a shared pathway. Each one is
|
|
141
|
+
a confounder the graph hints at but does not license adjusting for.
|
|
142
|
+
"""
|
|
143
|
+
if treatment not in dag:
|
|
144
|
+
return 0
|
|
145
|
+
|
|
146
|
+
ancestors = nx.ancestors(dag, treatment)
|
|
147
|
+
admissible_parents = set(dag.predecessors(outcome)) if outcome in dag else set()
|
|
148
|
+
|
|
149
|
+
return sum(
|
|
150
|
+
1
|
|
151
|
+
for node in ancestors
|
|
152
|
+
if node not in admissible_parents
|
|
153
|
+
and full_graph.has_edge(node, outcome)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def find_adjustment_set(
|
|
158
|
+
G: nx.DiGraph,
|
|
159
|
+
treatment: str,
|
|
160
|
+
outcome: str,
|
|
161
|
+
) -> Identification:
|
|
162
|
+
"""Search for a minimal valid backdoor adjustment set.
|
|
163
|
+
|
|
164
|
+
``G`` is the *full* disease graph. Restriction to the causal subgraph
|
|
165
|
+
and to its acyclic core happens here, so no caller can forget it.
|
|
166
|
+
"""
|
|
167
|
+
causal = causal_subgraph(G)
|
|
168
|
+
core = acyclic_core(causal)
|
|
169
|
+
dag = core.dag
|
|
170
|
+
|
|
171
|
+
if treatment in core.excluded or outcome in core.excluded:
|
|
172
|
+
return Identification(
|
|
173
|
+
identifiable=False,
|
|
174
|
+
adjustment_set=(),
|
|
175
|
+
reason=IdentificationReason.CYCLIC_COMPONENT,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# TREATMENT_ABSENT / OUTCOME_ABSENT mean the node does not exist in
|
|
179
|
+
# the graph at all -- a typo, a stale ID. That is a different fact
|
|
180
|
+
# from a node that exists but has no causal-admissible edge: such a
|
|
181
|
+
# node is dropped from ``dag`` by causal_subgraph, and reporting it
|
|
182
|
+
# as "absent" would blame the query when the honest complaint is
|
|
183
|
+
# that there is no causal path in what evidence exists. So presence
|
|
184
|
+
# is checked against the full input graph, not against ``dag``.
|
|
185
|
+
if treatment not in G:
|
|
186
|
+
return Identification(
|
|
187
|
+
identifiable=False,
|
|
188
|
+
adjustment_set=(),
|
|
189
|
+
reason=IdentificationReason.TREATMENT_ABSENT,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
if outcome not in G:
|
|
193
|
+
return Identification(
|
|
194
|
+
identifiable=False,
|
|
195
|
+
adjustment_set=(),
|
|
196
|
+
reason=IdentificationReason.OUTCOME_ABSENT,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
if (
|
|
200
|
+
treatment not in dag
|
|
201
|
+
or outcome not in dag
|
|
202
|
+
or not nx.has_path(dag, treatment, outcome)
|
|
203
|
+
):
|
|
204
|
+
return Identification(
|
|
205
|
+
identifiable=False,
|
|
206
|
+
adjustment_set=(),
|
|
207
|
+
reason=IdentificationReason.NO_CAUSAL_PATH,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
if satisfies_backdoor(dag, treatment, outcome, frozenset()):
|
|
211
|
+
return Identification(
|
|
212
|
+
identifiable=True,
|
|
213
|
+
adjustment_set=(),
|
|
214
|
+
reason=IdentificationReason.IDENTIFIABLE_TRIVIALLY,
|
|
215
|
+
n_near_miss_confounders=confounding_sensitivity(
|
|
216
|
+
dag, G, treatment, outcome,
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
descendants = nx.descendants(dag, treatment)
|
|
221
|
+
pool = sorted(
|
|
222
|
+
(
|
|
223
|
+
node for node in dag.nodes()
|
|
224
|
+
if node not in descendants
|
|
225
|
+
and node not in (treatment, outcome)
|
|
226
|
+
),
|
|
227
|
+
key=lambda n: (-dag.nodes[n].get("score", 0.0), n),
|
|
228
|
+
)
|
|
229
|
+
truncated = len(pool) > MAX_CANDIDATE_POOL
|
|
230
|
+
pool = pool[:MAX_CANDIDATE_POOL]
|
|
231
|
+
|
|
232
|
+
for size in range(1, MAX_ADJUSTMENT_SET_SIZE + 1):
|
|
233
|
+
for candidate in combinations(pool, size):
|
|
234
|
+
if satisfies_backdoor(dag, treatment, outcome, frozenset(candidate)):
|
|
235
|
+
return Identification(
|
|
236
|
+
identifiable=True,
|
|
237
|
+
adjustment_set=tuple(sorted(candidate)),
|
|
238
|
+
reason=IdentificationReason.IDENTIFIABLE_BY_ADJUSTMENT,
|
|
239
|
+
search_truncated=truncated,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
return Identification(
|
|
243
|
+
identifiable=False,
|
|
244
|
+
adjustment_set=(),
|
|
245
|
+
reason=IdentificationReason.NO_VALID_ADJUSTMENT_SET,
|
|
246
|
+
search_truncated=truncated or len(pool) > MAX_ADJUSTMENT_SET_SIZE,
|
|
247
|
+
)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Which edges may carry a causal claim.
|
|
3
|
+
|
|
4
|
+
A disease graph assembled from public sources mixes two kinds of edge.
|
|
5
|
+
Some assert a mechanism with a direction: OmniPath's signed regulatory
|
|
6
|
+
interactions, where a curator has said that A acts on B. Others assert
|
|
7
|
+
only that two things occur together: a literature co-mention, membership
|
|
8
|
+
of a shared pathway, an undirected protein interaction whose arrow exists
|
|
9
|
+
because one protein landed in the first column of an API response.
|
|
10
|
+
|
|
11
|
+
Do-calculus over the second kind assumes the very thing a causal method
|
|
12
|
+
is supposed to establish. So this module partitions the edge types and
|
|
13
|
+
identification runs only on the causal-admissible subgraph. Associational
|
|
14
|
+
edges keep contributing to evidence scoring; they stop being arrows.
|
|
15
|
+
|
|
16
|
+
Gene -> disease is admitted only on genetic evidence. A germline variant
|
|
17
|
+
is randomised at conception, so a genetic association carries a
|
|
18
|
+
natural-experiment warrant that a co-mention does not -- the Mendelian
|
|
19
|
+
randomisation argument (Davey Smith & Ebrahim, 2003, Int J Epidemiol
|
|
20
|
+
32:1-22). That warrant is what earns the arrow.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
|
|
27
|
+
import networkx as nx
|
|
28
|
+
|
|
29
|
+
# Directed, mechanistic, with an asserted direction.
|
|
30
|
+
CAUSAL_EDGE_TYPES: frozenset[str] = frozenset({
|
|
31
|
+
"activates",
|
|
32
|
+
"inhibits",
|
|
33
|
+
"phosphorylates",
|
|
34
|
+
"regulates",
|
|
35
|
+
"upregulates",
|
|
36
|
+
"downregulates",
|
|
37
|
+
"causes",
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
# Gene -> disease edge types, admissible only under genetic evidence.
|
|
41
|
+
_ASSOCIATION_EDGE_TYPES: frozenset[str] = frozenset({"associated_with"})
|
|
42
|
+
|
|
43
|
+
GENETIC_EVIDENCE_CLASSES: frozenset[str] = frozenset({
|
|
44
|
+
"genetic_association",
|
|
45
|
+
"somatic_mutation",
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"CAUSAL_EDGE_TYPES",
|
|
50
|
+
"GENETIC_EVIDENCE_CLASSES",
|
|
51
|
+
"AcyclicCore",
|
|
52
|
+
"acyclic_core",
|
|
53
|
+
"causal_subgraph",
|
|
54
|
+
"cyclic_components",
|
|
55
|
+
"is_causal_admissible",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def is_causal_admissible(attrs: dict) -> bool:
|
|
60
|
+
"""Whether an edge with these attributes may carry a causal claim.
|
|
61
|
+
|
|
62
|
+
Parameters
|
|
63
|
+
----------
|
|
64
|
+
attrs
|
|
65
|
+
Edge attribute mapping, as stored on the NetworkX graph. Reads
|
|
66
|
+
``edge_type`` and ``evidence_class``.
|
|
67
|
+
"""
|
|
68
|
+
edge_type = attrs.get("edge_type", "")
|
|
69
|
+
if edge_type in CAUSAL_EDGE_TYPES:
|
|
70
|
+
return True
|
|
71
|
+
if edge_type in _ASSOCIATION_EDGE_TYPES:
|
|
72
|
+
return attrs.get("evidence_class", "") in GENETIC_EVIDENCE_CLASSES
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def causal_subgraph(G: nx.DiGraph) -> nx.DiGraph:
|
|
77
|
+
"""The subgraph of causal-admissible edges, attributes preserved.
|
|
78
|
+
|
|
79
|
+
Nodes left with no admissible edge are dropped: a node that
|
|
80
|
+
participates in no causal relation cannot be a treatment, an
|
|
81
|
+
outcome, or a confounder.
|
|
82
|
+
"""
|
|
83
|
+
admissible = [
|
|
84
|
+
(u, v) for u, v, attrs in G.edges(data=True)
|
|
85
|
+
if is_causal_admissible(attrs)
|
|
86
|
+
]
|
|
87
|
+
return G.edge_subgraph(admissible).copy()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def cyclic_components(G: nx.DiGraph) -> list[frozenset[str]]:
|
|
91
|
+
"""Strongly connected components with cycles, including self-loops."""
|
|
92
|
+
cyclic = []
|
|
93
|
+
for component in nx.strongly_connected_components(G):
|
|
94
|
+
# Non-trivial SCC (more than one node)
|
|
95
|
+
if len(component) > 1:
|
|
96
|
+
cyclic.append(frozenset(component))
|
|
97
|
+
# Single node with self-loop
|
|
98
|
+
elif len(component) == 1:
|
|
99
|
+
node = next(iter(component))
|
|
100
|
+
if G.has_edge(node, node):
|
|
101
|
+
cyclic.append(frozenset(component))
|
|
102
|
+
return cyclic
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class AcyclicCore:
|
|
107
|
+
"""A DAG carved out of a possibly-cyclic graph, and what was removed.
|
|
108
|
+
|
|
109
|
+
``excluded`` names every node that sat in a feedback loop. Those
|
|
110
|
+
nodes are not silently repaired: d-separation is undefined on them,
|
|
111
|
+
and identification reports ``cyclic_component`` rather than guessing
|
|
112
|
+
an acyclic orientation.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
dag: nx.DiGraph
|
|
116
|
+
excluded: frozenset[str]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def acyclic_core(G: nx.DiGraph) -> AcyclicCore:
|
|
120
|
+
"""Remove every node in a strongly connected component with cycles.
|
|
121
|
+
|
|
122
|
+
Biological regulatory networks are full of feedback, and
|
|
123
|
+
``nx.is_d_separator`` requires a DAG of the whole graph -- not merely
|
|
124
|
+
of the neighbourhood under test. So cyclic nodes come out entirely,
|
|
125
|
+
and the caller is told which.
|
|
126
|
+
"""
|
|
127
|
+
cyclic_nodes: set[str] = set()
|
|
128
|
+
for component in cyclic_components(G):
|
|
129
|
+
cyclic_nodes |= set(component)
|
|
130
|
+
|
|
131
|
+
dag = G.copy()
|
|
132
|
+
dag.remove_nodes_from(cyclic_nodes)
|
|
133
|
+
return AcyclicCore(dag=dag, excluded=frozenset(cyclic_nodes))
|
causalphylo/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: causalphylo
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pearl's backdoor criterion over knowledge graphs — separates edges that may carry a causal claim from those that only assert co-occurrence
|
|
5
|
+
Project-URL: Homepage, https://github.com/Neoforge-Labs/causalphylo
|
|
6
|
+
Project-URL: Documentation, https://causalphylo.neoforgelabs.tech/
|
|
7
|
+
Project-URL: Repository, https://github.com/Neoforge-Labs/causalphylo
|
|
8
|
+
Project-URL: Issues, https://github.com/Neoforge-Labs/causalphylo/issues
|
|
9
|
+
Author: Kelyn Paul Njeri
|
|
10
|
+
License-Expression: LicenseRef-CausalPhylo-NonCommercial
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: backdoor-criterion,causal-inference,do-calculus,identification,knowledge-graph,networkx,pearl
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.12
|
|
23
|
+
Requires-Dist: networkx>=3.3
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# CausalPhylo
|
|
27
|
+
|
|
28
|
+
> Identification over knowledge graphs, where not every edge has earned its arrow.
|
|
29
|
+
|
|
30
|
+
[](https://python.org)
|
|
31
|
+
[](LICENSE)
|
|
32
|
+
[](https://causalphylo.neoforgelabs.tech/)
|
|
33
|
+
|
|
34
|
+
**CausalPhylo** implements Pearl's backdoor criterion over
|
|
35
|
+
[NetworkX](https://networkx.org) directed graphs, together with the
|
|
36
|
+
admissibility rules that decide which edges may carry a causal claim in the
|
|
37
|
+
first place. It was extracted from [NeoRx](https://github.com/Neoforge-Labs/neorx),
|
|
38
|
+
where it is the engine behind causal drug-target identification.
|
|
39
|
+
|
|
40
|
+
It has one runtime dependency: NetworkX.
|
|
41
|
+
|
|
42
|
+
**Full documentation: [causalphylo.neoforgelabs.tech](https://causalphylo.neoforgelabs.tech/)**
|
|
43
|
+
|
|
44
|
+
## The problem it solves
|
|
45
|
+
|
|
46
|
+
A graph assembled from public sources mixes two kinds of edge.
|
|
47
|
+
|
|
48
|
+
Some assert a **mechanism with a direction** — a curated regulatory
|
|
49
|
+
interaction where someone has established that A acts on B. Others assert only
|
|
50
|
+
that two things **occur together**: a literature co-mention, membership of a
|
|
51
|
+
shared pathway, an undirected protein interaction whose arrow exists only
|
|
52
|
+
because one protein landed in the first column of an API response.
|
|
53
|
+
|
|
54
|
+
Running do-calculus over the second kind assumes the very thing a causal
|
|
55
|
+
method is supposed to establish. So CausalPhylo partitions the edges first,
|
|
56
|
+
and identification runs only on the causal-admissible subgraph. Associational
|
|
57
|
+
edges are not discarded — they stop being arrows.
|
|
58
|
+
|
|
59
|
+
Gene → disease edges are admitted only on genetic evidence. A germline variant
|
|
60
|
+
is randomised at conception, so a genetic association carries a
|
|
61
|
+
natural-experiment warrant that a co-mention does not — the Mendelian
|
|
62
|
+
randomisation argument ([Davey Smith & Ebrahim, 2003](https://doi.org/10.1093/ije/dyg070),
|
|
63
|
+
*Int J Epidemiol* 32:1–22). That warrant is what earns the arrow.
|
|
64
|
+
|
|
65
|
+
## Install
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
uv pip install causalphylo
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Use
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
import networkx as nx
|
|
75
|
+
from causalphylo import find_adjustment_set
|
|
76
|
+
|
|
77
|
+
# The textbook confounding shape: U is a common cause of X and D.
|
|
78
|
+
G = nx.DiGraph()
|
|
79
|
+
G.add_edge("U", "X", edge_type="activates", evidence_class="regulatory")
|
|
80
|
+
G.add_edge("U", "D", edge_type="associated_with", evidence_class="genetic_association")
|
|
81
|
+
G.add_edge("X", "D", edge_type="associated_with", evidence_class="genetic_association")
|
|
82
|
+
|
|
83
|
+
verdict = find_adjustment_set(G, "X", "D")
|
|
84
|
+
|
|
85
|
+
verdict.identifiable # True
|
|
86
|
+
verdict.adjustment_set # ('U',)
|
|
87
|
+
verdict.reason.value # 'identifiable_by_adjustment'
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`find_adjustment_set` takes the **full** graph. Restriction to the admissible
|
|
91
|
+
subgraph and to its acyclic core happens inside, so no caller can forget it.
|
|
92
|
+
|
|
93
|
+
Edges carry two attributes that the admissibility rules read: `edge_type` and
|
|
94
|
+
`evidence_class`. Nothing else about your graph is assumed — CausalPhylo has no
|
|
95
|
+
domain model of its own.
|
|
96
|
+
|
|
97
|
+
## It tells you *why* it failed
|
|
98
|
+
|
|
99
|
+
A method that reports only pass/fail gives you a pile of prose to read. These
|
|
100
|
+
reasons are a closed enum, so a run's failures aggregate into a distribution:
|
|
101
|
+
|
|
102
|
+
| `reason` | Meaning |
|
|
103
|
+
|---|---|
|
|
104
|
+
| `identifiable_by_adjustment` | A valid backdoor adjustment set was found |
|
|
105
|
+
| `identifiable_trivially` | No confounder appears in the graph (see the caveat below) |
|
|
106
|
+
| `no_causal_path` | No admissible directed path from treatment to outcome |
|
|
107
|
+
| `cyclic_component` | Treatment or outcome sits in a feedback loop; d-separation is undefined there |
|
|
108
|
+
| `no_valid_adjustment_set` | Confounders exist, but no admissible subset blocks every backdoor path |
|
|
109
|
+
| `treatment_absent` / `outcome_absent` | Node is not in the admissible subgraph |
|
|
110
|
+
|
|
111
|
+
**The trivial case is not a clean bill of health.** A knowledge graph is
|
|
112
|
+
open-world: a missing edge is missing knowledge, not evidence of no
|
|
113
|
+
confounding. So a trivial verdict carries `n_near_miss_confounders` — how many
|
|
114
|
+
nodes regulate the treatment and have *some* association with the outcome that
|
|
115
|
+
fell short of admissibility. A target with many near misses is fragile. This is
|
|
116
|
+
the same move as an E-value in observational epidemiology: not "there is no
|
|
117
|
+
confounding", but "here is how much it would take to break this".
|
|
118
|
+
|
|
119
|
+
`search_truncated` reports when the subset search hit its bound
|
|
120
|
+
(`MAX_ADJUSTMENT_SET_SIZE`, `MAX_CANDIDATE_POOL`) rather than exhausting the
|
|
121
|
+
space. It is never hidden.
|
|
122
|
+
|
|
123
|
+
## API
|
|
124
|
+
|
|
125
|
+
**`causalphylo.graph_semantics`** — which edges may carry a causal claim
|
|
126
|
+
|
|
127
|
+
| | |
|
|
128
|
+
|---|---|
|
|
129
|
+
| `is_causal_admissible(attrs)` | Whether an edge with these attributes may carry a causal claim |
|
|
130
|
+
| `causal_subgraph(G)` | The subgraph of admissible edges, attributes preserved |
|
|
131
|
+
| `cyclic_components(G)` | Strongly connected components with cycles, self-loops included |
|
|
132
|
+
| `acyclic_core(G)` | A DAG carved out of a cyclic graph, plus the nodes removed |
|
|
133
|
+
| `CAUSAL_EDGE_TYPES`, `GENETIC_EVIDENCE_CLASSES` | The admissibility vocabularies |
|
|
134
|
+
|
|
135
|
+
**`causalphylo.backdoor`** — identification on the admissible subgraph
|
|
136
|
+
|
|
137
|
+
| | |
|
|
138
|
+
|---|---|
|
|
139
|
+
| `find_adjustment_set(G, treatment, outcome)` | Search for a minimal valid adjustment set |
|
|
140
|
+
| `satisfies_backdoor(G, treatment, outcome, adjustment)` | Whether `adjustment` (a frozenset) satisfies the criterion |
|
|
141
|
+
| `mutilated_graph(G, treatment)` | G with edges *into* the treatment removed — the interventional graph G<sub>X̄</sub> |
|
|
142
|
+
| `confounding_sensitivity(dag, full_graph, treatment, outcome)` | Near-miss confounder count behind a trivial verdict |
|
|
143
|
+
|
|
144
|
+
Cyclic nodes are not silently repaired. `acyclic_core` removes every node in a
|
|
145
|
+
feedback loop and names them in `excluded`, because `nx.is_d_separator` requires
|
|
146
|
+
a DAG of the whole graph — not merely of the neighbourhood under test.
|
|
147
|
+
|
|
148
|
+
## Development
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
uv venv && uv pip install -e . --group dev
|
|
152
|
+
.venv/bin/python -m pytest
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Licence
|
|
156
|
+
|
|
157
|
+
CausalPhylo Source Available License — non-commercial use permitted, commercial
|
|
158
|
+
use requires a separate licence. See [LICENSE](LICENSE).
|
|
159
|
+
|
|
160
|
+
Copyright (c) 2026 Kelyn Paul Njeri, NeoForge Labs.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
causalphylo/__init__.py,sha256=VD6W3ih7dPHRcGFDNeIinGcJcidrjn5yp85rUtHwxBc,2415
|
|
2
|
+
causalphylo/backdoor.py,sha256=-hosnpyr3oxcHidyesQi4ES_eM7Y64n8TLesSdDjCIA,8755
|
|
3
|
+
causalphylo/graph_semantics.py,sha256=c1MRJEr0sgo1e2ZjO88ZW9pJz72Dq1ELwNyA2lSmbSg,4426
|
|
4
|
+
causalphylo/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
causalphylo-0.1.0.dist-info/METADATA,sha256=tvoCwZycWAt2WhAT4ktlIF3YYK1uoIk9QBUcpOfLAw4,7316
|
|
6
|
+
causalphylo-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
7
|
+
causalphylo-0.1.0.dist-info/licenses/LICENSE,sha256=6PJ5o2-hp6p63jiQibT78GkNThqHz5n7I34PObslgmM,1654
|
|
8
|
+
causalphylo-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
CausalPhylo Source Available License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kelyn Paul Njeri, NeoForge Labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to use,
|
|
7
|
+
copy, modify, and distribute the Software for NON-COMMERCIAL PURPOSES ONLY,
|
|
8
|
+
subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
1. NON-COMMERCIAL USE PERMITTED. You may use, copy, modify, merge, and
|
|
11
|
+
distribute the Software and derivative works thereof for personal,
|
|
12
|
+
academic, educational, and research purposes at no charge.
|
|
13
|
+
|
|
14
|
+
2. COMMERCIAL USE PROHIBITED WITHOUT LICENCE. You may NOT use the Software,
|
|
15
|
+
or any derivative works thereof, for commercial purposes — including but
|
|
16
|
+
not limited to: selling the Software or services built on it, incorporating
|
|
17
|
+
it into commercial products, or using it to generate revenue — without
|
|
18
|
+
obtaining a separate commercial licence from the copyright holder.
|
|
19
|
+
|
|
20
|
+
To obtain a commercial licence, contact: kelyn@neoforgelabs.tech
|
|
21
|
+
|
|
22
|
+
3. ATTRIBUTION. The above copyright notice and this permission notice shall
|
|
23
|
+
be included in all copies or substantial portions of the Software.
|
|
24
|
+
|
|
25
|
+
4. NO WARRANTY. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
|
|
26
|
+
KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
27
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
|
|
28
|
+
NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
|
29
|
+
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
|
30
|
+
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
|
31
|
+
USE OR OTHER DEALINGS IN THE SOFTWARE.
|