camt-test 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.
- camt_test-0.1.0/LICENSE +21 -0
- camt_test-0.1.0/PKG-INFO +149 -0
- camt_test-0.1.0/README.md +129 -0
- camt_test-0.1.0/pyproject.toml +32 -0
- camt_test-0.1.0/setup.cfg +4 -0
- camt_test-0.1.0/src/camt/__init__.py +7 -0
- camt_test-0.1.0/src/camt/audit.py +56 -0
- camt_test-0.1.0/src/camt/core.py +224 -0
- camt_test-0.1.0/src/camt_test.egg-info/PKG-INFO +149 -0
- camt_test-0.1.0/src/camt_test.egg-info/SOURCES.txt +12 -0
- camt_test-0.1.0/src/camt_test.egg-info/dependency_links.txt +1 -0
- camt_test-0.1.0/src/camt_test.egg-info/requires.txt +5 -0
- camt_test-0.1.0/src/camt_test.egg-info/top_level.txt +1 -0
- camt_test-0.1.0/tests/test_camt.py +94 -0
camt_test-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aqib Gul
|
|
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.
|
camt_test-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: camt-test
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CAMT, the clustered AUC maximum test: patient-clustered, selection-adjusted test that the best of K models beats a baseline, with an audit of published claims
|
|
5
|
+
Author: Aqib Gul
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: AUC,clustered data,winner's curse,multiple comparisons,model comparison,medical AI
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Intended Audience :: Science/Research
|
|
11
|
+
Classifier: Topic :: Scientific/Engineering
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: numpy>=1.23
|
|
16
|
+
Requires-Dist: scipy>=1.9
|
|
17
|
+
Provides-Extra: test
|
|
18
|
+
Requires-Dist: pytest; extra == "test"
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# camt: the clustered AUC maximum test (CAMT)
|
|
22
|
+
|
|
23
|
+
Tests of the claim that **the best of K models has a higher AUC than a baseline** on a test set that contains several images or
|
|
24
|
+
records per patient, and a **summary-statistics audit** of published claims of this kind.
|
|
25
|
+
|
|
26
|
+
Two errors act together when the winner among K candidate models is compared with a baseline on one shared test set:
|
|
27
|
+
|
|
28
|
+
1. observations from one patient are correlated, so a test that treats every image as independent understates the uncertainty;
|
|
29
|
+
2. the winner is chosen among K candidates, so its apparent advantage is inflated even if all models are equally good.
|
|
30
|
+
|
|
31
|
+
`camt` implements CAMT, a test that accounts for both. Labels may vary within a patient. The method is described in the accompanying
|
|
32
|
+
manuscript (in preparation).
|
|
33
|
+
|
|
34
|
+
Status: research software, version 0.1.0.
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install camt-test # from PyPI; the import name is `camt`
|
|
40
|
+
pip install . # or from the repository root; requires Python >= 3.9, numpy and scipy
|
|
41
|
+
pip install ".[test]" # also installs pytest
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Quick start
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from camt import camt_test
|
|
48
|
+
|
|
49
|
+
# scores: array (n_images, 1 + K), one column per model; column 0 is the baseline
|
|
50
|
+
# labels: array (n_images,) of 0/1 image-level labels
|
|
51
|
+
# patients: array (n_images,) of patient identifiers (the clusters)
|
|
52
|
+
result = camt_test(scores, labels, patients, baseline=0, n_boot=4000, seed=1)
|
|
53
|
+
print(result.summary())
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
best of 5 candidates vs baseline (column 0); best = column 1
|
|
58
|
+
AUC best 0.841 vs baseline 0.782 (difference +0.058)
|
|
59
|
+
500 images, 100 patients (47 with positives, 53 with negatives, 0 with both)
|
|
60
|
+
p (patient-clustered, adjusted for K) = 0.1685
|
|
61
|
+
p (pruned) = 0.1585 | clustering only = 0.0490 | selection only = 0.0310 | naive = 0.0068
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
In this simulated example no candidate is truly better, and the naive analysis reports p = 0.007. A complete script is in
|
|
65
|
+
`examples/quickstart.py`.
|
|
66
|
+
|
|
67
|
+
### Which p-value to report
|
|
68
|
+
|
|
69
|
+
| attribute | meaning |
|
|
70
|
+
|---|---|
|
|
71
|
+
| `p_value` | **the proposed test**: patient-clustered, adjusted for K, with the small-class calibration. Use this one. |
|
|
72
|
+
| `p_value_pruned` | as above, but candidates that are clearly worse than the baseline are dropped from the null distribution. More power when many candidates are poor; valid pointwise only. |
|
|
73
|
+
| `p_naive` | what an image-level test of the best candidate would give (for comparison). |
|
|
74
|
+
| `p_clustering_only` | patient-clustered, but no adjustment for K. |
|
|
75
|
+
| `p_selection_only` | adjusted for K, but images treated as independent. |
|
|
76
|
+
|
|
77
|
+
All p-values are one-sided; the null hypothesis is that no candidate is better than the baseline.
|
|
78
|
+
|
|
79
|
+
## Functions
|
|
80
|
+
|
|
81
|
+
| function | purpose |
|
|
82
|
+
|---|---|
|
|
83
|
+
| `camt_test(scores, labels, patients, baseline=0, n_boot=4000, seed=None, influence_fn=None)` | the test; returns a `CamtResult` |
|
|
84
|
+
| `design_effect(scores, labels, patients, baseline=0)` | Kish cluster size, design effect and within-patient correlation of the AUC influence function, for each model and for each difference from the baseline |
|
|
85
|
+
| `threshold_influence(thresholds, kind="balanced")` | influence function for sensitivity, specificity or balanced accuracy at operating points fixed in advance; pass it as `influence_fn` |
|
|
86
|
+
| `audit_claim(p_naive, patients, images, K, rho=...)` | adjust a published p-value for clustering and for K, using only summary numbers |
|
|
87
|
+
| `sensitivity_table(...)` | `audit_claim` over a range of within-patient correlations |
|
|
88
|
+
| `hanley_mcneil_se`, `rho_from_patient_level_ci` | helpers for the audit |
|
|
89
|
+
|
|
90
|
+
### Metrics at a fixed operating point
|
|
91
|
+
|
|
92
|
+
For balanced accuracy at thresholds chosen in advance (for example on the training patients):
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from camt import camt_test, threshold_influence
|
|
96
|
+
result = camt_test(scores, labels, patients, influence_fn=threshold_influence(thresholds, kind="balanced"))
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Thresholds estimated on the test sample itself are not covered.
|
|
100
|
+
|
|
101
|
+
### Auditing a published claim
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from camt import audit_claim
|
|
105
|
+
a = audit_claim(p_naive=0.0004, patients=40, images=3900, K=10, rho=0.1)
|
|
106
|
+
a["p_bonferroni"] # adjusted p-value if the within-patient correlation is 0.1 and 10 models were tried
|
|
107
|
+
a["rho_star"] # correlation above which the clustered p-value alone exceeds 0.05
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The within-patient correlation cannot be recovered from summary data, so report the result over a range of values
|
|
111
|
+
(`sensitivity_table`) and treat `rho_star` as the quantity of interest. The audit is valid if the correlation is not understated and
|
|
112
|
+
K is the true number of models tried.
|
|
113
|
+
|
|
114
|
+
## Assumptions and limits
|
|
115
|
+
|
|
116
|
+
- **K must be the true number of candidates examined**, and the baseline must be fixed in advance. Pre-specifying one primary model
|
|
117
|
+
(K = 1) removes the selection cost.
|
|
118
|
+
- The target is the **fitted models** on patients like the test patients, not the training algorithm. Test patients must be disjoint
|
|
119
|
+
from the training data.
|
|
120
|
+
- The test assumes independent patients, a bounded number of images per patient and a moderate number of candidates.
|
|
121
|
+
- The test can be **liberal** when fewer than about 20 patients belong to the smaller class and many candidates are screened, and
|
|
122
|
+
when a few patients contribute most of the images. The function emits a warning in both situations. The cut-off for the second
|
|
123
|
+
warning (fewer than 100 effective clusters, that is, images divided by the Kish cluster size) is a heuristic and has not been
|
|
124
|
+
validated.
|
|
125
|
+
- Candidates whose scores give a zero-variance difference from the baseline are dropped.
|
|
126
|
+
- Metrics whose threshold is estimated on the test sample are not covered.
|
|
127
|
+
|
|
128
|
+
## Reproducing the behaviour of the test
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
pytest # unit tests, about 25 seconds
|
|
132
|
+
python examples/reproduce_size.py # false-positive rates in simulation (a few minutes)
|
|
133
|
+
python examples/reproduce_size.py --reps 200 # quick version
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`examples/reproduce_size.py` simulates test sets in which all models are equal and prints the false-positive rate of the naive,
|
|
137
|
+
clustering-only, selection-only and proposed procedures for K = 1, 5 and 20, at a nominal level of 5%.
|
|
138
|
+
|
|
139
|
+
## Citation
|
|
140
|
+
|
|
141
|
+
If you use this software, please cite it (see `CITATION.cff`) together with the accompanying manuscript:
|
|
142
|
+
|
|
143
|
+
> Gul A. Valid inference for "best of K" model comparisons on patient-clustered diagnostic data. Manuscript in preparation.
|
|
144
|
+
|
|
145
|
+
The archived release and its DOI are given in `CITATION.cff` once available.
|
|
146
|
+
|
|
147
|
+
## Licence
|
|
148
|
+
|
|
149
|
+
MIT. See `LICENSE`.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# camt: the clustered AUC maximum test (CAMT)
|
|
2
|
+
|
|
3
|
+
Tests of the claim that **the best of K models has a higher AUC than a baseline** on a test set that contains several images or
|
|
4
|
+
records per patient, and a **summary-statistics audit** of published claims of this kind.
|
|
5
|
+
|
|
6
|
+
Two errors act together when the winner among K candidate models is compared with a baseline on one shared test set:
|
|
7
|
+
|
|
8
|
+
1. observations from one patient are correlated, so a test that treats every image as independent understates the uncertainty;
|
|
9
|
+
2. the winner is chosen among K candidates, so its apparent advantage is inflated even if all models are equally good.
|
|
10
|
+
|
|
11
|
+
`camt` implements CAMT, a test that accounts for both. Labels may vary within a patient. The method is described in the accompanying
|
|
12
|
+
manuscript (in preparation).
|
|
13
|
+
|
|
14
|
+
Status: research software, version 0.1.0.
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install camt-test # from PyPI; the import name is `camt`
|
|
20
|
+
pip install . # or from the repository root; requires Python >= 3.9, numpy and scipy
|
|
21
|
+
pip install ".[test]" # also installs pytest
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quick start
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from camt import camt_test
|
|
28
|
+
|
|
29
|
+
# scores: array (n_images, 1 + K), one column per model; column 0 is the baseline
|
|
30
|
+
# labels: array (n_images,) of 0/1 image-level labels
|
|
31
|
+
# patients: array (n_images,) of patient identifiers (the clusters)
|
|
32
|
+
result = camt_test(scores, labels, patients, baseline=0, n_boot=4000, seed=1)
|
|
33
|
+
print(result.summary())
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
best of 5 candidates vs baseline (column 0); best = column 1
|
|
38
|
+
AUC best 0.841 vs baseline 0.782 (difference +0.058)
|
|
39
|
+
500 images, 100 patients (47 with positives, 53 with negatives, 0 with both)
|
|
40
|
+
p (patient-clustered, adjusted for K) = 0.1685
|
|
41
|
+
p (pruned) = 0.1585 | clustering only = 0.0490 | selection only = 0.0310 | naive = 0.0068
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
In this simulated example no candidate is truly better, and the naive analysis reports p = 0.007. A complete script is in
|
|
45
|
+
`examples/quickstart.py`.
|
|
46
|
+
|
|
47
|
+
### Which p-value to report
|
|
48
|
+
|
|
49
|
+
| attribute | meaning |
|
|
50
|
+
|---|---|
|
|
51
|
+
| `p_value` | **the proposed test**: patient-clustered, adjusted for K, with the small-class calibration. Use this one. |
|
|
52
|
+
| `p_value_pruned` | as above, but candidates that are clearly worse than the baseline are dropped from the null distribution. More power when many candidates are poor; valid pointwise only. |
|
|
53
|
+
| `p_naive` | what an image-level test of the best candidate would give (for comparison). |
|
|
54
|
+
| `p_clustering_only` | patient-clustered, but no adjustment for K. |
|
|
55
|
+
| `p_selection_only` | adjusted for K, but images treated as independent. |
|
|
56
|
+
|
|
57
|
+
All p-values are one-sided; the null hypothesis is that no candidate is better than the baseline.
|
|
58
|
+
|
|
59
|
+
## Functions
|
|
60
|
+
|
|
61
|
+
| function | purpose |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `camt_test(scores, labels, patients, baseline=0, n_boot=4000, seed=None, influence_fn=None)` | the test; returns a `CamtResult` |
|
|
64
|
+
| `design_effect(scores, labels, patients, baseline=0)` | Kish cluster size, design effect and within-patient correlation of the AUC influence function, for each model and for each difference from the baseline |
|
|
65
|
+
| `threshold_influence(thresholds, kind="balanced")` | influence function for sensitivity, specificity or balanced accuracy at operating points fixed in advance; pass it as `influence_fn` |
|
|
66
|
+
| `audit_claim(p_naive, patients, images, K, rho=...)` | adjust a published p-value for clustering and for K, using only summary numbers |
|
|
67
|
+
| `sensitivity_table(...)` | `audit_claim` over a range of within-patient correlations |
|
|
68
|
+
| `hanley_mcneil_se`, `rho_from_patient_level_ci` | helpers for the audit |
|
|
69
|
+
|
|
70
|
+
### Metrics at a fixed operating point
|
|
71
|
+
|
|
72
|
+
For balanced accuracy at thresholds chosen in advance (for example on the training patients):
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from camt import camt_test, threshold_influence
|
|
76
|
+
result = camt_test(scores, labels, patients, influence_fn=threshold_influence(thresholds, kind="balanced"))
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Thresholds estimated on the test sample itself are not covered.
|
|
80
|
+
|
|
81
|
+
### Auditing a published claim
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from camt import audit_claim
|
|
85
|
+
a = audit_claim(p_naive=0.0004, patients=40, images=3900, K=10, rho=0.1)
|
|
86
|
+
a["p_bonferroni"] # adjusted p-value if the within-patient correlation is 0.1 and 10 models were tried
|
|
87
|
+
a["rho_star"] # correlation above which the clustered p-value alone exceeds 0.05
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The within-patient correlation cannot be recovered from summary data, so report the result over a range of values
|
|
91
|
+
(`sensitivity_table`) and treat `rho_star` as the quantity of interest. The audit is valid if the correlation is not understated and
|
|
92
|
+
K is the true number of models tried.
|
|
93
|
+
|
|
94
|
+
## Assumptions and limits
|
|
95
|
+
|
|
96
|
+
- **K must be the true number of candidates examined**, and the baseline must be fixed in advance. Pre-specifying one primary model
|
|
97
|
+
(K = 1) removes the selection cost.
|
|
98
|
+
- The target is the **fitted models** on patients like the test patients, not the training algorithm. Test patients must be disjoint
|
|
99
|
+
from the training data.
|
|
100
|
+
- The test assumes independent patients, a bounded number of images per patient and a moderate number of candidates.
|
|
101
|
+
- The test can be **liberal** when fewer than about 20 patients belong to the smaller class and many candidates are screened, and
|
|
102
|
+
when a few patients contribute most of the images. The function emits a warning in both situations. The cut-off for the second
|
|
103
|
+
warning (fewer than 100 effective clusters, that is, images divided by the Kish cluster size) is a heuristic and has not been
|
|
104
|
+
validated.
|
|
105
|
+
- Candidates whose scores give a zero-variance difference from the baseline are dropped.
|
|
106
|
+
- Metrics whose threshold is estimated on the test sample are not covered.
|
|
107
|
+
|
|
108
|
+
## Reproducing the behaviour of the test
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
pytest # unit tests, about 25 seconds
|
|
112
|
+
python examples/reproduce_size.py # false-positive rates in simulation (a few minutes)
|
|
113
|
+
python examples/reproduce_size.py --reps 200 # quick version
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`examples/reproduce_size.py` simulates test sets in which all models are equal and prints the false-positive rate of the naive,
|
|
117
|
+
clustering-only, selection-only and proposed procedures for K = 1, 5 and 20, at a nominal level of 5%.
|
|
118
|
+
|
|
119
|
+
## Citation
|
|
120
|
+
|
|
121
|
+
If you use this software, please cite it (see `CITATION.cff`) together with the accompanying manuscript:
|
|
122
|
+
|
|
123
|
+
> Gul A. Valid inference for "best of K" model comparisons on patient-clustered diagnostic data. Manuscript in preparation.
|
|
124
|
+
|
|
125
|
+
The archived release and its DOI are given in `CITATION.cff` once available.
|
|
126
|
+
|
|
127
|
+
## Licence
|
|
128
|
+
|
|
129
|
+
MIT. See `LICENSE`.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "camt-test"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "CAMT, the clustered AUC maximum test: patient-clustered, selection-adjusted test that the best of K models beats a baseline, with an audit of published claims"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Aqib Gul" }]
|
|
13
|
+
dependencies = ["numpy>=1.23", "scipy>=1.9"]
|
|
14
|
+
keywords = ["AUC", "clustered data", "winner's curse", "multiple comparisons", "model comparison", "medical AI"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Intended Audience :: Science/Research",
|
|
19
|
+
"Topic :: Scientific/Engineering",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
test = ["pytest"]
|
|
24
|
+
|
|
25
|
+
# [project.urls]
|
|
26
|
+
# Repository = "add the repository URL here"
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.packages.find]
|
|
29
|
+
where = ["src"]
|
|
30
|
+
|
|
31
|
+
[tool.pytest.ini_options]
|
|
32
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""camt: the clustered AUC maximum test (CAMT), a patient-clustered, selection-adjusted test of "the best of K models beats a baseline"."""
|
|
2
|
+
from .audit import audit_claim, hanley_mcneil_se, rho_from_patient_level_ci, sensitivity_table
|
|
3
|
+
from .core import CamtResult, auc_influence, camt_test, design_effect, threshold_influence
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
__all__ = ["camt_test", "CamtResult", "design_effect", "auc_influence", "threshold_influence", "audit_claim",
|
|
7
|
+
"sensitivity_table", "hanley_mcneil_se", "rho_from_patient_level_ci", "__version__"]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Summary-statistics audit of a published "best of K models beats the baseline" claim (see the accompanying manuscript).
|
|
2
|
+
|
|
3
|
+
Inputs are the winner's naive one-sided p-value, the numbers of patients and images, and the number of models tried, K.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from scipy.stats import norm
|
|
9
|
+
from scipy.stats import t as tdist
|
|
10
|
+
|
|
11
|
+
__all__ = ["audit_claim", "sensitivity_table", "hanley_mcneil_se", "rho_from_patient_level_ci"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def audit_claim(p_naive, patients, images, K, rho=0.2, pos_patients=None, neg_patients=None, sided="one", alpha=0.05, image_sq_sum=None):
|
|
15
|
+
"""Adjust a published p-value for clustering (given rho) and for the number of models tried.
|
|
16
|
+
|
|
17
|
+
``sided="two"`` halves a two-sided p-value. ``image_sq_sum`` is the sum of squared images per patient if known (unequal
|
|
18
|
+
cluster sizes); otherwise equal cluster sizes are assumed. Passing the numbers of positive and negative patients switches
|
|
19
|
+
on the small-class t-calibration.
|
|
20
|
+
"""
|
|
21
|
+
z = norm.isf(p_naive if sided == "one" else p_naive / 2)
|
|
22
|
+
m = images / patients if image_sq_sum is None else image_sq_sum / images
|
|
23
|
+
nu = None if pos_patients is None else max(min(pos_patients, neg_patients) - 1, 1)
|
|
24
|
+
sf = (lambda x: tdist.sf(x, nu)) if nu else norm.sf
|
|
25
|
+
crit = tdist.isf(alpha, nu) if nu else norm.isf(alpha)
|
|
26
|
+
deff = 1 + (m - 1) * rho
|
|
27
|
+
p_c = float(sf(z / np.sqrt(deff)))
|
|
28
|
+
rho_star = max(((z / crit) ** 2 - 1) / (m - 1), 0.0) if m > 1 else np.inf
|
|
29
|
+
return dict(z_naive=float(z), m=float(m), deff=float(deff), p_cluster=p_c, p_bonferroni=min(1.0, K * p_c), p_independent=1 - (1 - p_c) ** K,
|
|
30
|
+
rho_star=float(rho_star), K_star_bonferroni=int(alpha // p_c) if p_c > 0 else np.inf,
|
|
31
|
+
K_star_independent=int(np.log(1 - alpha) // np.log(1 - p_c)) if 0 < p_c < alpha else (0 if p_c >= alpha else np.inf))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def sensitivity_table(p_naive, patients, images, K, rhos=(0.0, 0.05, 0.1, 0.2, 0.3, 0.5), **kw):
|
|
35
|
+
"""List of :func:`audit_claim` results over a range of within-patient correlations."""
|
|
36
|
+
return [dict(rho=r, **audit_claim(p_naive, patients, images, K, rho=r, **kw)) for r in rhos]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def hanley_mcneil_se(auc, n_pos_images, n_neg_images):
|
|
40
|
+
"""Image-level (independence) standard error of a single AUC from summary numbers (Hanley and McNeil 1982)."""
|
|
41
|
+
q1, q2 = auc / (2 - auc), 2 * auc ** 2 / (1 + auc)
|
|
42
|
+
var = (auc * (1 - auc) + (n_pos_images - 1) * (q1 - auc ** 2) + (n_neg_images - 1) * (q2 - auc ** 2)) / (n_pos_images * n_neg_images)
|
|
43
|
+
return float(np.sqrt(var))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def rho_from_patient_level_ci(auc, ci_low, ci_high, n_pos_images, n_neg_images, patients, images, image_sq_sum=None):
|
|
47
|
+
"""Within-patient correlation of a SINGLE model's AUC implied by a reported patient-level 95% interval.
|
|
48
|
+
|
|
49
|
+
On BreakHis this overstates the correlation by about 1.5-fold, because the Hanley-McNeil standard error is smaller than
|
|
50
|
+
the DeLong one, and the correlation for a paired difference is usually lower than that of a single AUC. Use it as a loose
|
|
51
|
+
upper bound, not as an estimate.
|
|
52
|
+
"""
|
|
53
|
+
se_rep = (ci_high - ci_low) / (2 * 1.96)
|
|
54
|
+
deff = (se_rep / hanley_mcneil_se(auc, n_pos_images, n_neg_images)) ** 2
|
|
55
|
+
m = images / patients if image_sq_sum is None else image_sq_sum / images
|
|
56
|
+
return float(max((deff - 1) / (m - 1), 0.0)), float(deff)
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Patient-clustered, selection-adjusted test of "the best of K models beats a baseline" (see the accompanying manuscript)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import warnings
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from scipy.stats import norm, rankdata
|
|
9
|
+
from scipy.stats import t as tdist
|
|
10
|
+
|
|
11
|
+
__all__ = ["CamtResult", "camt_test", "auc_influence", "threshold_influence", "design_effect"]
|
|
12
|
+
|
|
13
|
+
MIN_MINORITY_PATIENTS = 20 # below this, with many candidates, the test is liberal (paper, Section 5.3)
|
|
14
|
+
MIN_EFFECTIVE_CLUSTERS = 100 # heuristic flag for very unequal cluster sizes (paper, Section 6.2); not a validated threshold
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def auc_influence(scores, labels):
|
|
18
|
+
"""AUC of every column of ``scores`` and the per-image influence function (n x M). Ties receive average ranks."""
|
|
19
|
+
S = np.asarray(scores, dtype=float)
|
|
20
|
+
y = np.asarray(labels).astype(int)
|
|
21
|
+
pos = y == 1
|
|
22
|
+
n1, n0 = pos.sum(), (~pos).sum()
|
|
23
|
+
r_all = rankdata(S, axis=0)
|
|
24
|
+
V = np.empty_like(S, dtype=float)
|
|
25
|
+
V[pos] = (r_all[pos] - rankdata(S[pos], axis=0)) / n0
|
|
26
|
+
V[~pos] = 1 - (r_all[~pos] - rankdata(S[~pos], axis=0)) / n1
|
|
27
|
+
theta = V[pos].mean(0)
|
|
28
|
+
infl = np.where(pos[:, None], (V - theta) / n1, (V - theta) / n0)
|
|
29
|
+
return theta, infl
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def threshold_influence(thresholds, kind="balanced"):
|
|
33
|
+
"""Influence-function builder for a metric at operating points fixed in advance (one threshold per model column).
|
|
34
|
+
|
|
35
|
+
``kind`` is "balanced" (mean of sensitivity and specificity), "sensitivity" or "specificity". The thresholds must not be
|
|
36
|
+
estimated from the test sample; thresholds chosen on the test sample add a quantile term that is not covered.
|
|
37
|
+
Pass the returned function as ``influence_fn`` to :func:`camt_test`.
|
|
38
|
+
"""
|
|
39
|
+
thr = np.asarray(thresholds, dtype=float)
|
|
40
|
+
|
|
41
|
+
def fn(S, y):
|
|
42
|
+
pos = y == 1
|
|
43
|
+
n1, n0 = pos.sum(), (~pos).sum()
|
|
44
|
+
flag = S > thr[None, :]
|
|
45
|
+
sens = flag[pos].mean(0)
|
|
46
|
+
spec = (~flag[~pos]).mean(0)
|
|
47
|
+
w1, w0 = {"balanced": (0.5, 0.5), "sensitivity": (1.0, 0.0), "specificity": (0.0, 1.0)}[kind]
|
|
48
|
+
theta = w1 * sens + w0 * spec
|
|
49
|
+
infl = np.zeros_like(S, dtype=float)
|
|
50
|
+
infl[pos] = w1 * (flag[pos] - sens) / n1
|
|
51
|
+
infl[~pos] = w0 * ((~flag[~pos]) - spec) / n0
|
|
52
|
+
return theta, infl
|
|
53
|
+
|
|
54
|
+
return fn
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _max_pvalue(G, se, z_obs, rng, n_boot, chunk=500):
|
|
58
|
+
"""P(max_k sum_c xi_c G_ck / se_k >= z_obs) for independent standard normal xi_c."""
|
|
59
|
+
Gs = G / se
|
|
60
|
+
hits, done = 0, 0
|
|
61
|
+
while done < n_boot:
|
|
62
|
+
b = min(chunk, n_boot - done)
|
|
63
|
+
hits += ((rng.standard_normal((b, G.shape[0])) @ Gs).max(1) >= z_obs).sum()
|
|
64
|
+
done += b
|
|
65
|
+
return (hits + 1) / (n_boot + 1)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class CamtResult:
|
|
70
|
+
"""Result of :func:`camt_test`. All p-values are one-sided (H0: no candidate is better than the baseline)."""
|
|
71
|
+
|
|
72
|
+
p_value: float # patient-clustered, adjusted for the number of candidates, small-class calibration
|
|
73
|
+
p_value_pruned: float # same, with clearly inferior candidates dropped from the null (pointwise valid only)
|
|
74
|
+
p_naive: float # images treated as independent, best candidate, no adjustment for K
|
|
75
|
+
p_clustering_only: float # patient-clustered standard error, best candidate, no adjustment for K
|
|
76
|
+
p_selection_only: float # adjusted for K, but images treated as independent
|
|
77
|
+
best: int # column index of the candidate with the largest clustered z-statistic
|
|
78
|
+
auc: np.ndarray # AUC (or other metric) of every column
|
|
79
|
+
baseline: int
|
|
80
|
+
candidates: list # column indices of the candidates that entered the test
|
|
81
|
+
difference: np.ndarray # D_k for the candidates
|
|
82
|
+
se_clustered: np.ndarray
|
|
83
|
+
se_image: np.ndarray
|
|
84
|
+
n_images: int
|
|
85
|
+
n_patients: int
|
|
86
|
+
n_positive_patients: int
|
|
87
|
+
n_negative_patients: int
|
|
88
|
+
n_mixed_patients: int
|
|
89
|
+
kish_cluster_size: float
|
|
90
|
+
effective_clusters: float
|
|
91
|
+
se_ratio: float
|
|
92
|
+
warnings: list = field(default_factory=list)
|
|
93
|
+
|
|
94
|
+
def summary(self) -> str:
|
|
95
|
+
lines = [f"best of {len(self.candidates)} candidates vs baseline (column {self.baseline}); best = column {self.best}",
|
|
96
|
+
f" AUC best {self.auc[self.best]:.3f} vs baseline {self.auc[self.baseline]:.3f} "
|
|
97
|
+
f"(difference {self.auc[self.best] - self.auc[self.baseline]:+.3f})",
|
|
98
|
+
f" {self.n_images} images, {self.n_patients} patients ({self.n_positive_patients} with positives, "
|
|
99
|
+
f"{self.n_negative_patients} with negatives, {self.n_mixed_patients} with both)",
|
|
100
|
+
f" p (patient-clustered, adjusted for K) = {self.p_value:.4f}",
|
|
101
|
+
f" p (pruned) = {self.p_value_pruned:.4f} | clustering only = {self.p_clustering_only:.4f} | "
|
|
102
|
+
f"selection only = {self.p_selection_only:.4f} | naive = {self.p_naive:.4f}"]
|
|
103
|
+
lines += [" WARNING: " + w for w in self.warnings]
|
|
104
|
+
return "\n".join(lines)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _check_inputs(scores, labels, patients, baseline):
|
|
108
|
+
S = np.asarray(scores, dtype=float)
|
|
109
|
+
if S.ndim != 2 or S.shape[1] < 2:
|
|
110
|
+
raise ValueError("scores must be a 2-D array (images x models) with the baseline and at least one candidate")
|
|
111
|
+
y = np.asarray(labels)
|
|
112
|
+
if y.shape != (S.shape[0],) or not np.isin(y, (0, 1)).all():
|
|
113
|
+
raise ValueError("labels must be a 1-D array of 0/1 values, one per image")
|
|
114
|
+
pid = np.asarray(patients)
|
|
115
|
+
if pid.shape != (S.shape[0],):
|
|
116
|
+
raise ValueError("patients must have one id per image")
|
|
117
|
+
if not np.isfinite(S).all():
|
|
118
|
+
raise ValueError("scores contain NaN or infinite values")
|
|
119
|
+
if not 0 <= baseline < S.shape[1]:
|
|
120
|
+
raise ValueError("baseline must be a column index of scores")
|
|
121
|
+
return S, y.astype(int), pid
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def camt_test(scores, labels, patients, baseline=0, n_boot=4000, seed=None, influence_fn=None, warn=True) -> CamtResult:
|
|
125
|
+
"""Test whether the best of K candidate models has a higher AUC than the baseline.
|
|
126
|
+
|
|
127
|
+
Parameters
|
|
128
|
+
----------
|
|
129
|
+
scores : array (n_images, 1 + K). One column per model; ``baseline`` is the index of the baseline column.
|
|
130
|
+
labels : array (n_images,) of 0/1 image-level labels. Labels may vary within a patient.
|
|
131
|
+
patients : array (n_images,) of patient identifiers (the clusters).
|
|
132
|
+
n_boot : number of multiplier-bootstrap draws (at least 4,000 is recommended).
|
|
133
|
+
seed : seed or ``numpy.random.Generator``.
|
|
134
|
+
influence_fn : optional replacement for the AUC influence function, for example from :func:`threshold_influence`.
|
|
135
|
+
"""
|
|
136
|
+
S, y, pid = _check_inputs(scores, labels, patients, baseline)
|
|
137
|
+
rng = np.random.default_rng(seed)
|
|
138
|
+
_, cid = np.unique(pid, return_inverse=True)
|
|
139
|
+
P, n = cid.max() + 1, len(y)
|
|
140
|
+
pos = y == 1
|
|
141
|
+
has_pos = np.zeros(P, bool)
|
|
142
|
+
has_pos[cid[pos]] = True
|
|
143
|
+
has_neg = np.zeros(P, bool)
|
|
144
|
+
has_neg[cid[~pos]] = True
|
|
145
|
+
n1c, n0c = int(has_pos.sum()), int(has_neg.sum())
|
|
146
|
+
mixed = int((has_pos & has_neg).sum())
|
|
147
|
+
if min(n1c, n0c) < 2:
|
|
148
|
+
raise ValueError("need at least 2 patients with images of each class")
|
|
149
|
+
|
|
150
|
+
theta, infl = (influence_fn or auc_influence)(S, y)
|
|
151
|
+
cand = [j for j in range(S.shape[1]) if j != baseline]
|
|
152
|
+
D = theta[cand] - theta[baseline]
|
|
153
|
+
ID = infl[:, cand] - infl[:, [baseline]]
|
|
154
|
+
g_pos = np.zeros((P, len(cand)))
|
|
155
|
+
g_neg = np.zeros((P, len(cand)))
|
|
156
|
+
np.add.at(g_pos, cid[pos], ID[pos])
|
|
157
|
+
np.add.at(g_neg, cid[~pos], ID[~pos])
|
|
158
|
+
gs = np.sqrt(n1c / (n1c - 1)) * g_pos + np.sqrt(n0c / (n0c - 1)) * g_neg
|
|
159
|
+
se_c = np.sqrt((gs ** 2).sum(0))
|
|
160
|
+
se_i = np.sqrt(n / (n - 1) * (ID ** 2).sum(0))
|
|
161
|
+
ok = (se_c > 1e-12) & (se_i > 1e-12)
|
|
162
|
+
if not ok.any():
|
|
163
|
+
raise ValueError("no candidate differs from the baseline (zero-variance differences)")
|
|
164
|
+
if not ok.all():
|
|
165
|
+
cand = [c for c, keep_ in zip(cand, ok) if keep_]
|
|
166
|
+
D, ID, gs, se_c, se_i = D[ok], ID[:, ok], gs[:, ok], se_c[ok], se_i[ok]
|
|
167
|
+
z_c, z_i = D / se_c, D / se_i
|
|
168
|
+
ratio = tdist.ppf(0.95, max(min(n1c, n0c) - 1, 1)) / norm.ppf(0.95) # small-class calibration
|
|
169
|
+
|
|
170
|
+
p_sel = _max_pvalue(ID, se_i, z_i.max(), rng, n_boot)
|
|
171
|
+
p_main = _max_pvalue(gs, se_c, z_c.max() / ratio, rng, n_boot)
|
|
172
|
+
keep = z_c >= -np.sqrt(2 * np.log(np.log(max(P, 3))))
|
|
173
|
+
p_pruned = _max_pvalue(gs[:, keep], se_c[keep], z_c.max() / ratio, rng, n_boot) if keep.any() else 1.0
|
|
174
|
+
|
|
175
|
+
sizes = np.bincount(cid)
|
|
176
|
+
kish = float((sizes ** 2).sum() / sizes.sum())
|
|
177
|
+
eff = float(n / kish)
|
|
178
|
+
notes = []
|
|
179
|
+
if len(cand) > 1 and min(n1c, n0c) < MIN_MINORITY_PATIENTS:
|
|
180
|
+
notes.append(f"only {min(n1c, n0c)} patients in the smaller class with {len(cand)} candidates: the test is liberal in this regime; "
|
|
181
|
+
"treat the result as exploratory")
|
|
182
|
+
if eff < MIN_EFFECTIVE_CLUSTERS:
|
|
183
|
+
notes.append(f"effective number of clusters is about {eff:.0f} (Kish cluster size {kish:.0f}); with very unequal cluster sizes the "
|
|
184
|
+
"test was liberal in the OCT example of the paper; treat the result with caution")
|
|
185
|
+
if warn:
|
|
186
|
+
for w in notes:
|
|
187
|
+
warnings.warn(w, stacklevel=2)
|
|
188
|
+
best = int(np.argmax(z_c))
|
|
189
|
+
return CamtResult(
|
|
190
|
+
p_value=float(p_main), p_value_pruned=float(p_pruned), p_naive=float(norm.sf(z_i.max())), p_clustering_only=float(norm.sf(z_c.max())),
|
|
191
|
+
p_selection_only=float(p_sel), best=cand[best], auc=theta, baseline=baseline, candidates=cand, difference=D, se_clustered=se_c,
|
|
192
|
+
se_image=se_i, n_images=n, n_patients=int(P), n_positive_patients=n1c, n_negative_patients=n0c, n_mixed_patients=mixed,
|
|
193
|
+
kish_cluster_size=kish, effective_clusters=eff, se_ratio=float(np.mean(se_c / se_i)), warnings=notes)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def design_effect(scores, labels, patients, baseline=0):
|
|
197
|
+
"""Design effect and within-patient correlation of the AUC influence function.
|
|
198
|
+
|
|
199
|
+
Returns a dict with the Kish cluster size ``m`` and, for the AUC of each model and for the difference of each model from the
|
|
200
|
+
baseline, the design effect (clustered variance divided by image-level variance) and rho = (DEFF - 1) / (m - 1).
|
|
201
|
+
"""
|
|
202
|
+
S, y, pid = _check_inputs(scores, labels, patients, baseline)
|
|
203
|
+
_, cid = np.unique(pid, return_inverse=True)
|
|
204
|
+
P, n = cid.max() + 1, len(y)
|
|
205
|
+
sizes = np.bincount(cid)
|
|
206
|
+
m = float((sizes ** 2).sum() / sizes.sum())
|
|
207
|
+
pos = y == 1
|
|
208
|
+
h1, h0 = np.zeros(P, bool), np.zeros(P, bool)
|
|
209
|
+
h1[cid[pos]] = True
|
|
210
|
+
h0[cid[~pos]] = True
|
|
211
|
+
a1, a0 = h1.sum() / (h1.sum() - 1), h0.sum() / (h0.sum() - 1)
|
|
212
|
+
_, infl = auc_influence(S, y)
|
|
213
|
+
out = []
|
|
214
|
+
others = [j for j in range(S.shape[1]) if j != baseline]
|
|
215
|
+
for I in (infl, infl[:, others] - infl[:, [baseline]]):
|
|
216
|
+
gp, gn = np.zeros((P, I.shape[1])), np.zeros((P, I.shape[1]))
|
|
217
|
+
np.add.at(gp, cid[pos], I[pos])
|
|
218
|
+
np.add.at(gn, cid[~pos], I[~pos])
|
|
219
|
+
se_c2 = ((np.sqrt(a1) * gp + np.sqrt(a0) * gn) ** 2).sum(0)
|
|
220
|
+
se_i2 = n / (n - 1) * (I ** 2).sum(0)
|
|
221
|
+
out.append(se_c2 / se_i2)
|
|
222
|
+
rho = lambda d: (d - 1) / (m - 1) if m > 1 else np.full_like(d, np.nan)
|
|
223
|
+
return dict(kish_cluster_size=m, deff_auc=out[0], rho_auc=rho(out[0]), deff_difference=out[1], rho_difference=rho(out[1]),
|
|
224
|
+
difference_columns=others)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: camt-test
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CAMT, the clustered AUC maximum test: patient-clustered, selection-adjusted test that the best of K models beats a baseline, with an audit of published claims
|
|
5
|
+
Author: Aqib Gul
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: AUC,clustered data,winner's curse,multiple comparisons,model comparison,medical AI
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Intended Audience :: Science/Research
|
|
11
|
+
Classifier: Topic :: Scientific/Engineering
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: numpy>=1.23
|
|
16
|
+
Requires-Dist: scipy>=1.9
|
|
17
|
+
Provides-Extra: test
|
|
18
|
+
Requires-Dist: pytest; extra == "test"
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# camt: the clustered AUC maximum test (CAMT)
|
|
22
|
+
|
|
23
|
+
Tests of the claim that **the best of K models has a higher AUC than a baseline** on a test set that contains several images or
|
|
24
|
+
records per patient, and a **summary-statistics audit** of published claims of this kind.
|
|
25
|
+
|
|
26
|
+
Two errors act together when the winner among K candidate models is compared with a baseline on one shared test set:
|
|
27
|
+
|
|
28
|
+
1. observations from one patient are correlated, so a test that treats every image as independent understates the uncertainty;
|
|
29
|
+
2. the winner is chosen among K candidates, so its apparent advantage is inflated even if all models are equally good.
|
|
30
|
+
|
|
31
|
+
`camt` implements CAMT, a test that accounts for both. Labels may vary within a patient. The method is described in the accompanying
|
|
32
|
+
manuscript (in preparation).
|
|
33
|
+
|
|
34
|
+
Status: research software, version 0.1.0.
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install camt-test # from PyPI; the import name is `camt`
|
|
40
|
+
pip install . # or from the repository root; requires Python >= 3.9, numpy and scipy
|
|
41
|
+
pip install ".[test]" # also installs pytest
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Quick start
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from camt import camt_test
|
|
48
|
+
|
|
49
|
+
# scores: array (n_images, 1 + K), one column per model; column 0 is the baseline
|
|
50
|
+
# labels: array (n_images,) of 0/1 image-level labels
|
|
51
|
+
# patients: array (n_images,) of patient identifiers (the clusters)
|
|
52
|
+
result = camt_test(scores, labels, patients, baseline=0, n_boot=4000, seed=1)
|
|
53
|
+
print(result.summary())
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
best of 5 candidates vs baseline (column 0); best = column 1
|
|
58
|
+
AUC best 0.841 vs baseline 0.782 (difference +0.058)
|
|
59
|
+
500 images, 100 patients (47 with positives, 53 with negatives, 0 with both)
|
|
60
|
+
p (patient-clustered, adjusted for K) = 0.1685
|
|
61
|
+
p (pruned) = 0.1585 | clustering only = 0.0490 | selection only = 0.0310 | naive = 0.0068
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
In this simulated example no candidate is truly better, and the naive analysis reports p = 0.007. A complete script is in
|
|
65
|
+
`examples/quickstart.py`.
|
|
66
|
+
|
|
67
|
+
### Which p-value to report
|
|
68
|
+
|
|
69
|
+
| attribute | meaning |
|
|
70
|
+
|---|---|
|
|
71
|
+
| `p_value` | **the proposed test**: patient-clustered, adjusted for K, with the small-class calibration. Use this one. |
|
|
72
|
+
| `p_value_pruned` | as above, but candidates that are clearly worse than the baseline are dropped from the null distribution. More power when many candidates are poor; valid pointwise only. |
|
|
73
|
+
| `p_naive` | what an image-level test of the best candidate would give (for comparison). |
|
|
74
|
+
| `p_clustering_only` | patient-clustered, but no adjustment for K. |
|
|
75
|
+
| `p_selection_only` | adjusted for K, but images treated as independent. |
|
|
76
|
+
|
|
77
|
+
All p-values are one-sided; the null hypothesis is that no candidate is better than the baseline.
|
|
78
|
+
|
|
79
|
+
## Functions
|
|
80
|
+
|
|
81
|
+
| function | purpose |
|
|
82
|
+
|---|---|
|
|
83
|
+
| `camt_test(scores, labels, patients, baseline=0, n_boot=4000, seed=None, influence_fn=None)` | the test; returns a `CamtResult` |
|
|
84
|
+
| `design_effect(scores, labels, patients, baseline=0)` | Kish cluster size, design effect and within-patient correlation of the AUC influence function, for each model and for each difference from the baseline |
|
|
85
|
+
| `threshold_influence(thresholds, kind="balanced")` | influence function for sensitivity, specificity or balanced accuracy at operating points fixed in advance; pass it as `influence_fn` |
|
|
86
|
+
| `audit_claim(p_naive, patients, images, K, rho=...)` | adjust a published p-value for clustering and for K, using only summary numbers |
|
|
87
|
+
| `sensitivity_table(...)` | `audit_claim` over a range of within-patient correlations |
|
|
88
|
+
| `hanley_mcneil_se`, `rho_from_patient_level_ci` | helpers for the audit |
|
|
89
|
+
|
|
90
|
+
### Metrics at a fixed operating point
|
|
91
|
+
|
|
92
|
+
For balanced accuracy at thresholds chosen in advance (for example on the training patients):
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from camt import camt_test, threshold_influence
|
|
96
|
+
result = camt_test(scores, labels, patients, influence_fn=threshold_influence(thresholds, kind="balanced"))
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Thresholds estimated on the test sample itself are not covered.
|
|
100
|
+
|
|
101
|
+
### Auditing a published claim
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from camt import audit_claim
|
|
105
|
+
a = audit_claim(p_naive=0.0004, patients=40, images=3900, K=10, rho=0.1)
|
|
106
|
+
a["p_bonferroni"] # adjusted p-value if the within-patient correlation is 0.1 and 10 models were tried
|
|
107
|
+
a["rho_star"] # correlation above which the clustered p-value alone exceeds 0.05
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The within-patient correlation cannot be recovered from summary data, so report the result over a range of values
|
|
111
|
+
(`sensitivity_table`) and treat `rho_star` as the quantity of interest. The audit is valid if the correlation is not understated and
|
|
112
|
+
K is the true number of models tried.
|
|
113
|
+
|
|
114
|
+
## Assumptions and limits
|
|
115
|
+
|
|
116
|
+
- **K must be the true number of candidates examined**, and the baseline must be fixed in advance. Pre-specifying one primary model
|
|
117
|
+
(K = 1) removes the selection cost.
|
|
118
|
+
- The target is the **fitted models** on patients like the test patients, not the training algorithm. Test patients must be disjoint
|
|
119
|
+
from the training data.
|
|
120
|
+
- The test assumes independent patients, a bounded number of images per patient and a moderate number of candidates.
|
|
121
|
+
- The test can be **liberal** when fewer than about 20 patients belong to the smaller class and many candidates are screened, and
|
|
122
|
+
when a few patients contribute most of the images. The function emits a warning in both situations. The cut-off for the second
|
|
123
|
+
warning (fewer than 100 effective clusters, that is, images divided by the Kish cluster size) is a heuristic and has not been
|
|
124
|
+
validated.
|
|
125
|
+
- Candidates whose scores give a zero-variance difference from the baseline are dropped.
|
|
126
|
+
- Metrics whose threshold is estimated on the test sample are not covered.
|
|
127
|
+
|
|
128
|
+
## Reproducing the behaviour of the test
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
pytest # unit tests, about 25 seconds
|
|
132
|
+
python examples/reproduce_size.py # false-positive rates in simulation (a few minutes)
|
|
133
|
+
python examples/reproduce_size.py --reps 200 # quick version
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`examples/reproduce_size.py` simulates test sets in which all models are equal and prints the false-positive rate of the naive,
|
|
137
|
+
clustering-only, selection-only and proposed procedures for K = 1, 5 and 20, at a nominal level of 5%.
|
|
138
|
+
|
|
139
|
+
## Citation
|
|
140
|
+
|
|
141
|
+
If you use this software, please cite it (see `CITATION.cff`) together with the accompanying manuscript:
|
|
142
|
+
|
|
143
|
+
> Gul A. Valid inference for "best of K" model comparisons on patient-clustered diagnostic data. Manuscript in preparation.
|
|
144
|
+
|
|
145
|
+
The archived release and its DOI are given in `CITATION.cff` once available.
|
|
146
|
+
|
|
147
|
+
## Licence
|
|
148
|
+
|
|
149
|
+
MIT. See `LICENSE`.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/camt/__init__.py
|
|
5
|
+
src/camt/audit.py
|
|
6
|
+
src/camt/core.py
|
|
7
|
+
src/camt_test.egg-info/PKG-INFO
|
|
8
|
+
src/camt_test.egg-info/SOURCES.txt
|
|
9
|
+
src/camt_test.egg-info/dependency_links.txt
|
|
10
|
+
src/camt_test.egg-info/requires.txt
|
|
11
|
+
src/camt_test.egg-info/top_level.txt
|
|
12
|
+
tests/test_camt.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
camt
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import warnings
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pytest
|
|
5
|
+
from helpers import simulate
|
|
6
|
+
|
|
7
|
+
from camt import audit_claim, camt_test, design_effect, sensitivity_table, threshold_influence
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_reference_values():
|
|
11
|
+
"""Deterministic quantities are fixed; bootstrap p-values are Monte Carlo estimates and are checked with a tolerance."""
|
|
12
|
+
S, y, pid = simulate(seed=3)
|
|
13
|
+
r = camt_test(S, y, pid, n_boot=4000, seed=11, warn=False)
|
|
14
|
+
assert r.best == 5
|
|
15
|
+
assert (r.n_images, r.n_patients) == (320, 80)
|
|
16
|
+
np.testing.assert_allclose(r.auc, [0.8193139098, 0.8127741228, 0.8387374687, 0.8297305764, 0.7961701128, 0.8412437343], atol=1e-9)
|
|
17
|
+
assert r.se_clustered[0] == pytest.approx(0.0343591316, abs=1e-9)
|
|
18
|
+
assert r.se_image[0] == pytest.approx(0.0265136743, abs=1e-9)
|
|
19
|
+
assert r.p_naive == pytest.approx(0.20525702527235268, abs=1e-12)
|
|
20
|
+
assert r.p_clustering_only == pytest.approx(0.28082648001716126, abs=1e-12)
|
|
21
|
+
assert r.p_value == pytest.approx(0.613, abs=0.04)
|
|
22
|
+
assert r.p_selection_only == pytest.approx(0.497, abs=0.04)
|
|
23
|
+
assert r.kish_cluster_size == pytest.approx(4.0)
|
|
24
|
+
d = design_effect(S, y, pid)
|
|
25
|
+
np.testing.assert_allclose(d["deff_difference"], [1.679363, 1.87628, 1.987149, 2.466094, 2.01072], atol=1e-5)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_detects_a_large_edge_and_not_a_null():
|
|
29
|
+
S, y, pid = simulate(P=200, edge=0.8, seed=1)
|
|
30
|
+
assert camt_test(S, y, pid, n_boot=1000, seed=0, warn=False).p_value < 0.05
|
|
31
|
+
S0, y0, pid0 = simulate(P=200, edge=0.0, seed=1)
|
|
32
|
+
r0 = camt_test(S0, y0, pid0, n_boot=1000, seed=0, warn=False)
|
|
33
|
+
assert r0.p_value >= r0.p_clustering_only # adjusting for K can only raise the p-value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_size_is_near_nominal_under_the_null():
|
|
37
|
+
reps = 120
|
|
38
|
+
rej = sum(camt_test(*simulate(P=100, K=5, seed=100 + s), n_boot=500, seed=s, warn=False).p_value < 0.05 for s in range(reps))
|
|
39
|
+
assert rej / reps < 0.13 # nominal 5%, Monte Carlo SE about 2 points
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_balanced_accuracy_influence_runs():
|
|
43
|
+
S, y, pid = simulate(P=120, edge=0.8, seed=5)
|
|
44
|
+
r = camt_test(S, y, pid, n_boot=500, seed=0, influence_fn=threshold_influence(np.median(S, axis=0)), warn=False)
|
|
45
|
+
assert 0 <= r.p_value <= 1
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_input_validation():
|
|
49
|
+
S, y, pid = simulate()
|
|
50
|
+
with pytest.raises(ValueError):
|
|
51
|
+
camt_test(S[:, :1], y, pid)
|
|
52
|
+
with pytest.raises(ValueError):
|
|
53
|
+
camt_test(S, y + 2, pid)
|
|
54
|
+
S_bad = S.copy()
|
|
55
|
+
S_bad[0, 0] = np.nan
|
|
56
|
+
with pytest.raises(ValueError):
|
|
57
|
+
camt_test(S_bad, y, pid)
|
|
58
|
+
with pytest.raises(ValueError):
|
|
59
|
+
camt_test(S, np.zeros_like(y), pid) # no positives
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_warns_for_a_small_minority_class():
|
|
63
|
+
S, y, pid = simulate(P=60, K=5)
|
|
64
|
+
y = np.where(pid < 5, 1, 0) # five positive patients only
|
|
65
|
+
with pytest.warns(UserWarning):
|
|
66
|
+
camt_test(S, y, pid, n_boot=200, seed=0)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_no_warning_for_a_well_posed_problem():
|
|
70
|
+
S, y, pid = simulate(P=200, K=3, seed=2)
|
|
71
|
+
with warnings.catch_warnings():
|
|
72
|
+
warnings.simplefilter("error")
|
|
73
|
+
camt_test(S, y, pid, n_boot=200, seed=0)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_design_effect_is_one_for_one_image_per_patient():
|
|
77
|
+
S, y, pid = simulate(P=300, m=1)
|
|
78
|
+
d = design_effect(S, y, pid)
|
|
79
|
+
assert d["kish_cluster_size"] == pytest.approx(1.0)
|
|
80
|
+
assert np.allclose(d["deff_auc"], 1.0, atol=0.25)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_audit_worked_example():
|
|
84
|
+
a = audit_claim(0.0004, 40, 3900, 10, rho=0.1, pos_patients=28, neg_patients=12)
|
|
85
|
+
assert a["m"] == pytest.approx(97.5)
|
|
86
|
+
assert a["p_cluster"] > 0.0004 and a["p_bonferroni"] >= a["p_cluster"]
|
|
87
|
+
tab = sensitivity_table(0.0004, 40, 3900, 10)
|
|
88
|
+
assert all(tab[i]["p_cluster"] <= tab[i + 1]["p_cluster"] for i in range(len(tab) - 1)) # p rises with rho
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_audit_two_sided_halves_p():
|
|
92
|
+
one = audit_claim(0.01, 100, 100, 1)
|
|
93
|
+
two = audit_claim(0.02, 100, 100, 1, sided="two")
|
|
94
|
+
assert one["z_naive"] == pytest.approx(two["z_naive"])
|