shap-recommender 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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kaylee
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,120 @@
1
+ Metadata-Version: 2.4
2
+ Name: shap-recommender
3
+ Version: 0.1.0
4
+ Summary: Exclusion / non-linearity / interaction recommendations from saved SHAP attribution files, and application of them to a design matrix.
5
+ Author: Kaylee
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://pypi.org/project/shap-recommender/
8
+ Keywords: shap,feature-selection,interaction-detection,interpretability,machine-learning
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
12
+ Classifier: Intended Audience :: Science/Research
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: numpy>=1.23
17
+ Requires-Dist: pandas>=1.5
18
+ Requires-Dist: scipy>=1.9
19
+ Requires-Dist: statsmodels>=0.13
20
+ Requires-Dist: patsy>=0.5
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest; extra == "dev"
23
+ Dynamic: license-file
24
+
25
+ # shap-recommender
26
+
27
+ Exclusion / non-linearity / interaction recommendations from saved SHAP
28
+ attribution files, and application of them to a design matrix.
29
+
30
+ One shared idea runs through two of the three rules. A feature's own
31
+ contribution is represented flexibly (indicator columns when it takes few
32
+ values, a restricted cubic spline when it is continuous), and a model built
33
+ on that flexible basis is compared against a straight line in the feature.
34
+ That comparison answers two different questions:
35
+
36
+ - **non-linearity** -- does the attribution deviate from a linear function
37
+ of the feature? This directly tests the linear-trend assumption, rather
38
+ than relying on a raw correlation coefficient, which conflates "no effect"
39
+ with "non-linear effect".
40
+ - **interaction** -- does the attribution vary among subjects who share the
41
+ same feature value? Under additivity the attribution is a deterministic
42
+ function of the feature, so residual dispersion implies effect
43
+ modification.
44
+
45
+ Stratification for the interaction screen is always applied to the observed
46
+ feature value at a pre-specified cut point, never to the attribution itself
47
+ -- splitting on the attribution would condition on the candidate modifier.
48
+ Within strata, the contrast `E[phi_y | y=1] - E[phi_y | y=0]` is compared,
49
+ which (unlike the marginal attribution distribution) is invariant to
50
+ stratum composition under additivity.
51
+
52
+ ## Install
53
+
54
+ ```bash
55
+ pip install shap-recommender
56
+ ```
57
+
58
+ ## Expected input files
59
+
60
+ For each dataset "tag" you want to load, `Recommender.load(tag)` (and the
61
+ CLI's `--tags`) expects two tab-separated files in `res_dir`:
62
+
63
+ - `shap_values_<tag>.tsv` -- SHAP values, one row per subject, one column
64
+ per feature, first column = row index.
65
+ - `sel_data_<tag>.tsv` -- the corresponding feature values (design matrix),
66
+ same row index.
67
+
68
+ ## Command-line use
69
+
70
+ ```bash
71
+ shap-recommender \
72
+ --res-dir ./shap_results \
73
+ --tags cohort_a cohort_b \
74
+ --nonlinear-candidates age bmi creatinine \
75
+ --out ./recommendations
76
+ ```
77
+
78
+ This writes `exclusion_tests.tsv`, `exclusion_sensitivity.tsv`,
79
+ `nonlinear_tests.tsv`, `interaction_tests.tsv`, `attribution_patterns.tsv`,
80
+ and `recommendations.json` to `--out`. Run `shap-recommender --help` for all
81
+ options (thresholds, bootstrap count, spline degrees of freedom, a
82
+ `--cutpoints` JSON file for pre-specified stratification cut points, etc).
83
+
84
+ ## Library use
85
+
86
+ ```python
87
+ from shap_recommender import Recommender
88
+
89
+ rec = Recommender(res_dir="./shap_results")
90
+ recommendations = rec.generate(
91
+ candidates_nonlinear=["age", "bmi", "creatinine"],
92
+ tags=["cohort_a", "cohort_b"],
93
+ out="./recommendations",
94
+ )
95
+
96
+ # apply the recommendations to a design matrix
97
+ X_train_adj, X_test_adj = Recommender.apply(
98
+ X_train, X_test, recommendations, variant="all",
99
+ )
100
+ ```
101
+
102
+ `Recommender.apply(..., variant=...)` accepts `"baseline"`, `"exclusion"`,
103
+ `"nonlinear"`, `"interaction"`, or `"all"`, so each rule's effect on
104
+ downstream model performance can be evaluated separately.
105
+
106
+ ## Validating the interaction rule
107
+
108
+ `Recommender.null_sim()` runs a small simulation under an additive null
109
+ (no true interaction with the feature being tested) and reports the type-I
110
+ error rate of the within-stratum contrast used by `stratified_screen`,
111
+ compared against naively partitioning on the attribution itself:
112
+
113
+ ```python
114
+ from shap_recommender import Recommender
115
+ Recommender(res_dir=".").null_sim()
116
+ ```
117
+
118
+ ## License
119
+
120
+ MIT
@@ -0,0 +1,96 @@
1
+ # shap-recommender
2
+
3
+ Exclusion / non-linearity / interaction recommendations from saved SHAP
4
+ attribution files, and application of them to a design matrix.
5
+
6
+ One shared idea runs through two of the three rules. A feature's own
7
+ contribution is represented flexibly (indicator columns when it takes few
8
+ values, a restricted cubic spline when it is continuous), and a model built
9
+ on that flexible basis is compared against a straight line in the feature.
10
+ That comparison answers two different questions:
11
+
12
+ - **non-linearity** -- does the attribution deviate from a linear function
13
+ of the feature? This directly tests the linear-trend assumption, rather
14
+ than relying on a raw correlation coefficient, which conflates "no effect"
15
+ with "non-linear effect".
16
+ - **interaction** -- does the attribution vary among subjects who share the
17
+ same feature value? Under additivity the attribution is a deterministic
18
+ function of the feature, so residual dispersion implies effect
19
+ modification.
20
+
21
+ Stratification for the interaction screen is always applied to the observed
22
+ feature value at a pre-specified cut point, never to the attribution itself
23
+ -- splitting on the attribution would condition on the candidate modifier.
24
+ Within strata, the contrast `E[phi_y | y=1] - E[phi_y | y=0]` is compared,
25
+ which (unlike the marginal attribution distribution) is invariant to
26
+ stratum composition under additivity.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install shap-recommender
32
+ ```
33
+
34
+ ## Expected input files
35
+
36
+ For each dataset "tag" you want to load, `Recommender.load(tag)` (and the
37
+ CLI's `--tags`) expects two tab-separated files in `res_dir`:
38
+
39
+ - `shap_values_<tag>.tsv` -- SHAP values, one row per subject, one column
40
+ per feature, first column = row index.
41
+ - `sel_data_<tag>.tsv` -- the corresponding feature values (design matrix),
42
+ same row index.
43
+
44
+ ## Command-line use
45
+
46
+ ```bash
47
+ shap-recommender \
48
+ --res-dir ./shap_results \
49
+ --tags cohort_a cohort_b \
50
+ --nonlinear-candidates age bmi creatinine \
51
+ --out ./recommendations
52
+ ```
53
+
54
+ This writes `exclusion_tests.tsv`, `exclusion_sensitivity.tsv`,
55
+ `nonlinear_tests.tsv`, `interaction_tests.tsv`, `attribution_patterns.tsv`,
56
+ and `recommendations.json` to `--out`. Run `shap-recommender --help` for all
57
+ options (thresholds, bootstrap count, spline degrees of freedom, a
58
+ `--cutpoints` JSON file for pre-specified stratification cut points, etc).
59
+
60
+ ## Library use
61
+
62
+ ```python
63
+ from shap_recommender import Recommender
64
+
65
+ rec = Recommender(res_dir="./shap_results")
66
+ recommendations = rec.generate(
67
+ candidates_nonlinear=["age", "bmi", "creatinine"],
68
+ tags=["cohort_a", "cohort_b"],
69
+ out="./recommendations",
70
+ )
71
+
72
+ # apply the recommendations to a design matrix
73
+ X_train_adj, X_test_adj = Recommender.apply(
74
+ X_train, X_test, recommendations, variant="all",
75
+ )
76
+ ```
77
+
78
+ `Recommender.apply(..., variant=...)` accepts `"baseline"`, `"exclusion"`,
79
+ `"nonlinear"`, `"interaction"`, or `"all"`, so each rule's effect on
80
+ downstream model performance can be evaluated separately.
81
+
82
+ ## Validating the interaction rule
83
+
84
+ `Recommender.null_sim()` runs a small simulation under an additive null
85
+ (no true interaction with the feature being tested) and reports the type-I
86
+ error rate of the within-stratum contrast used by `stratified_screen`,
87
+ compared against naively partitioning on the attribution itself:
88
+
89
+ ```python
90
+ from shap_recommender import Recommender
91
+ Recommender(res_dir=".").null_sim()
92
+ ```
93
+
94
+ ## License
95
+
96
+ MIT
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "shap-recommender"
7
+ version = "0.1.0"
8
+ description = "Exclusion / non-linearity / interaction recommendations from saved SHAP attribution files, and application of them to a design matrix."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [
14
+ { name = "Kaylee" },
15
+ ]
16
+ keywords = ["shap", "feature-selection", "interaction-detection", "interpretability", "machine-learning"]
17
+ classifiers = [
18
+ "Programming Language :: Python :: 3",
19
+ "Operating System :: OS Independent",
20
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
21
+ "Intended Audience :: Science/Research",
22
+ ]
23
+ dependencies = [
24
+ "numpy>=1.23",
25
+ "pandas>=1.5",
26
+ "scipy>=1.9",
27
+ "statsmodels>=0.13",
28
+ "patsy>=0.5",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ dev = ["pytest"]
33
+
34
+ [project.scripts]
35
+ shap-recommender = "shap_recommender.cli:main"
36
+
37
+ [project.urls]
38
+ Homepage = "https://pypi.org/project/shap-recommender/"
39
+
40
+ [tool.setuptools.packages.find]
41
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,13 @@
1
+ """shap_recommender: exclusion / non-linearity / interaction recommendations
2
+ from saved SHAP attribution files, and application of them to a design
3
+ matrix.
4
+
5
+ See :class:`shap_recommender.core.Recommender` for the full API, and run
6
+ ``shap-recommender --help`` for the command-line interface.
7
+ """
8
+
9
+ from .core import Recommender
10
+
11
+ __version__ = "0.1.0"
12
+
13
+ __all__ = ["Recommender", "__version__"]
@@ -0,0 +1,84 @@
1
+ """
2
+ Command-line entry point for :meth:`shap_recommender.core.Recommender.generate`.
3
+
4
+ Usage
5
+ -----
6
+ shap-recommender \\
7
+ --res-dir ./shap_results \\
8
+ --tags cohort_a cohort_b \\
9
+ --nonlinear-candidates age bmi creatinine \\
10
+ --out ./recommendations
11
+
12
+ Expects, in --res-dir, a `shap_values_<tag>.tsv` and a `sel_data_<tag>.tsv`
13
+ for every tag passed to --tags (see Recommender.load).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+
21
+ from .core import Recommender
22
+
23
+
24
+ def build_parser() -> argparse.ArgumentParser:
25
+ p = argparse.ArgumentParser(
26
+ prog="shap-recommender",
27
+ description="Exclusion / non-linearity / interaction recommendations "
28
+ "from saved SHAP attribution files.",
29
+ )
30
+ p.add_argument("--res-dir", required=True,
31
+ help="Directory containing shap_values_<tag>.tsv and "
32
+ "sel_data_<tag>.tsv files.")
33
+ p.add_argument("--tags", required=True, nargs="+",
34
+ help="One or more dataset tags to load and pool.")
35
+ p.add_argument("--nonlinear-candidates", required=True, nargs="+",
36
+ help="Feature names to test for non-linearity.")
37
+ p.add_argument("--candidates", default=None, nargs="+",
38
+ help="Feature names eligible as interaction partners "
39
+ "(default: all columns).")
40
+ p.add_argument("--cutpoints", default=None,
41
+ help="Optional path to a JSON file of {feature: cut_point} "
42
+ "pre-specified stratification cut points.")
43
+ p.add_argument("--out", default="recommendations",
44
+ help="Output directory for test tables and recommendations.json.")
45
+ p.add_argument("--frac", type=float, default=0.05)
46
+ p.add_argument("--alpha", type=float, default=0.05)
47
+ p.add_argument("--n-boot", type=int, default=500)
48
+ p.add_argument("--min-delta-r2", type=float, default=0.01)
49
+ p.add_argument("--min-cell", type=int, default=100)
50
+ p.add_argument("--n-bins", type=int, default=10)
51
+ p.add_argument("--df-spline", type=int, default=4)
52
+ p.add_argument("--residual-thresh", type=float, default=0.05)
53
+ p.add_argument("--min-effect-ratio", type=float, default=0.10)
54
+ p.add_argument("--seed", type=int, default=1)
55
+ return p
56
+
57
+
58
+ def main(argv=None) -> int:
59
+ args = build_parser().parse_args(argv)
60
+
61
+ cutpoints = None
62
+ if args.cutpoints:
63
+ with open(args.cutpoints) as fh:
64
+ cutpoints = json.load(fh)
65
+
66
+ rec = Recommender(
67
+ res_dir=args.res_dir, frac=args.frac, alpha=args.alpha,
68
+ n_boot=args.n_boot, min_delta_r2=args.min_delta_r2,
69
+ min_cell=args.min_cell, n_bins=args.n_bins, df_spline=args.df_spline,
70
+ residual_thresh=args.residual_thresh,
71
+ min_effect_ratio=args.min_effect_ratio, seed=args.seed,
72
+ )
73
+ rec.generate(
74
+ candidates_nonlinear=args.nonlinear_candidates,
75
+ tags=args.tags,
76
+ cutpoints=cutpoints,
77
+ candidates=args.candidates,
78
+ out=args.out,
79
+ )
80
+ return 0
81
+
82
+
83
+ if __name__ == "__main__":
84
+ raise SystemExit(main())
@@ -0,0 +1,446 @@
1
+ """
2
+ core.py -- exclusion / non-linearity / interaction recommendations from saved
3
+ SHAP attribution files, and application of them to a design matrix.
4
+
5
+ One shared idea runs through two of the three rules. ``_basis(x)`` represents
6
+ a feature's own contribution flexibly: indicator columns when x takes few
7
+ values, a restricted cubic spline when it is continuous. Comparing a model
8
+ built on that basis against a straight line in x answers both questions:
9
+
10
+ non-linearity
11
+ Does phi_x deviate from a linear function of x? This is a direct test of
12
+ the linear-trend assumption, rather than relying on a raw correlation
13
+ coefficient, which conflates "no effect" with "non-linear effect" --
14
+ a monotone non-linear relationship can have a small |r| while a
15
+ genuinely linear-but-noisy one can have a larger |r|.
16
+
17
+ interaction
18
+ Does phi_x vary among patients who share a value of x? Under additivity
19
+ phi_x = h(x) - E[h(x)] is a deterministic function of x, so residual
20
+ dispersion implies effect modification; none implies a purely additive
21
+ (main-effects-only) relationship.
22
+
23
+ Stratification is always applied to the observed feature value at a
24
+ pre-specified cut point, never to the attribution itself: phi_x is a function
25
+ of the whole feature vector, so splitting on it would condition on the
26
+ candidate modifier itself. Within strata the contrast
27
+ E[phi_y | y=1] - E[phi_y | y=0] is compared, which is invariant to stratum
28
+ composition under additivity, unlike the marginal attribution distribution.
29
+ """
30
+
31
+ import json
32
+ import os
33
+
34
+ import numpy as np
35
+ import pandas as pd
36
+ from scipy.stats import f as fdist, norm
37
+ from statsmodels.stats.multitest import multipletests
38
+
39
+ __all__ = ["Recommender"]
40
+
41
+
42
+ class Recommender:
43
+ """
44
+ Parameters
45
+ ----------
46
+ res_dir : str
47
+ Directory containing ``shap_values_<tag>.tsv`` and
48
+ ``sel_data_<tag>.tsv`` files (see :meth:`load`).
49
+ frac : float
50
+ Exclusion threshold, as a fraction of the across-feature SD of mean
51
+ |attribution| (rule 1).
52
+ alpha : float
53
+ FDR-adjusted significance threshold used by the non-linearity and
54
+ interaction rules.
55
+ n_boot : int
56
+ Bootstrap resamples used for confidence intervals.
57
+ min_delta_r2 : float
58
+ Minimum incremental R^2 (flexible basis over a straight line) for a
59
+ feature to be flagged as non-linear.
60
+ min_cell : int
61
+ Minimum stratum size required to run the interaction screen.
62
+ n_bins : int
63
+ A feature with at most this many unique values is treated as
64
+ discrete (indicator basis); above it, a spline basis is used.
65
+ df_spline : int
66
+ Degrees of freedom for the restricted cubic spline basis.
67
+ residual_thresh : float
68
+ Residual-fraction threshold below which a feature's attribution is
69
+ treated as a deterministic (purely additive) function of the
70
+ feature itself.
71
+ min_effect_ratio : float
72
+ Minimum relative effect size for an interaction pair to be flagged.
73
+ seed : int
74
+ Seed for the bootstrap random number generator.
75
+ """
76
+
77
+ def __init__(self, res_dir, frac=0.05, alpha=0.05,
78
+ n_boot=500, min_delta_r2=0.01, min_cell=100, n_bins=10,
79
+ df_spline=4, residual_thresh=0.05, min_effect_ratio=0.10, seed=1):
80
+ self.res, self.frac, self.alpha = res_dir, frac, alpha
81
+ self.n_boot, self.min_delta_r2 = n_boot, min_delta_r2
82
+ self.min_cell, self.n_bins, self.df_spline = min_cell, n_bins, df_spline
83
+ self.residual_thresh, self.min_effect_ratio = residual_thresh, min_effect_ratio
84
+ self.rng = np.random.default_rng(seed)
85
+
86
+ def load(self, tag):
87
+ """Load a (shap_values, design_matrix) pair for `tag` from `res_dir`,
88
+ expecting files named ``shap_values_<tag>.tsv`` and
89
+ ``sel_data_<tag>.tsv`` (tab-separated, first column = row index)."""
90
+ s = pd.read_csv(f'{self.res}/shap_values_{tag}.tsv', sep='\t', index_col=0)
91
+ x = pd.read_csv(f'{self.res}/sel_data_{tag}.tsv', sep='\t', index_col=0)
92
+ return s, x.loc[s.index]
93
+
94
+ # ------------------------------------------------------ shared basis --
95
+ def _basis(self, x):
96
+ """Indicator columns for few-valued x, restricted cubic spline for
97
+ continuous x. Binning a continuous variable and using bin means
98
+ would leave h(x) varying inside each bin and get counted as
99
+ residual dispersion, so a spline basis is used instead once a
100
+ feature has more distinct values than `n_bins`."""
101
+ x = np.asarray(x, float)
102
+ u = np.unique(x[np.isfinite(x)])
103
+ if u.size < 2:
104
+ return np.empty((x.size, 0))
105
+ if u.size <= self.n_bins:
106
+ return np.column_stack([(x == v).astype(float) for v in u[1:]])
107
+ from patsy import dmatrix
108
+ return np.asarray(dmatrix(f'0 + cr(a, df={self.df_spline})', {'a': x},
109
+ return_type='dataframe'))
110
+
111
+ def _fit_r2(self, A, phi, tss):
112
+ beta = np.linalg.lstsq(A, phi, rcond=None)[0]
113
+ return 1 - float(((phi - A @ beta) ** 2).sum()) / tss
114
+
115
+ # ---------------------------------------------------- rule 1: exclude --
116
+ def exclusion(self, shap_df, frac=None, out=None):
117
+ """Exclude a feature when the upper bound of the 95% CI of its mean
118
+ absolute attribution falls below `frac` x SD(per-feature mean |attribution|)
119
+ -- the SD taken across features, not of the whole matrix."""
120
+ frac = self.frac if frac is None else frac
121
+ ma = shap_df.abs().mean(axis=0)
122
+ thr = frac * float(ma.std(ddof=1))
123
+ rows = []
124
+ for c in shap_df.columns:
125
+ v = shap_df[c].abs().to_numpy(float)
126
+ b = np.array([np.nanmean(v[self.rng.integers(0, v.size, v.size)])
127
+ for _ in range(self.n_boot)])
128
+ hi = float(np.percentile(b, 97.5))
129
+ rows.append(dict(feature=c, mean_abs=float(ma[c]), ci_high=hi,
130
+ threshold=thr, excluded=bool(hi < thr)))
131
+ t = pd.DataFrame(rows).sort_values('mean_abs')
132
+ if out:
133
+ t.to_csv(out, sep='\t', index=False)
134
+ excl = t.loc[t.excluded, 'feature'].tolist()
135
+ print(f'[exclusion] threshold={thr:.4g}; {len(excl)}/{len(t)} excluded')
136
+ return excl, t
137
+
138
+ def exclusion_sensitivity(self, shap_df, fracs=(0.01, 0.05, 0.10, 0.20), out=None):
139
+ """Re-run :meth:`exclusion` at several thresholds, to show how the
140
+ excluded-feature set changes with `frac`."""
141
+ rows = [dict(frac=f, n_excluded=len(e), features='; '.join(sorted(e))[:1000])
142
+ for f, e in ((f, self.exclusion(shap_df, frac=f)[0]) for f in fracs)]
143
+ t = pd.DataFrame(rows)
144
+ if out:
145
+ t.to_csv(out, sep='\t', index=False)
146
+ return t
147
+
148
+ # -------------------------------------------------- rule 2: nonlinear --
149
+ def nonlinear(self, shap_df, sel_df, candidates, out=None):
150
+ """Incremental R-squared of the flexible basis over a straight line.
151
+ Ordinal features are eligible and use the indicator basis, which is
152
+ the standard test of a linear-trend assumption for a variable whose
153
+ risk is not monotone in its coded levels."""
154
+ rows = []
155
+ for c in candidates:
156
+ if c not in shap_df.columns or c not in sel_df.columns:
157
+ continue
158
+ x, phi = sel_df[c].to_numpy(float), shap_df[c].to_numpy(float)
159
+ ok = np.isfinite(x) & np.isfinite(phi)
160
+ x, phi = x[ok], phi[ok]
161
+ tss = float(((phi - phi.mean()) ** 2).sum())
162
+ B = self._basis(x)
163
+ if x.size < 50 or B.shape[1] < 2 or tss == 0:
164
+ continue
165
+ r2_lin = self._fit_r2(np.c_[np.ones(x.size), x], phi, tss)
166
+ r2_flex = self._fit_r2(np.c_[np.ones(x.size), B], phi, tss)
167
+ k = B.shape[1]
168
+ den = (1 - r2_flex) / max(x.size - k - 1, 1)
169
+ F = ((r2_flex - r2_lin) / max(k - 1, 1) / den) if den > 0 else np.inf
170
+ rows.append(dict(feature=c, n=int(x.size), n_levels=int(np.unique(x).size),
171
+ basis='indicator' if np.unique(x).size <= self.n_bins else 'spline',
172
+ r=float(np.corrcoef(x, phi)[0, 1]),
173
+ delta_r2=float(r2_flex - r2_lin),
174
+ p=float(fdist.sf(F, max(k - 1, 1), max(x.size - k - 1, 1)))))
175
+ t = pd.DataFrame(rows)
176
+ if not len(t):
177
+ return [], t
178
+ t['p_fdr'] = multipletests(t['p'], method='fdr_bh')[1]
179
+ t['selected'] = (t.p_fdr < self.alpha) & (t.delta_r2 > self.min_delta_r2)
180
+ if out:
181
+ t.to_csv(out, sep='\t', index=False)
182
+ sel = t.loc[t.selected, 'feature'].tolist()
183
+ print(f'[non-linearity] {len(sel)}/{len(t)} flagged: {sel}')
184
+ return sel, t
185
+
186
+ # ------------------------------------------------ rule 3: interaction --
187
+ def residual_fraction(self, x, phi):
188
+ """Share of Var(phi_x) not explained by x itself. ~0 implies a
189
+ purely additive (main-effects-only) relationship."""
190
+ x, phi = np.asarray(x, float), np.asarray(phi, float)
191
+ tss = float(phi.var())
192
+ if tss == 0:
193
+ return 0.0
194
+ if np.unique(x[np.isfinite(x)]).size < 2:
195
+ return 1.0 # x constant: undefined, treat as flagged
196
+ A = np.c_[np.ones(x.size), self._basis(x)]
197
+ return float(np.var(phi - A @ np.linalg.lstsq(A, phi, rcond=None)[0]) / tss)
198
+
199
+ def _rf_ci(self, x, phi, b=200):
200
+ v = np.array([self.residual_fraction(x[i], phi[i]) for i in
201
+ (self.rng.integers(0, len(x), len(x)) for _ in range(b))])
202
+ v = v[np.isfinite(v)]
203
+ return float(np.percentile(v, 2.5)), float(np.percentile(v, 97.5))
204
+
205
+ def _cutpoint(self, x, var, cutpoints):
206
+ """Continuous variables need a pre-specified cut point: a threshold
207
+ read off the attribution plot is not defensible, since the zero
208
+ crossing of phi_x depends on the cohort's covariate distribution and
209
+ is not a fixed property of the variable itself."""
210
+ u = np.unique(np.asarray(x, float)[np.isfinite(x)])
211
+ if cutpoints and var in cutpoints:
212
+ return float(cutpoints[var]), 'pre-specified'
213
+ if u.size == 2:
214
+ return float(u[0]), 'binary'
215
+ if u.size <= self.n_bins:
216
+ return float(np.median(u)), 'median of observed levels'
217
+ return None, 'continuous: no pre-specified cut point, skipped'
218
+
219
+ @staticmethod
220
+ def _slopes(Y, PHI):
221
+ """Column-wise OLS slope of each attribution on its own feature, with
222
+ a heteroskedasticity-robust variance. For a binary feature this
223
+ equals E[phi|y=1] - E[phi|y=0], the within-stratum contrast."""
224
+ Yc, Pc = Y - Y.mean(0), PHI - PHI.mean(0)
225
+ den = (Yc ** 2).sum(0)
226
+ safe = np.where(den > 0, den, 1.0)
227
+ beta = np.where(den > 0, (Yc * Pc).sum(0) / safe, np.nan)
228
+ resid = Pc - Yc * beta
229
+ var = np.where(den > 0, ((Yc ** 2) * (resid ** 2)).sum(0) / safe ** 2, np.nan)
230
+ return beta, var
231
+
232
+ def stratified_screen(self, x, cut, shap_df, X_df, strat_var, cols):
233
+ """Split on the observed feature value, compare within-stratum
234
+ contrasts rather than the marginal attribution distribution, since a
235
+ common reference point does not repair the marginal comparison when
236
+ the two strata have different prevalences of the partner feature."""
237
+ x = np.asarray(x, float)
238
+ m1 = x <= cut
239
+ m2 = ~m1
240
+ if m1.sum() < self.min_cell or m2.sum() < self.min_cell:
241
+ return pd.DataFrame()
242
+ d1, v1 = self._slopes(X_df.loc[m1, cols].to_numpy(float),
243
+ shap_df.loc[m1, cols].to_numpy(float))
244
+ d2, v2 = self._slopes(X_df.loc[m2, cols].to_numpy(float),
245
+ shap_df.loc[m2, cols].to_numpy(float))
246
+ diff, se = d1 - d2, np.sqrt(v1 + v2)
247
+ # when the attribution is a deterministic function of the feature the
248
+ # residual variance is zero and floating-point noise would read as
249
+ # infinite significance; require the SE to clear a relative floor
250
+ floor = 1e-8 * np.maximum(np.abs(d1) + np.abs(d2), 1e-12)
251
+ with np.errstate(divide='ignore', invalid='ignore'):
252
+ z = np.where(se > floor, diff / se, 0.0)
253
+ scale = (np.abs(d1) + np.abs(d2)) / 2
254
+ rel = np.where(scale > 0, np.abs(diff) / scale, 0.0)
255
+ ok = np.isfinite(diff) & np.isfinite(se)
256
+ return pd.DataFrame(dict(strat=strat_var, partner=np.asarray(cols)[ok],
257
+ cut_point=cut, n_group1=int(m1.sum()),
258
+ n_group2=int(m2.sum()),
259
+ contrast_group1=d1[ok], contrast_group2=d2[ok],
260
+ effect_size=diff[ok],
261
+ ci_low=diff[ok] - 1.96 * se[ok],
262
+ ci_high=diff[ok] + 1.96 * se[ok],
263
+ rel_effect=rel[ok],
264
+ p_raw=np.minimum(2 * norm.sf(np.abs(z))[ok], 1.0)))
265
+
266
+ def interaction(self, shap_df, X_df, cutpoints=None, candidates=None,
267
+ out=None, diag_out=None):
268
+ """Screen all eligible feature pairs for effect modification. Returns
269
+ a `{stratifying_variable: [partner, ...]}` spec, the full test table,
270
+ and a per-variable diagnostic table (residual fraction and pattern)."""
271
+ X_df = X_df.loc[shap_df.index]
272
+ cand = [c for c in (candidates or X_df.columns) if c in X_df.columns]
273
+ diag, tabs = [], []
274
+ for v in [c for c in shap_df.columns if c in X_df.columns]:
275
+ x, phi = X_df[v].to_numpy(float), shap_df[v].to_numpy(float)
276
+ rf = self.residual_fraction(x, phi)
277
+ lo, hi = self._rf_ci(x, phi)
278
+ cut, basis = self._cutpoint(x, v, cutpoints)
279
+ pattern = ('A' if np.unique(x).size < 2 else
280
+ 'B' if rf < self.residual_thresh else 'C')
281
+ row = dict(variable=v, pattern=pattern, residual_fraction=rf,
282
+ rf_ci_low=lo, rf_ci_high=hi, threshold=self.residual_thresh,
283
+ cut_point=cut, cut_basis=basis, screened=False)
284
+ if lo > self.residual_thresh and cut is not None:
285
+ cols = [c for c in cand if c != v and c in shap_df.columns
286
+ and X_df[c].nunique(dropna=True) > 1]
287
+ t = self.stratified_screen(x, cut, shap_df, X_df, v, cols)
288
+ if len(t):
289
+ tabs.append(t)
290
+ row['screened'] = True
291
+ diag.append(row)
292
+
293
+ diag_df = pd.DataFrame(diag).sort_values('residual_fraction', ascending=False)
294
+ if diag_out:
295
+ diag_df.to_csv(diag_out, sep='\t', index=False)
296
+ print(f"[interaction] {len(diag_df)} variables: "
297
+ f"{int((diag_df.pattern == 'B').sum())} purely additive (not "
298
+ f"stratified), {int(diag_df.screened.sum())} screened")
299
+ if not tabs:
300
+ return {}, pd.DataFrame(), diag_df
301
+
302
+ t = pd.concat(tabs, ignore_index=True)
303
+ t['n_tests_total'] = len(t)
304
+ t['p_fdr'] = multipletests(t['p_raw'], method='fdr_bh')[1]
305
+ t['selected'] = (t.p_fdr < self.alpha) & (t.rel_effect >= self.min_effect_ratio)
306
+ t['pair'] = ['||'.join(sorted([a, b])) for a, b in zip(t.strat, t.partner)]
307
+ if out:
308
+ t.to_csv(out, sep='\t', index=False)
309
+ sel = t[t.selected].sort_values('p_fdr').drop_duplicates('pair')
310
+ spec = {}
311
+ for _, r in sel.iterrows():
312
+ spec.setdefault(r.strat, []).append(r.partner)
313
+ print(f'[interaction] {len(t)} tests, {len(sel)} unique pairs after FDR')
314
+ return spec, t, diag_df
315
+
316
+ # ------------------------------------------------------------ generate --
317
+ def generate(self, candidates_nonlinear, tags, cutpoints=None, candidates=None,
318
+ out='recs'):
319
+ """Run all three rules and write recommendations to `out`.
320
+
321
+ Parameters
322
+ ----------
323
+ candidates_nonlinear : list of str
324
+ Features to test for non-linearity.
325
+ tags : list of str
326
+ Dataset tags to load (via :meth:`load`) and concatenate before
327
+ screening -- e.g. separate tags for different subgroups or CV
328
+ folds that should be pooled for this analysis.
329
+ cutpoints : dict, optional
330
+ Pre-specified stratification cut points, keyed by variable name.
331
+ candidates : list of str, optional
332
+ Features eligible as interaction partners (default: all columns).
333
+ out : str
334
+ Output directory for the test tables and `recommendations.json`.
335
+ """
336
+ os.makedirs(out, exist_ok=True)
337
+ loaded = [self.load(tag) for tag in tags]
338
+ s_all = pd.concat([s for s, _ in loaded])
339
+ x_all = pd.concat([x for _, x in loaded])
340
+ s_uni = s_all[~s_all.index.duplicated()]
341
+
342
+ excl, _ = self.exclusion(s_uni, out=f'{out}/exclusion_tests.tsv')
343
+ self.exclusion_sensitivity(s_uni, out=f'{out}/exclusion_sensitivity.tsv')
344
+ nl, _ = self.nonlinear(s_all, x_all, candidates_nonlinear,
345
+ out=f'{out}/nonlinear_tests.tsv')
346
+ spec, _, _ = self.interaction(s_all, x_all, cutpoints=cutpoints,
347
+ candidates=candidates,
348
+ out=f'{out}/interaction_tests.tsv',
349
+ diag_out=f'{out}/attribution_patterns.tsv')
350
+ rec = dict(exclusion=excl, nonlinear=nl, interaction=spec)
351
+ with open(f'{out}/recommendations.json', 'w') as fh:
352
+ json.dump(rec, fh, indent=2)
353
+ print(f'exclusion {len(excl)}, nonlinear {len(nl)}, '
354
+ f'interaction {sum(len(v) for v in spec.values())}')
355
+ return rec
356
+
357
+ # --------------------------------------------------------------- apply --
358
+ @staticmethod
359
+ def _cols(name, frame, groups):
360
+ """SHAP columns carry the parent one-hot name; the model matrix
361
+ carries the dummy columns."""
362
+ return [name] if name in frame.columns else \
363
+ [c for c in groups.get(name, []) if c in frame.columns]
364
+
365
+ @staticmethod
366
+ def apply(X_train, X_test, rec, variant='all', groups=None,
367
+ hierarchy='drop', log=None):
368
+ """variant: baseline | exclusion | nonlinear | interaction | all.
369
+ hierarchy='drop' removes an interaction whose main effect was
370
+ excluded; 'keep' restores the main effect instead."""
371
+ tr, te = X_train.copy(), X_test.copy()
372
+ groups = groups or {}
373
+ excl = rec['exclusion'] if variant in ('exclusion', 'all') else []
374
+ nl = rec['nonlinear'] if variant in ('nonlinear', 'all') else []
375
+ spec = rec['interaction'] if variant in ('interaction', 'all') else {}
376
+
377
+ if hierarchy == 'keep':
378
+ needed = set(spec) | {p for v in spec.values() for p in v}
379
+ excl = [c for c in excl if c not in needed]
380
+ drop = [c for f in excl for c in Recommender._cols(f, tr, groups)]
381
+ tr, te = tr.drop(columns=drop), te.drop(columns=drop)
382
+
383
+ for f in nl:
384
+ if f in tr.columns:
385
+ m = float(tr[f].mean()) # training mean, both frames
386
+ tr[f], te[f] = tr[f] - m, te[f] - m
387
+ tr[f + '_quad'], te[f + '_quad'] = tr[f] ** 2, te[f] ** 2
388
+
389
+ made, skipped = set(), []
390
+ for a, partners in spec.items():
391
+ for b in partners:
392
+ key = tuple(sorted((a, b)))
393
+ if key in made or a == b:
394
+ continue
395
+ ca, cb = (Recommender._cols(a, tr, groups),
396
+ Recommender._cols(b, tr, groups))
397
+ if not ca or not cb:
398
+ skipped.append(dict(strat=a, partner=b, reason='main effect excluded'))
399
+ continue
400
+ made.add(key)
401
+ for u in ca:
402
+ for v in cb:
403
+ if u != v:
404
+ tr[f'{u}__x__{v}'] = tr[u] * tr[v]
405
+ te[f'{u}__x__{v}'] = te[u] * te[v]
406
+ if skipped:
407
+ print(f'{len(skipped)} interaction(s) dropped: main effect excluded')
408
+ if log is not None:
409
+ log.extend(skipped)
410
+ print(f"{variant}: {tr.shape[1]} columns, {len(made)} pairs -> "
411
+ f"{sum('__x__' in c for c in tr.columns)} interaction terms, "
412
+ f"{len(drop)} dropped, {len(nl)} quadratics")
413
+ return tr, te
414
+
415
+ # ------------------------------------------------- validation simulation --
416
+ def null_sim(self, n=4000, corr=0.8, R=200, out=None):
417
+ """Type I error under an additive null where x truly interacts with z
418
+ only, and y is correlated with z but has no interaction with x.
419
+ Compares partitioning on the attribution against the within-stratum
420
+ contrast, to demonstrate why the latter is used by
421
+ :meth:`stratified_screen`."""
422
+ from scipy.stats import mannwhitneyu
423
+ rng = np.random.default_rng(0)
424
+ a, b, c = 0.8, 0.9, 1.5
425
+ rej_part = rej_contrast = 0
426
+ for _ in range(R):
427
+ z = rng.binomial(1, .4, n).astype(float)
428
+ y = np.where(rng.random(n) < corr, z, 1 - z).astype(float)
429
+ x = rng.binomial(1, .5, n).astype(float)
430
+ px, py, pz = x.mean(), y.mean(), z.mean()
431
+ phi_x = (x - px) * (a + c * pz / 2 + c * z / 2)
432
+ phi_y = (y - py) * b
433
+ m = x == 1
434
+ g1 = phi_x[m] <= np.median(phi_x[m])
435
+ rej_part += mannwhitneyu(phi_y[m][g1], phi_y[m][~g1],
436
+ alternative='two-sided').pvalue < 0.05
437
+ d1, v1 = self._slopes(y[x == 0][:, None], phi_y[x == 0][:, None])
438
+ d2, v2 = self._slopes(y[x == 1][:, None], phi_y[x == 1][:, None])
439
+ se = float(np.sqrt(v1 + v2)[0])
440
+ rej_contrast += (2 * norm.sf(abs(float((d1 - d2)[0]) / se)) < 0.05) if se > 0 else 0
441
+ res = pd.DataFrame([dict(rule='partition on attribution', type_I_error=rej_part / R),
442
+ dict(rule='within-stratum contrast', type_I_error=rej_contrast / R)])
443
+ print(res.to_string(index=False))
444
+ if out:
445
+ res.to_csv(out, sep='\t', index=False)
446
+ return res
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.4
2
+ Name: shap-recommender
3
+ Version: 0.1.0
4
+ Summary: Exclusion / non-linearity / interaction recommendations from saved SHAP attribution files, and application of them to a design matrix.
5
+ Author: Kaylee
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://pypi.org/project/shap-recommender/
8
+ Keywords: shap,feature-selection,interaction-detection,interpretability,machine-learning
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
12
+ Classifier: Intended Audience :: Science/Research
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: numpy>=1.23
17
+ Requires-Dist: pandas>=1.5
18
+ Requires-Dist: scipy>=1.9
19
+ Requires-Dist: statsmodels>=0.13
20
+ Requires-Dist: patsy>=0.5
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest; extra == "dev"
23
+ Dynamic: license-file
24
+
25
+ # shap-recommender
26
+
27
+ Exclusion / non-linearity / interaction recommendations from saved SHAP
28
+ attribution files, and application of them to a design matrix.
29
+
30
+ One shared idea runs through two of the three rules. A feature's own
31
+ contribution is represented flexibly (indicator columns when it takes few
32
+ values, a restricted cubic spline when it is continuous), and a model built
33
+ on that flexible basis is compared against a straight line in the feature.
34
+ That comparison answers two different questions:
35
+
36
+ - **non-linearity** -- does the attribution deviate from a linear function
37
+ of the feature? This directly tests the linear-trend assumption, rather
38
+ than relying on a raw correlation coefficient, which conflates "no effect"
39
+ with "non-linear effect".
40
+ - **interaction** -- does the attribution vary among subjects who share the
41
+ same feature value? Under additivity the attribution is a deterministic
42
+ function of the feature, so residual dispersion implies effect
43
+ modification.
44
+
45
+ Stratification for the interaction screen is always applied to the observed
46
+ feature value at a pre-specified cut point, never to the attribution itself
47
+ -- splitting on the attribution would condition on the candidate modifier.
48
+ Within strata, the contrast `E[phi_y | y=1] - E[phi_y | y=0]` is compared,
49
+ which (unlike the marginal attribution distribution) is invariant to
50
+ stratum composition under additivity.
51
+
52
+ ## Install
53
+
54
+ ```bash
55
+ pip install shap-recommender
56
+ ```
57
+
58
+ ## Expected input files
59
+
60
+ For each dataset "tag" you want to load, `Recommender.load(tag)` (and the
61
+ CLI's `--tags`) expects two tab-separated files in `res_dir`:
62
+
63
+ - `shap_values_<tag>.tsv` -- SHAP values, one row per subject, one column
64
+ per feature, first column = row index.
65
+ - `sel_data_<tag>.tsv` -- the corresponding feature values (design matrix),
66
+ same row index.
67
+
68
+ ## Command-line use
69
+
70
+ ```bash
71
+ shap-recommender \
72
+ --res-dir ./shap_results \
73
+ --tags cohort_a cohort_b \
74
+ --nonlinear-candidates age bmi creatinine \
75
+ --out ./recommendations
76
+ ```
77
+
78
+ This writes `exclusion_tests.tsv`, `exclusion_sensitivity.tsv`,
79
+ `nonlinear_tests.tsv`, `interaction_tests.tsv`, `attribution_patterns.tsv`,
80
+ and `recommendations.json` to `--out`. Run `shap-recommender --help` for all
81
+ options (thresholds, bootstrap count, spline degrees of freedom, a
82
+ `--cutpoints` JSON file for pre-specified stratification cut points, etc).
83
+
84
+ ## Library use
85
+
86
+ ```python
87
+ from shap_recommender import Recommender
88
+
89
+ rec = Recommender(res_dir="./shap_results")
90
+ recommendations = rec.generate(
91
+ candidates_nonlinear=["age", "bmi", "creatinine"],
92
+ tags=["cohort_a", "cohort_b"],
93
+ out="./recommendations",
94
+ )
95
+
96
+ # apply the recommendations to a design matrix
97
+ X_train_adj, X_test_adj = Recommender.apply(
98
+ X_train, X_test, recommendations, variant="all",
99
+ )
100
+ ```
101
+
102
+ `Recommender.apply(..., variant=...)` accepts `"baseline"`, `"exclusion"`,
103
+ `"nonlinear"`, `"interaction"`, or `"all"`, so each rule's effect on
104
+ downstream model performance can be evaluated separately.
105
+
106
+ ## Validating the interaction rule
107
+
108
+ `Recommender.null_sim()` runs a small simulation under an additive null
109
+ (no true interaction with the feature being tested) and reports the type-I
110
+ error rate of the within-stratum contrast used by `stratified_screen`,
111
+ compared against naively partitioning on the attribution itself:
112
+
113
+ ```python
114
+ from shap_recommender import Recommender
115
+ Recommender(res_dir=".").null_sim()
116
+ ```
117
+
118
+ ## License
119
+
120
+ MIT
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/shap_recommender/__init__.py
5
+ src/shap_recommender/cli.py
6
+ src/shap_recommender/core.py
7
+ src/shap_recommender.egg-info/PKG-INFO
8
+ src/shap_recommender.egg-info/SOURCES.txt
9
+ src/shap_recommender.egg-info/dependency_links.txt
10
+ src/shap_recommender.egg-info/entry_points.txt
11
+ src/shap_recommender.egg-info/requires.txt
12
+ src/shap_recommender.egg-info/top_level.txt
13
+ tests/test_recommender.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ shap-recommender = shap_recommender.cli:main
@@ -0,0 +1,8 @@
1
+ numpy>=1.23
2
+ pandas>=1.5
3
+ scipy>=1.9
4
+ statsmodels>=0.13
5
+ patsy>=0.5
6
+
7
+ [dev]
8
+ pytest
@@ -0,0 +1 @@
1
+ shap_recommender
@@ -0,0 +1,92 @@
1
+ import json
2
+
3
+ import numpy as np
4
+ import pandas as pd
5
+ import pytest
6
+
7
+ from shap_recommender import Recommender
8
+
9
+
10
+ @pytest.fixture
11
+ def synthetic(tmp_path):
12
+ rng = np.random.default_rng(0)
13
+ n = 600
14
+ age = rng.normal(60, 10, n)
15
+ sex = rng.binomial(1, 0.5, n)
16
+ noise_feat = rng.normal(0, 1, n)
17
+
18
+ # age: non-linear (quadratic) attribution
19
+ phi_age = 0.01 * (age - 60) ** 2 + rng.normal(0, 0.05, n)
20
+ # sex: interacts with age (effect modification)
21
+ phi_sex = np.where(age > 60, 0.5, -0.5) + rng.normal(0, 0.05, n)
22
+ # noise_feat: negligible attribution -> should be excluded
23
+ phi_noise = rng.normal(0, 0.001, n)
24
+
25
+ X = pd.DataFrame({"age": age, "sex": sex, "noise_feat": noise_feat})
26
+ S = pd.DataFrame({"age": phi_age, "sex": phi_sex, "noise_feat": phi_noise})
27
+
28
+ res_dir = tmp_path / "res"
29
+ res_dir.mkdir()
30
+ S.to_csv(res_dir / "shap_values_train.tsv", sep="\t")
31
+ X.to_csv(res_dir / "sel_data_train.tsv", sep="\t")
32
+
33
+ return str(res_dir), X, S
34
+
35
+
36
+ def test_load_roundtrip(synthetic):
37
+ res_dir, X, S = synthetic
38
+ rec = Recommender(res_dir=res_dir, seed=0)
39
+ s, x = rec.load("train")
40
+ assert list(s.columns) == list(S.columns)
41
+ assert len(s) == len(x) == len(S)
42
+
43
+
44
+ def test_exclusion_flags_negligible_feature(synthetic):
45
+ res_dir, X, S = synthetic
46
+ rec = Recommender(res_dir=res_dir, n_boot=100, seed=0)
47
+ excluded, table = rec.exclusion(S)
48
+ assert "noise_feat" in excluded
49
+ assert "age" not in excluded
50
+
51
+
52
+ def test_nonlinear_flags_quadratic_feature(synthetic):
53
+ res_dir, X, S = synthetic
54
+ rec = Recommender(res_dir=res_dir, seed=0)
55
+ selected, table = rec.nonlinear(S, X, candidates=["age", "sex", "noise_feat"])
56
+ assert "age" in selected
57
+
58
+
59
+ def test_interaction_flags_effect_modifier(synthetic):
60
+ res_dir, X, S = synthetic
61
+ rec = Recommender(res_dir=res_dir, min_cell=50, seed=0)
62
+ spec, table, diag = rec.interaction(S, X, cutpoints={"age": 60})
63
+ # sex's attribution should show up as varying with age (Pattern C / screened)
64
+ assert (diag.set_index("variable").loc["sex", "pattern"] in ("A", "C"))
65
+
66
+
67
+ def test_generate_and_apply_end_to_end(synthetic, tmp_path):
68
+ res_dir, X, S = synthetic
69
+ rec = Recommender(res_dir=res_dir, n_boot=100, min_cell=50, seed=0)
70
+ out_dir = tmp_path / "out"
71
+ recs = rec.generate(
72
+ candidates_nonlinear=["age", "sex", "noise_feat"],
73
+ tags=["train"],
74
+ cutpoints={"age": 60},
75
+ out=str(out_dir),
76
+ )
77
+ assert (out_dir / "recommendations.json").exists()
78
+ with open(out_dir / "recommendations.json") as fh:
79
+ loaded = json.load(fh)
80
+ assert loaded == recs
81
+
82
+ X_train, X_test = X.iloc[:400], X.iloc[400:]
83
+ tr, te = Recommender.apply(X_train, X_test, recs, variant="all")
84
+ assert "noise_feat" not in tr.columns
85
+ assert set(tr.columns) == set(te.columns)
86
+
87
+
88
+ def test_null_sim_runs():
89
+ rec = Recommender(res_dir=".", seed=0)
90
+ res = rec.null_sim(n=500, R=10)
91
+ assert set(res["rule"]) == {"partition on attribution", "within-stratum contrast"}
92
+ assert (res["type_I_error"] >= 0).all() and (res["type_I_error"] <= 1).all()