causal-automata 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.
- causal_automata-0.1.0/NOTICE +2 -0
- causal_automata-0.1.0/PKG-INFO +141 -0
- causal_automata-0.1.0/README.md +111 -0
- causal_automata-0.1.0/causal_automata/__init__.py +14 -0
- causal_automata-0.1.0/causal_automata/bayesian.py +288 -0
- causal_automata-0.1.0/causal_automata/bayesian_synth.py +609 -0
- causal_automata-0.1.0/causal_automata/benchmark.py +450 -0
- causal_automata-0.1.0/causal_automata/jax_core.py +246 -0
- causal_automata-0.1.0/causal_automata.egg-info/PKG-INFO +141 -0
- causal_automata-0.1.0/causal_automata.egg-info/SOURCES.txt +14 -0
- causal_automata-0.1.0/causal_automata.egg-info/dependency_links.txt +1 -0
- causal_automata-0.1.0/causal_automata.egg-info/entry_points.txt +3 -0
- causal_automata-0.1.0/causal_automata.egg-info/requires.txt +24 -0
- causal_automata-0.1.0/causal_automata.egg-info/top_level.txt +1 -0
- causal_automata-0.1.0/pyproject.toml +57 -0
- causal_automata-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: causal-automata
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A blazing-fast, JAX-accelerated Cellular Automaton engine for causal discovery.
|
|
5
|
+
Author-email: Masakazu Yamagiwa <myamagiwa@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: NOTICE
|
|
10
|
+
Requires-Dist: numpy>=1.26
|
|
11
|
+
Requires-Dist: networkx>=3.2
|
|
12
|
+
Requires-Dist: scikit-learn>=1.4
|
|
13
|
+
Provides-Extra: cuda
|
|
14
|
+
Requires-Dist: jax[cuda13]; extra == "cuda"
|
|
15
|
+
Requires-Dist: torch>=2.1; extra == "cuda"
|
|
16
|
+
Requires-Dist: gcastle>=1.0.4; extra == "cuda"
|
|
17
|
+
Provides-Extra: jax
|
|
18
|
+
Requires-Dist: jax>=0.4.30; extra == "jax"
|
|
19
|
+
Requires-Dist: jaxlib>=0.4.30; extra == "jax"
|
|
20
|
+
Provides-Extra: gcastle
|
|
21
|
+
Requires-Dist: gcastle>=1.0.4; extra == "gcastle"
|
|
22
|
+
Requires-Dist: torch>=2.1; extra == "gcastle"
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
25
|
+
Provides-Extra: docs
|
|
26
|
+
Requires-Dist: sphinx>=7.0; extra == "docs"
|
|
27
|
+
Requires-Dist: sphinx-rtd-theme>=2.0; extra == "docs"
|
|
28
|
+
Requires-Dist: myst-parser>=2.0; extra == "docs"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# causal-automata
|
|
32
|
+
|
|
33
|
+
A blazing-fast, JAX-accelerated Cellular Automaton engine for causal discovery.
|
|
34
|
+
|
|
35
|
+
## Benchmark — N=100 on Tesla T4
|
|
36
|
+
|
|
37
|
+
Final figures (Colab **Tesla T4**, GOLEM `num_iter=20000`):
|
|
38
|
+
|
|
39
|
+
| Algorithm | Skeleton F1 | Directed F1 | Wall Time (s) |
|
|
40
|
+
| --- | ---: | ---: | ---: |
|
|
41
|
+
| PC (gcastle) | 0.968 | 0.813 | 528.631 |
|
|
42
|
+
| GOLEM (gcastle) | 0.577 | 0.403 | 251.182 |
|
|
43
|
+
| Bayesian CA (JAX) | 0.812 | 0.483 | 0.323 |
|
|
44
|
+
|
|
45
|
+
Bayesian CA is ~**778×** faster than GOLEM and ~**1636×** faster than PC, while beating GOLEM on both skeleton (**0.812 vs 0.577**) and directed (**0.483 vs 0.403**) F1.
|
|
46
|
+
|
|
47
|
+
## Documentation
|
|
48
|
+
|
|
49
|
+
Full docs (theory, physics, API, CLI): https://causal-automata.readthedocs.io/en/latest/
|
|
50
|
+
|
|
51
|
+
Build locally from `docs/`:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install -e ".[docs,jax]"
|
|
55
|
+
cd docs && make html
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## How it works — CA physics + V-well
|
|
59
|
+
|
|
60
|
+
State is a binary DAG adjacency `A`, node elevations `E ∈ [0,1]`, and a fixed empirical covariance `Cov` from continuous observations. Each tick is a local cellular update:
|
|
61
|
+
|
|
62
|
+
1. **Gravity** — keep only downhill edges (`E[parent] > E[child]`), then drift elevations toward the midpoint of parent/child means.
|
|
63
|
+
2. **V-structure gravity well** — absent undirected pairs that share neighbors and are marginally independent (`|ρ|` below the death threshold with empty conditioning set) mark shared neighbors as **colliders**. Those elevations are sunk (`E -= 2·lr`), so later birth prefers arrows into the collider — the classic V-structure `i → k ← j`.
|
|
64
|
+
3. **Death / birth** — kill weak edges and birth strong ones using partial correlations conditioned on the top-`k` union neighbors ranked by `|Cov|`. Birth orients by elevation after the `|ρ|` gate.
|
|
65
|
+
|
|
66
|
+
The NumPy prototype (`bayesian.step`) and the pure functional JAX port (`jax_core.step`, `@jax.jit`) share this physics.
|
|
67
|
+
|
|
68
|
+
## Install (Colab T4 / CUDA)
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
# Runtime → Change runtime type → T4 GPU
|
|
72
|
+
pip install -U "jax[cuda13]" gcastle torch networkx scikit-learn numpy
|
|
73
|
+
pip install -e .
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
CPU / portable (no CUDA wheel):
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pip install -e ".[jax,gcastle]"
|
|
80
|
+
# or: uv sync --extra jax --extra gcastle
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Full NVIDIA extra (mirrors the Colab stack in project metadata):
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
pip install -e ".[cuda]"
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Run
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
# Synthetic DAG harness (NumPy default; use --backend jax for JIT)
|
|
93
|
+
python -m causal_automata.bayesian_synth --n 10
|
|
94
|
+
python -m causal_automata.bayesian_synth --n 50 --backend jax
|
|
95
|
+
python -m causal_automata.bayesian_synth --n 100
|
|
96
|
+
|
|
97
|
+
# Benchmark vs gCastle PC / GOLEM (default N=100)
|
|
98
|
+
python -m causal_automata.benchmark
|
|
99
|
+
python -m causal_automata.benchmark --n 100 --seed 0 --ticks 100
|
|
100
|
+
python -m causal_automata.benchmark --smoke # device checks + GOLEM init only
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Smoke the JAX compile:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
python -c "from causal_automata import warmup; print(warmup())"
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Documentation
|
|
110
|
+
|
|
111
|
+
Sphinx docs (Read the Docs) live under `docs/`. Build locally:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
pip install -e ".[docs,jax]"
|
|
115
|
+
cd docs && make html
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Configuration for hosted builds is in `.readthedocs.yaml`. Theory pages
|
|
119
|
+
summarize the project manuscripts on causal / Bayesian automata; API pages
|
|
120
|
+
are generated from module docstrings via autodoc.
|
|
121
|
+
|
|
122
|
+
## Package layout
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
causal-automata/
|
|
126
|
+
├── causal_automata/
|
|
127
|
+
│ ├── __init__.py
|
|
128
|
+
│ ├── bayesian.py # NumPy Bayesian CA step
|
|
129
|
+
│ ├── bayesian_synth.py # Synthetic linear-DAG harness
|
|
130
|
+
│ ├── jax_core.py # JAX @jit port of step
|
|
131
|
+
│ └── benchmark.py # vs gCastle PC / GOLEM
|
|
132
|
+
├── docs/ # Sphinx / Read the Docs
|
|
133
|
+
├── .readthedocs.yaml
|
|
134
|
+
├── pyproject.toml
|
|
135
|
+
├── README.md
|
|
136
|
+
└── NOTICE
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## License / notice
|
|
140
|
+
|
|
141
|
+
See `NOTICE`. Copyright 2026 Masakazu Yamagiwa.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# causal-automata
|
|
2
|
+
|
|
3
|
+
A blazing-fast, JAX-accelerated Cellular Automaton engine for causal discovery.
|
|
4
|
+
|
|
5
|
+
## Benchmark — N=100 on Tesla T4
|
|
6
|
+
|
|
7
|
+
Final figures (Colab **Tesla T4**, GOLEM `num_iter=20000`):
|
|
8
|
+
|
|
9
|
+
| Algorithm | Skeleton F1 | Directed F1 | Wall Time (s) |
|
|
10
|
+
| --- | ---: | ---: | ---: |
|
|
11
|
+
| PC (gcastle) | 0.968 | 0.813 | 528.631 |
|
|
12
|
+
| GOLEM (gcastle) | 0.577 | 0.403 | 251.182 |
|
|
13
|
+
| Bayesian CA (JAX) | 0.812 | 0.483 | 0.323 |
|
|
14
|
+
|
|
15
|
+
Bayesian CA is ~**778×** faster than GOLEM and ~**1636×** faster than PC, while beating GOLEM on both skeleton (**0.812 vs 0.577**) and directed (**0.483 vs 0.403**) F1.
|
|
16
|
+
|
|
17
|
+
## Documentation
|
|
18
|
+
|
|
19
|
+
Full docs (theory, physics, API, CLI): https://causal-automata.readthedocs.io/en/latest/
|
|
20
|
+
|
|
21
|
+
Build locally from `docs/`:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install -e ".[docs,jax]"
|
|
25
|
+
cd docs && make html
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## How it works — CA physics + V-well
|
|
29
|
+
|
|
30
|
+
State is a binary DAG adjacency `A`, node elevations `E ∈ [0,1]`, and a fixed empirical covariance `Cov` from continuous observations. Each tick is a local cellular update:
|
|
31
|
+
|
|
32
|
+
1. **Gravity** — keep only downhill edges (`E[parent] > E[child]`), then drift elevations toward the midpoint of parent/child means.
|
|
33
|
+
2. **V-structure gravity well** — absent undirected pairs that share neighbors and are marginally independent (`|ρ|` below the death threshold with empty conditioning set) mark shared neighbors as **colliders**. Those elevations are sunk (`E -= 2·lr`), so later birth prefers arrows into the collider — the classic V-structure `i → k ← j`.
|
|
34
|
+
3. **Death / birth** — kill weak edges and birth strong ones using partial correlations conditioned on the top-`k` union neighbors ranked by `|Cov|`. Birth orients by elevation after the `|ρ|` gate.
|
|
35
|
+
|
|
36
|
+
The NumPy prototype (`bayesian.step`) and the pure functional JAX port (`jax_core.step`, `@jax.jit`) share this physics.
|
|
37
|
+
|
|
38
|
+
## Install (Colab T4 / CUDA)
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# Runtime → Change runtime type → T4 GPU
|
|
42
|
+
pip install -U "jax[cuda13]" gcastle torch networkx scikit-learn numpy
|
|
43
|
+
pip install -e .
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
CPU / portable (no CUDA wheel):
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install -e ".[jax,gcastle]"
|
|
50
|
+
# or: uv sync --extra jax --extra gcastle
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Full NVIDIA extra (mirrors the Colab stack in project metadata):
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install -e ".[cuda]"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Run
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# Synthetic DAG harness (NumPy default; use --backend jax for JIT)
|
|
63
|
+
python -m causal_automata.bayesian_synth --n 10
|
|
64
|
+
python -m causal_automata.bayesian_synth --n 50 --backend jax
|
|
65
|
+
python -m causal_automata.bayesian_synth --n 100
|
|
66
|
+
|
|
67
|
+
# Benchmark vs gCastle PC / GOLEM (default N=100)
|
|
68
|
+
python -m causal_automata.benchmark
|
|
69
|
+
python -m causal_automata.benchmark --n 100 --seed 0 --ticks 100
|
|
70
|
+
python -m causal_automata.benchmark --smoke # device checks + GOLEM init only
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Smoke the JAX compile:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
python -c "from causal_automata import warmup; print(warmup())"
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Documentation
|
|
80
|
+
|
|
81
|
+
Sphinx docs (Read the Docs) live under `docs/`. Build locally:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
pip install -e ".[docs,jax]"
|
|
85
|
+
cd docs && make html
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Configuration for hosted builds is in `.readthedocs.yaml`. Theory pages
|
|
89
|
+
summarize the project manuscripts on causal / Bayesian automata; API pages
|
|
90
|
+
are generated from module docstrings via autodoc.
|
|
91
|
+
|
|
92
|
+
## Package layout
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
causal-automata/
|
|
96
|
+
├── causal_automata/
|
|
97
|
+
│ ├── __init__.py
|
|
98
|
+
│ ├── bayesian.py # NumPy Bayesian CA step
|
|
99
|
+
│ ├── bayesian_synth.py # Synthetic linear-DAG harness
|
|
100
|
+
│ ├── jax_core.py # JAX @jit port of step
|
|
101
|
+
│ └── benchmark.py # vs gCastle PC / GOLEM
|
|
102
|
+
├── docs/ # Sphinx / Read the Docs
|
|
103
|
+
├── .readthedocs.yaml
|
|
104
|
+
├── pyproject.toml
|
|
105
|
+
├── README.md
|
|
106
|
+
└── NOTICE
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## License / notice
|
|
110
|
+
|
|
111
|
+
See `NOTICE`. Copyright 2026 Masakazu Yamagiwa.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""causal-automata: JAX-accelerated cellular automaton engine for causal discovery."""
|
|
2
|
+
|
|
3
|
+
from causal_automata.bayesian import step as numpy_step
|
|
4
|
+
from causal_automata.jax_core import device_info, step, warmup
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"step",
|
|
8
|
+
"numpy_step",
|
|
9
|
+
"device_info",
|
|
10
|
+
"warmup",
|
|
11
|
+
"__version__",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Bayesian CA update: DAG adjacency evolves via partial-correlation CMI.
|
|
2
|
+
|
|
3
|
+
NumPy prototype. Core state is binary adjacency ``A``, node elevations ``E``,
|
|
4
|
+
and a fixed global covariance ``Cov``. Each tick applies gravity (downhill-only
|
|
5
|
+
edges + elevation drift), a V-structure gravity well that sinks colliders,
|
|
6
|
+
then death / birth from batched partial correlations — no sequential ``N×N``
|
|
7
|
+
edge loops.
|
|
8
|
+
|
|
9
|
+
Birth evaluates all absent undirected pairs (no path-2 filter); elevation
|
|
10
|
+
chooses edge direction after the ``|rho|`` threshold is met. Death/birth
|
|
11
|
+
condition on the top-``k`` neighbors ranked by absolute covariance vs the pair.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
# Small ridge for sub-covariance inversion (linear-Gaussian precision).
|
|
19
|
+
_RIDGE = 1e-8
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _undirected_neighbors(A: np.ndarray) -> np.ndarray:
|
|
23
|
+
"""Binary undirected adjacency with zero diagonal."""
|
|
24
|
+
U = ((A + A.T) > 0).astype(np.float64)
|
|
25
|
+
np.fill_diagonal(U, 0.0)
|
|
26
|
+
return U
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _topk_by_score(
|
|
30
|
+
mask: np.ndarray, scores: np.ndarray, k: int
|
|
31
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
32
|
+
"""Take up to ``k`` masked columns per row with highest ``scores``.
|
|
33
|
+
|
|
34
|
+
Invalid (``mask`` False) entries are ignored. Ties break toward the lower
|
|
35
|
+
column index. ``Z`` is left-packed with ``-1`` padding.
|
|
36
|
+
|
|
37
|
+
Parameters
|
|
38
|
+
----------
|
|
39
|
+
mask:
|
|
40
|
+
Boolean array of shape ``(M, N)`` — candidate pool per row.
|
|
41
|
+
scores:
|
|
42
|
+
Float array of shape ``(M, N)`` — ranking scores (higher is better).
|
|
43
|
+
k:
|
|
44
|
+
Maximum number of indices to keep per row.
|
|
45
|
+
|
|
46
|
+
Returns
|
|
47
|
+
-------
|
|
48
|
+
Z, counts
|
|
49
|
+
``Z`` has shape ``(M, k)`` with ``-1`` padding; ``counts`` is ``(M,)``.
|
|
50
|
+
"""
|
|
51
|
+
M, N = mask.shape
|
|
52
|
+
if k <= 0:
|
|
53
|
+
return np.full((M, 0), -1, dtype=np.intp), np.zeros(M, dtype=np.intp)
|
|
54
|
+
|
|
55
|
+
# Invalid → -inf so they sort after all real scores under descending order.
|
|
56
|
+
ranked = np.where(mask, scores, -np.inf)
|
|
57
|
+
# Ascending sort key: lower is better → high score first; +index for ties.
|
|
58
|
+
sort_key = -ranked + np.arange(N, dtype=np.float64) * 1e-12
|
|
59
|
+
order = np.argsort(sort_key, axis=1)
|
|
60
|
+
top = order[:, :k]
|
|
61
|
+
rows = np.arange(M)[:, None]
|
|
62
|
+
valid = mask[rows, top]
|
|
63
|
+
Z = np.where(valid, top, -1).astype(np.intp)
|
|
64
|
+
counts = valid.sum(axis=1).astype(np.intp)
|
|
65
|
+
return Z, counts
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _batched_partial_corr(
|
|
69
|
+
Cov: np.ndarray,
|
|
70
|
+
I: np.ndarray,
|
|
71
|
+
J: np.ndarray,
|
|
72
|
+
Z: np.ndarray,
|
|
73
|
+
counts: np.ndarray,
|
|
74
|
+
) -> np.ndarray:
|
|
75
|
+
"""Partial correlation of ``(I[m], J[m])`` given ``Z[m, :counts[m]]``.
|
|
76
|
+
|
|
77
|
+
Groups edges by conditioning-set size and inverts stacked sub-covariances
|
|
78
|
+
with a single ``numpy.linalg.inv`` call per group.
|
|
79
|
+
"""
|
|
80
|
+
M = I.shape[0]
|
|
81
|
+
rho = np.zeros(M, dtype=np.float64)
|
|
82
|
+
if M == 0:
|
|
83
|
+
return rho
|
|
84
|
+
|
|
85
|
+
k = Z.shape[1] if Z.ndim == 2 else 0
|
|
86
|
+
for n_z in range(0, k + 1):
|
|
87
|
+
idx = np.flatnonzero(counts == n_z)
|
|
88
|
+
if idx.size == 0:
|
|
89
|
+
continue
|
|
90
|
+
parts = [I[idx], J[idx]]
|
|
91
|
+
for t in range(n_z):
|
|
92
|
+
parts.append(Z[idx, t])
|
|
93
|
+
nodes = np.column_stack(parts) # (m, 2+n_z)
|
|
94
|
+
# Advanced indexing: Cov[nodes[a], nodes[b]] for each row.
|
|
95
|
+
sub = Cov[nodes[:, :, None], nodes[:, None, :]].astype(np.float64)
|
|
96
|
+
eye = np.eye(sub.shape[-1], dtype=np.float64)
|
|
97
|
+
# Relative ridge tracks submatrix scale (long SEMs can have large vars).
|
|
98
|
+
scale = np.maximum(
|
|
99
|
+
np.mean(np.diagonal(sub, axis1=-2, axis2=-1), axis=-1), 1e-8
|
|
100
|
+
)
|
|
101
|
+
sub = sub + (_RIDGE + 1e-6 * scale)[:, None, None] * eye
|
|
102
|
+
P = np.linalg.inv(sub)
|
|
103
|
+
denom = np.sqrt(np.maximum(P[:, 0, 0] * P[:, 1, 1], 0.0))
|
|
104
|
+
rho[idx] = np.where(denom > 0.0, -P[:, 0, 1] / denom, 0.0)
|
|
105
|
+
return rho
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _union_neighbor_sets(
|
|
109
|
+
U: np.ndarray,
|
|
110
|
+
I: np.ndarray,
|
|
111
|
+
J: np.ndarray,
|
|
112
|
+
k: int,
|
|
113
|
+
Cov: np.ndarray,
|
|
114
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
115
|
+
"""Top-``k`` undirected neighbors of ``i`` or ``j``, ranked by |Cov|.
|
|
116
|
+
|
|
117
|
+
Pool = neighbors of ``i`` or ``j`` (excluding endpoints). Score for
|
|
118
|
+
candidate ``m`` is ``max(|Cov[m,i]|, |Cov[m,j]|)`` so conditioning prefers
|
|
119
|
+
the strongest known confounders rather than lowest index.
|
|
120
|
+
"""
|
|
121
|
+
M = I.shape[0]
|
|
122
|
+
if M == 0:
|
|
123
|
+
return np.full((0, k), -1, dtype=np.intp), np.zeros(0, dtype=np.intp)
|
|
124
|
+
# U[I] → (M, N); boolean OR of neighbor rows.
|
|
125
|
+
mask = (U[I] > 0) | (U[J] > 0)
|
|
126
|
+
mask[np.arange(M), I] = False
|
|
127
|
+
mask[np.arange(M), J] = False
|
|
128
|
+
# scores[m, n] = max(|Cov[n, I[m]]|, |Cov[n, J[m]]|)
|
|
129
|
+
score_i = np.abs(Cov[:, I].T)
|
|
130
|
+
score_j = np.abs(Cov[:, J].T)
|
|
131
|
+
scores = np.maximum(score_i, score_j)
|
|
132
|
+
return _topk_by_score(mask, scores, k)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _vstructure_gravity_well(
|
|
136
|
+
A: np.ndarray,
|
|
137
|
+
E: np.ndarray,
|
|
138
|
+
Cov: np.ndarray,
|
|
139
|
+
*,
|
|
140
|
+
learning_rate: float,
|
|
141
|
+
threshold_death: float,
|
|
142
|
+
) -> np.ndarray:
|
|
143
|
+
"""Sink elevations of collider nodes implied by V-structure pattern.
|
|
144
|
+
|
|
145
|
+
Absent undirected pairs ``(i, j)`` that share neighbor(s) via ``U @ U`` and
|
|
146
|
+
are marginally independent (``|rho| < threshold_death`` with empty ``Z``)
|
|
147
|
+
mark each shared neighbor ``k`` as a collider. Those elevations are lowered
|
|
148
|
+
by ``learning_rate * 2`` (once per tick if flagged), then clipped to
|
|
149
|
+
``[0, 1]``.
|
|
150
|
+
"""
|
|
151
|
+
U = _undirected_neighbors(A)
|
|
152
|
+
undirected_absent = (A + A.T) == 0.0
|
|
153
|
+
np.fill_diagonal(undirected_absent, False)
|
|
154
|
+
shared_counts = U @ U
|
|
155
|
+
np.fill_diagonal(shared_counts, 0.0)
|
|
156
|
+
pair_mask = np.triu(undirected_absent & (shared_counts > 0), k=1)
|
|
157
|
+
Pi, Pj = np.nonzero(pair_mask)
|
|
158
|
+
if Pi.size == 0:
|
|
159
|
+
return E
|
|
160
|
+
|
|
161
|
+
# Marginal / empty-Z partial correlation (batched 2×2 precision).
|
|
162
|
+
Z_empty = np.zeros((Pi.size, 0), dtype=np.intp)
|
|
163
|
+
counts0 = np.zeros(Pi.size, dtype=np.intp)
|
|
164
|
+
rho = _batched_partial_corr(Cov, Pi, Pj, Z_empty, counts0)
|
|
165
|
+
independent = np.abs(rho) < threshold_death
|
|
166
|
+
if not np.any(independent):
|
|
167
|
+
return E
|
|
168
|
+
|
|
169
|
+
Ii = Pi[independent]
|
|
170
|
+
Ij = Pj[independent]
|
|
171
|
+
shared = (U[Ii] > 0) & (U[Ij] > 0) # (M_ind, N)
|
|
172
|
+
collider = shared.any(axis=0) # unique k flagged this tick
|
|
173
|
+
E = E.copy()
|
|
174
|
+
E[collider] -= learning_rate * 2.0
|
|
175
|
+
return np.clip(E, 0.0, 1.0)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def step(
|
|
179
|
+
A: np.ndarray,
|
|
180
|
+
E: np.ndarray,
|
|
181
|
+
Cov: np.ndarray,
|
|
182
|
+
*,
|
|
183
|
+
learning_rate: float = 0.1,
|
|
184
|
+
threshold_death: float = 0.05,
|
|
185
|
+
threshold_birth: float = 0.15,
|
|
186
|
+
k: int = 2,
|
|
187
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
188
|
+
"""One Bayesian CA tick: gravity, V-well, then edge death / birth.
|
|
189
|
+
|
|
190
|
+
Parameters
|
|
191
|
+
----------
|
|
192
|
+
A:
|
|
193
|
+
Binary adjacency ``(N, N)``; ``A[i, j] = 1`` means directed edge ``i→j``.
|
|
194
|
+
E:
|
|
195
|
+
Node elevations ``(N,)``, intended in ``[0, 1]``.
|
|
196
|
+
Cov:
|
|
197
|
+
Global covariance ``(N, N)`` of the continuous observations.
|
|
198
|
+
learning_rate:
|
|
199
|
+
Step size for elevation drift toward parent/child midpoint.
|
|
200
|
+
threshold_death:
|
|
201
|
+
Kill existing edges with ``|rho|`` below this value.
|
|
202
|
+
threshold_birth:
|
|
203
|
+
Birth candidate edges with ``|rho|`` above this value.
|
|
204
|
+
k:
|
|
205
|
+
Cap on conditioning-set size ``|Z|`` (default 2). Death/birth pick the
|
|
206
|
+
top-``k`` neighbors by ``max(|Cov[m,i]|, |Cov[m,j]|)``.
|
|
207
|
+
|
|
208
|
+
Returns
|
|
209
|
+
-------
|
|
210
|
+
A_new, E_new
|
|
211
|
+
Updated binary adjacency and elevations (copies; inputs are not mutated).
|
|
212
|
+
"""
|
|
213
|
+
A = np.asarray(A, dtype=np.float64).copy()
|
|
214
|
+
E = np.asarray(E, dtype=np.float64).copy().reshape(-1)
|
|
215
|
+
Cov = np.asarray(Cov, dtype=np.float64)
|
|
216
|
+
if A.ndim != 2 or A.shape[0] != A.shape[1]:
|
|
217
|
+
raise ValueError("A must be a square adjacency matrix")
|
|
218
|
+
N = A.shape[0]
|
|
219
|
+
if E.shape != (N,):
|
|
220
|
+
raise ValueError(f"E must have shape ({N},), got {E.shape}")
|
|
221
|
+
if Cov.shape != (N, N):
|
|
222
|
+
raise ValueError(f"Cov must have shape ({N}, {N}), got {Cov.shape}")
|
|
223
|
+
if k < 0:
|
|
224
|
+
raise ValueError("k must be non-negative")
|
|
225
|
+
|
|
226
|
+
# --- 1. Gravity: downhill-only edges, then elevation drift ---------------
|
|
227
|
+
gravity_ok = E[:, None] > E[None, :]
|
|
228
|
+
np.fill_diagonal(gravity_ok, False)
|
|
229
|
+
A *= gravity_ok
|
|
230
|
+
|
|
231
|
+
parent_count = A.sum(axis=0)
|
|
232
|
+
child_count = A.sum(axis=1)
|
|
233
|
+
parent_sum = A.T @ E
|
|
234
|
+
child_sum = A @ E
|
|
235
|
+
# Isolated side: treat missing mean as the node's own elevation (no pull).
|
|
236
|
+
mean_parents = np.divide(
|
|
237
|
+
parent_sum, parent_count, out=E.copy(), where=parent_count > 0
|
|
238
|
+
)
|
|
239
|
+
mean_children = np.divide(
|
|
240
|
+
child_sum, child_count, out=E.copy(), where=child_count > 0
|
|
241
|
+
)
|
|
242
|
+
E = E + learning_rate * ((mean_parents + mean_children) * 0.5 - E)
|
|
243
|
+
E = np.clip(E, 0.0, 1.0)
|
|
244
|
+
|
|
245
|
+
# --- 2. V-structure gravity well (before death / birth edge updates) -----
|
|
246
|
+
E = _vstructure_gravity_well(
|
|
247
|
+
A,
|
|
248
|
+
E,
|
|
249
|
+
Cov,
|
|
250
|
+
learning_rate=learning_rate,
|
|
251
|
+
threshold_death=threshold_death,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# Neighbor structure from the post-gravity DAG (undirected for Z).
|
|
255
|
+
U = _undirected_neighbors(A)
|
|
256
|
+
|
|
257
|
+
# --- 3. Death: weak partial correlation given Cov-ranked neighbor Z -----
|
|
258
|
+
I, J = np.nonzero(A > 0)
|
|
259
|
+
if I.size:
|
|
260
|
+
Z, counts = _union_neighbor_sets(U, I, J, k, Cov)
|
|
261
|
+
rho = _batched_partial_corr(Cov, I, J, Z, counts)
|
|
262
|
+
kill = np.abs(rho) < threshold_death
|
|
263
|
+
if np.any(kill):
|
|
264
|
+
A[I[kill], J[kill]] = 0.0
|
|
265
|
+
U = _undirected_neighbors(A)
|
|
266
|
+
|
|
267
|
+
# --- 4. Birth: all absent undirected pairs; elevation picks direction ----
|
|
268
|
+
# Unordered pairs with no edge either way (batched via upper triangle).
|
|
269
|
+
undirected_absent = (A + A.T) == 0.0
|
|
270
|
+
np.fill_diagonal(undirected_absent, False)
|
|
271
|
+
Bi, Bj = np.nonzero(np.triu(undirected_absent, k=1))
|
|
272
|
+
if Bi.size:
|
|
273
|
+
Z, counts = _union_neighbor_sets(U, Bi, Bj, k, Cov)
|
|
274
|
+
rho = _batched_partial_corr(Cov, Bi, Bj, Z, counts)
|
|
275
|
+
strong = np.abs(rho) > threshold_birth
|
|
276
|
+
# Direction from elevations after the rho gate (equal E → no-op).
|
|
277
|
+
forward = strong & (E[Bi] > E[Bj]) # i → j
|
|
278
|
+
backward = strong & (E[Bj] > E[Bi]) # j → i
|
|
279
|
+
if np.any(forward):
|
|
280
|
+
A[Bi[forward], Bj[forward]] = 1.0
|
|
281
|
+
if np.any(backward):
|
|
282
|
+
A[Bj[backward], Bi[backward]] = 1.0
|
|
283
|
+
|
|
284
|
+
# Binary adjacency; clear flat/uphill edges after collider sinks reshape E.
|
|
285
|
+
A = (A > 0).astype(np.float64)
|
|
286
|
+
A *= E[:, None] > E[None, :]
|
|
287
|
+
np.fill_diagonal(A, 0.0)
|
|
288
|
+
return A, E
|