Metadata-Version: 2.4
Name: permscore
Version: 0.1.0
Summary: Joint causal discovery with unknown intervention targets: recover a DAG and which environment perturbed which node
Author: PermScore contributors
Maintainer: PermScore contributors
License-Expression: MIT
Keywords: causal-discovery,causal-inference,structure-learning,interventions,unknown-targets,perturbation,dag,graphical-models
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Science/Research
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
Classifier: Topic :: Scientific/Engineering :: Mathematics
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy>=1.21
Requires-Dist: scipy>=1.7
Provides-Extra: test
Requires-Dist: pytest>=7; extra == "test"
Dynamic: license-file

# 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.

```bash
pip install permscore
```

## Quickstart

```python
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:

```python
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:

```python
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`. Use
  `assignment=False` if 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 `d` is 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:

```bash
pip download --no-binary :all: --no-deps permscore
tar xf permscore-*.tar.gz && cd permscore-*
pip install ".[test]" && pytest
```

## License

MIT. See `LICENSE`.
