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.
Files changed (95) hide show
  1. nltools/__init__.py +55 -0
  2. nltools/algorithms/__init__.py +90 -0
  3. nltools/algorithms/alignment/__init__.py +21 -0
  4. nltools/algorithms/alignment/procrustes.py +565 -0
  5. nltools/algorithms/alignment/srm.py +758 -0
  6. nltools/algorithms/backends.py +1059 -0
  7. nltools/algorithms/corrections.py +177 -0
  8. nltools/algorithms/decoding.py +327 -0
  9. nltools/algorithms/inference/__init__.py +50 -0
  10. nltools/algorithms/inference/bootstrap.py +1386 -0
  11. nltools/algorithms/inference/correlation.py +373 -0
  12. nltools/algorithms/inference/intersubject.py +422 -0
  13. nltools/algorithms/inference/isc.py +1554 -0
  14. nltools/algorithms/inference/matrix.py +602 -0
  15. nltools/algorithms/inference/one_sample.py +288 -0
  16. nltools/algorithms/inference/random.py +122 -0
  17. nltools/algorithms/inference/timeseries.py +347 -0
  18. nltools/algorithms/inference/two_sample.py +212 -0
  19. nltools/algorithms/inference/utils.py +58 -0
  20. nltools/algorithms/inference/validation.py +282 -0
  21. nltools/algorithms/neighborhoods.py +207 -0
  22. nltools/algorithms/outliers.py +308 -0
  23. nltools/algorithms/regression.py +83 -0
  24. nltools/algorithms/signal.py +303 -0
  25. nltools/algorithms/similarity.py +234 -0
  26. nltools/algorithms/validation.py +151 -0
  27. nltools/cross_validation.py +72 -0
  28. nltools/data/__init__.py +30 -0
  29. nltools/data/adjacency/__init__.py +875 -0
  30. nltools/data/adjacency/io.py +111 -0
  31. nltools/data/adjacency/modeling.py +569 -0
  32. nltools/data/adjacency/plotting.py +174 -0
  33. nltools/data/adjacency/state.py +349 -0
  34. nltools/data/adjacency/stats.py +596 -0
  35. nltools/data/adjacency/utils.py +79 -0
  36. nltools/data/atlases/__init__.py +23 -0
  37. nltools/data/atlases/labeling.py +158 -0
  38. nltools/data/atlases/loading.py +76 -0
  39. nltools/data/atlases/registry.py +96 -0
  40. nltools/data/atlases/reporting.py +456 -0
  41. nltools/data/braindata/__init__.py +2170 -0
  42. nltools/data/braindata/analysis.py +1381 -0
  43. nltools/data/braindata/bootstrap.py +398 -0
  44. nltools/data/braindata/io.py +896 -0
  45. nltools/data/braindata/modeling.py +594 -0
  46. nltools/data/braindata/plotting.py +501 -0
  47. nltools/data/braindata/prediction.py +1250 -0
  48. nltools/data/braindata/utils.py +348 -0
  49. nltools/data/braindata/validation.py +197 -0
  50. nltools/data/braindata/viewer.js +266 -0
  51. nltools/data/braindata/viewer.py +770 -0
  52. nltools/data/combine.py +27 -0
  53. nltools/data/designmatrix/__init__.py +1032 -0
  54. nltools/data/designmatrix/append.py +518 -0
  55. nltools/data/designmatrix/diagnostics.py +248 -0
  56. nltools/data/designmatrix/io.py +356 -0
  57. nltools/data/designmatrix/plotting.py +291 -0
  58. nltools/data/designmatrix/regressors.py +463 -0
  59. nltools/data/designmatrix/transforms.py +200 -0
  60. nltools/data/designmatrix/utils.py +350 -0
  61. nltools/data/ownership.py +129 -0
  62. nltools/data/results.py +291 -0
  63. nltools/data/roc/__init__.py +398 -0
  64. nltools/data/simulator/__init__.py +927 -0
  65. nltools/data/simulator/haxby.py +124 -0
  66. nltools/data/validation.py +83 -0
  67. nltools/datasets.py +218 -0
  68. nltools/io/__init__.py +10 -0
  69. nltools/io/events.py +67 -0
  70. nltools/io/h5.py +246 -0
  71. nltools/mask.py +403 -0
  72. nltools/models/__init__.py +11 -0
  73. nltools/models/glm.py +543 -0
  74. nltools/models/results.py +49 -0
  75. nltools/models/ridge.py +1303 -0
  76. nltools/models/validation.py +26 -0
  77. nltools/plotting/__init__.py +32 -0
  78. nltools/plotting/adjacency.py +421 -0
  79. nltools/plotting/brain.py +669 -0
  80. nltools/plotting/decomposition.py +111 -0
  81. nltools/plotting/prediction.py +110 -0
  82. nltools/resources/covariates_example.csv +161 -0
  83. nltools/resources/onsets_example.csv +40 -0
  84. nltools/templates/__init__.py +51 -0
  85. nltools/templates/config.py +144 -0
  86. nltools/templates/fetch.py +260 -0
  87. nltools/templates/matching.py +183 -0
  88. nltools/templates/paths.py +106 -0
  89. nltools/templates/registry.py +25 -0
  90. nltools/utils.py +230 -0
  91. nltools/version.py +13 -0
  92. nltools-0.6.0.dev0.dist-info/METADATA +95 -0
  93. nltools-0.6.0.dev0.dist-info/RECORD +95 -0
  94. nltools-0.6.0.dev0.dist-info/WHEEL +4 -0
  95. nltools-0.6.0.dev0.dist-info/licenses/LICENSE +21 -0
@@ -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("------------------------")