ncrf 0.5__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.
- ncrf/__init__.py +34 -0
- ncrf/_crossvalidation.py +290 -0
- ncrf/_data.py +635 -0
- ncrf/_fastac.py +279 -0
- ncrf/_forward.py +205 -0
- ncrf/_initialization.py +115 -0
- ncrf/_linalg.py +105 -0
- ncrf/_metrics.py +65 -0
- ncrf/_model.py +593 -0
- ncrf/_ncrf.py +240 -0
- ncrf/_penalties.py +147 -0
- ncrf/_pickle.py +19 -0
- ncrf/_repr.py +30 -0
- ncrf/_solvers/__init__.py +3 -0
- ncrf/_solvers/base.py +160 -0
- ncrf/_solvers/champ_lasso.py +768 -0
- ncrf/_trf_design.py +274 -0
- ncrf/_typing.py +22 -0
- ncrf/tests/__init__.py +5 -0
- ncrf/tests/fetch.py +112 -0
- ncrf/tests/test_crossvalidation.py +246 -0
- ncrf/tests/test_linalg.py +61 -0
- ncrf/tests/test_model.py +767 -0
- ncrf/tests/test_ncrf.py +241 -0
- ncrf/tests/test_penalties.py +41 -0
- ncrf-0.5.dist-info/METADATA +133 -0
- ncrf-0.5.dist-info/RECORD +30 -0
- ncrf-0.5.dist-info/WHEEL +5 -0
- ncrf-0.5.dist-info/licenses/LICENSE.txt +7 -0
- ncrf-0.5.dist-info/top_level.txt +1 -0
ncrf/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Public API for fitting and applying neuro-current response functions.
|
|
2
|
+
|
|
3
|
+
:func:`fit_ncrf` provides the complete convenience workflow. The component API
|
|
4
|
+
separates the :class:`RegressionData` dataset, the :class:`NCRFEstimator` fitting
|
|
5
|
+
orchestrator, :class:`ForwardModel` and :class:`TRFDesign` model components,
|
|
6
|
+
pluggable :class:`Solver` configurations, the reusable fitted :class:`NCRF`, and
|
|
7
|
+
the accompanying :class:`NCRFFit` report.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from ._trf_design import TRFDesign
|
|
11
|
+
from ._data import RegressionData
|
|
12
|
+
from ._forward import ForwardModel
|
|
13
|
+
from ._crossvalidation import CrossValidation, CVResult, crossvalidate
|
|
14
|
+
from ._model import NCRFEstimator, NCRF, NCRFExecutionContext, NCRFFit, fit_model
|
|
15
|
+
from ._metrics import explained_variance, l2_error
|
|
16
|
+
from ._solvers import ChampLasso, ChampLassoFit, Solver, SolverFit
|
|
17
|
+
from ._ncrf import fit_ncrf
|
|
18
|
+
|
|
19
|
+
from importlib.metadata import version
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
__version__ = version("ncrf")
|
|
24
|
+
except Exception:
|
|
25
|
+
__version__ = "0.0.0"
|
|
26
|
+
# In a git checkout (e.g., editable install), derive the current version from git
|
|
27
|
+
if (Path(__file__).parent.parent / '.git').exists():
|
|
28
|
+
try:
|
|
29
|
+
from setuptools_scm import get_version
|
|
30
|
+
__version__ = get_version(root='..', relative_to=__file__)
|
|
31
|
+
del get_version
|
|
32
|
+
except Exception:
|
|
33
|
+
pass
|
|
34
|
+
del version, Path
|
ncrf/_crossvalidation.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Cross-validation machinery used by the NCRF estimator.
|
|
2
|
+
|
|
3
|
+
This module only scores fixed solver configurations: it splits the time series,
|
|
4
|
+
fits and evaluates each candidate on every fold, and computes the
|
|
5
|
+
estimation-stability metric across folds. Which candidates to score, and which
|
|
6
|
+
of them wins, is decided by :meth:`~ncrf.Solver.search`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
# Author: Proloy Das <email:proloyd94@gmail.com>
|
|
12
|
+
# License: BSD (3-clause)
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from math import ceil
|
|
18
|
+
from multiprocessing import Pool
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
from collections.abc import Iterator, Sequence
|
|
21
|
+
|
|
22
|
+
from eelbrain._config import CONFIG
|
|
23
|
+
import numpy as np
|
|
24
|
+
from tqdm import tqdm
|
|
25
|
+
|
|
26
|
+
from ._data import RegressionData
|
|
27
|
+
from ._metrics import merge_scores
|
|
28
|
+
from ._typing import FloatArray, IndexArray
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from ._model import NCRF
|
|
32
|
+
from ._solvers import Solver
|
|
33
|
+
|
|
34
|
+
_worker_context: tuple[RegressionData, list[tuple[RegressionData, RegressionData]]] | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class CrossValidation:
|
|
39
|
+
"""Configuration for selecting among solver candidates.
|
|
40
|
+
|
|
41
|
+
Parameters
|
|
42
|
+
----------
|
|
43
|
+
n_splits
|
|
44
|
+
Number of cross-validation folds.
|
|
45
|
+
n_workers
|
|
46
|
+
Number of worker processes, or ``None`` to derive it from Eelbrain's
|
|
47
|
+
``n_workers`` setting (see :func:`eelbrain.configure`). Set to ``0`` to
|
|
48
|
+
run without :mod:`multiprocessing`, for debugging.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
n_splits: int = 3
|
|
52
|
+
n_workers: int | None = None
|
|
53
|
+
|
|
54
|
+
def __post_init__(self) -> None:
|
|
55
|
+
if self.n_splits < 1:
|
|
56
|
+
raise ValueError(f"n_splits={self.n_splits!r}: need at least one cross-validation fold")
|
|
57
|
+
|
|
58
|
+
def worker_processes(self) -> int:
|
|
59
|
+
"""Number of worker processes, resolving ``n_workers=None`` from Eelbrain's setting."""
|
|
60
|
+
if self.n_workers is not None:
|
|
61
|
+
return self.n_workers
|
|
62
|
+
# Eelbrain's setting is cpu_count() by default, and 0 when multiprocessing
|
|
63
|
+
# is disabled. Use a fraction of the CPUs to avoid oversubscribing them,
|
|
64
|
+
# since each worker's BLAS already runs its own thread pool
|
|
65
|
+
return ceil(CONFIG['n_workers'] / 8)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _make_folds(data: RegressionData, n_splits: int) -> list[tuple[RegressionData, RegressionData]]:
|
|
69
|
+
"""Train/test fold datasets, shared by every candidate scored on ``data``."""
|
|
70
|
+
d = max(data.design.filter_length)
|
|
71
|
+
kf = TimeSeriesSplit(r=0.05, p=n_splits, d=d)
|
|
72
|
+
return [(data.timeslice(train), data.timeslice(test)) for train, test in kf.split(data.samples)]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _initialize_worker(data: RegressionData, n_splits: int) -> None:
|
|
76
|
+
"""Store the shared whitened data and the fold datasets once per multiprocessing worker."""
|
|
77
|
+
global _worker_context
|
|
78
|
+
if CONFIG['nice']:
|
|
79
|
+
os.nice(CONFIG['nice'])
|
|
80
|
+
_worker_context = data, _make_folds(data, n_splits)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _score_worker(solver: Solver) -> CVResult:
|
|
84
|
+
if _worker_context is None:
|
|
85
|
+
raise RuntimeError("cross-validation worker was not initialized")
|
|
86
|
+
data, folds = _worker_context
|
|
87
|
+
return _score_candidate(data, folds, solver)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def compute_es_metric(models: Sequence[NCRF], data: RegressionData) -> float:
|
|
91
|
+
"""Compute the estimation-stability metric across cross-validation folds.
|
|
92
|
+
|
|
93
|
+
Details can be found at:
|
|
94
|
+
Lim, Chinghway, and Bin Yu. "Estimation stability with cross-validation (ESCV)."
|
|
95
|
+
Journal of Computational and Graphical Statistics 25.2 (2016): 464-492.
|
|
96
|
+
|
|
97
|
+
Parameters
|
|
98
|
+
----------
|
|
99
|
+
models
|
|
100
|
+
Fitted fold models from cross-validation.
|
|
101
|
+
data
|
|
102
|
+
Dataset used to compare their predictions.
|
|
103
|
+
|
|
104
|
+
Returns
|
|
105
|
+
-------
|
|
106
|
+
float
|
|
107
|
+
Estimation-stability score.
|
|
108
|
+
"""
|
|
109
|
+
Y = np.array([np.concatenate([prediction.ravel() for prediction in model.predict(data, whitened=True)]) for model in models])
|
|
110
|
+
Y_bar = Y.mean(axis=0)
|
|
111
|
+
VarY = (((Y - Y_bar) ** 2).sum(axis=1)).mean()
|
|
112
|
+
denominator = (Y_bar ** 2).sum()
|
|
113
|
+
# NaN predictions (a diverged fit) are as unstable as it gets
|
|
114
|
+
if denominator <= 0 or np.isnan(VarY):
|
|
115
|
+
return np.inf
|
|
116
|
+
return VarY / denominator
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class CVResult:
|
|
121
|
+
"""Cross-validation scores for one solver candidate.
|
|
122
|
+
|
|
123
|
+
Parameters
|
|
124
|
+
----------
|
|
125
|
+
solver
|
|
126
|
+
Candidate that was evaluated.
|
|
127
|
+
scores
|
|
128
|
+
Mean held-out scores across folds. Always contains the solver-independent
|
|
129
|
+
model metrics (``explained_variance``, ``l2_error``) and
|
|
130
|
+
``estimation_stability``; solvers add their own through
|
|
131
|
+
:meth:`SolverFit.score`.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
solver: Solver
|
|
135
|
+
scores: dict[str, float]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _score_candidate(
|
|
139
|
+
data: RegressionData,
|
|
140
|
+
folds: Sequence[tuple[RegressionData, RegressionData]],
|
|
141
|
+
solver: Solver,
|
|
142
|
+
) -> CVResult:
|
|
143
|
+
"""Fit and score all cross-validation folds for one solver candidate.
|
|
144
|
+
|
|
145
|
+
Each fold is fit through :func:`~ncrf.fit_model`, then scored on its held-out
|
|
146
|
+
window with the model metrics plus whatever the solver's fit contributes.
|
|
147
|
+
"""
|
|
148
|
+
from ._model import fit_model, _score_fit # _model imports this module
|
|
149
|
+
|
|
150
|
+
fold_solver = solver.without_history()
|
|
151
|
+
models = []
|
|
152
|
+
fold_scores = []
|
|
153
|
+
for traindata, testdata in folds:
|
|
154
|
+
model, solver_fit = fit_model(traindata, fold_solver)
|
|
155
|
+
models.append(model)
|
|
156
|
+
fold_scores.append(_score_fit(model, solver_fit, testdata))
|
|
157
|
+
|
|
158
|
+
scores = {key: sum(fold[key] for fold in fold_scores) / len(fold_scores) for key in fold_scores[0]}
|
|
159
|
+
estimation_stability = compute_es_metric(models, data)
|
|
160
|
+
return CVResult(solver, merge_scores(scores, {'estimation_stability': estimation_stability}))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def crossvalidate(
|
|
164
|
+
data: RegressionData,
|
|
165
|
+
candidates: Sequence[Solver],
|
|
166
|
+
cv: CrossValidation,
|
|
167
|
+
) -> list[CVResult]:
|
|
168
|
+
"""Perform cross-validation over a set of solver candidates.
|
|
169
|
+
|
|
170
|
+
Each candidate is fit and scored on the same folds, and the resulting
|
|
171
|
+
:class:`CVResult` objects are returned for the caller to compare. This is what
|
|
172
|
+
:meth:`~ncrf.Solver.search` calls to score the configurations it chooses between.
|
|
173
|
+
|
|
174
|
+
Parameters
|
|
175
|
+
----------
|
|
176
|
+
data
|
|
177
|
+
Whitened M/EEG data and the corresponding stimulus variables (see
|
|
178
|
+
:meth:`RegressionData.whiten`); its forward model is the one the
|
|
179
|
+
candidates are fit with. It is pickled to send it to worker processes.
|
|
180
|
+
candidates
|
|
181
|
+
Fixed solver configurations to compare.
|
|
182
|
+
cv
|
|
183
|
+
Folds and worker count to score the candidates on.
|
|
184
|
+
|
|
185
|
+
Returns
|
|
186
|
+
-------
|
|
187
|
+
list
|
|
188
|
+
Cross-validation results.
|
|
189
|
+
"""
|
|
190
|
+
logging.getLogger(__name__).info('Crossvalidation initiated!')
|
|
191
|
+
n_workers = cv.worker_processes()
|
|
192
|
+
results = []
|
|
193
|
+
with tqdm(total=len(candidates), desc="Crossvalidation", unit='candidate', unit_scale=True) as prog:
|
|
194
|
+
if n_workers == 0:
|
|
195
|
+
folds = _make_folds(data, cv.n_splits)
|
|
196
|
+
for candidate in candidates:
|
|
197
|
+
results.append(_score_candidate(data, folds, candidate))
|
|
198
|
+
prog.update()
|
|
199
|
+
else:
|
|
200
|
+
with Pool(
|
|
201
|
+
processes=n_workers,
|
|
202
|
+
initializer=_initialize_worker,
|
|
203
|
+
initargs=(data, cv.n_splits),
|
|
204
|
+
) as pool:
|
|
205
|
+
for result in pool.imap_unordered(_score_worker, candidates):
|
|
206
|
+
results.append(result)
|
|
207
|
+
prog.update()
|
|
208
|
+
|
|
209
|
+
return results
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class TimeSeriesSplit:
|
|
213
|
+
"""Split contiguous time indices into ordered train/test windows.
|
|
214
|
+
|
|
215
|
+
The last ``p`` windows of the time series are held out one at a time, and
|
|
216
|
+
each split trains on the samples preceding its window, so training data
|
|
217
|
+
always comes before the held-out data. Successive splits move the validation
|
|
218
|
+
window forward in time and thus train on progressively more data.
|
|
219
|
+
|
|
220
|
+
Parameters
|
|
221
|
+
----------
|
|
222
|
+
r
|
|
223
|
+
Size of each validation window relative to the samples left for
|
|
224
|
+
training: for ``n`` samples the window is ``ceil(r / (1 + r) * n)``
|
|
225
|
+
samples long.
|
|
226
|
+
p
|
|
227
|
+
Number of splits.
|
|
228
|
+
d
|
|
229
|
+
Number of samples to skip between the end of the training window and the
|
|
230
|
+
start of the validation window. Set it to the TRF length so that lagged
|
|
231
|
+
predictors in the training data do not reach into the held-out window.
|
|
232
|
+
|
|
233
|
+
Notes
|
|
234
|
+
-----
|
|
235
|
+
Only the length of the array passed to :meth:`split` is used; the splits are
|
|
236
|
+
index arrays that the caller applies to the data itself.
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
def __init__(self, r: float = 0.05, p: int = 5, d: int = 100):
|
|
240
|
+
self.ratio = r
|
|
241
|
+
self.p = p
|
|
242
|
+
self.d = d
|
|
243
|
+
|
|
244
|
+
def __repr__(self) -> str:
|
|
245
|
+
r, p, d = self.ratio, self.p, self.d
|
|
246
|
+
return f'{type(self).__name__}({r=}, {p=}, {d=})'
|
|
247
|
+
|
|
248
|
+
def _iter_part_masks(self, X: FloatArray) -> Iterator[tuple[np.ndarray, np.ndarray]]:
|
|
249
|
+
"""Yield boolean masks for each backward-moving validation split."""
|
|
250
|
+
n_v = ceil(self.ratio / (1 + self.ratio) * len(X))
|
|
251
|
+
for i in range(self.p, 0, -1):
|
|
252
|
+
test_mask = np.zeros(len(X), dtype=bool)
|
|
253
|
+
train_mask = np.ones(len(X), dtype=bool)
|
|
254
|
+
train_mask[-(i * n_v + self.d):] = False
|
|
255
|
+
if i == 1:
|
|
256
|
+
test_mask[-i * n_v:] = True
|
|
257
|
+
else:
|
|
258
|
+
test_mask[-i * n_v:-(i - 1) * n_v] = True
|
|
259
|
+
yield train_mask, test_mask
|
|
260
|
+
|
|
261
|
+
def split(self, X: FloatArray) -> Iterator[tuple[IndexArray, IndexArray]]:
|
|
262
|
+
"""Yield integer index arrays for each validation split.
|
|
263
|
+
|
|
264
|
+
Parameters
|
|
265
|
+
----------
|
|
266
|
+
X
|
|
267
|
+
Time course defining the number of samples to split; only its length
|
|
268
|
+
is used.
|
|
269
|
+
|
|
270
|
+
Yields
|
|
271
|
+
------
|
|
272
|
+
train_index
|
|
273
|
+
Time indices preceding the validation window, excluding the
|
|
274
|
+
``d``-sample gap.
|
|
275
|
+
test_index
|
|
276
|
+
Time indices of the validation window.
|
|
277
|
+
|
|
278
|
+
Raises
|
|
279
|
+
------
|
|
280
|
+
ValueError
|
|
281
|
+
If ``X`` is too short to leave any training samples once the
|
|
282
|
+
validation windows and the gap between them are removed.
|
|
283
|
+
"""
|
|
284
|
+
indices = np.arange(len(X))
|
|
285
|
+
for (train_mask, test_mask) in self._iter_part_masks(X):
|
|
286
|
+
train_index = indices[train_mask]
|
|
287
|
+
test_index = indices[test_mask]
|
|
288
|
+
if not len(train_index):
|
|
289
|
+
raise ValueError(f"{len(X)} samples are not enough for {self.p} cross-validation folds with a {self.d}-sample gap; use fewer folds, a shorter TRF, or more data")
|
|
290
|
+
yield train_index, test_index
|