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 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
@@ -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