causalcheck 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.
- causalcheck-0.1.0/LICENSE +21 -0
- causalcheck-0.1.0/PKG-INFO +128 -0
- causalcheck-0.1.0/README.md +100 -0
- causalcheck-0.1.0/pyproject.toml +38 -0
- causalcheck-0.1.0/setup.cfg +4 -0
- causalcheck-0.1.0/src/causalcheck/__init__.py +13 -0
- causalcheck-0.1.0/src/causalcheck/checks.py +262 -0
- causalcheck-0.1.0/src/causalcheck/cli.py +51 -0
- causalcheck-0.1.0/src/causalcheck/recommend.py +153 -0
- causalcheck-0.1.0/src/causalcheck/report.py +96 -0
- causalcheck-0.1.0/src/causalcheck.egg-info/PKG-INFO +128 -0
- causalcheck-0.1.0/src/causalcheck.egg-info/SOURCES.txt +15 -0
- causalcheck-0.1.0/src/causalcheck.egg-info/dependency_links.txt +1 -0
- causalcheck-0.1.0/src/causalcheck.egg-info/entry_points.txt +2 -0
- causalcheck-0.1.0/src/causalcheck.egg-info/requires.txt +12 -0
- causalcheck-0.1.0/src/causalcheck.egg-info/top_level.txt +1 -0
- causalcheck-0.1.0/tests/test_checks.py +160 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Taha Bouhoun
|
|
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 deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: causalcheck
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pre-flight assumption checks for causal discovery: which methods your data can support, and which outputs to trust.
|
|
5
|
+
Author-email: Taha Bouhoun <bouhountaha@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Tahahaha7/causalcheck
|
|
8
|
+
Project-URL: Issues, https://github.com/Tahahaha7/causalcheck/issues
|
|
9
|
+
Keywords: causal discovery,causal inference,assumptions,diagnostics,LiNGAM,PC,FCI
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: numpy>=1.23
|
|
18
|
+
Requires-Dist: pandas>=1.5
|
|
19
|
+
Requires-Dist: scipy>=1.9
|
|
20
|
+
Requires-Dist: scikit-learn>=1.2
|
|
21
|
+
Provides-Extra: full
|
|
22
|
+
Requires-Dist: causal-learn>=0.1.3; extra == "full"
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
25
|
+
Requires-Dist: build; extra == "dev"
|
|
26
|
+
Requires-Dist: twine; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# causalcheck
|
|
30
|
+
|
|
31
|
+
Pre-flight assumption checks for causal discovery.
|
|
32
|
+
|
|
33
|
+
Every causal discovery method rests on assumptions about the data: linear relations, non-Gaussian noise, no hidden confounders, no near-deterministic links, comparable scales. Libraries will happily run any method on any data and return a confident graph either way. `causalcheck` looks at the data first and tells you which methods it can support, which outputs to trust, and what to fix before you run anything.
|
|
34
|
+
|
|
35
|
+
It describes the data, not a graph. It does not do causal discovery itself.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install causalcheck
|
|
41
|
+
pip install "causalcheck[full]" # adds causal-learn for the optional hidden-confounder check
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
Command line:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
causalcheck data.csv # text report
|
|
50
|
+
causalcheck data.csv --json # machine-readable
|
|
51
|
+
causalcheck data.csv --confounding # also run FCI to flag likely hidden common causes (slower)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Python:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
import pandas as pd
|
|
58
|
+
import causalcheck
|
|
59
|
+
|
|
60
|
+
report = causalcheck.check(pd.read_csv("data.csv"))
|
|
61
|
+
print(report) # text report
|
|
62
|
+
report.to_json() # same content as JSON
|
|
63
|
+
report.worst_status # "pass" | "info" | "warn" | "fail"
|
|
64
|
+
report.advice.methods # per-method verdicts
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The exit code of the CLI is `1` when any check fails, so it can gate a pipeline.
|
|
68
|
+
|
|
69
|
+
## What it checks
|
|
70
|
+
|
|
71
|
+
| Check | Question it answers | Why it matters |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| data types | Which columns are continuous, discrete, or non-numeric? How many rows have missing values? | Additive-noise direction tests need continuous variables; mixed data needs mixed-data scores |
|
|
74
|
+
| sample size | Is n large enough for the number of variables? | Small n gives weak CI tests and unstable orientations |
|
|
75
|
+
| linearity | Does a spline model beat a straight line out of sample for any pair? | Fisher-z, linear BIC and LiNGAM are mis-specified on nonlinear pairs |
|
|
76
|
+
| noise distribution | Are the residuals non-Gaussian (beyond a trivial amount)? | Non-Gaussian noise is what lets LiNGAM orient edges; Gaussian noise leaves only an equivalence class |
|
|
77
|
+
| near-determinism | Is any variable almost fully explained by the others? Are any pairs effectively duplicates? | Faithfulness breaks: CI-based skeletons (PC, FCI) drop true edges; duplicates confuse every method |
|
|
78
|
+
| scale | Do variances differ by orders of magnitude? | Continuous-optimization methods (NOTEARS, DAGMA) tend to recover the variance ordering instead of the causal one |
|
|
79
|
+
| hidden confounding (optional) | Does FCI consistently see bidirected edges? | Methods that assume no latents will orient those edges wrongly |
|
|
80
|
+
|
|
81
|
+
Each check returns one of four statuses:
|
|
82
|
+
|
|
83
|
+
- `pass`: the assumption looks satisfied
|
|
84
|
+
- `warn`: something that will mislead some methods
|
|
85
|
+
- `fail`: a method family should not be used on this data as is
|
|
86
|
+
- `info`: a finding that routes you to a method, with no good/bad meaning
|
|
87
|
+
|
|
88
|
+
## What it recommends
|
|
89
|
+
|
|
90
|
+
From the check results, the report gives:
|
|
91
|
+
|
|
92
|
+
- **Fix first:** data problems to resolve before running anything (duplicates, standardization, unencoded columns, heavy missingness).
|
|
93
|
+
- **Methods:** a verdict of `recommended`, `usable` or `avoid` for each family (PC, FCI/GFCI, GES/BOSS/GRaSP, DirectLiNGAM, ANM/CAM/RESIT, NOTEARS/DAGMA), with a one-line reason.
|
|
94
|
+
- **How to read your results:** what the output of any method can and cannot mean on this data.
|
|
95
|
+
|
|
96
|
+
## Example
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
causalcheck 0.1.0 | 1000 rows x 3 variables
|
|
100
|
+
CHECKS
|
|
101
|
+
[PASS] linearity all pairwise relations look linear
|
|
102
|
+
[PASS] noise distribution 3/3 residuals clearly non-Gaussian -> LiNGAM-style direction identification is possible
|
|
103
|
+
[WARN] scale variances differ by a factor of 37 -> standardize first
|
|
104
|
+
METHODS
|
|
105
|
+
recommended GES / BOSS / GRaSP (score-based): most reliable skeleton
|
|
106
|
+
recommended DirectLiNGAM: linear with non-Gaussian noise: full DAG and coefficients are identifiable
|
|
107
|
+
avoid NOTEARS / DAGMA (continuous optimization): variance ordering will drive the result
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Limitations
|
|
111
|
+
|
|
112
|
+
- Some assumptions cannot be tested from observational data: faithfulness (no canceling effects) and the absence of selection bias. The report says so rather than pretending.
|
|
113
|
+
- The noise check uses each variable's residual given all others as a stand-in for its true disturbance, because the graph is unknown.
|
|
114
|
+
- The hidden-confounder check is a signal, not a proof. Passing it does not rule out confounding.
|
|
115
|
+
- Thresholds (linearity gain 0.02, R² 0.98 for near-determinism, variance ratio 10) are sensible defaults, not laws. All are arguments to the check functions.
|
|
116
|
+
|
|
117
|
+
## Development
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
git clone https://github.com/Tahahaha7/causalcheck && cd causalcheck
|
|
121
|
+
python3 -m venv .venv && source .venv/bin/activate
|
|
122
|
+
pip install -e ".[dev]"
|
|
123
|
+
pytest
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## License
|
|
127
|
+
|
|
128
|
+
MIT
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# causalcheck
|
|
2
|
+
|
|
3
|
+
Pre-flight assumption checks for causal discovery.
|
|
4
|
+
|
|
5
|
+
Every causal discovery method rests on assumptions about the data: linear relations, non-Gaussian noise, no hidden confounders, no near-deterministic links, comparable scales. Libraries will happily run any method on any data and return a confident graph either way. `causalcheck` looks at the data first and tells you which methods it can support, which outputs to trust, and what to fix before you run anything.
|
|
6
|
+
|
|
7
|
+
It describes the data, not a graph. It does not do causal discovery itself.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install causalcheck
|
|
13
|
+
pip install "causalcheck[full]" # adds causal-learn for the optional hidden-confounder check
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
Command line:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
causalcheck data.csv # text report
|
|
22
|
+
causalcheck data.csv --json # machine-readable
|
|
23
|
+
causalcheck data.csv --confounding # also run FCI to flag likely hidden common causes (slower)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Python:
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
import pandas as pd
|
|
30
|
+
import causalcheck
|
|
31
|
+
|
|
32
|
+
report = causalcheck.check(pd.read_csv("data.csv"))
|
|
33
|
+
print(report) # text report
|
|
34
|
+
report.to_json() # same content as JSON
|
|
35
|
+
report.worst_status # "pass" | "info" | "warn" | "fail"
|
|
36
|
+
report.advice.methods # per-method verdicts
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The exit code of the CLI is `1` when any check fails, so it can gate a pipeline.
|
|
40
|
+
|
|
41
|
+
## What it checks
|
|
42
|
+
|
|
43
|
+
| Check | Question it answers | Why it matters |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| data types | Which columns are continuous, discrete, or non-numeric? How many rows have missing values? | Additive-noise direction tests need continuous variables; mixed data needs mixed-data scores |
|
|
46
|
+
| sample size | Is n large enough for the number of variables? | Small n gives weak CI tests and unstable orientations |
|
|
47
|
+
| linearity | Does a spline model beat a straight line out of sample for any pair? | Fisher-z, linear BIC and LiNGAM are mis-specified on nonlinear pairs |
|
|
48
|
+
| noise distribution | Are the residuals non-Gaussian (beyond a trivial amount)? | Non-Gaussian noise is what lets LiNGAM orient edges; Gaussian noise leaves only an equivalence class |
|
|
49
|
+
| near-determinism | Is any variable almost fully explained by the others? Are any pairs effectively duplicates? | Faithfulness breaks: CI-based skeletons (PC, FCI) drop true edges; duplicates confuse every method |
|
|
50
|
+
| scale | Do variances differ by orders of magnitude? | Continuous-optimization methods (NOTEARS, DAGMA) tend to recover the variance ordering instead of the causal one |
|
|
51
|
+
| hidden confounding (optional) | Does FCI consistently see bidirected edges? | Methods that assume no latents will orient those edges wrongly |
|
|
52
|
+
|
|
53
|
+
Each check returns one of four statuses:
|
|
54
|
+
|
|
55
|
+
- `pass`: the assumption looks satisfied
|
|
56
|
+
- `warn`: something that will mislead some methods
|
|
57
|
+
- `fail`: a method family should not be used on this data as is
|
|
58
|
+
- `info`: a finding that routes you to a method, with no good/bad meaning
|
|
59
|
+
|
|
60
|
+
## What it recommends
|
|
61
|
+
|
|
62
|
+
From the check results, the report gives:
|
|
63
|
+
|
|
64
|
+
- **Fix first:** data problems to resolve before running anything (duplicates, standardization, unencoded columns, heavy missingness).
|
|
65
|
+
- **Methods:** a verdict of `recommended`, `usable` or `avoid` for each family (PC, FCI/GFCI, GES/BOSS/GRaSP, DirectLiNGAM, ANM/CAM/RESIT, NOTEARS/DAGMA), with a one-line reason.
|
|
66
|
+
- **How to read your results:** what the output of any method can and cannot mean on this data.
|
|
67
|
+
|
|
68
|
+
## Example
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
causalcheck 0.1.0 | 1000 rows x 3 variables
|
|
72
|
+
CHECKS
|
|
73
|
+
[PASS] linearity all pairwise relations look linear
|
|
74
|
+
[PASS] noise distribution 3/3 residuals clearly non-Gaussian -> LiNGAM-style direction identification is possible
|
|
75
|
+
[WARN] scale variances differ by a factor of 37 -> standardize first
|
|
76
|
+
METHODS
|
|
77
|
+
recommended GES / BOSS / GRaSP (score-based): most reliable skeleton
|
|
78
|
+
recommended DirectLiNGAM: linear with non-Gaussian noise: full DAG and coefficients are identifiable
|
|
79
|
+
avoid NOTEARS / DAGMA (continuous optimization): variance ordering will drive the result
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Limitations
|
|
83
|
+
|
|
84
|
+
- Some assumptions cannot be tested from observational data: faithfulness (no canceling effects) and the absence of selection bias. The report says so rather than pretending.
|
|
85
|
+
- The noise check uses each variable's residual given all others as a stand-in for its true disturbance, because the graph is unknown.
|
|
86
|
+
- The hidden-confounder check is a signal, not a proof. Passing it does not rule out confounding.
|
|
87
|
+
- Thresholds (linearity gain 0.02, R² 0.98 for near-determinism, variance ratio 10) are sensible defaults, not laws. All are arguments to the check functions.
|
|
88
|
+
|
|
89
|
+
## Development
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
git clone https://github.com/Tahahaha7/causalcheck && cd causalcheck
|
|
93
|
+
python3 -m venv .venv && source .venv/bin/activate
|
|
94
|
+
pip install -e ".[dev]"
|
|
95
|
+
pytest
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
MIT
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "causalcheck"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Pre-flight assumption checks for causal discovery: which methods your data can support, and which outputs to trust."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
authors = [{ name = "Taha Bouhoun", email = "bouhountaha@gmail.com" }]
|
|
14
|
+
keywords = ["causal discovery", "causal inference", "assumptions", "diagnostics", "LiNGAM", "PC", "FCI"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Science/Research",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Scientific/Engineering",
|
|
20
|
+
]
|
|
21
|
+
dependencies = ["numpy>=1.23", "pandas>=1.5", "scipy>=1.9", "scikit-learn>=1.2"]
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
full = ["causal-learn>=0.1.3"]
|
|
25
|
+
dev = ["pytest>=7", "build", "twine"]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/Tahahaha7/causalcheck"
|
|
29
|
+
Issues = "https://github.com/Tahahaha7/causalcheck/issues"
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
causalcheck = "causalcheck.cli:main"
|
|
33
|
+
|
|
34
|
+
[tool.setuptools.packages.find]
|
|
35
|
+
where = ["src"]
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""causalcheck: pre-flight assumption checks for causal discovery.
|
|
2
|
+
|
|
3
|
+
import causalcheck
|
|
4
|
+
report = causalcheck.check(df) # df: pandas DataFrame
|
|
5
|
+
print(report) # text report
|
|
6
|
+
report.to_json() # machine-readable
|
|
7
|
+
"""
|
|
8
|
+
from .checks import CheckResult, run_checks
|
|
9
|
+
from .recommend import Advice, Recommendation, recommend
|
|
10
|
+
from .report import Report, check
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
__all__ = ["check", "Report", "CheckResult", "run_checks", "recommend", "Advice", "Recommendation"]
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Pre-flight assumption checks for causal discovery.
|
|
2
|
+
|
|
3
|
+
Each check inspects the data (not any graph) and returns a CheckResult with a status:
|
|
4
|
+
pass - assumption looks satisfied
|
|
5
|
+
warn - something that will mislead some methods
|
|
6
|
+
fail - a method family should not be used on this data
|
|
7
|
+
info - a finding that routes you to a method, with no good/bad meaning
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import warnings
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
import pandas as pd
|
|
17
|
+
from scipy import stats
|
|
18
|
+
from sklearn.linear_model import LinearRegression, RidgeCV
|
|
19
|
+
from sklearn.model_selection import KFold, cross_val_score
|
|
20
|
+
from sklearn.pipeline import make_pipeline
|
|
21
|
+
from sklearn.preprocessing import SplineTransformer
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@contextlib.contextmanager
|
|
26
|
+
def _quiet():
|
|
27
|
+
"""Silence numeric/convergence warnings from numpy and sklearn only while a check runs."""
|
|
28
|
+
with warnings.catch_warnings():
|
|
29
|
+
warnings.simplefilter("ignore", RuntimeWarning)
|
|
30
|
+
warnings.simplefilter("ignore", UserWarning)
|
|
31
|
+
yield
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class CheckResult:
|
|
36
|
+
name: str
|
|
37
|
+
status: str
|
|
38
|
+
summary: str
|
|
39
|
+
details: dict = field(default_factory=dict)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Prepared:
|
|
44
|
+
X: np.ndarray # standardized numeric data, rows with missing values dropped
|
|
45
|
+
raw: np.ndarray # same rows, original scale
|
|
46
|
+
names: list[str]
|
|
47
|
+
discrete: list[str] # numeric but low-cardinality columns
|
|
48
|
+
dropped: list[str] # non-numeric columns
|
|
49
|
+
n_missing_rows: int
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _gam():
|
|
53
|
+
return make_pipeline(SplineTransformer(n_knots=6, degree=3, extrapolation="linear"),
|
|
54
|
+
RidgeCV(alphas=np.logspace(-3, 3, 13)))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _cv_r2(model, D, y, seed):
|
|
58
|
+
kf = KFold(5, shuffle=True, random_state=seed)
|
|
59
|
+
return float(cross_val_score(model, D, y, cv=kf, scoring="r2").mean())
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def prepare(df: pd.DataFrame, min_unique: int = 10) -> Prepared:
|
|
63
|
+
num = df.select_dtypes(include=[np.number])
|
|
64
|
+
dropped = [c for c in df.columns if c not in num.columns]
|
|
65
|
+
n0 = len(num)
|
|
66
|
+
num = num.dropna()
|
|
67
|
+
num = num.loc[:, num.std() > 0]
|
|
68
|
+
names = list(num.columns)
|
|
69
|
+
discrete = [c for c in names if num[c].nunique() < min_unique]
|
|
70
|
+
raw = num.to_numpy(float)
|
|
71
|
+
X = (raw - raw.mean(0)) / raw.std(0)
|
|
72
|
+
return Prepared(X, raw, names, discrete, dropped, n0 - len(num))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------- checks
|
|
76
|
+
|
|
77
|
+
def check_data_types(prep: Prepared, n_total_rows: int) -> CheckResult:
|
|
78
|
+
d = dict(numeric=len(prep.names), discrete=prep.discrete, dropped=prep.dropped,
|
|
79
|
+
rows_used=len(prep.X), rows_dropped_missing=prep.n_missing_rows)
|
|
80
|
+
if prep.dropped and not prep.names:
|
|
81
|
+
return CheckResult("data types", "fail", "no numeric columns found", d)
|
|
82
|
+
if prep.discrete or prep.dropped:
|
|
83
|
+
what = []
|
|
84
|
+
if prep.discrete:
|
|
85
|
+
what.append(f"{len(prep.discrete)} low-cardinality column(s) treated as discrete")
|
|
86
|
+
if prep.dropped:
|
|
87
|
+
what.append(f"{len(prep.dropped)} non-numeric column(s) ignored")
|
|
88
|
+
return CheckResult("data types", "warn", "; ".join(what)
|
|
89
|
+
+ " -> use mixed-data scores/tests, skip additive-noise tests on them", d)
|
|
90
|
+
return CheckResult("data types", "pass", f"{len(prep.names)} continuous columns, "
|
|
91
|
+
f"{prep.n_missing_rows} of {n_total_rows} rows dropped for missing values", d)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def check_sample_size(prep: Prepared) -> CheckResult:
|
|
95
|
+
n, p = prep.X.shape
|
|
96
|
+
d = dict(n=n, p=p, n_per_variable=round(n / max(p, 1), 1))
|
|
97
|
+
if n < 200 or n < 20 * p:
|
|
98
|
+
return CheckResult("sample size", "warn",
|
|
99
|
+
f"n={n} for p={p} variables: expect low power, unstable orientations, "
|
|
100
|
+
"and many undirected edges", d)
|
|
101
|
+
if n < 1000:
|
|
102
|
+
return CheckResult("sample size", "pass",
|
|
103
|
+
f"n={n} for p={p}: fine for skeleton; direction tests may be weak", d)
|
|
104
|
+
return CheckResult("sample size", "pass", f"n={n} for p={p}", d)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def check_linearity(prep: Prepared, tol: float = 0.02, max_rows: int = 2000, seed: int = 0) -> CheckResult:
|
|
108
|
+
"""Pairwise: does a spline GAM beat a straight line out of sample?"""
|
|
109
|
+
X, names = prep.X, prep.names
|
|
110
|
+
rng = np.random.default_rng(seed)
|
|
111
|
+
Xs = X[rng.choice(len(X), min(len(X), max_rows), replace=False)]
|
|
112
|
+
cont = [k for k, c in enumerate(names) if c not in prep.discrete]
|
|
113
|
+
nonlinear = []
|
|
114
|
+
for i in cont:
|
|
115
|
+
for j in cont:
|
|
116
|
+
if i == j:
|
|
117
|
+
continue
|
|
118
|
+
D, y = Xs[:, [i]], Xs[:, j]
|
|
119
|
+
gain = _cv_r2(_gam(), D, y, seed) - _cv_r2(LinearRegression(), D, y, seed)
|
|
120
|
+
if gain > tol:
|
|
121
|
+
nonlinear.append((names[i], names[j], round(gain, 3)))
|
|
122
|
+
nonlinear.sort(key=lambda t: -t[2])
|
|
123
|
+
d = dict(nonlinear_pairs=nonlinear, tol=tol, pairs_tested=len(cont) * (len(cont) - 1))
|
|
124
|
+
if nonlinear:
|
|
125
|
+
return CheckResult("linearity", "info",
|
|
126
|
+
f"{len(nonlinear)} pair(s) clearly nonlinear (largest R2 gain "
|
|
127
|
+
f"{nonlinear[0][2]:.2f}) -> linear methods (LiNGAM, Fisher-z, linear BIC) "
|
|
128
|
+
"will mis-specify these", d)
|
|
129
|
+
return CheckResult("linearity", "pass",
|
|
130
|
+
"all pairwise relations look linear -> linear scores and tests are appropriate", d)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def check_noise(prep: Prepared, linear: bool, alpha: float = 0.01,
|
|
134
|
+
min_skew: float = 0.3, min_kurt: float = 0.5) -> CheckResult:
|
|
135
|
+
"""Non-Gaussian disturbances are what let LiNGAM/ANM orient edges.
|
|
136
|
+
|
|
137
|
+
Without the graph we use each variable's residual given all other variables as a proxy:
|
|
138
|
+
if every disturbance is Gaussian, these residuals are Gaussian too.
|
|
139
|
+
"""
|
|
140
|
+
X, names = prep.X, prep.names
|
|
141
|
+
cont = [k for k, c in enumerate(names) if c not in prep.discrete]
|
|
142
|
+
rows = []
|
|
143
|
+
for v in cont:
|
|
144
|
+
others = [u for u in cont if u != v]
|
|
145
|
+
if not others:
|
|
146
|
+
r = X[:, v]
|
|
147
|
+
else:
|
|
148
|
+
m = LinearRegression() if linear else _gam()
|
|
149
|
+
m.fit(X[:, others], X[:, v])
|
|
150
|
+
r = X[:, v] - m.predict(X[:, others])
|
|
151
|
+
r = (r - r.mean()) / (r.std() + 1e-12)
|
|
152
|
+
p = float(stats.normaltest(r).pvalue) if len(r) >= 20 else 1.0
|
|
153
|
+
sk, ku = float(stats.skew(r)), float(stats.kurtosis(r))
|
|
154
|
+
nongauss = p < alpha and (abs(sk) > min_skew or abs(ku) > min_kurt)
|
|
155
|
+
rows.append(dict(variable=names[v], p=p, skew=round(sk, 2), excess_kurtosis=round(ku, 2),
|
|
156
|
+
non_gaussian=bool(nongauss)))
|
|
157
|
+
k = sum(r["non_gaussian"] for r in rows)
|
|
158
|
+
d = dict(per_variable=rows, non_gaussian_count=k, tested=len(rows))
|
|
159
|
+
if not linear:
|
|
160
|
+
return CheckResult("noise distribution", "info",
|
|
161
|
+
f"{k}/{len(rows)} residuals non-Gaussian; with nonlinear relations, additive-noise "
|
|
162
|
+
"methods (ANM, CAM, RESIT) can orient edges regardless of noise shape", d)
|
|
163
|
+
if len(rows) and k >= len(rows) - 1:
|
|
164
|
+
return CheckResult("noise distribution", "pass",
|
|
165
|
+
f"{k}/{len(rows)} residuals clearly non-Gaussian -> LiNGAM-style "
|
|
166
|
+
"direction identification is possible", d)
|
|
167
|
+
if k == 0:
|
|
168
|
+
return CheckResult("noise distribution", "warn",
|
|
169
|
+
"all residuals look Gaussian -> directions beyond colliders are not "
|
|
170
|
+
"identifiable; expect an equivalence class, do not trust LiNGAM arrows", d)
|
|
171
|
+
return CheckResult("noise distribution", "info",
|
|
172
|
+
f"{k}/{len(rows)} residuals non-Gaussian -> LiNGAM needs at most one Gaussian "
|
|
173
|
+
"disturbance; trust only arrows touching the non-Gaussian variables", d)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def check_determinism(prep: Prepared, r2_thresh: float = 0.98, corr_thresh: float = 0.99,
|
|
177
|
+
dup_thresh: float = 0.999) -> CheckResult:
|
|
178
|
+
"""Near-deterministic links break faithfulness: CI tests drop true edges."""
|
|
179
|
+
X, names = prep.X, prep.names
|
|
180
|
+
flagged = []
|
|
181
|
+
for v in range(len(names)):
|
|
182
|
+
others = [u for u in range(len(names)) if u != v]
|
|
183
|
+
if not others:
|
|
184
|
+
continue
|
|
185
|
+
m = LinearRegression().fit(X[:, others], X[:, v])
|
|
186
|
+
r2 = float(m.score(X[:, others], X[:, v]))
|
|
187
|
+
if r2 > r2_thresh:
|
|
188
|
+
flagged.append((names[v], round(r2, 4)))
|
|
189
|
+
C = np.corrcoef(X.T) if len(names) > 1 else np.eye(1)
|
|
190
|
+
pairs = [(names[i], names[j], round(float(C[i, j]), 4))
|
|
191
|
+
for i in range(len(names)) for j in range(i + 1, len(names)) if abs(C[i, j]) > corr_thresh]
|
|
192
|
+
dups = [t for t in pairs if abs(t[2]) > dup_thresh]
|
|
193
|
+
d = dict(near_deterministic_variables=flagged, near_deterministic_pairs=pairs, duplicate_pairs=dups)
|
|
194
|
+
if dups:
|
|
195
|
+
return CheckResult("near-determinism", "fail",
|
|
196
|
+
f"{len(dups)} pair(s) correlated above {dup_thresh} -> effectively the same "
|
|
197
|
+
"variable; merge or drop one of each pair before running anything", d)
|
|
198
|
+
if flagged or pairs:
|
|
199
|
+
return CheckResult("near-determinism", "warn",
|
|
200
|
+
f"{len(flagged)} variable(s) almost fully explained by the others and "
|
|
201
|
+
f"{len(pairs)} pair(s) correlated above {corr_thresh} -> faithfulness at risk: "
|
|
202
|
+
"CI-based skeletons (PC, FCI) will drop true edges; prefer score-based search "
|
|
203
|
+
"(BOSS, GES)", d)
|
|
204
|
+
return CheckResult("near-determinism", "pass", "no near-deterministic relations", d)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def check_scale(prep: Prepared, ratio_thresh: float = 10.0) -> CheckResult:
|
|
208
|
+
"""Large variance differences let continuous-optimization methods sort by variance (varsortability)."""
|
|
209
|
+
var = prep.raw.var(0)
|
|
210
|
+
ratio = float(var.max() / max(var.min(), 1e-12))
|
|
211
|
+
d = dict(variance_ratio=round(ratio, 1), variances={n: float(v) for n, v in zip(prep.names, var)})
|
|
212
|
+
if ratio > ratio_thresh:
|
|
213
|
+
return CheckResult("scale", "warn",
|
|
214
|
+
f"variances differ by a factor of {ratio:.0f} -> NOTEARS/DAGMA-style methods "
|
|
215
|
+
"may just recover the variance ordering; standardize first and distrust "
|
|
216
|
+
"directions that follow variance", d)
|
|
217
|
+
return CheckResult("scale", "pass", "variables are on comparable scales", d)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def check_confounding(prep: Prepared, linear: bool, n_boot: int = 5, frac: float = 0.8,
|
|
221
|
+
alpha: float = 0.05, seed: int = 0) -> CheckResult:
|
|
222
|
+
"""Optional: signs of hidden common causes, via bidirected edges in FCI (needs causal-learn)."""
|
|
223
|
+
try:
|
|
224
|
+
from causallearn.search.ConstraintBased.FCI import fci
|
|
225
|
+
except ImportError:
|
|
226
|
+
return CheckResult("hidden confounding", "info",
|
|
227
|
+
"skipped: install causal-learn (pip install causalcheck[full]) to run FCI", {})
|
|
228
|
+
X = prep.X
|
|
229
|
+
n = len(X)
|
|
230
|
+
ci = "fisherz" if linear else "kci"
|
|
231
|
+
m = min(int(frac * n), n if linear else 500)
|
|
232
|
+
rng = np.random.default_rng(seed)
|
|
233
|
+
p = X.shape[1]
|
|
234
|
+
bi = np.zeros((p, p))
|
|
235
|
+
for _ in range(n_boot):
|
|
236
|
+
G, _ = fci(X[rng.choice(n, m, replace=False)], independence_test_method=ci, alpha=alpha,
|
|
237
|
+
verbose=False, show_progress=False)
|
|
238
|
+
g = G.graph
|
|
239
|
+
bi += (g == 1) & (g.T == 1)
|
|
240
|
+
pairs = [(prep.names[i], prep.names[j], round(bi[i, j] / n_boot, 2))
|
|
241
|
+
for i in range(p) for j in range(i + 1, p) if bi[i, j] / n_boot >= 0.6]
|
|
242
|
+
d = dict(bidirected_pairs=pairs, ci_test=ci, subsamples=n_boot)
|
|
243
|
+
if pairs:
|
|
244
|
+
return CheckResult("hidden confounding", "warn",
|
|
245
|
+
f"{len(pairs)} pair(s) consistently bidirected in FCI -> likely hidden common "
|
|
246
|
+
"causes; methods that assume no latents (PC, GES, BOSS, LiNGAM) will orient "
|
|
247
|
+
"these wrongly", d)
|
|
248
|
+
return CheckResult("hidden confounding", "pass",
|
|
249
|
+
"no consistent signs of hidden common causes (not a guarantee)", d)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def run_checks(df: pd.DataFrame, confounding: bool = False, seed: int = 0) -> tuple[Prepared, list[CheckResult]]:
|
|
253
|
+
with _quiet():
|
|
254
|
+
prep = prepare(df)
|
|
255
|
+
results = [check_data_types(prep, len(df)), check_sample_size(prep)]
|
|
256
|
+
if len(prep.names) >= 2:
|
|
257
|
+
lin = check_linearity(prep, seed=seed)
|
|
258
|
+
linear = lin.status == "pass"
|
|
259
|
+
results += [lin, check_noise(prep, linear), check_determinism(prep), check_scale(prep)]
|
|
260
|
+
if confounding:
|
|
261
|
+
results.append(check_confounding(prep, linear, seed=seed))
|
|
262
|
+
return prep, results
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Command line: `causalcheck data.csv [--json] [--confounding]`."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
from .report import check
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _load(path: str, sep: str | None) -> pd.DataFrame:
|
|
14
|
+
p = Path(path)
|
|
15
|
+
if not p.exists():
|
|
16
|
+
sys.exit(f"causalcheck: file not found: {path}")
|
|
17
|
+
suffix = p.suffix.lower()
|
|
18
|
+
if suffix in (".parquet", ".pq"):
|
|
19
|
+
return pd.read_parquet(p)
|
|
20
|
+
if suffix in (".xlsx", ".xls"):
|
|
21
|
+
return pd.read_excel(p)
|
|
22
|
+
if suffix in (".tsv", ".tab"):
|
|
23
|
+
return pd.read_csv(p, sep="\t")
|
|
24
|
+
return pd.read_csv(p, sep=sep) if sep else pd.read_csv(p, sep=None, engine="python")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main(argv: list[str] | None = None) -> int:
|
|
28
|
+
ap = argparse.ArgumentParser(
|
|
29
|
+
prog="causalcheck",
|
|
30
|
+
description="Pre-flight assumption checks for causal discovery: which methods your data "
|
|
31
|
+
"can support, and which outputs to trust.")
|
|
32
|
+
ap.add_argument("file", help="CSV, TSV, Parquet, or Excel file with one column per variable")
|
|
33
|
+
ap.add_argument("--json", action="store_true", help="print a JSON report instead of text")
|
|
34
|
+
ap.add_argument("--confounding", action="store_true",
|
|
35
|
+
help="also run the hidden-confounder check (needs causal-learn, slower)")
|
|
36
|
+
ap.add_argument("--sep", default=None, help="CSV delimiter (auto-detected if omitted)")
|
|
37
|
+
ap.add_argument("--seed", type=int, default=0, help="random seed for subsampling")
|
|
38
|
+
ap.add_argument("--width", type=int, default=100, help="text report width")
|
|
39
|
+
args = ap.parse_args(argv)
|
|
40
|
+
|
|
41
|
+
df = _load(args.file, args.sep)
|
|
42
|
+
report = check(df, confounding=args.confounding, seed=args.seed)
|
|
43
|
+
try:
|
|
44
|
+
print(report.to_json() if args.json else report.to_text(width=args.width))
|
|
45
|
+
except BrokenPipeError: # e.g. piped into `head`
|
|
46
|
+
pass
|
|
47
|
+
return 1 if report.worst_status == "fail" else 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
sys.exit(main())
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Turn check results into advice: what to fix first, which methods fit, what to trust."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
from .checks import CheckResult
|
|
7
|
+
|
|
8
|
+
RECOMMENDED, USABLE, AVOID = "recommended", "usable", "avoid"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Recommendation:
|
|
13
|
+
method: str
|
|
14
|
+
verdict: str
|
|
15
|
+
reason: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Advice:
|
|
20
|
+
fix_first: list[str] = field(default_factory=list)
|
|
21
|
+
methods: list[Recommendation] = field(default_factory=list)
|
|
22
|
+
trust: list[str] = field(default_factory=list)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _by_name(results: list[CheckResult]) -> dict[str, CheckResult]:
|
|
26
|
+
return {r.name: r for r in results}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def recommend(results: list[CheckResult]) -> Advice:
|
|
30
|
+
r = _by_name(results)
|
|
31
|
+
adv = Advice()
|
|
32
|
+
|
|
33
|
+
types = r.get("data types")
|
|
34
|
+
size = r.get("sample size")
|
|
35
|
+
lin = r.get("linearity")
|
|
36
|
+
noise = r.get("noise distribution")
|
|
37
|
+
det = r.get("near-determinism")
|
|
38
|
+
scale = r.get("scale")
|
|
39
|
+
conf = r.get("hidden confounding")
|
|
40
|
+
|
|
41
|
+
linear = lin is not None and lin.status == "pass"
|
|
42
|
+
nongauss = noise is not None and noise.status == "pass"
|
|
43
|
+
gaussian = noise is not None and noise.status == "warn"
|
|
44
|
+
mixed = types is not None and bool(types.details.get("discrete"))
|
|
45
|
+
small = size is not None and size.status == "warn"
|
|
46
|
+
near_det = det is not None and det.status in ("warn", "fail")
|
|
47
|
+
latents = conf is not None and conf.status == "warn"
|
|
48
|
+
latents_untested = conf is None or conf.status == "info"
|
|
49
|
+
|
|
50
|
+
# ---- fix first
|
|
51
|
+
if det is not None and det.status == "fail":
|
|
52
|
+
adv.fix_first.append("Drop or merge the near-duplicate columns listed under near-determinism; "
|
|
53
|
+
"every method treats them as a confusing extra edge.")
|
|
54
|
+
if scale is not None and scale.status == "warn":
|
|
55
|
+
adv.fix_first.append("Standardize all variables before running anything, or any continuous-"
|
|
56
|
+
"optimization method will mostly recover the variance ordering.")
|
|
57
|
+
if types is not None and types.details.get("dropped"):
|
|
58
|
+
adv.fix_first.append(f"Encode or drop the non-numeric columns {types.details['dropped']} explicitly, "
|
|
59
|
+
"rather than letting them be silently ignored.")
|
|
60
|
+
if types is not None and types.details.get("rows_dropped_missing", 0) > 0.2 * types.details.get("rows_used", 1):
|
|
61
|
+
adv.fix_first.append("More than 20% of rows were dropped for missing values; impute or check "
|
|
62
|
+
"whether missingness itself depends on the variables.")
|
|
63
|
+
|
|
64
|
+
# ---- method verdicts
|
|
65
|
+
def add(method, verdict, reason):
|
|
66
|
+
adv.methods.append(Recommendation(method, verdict, reason))
|
|
67
|
+
|
|
68
|
+
# constraint-based, no latents
|
|
69
|
+
if near_det:
|
|
70
|
+
add("PC (constraint-based)", AVOID, "near-deterministic links make CI tests drop true edges")
|
|
71
|
+
elif latents:
|
|
72
|
+
add("PC (constraint-based)", AVOID, "assumes no hidden confounders, but FCI found signs of them")
|
|
73
|
+
else:
|
|
74
|
+
add("PC (constraint-based)", USABLE, "use fisherz if linear, kci if nonlinear; expect an equivalence class")
|
|
75
|
+
|
|
76
|
+
# latent-tolerant constraint-based
|
|
77
|
+
if near_det:
|
|
78
|
+
add("FCI / GFCI (latent-tolerant)", USABLE if latents else AVOID,
|
|
79
|
+
"CI tests are unreliable here; if you need latent handling, use GFCI or BOSS-FCI, "
|
|
80
|
+
"which take the skeleton from a score")
|
|
81
|
+
else:
|
|
82
|
+
add("FCI / GFCI (latent-tolerant)", RECOMMENDED if (latents or latents_untested) else USABLE,
|
|
83
|
+
"honest about hidden confounding; output is a PAG with circle endpoints")
|
|
84
|
+
|
|
85
|
+
# score-based
|
|
86
|
+
if latents:
|
|
87
|
+
add("GES / BOSS / GRaSP (score-based)", USABLE,
|
|
88
|
+
"best skeleton recovery, but orientations assume no latents; check them against FCI")
|
|
89
|
+
else:
|
|
90
|
+
add("GES / BOSS / GRaSP (score-based)", RECOMMENDED,
|
|
91
|
+
"most reliable skeleton" + ("; the right choice under near-determinism" if near_det else "")
|
|
92
|
+
+ ("; use a mixed-data score (conditional Gaussian / BDeu)" if mixed else "")
|
|
93
|
+
+ ("; use a nonparametric score (basis-function BIC)" if not linear else ""))
|
|
94
|
+
|
|
95
|
+
# LiNGAM
|
|
96
|
+
if not linear:
|
|
97
|
+
add("DirectLiNGAM", AVOID, "relations are nonlinear; LiNGAM's linear model is mis-specified")
|
|
98
|
+
elif gaussian:
|
|
99
|
+
add("DirectLiNGAM", AVOID, "noise looks Gaussian: LiNGAM will output confident but arbitrary directions")
|
|
100
|
+
elif latents:
|
|
101
|
+
add("DirectLiNGAM", AVOID, "signs of hidden confounding; use RCD or LiNGAM-with-latents instead")
|
|
102
|
+
elif nongauss:
|
|
103
|
+
add("DirectLiNGAM", RECOMMENDED, "linear with non-Gaussian noise: full DAG and coefficients are identifiable"
|
|
104
|
+
+ ("; verify directions with bootstrap at this sample size" if small else ""))
|
|
105
|
+
else:
|
|
106
|
+
add("DirectLiNGAM", USABLE, "only some disturbances are non-Gaussian; trust arrows touching those variables")
|
|
107
|
+
|
|
108
|
+
# nonlinear additive-noise
|
|
109
|
+
if not linear and not mixed:
|
|
110
|
+
add("ANM / CAM / RESIT (nonlinear additive noise)", RECOMMENDED if not latents else USABLE,
|
|
111
|
+
"nonlinearity itself identifies directions" + ("; confounding may bias them" if latents else ""))
|
|
112
|
+
elif mixed:
|
|
113
|
+
add("ANM / CAM / RESIT (nonlinear additive noise)", AVOID, "needs continuous variables; discrete columns present")
|
|
114
|
+
else:
|
|
115
|
+
add("ANM / CAM / RESIT (nonlinear additive noise)", USABLE,
|
|
116
|
+
"works, but with linear relations the direction signal comes only from non-Gaussian noise")
|
|
117
|
+
|
|
118
|
+
# continuous optimization
|
|
119
|
+
if scale is not None and scale.status == "warn":
|
|
120
|
+
add("NOTEARS / DAGMA (continuous optimization)", AVOID,
|
|
121
|
+
"variance ordering will drive the result (varsortability); standardize first and still distrust directions")
|
|
122
|
+
elif gaussian:
|
|
123
|
+
add("NOTEARS / DAGMA (continuous optimization)", AVOID,
|
|
124
|
+
"with Gaussian noise its directions are not identifiable; it will still output a DAG")
|
|
125
|
+
else:
|
|
126
|
+
add("NOTEARS / DAGMA (continuous optimization)", USABLE,
|
|
127
|
+
"fine for a skeleton; validate directions against a method with identifiability guarantees")
|
|
128
|
+
|
|
129
|
+
# ---- trust notes
|
|
130
|
+
if gaussian:
|
|
131
|
+
adv.trust.append("Expect an equivalence class: only collider patterns can be oriented from this data. "
|
|
132
|
+
"Treat any fully directed output as a guess beyond those.")
|
|
133
|
+
if nongauss and linear:
|
|
134
|
+
adv.trust.append("Directions and coefficients from LiNGAM are trustworthy here, provided there are "
|
|
135
|
+
"no hidden confounders.")
|
|
136
|
+
if not linear:
|
|
137
|
+
adv.trust.append("Linear-only outputs (Fisher-z tests, linear BIC, LiNGAM) are mis-specified for the "
|
|
138
|
+
"nonlinear pairs listed in the linearity check.")
|
|
139
|
+
if near_det:
|
|
140
|
+
adv.trust.append("Missing edges from PC/FCI are suspect: near-deterministic links hide real "
|
|
141
|
+
"dependencies from CI tests.")
|
|
142
|
+
if small:
|
|
143
|
+
adv.trust.append("Bootstrap every result; keep only edges and directions that appear in most resamples.")
|
|
144
|
+
if latents:
|
|
145
|
+
adv.trust.append("Pairs flagged as bidirected may be driven by a hidden common cause; "
|
|
146
|
+
"do not read their arrows as cause and effect.")
|
|
147
|
+
elif latents_untested:
|
|
148
|
+
adv.trust.append("Hidden confounding was not tested; no method here can rule it out from "
|
|
149
|
+
"observational data alone.")
|
|
150
|
+
if mixed:
|
|
151
|
+
adv.trust.append("Additive-noise direction tests are meaningless on the discrete columns; "
|
|
152
|
+
"their edges will stay undirected unless a collider orients them.")
|
|
153
|
+
return adv
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Report: the result of running all checks, printable as text or JSON."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import asdict, dataclass, field
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
from .checks import CheckResult, run_checks
|
|
10
|
+
from .recommend import Advice, recommend
|
|
11
|
+
|
|
12
|
+
_TAG = {"pass": "PASS", "warn": "WARN", "fail": "FAIL", "info": "INFO"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Report:
|
|
17
|
+
n_rows: int
|
|
18
|
+
variables: list[str]
|
|
19
|
+
discrete: list[str]
|
|
20
|
+
checks: list[CheckResult]
|
|
21
|
+
advice: Advice
|
|
22
|
+
version: str = "0.1.0"
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def worst_status(self) -> str:
|
|
26
|
+
order = {"fail": 3, "warn": 2, "info": 1, "pass": 0}
|
|
27
|
+
return max((c.status for c in self.checks), key=lambda s: order[s], default="pass")
|
|
28
|
+
|
|
29
|
+
def to_dict(self) -> dict:
|
|
30
|
+
return dict(causalcheck=self.version, n_rows=self.n_rows, variables=self.variables,
|
|
31
|
+
discrete=self.discrete, worst_status=self.worst_status,
|
|
32
|
+
checks=[asdict(c) for c in self.checks], advice=asdict(self.advice))
|
|
33
|
+
|
|
34
|
+
def to_json(self, indent: int = 2) -> str:
|
|
35
|
+
return json.dumps(self.to_dict(), indent=indent, default=_jsonable)
|
|
36
|
+
|
|
37
|
+
def to_text(self, width: int = 100) -> str:
|
|
38
|
+
lines = [f"causalcheck {self.version} | {self.n_rows} rows x {len(self.variables)} variables"
|
|
39
|
+
+ (f" ({len(self.discrete)} discrete)" if self.discrete else ""),
|
|
40
|
+
"=" * width, "CHECKS"]
|
|
41
|
+
for c in self.checks:
|
|
42
|
+
lines.append(f" [{_TAG[c.status]}] {c.name}")
|
|
43
|
+
lines += _wrap(c.summary, width, indent=9)
|
|
44
|
+
if self.advice.fix_first:
|
|
45
|
+
lines += ["", "FIX FIRST"]
|
|
46
|
+
for f in self.advice.fix_first:
|
|
47
|
+
lines += _wrap("- " + f, width, indent=2, hang=4)
|
|
48
|
+
lines += ["", "METHODS"]
|
|
49
|
+
for m in self.advice.methods:
|
|
50
|
+
lines += _wrap(f"{m.verdict:<12} {m.method}: {m.reason}", width, indent=2, hang=15)
|
|
51
|
+
if self.advice.trust:
|
|
52
|
+
lines += ["", "HOW TO READ YOUR RESULTS"]
|
|
53
|
+
for t in self.advice.trust:
|
|
54
|
+
lines += _wrap("- " + t, width, indent=2, hang=4)
|
|
55
|
+
lines += ["=" * width,
|
|
56
|
+
"Checks describe the data, not any graph. They cannot detect every violation "
|
|
57
|
+
"(faithfulness and selection bias are untestable)."]
|
|
58
|
+
return "\n".join(lines)
|
|
59
|
+
|
|
60
|
+
def __str__(self) -> str:
|
|
61
|
+
return self.to_text()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _wrap(text: str, width: int, indent: int, hang: int | None = None) -> list[str]:
|
|
65
|
+
import textwrap
|
|
66
|
+
hang = indent if hang is None else hang
|
|
67
|
+
return textwrap.wrap(text, width=width, initial_indent=" " * indent, subsequent_indent=" " * hang) or [""]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _jsonable(o):
|
|
71
|
+
try:
|
|
72
|
+
import numpy as np
|
|
73
|
+
if isinstance(o, (np.integer,)):
|
|
74
|
+
return int(o)
|
|
75
|
+
if isinstance(o, (np.floating,)):
|
|
76
|
+
return float(o)
|
|
77
|
+
if isinstance(o, (np.bool_,)):
|
|
78
|
+
return bool(o)
|
|
79
|
+
if isinstance(o, np.ndarray):
|
|
80
|
+
return o.tolist()
|
|
81
|
+
except ImportError:
|
|
82
|
+
pass
|
|
83
|
+
return str(o)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def check(data, confounding: bool = False, seed: int = 0) -> Report:
|
|
87
|
+
"""Run every check on a DataFrame (or anything DataFrame() accepts) and return a Report.
|
|
88
|
+
|
|
89
|
+
>>> import causalcheck
|
|
90
|
+
>>> report = causalcheck.check(df)
|
|
91
|
+
>>> print(report)
|
|
92
|
+
"""
|
|
93
|
+
df = data if isinstance(data, pd.DataFrame) else pd.DataFrame(data)
|
|
94
|
+
prep, results = run_checks(df, confounding=confounding, seed=seed)
|
|
95
|
+
return Report(n_rows=len(prep.X), variables=prep.names, discrete=prep.discrete,
|
|
96
|
+
checks=results, advice=recommend(results))
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: causalcheck
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pre-flight assumption checks for causal discovery: which methods your data can support, and which outputs to trust.
|
|
5
|
+
Author-email: Taha Bouhoun <bouhountaha@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Tahahaha7/causalcheck
|
|
8
|
+
Project-URL: Issues, https://github.com/Tahahaha7/causalcheck/issues
|
|
9
|
+
Keywords: causal discovery,causal inference,assumptions,diagnostics,LiNGAM,PC,FCI
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: numpy>=1.23
|
|
18
|
+
Requires-Dist: pandas>=1.5
|
|
19
|
+
Requires-Dist: scipy>=1.9
|
|
20
|
+
Requires-Dist: scikit-learn>=1.2
|
|
21
|
+
Provides-Extra: full
|
|
22
|
+
Requires-Dist: causal-learn>=0.1.3; extra == "full"
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
25
|
+
Requires-Dist: build; extra == "dev"
|
|
26
|
+
Requires-Dist: twine; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# causalcheck
|
|
30
|
+
|
|
31
|
+
Pre-flight assumption checks for causal discovery.
|
|
32
|
+
|
|
33
|
+
Every causal discovery method rests on assumptions about the data: linear relations, non-Gaussian noise, no hidden confounders, no near-deterministic links, comparable scales. Libraries will happily run any method on any data and return a confident graph either way. `causalcheck` looks at the data first and tells you which methods it can support, which outputs to trust, and what to fix before you run anything.
|
|
34
|
+
|
|
35
|
+
It describes the data, not a graph. It does not do causal discovery itself.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install causalcheck
|
|
41
|
+
pip install "causalcheck[full]" # adds causal-learn for the optional hidden-confounder check
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
Command line:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
causalcheck data.csv # text report
|
|
50
|
+
causalcheck data.csv --json # machine-readable
|
|
51
|
+
causalcheck data.csv --confounding # also run FCI to flag likely hidden common causes (slower)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Python:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
import pandas as pd
|
|
58
|
+
import causalcheck
|
|
59
|
+
|
|
60
|
+
report = causalcheck.check(pd.read_csv("data.csv"))
|
|
61
|
+
print(report) # text report
|
|
62
|
+
report.to_json() # same content as JSON
|
|
63
|
+
report.worst_status # "pass" | "info" | "warn" | "fail"
|
|
64
|
+
report.advice.methods # per-method verdicts
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The exit code of the CLI is `1` when any check fails, so it can gate a pipeline.
|
|
68
|
+
|
|
69
|
+
## What it checks
|
|
70
|
+
|
|
71
|
+
| Check | Question it answers | Why it matters |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| data types | Which columns are continuous, discrete, or non-numeric? How many rows have missing values? | Additive-noise direction tests need continuous variables; mixed data needs mixed-data scores |
|
|
74
|
+
| sample size | Is n large enough for the number of variables? | Small n gives weak CI tests and unstable orientations |
|
|
75
|
+
| linearity | Does a spline model beat a straight line out of sample for any pair? | Fisher-z, linear BIC and LiNGAM are mis-specified on nonlinear pairs |
|
|
76
|
+
| noise distribution | Are the residuals non-Gaussian (beyond a trivial amount)? | Non-Gaussian noise is what lets LiNGAM orient edges; Gaussian noise leaves only an equivalence class |
|
|
77
|
+
| near-determinism | Is any variable almost fully explained by the others? Are any pairs effectively duplicates? | Faithfulness breaks: CI-based skeletons (PC, FCI) drop true edges; duplicates confuse every method |
|
|
78
|
+
| scale | Do variances differ by orders of magnitude? | Continuous-optimization methods (NOTEARS, DAGMA) tend to recover the variance ordering instead of the causal one |
|
|
79
|
+
| hidden confounding (optional) | Does FCI consistently see bidirected edges? | Methods that assume no latents will orient those edges wrongly |
|
|
80
|
+
|
|
81
|
+
Each check returns one of four statuses:
|
|
82
|
+
|
|
83
|
+
- `pass`: the assumption looks satisfied
|
|
84
|
+
- `warn`: something that will mislead some methods
|
|
85
|
+
- `fail`: a method family should not be used on this data as is
|
|
86
|
+
- `info`: a finding that routes you to a method, with no good/bad meaning
|
|
87
|
+
|
|
88
|
+
## What it recommends
|
|
89
|
+
|
|
90
|
+
From the check results, the report gives:
|
|
91
|
+
|
|
92
|
+
- **Fix first:** data problems to resolve before running anything (duplicates, standardization, unencoded columns, heavy missingness).
|
|
93
|
+
- **Methods:** a verdict of `recommended`, `usable` or `avoid` for each family (PC, FCI/GFCI, GES/BOSS/GRaSP, DirectLiNGAM, ANM/CAM/RESIT, NOTEARS/DAGMA), with a one-line reason.
|
|
94
|
+
- **How to read your results:** what the output of any method can and cannot mean on this data.
|
|
95
|
+
|
|
96
|
+
## Example
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
causalcheck 0.1.0 | 1000 rows x 3 variables
|
|
100
|
+
CHECKS
|
|
101
|
+
[PASS] linearity all pairwise relations look linear
|
|
102
|
+
[PASS] noise distribution 3/3 residuals clearly non-Gaussian -> LiNGAM-style direction identification is possible
|
|
103
|
+
[WARN] scale variances differ by a factor of 37 -> standardize first
|
|
104
|
+
METHODS
|
|
105
|
+
recommended GES / BOSS / GRaSP (score-based): most reliable skeleton
|
|
106
|
+
recommended DirectLiNGAM: linear with non-Gaussian noise: full DAG and coefficients are identifiable
|
|
107
|
+
avoid NOTEARS / DAGMA (continuous optimization): variance ordering will drive the result
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Limitations
|
|
111
|
+
|
|
112
|
+
- Some assumptions cannot be tested from observational data: faithfulness (no canceling effects) and the absence of selection bias. The report says so rather than pretending.
|
|
113
|
+
- The noise check uses each variable's residual given all others as a stand-in for its true disturbance, because the graph is unknown.
|
|
114
|
+
- The hidden-confounder check is a signal, not a proof. Passing it does not rule out confounding.
|
|
115
|
+
- Thresholds (linearity gain 0.02, R² 0.98 for near-determinism, variance ratio 10) are sensible defaults, not laws. All are arguments to the check functions.
|
|
116
|
+
|
|
117
|
+
## Development
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
git clone https://github.com/Tahahaha7/causalcheck && cd causalcheck
|
|
121
|
+
python3 -m venv .venv && source .venv/bin/activate
|
|
122
|
+
pip install -e ".[dev]"
|
|
123
|
+
pytest
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## License
|
|
127
|
+
|
|
128
|
+
MIT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/causalcheck/__init__.py
|
|
5
|
+
src/causalcheck/checks.py
|
|
6
|
+
src/causalcheck/cli.py
|
|
7
|
+
src/causalcheck/recommend.py
|
|
8
|
+
src/causalcheck/report.py
|
|
9
|
+
src/causalcheck.egg-info/PKG-INFO
|
|
10
|
+
src/causalcheck.egg-info/SOURCES.txt
|
|
11
|
+
src/causalcheck.egg-info/dependency_links.txt
|
|
12
|
+
src/causalcheck.egg-info/entry_points.txt
|
|
13
|
+
src/causalcheck.egg-info/requires.txt
|
|
14
|
+
src/causalcheck.egg-info/top_level.txt
|
|
15
|
+
tests/test_checks.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
causalcheck
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Known-answer tests: synthetic data whose assumption profile we know in advance."""
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pandas as pd
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
import causalcheck
|
|
9
|
+
from causalcheck.cli import main
|
|
10
|
+
|
|
11
|
+
N = 1000
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture
|
|
15
|
+
def rng():
|
|
16
|
+
return np.random.default_rng(0)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def status(report, name):
|
|
20
|
+
return {c.name: c.status for c in report.checks}[name]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def verdict(report, prefix):
|
|
24
|
+
return next(m.verdict for m in report.advice.methods if m.method.startswith(prefix))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ----------------------------------------------------------------- datasets
|
|
28
|
+
|
|
29
|
+
def linear_gaussian(rng):
|
|
30
|
+
a = rng.normal(size=N)
|
|
31
|
+
b = 0.8 * a + rng.normal(size=N)
|
|
32
|
+
c = 0.5 * a + 0.5 * b + rng.normal(size=N)
|
|
33
|
+
return pd.DataFrame(dict(a=a, b=b, c=c))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def linear_nongaussian(rng):
|
|
37
|
+
a = rng.uniform(-1, 1, N)
|
|
38
|
+
b = 0.8 * a + 0.5 * rng.uniform(-1, 1, N)
|
|
39
|
+
c = 0.6 * a - 0.6 * b + 0.5 * rng.uniform(-1, 1, N)
|
|
40
|
+
return pd.DataFrame(dict(a=a, b=b, c=c))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def nonlinear(rng):
|
|
44
|
+
g = rng.normal(size=N)
|
|
45
|
+
h = np.tanh(2 * g) + 0.3 * rng.normal(size=N)
|
|
46
|
+
k = np.sin(h) + 0.3 * rng.normal(size=N)
|
|
47
|
+
return pd.DataFrame(dict(g=g, h=h, k=k))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def near_deterministic(rng):
|
|
51
|
+
x3 = rng.uniform(size=N)
|
|
52
|
+
x0 = 3 * x3 + rng.uniform(size=N)
|
|
53
|
+
x2 = 6 * x3 + rng.uniform(size=N)
|
|
54
|
+
x4 = 8 * x0 - x2 + rng.uniform(size=N)
|
|
55
|
+
return pd.DataFrame(dict(x0=x0, x2=x2, x3=x3, x4=x4))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# -------------------------------------------------------------------- tests
|
|
59
|
+
|
|
60
|
+
def test_linear_gaussian_refuses_direction_methods(rng):
|
|
61
|
+
r = causalcheck.check(linear_gaussian(rng))
|
|
62
|
+
assert status(r, "linearity") == "pass"
|
|
63
|
+
assert status(r, "noise distribution") == "warn"
|
|
64
|
+
assert verdict(r, "DirectLiNGAM") == "avoid"
|
|
65
|
+
assert verdict(r, "NOTEARS") == "avoid"
|
|
66
|
+
assert any("equivalence class" in t for t in r.advice.trust)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_linear_nongaussian_recommends_lingam(rng):
|
|
70
|
+
r = causalcheck.check(linear_nongaussian(rng))
|
|
71
|
+
assert status(r, "linearity") == "pass"
|
|
72
|
+
assert status(r, "noise distribution") == "pass"
|
|
73
|
+
assert verdict(r, "DirectLiNGAM") == "recommended"
|
|
74
|
+
assert verdict(r, "GES / BOSS") == "recommended"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_nonlinear_routes_to_anm(rng):
|
|
78
|
+
r = causalcheck.check(nonlinear(rng))
|
|
79
|
+
assert status(r, "linearity") == "info"
|
|
80
|
+
assert verdict(r, "DirectLiNGAM") == "avoid"
|
|
81
|
+
assert verdict(r, "ANM") == "recommended"
|
|
82
|
+
pairs = {c.name: c for c in r.checks}["linearity"].details["nonlinear_pairs"]
|
|
83
|
+
assert any({p[0], p[1]} == {"g", "h"} for p in pairs)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_near_determinism_flags_faithfulness_risk(rng):
|
|
87
|
+
r = causalcheck.check(near_deterministic(rng))
|
|
88
|
+
assert status(r, "near-determinism") == "warn"
|
|
89
|
+
assert verdict(r, "PC") == "avoid"
|
|
90
|
+
assert verdict(r, "GES / BOSS") == "recommended"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_duplicate_column_fails(rng):
|
|
94
|
+
df = linear_gaussian(rng)
|
|
95
|
+
df["a_inches"] = df["a"] * 2.54
|
|
96
|
+
r = causalcheck.check(df)
|
|
97
|
+
assert status(r, "near-determinism") == "fail"
|
|
98
|
+
assert r.worst_status == "fail"
|
|
99
|
+
assert any("duplicate" in f.lower() for f in r.advice.fix_first)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_discrete_missing_and_text_columns(rng):
|
|
103
|
+
df = linear_gaussian(rng)
|
|
104
|
+
df["level"] = rng.integers(0, 3, N)
|
|
105
|
+
df["label"] = np.where(df["level"] == 0, "x", "y")
|
|
106
|
+
df.loc[:49, "b"] = np.nan
|
|
107
|
+
r = causalcheck.check(df)
|
|
108
|
+
assert status(r, "data types") == "warn"
|
|
109
|
+
assert r.discrete == ["level"]
|
|
110
|
+
assert r.n_rows == N - 50
|
|
111
|
+
assert "label" not in r.variables
|
|
112
|
+
assert verdict(r, "ANM") == "avoid"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_small_sample_warns(rng):
|
|
116
|
+
r = causalcheck.check(linear_gaussian(rng).head(80))
|
|
117
|
+
assert status(r, "sample size") == "warn"
|
|
118
|
+
assert any("bootstrap" in t.lower() for t in r.advice.trust)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_scale_warns_and_asks_to_standardize(rng):
|
|
122
|
+
df = linear_gaussian(rng)
|
|
123
|
+
df["c"] *= 100
|
|
124
|
+
r = causalcheck.check(df)
|
|
125
|
+
assert status(r, "scale") == "warn"
|
|
126
|
+
assert any("standardize" in f.lower() for f in r.advice.fix_first)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_report_text_and_json(rng):
|
|
130
|
+
r = causalcheck.check(linear_gaussian(rng))
|
|
131
|
+
text = str(r)
|
|
132
|
+
assert "CHECKS" in text and "METHODS" in text and "HOW TO READ" in text
|
|
133
|
+
d = json.loads(r.to_json())
|
|
134
|
+
assert d["worst_status"] == r.worst_status
|
|
135
|
+
assert len(d["checks"]) == len(r.checks)
|
|
136
|
+
assert d["causalcheck"] == causalcheck.__version__
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_confounding_check_skips_without_causal_learn(rng):
|
|
140
|
+
pytest.importorskip("numpy")
|
|
141
|
+
try:
|
|
142
|
+
import causallearn # noqa: F401
|
|
143
|
+
pytest.skip("causal-learn installed; skip-path not exercised")
|
|
144
|
+
except ImportError:
|
|
145
|
+
pass
|
|
146
|
+
r = causalcheck.check(linear_gaussian(rng), confounding=True)
|
|
147
|
+
assert status(r, "hidden confounding") == "info"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_cli_exit_codes(rng, tmp_path, capsys):
|
|
151
|
+
ok = tmp_path / "ok.csv"
|
|
152
|
+
linear_gaussian(rng).to_csv(ok, index=False)
|
|
153
|
+
assert main([str(ok)]) == 0
|
|
154
|
+
assert "CHECKS" in capsys.readouterr().out
|
|
155
|
+
bad = tmp_path / "bad.csv"
|
|
156
|
+
df = linear_gaussian(rng)
|
|
157
|
+
df["a2"] = df["a"]
|
|
158
|
+
df.to_csv(bad, index=False)
|
|
159
|
+
assert main([str(bad), "--json"]) == 1
|
|
160
|
+
assert json.loads(capsys.readouterr().out)["worst_status"] == "fail"
|