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.
@@ -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
+ ]
@@ -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
+ [![Python 3.12+](https://img.shields.io/badge/python-3.12%2B-blue.svg)](https://python.org)
31
+ [![License](https://img.shields.io/badge/license-Non--Commercial-orange.svg)](LICENSE)
32
+ [![Docs](https://img.shields.io/badge/docs-causalphylo.neoforgelabs.tech-teal.svg)](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&#772;</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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.