nltools 0.6.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nltools/__init__.py +55 -0
- nltools/algorithms/__init__.py +90 -0
- nltools/algorithms/alignment/__init__.py +21 -0
- nltools/algorithms/alignment/procrustes.py +565 -0
- nltools/algorithms/alignment/srm.py +758 -0
- nltools/algorithms/backends.py +1059 -0
- nltools/algorithms/corrections.py +177 -0
- nltools/algorithms/decoding.py +327 -0
- nltools/algorithms/inference/__init__.py +50 -0
- nltools/algorithms/inference/bootstrap.py +1386 -0
- nltools/algorithms/inference/correlation.py +373 -0
- nltools/algorithms/inference/intersubject.py +422 -0
- nltools/algorithms/inference/isc.py +1554 -0
- nltools/algorithms/inference/matrix.py +602 -0
- nltools/algorithms/inference/one_sample.py +288 -0
- nltools/algorithms/inference/random.py +122 -0
- nltools/algorithms/inference/timeseries.py +347 -0
- nltools/algorithms/inference/two_sample.py +212 -0
- nltools/algorithms/inference/utils.py +58 -0
- nltools/algorithms/inference/validation.py +282 -0
- nltools/algorithms/neighborhoods.py +207 -0
- nltools/algorithms/outliers.py +308 -0
- nltools/algorithms/regression.py +83 -0
- nltools/algorithms/signal.py +303 -0
- nltools/algorithms/similarity.py +234 -0
- nltools/algorithms/validation.py +151 -0
- nltools/cross_validation.py +72 -0
- nltools/data/__init__.py +30 -0
- nltools/data/adjacency/__init__.py +875 -0
- nltools/data/adjacency/io.py +111 -0
- nltools/data/adjacency/modeling.py +569 -0
- nltools/data/adjacency/plotting.py +174 -0
- nltools/data/adjacency/state.py +349 -0
- nltools/data/adjacency/stats.py +596 -0
- nltools/data/adjacency/utils.py +79 -0
- nltools/data/atlases/__init__.py +23 -0
- nltools/data/atlases/labeling.py +158 -0
- nltools/data/atlases/loading.py +76 -0
- nltools/data/atlases/registry.py +96 -0
- nltools/data/atlases/reporting.py +456 -0
- nltools/data/braindata/__init__.py +2170 -0
- nltools/data/braindata/analysis.py +1381 -0
- nltools/data/braindata/bootstrap.py +398 -0
- nltools/data/braindata/io.py +896 -0
- nltools/data/braindata/modeling.py +594 -0
- nltools/data/braindata/plotting.py +501 -0
- nltools/data/braindata/prediction.py +1250 -0
- nltools/data/braindata/utils.py +348 -0
- nltools/data/braindata/validation.py +197 -0
- nltools/data/braindata/viewer.js +266 -0
- nltools/data/braindata/viewer.py +770 -0
- nltools/data/combine.py +27 -0
- nltools/data/designmatrix/__init__.py +1032 -0
- nltools/data/designmatrix/append.py +518 -0
- nltools/data/designmatrix/diagnostics.py +248 -0
- nltools/data/designmatrix/io.py +356 -0
- nltools/data/designmatrix/plotting.py +291 -0
- nltools/data/designmatrix/regressors.py +463 -0
- nltools/data/designmatrix/transforms.py +200 -0
- nltools/data/designmatrix/utils.py +350 -0
- nltools/data/ownership.py +129 -0
- nltools/data/results.py +291 -0
- nltools/data/roc/__init__.py +398 -0
- nltools/data/simulator/__init__.py +927 -0
- nltools/data/simulator/haxby.py +124 -0
- nltools/data/validation.py +83 -0
- nltools/datasets.py +218 -0
- nltools/io/__init__.py +10 -0
- nltools/io/events.py +67 -0
- nltools/io/h5.py +246 -0
- nltools/mask.py +403 -0
- nltools/models/__init__.py +11 -0
- nltools/models/glm.py +543 -0
- nltools/models/results.py +49 -0
- nltools/models/ridge.py +1303 -0
- nltools/models/validation.py +26 -0
- nltools/plotting/__init__.py +32 -0
- nltools/plotting/adjacency.py +421 -0
- nltools/plotting/brain.py +669 -0
- nltools/plotting/decomposition.py +111 -0
- nltools/plotting/prediction.py +110 -0
- nltools/resources/covariates_example.csv +161 -0
- nltools/resources/onsets_example.csv +40 -0
- nltools/templates/__init__.py +51 -0
- nltools/templates/config.py +144 -0
- nltools/templates/fetch.py +260 -0
- nltools/templates/matching.py +183 -0
- nltools/templates/paths.py +106 -0
- nltools/templates/registry.py +25 -0
- nltools/utils.py +230 -0
- nltools/version.py +13 -0
- nltools-0.6.0.dev0.dist-info/METADATA +95 -0
- nltools-0.6.0.dev0.dist-info/RECORD +95 -0
- nltools-0.6.0.dev0.dist-info/WHEEL +4 -0
- nltools-0.6.0.dev0.dist-info/licenses/LICENSE +21 -0
nltools/data/results.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Structural result records returned by decoding and resampling operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from dataclasses import asdict as dataclass_asdict
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Generic
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from nltools.models.results import Payload
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from .braindata import BrainData
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
#: Fields that may be populated for each spatial scale, and the subset that
|
|
19
|
+
#: every result of that scale must carry. Anything outside `permitted` must be
|
|
20
|
+
#: `None`, so an invalid field combination cannot be constructed.
|
|
21
|
+
_MODE_FIELDS = {
|
|
22
|
+
"whole_brain": {
|
|
23
|
+
"required": ("predictions", "cv_folds", "scores", "estimator", "weight_map"),
|
|
24
|
+
"optional": ("classes",),
|
|
25
|
+
},
|
|
26
|
+
"roi": {
|
|
27
|
+
"required": ("scores", "roi_labels", "score_map", "weight_map"),
|
|
28
|
+
"optional": ("classes",),
|
|
29
|
+
},
|
|
30
|
+
"searchlight": {
|
|
31
|
+
"required": ("score_map",),
|
|
32
|
+
"optional": ("classes",),
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#: Fields that describe the call itself rather than one spatial scale's output.
|
|
37
|
+
#: They always carry a value, including a `scoring` of `None`, so they are exempt
|
|
38
|
+
#: from both the per-mode "None means not applicable" rule and the None filter in
|
|
39
|
+
#: `available` and `asdict`.
|
|
40
|
+
_MODE_INDEPENDENT_FIELDS = ("spatial_scale", "scoring")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _fold_mean(scores, axis=None):
|
|
44
|
+
"""Reduce fold scores to their cross-fold mean, ignoring failed folds.
|
|
45
|
+
|
|
46
|
+
`Predict.mean_score` and the ROI runner's painted `score_map` must report the
|
|
47
|
+
same number for the same parcel, so both call this one reduction.
|
|
48
|
+
"""
|
|
49
|
+
return np.nanmean(scores, axis=axis)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class Predict:
|
|
54
|
+
"""Frozen structural record for `BrainData.predict` decoding results.
|
|
55
|
+
|
|
56
|
+
``spatial_scale`` is the discriminator: it decides which fields carry a
|
|
57
|
+
value and which stay ``None``. Construction validates that combination, so
|
|
58
|
+
an empty or mixed-mode record cannot exist; the shapes within it are the
|
|
59
|
+
producer's responsibility. Field bindings cannot be rebound, but the
|
|
60
|
+
payloads they hold remain usable, and the record takes independent
|
|
61
|
+
ownership of every array, brain map, and estimator it stores.
|
|
62
|
+
|
|
63
|
+
**Brain-space outputs are `BrainData` objects**, not raw arrays, so
|
|
64
|
+
``result.weight_map.plot()`` works directly (``.data`` gives the array).
|
|
65
|
+
Non-spatial fields are numpy.
|
|
66
|
+
|
|
67
|
+
**Populated by `spatial_scale`.** ``'whole_brain'``: ``predictions``,
|
|
68
|
+
``cv_folds``, ``scores``, ``estimator``, ``weight_map``. ``'roi'``:
|
|
69
|
+
``scores``, ``roi_labels``, ``score_map``, ``weight_map``.
|
|
70
|
+
``'searchlight'``: ``score_map``. ``classes`` accompanies any classifier;
|
|
71
|
+
``scoring`` records the caller's scoring specification in every mode.
|
|
72
|
+
|
|
73
|
+
**Why the all-data fit is the canonical map.** The mean of per-fold
|
|
74
|
+
``coef_`` vectors corresponds to no actual fitted estimator (each fold saw
|
|
75
|
+
a different subset), and fits on overlapping training folds are not
|
|
76
|
+
independent uncertainty samples. The record therefore exposes one
|
|
77
|
+
coefficient map, from the estimator refitted on all observations after
|
|
78
|
+
cross-validation: cross-validation gives the honest *score*, the refit
|
|
79
|
+
gives the publishable *map*.
|
|
80
|
+
|
|
81
|
+
Attributes:
|
|
82
|
+
spatial_scale (str): ``'whole_brain'``, ``'roi'``, or
|
|
83
|
+
``'searchlight'``.
|
|
84
|
+
scoring (str | callable | None): The scoring specification the caller
|
|
85
|
+
passed. ``None`` records that the estimator's own ``score`` method
|
|
86
|
+
was used; it does not by itself name that method's metric.
|
|
87
|
+
classes (ndarray | None): Classifier class labels, ``(n_classes,)``.
|
|
88
|
+
``None`` for regression.
|
|
89
|
+
predictions (ndarray | None): Out-of-fold predictions, one per row,
|
|
90
|
+
``(n_samples,)`` (whole-brain only).
|
|
91
|
+
cv_folds (ndarray | None): Fold index per row, ``(n_samples,)``
|
|
92
|
+
(whole-brain only).
|
|
93
|
+
scores (ndarray | None): Per-fold score — ``(n_folds,)`` for
|
|
94
|
+
whole-brain, ``(n_folds, n_rois)`` for ROI.
|
|
95
|
+
estimator (Any): The all-data fitted sklearn estimator (whole-brain
|
|
96
|
+
only); use it to ``.predict()`` on new data.
|
|
97
|
+
weight_map (BrainData | None): Coefficients of the estimator refit on
|
|
98
|
+
all data, ``(n_voxels,)`` or ``(n_classes, n_voxels)`` for
|
|
99
|
+
multiclass — one map for regression and binary classification, one
|
|
100
|
+
map per class in ``classes`` order for multiclass. For ROI, each
|
|
101
|
+
parcel's coefficients are written into its voxels (NaN outside
|
|
102
|
+
parcels); magnitudes are not comparable across parcels.
|
|
103
|
+
roi_labels (ndarray | None): Atlas integer ids, ``(n_rois,)``, in the
|
|
104
|
+
order of the ``scores`` parcel axis (ROI only).
|
|
105
|
+
score_map (BrainData | None): ``(n_voxels,)`` map of cross-validated
|
|
106
|
+
scores — for ROI, every voxel of parcel *i* holds that parcel's
|
|
107
|
+
mean fold score (NaN outside parcels); for searchlight, the
|
|
108
|
+
sphere-centered mean fold score at each voxel.
|
|
109
|
+
mean_score (float | ndarray): Mean of ``scores`` across folds, computed
|
|
110
|
+
on demand — a float for whole-brain, ``(n_rois,)`` for ROI.
|
|
111
|
+
Accessing it on a searchlight result raises `AttributeError`.
|
|
112
|
+
std_score (float | ndarray): Standard deviation of ``scores`` across
|
|
113
|
+
folds, in ``mean_score``'s form and with the same searchlight rule.
|
|
114
|
+
|
|
115
|
+
Note:
|
|
116
|
+
Encoding-model timeseries prediction (``bd.predict(X=...)``) returns a
|
|
117
|
+
`BrainData` directly rather than a `Predict` — the natural container
|
|
118
|
+
for a voxel timeseries.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
spatial_scale: str
|
|
122
|
+
scoring: Any = None
|
|
123
|
+
classes: np.ndarray | None = None
|
|
124
|
+
predictions: np.ndarray | None = None
|
|
125
|
+
cv_folds: np.ndarray | None = None
|
|
126
|
+
scores: np.ndarray | None = None
|
|
127
|
+
estimator: Any = None
|
|
128
|
+
weight_map: BrainData | None = None
|
|
129
|
+
roi_labels: np.ndarray | None = None
|
|
130
|
+
score_map: BrainData | None = None
|
|
131
|
+
|
|
132
|
+
def __post_init__(self):
|
|
133
|
+
"""Validate the field combination, then take ownership."""
|
|
134
|
+
self._validate_mode()
|
|
135
|
+
for name in self.__dataclass_fields__:
|
|
136
|
+
if name in _MODE_INDEPENDENT_FIELDS:
|
|
137
|
+
# A scoring name or callable is the caller's specification, not
|
|
138
|
+
# a payload the record owns; copying a callable scorer would
|
|
139
|
+
# change what `scoring` reports.
|
|
140
|
+
continue
|
|
141
|
+
value = getattr(self, name)
|
|
142
|
+
if value is not None:
|
|
143
|
+
object.__setattr__(self, name, deepcopy(value))
|
|
144
|
+
|
|
145
|
+
def _validate_mode(self) -> None:
|
|
146
|
+
"""Require exactly the fields the spec's shape table lists for this scale."""
|
|
147
|
+
if self.spatial_scale not in _MODE_FIELDS:
|
|
148
|
+
raise ValueError(
|
|
149
|
+
f"spatial_scale must be one of {sorted(_MODE_FIELDS)}; "
|
|
150
|
+
f"got {self.spatial_scale!r}."
|
|
151
|
+
)
|
|
152
|
+
mode = _MODE_FIELDS[self.spatial_scale]
|
|
153
|
+
permitted = set(mode["required"]) | set(mode["optional"])
|
|
154
|
+
for name in mode["required"]:
|
|
155
|
+
if getattr(self, name) is None:
|
|
156
|
+
raise ValueError(
|
|
157
|
+
f"{name} is required for spatial_scale="
|
|
158
|
+
f"{self.spatial_scale!r} and cannot be None."
|
|
159
|
+
)
|
|
160
|
+
for name in self.__dataclass_fields__:
|
|
161
|
+
if name in _MODE_INDEPENDENT_FIELDS or name in permitted:
|
|
162
|
+
continue
|
|
163
|
+
if getattr(self, name) is not None:
|
|
164
|
+
raise ValueError(
|
|
165
|
+
f"{name} does not apply to spatial_scale="
|
|
166
|
+
f"{self.spatial_scale!r} and must be None. That scale "
|
|
167
|
+
f"populates {sorted(permitted)}."
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def mean_score(self):
|
|
172
|
+
"""Mean score across folds — a float for whole-brain, per parcel for ROI."""
|
|
173
|
+
return self._summarize(_fold_mean, "mean_score")
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def std_score(self):
|
|
177
|
+
"""Score standard deviation across folds, in `mean_score`'s form."""
|
|
178
|
+
return self._summarize(np.nanstd, "std_score")
|
|
179
|
+
|
|
180
|
+
def _summarize(self, reduction, name: str):
|
|
181
|
+
"""Derive one cross-fold summary from `scores`."""
|
|
182
|
+
if self.spatial_scale == "searchlight":
|
|
183
|
+
raise AttributeError(
|
|
184
|
+
f"{name} does not exist for a searchlight result: searchlight "
|
|
185
|
+
f"stores its cross-fold mean directly in score_map, with one "
|
|
186
|
+
f"value per sphere center."
|
|
187
|
+
)
|
|
188
|
+
if self.spatial_scale == "roi":
|
|
189
|
+
return reduction(self.scores, axis=0)
|
|
190
|
+
return float(reduction(self.scores))
|
|
191
|
+
|
|
192
|
+
def __setstate__(self, state):
|
|
193
|
+
"""Restore a pickled record, rejecting one written by an older nltools.
|
|
194
|
+
|
|
195
|
+
Unpickling is the only path that bypasses `__post_init__`, and joblib
|
|
196
|
+
caches (the tutorials memoize `predict` results) are full of pickles. An
|
|
197
|
+
old record's fields are disjoint enough to detect, and without this check
|
|
198
|
+
its `score_map` would silently read as the class default `None`.
|
|
199
|
+
"""
|
|
200
|
+
if "spatial_scale" not in state or set(state) - set(self.__dataclass_fields__):
|
|
201
|
+
raise ValueError(
|
|
202
|
+
"This Predict was pickled by an older nltools and cannot be "
|
|
203
|
+
"restored: its field set predates the spatial_scale "
|
|
204
|
+
"discriminator. Clear the cache (for the tutorials, "
|
|
205
|
+
"`uv run poe tutorials-clean-cache`) and rerun."
|
|
206
|
+
)
|
|
207
|
+
self.__dict__.update(state)
|
|
208
|
+
|
|
209
|
+
def available(self) -> list:
|
|
210
|
+
"""Return names of the fields this result carries (excludes private).
|
|
211
|
+
|
|
212
|
+
`spatial_scale` and `scoring` always count: a `scoring` of `None` records
|
|
213
|
+
that the estimator's own `score` method was used, which is a value, not an
|
|
214
|
+
absent field.
|
|
215
|
+
"""
|
|
216
|
+
return [
|
|
217
|
+
field_name
|
|
218
|
+
for field_name in self.__dataclass_fields__
|
|
219
|
+
if not field_name.startswith("_") and self._is_reported(field_name)
|
|
220
|
+
]
|
|
221
|
+
|
|
222
|
+
def _is_reported(self, field_name: str) -> bool:
|
|
223
|
+
"""Whether a field appears in `available` and the default `asdict`."""
|
|
224
|
+
return (
|
|
225
|
+
field_name in _MODE_INDEPENDENT_FIELDS
|
|
226
|
+
or getattr(self, field_name) is not None
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def asdict(self, include_none: bool = False) -> dict:
|
|
230
|
+
"""Convert to dictionary.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
include_none: If True, include every field that does not apply to
|
|
234
|
+
this spatial scale, whose value is None. `spatial_scale` and
|
|
235
|
+
`scoring` are always included. Private fields (starting with _)
|
|
236
|
+
are always excluded.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
Dictionary of field names to values.
|
|
240
|
+
"""
|
|
241
|
+
full_dict = dataclass_asdict(self)
|
|
242
|
+
filtered = {k: v for k, v in full_dict.items() if not k.startswith("_")}
|
|
243
|
+
if not include_none:
|
|
244
|
+
filtered = {k: v for k, v in filtered.items() if self._is_reported(k)}
|
|
245
|
+
return filtered
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@dataclass(frozen=True)
|
|
249
|
+
class BootstrapResult(Generic[Payload]):
|
|
250
|
+
"""Frozen record of one bootstrap statistic's estimate and uncertainty.
|
|
251
|
+
|
|
252
|
+
The single result structure every supported `bootstrap` statistic returns.
|
|
253
|
+
Its payload is whatever the producer works in: `BrainData` for the
|
|
254
|
+
`BrainData` facade, `Adjacency` for the `Adjacency` facade. The four
|
|
255
|
+
summary payloads share one data shape.
|
|
256
|
+
|
|
257
|
+
Field bindings cannot be rebound. The payloads stay usable, but the record
|
|
258
|
+
takes independent ownership of each one, so mutating a returned payload
|
|
259
|
+
never reaches the source object or a sibling payload.
|
|
260
|
+
|
|
261
|
+
The record deliberately exposes no replicate mean and no `z`, `p`, or
|
|
262
|
+
`tail` output: those need a separately defined bootstrap hypothesis test.
|
|
263
|
+
For a normal-approximation stand-in, users compute it themselves from
|
|
264
|
+
`estimate` and `standard_error`.
|
|
265
|
+
|
|
266
|
+
Attributes:
|
|
267
|
+
estimate (Payload): The statistic evaluated once on the original full
|
|
268
|
+
sample — not the mean of the replicates.
|
|
269
|
+
standard_error (Payload): Elementwise standard deviation of the
|
|
270
|
+
bootstrap replicates, with `ddof=1`.
|
|
271
|
+
ci_lower (Payload): Lower bound of the central percentile interval at
|
|
272
|
+
the requested `confidence_level`.
|
|
273
|
+
ci_upper (Payload): Upper bound of that interval. The bounds are
|
|
274
|
+
elementwise marginal: the nominal level applies separately to each
|
|
275
|
+
voxel, feature, or test row, with no simultaneous-coverage claim.
|
|
276
|
+
samples (np.ndarray | None): Every replicate, bootstrap axis first,
|
|
277
|
+
when `return_samples=True`; `None` otherwise.
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
estimate: Payload
|
|
281
|
+
standard_error: Payload
|
|
282
|
+
ci_lower: Payload
|
|
283
|
+
ci_upper: Payload
|
|
284
|
+
samples: np.ndarray | None = None
|
|
285
|
+
|
|
286
|
+
def __post_init__(self):
|
|
287
|
+
"""Take independent ownership of every payload the record stores."""
|
|
288
|
+
for name in self.__dataclass_fields__:
|
|
289
|
+
value = getattr(self, name)
|
|
290
|
+
if value is not None:
|
|
291
|
+
object.__setattr__(self, name, deepcopy(value))
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
"""ROC (Receiver Operating Characteristic) analysis for single-interval classification.
|
|
2
|
+
|
|
3
|
+
These tools provide the ability to quickly run receiver operating characteristic
|
|
4
|
+
analyses on the output of machine-learning models applied to imaging data.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from nltools.plotting import _plot_roc
|
|
9
|
+
from scipy.stats import norm, binomtest
|
|
10
|
+
from sklearn.metrics import auc
|
|
11
|
+
from copy import deepcopy
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Roc:
|
|
15
|
+
"""Compute receiver operating characteristic curves for single-interval or forced-choice classification.
|
|
16
|
+
|
|
17
|
+
The Roc class is based on Tor Wager's Matlab roc_plot.m function and
|
|
18
|
+
allows a user to easily run different types of receiver operator
|
|
19
|
+
characteristic curves. For example, one might be interested in single
|
|
20
|
+
interval or forced choice.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
input_values (array-like): 1-D continuous decision values, one per observation.
|
|
24
|
+
binary_outcome (array-like): Boolean class label per observation.
|
|
25
|
+
method (str): Threshold-selection variant, naming what the chosen
|
|
26
|
+
threshold maximizes or minimizes: `'optimal_overall'` maximizes the
|
|
27
|
+
number of correct classifications, so the larger class dominates;
|
|
28
|
+
`'optimal_balanced'` maximizes balanced accuracy, the mean of
|
|
29
|
+
sensitivity and specificity, weighting the two classes equally;
|
|
30
|
+
`'minimum_sdt_bias'` minimizes the signal-detection response bias
|
|
31
|
+
`c`, which places the threshold midway between the two classes'
|
|
32
|
+
estimated distributions. With equal class sizes the first two often
|
|
33
|
+
agree.
|
|
34
|
+
forced_choice (array-like, optional): Subject id per observation for
|
|
35
|
+
forced-choice classification (each subject contributes one positive and
|
|
36
|
+
one negative observation).
|
|
37
|
+
|
|
38
|
+
Attributes:
|
|
39
|
+
input_values (np.ndarray): Decision values.
|
|
40
|
+
binary_outcome (np.ndarray): Boolean labels.
|
|
41
|
+
method (str): Configured threshold-selection variant. Set at construction;
|
|
42
|
+
`calculate`'s `method=` argument reads this as its default and never
|
|
43
|
+
writes back to it, so an explicit override passed to `calculate` only
|
|
44
|
+
affects that call.
|
|
45
|
+
forced_choice (np.ndarray | None): Subject ids for forced-choice classification.
|
|
46
|
+
criterion_values (np.ndarray): Thresholds at which `tpr`/`fpr` were evaluated;
|
|
47
|
+
set by `calculate`.
|
|
48
|
+
tpr (np.ndarray): True positive rate per criterion value; set by `calculate`.
|
|
49
|
+
fpr (np.ndarray): False positive rate per criterion value; set by `calculate`.
|
|
50
|
+
auc (float): Area under the ROC curve; set by `calculate`.
|
|
51
|
+
class_thr (float): Selected classification threshold; set by `calculate`.
|
|
52
|
+
sensitivity (float): Sensitivity at `class_thr`; set by `calculate`.
|
|
53
|
+
specificity (float): Specificity at `class_thr`; set by `calculate`.
|
|
54
|
+
ppv (float): Positive predictive value at `class_thr`; set by `calculate`.
|
|
55
|
+
accuracy (float): Classification accuracy; set by `calculate`.
|
|
56
|
+
accuracy_se (float): Standard error of the accuracy; set by `calculate`.
|
|
57
|
+
accuracy_p (BinomTestResult): `scipy.stats.binomtest` result comparing accuracy
|
|
58
|
+
against chance (read `.pvalue`); set by `calculate`.
|
|
59
|
+
tpr_smooth (np.ndarray): Gaussian-model true positive rate curve; set by
|
|
60
|
+
`plot(method='gaussian')`. Never read by `calculate`.
|
|
61
|
+
fpr_smooth (np.ndarray): Gaussian-model false positive rate curve; set by
|
|
62
|
+
`plot(method='gaussian')`. Never read by `calculate`.
|
|
63
|
+
aucn (float): Area under the Gaussian-model curve (`tpr_smooth`/`fpr_smooth`);
|
|
64
|
+
set by `plot(method='gaussian')`. Never read by `calculate`.
|
|
65
|
+
gaussian_sensitivity (float): Gaussian-model sensitivity estimate for
|
|
66
|
+
forced-choice data; set by `plot(method='gaussian')`. Never read by
|
|
67
|
+
`calculate`.
|
|
68
|
+
gaussian_specificity (float): Gaussian-model specificity estimate for
|
|
69
|
+
forced-choice data; set by `plot(method='gaussian')`. Never read by
|
|
70
|
+
`calculate`.
|
|
71
|
+
gaussian_ppv (float): Gaussian-model positive predictive value for
|
|
72
|
+
forced-choice data; set by `plot(method='gaussian')`. Never read by
|
|
73
|
+
`calculate`.
|
|
74
|
+
gaussian_auc (float): Gaussian-model area under the curve for forced-choice
|
|
75
|
+
data; set by `plot(method='gaussian')`. Never read by `calculate`.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
*,
|
|
81
|
+
input_values=None,
|
|
82
|
+
binary_outcome=None,
|
|
83
|
+
method="optimal_overall",
|
|
84
|
+
forced_choice=None,
|
|
85
|
+
):
|
|
86
|
+
if len(input_values) != len(binary_outcome):
|
|
87
|
+
raise ValueError(
|
|
88
|
+
"Data Problem: input_value and binary_outcomeare different lengths."
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
binary_outcome = np.asarray(binary_outcome).astype(bool).flatten()
|
|
92
|
+
if binary_outcome.all() or not binary_outcome.any():
|
|
93
|
+
raise ValueError(
|
|
94
|
+
"Data Problem: binary_outcome must contain both positive and "
|
|
95
|
+
"negative cases (True and False)."
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
valid_methods = ["optimal_overall", "optimal_balanced", "minimum_sdt_bias"]
|
|
99
|
+
if method not in valid_methods:
|
|
100
|
+
raise ValueError(
|
|
101
|
+
"method must be ['optimal_overall', "
|
|
102
|
+
"'optimal_balanced','minimum_sdt_bias']"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
self.input_values = np.array(input_values)
|
|
106
|
+
self.method = deepcopy(method)
|
|
107
|
+
self.forced_choice = deepcopy(forced_choice)
|
|
108
|
+
self.binary_outcome = binary_outcome
|
|
109
|
+
|
|
110
|
+
def calculate(
|
|
111
|
+
self,
|
|
112
|
+
*,
|
|
113
|
+
input_values=None,
|
|
114
|
+
binary_outcome=None,
|
|
115
|
+
criterion_values=None,
|
|
116
|
+
method=None,
|
|
117
|
+
forced_choice=None,
|
|
118
|
+
balanced_acc=False,
|
|
119
|
+
tail=2,
|
|
120
|
+
):
|
|
121
|
+
"""Calculate ROC metrics and store them on the instance.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
input_values (array-like, optional): 1-D continuous decision values, one
|
|
125
|
+
per observation. Defaults to the values given at construction.
|
|
126
|
+
binary_outcome (array-like, optional): Boolean class label per
|
|
127
|
+
observation. Defaults to the labels given at construction.
|
|
128
|
+
criterion_values (array-like, optional): Thresholds at which to evaluate
|
|
129
|
+
`fpr` and `tpr`. Defaults to a dense grid over the range of
|
|
130
|
+
`input_values`.
|
|
131
|
+
method (str, optional): Threshold-selection variant, one of
|
|
132
|
+
`'optimal_overall'` (maximize correct classifications),
|
|
133
|
+
`'optimal_balanced'` (maximize balanced accuracy, the mean of
|
|
134
|
+
sensitivity and specificity), or `'minimum_sdt_bias'` (minimize
|
|
135
|
+
signal-detection response bias).
|
|
136
|
+
Defaults to `None`, which uses the instance's configured `method`
|
|
137
|
+
(set at construction, or by assigning `self.method` directly). An
|
|
138
|
+
explicit value overrides the configured `method` for this call only
|
|
139
|
+
and does not change `self.method`.
|
|
140
|
+
forced_choice (array-like, optional): Subject id per observation for
|
|
141
|
+
forced-choice classification.
|
|
142
|
+
balanced_acc (bool): Report balanced accuracy (mean of sensitivity and
|
|
143
|
+
specificity) instead of overall accuracy. Only affects the accuracy
|
|
144
|
+
estimate, not the p-value or the threshold used for
|
|
145
|
+
sensitivity/specificity.
|
|
146
|
+
tail (int | str): `2`/`'two'` for two-tailed (default); `1`/`'one'` for
|
|
147
|
+
one-tailed (accuracy > chance) in the binomial test for `accuracy_p`.
|
|
148
|
+
"""
|
|
149
|
+
from nltools.algorithms.validation import _validate_tail_parameter
|
|
150
|
+
|
|
151
|
+
binom_alternative = (
|
|
152
|
+
"two-sided" if _validate_tail_parameter(tail) == "two" else "greater"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
if input_values is not None:
|
|
156
|
+
self.input_values = np.array(input_values)
|
|
157
|
+
|
|
158
|
+
if binary_outcome is not None:
|
|
159
|
+
self.binary_outcome = np.asarray(binary_outcome).astype(bool).flatten()
|
|
160
|
+
|
|
161
|
+
# Create Criterion Values
|
|
162
|
+
if criterion_values is not None:
|
|
163
|
+
self.criterion_values = deepcopy(criterion_values)
|
|
164
|
+
else:
|
|
165
|
+
self.criterion_values = np.linspace(
|
|
166
|
+
np.min(self.input_values.squeeze()),
|
|
167
|
+
np.max(self.input_values.squeeze()),
|
|
168
|
+
num=50 * len(self.binary_outcome),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if forced_choice is not None:
|
|
172
|
+
self.forced_choice = deepcopy(forced_choice)
|
|
173
|
+
|
|
174
|
+
if self.forced_choice is not None:
|
|
175
|
+
sub_idx = np.unique(self.forced_choice)
|
|
176
|
+
if len(sub_idx) != len(self.binary_outcome) / 2:
|
|
177
|
+
raise ValueError(
|
|
178
|
+
"Make sure that subject ids are correct for 'forced_choice'."
|
|
179
|
+
)
|
|
180
|
+
if len(
|
|
181
|
+
set(sub_idx).union(
|
|
182
|
+
set(np.array(self.forced_choice)[self.binary_outcome])
|
|
183
|
+
)
|
|
184
|
+
) != len(sub_idx):
|
|
185
|
+
raise ValueError("Issue with forced_choice subject labels.")
|
|
186
|
+
if len(
|
|
187
|
+
set(sub_idx).union(
|
|
188
|
+
set(np.array(self.forced_choice)[~self.binary_outcome])
|
|
189
|
+
)
|
|
190
|
+
) != len(sub_idx):
|
|
191
|
+
raise ValueError("Issue with forced_choice subject labels.")
|
|
192
|
+
for sub in sub_idx:
|
|
193
|
+
sub_mn = (
|
|
194
|
+
self.input_values[
|
|
195
|
+
(self.forced_choice == sub) & (self.binary_outcome)
|
|
196
|
+
]
|
|
197
|
+
+ self.input_values[
|
|
198
|
+
(self.forced_choice == sub) & (~self.binary_outcome)
|
|
199
|
+
]
|
|
200
|
+
)[0] / 2
|
|
201
|
+
self.input_values[
|
|
202
|
+
(self.forced_choice == sub) & (self.binary_outcome)
|
|
203
|
+
] = (
|
|
204
|
+
self.input_values[
|
|
205
|
+
(self.forced_choice == sub) & (self.binary_outcome)
|
|
206
|
+
][0]
|
|
207
|
+
- sub_mn
|
|
208
|
+
)
|
|
209
|
+
self.input_values[
|
|
210
|
+
(self.forced_choice == sub) & (~self.binary_outcome)
|
|
211
|
+
] = (
|
|
212
|
+
self.input_values[
|
|
213
|
+
(self.forced_choice == sub) & (~self.binary_outcome)
|
|
214
|
+
][0]
|
|
215
|
+
- sub_mn
|
|
216
|
+
)
|
|
217
|
+
self.class_thr = 0
|
|
218
|
+
|
|
219
|
+
# Calculate true positive and false positive rate
|
|
220
|
+
self.tpr = np.zeros(self.criterion_values.shape)
|
|
221
|
+
self.fpr = np.zeros(self.criterion_values.shape)
|
|
222
|
+
for i, x in enumerate(self.criterion_values):
|
|
223
|
+
wh = self.input_values >= x
|
|
224
|
+
self.tpr[i] = np.sum(wh[self.binary_outcome]) / np.sum(self.binary_outcome)
|
|
225
|
+
self.fpr[i] = np.sum(wh[~self.binary_outcome]) / np.sum(
|
|
226
|
+
~self.binary_outcome
|
|
227
|
+
)
|
|
228
|
+
self.n_true = np.sum(self.binary_outcome)
|
|
229
|
+
self.n_false = np.sum(~self.binary_outcome)
|
|
230
|
+
self.auc = auc(self.fpr, self.tpr)
|
|
231
|
+
|
|
232
|
+
# Get criterion threshold. An explicit method= overrides the instance's
|
|
233
|
+
# configured self.method for this call only; self.method itself is left
|
|
234
|
+
# untouched so a later bare calculate() reverts to it (q31x fvgk #12).
|
|
235
|
+
if self.forced_choice is None:
|
|
236
|
+
resolved_method = self.method if method is None else method
|
|
237
|
+
if resolved_method == "optimal_balanced":
|
|
238
|
+
# Balanced accuracy is the mean of sensitivity and specificity.
|
|
239
|
+
# Averaging tpr with fpr instead maximizes at the lowest
|
|
240
|
+
# criterion value, where everything is called positive.
|
|
241
|
+
balanced_accuracy = (self.tpr + (1 - self.fpr)) / 2
|
|
242
|
+
self.class_thr = self.criterion_values[np.argmax(balanced_accuracy)]
|
|
243
|
+
elif resolved_method == "optimal_overall":
|
|
244
|
+
n_corr_t = self.tpr * self.n_true
|
|
245
|
+
n_corr_f = (1 - self.fpr) * self.n_false
|
|
246
|
+
sm = n_corr_t + n_corr_f
|
|
247
|
+
self.class_thr = self.criterion_values[np.argmax(sm)]
|
|
248
|
+
elif resolved_method == "minimum_sdt_bias":
|
|
249
|
+
# Calculate MacMillan and Creelman 2005 Response Bias (c_bias)
|
|
250
|
+
c_bias = (
|
|
251
|
+
norm.ppf(np.maximum(0.0001, np.minimum(0.9999, self.tpr)))
|
|
252
|
+
+ norm.ppf(np.maximum(0.0001, np.minimum(0.9999, self.fpr)))
|
|
253
|
+
) / float(2)
|
|
254
|
+
self.class_thr = self.criterion_values[np.argmin(abs(c_bias))]
|
|
255
|
+
|
|
256
|
+
# Calculate output
|
|
257
|
+
self.false_positive = (self.input_values >= self.class_thr) & (
|
|
258
|
+
~self.binary_outcome
|
|
259
|
+
)
|
|
260
|
+
self.false_negative = (self.input_values < self.class_thr) & (
|
|
261
|
+
self.binary_outcome
|
|
262
|
+
)
|
|
263
|
+
self.misclass = (self.false_negative) | (self.false_positive)
|
|
264
|
+
self.true_positive = (self.binary_outcome) & (~self.misclass)
|
|
265
|
+
self.true_negative = (~self.binary_outcome) & (~self.misclass)
|
|
266
|
+
self.sensitivity = (
|
|
267
|
+
np.sum(self.input_values[self.binary_outcome] >= self.class_thr)
|
|
268
|
+
/ self.n_true
|
|
269
|
+
)
|
|
270
|
+
self.specificity = (
|
|
271
|
+
1
|
|
272
|
+
- np.sum(self.input_values[~self.binary_outcome] >= self.class_thr)
|
|
273
|
+
/ self.n_false
|
|
274
|
+
)
|
|
275
|
+
self.ppv = np.sum(self.true_positive) / (
|
|
276
|
+
np.sum(self.true_positive) + np.sum(self.false_positive)
|
|
277
|
+
)
|
|
278
|
+
if self.forced_choice is not None:
|
|
279
|
+
self.true_positive = self.true_positive[self.binary_outcome]
|
|
280
|
+
self.true_negative = self.true_negative[~self.binary_outcome]
|
|
281
|
+
self.false_negative = self.false_negative[self.binary_outcome]
|
|
282
|
+
self.false_positive = self.false_positive[~self.binary_outcome]
|
|
283
|
+
self.misclass = (self.false_positive) | (self.false_negative)
|
|
284
|
+
|
|
285
|
+
# Calculate Accuracy
|
|
286
|
+
if balanced_acc:
|
|
287
|
+
self.accuracy = np.mean(
|
|
288
|
+
[self.sensitivity, self.specificity]
|
|
289
|
+
) # See Brodersen, Ong, Stephan, Buhmann (2010)
|
|
290
|
+
else:
|
|
291
|
+
self.accuracy = 1 - np.mean(self.misclass)
|
|
292
|
+
|
|
293
|
+
# Calculate p-Value using binomial test (can add hierarchical version of binomial test)
|
|
294
|
+
self.n = len(self.misclass)
|
|
295
|
+
self.accuracy_p = binomtest(
|
|
296
|
+
int(np.sum(~self.misclass)), self.n, p=0.5, alternative=binom_alternative
|
|
297
|
+
)
|
|
298
|
+
p = np.mean(~self.misclass)
|
|
299
|
+
self.accuracy_se = np.sqrt(p * (1 - p) / self.n)
|
|
300
|
+
|
|
301
|
+
def plot(self, *, method="gaussian", balanced_acc=False):
|
|
302
|
+
"""Create a ROC plot.
|
|
303
|
+
|
|
304
|
+
Runs `calculate` first, then plots either a Gaussian-smoothed ROC curve fit
|
|
305
|
+
to the decision values or the observed empirical curve. The underlying
|
|
306
|
+
`calculate` call re-runs with the instance's configured `method` (it never
|
|
307
|
+
overrides the threshold rule), and the Gaussian-model curve estimates are
|
|
308
|
+
stored on their own attributes rather than overwriting `calculate`'s
|
|
309
|
+
`sensitivity`, `specificity`, `ppv`, and `auc`.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
method (str): Type of plot, `'gaussian'` or `'observed'`.
|
|
313
|
+
balanced_acc (bool): Passed to `calculate`; report balanced accuracy.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
matplotlib.figure.Figure: The ROC figure.
|
|
317
|
+
|
|
318
|
+
Note:
|
|
319
|
+
For `method='gaussian'` on forced-choice data, this also sets
|
|
320
|
+
`gaussian_sensitivity`, `gaussian_specificity`, `gaussian_ppv`, and
|
|
321
|
+
`gaussian_auc` from the fitted Gaussian model. For `method='gaussian'`
|
|
322
|
+
on either kind of data, it also sets `tpr_smooth`, `fpr_smooth`, and
|
|
323
|
+
`aucn` (the smoothed curve and its AUC). None of these attributes are
|
|
324
|
+
read by `calculate`.
|
|
325
|
+
"""
|
|
326
|
+
|
|
327
|
+
self.calculate(balanced_acc=balanced_acc) # Calculate ROC parameters
|
|
328
|
+
|
|
329
|
+
if method == "gaussian":
|
|
330
|
+
if self.forced_choice is not None:
|
|
331
|
+
sub_idx = np.unique(self.forced_choice)
|
|
332
|
+
diff_scores = []
|
|
333
|
+
for sub in sub_idx:
|
|
334
|
+
diff_scores.append(
|
|
335
|
+
self.input_values[
|
|
336
|
+
(self.forced_choice == sub) & (self.binary_outcome)
|
|
337
|
+
][0]
|
|
338
|
+
- self.input_values[
|
|
339
|
+
(self.forced_choice == sub) & (~self.binary_outcome)
|
|
340
|
+
][0]
|
|
341
|
+
)
|
|
342
|
+
diff_scores = np.array(diff_scores)
|
|
343
|
+
mn_diff = np.mean(diff_scores)
|
|
344
|
+
d = mn_diff / np.std(diff_scores)
|
|
345
|
+
pooled_sd = np.std(diff_scores) / np.sqrt(2)
|
|
346
|
+
d_a_model = mn_diff / pooled_sd
|
|
347
|
+
|
|
348
|
+
expected_acc = 1 - norm.cdf(0, d, 1)
|
|
349
|
+
self.gaussian_sensitivity = expected_acc
|
|
350
|
+
self.gaussian_specificity = expected_acc
|
|
351
|
+
self.gaussian_ppv = self.gaussian_sensitivity / (
|
|
352
|
+
self.gaussian_sensitivity + 1 - self.gaussian_specificity
|
|
353
|
+
)
|
|
354
|
+
self.gaussian_auc = norm.cdf(d_a_model / np.sqrt(2))
|
|
355
|
+
|
|
356
|
+
x = np.arange(-3, 3, 0.1)
|
|
357
|
+
self.tpr_smooth = 1 - norm.cdf(x, d, 1)
|
|
358
|
+
self.fpr_smooth = 1 - norm.cdf(x, -d, 1)
|
|
359
|
+
else:
|
|
360
|
+
mn_true = np.mean(self.input_values[self.binary_outcome])
|
|
361
|
+
mn_false = np.mean(self.input_values[~self.binary_outcome])
|
|
362
|
+
var_true = np.var(self.input_values[self.binary_outcome])
|
|
363
|
+
var_false = np.var(self.input_values[~self.binary_outcome])
|
|
364
|
+
pooled_sd = np.sqrt(
|
|
365
|
+
(var_true * (self.n_true - 1) + var_false * (self.n_false - 1))
|
|
366
|
+
/ (self.n_true + self.n_false - 2)
|
|
367
|
+
)
|
|
368
|
+
d = (mn_true - mn_false) / pooled_sd
|
|
369
|
+
z_true = mn_true / pooled_sd
|
|
370
|
+
z_false = mn_false / pooled_sd
|
|
371
|
+
|
|
372
|
+
x = np.arange(z_false - 3, z_true + 3, 0.1)
|
|
373
|
+
self.tpr_smooth = 1 - (norm.cdf(x, z_true, 1))
|
|
374
|
+
self.fpr_smooth = 1 - (norm.cdf(x, z_false, 1))
|
|
375
|
+
|
|
376
|
+
self.aucn = auc(self.fpr_smooth, self.tpr_smooth)
|
|
377
|
+
fig = _plot_roc(self.fpr_smooth, self.tpr_smooth)
|
|
378
|
+
|
|
379
|
+
elif method == "observed":
|
|
380
|
+
fig = _plot_roc(self.fpr, self.tpr)
|
|
381
|
+
else:
|
|
382
|
+
raise ValueError("method must be 'gaussian' or 'observed'")
|
|
383
|
+
return fig
|
|
384
|
+
|
|
385
|
+
def summary(self):
|
|
386
|
+
"""Display a formatted summary of ROC analysis."""
|
|
387
|
+
|
|
388
|
+
print("------------------------")
|
|
389
|
+
print(".:ROC Analysis Summary:.")
|
|
390
|
+
print("------------------------")
|
|
391
|
+
print("{:20s}".format("Accuracy:") + f"{self.accuracy:.2f}")
|
|
392
|
+
print("{:20s}".format("Accuracy SE:") + f"{self.accuracy_se:.2f}")
|
|
393
|
+
print("{:20s}".format("Accuracy p-value:") + f"{self.accuracy_p.pvalue:.2f}")
|
|
394
|
+
print("{:20s}".format("Sensitivity:") + f"{self.sensitivity:.2f}")
|
|
395
|
+
print("{:20s}".format("Specificity:") + f"{self.specificity:.2f}")
|
|
396
|
+
print("{:20s}".format("AUC:") + f"{self.auc:.2f}")
|
|
397
|
+
print("{:20s}".format("PPV:") + f"{self.ppv:.2f}")
|
|
398
|
+
print("------------------------")
|