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.
- shap_recommender-0.1.0/LICENSE +21 -0
- shap_recommender-0.1.0/PKG-INFO +120 -0
- shap_recommender-0.1.0/README.md +96 -0
- shap_recommender-0.1.0/pyproject.toml +41 -0
- shap_recommender-0.1.0/setup.cfg +4 -0
- shap_recommender-0.1.0/src/shap_recommender/__init__.py +13 -0
- shap_recommender-0.1.0/src/shap_recommender/cli.py +84 -0
- shap_recommender-0.1.0/src/shap_recommender/core.py +446 -0
- shap_recommender-0.1.0/src/shap_recommender.egg-info/PKG-INFO +120 -0
- shap_recommender-0.1.0/src/shap_recommender.egg-info/SOURCES.txt +13 -0
- shap_recommender-0.1.0/src/shap_recommender.egg-info/dependency_links.txt +1 -0
- shap_recommender-0.1.0/src/shap_recommender.egg-info/entry_points.txt +2 -0
- shap_recommender-0.1.0/src/shap_recommender.egg-info/requires.txt +8 -0
- shap_recommender-0.1.0/src/shap_recommender.egg-info/top_level.txt +1 -0
- shap_recommender-0.1.0/tests/test_recommender.py +92 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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()
|