causalphylo 0.1.0__tar.gz
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-0.1.0/.gitignore +79 -0
- causalphylo-0.1.0/CHANGELOG.md +49 -0
- causalphylo-0.1.0/LICENSE +31 -0
- causalphylo-0.1.0/PKG-INFO +160 -0
- causalphylo-0.1.0/README.md +135 -0
- causalphylo-0.1.0/docs/CNAME +1 -0
- causalphylo-0.1.0/docs/admissibility.md +132 -0
- causalphylo-0.1.0/docs/api.md +54 -0
- causalphylo-0.1.0/docs/identification.md +176 -0
- causalphylo-0.1.0/docs/index.md +99 -0
- causalphylo-0.1.0/docs/licence.md +32 -0
- causalphylo-0.1.0/mkdocs.yml +74 -0
- causalphylo-0.1.0/pyproject.toml +129 -0
- causalphylo-0.1.0/src/causalphylo/__init__.py +80 -0
- causalphylo-0.1.0/src/causalphylo/backdoor.py +247 -0
- causalphylo-0.1.0/src/causalphylo/graph_semantics.py +133 -0
- causalphylo-0.1.0/src/causalphylo/py.typed +0 -0
- causalphylo-0.1.0/tests/test_backdoor.py +180 -0
- causalphylo-0.1.0/tests/test_graph_semantics.py +184 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Python-generated files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[oc]
|
|
4
|
+
build/
|
|
5
|
+
dist/
|
|
6
|
+
wheels/
|
|
7
|
+
*.egg-info
|
|
8
|
+
|
|
9
|
+
# Virtual environments
|
|
10
|
+
.venv
|
|
11
|
+
|
|
12
|
+
# Environment files (not .env.example)
|
|
13
|
+
.env
|
|
14
|
+
.env.local
|
|
15
|
+
.env.production
|
|
16
|
+
|
|
17
|
+
# IDE
|
|
18
|
+
.idea/
|
|
19
|
+
.vscode/
|
|
20
|
+
*.swp
|
|
21
|
+
*.swo
|
|
22
|
+
*~
|
|
23
|
+
|
|
24
|
+
# OS
|
|
25
|
+
.DS_Store
|
|
26
|
+
Thumbs.db
|
|
27
|
+
|
|
28
|
+
# Test / coverage
|
|
29
|
+
.pytest_cache/
|
|
30
|
+
htmlcov/
|
|
31
|
+
.coverage
|
|
32
|
+
.coverage.*
|
|
33
|
+
coverage.xml
|
|
34
|
+
|
|
35
|
+
# Generated outputs
|
|
36
|
+
reports/
|
|
37
|
+
results/
|
|
38
|
+
|
|
39
|
+
# Molecular data caches
|
|
40
|
+
modules/dockbot/cache/
|
|
41
|
+
*.pdb
|
|
42
|
+
*.pdbqt
|
|
43
|
+
*.sdf
|
|
44
|
+
*.mol2
|
|
45
|
+
|
|
46
|
+
# Jupyter
|
|
47
|
+
.ipynb_checkpoints/
|
|
48
|
+
|
|
49
|
+
# Model weights (too large for git)
|
|
50
|
+
*.pt
|
|
51
|
+
*.pth
|
|
52
|
+
*.h5
|
|
53
|
+
*.pkl
|
|
54
|
+
*.ckpt
|
|
55
|
+
|
|
56
|
+
# Shipped model asset — must be committed and packaged (see pyproject artifacts)
|
|
57
|
+
!src/neorx/genmol/assets/*.pt
|
|
58
|
+
|
|
59
|
+
# ChEMBL SQLite database (28 GB — too large for git)
|
|
60
|
+
chembl_*.db
|
|
61
|
+
|
|
62
|
+
# Local backups and trained-model checkpoints -- large and machine-specific,
|
|
63
|
+
# never meant to be committed (a `git add -A` would otherwise pull in tens
|
|
64
|
+
# of MB of these).
|
|
65
|
+
.backup-scripts/
|
|
66
|
+
checkpoints/
|
|
67
|
+
|
|
68
|
+
# Experiment run records are deliverables, not scratch -- they are the
|
|
69
|
+
# evidence behind every reported number, IF they are cited. See
|
|
70
|
+
# docs/run-manifest.toml. Default to NOT tracking a run directory (so a
|
|
71
|
+
# routine `git add -A` cannot sweep in an ad-hoc or smoke run); a record
|
|
72
|
+
# that actually backs a claim is added deliberately with
|
|
73
|
+
# `git add -f runs/<run-id>`.
|
|
74
|
+
!runs/
|
|
75
|
+
runs/*
|
|
76
|
+
!runs/.gitkeep
|
|
77
|
+
|
|
78
|
+
# MkDocs build output
|
|
79
|
+
site/
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to CausalPhylo will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] — 2026-10-17
|
|
11
|
+
|
|
12
|
+
First release as a standalone package.
|
|
13
|
+
|
|
14
|
+
`graph_semantics` and `backdoor` were written inside
|
|
15
|
+
[NeoRx](https://github.com/Neoforge-Labs/neorx) and extracted here with their
|
|
16
|
+
commit history. They were the part of NeoRx's causal code that depended on
|
|
17
|
+
nothing but NetworkX, while NeoRx itself imported admissibility back out of
|
|
18
|
+
them — a cycle one distribution can hold and two cannot. So the boundary was
|
|
19
|
+
drawn where the dependency graph allows a real one.
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- `causalphylo.graph_semantics` — `is_causal_admissible`, `causal_subgraph`,
|
|
24
|
+
`cyclic_components`, `acyclic_core`, and the `CAUSAL_EDGE_TYPES` /
|
|
25
|
+
`GENETIC_EVIDENCE_CLASSES` vocabularies. Which edges may carry a causal
|
|
26
|
+
claim, and a DAG carved out of a graph with feedback loops that names what
|
|
27
|
+
it removed.
|
|
28
|
+
- `causalphylo.backdoor` — `find_adjustment_set`, `satisfies_backdoor`,
|
|
29
|
+
`mutilated_graph`, `confounding_sensitivity`, and the `Identification` /
|
|
30
|
+
`IdentificationReason` verdict types. Pearl's backdoor criterion, reporting
|
|
31
|
+
*why* identification failed as a closed enum rather than as prose, and
|
|
32
|
+
carrying `n_near_miss_confounders` so a trivial verdict cannot be mistaken
|
|
33
|
+
for an absence of confounding.
|
|
34
|
+
- Documentation at [causalphylo.neoforgelabs.tech](https://causalphylo.neoforgelabs.tech/).
|
|
35
|
+
Every `>>>` example in it runs as part of the test suite.
|
|
36
|
+
|
|
37
|
+
### Changed from the NeoRx original
|
|
38
|
+
|
|
39
|
+
Behaviour is unchanged. Three things were cleaned up rather than carried:
|
|
40
|
+
|
|
41
|
+
- `IdentificationReason` is a `StrEnum` instead of inheriting `str` and `Enum`
|
|
42
|
+
separately. Member values and identity comparisons are identical; every
|
|
43
|
+
consumer read `.value` or compared with `is`, which was checked before the
|
|
44
|
+
change and is covered by NeoRx's own suite.
|
|
45
|
+
- A test asserting that `Identification` is immutable caught a blind
|
|
46
|
+
`Exception`, which a typo would also have satisfied. It names
|
|
47
|
+
`FrozenInstanceError` now.
|
|
48
|
+
- An unused import of `confounding_sensitivity` is gone. Both of its tests
|
|
49
|
+
reach it through `find_adjustment_set`; it still has no direct unit test.
|
|
@@ -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.
|
|
@@ -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,135 @@
|
|
|
1
|
+
# CausalPhylo
|
|
2
|
+
|
|
3
|
+
> Identification over knowledge graphs, where not every edge has earned its arrow.
|
|
4
|
+
|
|
5
|
+
[](https://python.org)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](https://causalphylo.neoforgelabs.tech/)
|
|
8
|
+
|
|
9
|
+
**CausalPhylo** implements Pearl's backdoor criterion over
|
|
10
|
+
[NetworkX](https://networkx.org) directed graphs, together with the
|
|
11
|
+
admissibility rules that decide which edges may carry a causal claim in the
|
|
12
|
+
first place. It was extracted from [NeoRx](https://github.com/Neoforge-Labs/neorx),
|
|
13
|
+
where it is the engine behind causal drug-target identification.
|
|
14
|
+
|
|
15
|
+
It has one runtime dependency: NetworkX.
|
|
16
|
+
|
|
17
|
+
**Full documentation: [causalphylo.neoforgelabs.tech](https://causalphylo.neoforgelabs.tech/)**
|
|
18
|
+
|
|
19
|
+
## The problem it solves
|
|
20
|
+
|
|
21
|
+
A graph assembled from public sources mixes two kinds of edge.
|
|
22
|
+
|
|
23
|
+
Some assert a **mechanism with a direction** — a curated regulatory
|
|
24
|
+
interaction where someone has established that A acts on B. Others assert only
|
|
25
|
+
that two things **occur together**: a literature co-mention, membership of a
|
|
26
|
+
shared pathway, an undirected protein interaction whose arrow exists only
|
|
27
|
+
because one protein landed in the first column of an API response.
|
|
28
|
+
|
|
29
|
+
Running do-calculus over the second kind assumes the very thing a causal
|
|
30
|
+
method is supposed to establish. So CausalPhylo partitions the edges first,
|
|
31
|
+
and identification runs only on the causal-admissible subgraph. Associational
|
|
32
|
+
edges are not discarded — they stop being arrows.
|
|
33
|
+
|
|
34
|
+
Gene → disease edges are admitted only on genetic evidence. A germline variant
|
|
35
|
+
is randomised at conception, so a genetic association carries a
|
|
36
|
+
natural-experiment warrant that a co-mention does not — the Mendelian
|
|
37
|
+
randomisation argument ([Davey Smith & Ebrahim, 2003](https://doi.org/10.1093/ije/dyg070),
|
|
38
|
+
*Int J Epidemiol* 32:1–22). That warrant is what earns the arrow.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
uv pip install causalphylo
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Use
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import networkx as nx
|
|
50
|
+
from causalphylo import find_adjustment_set
|
|
51
|
+
|
|
52
|
+
# The textbook confounding shape: U is a common cause of X and D.
|
|
53
|
+
G = nx.DiGraph()
|
|
54
|
+
G.add_edge("U", "X", edge_type="activates", evidence_class="regulatory")
|
|
55
|
+
G.add_edge("U", "D", edge_type="associated_with", evidence_class="genetic_association")
|
|
56
|
+
G.add_edge("X", "D", edge_type="associated_with", evidence_class="genetic_association")
|
|
57
|
+
|
|
58
|
+
verdict = find_adjustment_set(G, "X", "D")
|
|
59
|
+
|
|
60
|
+
verdict.identifiable # True
|
|
61
|
+
verdict.adjustment_set # ('U',)
|
|
62
|
+
verdict.reason.value # 'identifiable_by_adjustment'
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`find_adjustment_set` takes the **full** graph. Restriction to the admissible
|
|
66
|
+
subgraph and to its acyclic core happens inside, so no caller can forget it.
|
|
67
|
+
|
|
68
|
+
Edges carry two attributes that the admissibility rules read: `edge_type` and
|
|
69
|
+
`evidence_class`. Nothing else about your graph is assumed — CausalPhylo has no
|
|
70
|
+
domain model of its own.
|
|
71
|
+
|
|
72
|
+
## It tells you *why* it failed
|
|
73
|
+
|
|
74
|
+
A method that reports only pass/fail gives you a pile of prose to read. These
|
|
75
|
+
reasons are a closed enum, so a run's failures aggregate into a distribution:
|
|
76
|
+
|
|
77
|
+
| `reason` | Meaning |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `identifiable_by_adjustment` | A valid backdoor adjustment set was found |
|
|
80
|
+
| `identifiable_trivially` | No confounder appears in the graph (see the caveat below) |
|
|
81
|
+
| `no_causal_path` | No admissible directed path from treatment to outcome |
|
|
82
|
+
| `cyclic_component` | Treatment or outcome sits in a feedback loop; d-separation is undefined there |
|
|
83
|
+
| `no_valid_adjustment_set` | Confounders exist, but no admissible subset blocks every backdoor path |
|
|
84
|
+
| `treatment_absent` / `outcome_absent` | Node is not in the admissible subgraph |
|
|
85
|
+
|
|
86
|
+
**The trivial case is not a clean bill of health.** A knowledge graph is
|
|
87
|
+
open-world: a missing edge is missing knowledge, not evidence of no
|
|
88
|
+
confounding. So a trivial verdict carries `n_near_miss_confounders` — how many
|
|
89
|
+
nodes regulate the treatment and have *some* association with the outcome that
|
|
90
|
+
fell short of admissibility. A target with many near misses is fragile. This is
|
|
91
|
+
the same move as an E-value in observational epidemiology: not "there is no
|
|
92
|
+
confounding", but "here is how much it would take to break this".
|
|
93
|
+
|
|
94
|
+
`search_truncated` reports when the subset search hit its bound
|
|
95
|
+
(`MAX_ADJUSTMENT_SET_SIZE`, `MAX_CANDIDATE_POOL`) rather than exhausting the
|
|
96
|
+
space. It is never hidden.
|
|
97
|
+
|
|
98
|
+
## API
|
|
99
|
+
|
|
100
|
+
**`causalphylo.graph_semantics`** — which edges may carry a causal claim
|
|
101
|
+
|
|
102
|
+
| | |
|
|
103
|
+
|---|---|
|
|
104
|
+
| `is_causal_admissible(attrs)` | Whether an edge with these attributes may carry a causal claim |
|
|
105
|
+
| `causal_subgraph(G)` | The subgraph of admissible edges, attributes preserved |
|
|
106
|
+
| `cyclic_components(G)` | Strongly connected components with cycles, self-loops included |
|
|
107
|
+
| `acyclic_core(G)` | A DAG carved out of a cyclic graph, plus the nodes removed |
|
|
108
|
+
| `CAUSAL_EDGE_TYPES`, `GENETIC_EVIDENCE_CLASSES` | The admissibility vocabularies |
|
|
109
|
+
|
|
110
|
+
**`causalphylo.backdoor`** — identification on the admissible subgraph
|
|
111
|
+
|
|
112
|
+
| | |
|
|
113
|
+
|---|---|
|
|
114
|
+
| `find_adjustment_set(G, treatment, outcome)` | Search for a minimal valid adjustment set |
|
|
115
|
+
| `satisfies_backdoor(G, treatment, outcome, adjustment)` | Whether `adjustment` (a frozenset) satisfies the criterion |
|
|
116
|
+
| `mutilated_graph(G, treatment)` | G with edges *into* the treatment removed — the interventional graph G<sub>X̄</sub> |
|
|
117
|
+
| `confounding_sensitivity(dag, full_graph, treatment, outcome)` | Near-miss confounder count behind a trivial verdict |
|
|
118
|
+
|
|
119
|
+
Cyclic nodes are not silently repaired. `acyclic_core` removes every node in a
|
|
120
|
+
feedback loop and names them in `excluded`, because `nx.is_d_separator` requires
|
|
121
|
+
a DAG of the whole graph — not merely of the neighbourhood under test.
|
|
122
|
+
|
|
123
|
+
## Development
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
uv venv && uv pip install -e . --group dev
|
|
127
|
+
.venv/bin/python -m pytest
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Licence
|
|
131
|
+
|
|
132
|
+
CausalPhylo Source Available License — non-commercial use permitted, commercial
|
|
133
|
+
use requires a separate licence. See [LICENSE](LICENSE).
|
|
134
|
+
|
|
135
|
+
Copyright (c) 2026 Kelyn Paul Njeri, NeoForge Labs.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
causalphylo.neoforgelabs.tech
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Which edges earn an arrow
|
|
2
|
+
|
|
3
|
+
An arrow in a causal diagram is a claim: intervene on the tail and the head
|
|
4
|
+
responds. Most edges in a knowledge graph were never asserted with that
|
|
5
|
+
strength. CausalPhylo's first job is to say which ones were.
|
|
6
|
+
|
|
7
|
+
## The rule
|
|
8
|
+
|
|
9
|
+
An edge is causal-admissible when either:
|
|
10
|
+
|
|
11
|
+
1. its `edge_type` names a **directed mechanism** — someone has asserted that
|
|
12
|
+
the source acts on the target; or
|
|
13
|
+
2. it is a gene-to-disease **association backed by genetic evidence**.
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
>>> from causalphylo import CAUSAL_EDGE_TYPES, GENETIC_EVIDENCE_CLASSES
|
|
17
|
+
>>> sorted(CAUSAL_EDGE_TYPES)
|
|
18
|
+
['activates', 'causes', 'downregulates', 'inhibits', 'phosphorylates',
|
|
19
|
+
'regulates', 'upregulates']
|
|
20
|
+
>>> sorted(GENETIC_EVIDENCE_CLASSES)
|
|
21
|
+
['genetic_association', 'somatic_mutation']
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Everything else is associational.
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from causalphylo import is_causal_admissible
|
|
29
|
+
|
|
30
|
+
is_causal_admissible({"edge_type": "activates", "evidence_class": "regulatory"})
|
|
31
|
+
is_causal_admissible({"edge_type": "associated_with", "evidence_class": "genetic_association"})
|
|
32
|
+
is_causal_admissible({"edge_type": "associated_with", "evidence_class": "literature"})
|
|
33
|
+
is_causal_admissible({"edge_type": "interacts_with", "evidence_class": "regulatory"})
|
|
34
|
+
is_causal_admissible({"edge_type": "participates_in", "evidence_class": ""})
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
| Edge | Admissible |
|
|
38
|
+
|---|---|
|
|
39
|
+
| `activates` / `regulatory` | **True** |
|
|
40
|
+
| `associated_with` / `genetic_association` | **True** |
|
|
41
|
+
| `associated_with` / `literature` | False |
|
|
42
|
+
| `interacts_with` / `regulatory` (STRING) | False |
|
|
43
|
+
| `participates_in` (pathway membership) | False |
|
|
44
|
+
|
|
45
|
+
## Why genetic evidence is the exception
|
|
46
|
+
|
|
47
|
+
A germline variant is randomised at conception. A genetic association
|
|
48
|
+
therefore carries a natural-experiment warrant that a co-mention does not —
|
|
49
|
+
this is the Mendelian randomisation argument
|
|
50
|
+
([Davey Smith & Ebrahim, 2003](https://doi.org/10.1093/ije/dyg070),
|
|
51
|
+
*Int J Epidemiol* 32:1–22). That warrant is what earns the arrow.
|
|
52
|
+
|
|
53
|
+
A literature co-mention earns nothing. Two genes appear in one abstract for
|
|
54
|
+
reasons that include a shared assay, a shared author, and a shared review.
|
|
55
|
+
|
|
56
|
+
## Why an undirected interaction earns nothing
|
|
57
|
+
|
|
58
|
+
!!! note "STRING interactions are not arrows"
|
|
59
|
+
A protein–protein interaction is symmetric. When it reaches you as a
|
|
60
|
+
directed edge, the direction is an artefact of which protein the API
|
|
61
|
+
returned first. Admitting it would let the ordering of a response
|
|
62
|
+
determine a causal conclusion.
|
|
63
|
+
|
|
64
|
+
## Pruning the graph
|
|
65
|
+
|
|
66
|
+
`causal_subgraph` keeps the admissible edges with their attributes intact, and
|
|
67
|
+
drops nodes left with no admissible edge — a node participating in no causal
|
|
68
|
+
relation can be neither treatment, outcome, nor confounder.
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
import networkx as nx
|
|
72
|
+
from causalphylo import causal_subgraph
|
|
73
|
+
|
|
74
|
+
G = nx.DiGraph()
|
|
75
|
+
G.add_edge("U", "X", edge_type="activates", evidence_class="regulatory")
|
|
76
|
+
G.add_edge("X", "D", edge_type="associated_with", evidence_class="genetic_association")
|
|
77
|
+
G.add_edge("P", "D", edge_type="associated_with", evidence_class="literature")
|
|
78
|
+
G.add_edge("X", "pathway:1", edge_type="participates_in", evidence_class="")
|
|
79
|
+
|
|
80
|
+
sub = causal_subgraph(G)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
sorted(G.nodes()) # ['D', 'P', 'U', 'X', 'pathway:1']
|
|
85
|
+
sorted(sub.nodes()) # ['D', 'U', 'X']
|
|
86
|
+
sorted(sub.edges()) # [('U', 'X'), ('X', 'D')]
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`P` and `pathway:1` are gone. The association that linked `P` to `D` still
|
|
90
|
+
exists in your own graph and can still contribute to evidence scoring — it is
|
|
91
|
+
simply not an arrow here.
|
|
92
|
+
|
|
93
|
+
## Feedback loops
|
|
94
|
+
|
|
95
|
+
`nx.is_d_separator` requires a DAG of the **whole** graph, not merely of the
|
|
96
|
+
neighbourhood under test. Biological regulatory networks are full of feedback,
|
|
97
|
+
so cyclic nodes have to come out — and `acyclic_core` names the ones it
|
|
98
|
+
removed rather than silently repairing them.
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from causalphylo import acyclic_core
|
|
102
|
+
|
|
103
|
+
G = nx.DiGraph()
|
|
104
|
+
G.add_edge("a", "b") # a and b form a 2-cycle
|
|
105
|
+
G.add_edge("b", "a")
|
|
106
|
+
G.add_edge("b", "c")
|
|
107
|
+
G.add_edge("c", "d")
|
|
108
|
+
|
|
109
|
+
core = acyclic_core(G)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
sorted(core.excluded) # ['a', 'b']
|
|
114
|
+
sorted(core.dag.nodes()) # ['c', 'd']
|
|
115
|
+
nx.is_directed_acyclic_graph(core.dag) # True
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Self-loops count as cycles. A node that regulates itself is excluded on the
|
|
119
|
+
same grounds.
|
|
120
|
+
|
|
121
|
+
Identification involving an excluded node reports
|
|
122
|
+
`cyclic_component` rather than guessing an acyclic orientation. Choosing a
|
|
123
|
+
direction to break the loop would be inventing the mechanism the method is
|
|
124
|
+
supposed to test.
|
|
125
|
+
|
|
126
|
+
## Adapting the vocabulary
|
|
127
|
+
|
|
128
|
+
The edge types and evidence classes above are frozensets of plain strings.
|
|
129
|
+
If your graph speaks a different vocabulary, map it to these names when you
|
|
130
|
+
build the graph. CausalPhylo deliberately has no configuration layer: the
|
|
131
|
+
admissibility rule is an argument about evidence, and burying it in a settings
|
|
132
|
+
file would make it look like a preference.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# API reference
|
|
2
|
+
|
|
3
|
+
Everything below is re-exported from the top level, so
|
|
4
|
+
`from causalphylo import find_adjustment_set` and
|
|
5
|
+
`from causalphylo.backdoor import find_adjustment_set` are equivalent.
|
|
6
|
+
|
|
7
|
+
## causalphylo.graph_semantics
|
|
8
|
+
|
|
9
|
+
Which edges may carry a causal claim. See
|
|
10
|
+
[Which edges earn an arrow](admissibility.md).
|
|
11
|
+
|
|
12
|
+
::: causalphylo.graph_semantics
|
|
13
|
+
options:
|
|
14
|
+
show_root_heading: false
|
|
15
|
+
members:
|
|
16
|
+
- is_causal_admissible
|
|
17
|
+
- causal_subgraph
|
|
18
|
+
- cyclic_components
|
|
19
|
+
- acyclic_core
|
|
20
|
+
- AcyclicCore
|
|
21
|
+
|
|
22
|
+
### Vocabularies
|
|
23
|
+
|
|
24
|
+
`CAUSAL_EDGE_TYPES`
|
|
25
|
+
: Frozenset of `edge_type` values that assert a directed mechanism.
|
|
26
|
+
|
|
27
|
+
`GENETIC_EVIDENCE_CLASSES`
|
|
28
|
+
: Frozenset of `evidence_class` values that license a gene-to-disease arrow.
|
|
29
|
+
|
|
30
|
+
## causalphylo.backdoor
|
|
31
|
+
|
|
32
|
+
Identification on the admissible subgraph. See
|
|
33
|
+
[Identification](identification.md).
|
|
34
|
+
|
|
35
|
+
::: causalphylo.backdoor
|
|
36
|
+
options:
|
|
37
|
+
show_root_heading: false
|
|
38
|
+
members:
|
|
39
|
+
- find_adjustment_set
|
|
40
|
+
- Identification
|
|
41
|
+
- IdentificationReason
|
|
42
|
+
- satisfies_backdoor
|
|
43
|
+
- mutilated_graph
|
|
44
|
+
- confounding_sensitivity
|
|
45
|
+
|
|
46
|
+
### Search bounds
|
|
47
|
+
|
|
48
|
+
`MAX_ADJUSTMENT_SET_SIZE`
|
|
49
|
+
: Largest adjustment set the search will consider. Default `3`.
|
|
50
|
+
|
|
51
|
+
`MAX_CANDIDATE_POOL`
|
|
52
|
+
: Largest candidate pool the search will draw from. Default `20`.
|
|
53
|
+
|
|
54
|
+
Reaching either bound sets `Identification.search_truncated`.
|