Skip to content

Commit 9e8d222

Browse files
authored
Merge pull request #67 from courtois-neuromod/iss61
Reorganized the toolbox.
2 parents c6a9cd4 + 765142b commit 9e8d222

File tree

12 files changed

+546
-569
lines changed

12 files changed

+546
-569
lines changed

.circleci/config.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@ jobs:
1414
command: |
1515
pip install --progress-bar off -r requirements.txt
1616
pip install --progress-bar off pytest coverage
17-
pip install --progress-bar off -e dypac/
17+
pip install --progress-bar off -e .
1818
- run:
1919
command: |
20-
coverage run --source bascpp,dypac -m pytest dypac/test_bascpp.py dypac/test_dypac.py
20+
coverage run --source bascpp,dypac -m pytest dypac/tests/test_bascpp.py dypac/tests/test_dypac.py
2121
coverage report
2222
coverage html
2323
name: Test_bascppp

dypac/__init__.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
"""Dynamic Parcel Aggregation with Clustering (dypac)."""
2-
from .dypac import dypac
3-
from .bascpp import replicate_clusters, find_states, stab_maps
4-
__all__ = ['dypac', 'replicate_clusters', 'find_states', 'stab_maps']
2+
from dypac.dypac import Dypac
3+
from dypac.embeddings import Embedding
4+
from dypac.bascpp import replicate_clusters, find_states, stab_maps
5+
from dypac.tests import test_bascpp, test_dypac
6+
__all__ = ['Dypac', 'test_bascpp', 'test_dypac', 'replicate_clusters', 'find_states', 'stab_maps', 'Embedding']

dypac/dypac.py

Lines changed: 156 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,17 @@
1515

1616
from nilearn import EXPAND_PATH_WILDCARDS
1717
from joblib import Memory
18+
from nilearn import datasets
1819
from nilearn._utils.niimg_conversions import _resolve_globbing
1920
from nilearn.input_data import NiftiMasker
2021
from nilearn.input_data.masker_validation import check_embedded_nifti_masker
2122
from nilearn.decomposition.base import BaseDecomposition
2223

23-
import bascpp as bpp
24+
import dypac.bascpp as bpp
25+
from dypac.embeddings import Embedding
2426

2527

26-
class dypac(BaseDecomposition):
28+
class Dypac(BaseDecomposition):
2729
"""
2830
Perform Stable Dynamic Cluster Analysis.
2931
@@ -69,7 +71,10 @@ class dypac(BaseDecomposition):
6971
grey_matter: Niimg-like object or MultiNiftiMasker instance, optional
7072
A voxel-wise estimate of grey matter partial volumes.
7173
If provided, this mask is used to give more weight to grey matter in the
72-
replications of functional clusters.
74+
replications of functional clusters. Use None to skip.
75+
By default, uses the ICBM152_2009 probabilistic grey matter segmentation.
76+
Note that the segmentation will be smoothed with the same kernel as the
77+
functional images.
7378
7479
std_grey_matter: float (1 <= .)
7580
The standard deviation of voxels will be adjusted to
@@ -159,8 +164,8 @@ def __init__(
159164
threshold_sim=0.3,
160165
random_state=None,
161166
mask=None,
162-
grey_matter=None,
163-
std_grey_matter=1,
167+
grey_matter="MNI",
168+
std_grey_matter=3,
164169
smoothing_fwhm=None,
165170
standardize=True,
166171
detrend=True,
@@ -215,6 +220,32 @@ def _check_components_(self):
215220
"been called."
216221
)
217222

223+
def _sanitize_imgs(self, imgs, confounds):
224+
"""Check that provided images are in the correct format."""
225+
# Base fit for decomposition estimators : compute the embedded masker
226+
if isinstance(imgs, str):
227+
if EXPAND_PATH_WILDCARDS and glob.has_magic(imgs):
228+
imgs = _resolve_globbing(imgs)
229+
230+
if isinstance(imgs, str) or not hasattr(imgs, "__iter__"):
231+
# these classes are meant for list of 4D images
232+
# (multi-subject), we want it to work also on a single
233+
# subject, so we hack it.
234+
imgs = [imgs]
235+
236+
if len(imgs) == 0:
237+
# Common error that arises from a null glob. Capture
238+
# it early and raise a helpful message
239+
raise ValueError(
240+
"Need one or more Niimg-like objects as input, "
241+
"an empty list was given."
242+
)
243+
244+
# if no confounds have been specified, match length of imgs
245+
if confounds is None:
246+
confounds = list(itertools.repeat(confounds, len(imgs)))
247+
return imgs, confounds
248+
218249
def fit(self, imgs, confounds=None):
219250
"""
220251
Compute the mask and the dynamic parcels across datasets.
@@ -237,25 +268,8 @@ def fit(self, imgs, confounds=None):
237268
Returns the instance itself. Contains attributes listed
238269
at the object level.
239270
"""
240-
# Base fit for decomposition estimators : compute the embedded masker
241-
if isinstance(imgs, str):
242-
if EXPAND_PATH_WILDCARDS and glob.has_magic(imgs):
243-
imgs = _resolve_globbing(imgs)
244-
245-
if isinstance(imgs, str) or not hasattr(imgs, "__iter__"):
246-
# these classes are meant for list of 4D images
247-
# (multi-subject), we want it to work also on a single
248-
# subject, so we hack it.
249-
imgs = [imgs]
250-
251-
if len(imgs) == 0:
252-
# Common error that arises from a null glob. Capture
253-
# it early and raise a helpful message
254-
raise ValueError(
255-
"Need one or more Niimg-like objects as input, "
256-
"an empty list was given."
257-
)
258271
self.masker_ = check_embedded_nifti_masker(self)
272+
imgs, confounds = self._sanitize_imgs(imgs, confounds)
259273

260274
# Avoid warning with imgs != None
261275
# if masker_ has been provided a mask_img
@@ -266,6 +280,10 @@ def fit(self, imgs, confounds=None):
266280
self.mask_img_ = self.masker_.mask_img_
267281

268282
# Load grey_matter segmentation
283+
if self.grey_matter == "MNI":
284+
mni = datasets.fetch_icbm152_2009()
285+
self.grey_matter = mni.gm
286+
269287
if self.grey_matter is not None:
270288
masker_anat = NiftiMasker(
271289
mask_img=self.mask_img_, smoothing_fwhm=self.smoothing_fwhm
@@ -276,11 +294,7 @@ def fit(self, imgs, confounds=None):
276294
1 - grey_matter
277295
) + self.std_grey_matter * grey_matter
278296
else:
279-
self.grey_matter_ = None
280-
281-
# if no confounds have been specified, match length of imgs
282-
if confounds is None:
283-
confounds = list(itertools.repeat(confounds, len(imgs)))
297+
self.weights_grey_matter_ = None
284298

285299
# Control random number generation
286300
self.random_state = check_random_state(self.random_state)
@@ -303,6 +317,9 @@ def fit(self, imgs, confounds=None):
303317
# Return components
304318
self.components_ = stab_maps
305319
self.dwell_time_ = dwell_time
320+
321+
# Create embedding
322+
self.embedding = Embedding(stab_maps.todense())
306323
return self
307324

308325
def _mask_and_reduce_batch(self, imgs, confounds=None):
@@ -345,17 +362,17 @@ def _mask_and_reduce(self, imgs, confounds=None):
345362
dwell_time: ndarray
346363
dwell time of each state.
347364
"""
348-
349365
onehot_list = []
350366
for ind, img, confound in zip(range(len(imgs)), imgs, confounds):
351367
this_data = self.masker_.transform(img, confound)
352368
# Now get rid of the img as fast as possible, to free a
353369
# reference count on it, and possibly free the corresponding
354370
# data
355-
this_data = np.multiply(this_data, self.weights_grey_matter_)
356371
del img
357372
# Scale grey matter voxels to give them more weight in the
358373
# classification
374+
if self.weights_grey_matter_ is not None:
375+
this_data = np.multiply(this_data, self.weights_grey_matter_)
359376
onehot = bpp.replicate_clusters(
360377
this_data.transpose(),
361378
subsample_size=self.subsample_size,
@@ -390,17 +407,116 @@ def _mask_and_reduce(self, imgs, confounds=None):
390407

391408
return stab_maps, dwell_time
392409

393-
def transform_sparse(self, img, confound=None):
394-
"""Transform a 4D dataset in a component space."""
410+
def load_img(self, img, confound=None):
411+
"""
412+
Load a 4D image using the same preprocessing as model fitting.
413+
414+
Parameters
415+
----------
416+
img : Niimg-like object.
417+
See http://nilearn.github.io/manipulating_images/input_output.html
418+
An fMRI dataset
419+
420+
Returns
421+
-------
422+
img_p : Niimg-like object.
423+
Same as input, after the preprocessing step used in the model have
424+
been applied.
425+
"""
395426
self._check_components_()
396-
this_data = self.masker_.transform(img, confound)
427+
tseries = self.masker_.transform(img, confound)
428+
return self.masker_.inverse_transform(tseries)
429+
430+
def transform(self, img, confound=None):
431+
"""
432+
Transform a 4D dataset in a component space.
433+
434+
Parameters
435+
----------
436+
img : Niimg-like object.
437+
See http://nilearn.github.io/manipulating_images/input_output.html
438+
An fMRI dataset
439+
confound : CSV file or 2D matrix, optional.
440+
Confound parameters, to be passed to nilearn.signal.clean.
441+
442+
Returns
443+
-------
444+
weights : numpy array of shape [n_samples, n_states + 1]
445+
The fMRI tseries after projection in the parcellation
446+
space. Note that the first coefficient corresponds to the intercept,
447+
and not one of the parcels.
448+
"""
449+
self._check_components_()
450+
tseries = self.masker_.transform(img, confound)
397451
del img
398-
reg = LinearRegression().fit(
399-
self.components_.transpose(), this_data.transpose()
400-
)
401-
return reg.coef_
452+
return self.embedding.transform(tseries)
453+
454+
def inverse_transform(self, weights):
455+
"""
456+
Transform component weights as a 4D dataset.
457+
458+
Parameters
459+
----------
460+
weights : numpy array of shape [n_samples, n_states + 1]
461+
The fMRI tseries after projection in the parcellation
462+
space. Note that the first coefficient corresponds to the intercept,
463+
and not one of the parcels.
464+
465+
Returns
466+
-------
467+
img : Niimg-like object.
468+
The 4D fMRI dataset corresponding to the weights.
469+
"""
470+
self._check_components_()
471+
return self.masker_.inverse_transform(self.embedding.inverse_transform(weights))
402472

403-
def inverse_transform_sparse(self, weights):
404-
"""Transform component weights as a 4D dataset."""
473+
def compress(self, img, confound=None):
474+
"""
475+
Provide the approximation of a 4D dataset after projection in parcellation space.
476+
477+
Parameters
478+
----------
479+
img : Niimg-like object.
480+
See http://nilearn.github.io/manipulating_images/input_output.html
481+
An fMRI dataset
482+
confound : CSV file or 2D matrix, optional.
483+
Confound parameters, to be passed to nilearn.signal.clean.
484+
485+
Returns
486+
-------
487+
img_c : Niimg-like object.
488+
The 4D fMRI dataset corresponding to the input, compressed in the parcel space.
489+
"""
405490
self._check_components_()
406-
self.masker_.inverse_transform(weights * self.components_)
491+
tseries = self.masker_.transform(img, confound)
492+
del img
493+
return self.masker_.inverse_transform(self.embedding.compress(tseries))
494+
495+
def score(self, img, confound=None):
496+
"""
497+
R2 map of the quality of the compression.
498+
499+
Parameters
500+
----------
501+
img : Niimg-like object.
502+
See http://nilearn.github.io/manipulating_images/input_output.html
503+
An fMRI dataset
504+
confound : CSV file or 2D matrix, optional.
505+
Confound parameters, to be passed to nilearn.signal.clean.
506+
507+
Returns
508+
-------
509+
score : Niimg-like object.
510+
A 3D map of R2 score of the quality of the compression.
511+
512+
Note
513+
----
514+
The R2 score map is the fraction of the variance of fMRI time series captured
515+
by the parcels at each voxel. A score of 1 means perfect approximation.
516+
The score can be negative, in which case the parcellation approximation
517+
performs worst than the average of the signal.
518+
"""
519+
self._check_components_()
520+
tseries = self.masker_.transform(img, confound)
521+
del img
522+
return self.masker_.inverse_transform(self.embedding.score(tseries))

dypac/embeddings.py

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
import numpy as np
2+
from sklearn.preprocessing import StandardScaler
3+
24

35
def projector(X):
46
"""Ordinary least-square projection."""
57
# when solving Y = beta * X + E, for beta minimizing sum of E squares,
68
# beta takes the form of Y * P, with P the projection matrix into the space
79
# spanned by X. The following formula computes P.
8-
return np.dot(X.transpose(), np.pinv(np.dot(X, X.transpose())))
10+
return np.dot(X.transpose(), np.linalg.pinv(np.dot(X, X.transpose())))
911

1012

1113
def miss_constant(X, precision=1e-10):
1214
"""Check if a constant vector is missing in a vector basis.
1315
"""
14-
return np.min(np.sum(np.absolute(X-1), axis=1)) > precision
16+
return np.min(np.sum(np.absolute(X - 1), axis=1)) > precision
1517

1618

1719
class Embedding:
@@ -39,39 +41,40 @@ def __init__(self, X, add_constant=True):
3941
inverse_transform_mat: ndarray
4042
matrix projection from embedding to original space.
4143
"""
42-
self.size = dypac.components_.shape[0]
44+
self.size = X.shape[0]
4345
# Once we have the embedded representation beta, the inverse transform
4446
# is a simple linear mixture:
4547
# Y_hat = beta * X
4648
# We store X as the inverse transform matrix
47-
if add_constant && miss_constant(X):
48-
self.inv_transform_mat = np.concat(np.ones([1, X.shape[1]]), X)
49-
else
50-
self.inv_transform_mat = X
49+
if add_constant and miss_constant(X):
50+
self.inverse_transform_mat = np.concatenate([np.ones([1, X.shape[1]]), X])
51+
else:
52+
self.inverse_transform_mat = X
5153
# The embedded representation beta is also derived by a simple linear
5254
# mixture Y * P, where P is defined in `projector`
5355
# We store P as our transform matrix
54-
self.transform_mat = projector(self.inv_transform_mat)
56+
self.transform_mat = projector(self.inverse_transform_mat)
5557

5658
def transform(self, data):
5759
"""Project data in embedding space."""
5860
# Given Y, we get
5961
# beta = Y * P
6062
return np.matmul(data, self.transform_mat)
6163

62-
def inv_transform(self, embedded_data):
64+
def inverse_transform(self, embedded_data):
6365
"""Project embedded data back to original space."""
6466
# Given beta, we get:
6567
# Y_hat = beta * X
66-
return np.matmul(embedded_data, self.inv_transform_mat)
68+
return np.matmul(embedded_data, self.inverse_transform_mat)
6769

68-
def compression(self, data):
69-
"""embedding compression of data in original space."""
70+
def compress(self, data):
71+
"""Embedding compression of data in original space."""
7072
# Given Y, by combining transform and inverse_transform, we get:
7173
# Y_hat = Y * P * X
72-
return self.inv_transform(self.transform(data))
74+
return self.inverse_transform(self.transform(data))
7375

7476
def score(self, data):
75-
"""Average residual squares after compression in embedding space."""
76-
# Given Y, compute || Y - Y_hat ||^2
77-
return np.sum(np.square(data - self.transform(data)))
77+
"""Average residual squares after compress in embedding space."""
78+
# The R2 score is only interpretable for standardized data
79+
data = StandardScaler().fit_transform(data)
80+
return 1 - np.var(data - self.compress(data), axis=0)

dypac/setup.py

Lines changed: 0 additions & 11 deletions
This file was deleted.

dypac/tests/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)