causalcheck 0.1.0__py3-none-any.whl

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.
@@ -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"]
causalcheck/checks.py ADDED
@@ -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
causalcheck/cli.py ADDED
@@ -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
causalcheck/report.py ADDED
@@ -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,11 @@
1
+ causalcheck/__init__.py,sha256=-1v9XjAHST9gitnRGL-1Rf62a3dDlbHo0XAq19dtAV8,535
2
+ causalcheck/checks.py,sha256=EmNuFGrIBQlkmt6l6HLCh8dreoBxXyLH_3n9BWuT718,12576
3
+ causalcheck/cli.py,sha256=X5Wloam9eCxohMWainWgYd-1GMJ3FM_aiyy9V2Pr2JM,1972
4
+ causalcheck/recommend.py,sha256=8Od5nR3WFUctouPxTJRxiKH1fMSdbs0bj66giA98oGg,7595
5
+ causalcheck/report.py,sha256=Bmog-S0YPSjIsw0BydlEo6xTDNXBHaIrjz_ZfReKRvY,3582
6
+ causalcheck-0.1.0.dist-info/licenses/LICENSE,sha256=0IViFdYUWEEkbScww3XnmZ2LNuUNHItQ2DC2gkZuzHA,1069
7
+ causalcheck-0.1.0.dist-info/METADATA,sha256=kEhb3-tnjThZarnvU3TEQEC8szRO_SwaWdv9Zlth3Uw,5866
8
+ causalcheck-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
9
+ causalcheck-0.1.0.dist-info/entry_points.txt,sha256=r3q2uNDjrPfH2OvV6ueQHZVKvF0wRLKheyqmLZ3oCJ0,53
10
+ causalcheck-0.1.0.dist-info/top_level.txt,sha256=iPw30A50U_AxgfsTOtjmk-8-dIXTYQQX-XKrpkyutk4,12
11
+ causalcheck-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ causalcheck = causalcheck.cli:main
@@ -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 @@
1
+ causalcheck