PermScore
Joint causal discovery when you don't know what was perturbed.
Given data from several environments — one observational, the rest interventional, with the perturbation labels not recorded — PermScore recovers both the causal DAG and which environment perturbed which node, in a single search.
Deterministic, CPU-only, no dataset-specific tuning. Dependencies are NumPy and SciPy.
pip install permscore
Quickstart
from permscore import PermScore, simulate, metrics
envs, truth = simulate.make_instance(d=10, n_env=5, seed=0)
# envs[0] -> observational sample, shape (n, d)
# envs[1:] -> interventional samples; which node each one perturbed is NOT given
result = PermScore(seed=0).fit(envs).result_
result.graph # (d, d) adjacency, acyclic by construction; graph[i, j]==1 means i -> j
result.targets # {environment -> node or None}: one target per environment
result.target_sets # {environment -> {nodes}}: the unconstrained flagging
result.order # the topological order the search settled on
metrics.shd(result.graph, truth["graph"]) # 0
metrics.exact_top1(result.targets, truth["targets"]) # accuracy 1.0
Your own data is just a list of arrays over the same variables. Sample counts may differ between environments:
envs = [X_control, X_env1, X_env2, X_env3] # each (n_i, d), same d
result = permscore.fit(envs)
How it works
The search runs over topological orders. An order determines, for each node, which nodes may be its parents; the graph is read off by selecting parents within that order, so acyclicity holds by construction — there is no acyclicity penalty and no projection step.
For each node, parent selection and target inference come from the same local score. That score compares each environment's own fit for the node against the fit it gets from the mechanism shared with the other environments; environments that gain enough by having their own mechanism are flagged as intervened, and parents are then selected on the pooled data of the rest. The score is closed-form and decomposable, computed from pooled second-moment statistics, so each evaluation is O(1) in the number of samples — cost grows with the number of variables and environments, not with how many rows you have.
Targets are resolved by one global rectangular assignment (Hungarian), so each environment gets at most one target and the output is directly comparable with single-target baselines.
Measuring target recovery correctly
This is the part most easily got wrong, so the package makes it hard to get wrong.
A method that flags many candidate nodes per environment can look perfect under a containment metric ("is the true gene somewhere in the flagged set?") while identifying nothing. Suppose three environments and a method that flags 20 nodes for each:
from permscore import metrics
truth = {1: 0, 2: 1, 3: 2}
flag_everything = {e: set(range(20)) for e in (1, 2, 3)}
metrics.containment(flag_everything, truth)["rate"] # 1.0 <- looks perfect
metrics.pair_scores(flag_everything, truth)["precision"] # 0.05 <- 1 of every 20 is right
metrics.exact_top1(flag_everything, truth)["accuracy"] # 0.0 <- identified nothing
containment always reports mean_set_size alongside the rate, because the rate cannot be read
without it. union_node_precision is provided for comparison with published tables that use it,
with the caveat that it computes precision over the union of flagged nodes and so cannot
distinguish "right node, right environment" from "flagged a node that is some other
environment's target".
Default to exact_top1 and pair_scores. Report containment only next to the set size.
API
PermScore(...) |
estimator; .fit(envs) then .result_, .graph_, .targets_ |
fit(envs, **kwargs) |
shorthand returning PermScoreResult directly |
PermScoreResult |
.graph, .targets, .target_sets, .order, .score, .parents(j), .edges(), .target_array() |
metrics |
shd, skeleton_f1, exact_top1, pair_scores, containment, union_node_precision |
simulate |
make_instance(d, n_env, seed, shift, ...) |
Main parameters:
| parameter | default | meaning |
|---|---|---|
restarts |
3 | random permutation restarts; best-scoring order kept |
margin |
1.0 | multiplier on the BIC penalty in target flagging; larger is more conservative |
assignment |
True |
resolve targets by global matching (one per environment) rather than independent flags |
abstain |
True |
allow an environment to receive no target when its best candidate is not significant |
strategy |
"first" |
"first" accepts the first improving move; "best" scans all moves each round |
seed |
0 | seeds the restarts; the method is otherwise deterministic |
Scope and limitations
Stated plainly, because they determine whether this is the right tool.
- Model class. Linear-Gaussian structural equations with additive-shift interventions. On additive-nonlinear mechanisms the linear score degrades badly. Not a drop-in for nonlinear causal discovery.
- One target per environment is the modelling assumption behind
assignment=True. Useassignment=Falseif environments may perturb several nodes. - The assignment constraint is not free. On weakly-intervened synthetic data it can cost accuracy relative to independent flagging, because one mis-scored environment can displace a correct assignment elsewhere. It is the default for output comparability, not because it is uniformly better.
- Scaling in
dis the weak point. Cost is O(1) in samples but grows with variables and environments. It is fast at tens of variables and slow at a hundred; if you have hundreds of variables, benchmark before committing. - Interventions must move something observable about their own target. If a perturbation leaves no trace in the measured variable it targets, no method in this family can recover it — and neither can this one.
- Not hyperparameter-free. It requires no dataset-specific tuning: every constant is fixed across every experiment we ran. That is a different and weaker claim.
Reproducibility
The method is deterministic given seed: repeated fits are bit-identical, and the test suite
asserts that rather than assuming it. Outputs on a set of fixed instances are pinned in
tests/golden_values.json, and those instances were selected by mutation testing — an
earlier golden set chosen for readability survived every perturbation of the scorer's constants,
i.e. pinned nothing. If you change the algorithm deliberately, regenerate the pinned values and
say so in the changelog; never regenerate them to make a red test go green.
The test suite ships in the source distribution:
pip download --no-binary :all: --no-deps permscore
tar xf permscore-*.tar.gz && cd permscore-*
pip install ".[test]" && pytest
License
MIT. See LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file permscore-0.1.0.tar.gz.
File metadata
- Download URL: permscore-0.1.0.tar.gz
- Upload date:
- Size: 30.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.12.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d57555b2091a06799bf7354f5ad642a2861c281315a315a68ba57288f8d2838a
|
|
| MD5 |
5fa20985bc8b53e7a135300c0fdc361a
|
|
| BLAKE2b-256 |
411e3de8bf893006da53630f397f937aa7a3357400783badda9f57db04816b49
|
File details
Details for the file permscore-0.1.0-py3-none-any.whl.
File metadata
- Download URL: permscore-0.1.0-py3-none-any.whl
- Upload date:
- Size: 26.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.12.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b428d8e8e4d2ebce651ac086aa271fc6fc7b68fd3506b81ed5d1c735b4c21cd3
|
|
| MD5 |
fcbccfca3b2e06b00565c19ae3168cfb
|
|
| BLAKE2b-256 |
ee1133bf64cdbcb73576f9d2fc313852eb931a8f765b4edb7431dba084275298
|