diff --git a/brainiak/funcalign/fastsrm.py b/brainiak/funcalign/fastsrm.py new file mode 100644 index 000000000..184c09c19 --- /dev/null +++ b/brainiak/funcalign/fastsrm.py @@ -0,0 +1,1741 @@ +"""Fast Shared Response Model (FastSRM) + +The implementation is based on the following publications: + +.. [Richard2019] "Fast Shared Response Model for fMRI data" + H. Richard, L. Martin, A. Pinho, J. Pillow, B. Thirion, 2019 + https://arxiv.org/pdf/1909.12537.pdf +""" + +# Author: Hugo Richard + +import hashlib +import logging +import os + +import numpy as np +import scipy +from joblib import Parallel, delayed + +from brainiak.funcalign.srm import DetSRM +from sklearn.base import BaseEstimator, TransformerMixin +from sklearn.exceptions import NotFittedError +import uuid + +__all__ = [ + "FastSRM", +] + +logger = logging.getLogger(__name__) + + +def get_shape(path): + """Get shape of saved np array + Parameters + ---------- + path: str + path to np array + """ + f = open(path, "rb") + version = np.lib.format.read_magic(f) + shape, fortran_order, dtype = np.lib.format._read_array_header(f, version) + f.close() + return shape + + +def safe_load(data): + """If data is an array returns data else returns np.load(data)""" + if isinstance(data, np.ndarray): + return data + else: + return np.load(data) + + +def safe_encode(img): + if isinstance(img, np.ndarray): + name = hashlib.md5(img.tostring()).hexdigest() + else: + name = hashlib.md5(img.encode()).hexdigest() + return name + + +def assert_non_empty_list(input_list, list_name): + """ + Check that input list is not empty + Parameters + ---------- + input_list: list + list_name: str + Name of the list + """ + if len(input_list) == 0: + raise ValueError("%s is a list of length 0 which is not valid" % + list_name) + + +def assert_array_2axis(array, name_array): + """Check that input is an np array with 2 axes + + Parameters + ---------- + array: np array + name_array: str + Name of the array + """ + + if not isinstance(array, np.ndarray): + raise ValueError("%s should be of type " + "np.ndarray but is of type %s" % + (name_array, type(array))) + + if len(array.shape) != 2: + raise ValueError("%s must have exactly 2 axes " + "but has %i axes" % (name_array, len(array.shape))) + + +def assert_valid_index(indexes, max_value, name_indexes): + """ + Check that indexes are between 0 and max_value and number + of indexes is less than max_value + """ + for i, ind_i in enumerate(indexes): + if ind_i < 0 or ind_i >= max_value: + raise ValueError("Index %i of %s has value %i " + "whereas value should be between 0 and %i" % + (i, name_indexes, ind_i, max_value - 1)) + + +def _check_imgs_list(imgs): + """ + Checks that imgs is a non empty list of elements of the same type + + Parameters + ---------- + + imgs : list + """ + # Check the list is non empty + assert_non_empty_list(imgs, "imgs") + + # Check that all input have same type + for i in range(len(imgs)): + if not isinstance(imgs[i], type(imgs[0])): + raise ValueError("imgs[%i] has type %s whereas " + "imgs[%i] has type %s. " + "This is inconsistent." % + (i, type(imgs[i]), 0, type(imgs[0]))) + + +def _check_imgs_list_list(imgs): + """ + Check input images if they are list of list of arrays + + Parameters + ---------- + + imgs : list of list of array of shape [n_voxels, n_components] + imgs is a list of list of arrays where element i, j of + the array is a numpy array of shape [n_voxels, n_timeframes] that + contains the data of subject i collected during session j. + n_timeframes and n_voxels are assumed to be the same across + subjects + n_timeframes can vary across sessions + Each voxel's timecourse is assumed to have mean 0 and variance 1 + + Returns + ------- + shapes: array + Shape of input images + """ + n_subjects = len(imgs) + + # Check that the number of session is not 0 + assert_non_empty_list(imgs[0], "imgs[%i]" % 0) + + # Check that the number of sessions is the same for all subjects + n_sessions = None + for i in range(len(imgs)): + if n_sessions is None: + n_sessions = len(imgs[i]) + if n_sessions != len(imgs[i]): + raise ValueError("imgs[%i] has length %i whereas imgs[%i] " + "has length %i. All subjects should have " + "the same number of sessions." % + (i, len(imgs[i]), 0, len(imgs[0]))) + + shapes = np.zeros((n_subjects, n_sessions, 2)) + # Run array-level checks + for i in range(len(imgs)): + for j in range(len(imgs[i])): + assert_array_2axis(imgs[i][j], "imgs[%i][%i]" % (i, j)) + shapes[i, j, :] = imgs[i][j].shape + + return shapes + + +def _check_imgs_list_array(imgs): + """ + Check input images if they are list of arrays. + In this case returned images are a list of list of arrays + where element i,j of the array is a numpy array of + shape [n_voxels, n_timeframes] that contains the data of subject i + collected during session j. + + Parameters + ---------- + + imgs : array of str, shape=[n_subjects, n_sessions] + imgs is a list of arrays where element i of the array is + a numpy array of shape [n_voxels, n_timeframes] that contains the + data of subject i (number of sessions is implicitly 1) + n_timeframes and n_voxels are assumed to be the same across + subjects + n_timeframes can vary across sessions + Each voxel's timecourse is assumed to have mean 0 and variance 1 + + Returns + ------- + shapes: array + Shape of input images + new_imgs: list of list of array of shape [n_voxels, n_components] + """ + n_subjects = len(imgs) + n_sessions = 1 + shapes = np.zeros((n_subjects, n_sessions, 2)) + new_imgs = [] + for i in range(len(imgs)): + assert_array_2axis(imgs[i], "imgs[%i]" % i) + shapes[i, 0, :] = imgs[i].shape + new_imgs.append([imgs[i]]) + + return new_imgs, shapes + + +def _check_imgs_array(imgs): + """Check input image if it is an array + + Parameters + ---------- + imgs : array of str, shape=[n_subjects, n_sessions] + Element i, j of the array is a path to the data of subject i + collected during session j. + Data are loaded with numpy.load and expected + shape is [n_voxels, n_timeframes] + n_timeframes and n_voxels are assumed to be the same across + subjects + n_timeframes can vary across sessions + Each voxel's timecourse is assumed to have mean 0 and variance 1 + + Returns + ------- + shapes : array + Shape of input images + """ + assert_array_2axis(imgs, "imgs") + n_subjects, n_sessions = imgs.shape + + shapes = np.zeros((n_subjects, n_sessions, 2)) + for i in range(n_subjects): + for j in range(n_sessions): + if not (isinstance(imgs[i, j], str) or isinstance( + imgs[i, j], np.str_) or isinstance(imgs[i, j], np.str)): + raise ValueError("imgs[%i, %i] is stored using " + "type %s which is not a str" % + (i, j, type(imgs[i, j]))) + shapes[i, j, :] = get_shape(imgs[i, j]) + return shapes + + +def _check_shapes_components(n_components, n_timeframes): + """Check that n_timeframes is greater than number of components""" + + +def _check_shapes_atlas_compatibility(n_voxels, + n_timeframes, + n_components=None, + atlas_shape=None): + if n_components is not None: + if np.sum(n_timeframes) < n_components: + raise ValueError("Total number of timeframes is shorter than " + "number of components (%i < %i)" % + (np.sum(n_timeframes), n_components)) + + if atlas_shape is not None: + n_supervoxels, n_atlas_voxels = atlas_shape + if n_atlas_voxels != n_voxels: + raise ValueError( + "Number of voxels in the atlas is not the same " + "as the number of voxels in input data (%i != %i)" % + (n_atlas_voxels, n_voxels)) + + +def _check_shapes(shapes, + n_components=None, + atlas_shape=None, + ignore_nsubjects=False): + """Check that number of voxels is the same for each subjects. Number of + timeframes can vary between sessions but must be consistent across + subjects + + Parameters + ---------- + shapes : array of shape (n_subjects, n_sessions, 2) + Array of shapes of input images + """ + n_subjects, n_sessions, _ = shapes.shape + + if n_subjects <= 1 and not ignore_nsubjects: + raise ValueError("The number of subjects should be greater than 1") + + n_timeframes_list = [None] * n_sessions + n_voxels = None + for n in range(n_subjects): + for m in range(n_sessions): + if n_timeframes_list[m] is None: + n_timeframes_list[m] = shapes[n, m, 1] + + if n_voxels is None: + n_voxels = shapes[m, n, 0] + + if n_timeframes_list[m] != shapes[n, m, 1]: + raise ValueError("Subject %i Session %i does not have the " + "same number of timeframes " + "as Subject %i Session %i" % (n, m, 0, m)) + + if n_voxels != shapes[n, m, 0]: + raise ValueError("Subject %i Session %i" + " does not have the same number of voxels as " + "Subject %i Session %i." % (n, m, 0, 0)) + + _check_shapes_atlas_compatibility(n_voxels, np.sum(n_timeframes_list), + n_components, atlas_shape) + + +def check_atlas(atlas, n_components=None): + """ Check input atlas + + Parameters + ---------- + atlas : array, shape=[n_supervoxels, n_voxels] or array, shape=[n_voxels] + or str or None + Probabilistic or deterministic atlas on which to project the data + Deterministic atlas is an array of shape [n_voxels,] where values + range from 1 to n_supervoxels. Voxels labelled 0 will be ignored. + If atlas is a str the corresponding array is loaded with numpy.load + and expected shape is (n_voxels,) for a deterministic atlas and + (n_supervoxels, n_voxels) for a probabilistic atlas. + + n_components : int + Number of timecourses of the shared coordinates + + Returns + ------- + shape : array or None + atlas shape + """ + if atlas is None: + return None + + if not (isinstance(atlas, np.ndarray) or isinstance(atlas, str) + or isinstance(atlas, np.str_) or isinstance(atlas, np.str)): + raise ValueError("Atlas is stored using " + "type %s which is neither np.ndarray or str" % + type(atlas)) + + if isinstance(atlas, np.ndarray): + shape = atlas.shape + else: + shape = get_shape(atlas) + + if len(shape) == 1: + # We have a deterministic atlas + atlas_array = safe_load(atlas) + n_voxels = atlas_array.shape[0] + n_supervoxels = len(np.unique(atlas_array)) - 1 + shape = (n_supervoxels, n_voxels) + elif len(shape) != 2: + raise ValueError( + "Atlas has %i axes. It should have either 1 or 2 axes." % + len(shape)) + + n_supervoxels, n_voxels = shape + + if n_supervoxels > n_voxels: + raise ValueError("Number of regions in the atlas is bigger than " + "the number of voxels (%i > %i)" % + (n_supervoxels, n_voxels)) + + if n_components is not None: + if n_supervoxels < n_components: + raise ValueError("Number of regions in the atlas is " + "lower than the number of components " + "(%i < %i)" % (n_supervoxels, n_components)) + return shape + + +def check_imgs(imgs, + n_components=None, + atlas_shape=None, + ignore_nsubjects=False): + """ + Check input images + + Parameters + ---------- + + imgs : array of str, shape=[n_subjects, n_sessions] + Element i, j of the array is a path to the data of subject i + collected during session j. + Data are loaded with numpy.load and expected + shape is [n_voxels, n_timeframes] + n_timeframes and n_voxels are assumed to be the same across + subjects + n_timeframes can vary across sessions + Each voxel's timecourse is assumed to have mean 0 and variance 1 + + imgs can also be a list of list of arrays where element i, j of + the array is a numpy array of shape [n_voxels, n_timeframes] that + contains the data of subject i collected during session j. + + imgs can also be a list of arrays where element i of the array is + a numpy array of shape [n_voxels, n_timeframes] that contains the + data of subject i (number of sessions is implicitly 1) + + Returns + ------- + reshaped_input: bool + True if input had to be reshaped to match the + n_subjects, n_sessions input + new_imgs: list of list of array or np array + input imgs reshaped if it is a list of arrays so that it becomes a + list of list of arrays + shapes: array + Shape of input images + """ + reshaped_input = False + new_imgs = imgs + if isinstance(imgs, list): + _check_imgs_list(imgs) + if isinstance(imgs[0], list): + shapes = _check_imgs_list_list(imgs) + elif isinstance(imgs[0], np.ndarray): + new_imgs, shapes = _check_imgs_list_array(imgs) + reshaped_input = True + else: + raise ValueError( + "Since imgs is a list, it should be a list of list " + "of arrays or a list of arrays but imgs[0] has type %s" % + type(imgs[0])) + elif isinstance(imgs, np.ndarray): + shapes = _check_imgs_array(imgs) + else: + raise ValueError( + "Input imgs should either be a list or an array but has type %s" % + type(imgs)) + + _check_shapes(shapes, n_components, atlas_shape, ignore_nsubjects) + + return reshaped_input, new_imgs, shapes + + +def check_indexes(indexes, name): + if not (indexes is None or isinstance(indexes, list) + or isinstance(indexes, np.ndarray)): + raise ValueError( + "%s should be either a list, an array or None but received type %s" + % (name, type(indexes))) + + +def _check_shared_response_list_of_list(shared_response, n_components, + input_shapes): + + # Check that shared_response is indeed a list of list of arrays + n_subjects = len(shared_response) + n_sessions = None + for i in range(len(shared_response)): + if not isinstance(shared_response[i], list): + raise ValueError("shared_response[0] is a list but " + "shared_response[%i] is not a list " + "this is incompatible." % i) + assert_non_empty_list(shared_response[i], "shared_response[%i]" % i) + if n_sessions is None: + n_sessions = len(shared_response[i]) + elif n_sessions != len(shared_response[i]): + raise ValueError( + "shared_response[%i] has len %i whereas " + "shared_response[0] has len %i. They should " + "have same length" % + (i, len(shared_response[i]), len(shared_response[0]))) + for j in range(len(shared_response[i])): + assert_array_2axis(shared_response[i][j], + "shared_response[%i][%i]" % (i, j)) + + return _check_shared_response_list_sessions([ + np.mean([shared_response[i][j] for i in range(n_subjects)], axis=0) + for j in range(n_sessions) + ], n_components, input_shapes) + + +def _check_shared_response_list_sessions(shared_response, n_components, + input_shapes): + for j in range(len(shared_response)): + assert_array_2axis(shared_response[j], "shared_response[%i]" % j) + if input_shapes is not None: + if shared_response[j].shape[1] != input_shapes[0][j][1]: + raise ValueError( + "Number of timeframes in input images during " + "session %i does not match the number of " + "timeframes during session %i " + "of shared_response (%i != %i)" % + (j, j, shared_response[j].shape[1], input_shapes[0, j, 1])) + if n_components is not None: + if shared_response[j].shape[0] != n_components: + raise ValueError( + "Number of components in " + "shared_response during session %i is " + "different than " + "the number of components of the model (%i != %i)" % + (j, shared_response[j].shape[0], n_components)) + return shared_response + + +def _check_shared_response_list_subjects(shared_response, n_components, + input_shapes): + for i in range(len(shared_response)): + assert_array_2axis(shared_response[i], "shared_response[%i]" % i) + + return _check_shared_response_array(np.mean(shared_response, axis=0), + n_components, input_shapes) + + +def _check_shared_response_array(shared_response, n_components, input_shapes): + assert_array_2axis(shared_response, "shared_response") + if input_shapes is None: + new_input_shapes = None + else: + n_subjects, n_sessions, _ = input_shapes.shape + new_input_shapes = np.zeros((n_subjects, 1, 2)) + new_input_shapes[:, 0, 0] = input_shapes[:, 0, 0] + new_input_shapes[:, 0, 1] = np.sum(input_shapes[:, :, 1], axis=1) + return _check_shared_response_list_sessions([shared_response], + n_components, new_input_shapes) + + +def check_shared_response(shared_response, + aggregate="mean", + n_components=None, + input_shapes=None): + """ + Check that shared response has valid input and turn it into + a session-wise shared response + + Returns + ------- + added_session: bool + True if an artificial sessions was added to match the list of + session input type for shared_response + reshaped_shared_response: list of arrays + shared response (reshaped to match the list of session input) + """ + # Depending on aggregate and shape of input we infer what to do + if isinstance(shared_response, list): + assert_non_empty_list(shared_response, "shared_response") + if isinstance(shared_response[0], list): + if aggregate == "mean": + raise ValueError("self.aggregate has value 'mean' but " + "shared response is a list of list. This is " + "incompatible") + return False, _check_shared_response_list_of_list( + shared_response, n_components, input_shapes) + elif isinstance(shared_response[0], np.ndarray): + if aggregate == "mean": + return False, _check_shared_response_list_sessions( + shared_response, n_components, input_shapes) + else: + return True, _check_shared_response_list_subjects( + shared_response, n_components, input_shapes) + else: + raise ValueError("shared_response is a list but " + "shared_response[0] is neither a list " + "or an array. This is invalid.") + elif isinstance(shared_response, np.ndarray): + return True, _check_shared_response_array(shared_response, + n_components, input_shapes) + else: + raise ValueError("shared_response should be either " + "a list or an array but is of type %s" % + type(shared_response)) + + +def create_temp_dir(temp_dir): + """ + This check whether temp_dir exists and creates dir otherwise + """ + if temp_dir is None: + return None + + if not os.path.exists(temp_dir): + os.makedirs(temp_dir) + else: + raise ValueError("Path %s already exists. " + "When a model is used, filesystem should be cleaned " + "by using the .clean() method" % temp_dir) + + +def reduce_data_single(subject_index, + session_index, + img, + atlas=None, + inv_atlas=None, + low_ram=False, + temp_dir=None): + """Reduce data using given atlas + + Parameters + ---------- + subject_index : int + + session_index : int + + img : str or array + path to data. + Data are loaded with numpy.load and expected shape is + (n_voxels, n_timeframes) + n_timeframes and n_voxels are assumed to be the same across subjects + n_timeframes can vary across sessions + Each voxel's timecourse is assumed to have mean 0 and variance 1 + + img can also be an array of shape (n_voxels, n_timeframes) + + atlas : array, shape=[n_supervoxels, n_voxels] or [n_voxels] or None + Probabilistic or deterministic atlas on which to project the data + Deterministic atlas is an array of shape [n_voxels,] where values + range from 1 to n_supervoxels. Voxels labelled 0 will be ignored. + + inv_atlas : array, shape=[n_voxels, n_supervoxels] or None + Pseudo inverse of the atlas (only for probabilistic atlases) + + temp_dir : str or None + path to dir where temporary results are stored + if None temporary results will be stored in memory. This + can results in memory errors when the number of subjects + and / or sessions is large + + low_ram : bool + if True and temp_dir is not None, reduced_data will be saved on disk + this increases the number of IO but reduces memory complexity when the + number + of subject and number of sessions are large + + Returns + ------- + + reduced_data : array, shape=[n_timeframes, n_supervoxels] + reduced data + """ + # Here we return to the conventions of the paper + data = safe_load(img).T + + n_timeframes, n_voxels = data.shape + + # Here we check that input is normalized + if (np.max(np.abs(np.mean(data, axis=0))) > 1e-6 + or np.max(np.abs(np.var(data, axis=0) - 1))) > 1e-6: + ValueError("Data in imgs[%i, %i] does not have 0 mean and unit \ + variance. If you are using NiftiMasker to mask your data \ + (nilearn) please use standardize=True." % + (subject_index, session_index)) + + if inv_atlas is None and atlas is not None: + atlas_values = np.unique(atlas) + if 0 in atlas_values: + atlas_values = atlas_values[1:] + + reduced_data = np.array( + [np.mean(data[:, atlas == c], axis=1) for c in atlas_values]).T + elif inv_atlas is not None and atlas is None: + # this means that it is a probabilistic atlas + reduced_data = data.dot(inv_atlas) + else: + reduced_data = data + + if low_ram: + name = safe_encode(img) + path = os.path.join(temp_dir, "reduced_data_" + name) + np.save(path, reduced_data) + return path + ".npy" + else: + return reduced_data + + +def reduce_data(imgs, atlas, n_jobs=1, low_ram=False, temp_dir=None): + """Reduce data using given atlas. + Work done in parallel across subjects. + + Parameters + ---------- + + imgs : array of str, shape=[n_subjects, n_sessions] + Element i, j of the array is a path to the data of subject i + collected during session j. + Data are loaded with numpy.load and expected shape is + [n_timeframes, n_voxels] + n_timeframes and n_voxels are assumed to be the same across subjects + n_timeframes can vary across sessions + Each voxel's timecourse is assumed to have mean 0 and variance 1 + + imgs can also be a list of list of arrays where element i, j of + the array is a numpy array of shape [n_voxels, n_timeframes] that + contains the data of subject i collected during session j. + + imgs can also be a list of arrays where element i of the array is + a numpy array of shape [n_voxels, n_timeframes] that contains the + data of subject i (number of sessions is implicitly 1) + + atlas : array, shape=[n_supervoxels, n_voxels] or array, shape=[n_voxels] + or None + Probabilistic or deterministic atlas on which to project the data + Deterministic atlas is an array of shape [n_voxels,] where values + range from 1 to n_supervoxels. Voxels labelled 0 will be ignored. + + n_jobs : integer, optional, default=1 + The number of CPUs to use to do the computation. + -1 means all CPUs, -2 all CPUs but one, and so on. + + temp_dir : str or None + path to dir where temporary results are stored + if None temporary results will be stored in memory. This + can results in memory errors when the number of subjects + and / or sessions is large + + low_ram : bool + if True and temp_dir is not None, reduced_data will be saved on disk + this increases the number of IO but reduces memory complexity when + the number of subject and/or sessions is large + + Returns + ------- + + reduced_data_list : array of str, shape=[n_subjects, n_sessions] + or array, shape=[n_subjects, n_sessions, n_timeframes, n_supervoxels] + Element i, j of the array is a path to the data of subject i collected + during session j. + Data are loaded with numpy.load and expected shape is + [n_timeframes, n_supervoxels] + or Element i, j of the array is the data in array of + shape=[n_timeframes, n_supervoxels] + n_timeframes and n_supervoxels + are assumed to be the same across subjects + n_timeframes can vary across sessions + Each voxel's timecourse is assumed to have mean 0 and variance 1 + """ + if atlas is None: + A = None + A_inv = None + else: + loaded_atlas = safe_load(atlas) + if len(loaded_atlas.shape) == 2: + A = None + A_inv = loaded_atlas.T.dot( + np.linalg.inv(loaded_atlas.dot(loaded_atlas.T))) + else: + A = loaded_atlas + A_inv = None + + n_subjects = len(imgs) + n_sessions = len(imgs[0]) + + reduced_data_list = Parallel(n_jobs=n_jobs)( + delayed(reduce_data_single)(i, + j, + imgs[i][j], + atlas=A, + inv_atlas=A_inv, + low_ram=low_ram, + temp_dir=temp_dir) + for i in range(n_subjects) for j in range(n_sessions)) + + if low_ram: + reduced_data_list = np.reshape(reduced_data_list, + (n_subjects, n_sessions)) + else: + if len(np.array(reduced_data_list).shape) == 1: + reduced_data_list = np.reshape(reduced_data_list, + (n_subjects, n_sessions)) + else: + n_timeframes, n_supervoxels = np.array(reduced_data_list).shape[1:] + reduced_data_list = np.reshape( + reduced_data_list, + (n_subjects, n_sessions, n_timeframes, n_supervoxels)) + + return reduced_data_list + + +def _reduced_space_compute_shared_response(reduced_data_list, + reduced_basis_list, + n_components=50): + """Compute shared response with basis fixed in reduced space + + Parameters + ---------- + + reduced_data_list : array of str, shape=[n_subjects, n_sessions] + or array, shape=[n_subjects, n_sessions, n_timeframes, n_supervoxels] + Element i, j of the array is a path to the data of subject i + collected during session j. + Data are loaded with numpy.load and expected shape is + [n_timeframes, n_supervoxels] + or Element i, j of the array is the data in array of + shape=[n_timeframes, n_supervoxels] + n_timeframes and n_supervoxels are + assumed to be the same across subjects + n_timeframes can vary across sessions + Each voxel's timecourse is assumed to have mean 0 and variance 1 + + reduced_basis_list : None or list of array, element i has + shape=[n_components, n_supervoxels] + each subject's reduced basis + if None the basis will be generated on the fly + + n_components : int or None + number of components + + Returns + ------- + + shared_response_list : list of array, element i has + shape=[n_timeframes, n_components] + shared response, element i is the shared response during session i + + """ + n_subjects, n_sessions = reduced_data_list.shape[:2] + + s = [None] * n_sessions + + # This is just to check that all subjects have same number of + # timeframes in a given session + for n in range(n_subjects): + for m in range(n_sessions): + data_nm = safe_load(reduced_data_list[n][m]) + n_timeframes, n_supervoxels = data_nm.shape + + if reduced_basis_list is None: + reduced_basis_list = [] + for subject in range(n_subjects): + q = np.eye(n_components, n_supervoxels) + reduced_basis_list.append(q) + + basis_n = reduced_basis_list[n] + if s[m] is None: + s[m] = data_nm.dot(basis_n.T) + else: + s[m] = s[m] + data_nm.dot(basis_n.T) + + for m in range(n_sessions): + s[m] = s[m] / float(n_subjects) + + return s + + +def _compute_and_save_corr_mat(img, shared_response, temp_dir): + """computes correlation matrix and stores it + + Parameters + ---------- + img : str + path to data. + Data are loaded with numpy.load and expected shape is + [n_timeframes, n_voxels] + n_timeframes and n_voxels are assumed to be the same across subjects + n_timeframes can vary across sessions + Each voxel's timecourse is assumed to have mean 0 and variance 1 + + shared_response : array, shape=[n_timeframes, n_components] + shared response + """ + data = safe_load(img).T + name = safe_encode(img) + path = os.path.join(temp_dir, "corr_mat_" + name) + np.save(path, shared_response.T.dot(data)) + + +def _compute_and_save_subject_basis(subject_number, sessions, temp_dir): + """computes correlation matrix for all sessions + + Parameters + ---------- + + subject_number: int + Number that identifies the subject. Basis will be stored in + [temp_dir]/basis_[subject_number].npy + + sessions : array of str + Element i of the array is a path to the data collected during + session i. + Data are loaded with numpy.load and expected shape is + [n_timeframes, n_voxels] + n_timeframes and n_voxels are assumed to be the same across subjects + n_timeframes can vary across sessions + Each voxel's timecourse is assumed to have mean 0 and variance + + temp_dir : str or None + path to dir where temporary results are stored + if None temporary results will be stored in memory. This + can results in memory errors when the number of subjects + and / or sessions is large + + Returns + ------- + + basis: array, shape=[n_component, n_voxels] or str + basis of subject [subject_number] or path to this basis + """ + corr_mat = None + for session in sessions: + name = safe_encode(session) + path = os.path.join(temp_dir, "corr_mat_" + name + ".npy") + if corr_mat is None: + corr_mat = np.load(path) + else: + corr_mat += np.load(path) + os.remove(path) + basis_i = _compute_subject_basis(corr_mat) + path = os.path.join(temp_dir, "basis_%i" % subject_number) + np.save(path, basis_i) + return path + ".npy" + + +def _compute_subject_basis(corr_mat): + """From correlation matrix between shared response and subject data, + Finds subject's basis + + Parameters + ---------- + + corr_mat: array, shape=[n_component, n_voxels] + or shape=[n_components, n_supervoxels] + correlation matrix between shared response and subject data or + subject reduced data + element k, v is given by S.T.dot(X_i) where S is the shared response + and X_i the data of subject i. + + Returns + ------- + + basis: array, shape=[n_components, n_voxels] + or shape=[n_components, n_supervoxels] + basis of subject or reduced_basis of subject + """ + U, _, V = scipy.linalg.svd(corr_mat, full_matrices=False) + return U.dot(V) + + +def fast_srm(reduced_data_list, + n_iter=10, + n_components=None, + low_ram=False, + seed=0): + """Computes shared response and basis in reduced space + + Parameters + ---------- + + reduced_data_list : array, shape=[n_subjects, n_sessions] + or array, shape=[n_subjects, n_sessions, n_timeframes, n_supervoxels] + Element i, j of the array is a path to the data of subject i + collected during session j. + Data are loaded with numpy.load and expected + shape is [n_timeframes, n_supervoxels] + or Element i, j of the array is the data in array of + shape=[n_timeframes, n_supervoxels] + n_timeframes and n_supervoxels are + assumed to be the same across subjects + n_timeframes can vary across sessions + Each voxel's timecourse is assumed to have mean 0 and variance 1 + + n_iter : int + Number of iterations performed + + n_components : int or None + number of components + + Returns + ------- + + shared_response_list : list of array, element i has + shape=[n_timeframes, n_components] + shared response, element i is the shared response during session i + """ + if low_ram: + return lowram_srm(reduced_data_list, n_iter, n_components) + else: + # We need to switch data to DetSRM format + n_subjects, n_sessions = reduced_data_list.shape[:2] + # We store the correspondence between timeframes and session + timeframes_slices = [] + current_j = 0 + for j in range(n_sessions): + timeframes_slices.append( + slice(current_j, current_j + len(reduced_data_list[0, j]))) + current_j = len(reduced_data_list[0][j]) + # Now we can concatenate everything + X = [ + np.concatenate(reduced_data_list[i], axis=0).T + for i in range(n_subjects) + ] + + srm = DetSRM(n_iter=n_iter, features=n_components, rand_seed=seed) + srm.fit(X) + + # SRM gives a list of data projected in shared space + # we get the shared response by averaging those + concatenated_s = np.mean(srm.transform(X), axis=0).T + + # Let us return the shared response sliced by sessions + return [concatenated_s[i] for i in timeframes_slices] + + +def lowram_srm(reduced_data_list, n_iter=10, n_components=None): + """Computes shared response and basis in reduced space + + Parameters + ---------- + + reduced_data_list : array of str, shape=[n_subjects, n_sessions] + Element i, j of the array is a path to the data of subject i + collected during session j. + Data are loaded with numpy.load and expected + shape is [n_timeframes, n_supervoxels] + n_timeframes and n_supervoxels are + assumed to be the same across subjects + n_timeframes can vary across sessions + Each voxel's timecourse is assumed to have mean 0 and variance 1 + + n_iter : int + Number of iterations performed + + n_components : int or None + number of components + + Returns + ------- + + shared_response_list : list of array, element i has + shape=[n_timeframes, n_components] + shared response, element i is the shared response during session i + """ + + n_subjects, n_sessions = reduced_data_list.shape[:2] + shared_response = _reduced_space_compute_shared_response( + reduced_data_list, None, n_components) + + reduced_basis = [None] * n_subjects + for _ in range(n_iter): + for n in range(n_subjects): + cov = None + for m in range(n_sessions): + data_nm = np.load(reduced_data_list[n, m]) + if cov is None: + cov = shared_response[m].T.dot(data_nm) + else: + cov += shared_response[m].T.dot(data_nm) + reduced_basis[n] = _compute_subject_basis(cov) + + shared_response = _reduced_space_compute_shared_response( + reduced_data_list, reduced_basis, n_components) + + return shared_response + + +def _compute_basis_subject_online(sessions, shared_response_list): + """Computes subject's basis with shared response fixed + + Parameters + ---------- + + sessions : array of str + Element i of the array is a path to the data + collected during session i. + Data are loaded with numpy.load and expected shape is + [n_timeframes, n_voxels] + n_timeframes and n_voxels are assumed to be the same across subjects + n_timeframes can vary across sessions + Each voxel's timecourse is assumed to have mean 0 and variance 1 + + shared_response_list : list of array, element i has + shape=[n_timeframes, n_components] + shared response, element i is the shared response during session i + + Returns + ------- + + basis: array, shape=[n_components, n_voxels] + basis + """ + + basis_i = None + i = 0 + for session in sessions: + data = safe_load(session).T + if basis_i is None: + basis_i = shared_response_list[i].T.dot(data) + else: + basis_i += shared_response_list[i].T.dot(data) + i += 1 + del data + return _compute_subject_basis(basis_i) + + +def _compute_shared_response_online_single(subjects, basis_list, temp_dir, + subjects_indexes, aggregate): + """Computes shared response during one session with basis fixed + + Parameters + ---------- + + subjects : array of str + Element i of the array is a path to the data of subject i. + Data are loaded with numpy.load and expected shape is + [n_timeframes, n_voxels] + n_timeframes and n_voxels are assumed to be the same across subjects + n_timeframes can vary across sessions + Each voxel's timecourse is assumed to have mean 0 and variance 1 + + basis_list : None or list of array, element i has + shape=[n_components, n_voxels] + basis of all subjects, element i is the basis of subject i + + temp_dir : None or str + path to basis folder where file basis_%i.npy contains the basis of + subject i + + subjects_indexes : list of int or None + list of indexes corresponding to the subjects to use to compute + shared response + + aggregate: str or None, default="mean" + if "mean": returns the mean shared response S from all subjects + if None: returns the subject-specific response in shared space S_i + + Returns + ------- + + shared_response : array, shape=[n_timeframes, n_components] or list + shared response + """ + n = 0 + if aggregate == "mean": + shared_response = None + if aggregate is None: + shared_response = [] + + for k, i in enumerate(subjects_indexes): + subject = subjects[k] + # Transpose to be consistent with paper + data = safe_load(subject).T + if temp_dir is None: + basis_i = basis_list[i] + else: + basis_i = np.load(os.path.join(temp_dir, "basis_%i.npy" % i)) + + if aggregate == "mean": + if shared_response is None: + shared_response = data.dot(basis_i.T) + else: + shared_response += data.dot(basis_i.T) + n += 1 + + if aggregate is None: + shared_response.append(data.dot(basis_i.T)) + + if aggregate is None: + return shared_response + + if aggregate == "mean": + return shared_response / float(n) + + +def _compute_shared_response_online(imgs, basis_list, temp_dir, n_jobs, + subjects_indexes, aggregate): + """Computes shared response with basis fixed + + Parameters + ---------- + + imgs : array of str, shape=[n_subjects, n_sessions] + Element i, j of the array is a path to the data of subject i + collected during session j. + Data are loaded with numpy.load and expected shape is + [n_timeframes, n_voxels] + n_timeframes and n_voxels are assumed to be the same across subjects + n_timeframes can vary across sessions + Each voxel's timecourse is assumed to have mean 0 and variance 1 + + imgs can also be a list of list of arrays where element i, j of + the array is a numpy array of shape [n_voxels, n_timeframes] that + contains the data of subject i collected during session j. + + basis_list : None or list of array, element i has + shape=[n_components, n_voxels] + basis of all subjects, element i is the basis of subject i + + temp_dir : None or str + path to basis folder where file basis_%i.npy contains the basis of + subject i + + n_jobs : integer, optional, default=1 + The number of CPUs to use to do the computation. + -1 means all CPUs, -2 all CPUs but one, and so on. + + subjects_indexes : list or None + list of indexes corresponding to the subjects to use to compute + shared response + + aggregate: str or None, default="mean" + if "mean": returns the mean shared response S from all subjects + if None: returns the subject-specific response in shared space S_i + + Returns + ------- + + shared_response_list : list of array or list of list of array + shared response, element i is the shared response during session i + or element i, j is the shared response of subject i during session j + """ + + n_subjects = len(subjects_indexes) + n_sessions = len(imgs[0]) + + shared_response_list = Parallel(n_jobs=n_jobs)( + delayed(_compute_shared_response_online_single) + ([imgs[i][j] for i in range(n_subjects)], basis_list, temp_dir, + subjects_indexes, aggregate) for j in range(n_sessions)) + + if aggregate is None: + shared_response_list = [[ + shared_response_list[j][i].T for j in range(n_sessions) + ] for i in range(n_subjects)] + + if aggregate == "mean": + shared_response_list = [ + shared_response_list[j].T for j in range(n_sessions) + ] + + return shared_response_list + + +class FastSRM(BaseEstimator, TransformerMixin): + """SRM decomposition using a very low amount of memory and \ +computational power thanks to the use of an atlas \ +as described in [Richard2019]_. + + Given multi-subject data, factorize it as a shared response S \ +among all subjects and an orthogonal transform (basis) W per subject: + + .. math:: X_i \\approx W_i S, \\forall i=1 \\dots N + + Parameters + ---------- + + atlas : array, shape=[n_supervoxels, n_voxels] or array,\ +shape=[n_voxels] or str or None, default=None + Probabilistic or deterministic atlas on which to project the data. \ +Deterministic atlas is an array of shape [n_voxels,] \ +where values range from 1 \ +to n_supervoxels. Voxels labelled 0 will be ignored. If atlas is a str the \ +corresponding array is loaded with numpy.load and expected shape \ +is (n_voxels,) for a deterministic atlas and \ +(n_supervoxels, n_voxels) for a probabilistic atlas. + + n_components : int + Number of timecourses of the shared coordinates + + n_iter : int + Number of iterations to perform + + temp_dir : str or None + Path to dir where temporary results are stored. If None \ +temporary results will be stored in memory. This can results in memory \ +errors when the number of subjects and/or sessions is large + + low_ram : bool + If True and temp_dir is not None, reduced_data will be saved on \ +disk. This increases the number of IO but reduces memory complexity when \ +the number of subject and/or sessions is large + + seed : int + Seed used for random sampling. + + n_jobs : int, optional, default=1 + The number of CPUs to use to do the computation. \ +-1 means all CPUs, -2 all CPUs but one, and so on. + + verbose : bool or "warn" + If True, logs are enabled. If False, logs are disabled. \ +If "warn" only warnings are printed. + + aggregate: str or None, default="mean" + If "mean", shared_response is the mean shared response \ +from all subjects. If None, shared_response contains all \ +subject-specific responses in shared space + + Attributes + ---------- + + `basis_list`: list of array, element i has \ +shape=[n_components, n_voxels] or list of str + - if basis is a list of array, element i is the basis of subject i + - if basis is a list of str, element i is the path to the basis \ +of subject i that is loaded with np.load yielding an array of \ +shape [n_components, n_voxels]. + + Note that any call to the clean method erases this attribute + + Note + ----- + + **References:** + H. Richard, L. Martin, A. Pinho, J. Pillow, B. Thirion, 2019: \ +Fast shared response model for fMRI data (https://arxiv.org/pdf/1909.12537.pdf) + + """ + def __init__(self, + atlas=None, + n_components=20, + n_iter=100, + temp_dir=None, + low_ram=False, + seed=None, + n_jobs=1, + verbose="warn", + aggregate="mean"): + + self.seed = seed + self.n_jobs = n_jobs + self.verbose = verbose + self.n_components = n_components + self.n_iter = n_iter + self.atlas = atlas + + if aggregate is not None and aggregate != "mean": + raise ValueError("aggregate can have only value mean or None") + + self.aggregate = aggregate + + self.basis_list = None + + if temp_dir is None: + if self.verbose == "warn" or self.verbose is True: + logger.warning("temp_dir has value None. " + "All basis (spatial maps) and reconstructed " + "data will therefore be kept in memory." + "This can lead to memory errors when the " + "number of subjects " + "and/or sessions is large.") + self.temp_dir = None + self.low_ram = False + + if temp_dir is not None: + self.temp_dir = os.path.join(temp_dir, + "fastsrm" + str(uuid.uuid4())) + self.low_ram = low_ram + + def clean(self): + """This erases temporary files and basis_list attribute to \ +free memory. This method should be called when fitted model \ +is not needed anymore. + """ + if self.temp_dir is not None: + if os.path.exists(self.temp_dir): + for root, dirs, files in os.walk(self.temp_dir, topdown=False): + for name in files: + os.remove(os.path.join(root, name)) + os.rmdir(self.temp_dir) + + if self.basis_list is not None: + self.basis_list is None + + def fit(self, imgs): + """Computes basis across subjects from input imgs + + Parameters + ---------- + imgs : array of str, shape=[n_subjects, n_sessions] or \ +list of list of arrays or list of arrays + Element i, j of the array is a path to the data of subject i \ +collected during session j. Data are loaded with numpy.load and expected \ +shape is [n_voxels, n_timeframes] n_timeframes and n_voxels are assumed \ +to be the same across subjects n_timeframes can vary across sessions. \ +Each voxel's timecourse is assumed to have mean 0 and variance 1 + + imgs can also be a list of list of arrays where element i, j \ +of the array is a numpy array of shape [n_voxels, n_timeframes] \ +that contains the data of subject i collected during session j. + + imgs can also be a list of arrays where element i \ +of the array is a numpy array of shape [n_voxels, n_timeframes] \ +that contains the data of subject i (number of sessions is implicitly 1) + + Returns + ------- + self : object + Returns the instance itself. Contains attributes listed \ +at the object level. + """ + atlas_shape = check_atlas(self.atlas, self.n_components) + reshaped_input, imgs, shapes = check_imgs( + imgs, n_components=self.n_components, atlas_shape=atlas_shape) + self.clean() + create_temp_dir(self.temp_dir) + + if self.verbose is True: + logger.info("[FastSRM.fit] Reducing data") + + reduced_data = reduce_data(imgs, + atlas=self.atlas, + n_jobs=self.n_jobs, + low_ram=self.low_ram, + temp_dir=self.temp_dir) + + if self.verbose is True: + logger.info("[FastSRM.fit] Finds shared " + "response using reduced data") + + shared_response_list = fast_srm(reduced_data, + n_iter=self.n_iter, + n_components=self.n_components, + low_ram=self.low_ram, + seed=self.seed) + + if self.verbose is True: + logger.info("[FastSRM.fit] Finds basis using " + "full data and shared response") + + if self.n_jobs == 1: + basis = [] + for i, sessions in enumerate(imgs): + basis_i = _compute_basis_subject_online( + sessions, shared_response_list) + if self.temp_dir is None: + basis.append(basis_i) + else: + path = os.path.join(self.temp_dir, "basis_%i" % i) + np.save(path, basis_i) + basis.append(path + ".npy") + del basis_i + else: + if self.temp_dir is None: + basis = Parallel(n_jobs=self.n_jobs)( + delayed(_compute_basis_subject_online)( + sessions, shared_response_list) for sessions in imgs) + else: + Parallel(n_jobs=self.n_jobs)( + delayed(_compute_and_save_corr_mat)( + imgs[i][j], shared_response_list[j], self.temp_dir) + for j in range(len(imgs[0])) for i in range(len(imgs))) + + basis = Parallel(n_jobs=self.n_jobs)( + delayed(_compute_and_save_subject_basis)(i, sessions, + self.temp_dir) + for i, sessions in enumerate(imgs)) + + self.basis_list = basis + return self + + def fit_transform(self, imgs, subjects_indexes=None): + """Computes basis across subjects and shared response from input imgs + return shared response. + + Parameters + ---------- + imgs : array of str, shape=[n_subjects, n_sessions] or \ +list of list of arrays or list of arrays + Element i, j of the array is a path to the data of subject i \ +collected during session j. Data are loaded with numpy.load and expected \ +shape is [n_voxels, n_timeframes] n_timeframes and n_voxels are assumed \ +to be the same across subjects n_timeframes can vary across sessions. \ +Each voxel's timecourse is assumed to have mean 0 and variance 1 + + imgs can also be a list of list of arrays where element i, j \ +of the array is a numpy array of shape [n_voxels, n_timeframes] \ +that contains the data of subject i collected during session j. + + imgs can also be a list of arrays where element i \ +of the array is a numpy array of shape [n_voxels, n_timeframes] \ +that contains the data of subject i (number of sessions is implicitly 1) + + subjects_indexes : list or None: + if None imgs[i] will be transformed using basis_list[i]. \ +Otherwise imgs[i] will be transformed using basis_list[subjects_index[i]] + + Returns + -------- + shared_response : list of arrays, list of list of arrays or array + - if imgs is a list of array and self.aggregate="mean": shared \ +response is an array of shape (n_components, n_timeframes) + - if imgs is a list of array and self.aggregate=None: shared \ +response is a list of array, element i is the projection of data of \ +subject i in shared space. + - if imgs is an array or a list of list of array and \ +self.aggregate="mean": shared response is a list of array, \ +element j is the shared response during session j + - if imgs is an array or a list of list of array and \ +self.aggregate=None: shared response is a list of list of array, \ +element i, j is the projection of data of subject i collected \ +during session j in shared space. + """ + self.fit(imgs) + return self.transform(imgs, subjects_indexes=subjects_indexes) + + def transform(self, imgs, subjects_indexes=None): + """From data in imgs and basis from training data, + computes shared response. + + Parameters + ---------- + imgs : array of str, shape=[n_subjects, n_sessions] or \ +list of list of arrays or list of arrays + Element i, j of the array is a path to the data of subject i \ +collected during session j. Data are loaded with numpy.load and expected \ +shape is [n_voxels, n_timeframes] n_timeframes and n_voxels are assumed \ +to be the same across subjects n_timeframes can vary across sessions. \ +Each voxel's timecourse is assumed to have mean 0 and variance 1 + + imgs can also be a list of list of arrays where element i, j \ +of the array is a numpy array of shape [n_voxels, n_timeframes] \ +that contains the data of subject i collected during session j. + + imgs can also be a list of arrays where element i \ +of the array is a numpy array of shape [n_voxels, n_timeframes] \ +that contains the data of subject i (number of sessions is implicitly 1) + + subjects_indexes : list or None: + if None imgs[i] will be transformed using basis_list[i]. \ +Otherwise imgs[i] will be transformed using basis[subjects_index[i]] + + Returns + -------- + shared_response : list of arrays, list of list of arrays or array + - if imgs is a list of array and self.aggregate="mean": shared \ +response is an array of shape (n_components, n_timeframes) + - if imgs is a list of array and self.aggregate=None: shared \ +response is a list of array, element i is the projection of data of \ +subject i in shared space. + - if imgs is an array or a list of list of array and \ +self.aggregate="mean": shared response is a list of array, \ +element j is the shared response during session j + - if imgs is an array or a list of list of array and \ +self.aggregate=None: shared response is a list of list of array, \ +element i, j is the projection of data of subject i collected \ +during session j in shared space. + """ + aggregate = self.aggregate + if self.basis_list is None: + raise NotFittedError("The model fit has not been run yet.") + + atlas_shape = check_atlas(self.atlas, self.n_components) + reshaped_input, imgs, shapes = check_imgs( + imgs, n_components=self.n_components, atlas_shape=atlas_shape) + check_indexes(subjects_indexes, "subjects_indexes") + if subjects_indexes is None: + subjects_indexes = np.arange(len(imgs)) + else: + subjects_indexes = np.array(subjects_indexes) + + # Transform specific checks + if len(subjects_indexes) < len(imgs): + raise ValueError("Input data imgs has len %i whereas " + "subject_indexes has len %i. " + "The number of basis used to compute " + "the shared response should be equal " + "to the number of subjects in imgs" % + (len(imgs), len(subjects_indexes))) + + assert_valid_index(subjects_indexes, len(self.basis_list), + "subjects_indexes") + + shared_response = _compute_shared_response_online( + imgs, self.basis_list, self.temp_dir, self.n_jobs, + subjects_indexes, aggregate) + + # If shared response has only 1 session we need to reshape it + if reshaped_input: + if aggregate == "mean": + shared_response = shared_response[0] + if aggregate is None: + shared_response = [ + shared_response[i][0] for i in range(len(subjects_indexes)) + ] + + return shared_response + + def inverse_transform( + self, + shared_response, + subjects_indexes=None, + sessions_indexes=None, + ): + """From shared response and basis from training data + reconstruct subject's data + + Parameters + ---------- + + shared_response : list of arrays, list of list of arrays or array + - if imgs is a list of array and self.aggregate="mean": shared \ +response is an array of shape (n_components, n_timeframes) + - if imgs is a list of array and self.aggregate=None: shared \ +response is a list of array, element i is the projection of data of \ +subject i in shared space. + - if imgs is an array or a list of list of array and \ +self.aggregate="mean": shared response is a list of array, \ +element j is the shared response during session j + - if imgs is an array or a list of list of array and \ +self.aggregate=None: shared response is a list of list of array, \ +element i, j is the projection of data of subject i collected \ +during session j in shared space. + + subjects_indexes : list or None + if None reconstructs data of all subjects used during train. \ +Otherwise reconstructs data of subjects specified by subjects_indexes. + + sessions_indexes : list or None + if None reconstructs data of all sessions. \ +Otherwise uses reconstructs data of sessions specified by sessions_indexes. + + Returns + ------- + reconstructed_data: list of list of arrays or list of arrays + - if reconstructed_data is a list of list : element i, j is \ +the reconstructed data for subject subjects_indexes[i] and \ +session sessions_indexes[j] as an np array of shape n_voxels, \ +n_timeframes + - if reconstructed_data is a list : element i is the \ +reconstructed data for subject \ +subject_indexes[i] as an np array of shape n_voxels, n_timeframes + + """ + added_session, shared = check_shared_response( + shared_response, self.aggregate, n_components=self.n_components) + n_subjects = len(self.basis_list) + n_sessions = len(shared) + + for j in range(n_sessions): + assert_array_2axis(shared[j], "shared_response[%i]" % j) + + check_indexes(subjects_indexes, "subjects_indexes") + check_indexes(sessions_indexes, "sessions_indexes") + + if subjects_indexes is None: + subjects_indexes = np.arange(n_subjects) + else: + subjects_indexes = np.array(subjects_indexes) + + assert_valid_index(subjects_indexes, n_subjects, "subjects_indexes") + + if sessions_indexes is None: + sessions_indexes = np.arange(len(shared)) + else: + sessions_indexes = np.array(sessions_indexes) + + assert_valid_index(sessions_indexes, n_sessions, "sessions_indexes") + + data = [] + for i in subjects_indexes: + data_ = [] + basis_i = safe_load(self.basis_list[i]) + if added_session: + data.append(basis_i.T.dot(shared[0])) + else: + for j in sessions_indexes: + data_.append(basis_i.T.dot(shared[j])) + data.append(data_) + return data + + def add_subjects(self, imgs, shared_response): + """ Add subjects to the current fit. Each new basis will be \ +appended at the end of the list of basis (which can \ +be accessed using self.basis) + + Parameters + ---------- + + imgs : array of str, shape=[n_subjects, n_sessions] or \ +list of list of arrays or list of arrays + Element i, j of the array is a path to the data of subject i \ +collected during session j. Data are loaded with numpy.load and expected \ +shape is [n_voxels, n_timeframes] n_timeframes and n_voxels are assumed \ +to be the same across subjects n_timeframes can vary across sessions. \ +Each voxel's timecourse is assumed to have mean 0 and variance 1 + + imgs can also be a list of list of arrays where element i, j \ +of the array is a numpy array of shape [n_voxels, n_timeframes] \ +that contains the data of subject i collected during session j. + + imgs can also be a list of arrays where element i \ +of the array is a numpy array of shape [n_voxels, n_timeframes] \ +that contains the data of subject i (number of sessions is implicitly 1) + + shared_response : list of arrays, list of list of arrays or array + - if imgs is a list of array and self.aggregate="mean": shared \ +response is an array of shape (n_components, n_timeframes) + - if imgs is a list of array and self.aggregate=None: shared \ +response is a list of array, element i is the projection of data of \ +subject i in shared space. + - if imgs is an array or a list of list of array and \ +self.aggregate="mean": shared response is a list of array, \ +element j is the shared response during session j + - if imgs is an array or a list of list of array and \ +self.aggregate=None: shared response is a list of list of array, \ +element i, j is the projection of data of subject i collected \ +during session j in shared space. + """ + atlas_shape = check_atlas(self.atlas, self.n_components) + reshaped_input, imgs, shapes = check_imgs( + imgs, + n_components=self.n_components, + atlas_shape=atlas_shape, + ignore_nsubjects=True) + + _, shared_response_list = check_shared_response( + shared_response, + n_components=self.n_components, + aggregate=self.aggregate, + input_shapes=shapes) + + # we need to transpose shared_response_list to be consistent with + # other functions + shared_response_list = [ + shared_response_list[j].T for j in range(len(shared_response_list)) + ] + + if self.n_jobs == 1: + basis = [] + for i, sessions in enumerate(imgs): + basis_i = _compute_basis_subject_online( + sessions, shared_response_list) + if self.temp_dir is None: + basis.append(basis_i) + else: + path = os.path.join( + self.temp_dir, "basis_%i" % (len(self.basis_list) + i)) + np.save(path, basis_i) + basis.append(path + ".npy") + del basis_i + else: + if self.temp_dir is None: + basis = Parallel(n_jobs=self.n_jobs)( + delayed(_compute_basis_subject_online)( + sessions, shared_response_list) for sessions in imgs) + else: + Parallel(n_jobs=self.n_jobs)( + delayed(_compute_and_save_corr_mat)( + imgs[i][j], shared_response_list[j], self.temp_dir) + for j in range(len(imgs[0])) for i in range(len(imgs))) + + basis = Parallel(n_jobs=self.n_jobs)( + delayed(_compute_and_save_subject_basis)( + len(self.basis_list) + i, sessions, self.temp_dir) + for i, sessions in enumerate(imgs)) + + self.basis_list += basis diff --git a/examples/funcalign/FastSRM_encoding_experiment.ipynb b/examples/funcalign/FastSRM_encoding_experiment.ipynb new file mode 100644 index 000000000..4f4afe0d1 --- /dev/null +++ b/examples/funcalign/FastSRM_encoding_experiment.ipynb @@ -0,0 +1,586 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Encoding experiment using the fast shared response model (FastSRM)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In this notebook we introduce some basic functionalities of FastSRM and compare its performance to another implementation of SRM (ProbSRM). We present an encoding experiment that shows how fmri data of train subjects can be used to predict fmri data of test subjects (after training).\n", + "\n", + "More precisely, let us assume we have 2 groups of subjects (train, test) exposed to 2 similar but different naturalistic stimuli (session 1 and session 2) while we record their brain activity using an fMRI scanner. \n", + "\n", + "Our experiment follows the following steps:\n", + "\n", + "- Align train subjects: We train an alignment model on session 1 using train subjects\n", + "- Align test subjects: Using data of test subjects during session 1 and the previously fitted model we add test subjects to the model\n", + "- Predict test subjects data from train subjects: We use the model to align train subjects during session 2. From the aligned data (shared response) we predict the data of test subjects during session 2.\n", + "- Measure performance: We report the R2 score between predicted and actual data." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Real fMRI data" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We'll download a publicly available fMRI dataset and run SRM on these data. This dataset comprises fMRI data for 20 subjects listening to the spoken story Pie Man by Jim O'Grady (archived on the Princeton DataSpace). Note that we use 20 subjects to minimize computational demands for this tutorial and recommend larger sample sizes for publication. The gzipped data archive file is ~1.5 GB in size, and may take a couple minutes to download and unzip. The functional data were acquired with 3 x 3 x 4 mm voxels and 1.5 s TRs. Data were preprocessed using fMRIPrep (Esteban et al., 2018), including spatial normalization to MNI space (the T1-weighted ICBM 2009c Nonlinear Asymmetric template). The data were then smoothed to 6 mm FWHM using AFNI's 3dBlurToFWHM (Cox, 1996). The following confound variables were regressed out using 3dTproject: six head motion parameters (and their first derivatives), framewise displacement, six prinicipal components from an anatomical mask of cerebrospinal fluid (CSF) and white matter, sine/cosine bases for high-pass filtering (cutoff: 0.00714 Hz; 140 s), as well as a linear and quadratic trends. The anatomical template and a brain mask (i.e., excluding skull) are supplied as well. These have been resampled to match resolution of the functional images." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/hugo/Research/brainiak/venv/lib/python3.6/site-packages/sklearn/externals/joblib/__init__.py:15: DeprecationWarning: sklearn.externals.joblib is deprecated in 0.21 and will be removed in 0.23. Please import this functionality directly from joblib, which can be installed with: pip install joblib. If this warning is raised when loading pickled models, you may need to re-serialize those models with scikit-learn 0.21+.\n", + " warnings.warn(msg, category=DeprecationWarning)\n" + ] + } + ], + "source": [ + "import wget\n", + "from time import time\n", + "from glob import glob\n", + "from os.path import join\n", + "import nibabel\n", + "from nilearn.image import new_img_like\n", + "from nilearn.input_data import NiftiMasker, MultiNiftiMasker\n", + "import numpy as np\n", + "from joblib import Parallel, delayed\n", + "from nilearn.plotting import plot_stat_map\n", + "import matplotlib.pyplot as plt\n", + "from IPython.display import clear_output\n", + "import tarfile" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Progress: [################################################################################] 100.0%\n" + ] + }, + { + "data": { + "text/plain": [ + "'pieman_isc'" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Download data tarball from Princeton DataSpace (about 1 Gb to download)\n", + "t0 = time()\n", + "def update_progress(current, total, width=0):\n", + " bar_length = 80\n", + " progress = current / total\n", + " if isinstance(progress, int):\n", + " progress = float(progress)\n", + " if not isinstance(progress, float):\n", + " progress = 0\n", + " if progress < 0:\n", + " progress = 0\n", + " if progress >= 1:\n", + " progress = 1\n", + "\n", + " block = int(round(bar_length * progress))\n", + " clear_output(wait = True)\n", + " text = \"Progress: [{0}] {1:.1f}%\".format( \"#\" * block + \"-\" * (bar_length - block), progress * 100)\n", + " print(text)\n", + " \n", + "wget.download('https://dataspace.princeton.edu/jspui/bitstream/88435/dsp01dz010s83s/6/pieman-isc-tutorial.tgz', \n", + " 'pieman_isc', bar=update_progress)\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Done in 801.00 seconds\n" + ] + } + ], + "source": [ + "tar = tarfile.open(\"pieman_isc\", \"r:gz\")\n", + "tar.extractall()\n", + "tar.close()\n", + "print(\"Done in %.2f seconds\" % (time() - t0))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Step 1: Mask and save the data\n", + "- We split our data into two sessions (in order to be able to perform our encoding experiment)\n", + "- We mask the data and save them into .npy file\n", + "\n", + "### Note:\n", + "We use ``detrend=True`` and ``standardize=True`` in the ``NiftiMasker``. This is standard fMRI preprocessing and is needed for FastSRM to work." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "[Parallel(n_jobs=4)]: Using backend LokyBackend with 4 concurrent workers.\n", + "[Parallel(n_jobs=4)]: Done 5 tasks | elapsed: 15.1s\n", + "[Parallel(n_jobs=4)]: Done 10 tasks | elapsed: 22.4s\n", + "[Parallel(n_jobs=4)]: Done 16 out of 20 | elapsed: 29.8s remaining: 7.5s\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Done in 37.37 seconds\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "[Parallel(n_jobs=4)]: Done 20 out of 20 | elapsed: 37.4s finished\n" + ] + } + ], + "source": [ + "t0 = time()\n", + "# the directory where our data are located\n", + "data_dir = 'pieman-isc-tutorial'\n", + "# Filenames for MRI data; gzipped NIfTI images (.nii.gz)\n", + "func_fns = glob(join(data_dir, ('sub-*_task-pieman_space-MNI152NLin2009cAsym'\n", + " '_desc-tproject_bold.nii.gz')))\n", + "# The mask for our data\n", + "mask_fn = join(data_dir, 'MNI152NLin2009cAsym_desc-brain_mask.nii.gz')\n", + "\n", + "# Let us mask these data and separate them into two sessions\n", + "def separate_and_mask(func):\n", + " # Load data\n", + " N = nibabel.load(func).get_data()\n", + " # Separate them into two sessions\n", + " N_1 = N[:, :, :, :250]\n", + " N_2 = N[:, :, :, 250:]\n", + " I_1 = new_img_like(func, N_1)\n", + " I_2 = new_img_like(func, N_2)\n", + " # Mask data\n", + " masker = NiftiMasker(\n", + " mask_img=mask_fn, \n", + " detrend=True,\n", + " standardize=True,\n", + " smoothing_fwhm=6\n", + " ).fit()\n", + " # Transpose the data to fit with SRM conventions\n", + " X_1 = masker.transform(I_1).T\n", + " X_2 = masker.transform(I_2).T\n", + " # Save data\n", + " np.save(func[:-7] + \"_session_1\", X_1)\n", + " np.save(func[:-7] + \"_session_2\", X_2)\n", + "\n", + "# I have 4 cores in my computer, it you have more increase n_jobs\n", + "Parallel(n_jobs=4, verbose=10)(\n", + " delayed(separate_and_mask)(\n", + " func\n", + " ) for func in func_fns)\n", + "print(\"Done in %.2f seconds\" % (time() - t0))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Step 2: mask and save an atlas\n", + "- Atlases are used in FastSRM to make computation faster\n", + "- Any off-the-shelf big atlas should work (number of regions of the atlas should be larger than number of components used in SRM) we use Basc 444 for our example" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Dataset created in pieman-isc-tutorial/basc_multiscale_2015\n", + "\n", + "Downloading data from https://ndownloader.figshare.com/files/1861819 ...\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "\r", + "Downloaded 193985 of 193985 bytes (100.0%, 0.0s remaining) ...done. (1 seconds, 0 min)\n", + "Extracting data from pieman-isc-tutorial/basc_multiscale_2015/3cbcf0eeb3f666f55070aba1db9a758f/1861819..... done.\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "pieman-isc-tutorial/basc_multiscale_2015/template_cambridge_basc_multiscale_nii_sym/template_cambridge_basc_multiscale_sym_scale444.nii.gz\n", + "Done in 24.46\n" + ] + } + ], + "source": [ + "def load_atlas(atlas, mask_img):\n", + " # Load masker\n", + " atlas_masker = MultiNiftiMasker(\n", + " mask_img=mask_img).fit()\n", + " X = nibabel.load(atlas).get_data()\n", + " # If the atlas is a deterministic atlas\n", + " # (each region is identified by a number starting from 1)\n", + " if len(X.shape) == 3:\n", + " n_components = len(np.unique(X)) - 1\n", + " xa, ya, za = X.shape\n", + " A = np.zeros((xa, ya, za, n_components + 1))\n", + " for c in np.unique(X)[1:].astype(int):\n", + " X_ = np.copy(X)\n", + " X_[X_ != c] = 0.\n", + " X_[X_ == c] = 1.\n", + " A[:, :, :, c] = X_\n", + " A = atlas_masker.transform(new_img_like(atlas, A))\n", + " A = np.argmax(A, axis=0)\n", + " # If the atlas is a probabilistic atlas\n", + " # (each region is assigned to a component)\n", + " else:\n", + " A = atlas_masker.transform(atlas)\n", + " return A\n", + "\n", + "t0 = time()\n", + "from nilearn.datasets import fetch_atlas_basc_multiscale_2015\n", + "atlas = fetch_atlas_basc_multiscale_2015(data_dir=data_dir)['scale444']\n", + "print(atlas)\n", + "A = load_atlas(atlas, mask_fn)\n", + "np.save(atlas[:-7], A)\n", + "atlas_path = atlas[:-7] + \".npy\"\n", + "print(\"Done in %.2f\" % (time() - t0))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Step 3: Fit of the model and predict data of left-out subjects\n", + "- Load data\n", + "- Train model on first session using train subjects \n", + "- Compute shared response on second session using train subjects\n", + "- Compute alignment for test subjects using session 1\n", + "- Predict data of test subjects during session 2 using the trained model\n", + "\n", + "### Note about input images\n", + "##### ProbSRM/DetSRM possible input\n", + "- imgs is a list of arrays where element i of the array is a numpy array of shape [n_voxels, n_timeframes] that contains the data of subject i\n", + "\n", + "##### FastSRM possible input\n", + "- imgs is a list of arrays where element i of the array is a numpy array of shape [n_voxels, n_timeframes] that contains the data of subject i\n", + "\n", + "- imgs is a list of list of arrays where element i, j of the array is a numpy array of shape [n_voxels, n_timeframes] that contains the data of subject i collected during session j.\n", + "\n", + "- imgs is an np array imgs, imgs[i, j] is a path to the data of subject i collected during session j. Data are loaded with numpy.load and expected shape is [n_voxels, n_timeframes] n_timeframes and n_voxels are assumed to be the same across subjects n_timeframes can vary across sessions. Each voxel’s timecourse is assumed to have mean 0 and variance 1\n", + "\n", + "=> So FastSRM can be used with very large dataset (even those where data cannot be hold in memory)" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Input shape\n", + "(20, 2)\n" + ] + } + ], + "source": [ + "subjects = [18, 19, 20, 21, 24, 25, 26, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 40, 42]\n", + "sessions = [1, 2]\n", + "\n", + "files = np.array([\n", + " [glob(join(data_dir, \"sub-%.3i*_session_%i*\" %(sub, sess)))[0] \n", + " for sess in sessions]\n", + " for sub in subjects])\n", + "\n", + "# 20 subjects x 2 sessions file matrix\n", + "print(\"Input shape\")\n", + "print(files.shape)" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "def load_and_concat(paths):\n", + " \"\"\"\n", + " Take an array (n_subjects, n_sessions) of path and yields a list of arrays\n", + " Parameters\n", + " ----------\n", + " paths\n", + " Returns\n", + " -------\n", + " X\n", + " \"\"\"\n", + " X = []\n", + " for i in range(len(paths)):\n", + " X_i = np.concatenate([np.load(paths[i, j])\n", + " for j in range(len(paths[i]))], axis=1)\n", + " X.append(X_i)\n", + " return X" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [], + "source": [ + "from brainiak.funcalign.fastsrm import FastSRM\n", + "from brainiak.funcalign.srm import SRM\n", + "\n", + "fastsrm = FastSRM(\n", + " atlas=atlas_path, # the path to basc atlas (we could have used np.load(atlas_path) instead)\n", + " n_components=20,\n", + " n_jobs=1, # Since we use a small dataset paralellization is counter-productive so we do not use it here\n", + " n_iter=10, \n", + " temp_dir=data_dir, # We will use the disk as if we had a small memory\n", + " low_ram=True, # Let's say I really have a small memory so I need low_ram mode\n", + " aggregate=\"mean\" # transform will return the mean of subject specific shared response\n", + ")\n", + "\n", + "probsrm = SRM(\n", + " n_iter=10, # same number of iterations\n", + " features=20 # same number of components\n", + ")\n", + "\n", + "models = [(\"probsrm\", probsrm), (\"fastsrm\", fastsrm)]" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Running reconstruction experiment for probsrm\n", + "Done in 105.25\n", + "Running reconstruction experiment for fastsrm\n", + "Done in 48.39\n" + ] + } + ], + "source": [ + "from sklearn.model_selection import KFold\n", + "\n", + "# List in which we record for each algo the R2 scores per voxels averaged across subjects\n", + "r2_mean = {}\n", + "for name, model in models:\n", + " print(\"Running reconstruction experiment for %s\" % name)\n", + " t0 = time()\n", + " # List in which we record for each subject the test R2 scores per voxels\n", + " r2_subjects = []\n", + "\n", + " # We divide all subjects into train subjects and test subjects\n", + " for subjects_train, subjects_test in KFold(n_splits=5,\n", + " shuffle=True\n", + " ).split(np.arange(len(subjects))):\n", + "\n", + " # First let us train the model on train subjects during session 1\n", + " # # For this we will use an input format that is supported by both FastSRM and SRM: a list of arrays\n", + " train_subjects_session_1 = load_and_concat(files[subjects_train, :][:, :1])\n", + " train_subjects_session_2 = load_and_concat(files[subjects_train, :][:, 1:])\n", + " test_subjects_session_1 = load_and_concat(files[subjects_test, :][:, :1])\n", + " test_subjects_session_2 = load_and_concat(files[subjects_test, :][:, 1:])\n", + " \n", + " n_subjects_train = len(subjects_train)\n", + " n_subjects_test = len(subjects_test)\n", + "\n", + " # # Let us fit the model on the first session\n", + " model.fit(train_subjects_session_1)\n", + "\n", + "\n", + " # Then let us compute the shared response on the second session\n", + "\n", + " # # With ProbSRM the transform method returns a list of subject-specific responses in shared space\n", + " # # so we need an additional step to aggregate them\n", + " if name == \"probsrm\":\n", + " shared_session_2 = model.transform(train_subjects_session_2)\n", + " shared_session_2 = np.mean(shared_session_2, axis=0)\n", + "\n", + " # # With FastSRM we can specify the desired behavior. \n", + " # # Because we specified aggregate=\"mean\" transform directly returns\n", + " # # the mean of subject-specific responses (aggregate=None would result \n", + " # # in the same behavior as in ProbSRM)\n", + " if name == \"fastsrm\":\n", + " shared_session_2 = model.transform(train_subjects_session_2)\n", + "\n", + " # Now we add test subjects to the model\n", + "\n", + " # # With ProbSRM we have a function transform subject that returns the basis for \n", + " # # one specific subject. We will save this in a list\n", + " if name == \"probsrm\":\n", + " list_basis_test_subjects = [model.transform_subject(x) for x in test_subjects_session_1]\n", + "\n", + " # # With FastSRM we have a function add subject that takes a list of subjects\n", + " # # new subjects are added to internal basis_list (that can be accessed using .basis_list\n", + " # # but this is usually not necessary)\n", + " # # With FastSRM we need to specify what is the shared response that is used to learn the alignment\n", + " if name == \"fastsrm\":\n", + " shared_session_1 = model.transform(train_subjects_session_1)\n", + " model.add_subjects(test_subjects_session_1, shared_session_1)\n", + " \n", + " # Then we try to reconstruct the data of test subjects during session 2\n", + "\n", + " # # ProbSRM does not provide an inverse transform so we need to implement this\n", + " # # (it is rather easy)\n", + " if name == \"probsrm\":\n", + " reconstructed_data_test_subjects_session_2 = [\n", + " list_basis_test_subjects[i].dot(shared_session_2)\n", + " for i in range(n_subjects_test)]\n", + "\n", + " # # FastSRM provides an inverse transform but we need to specify what to reconstruct\n", + " # # New subjects are added at the end of the list so we need to reconstruct the data of the last \n", + " # # n_subjects_test subjects\n", + " if name == \"fastsrm\":\n", + " reconstructed_data_test_subjects_session_2 = model.inverse_transform(\n", + " shared_session_2,\n", + " subjects_indexes=np.arange(n_subjects_train, n_subjects_train + n_subjects_test))\n", + " \n", + " # This is the true data we are trying to reconstruct ()\n", + " real_data_test_subjects_session_2 = np.array([np.load(file) for file in files[subjects_test, :][:, 1]])\n", + "\n", + " for i in range(n_subjects_test):\n", + " diff = reconstructed_data_test_subjects_session_2[i] - real_data_test_subjects_session_2[i]\n", + " r2 = 1 - diff.var(axis=1)\n", + " r2_subjects.append(r2)\n", + " \n", + " r2_mean[name] = np.mean(r2_subjects, axis=0)\n", + " print(\"Done in %.2f\" % (time() - t0))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Step 5: Plot results" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "R2 score probsrm: 0.036\n", + "R2 score fastsrm: 0.039\n" + ] + }, + { + "data": { + "image/png": "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d21OobArAKALMW8mtzn3QMGa2FZ4wkAMhDKptm42Be15I+3bI+rCNhzOYf0sYxt8suZljbV1u2tfCoV9qkvcP3GL6r5Kofon7oXYfnfPlJ6+5YOtcU1YQ8K1fbza75XLnuu0GbZs2YLXvva1uPvuu9Hd3Y0FCxZg5cqVyQ+ol19+uTbw/N3vfheXX3457rjjjhGp37BFEXzNa14DoPLDqBEHH3wwAOA3v/lNbpnHHHMMnnrqKSxevBi77bYbLrzwQqxYsQJHHXVUst9Xv/pVXHvttbj44otrnqOrr74ahx9+OM4555xaGbfddhve8Y534Ic//GFSn7333hsf//jHMWXKFFxwwQX4zne+g7e85S0AgA9/+MM455xzcNZZZ+GXv/wldtppJyxcuBDTp0/PrQMA/N//+39x22234ZhjjsF73vMefPGLX8Quu+yCBQsW4PTTT8drXvMaXHTRRfjNb36DCy64AI7jOM2ybNmyVlfBGaME29t1111bXBPHccYCDz30EObPn1+bS3z00UfjlltuSX5gsapn48aNI5rPd0h/YI0fPx4AsMcee2D58uV45JFHGsqjpk6digsuuAD33Xcffvazn+WWP3XqVPz1X/91zc26ceNGfOMb38A+++yDX/866vNvuukmfPrTcZ7APvvsg2OOOQbHH388vv71rwOoTKL9xS9+gX/+53+uzR0DKgEIDj74YDz7bCVM7DPPPIMf/vCHOOyww3DnnXfiLW95C+66666aTAEAbr755kxd6+sQWLVqVS3M8T333IMjjzwSixcvxh577IFSqTKy/Jd/+Zc44ogj/AfWKCJ4X0OwgDBBu9O54YYbahODP/ShD+Xs7Qw1YXL/pEmV0f0ddtih0e4dw4oVK3DiiSe2uhpOlYsvvrgWWGeoJamOYxGCpoTAT53CpZdeitNOO63V1Wh71qxZg7lzY3qH7u7u2lQl5rLLLsOXv/xlbNq0CffeO3JzyIfsB9bOO+9c+6IFAC+88AIWLFiATZs2mcd89atfxS677IK//uu/buocd999d6K5vvnmmzFu3DgsWLAg+YF12223JcctWLAA48aNq81JAIByuYybbroJn/zkJ5N9f/azn9V+XAHAj370I/T39+Mtb3kL7rzzTjz66KP4yEc+gvPOOw+33XYbfvrTn2Lr1q2ZutbXIfDf//3fSR2efvppDAwM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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA1gAAAC0CAYAAACJ8Y3MAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAALEgAACxIB0t1+/AAAADh0RVh0U29mdHdhcmUAbWF0cGxvdGxpYiB2ZXJzaW9uMy4xLjEsIGh0dHA6Ly9tYXRwbG90bGliLm9yZy8QZhcZAAAgAElEQVR4nOyde5RcVZX/d+X9oCEkdBLpjvSE8BBQ0SEanXFUFgiTWTCoII9xEDIgkgBhlCXMOD95DCPCMECSDs8IhJlJwNcYfICiDA8HCYIJD6MhEDqmE9IEioQyLSSE/v1Rte/5nrrf3bc66e6qrt6ftZI6fZ/nnrvvuVVnf8/eua6uri5xHMdxHMdxHMdxdpsh1a6A4ziO4ziO4zhOveA/sBzHcRzHcRzHcXoJ/4HlOI7jOI7jOI7TSwzrycaTJ0+Wjo6OvqqLA0yaNEk2bdpU7Wo4juM4juM4jtMDcj0JcpHL5fqyLk4ZHn/EcRzHcRzHcQYWLhF0HMdxHMdxHMfpJXokEXT6l1zuHOnquqXa1XD6kJtvvllERM4998ewdC+y5ZNJafHifxYRkdNPPz3z+LncnqXScFi6L5QPKH3ugGXbklJX14OZ53AGLtdff72IiIwaNUpmz15cWtoIW2xOSjfddIaIiHzpS1/KPO7kktqhQxqSZQ1SSG1XkPFJuavrtQpr7dQjudz+Sbmr68XdOtb+htrmRVeFOI7TT7gHy3Ecx3Ecx3Ecp5foFQ/WpZdeKpdddlny96ZNm+TJJ5+Uf/7nf5Znn302WX7ggQfKBRdcIEceeaTst99+smnTJvnhD38ol156qWzdurXbc4wZM0a+9a1vydFHHy0TJkyQM844QxYvXtztPpUwffp0mTlzplx++eW7fay+4O677xYRkVdeeUUuuOCCKtfG2R1uu+02ERGZOHGiiIg0NDTIYYcdVlr7Y2OvNE1NTSIismTJEhGRJPDMP/7jPyYeiUmTJvVGlZ064o477hARkZEjR8qwYcWuf+rUqcmyLA466CARCXaXz+dFRGTEiBHypz/9SUREJkyYsMv1u+eee0RE5LXXXpPZs2fv8nGc2kU99mPGjIk+R40aFW3305/+NLGpN954I/o877zzUsedN2+eiIiMGzeu2/PfdNNNcu655+5q9Z06orW1VUSKfd+QIUVfw9tvvy0iIuecc07V6uXUD70S5OLSSy+VCy+8UI499lgREWlpaZErrrhC9tprL3nPe94jr7/+uoiIzJkzR8466yy5/fbb5ZlnnpGpU6fKlVdeKe3t7TJjxoxugzp85Stfkcsvv1xOP/102bBhg7z44ovy6quv9uRaKXPmzJHW1tYaDeDxdyIyAv5G6dY9/V4bZ9fJ5WaVSltg6Soo43KV8OGXhTVQVjlfkP1dd92Xkx9Y1335y8lyHbYoyFRyfJEgB0OJILIByi+IiEhX1xvGtk6tsif0b0fAcrzramGdsAyFpXkp/mj/7/++rvh32Q+s7154Yeq8G6G8Dcp6DpQINkienld5zeVdAxq0wQJIRxWUkI4h+//LggUikv6BdQwcl/28wp51R9mnCLc1EZEH3d7qiilgJ9r/5KH/mQT9j74VnzVsYAYca3vpE22K2Zd1LKd+6bU5WG+//bYsX75cRESWL18ubW1t8vjjj8uxxx4rS5cuFRGRpUuXysKFC5N9Hn74YWlvb5ef/exn8rGPfUweeeQR8/gHH3ywrF69Wr7//e/3VpV7lSFDhsjQoUNlxw7ri2rvcsMNN8iF5AuNU3ssW7asz88xevRoueGGG2TPPffM3tgZFHznO98REZHx48dnbNkzmpubRUSSvq6zs1MaGtJfmJ3BzaJFi0REZJ999umV46kd33TTTSJS9H5t27atu12cQc6tt94qo0eP3uX91eu/c+dOESna4Guv7dpc0TvuuCOxV+aFdXaN+++/X+bOnSs7d+6Us846Sy655JJo/c033ywLFy6UoUOHyh577CG33nqrHHLIIf1St17zYJ133nnS2BgmR48aNUr+9Kc/ycUXXyzXXHONecx99tlHNm/eLKeeemoihyvnpZdekpaWllRdDjroILnsssvkL/7iL2TChAny0ksvyW233Sbz589PvGHDhg2Tq666Sj73uc/JpEmT5LXXXpPly5fLySefLKeddprceeed0XEfeugh+eQnPylNTU1y3XXXySc+8QlpaGiQjRs3ypIlS+TrX/+6iBQflsMOO0yuvPJK+bd/+zc58MAD5cgjj5T9999f7rzzTvngBz8o1113nXzoQx+S559/XmbNmiWrV6+W1tZW+cxnPiOvvfaafO1rXzOvucinRGQs/I3jwepZQGllGPfr6vIcWtUml/tH+EvHzPAePpaUcPQ+eJtwHBfHYcuPKTLeGP3vEJUKoteKjfNiYAMcJHgazrE2tZd7FWqPXO5I+KsYpGK8PJcswVfLmNSW8d1Ha81Lc2qLqRLyIk4jdVlHji8SbBS9ZQjWiw1Zud3VNhNK3xWwp9nON03ecHifcVtmI2jDB0BZezHsA9ugrPaIP8vwDTuGbIvPwBtudzULBkkJhF6nGTykzObwDYhvSwbaSVPpE232aSir/eC5MIwVPiOqadnkdlYRO3fulAMPPFAeeOABaW5ulunTp8vSpUujH1BvvPFGMvB87733yo033ij3339/v9Svz6IIvvvd7xaR4o+j7vjIRz4iIiLPP/+8uc2nP/1pufLKK2Xq1Kly5plnJsubmppk9erV8t///d9SKBTk8MMPl8svv1xGjx4t3/zmN0VE5J/+6Z/k7/7u7+SSSy6Rl156SSZPniwzZ86UoUOHyo9//GO59tpr5aKLLpIZM2aISNB533XXXTJ69Gj54he/KFu2bJGpU6fKwQcfHNWrpaVFrrnmGrniiitk06ZN8tJLL8n++xcf8sWLF0tra6tcffXV8s1vflO++93vyhNPPCEvvviinHjiiTJr1iy566675NFHH5UNGzaI4zjO7jJ//vxqV8EZpKjtTZ48uco1cRxnMPDEE0/ItGnTkrnEp5xyiixbtiz6gYWqnm3btvXrdKBe/YE1dOhQERHZb7/9pLW1VVasWNGtPGr06NFy9dVXy0MPPSS/+c1vzO1WrlwpmzdvlkmTJiUyRBGRBx98UB58MISR/uUvfyljxoyRs88+O/mB9aEPfUiWLFkid911V7KdSmfefPNNaWtrExGJjqv7nXrqqfKjH/1IRIpyxnL22WcfOeqoo+Tpp59Orbv22muTc+ZyOfnJT34iDz30kPzLv/yLiBQN48QTT5TjjjsumfjrDFzUE6nBAnSCdr1z9913JxODP//5z1e5NoMPndw/YkRxruZee7EQ//XHokWL5Kyzzqp2NZwSN9xwQxJYp7clqY5jod+dNPBTvbBgwQI5//zzq12NmmfDhg0yZcqU5O/m5ubUd3kRkYULF8p1110n27dvj34z9DW99gNrn332Sb5oiYi8+uqrMn36dNm+3RIGiHzrW9+SiRMnyt/8zd/s0jlHjhyZeKje/e53J18yRIo/9nbu3CkrV66Uc889Vzo6OuT++++Pohp2x8qVK+Wqq66SCRMmyIMPPijr169PbdPe3k5/XImI/OIXv0jKL7xQDA6AN/aNN96QzZs3Z3QMIyQWKIRAB80lSRiGwNgOLvCQ/yh84erqSl+D0zuceuoNpRKKB1B0MJysD0KEQrRc75kVWXNE2adIPhLFoKhGhQko0EJRAhNrbaFl3Quf6C+eempSnvv3fy8iLt+qBrNnz4O/xqXKeTkoWbJGVidllMIwS4nleUUbQTlrlpRvuLE+nxHkwJowrlx89tly8dlnR+dwWU3/E8uyguXo3cWeBK2S9YwsCIpI6OXQ1tCusD9iM7JYhj9Lmor769VgIJZcLkTJ9Lxt1edWiAipdwn7FpQFos0pKA9F2aluy9+EsS3r27rTWI/f4BRm37j8ggsWygUXFOMVdHX9nhzB6Qlz5syROXPmyJIlS+TKK6/MjEB+7LHHdhtEb5999qlIZthrebC2bNkiRxxxhHz4wx+WL37xizJixAhZsmSJ6Y67+uqr5dOf/rSccMIJmTJCi6uvvlouuugiufXWW2XmzJlyxBFHyL/+67+KSAj7euWVV8rChQtl9uzZ8swzz8j69esrCnd+8skny5NPPinXX3+9/OEPf5AVK1bIkUceGW2j4bEZW7aEx1F/ZOIyXV4enrbatLS0yOjRo2WPPfaQyZMnyxlnnCF//OMfq12tPqOlpUV+/vOf93i/RYsWyaJFi+T+++/vNz3vQGHx4sU9TqEwmOxuV20OWbp0qSxdulT233//RJbs9IzBZHMivWN3SmtraxLm2ukZbne9g9tfz6hHu2tqaoqcH+3t7d06LU455RT5wQ9+kHncV199VZ588knzX6URzHvtB9bbb78tTz31lDzxxBNy2223yZw5c+QjH/mInHTSSaltL7zwQrnooovk9NNPl1/+8pe7fM6TTjpJFixYIP/+7/8uv/jFL+Spp56KvGgiIm+99ZZceuml8md/9mdywAEHyD333CPz5s2TY445pttjb9y4Uc4880yZMGGCzJgxQzZt2iT33ntvJH/oQXyQAcUPf/hD+eMf/ygrV66UFStWyFVXXVXtKjmDALc7p79xm3OqgdudUw3qze6mT58ua9askZdeekm2b98ud999txx//PHRNmvWBOXXj3/8YznggAPKD0PYKUWfpfWvMvosyMV//dd/ycUXXywXX3yxfPvb306Wn3baafIf//Ef8uUvfzmZ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\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "masker = NiftiMasker(\n", + " mask_img=mask_fn).fit()\n", + "\n", + "for name in [\"probsrm\", \"fastsrm\"]:\n", + " # R2 score in a ROI given by areas where ProbSRM performs well\n", + " print(\"R2 score %s: %.3f\" % (name, np.mean(r2_mean[name][r2_mean[\"probsrm\"] > 0.01])))\n", + " plot_stat_map(\n", + " masker.inverse_transform(r2_mean[name]),\n", + " display_mode=\"z\",\n", + " cut_coords=[0, 5, 10, 15, 20],\n", + " vmax=0.3,\n", + " title=\"R2 %s\" % name\n", + " )" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.6.8" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/examples/funcalign/requirements.txt b/examples/funcalign/requirements.txt index bf4f33004..a658617cb 100644 --- a/examples/funcalign/requirements.txt +++ b/examples/funcalign/requirements.txt @@ -1,3 +1,4 @@ matplotlib nilearn notebook +wget diff --git a/setup.py b/setup.py index a84083170..f17a953cd 100644 --- a/setup.py +++ b/setup.py @@ -138,6 +138,7 @@ def finalize_options(self): 'pybind11>=1.7', 'psutil', 'nibabel', + 'joblib' ], author='Princeton Neuroscience Institute and Intel Corporation', author_email='mihai.capota@intel.com', diff --git a/tests/funcalign/test_fastsrm.py b/tests/funcalign/test_fastsrm.py new file mode 100644 index 000000000..d7486dd4f --- /dev/null +++ b/tests/funcalign/test_fastsrm.py @@ -0,0 +1,818 @@ +import os +import tempfile + +import numpy as np +import pytest +from numpy.testing import assert_array_almost_equal +from sklearn.exceptions import NotFittedError + +from brainiak.funcalign.fastsrm import ( + FastSRM, _compute_and_save_corr_mat, _compute_and_save_subject_basis, + _compute_basis_subject_online, _reduced_space_compute_shared_response, + check_atlas, check_imgs, check_shared_response, create_temp_dir, fast_srm, + reduce_data, safe_load) + + +def to_path(X, dirpath): + """ + Save list of list of array to path and returns the path_like array + Parameters + ---------- + X: list of list of array + input data + dirpath: str + dirpath + Returns + ------- + paths: array of str + path arrays where all data are stored + """ + paths = [] + for i, sessions in enumerate(X): + sessions_path = [] + for j, session in enumerate(sessions): + pth = "%i_%i" % (i, j) + np.save(os.path.join(dirpath, pth), session) + sessions_path.append(os.path.join(dirpath, pth + ".npy")) + paths.append(sessions_path) + return np.array(paths) + + +def generate_data(n_voxels, + n_timeframes, + n_subjects, + n_components, + datadir, + noise_level=0.1, + input_format="array"): + n_sessions = len(n_timeframes) + cumsum_timeframes = np.cumsum([0] + n_timeframes) + slices_timeframes = [ + slice(cumsum_timeframes[i], cumsum_timeframes[i + 1]) + for i in range(n_sessions) + ] + + # Create a Shared response S with K = 3 + theta = np.linspace(-4 * np.pi, 4 * np.pi, int(np.sum(n_timeframes))) + z = np.linspace(-2, 2, int(np.sum(n_timeframes))) + r = z**2 + 1 + x = r * np.sin(theta) + y = r * np.cos(theta) + + S = np.vstack((x, y, z)) + + # Generate fake data + W = [] + X = [] + for subject in range(n_subjects): + Q, R = np.linalg.qr(np.random.random((n_voxels, n_components))) + W.append(Q.T) + X_ = [] + for session in range(n_sessions): + S_s = S[:, slices_timeframes[session]] + S_s = S_s - np.mean(S_s, axis=1, keepdims=True) + noise = noise_level * np.random.random( + (n_voxels, n_timeframes[session])) + noise = noise - np.mean(noise, axis=1, keepdims=True) + data = Q.dot(S_s) + noise + X_.append(data) + X.append(X_) + + # create paths such that paths[i, j] contains data + # of subject i during session j + paths = to_path(X, datadir) + S = [(S[:, s] - np.mean(S[:, s], axis=1, keepdims=True)) + for s in slices_timeframes] + + if input_format == "array": + return paths, W, S + + elif input_format == "list_of_list": + return X, W, S + + elif input_format == "list_of_array": + return [ + np.concatenate([X[i][j].T for j in range(n_sessions)]).T + for i in range(n_subjects) + ], W, S + else: + raise ValueError("Wrong input_format") + + +def test_generated_data(): + with tempfile.TemporaryDirectory() as datadir: + + # We authorize different timeframes for different sessions + # but they should be the same across subject + n_voxels = 10 + n_timeframes = [25, 24] + n_subjects = 2 + n_components = 3 # number of components used for SRM model + n_sessions = len(n_timeframes) + + np.random.seed(0) + paths, W, S = generate_data(n_voxels, n_timeframes, n_subjects, + n_components, datadir) + + # Test if generated data has the good shape + for subject in range(n_subjects): + for session in range(n_sessions): + assert (np.load( + paths[subject, session]).shape == (n_voxels, + n_timeframes[session])) + + # Test if generated basis have good shape + assert len(W) == n_subjects + for w in W: + assert w.shape == (n_components, n_voxels) + + assert len(S) == n_sessions + for j, s in enumerate(S): + assert s.shape == (n_components, n_timeframes[j]) + + +def test_bad_aggregate(): + with pytest.raises(ValueError, + match="aggregate can have only value mean or None"): + FastSRM(aggregate="invalid") + + +def test_check_atlas(): + assert check_atlas(None) is None + with pytest.raises(ValueError, + match=("Atlas is stored using type " + "which is neither np.ndarray or str")): + check_atlas([]) + + A = np.random.rand(10, 100) + assert check_atlas(A) == (10, 100) + + with tempfile.TemporaryDirectory() as datadir: + f = os.path.join(datadir, "atlas") + np.save(f, A) + assert check_atlas(f + ".npy") == (10, 100) + + A = np.array([0, 0, 1, 1, 2, 2, 3, 3, 4, 5, 5]) + assert check_atlas(A) == (5, 11) + + with tempfile.TemporaryDirectory() as datadir: + f = os.path.join(datadir, "atlas") + np.save(f, A) + assert check_atlas(f + ".npy") == (5, 11) + + with pytest.raises(ValueError, + match=("Atlas has 3 axes. It should have either " + "1 or 2 axes.")): + check_atlas(np.random.rand(5, 1, 2)) + + with pytest.raises(ValueError, + match=(r"Number of regions in the atlas is lower than " + r"the number of components \(3 < 5\)")): + check_atlas(np.random.rand(3, 10), n_components=5) + + with pytest.raises(ValueError, + match=(r"Number of regions in the atlas is bigger than " + r"the number of voxels \(5 > 2\)")): + check_atlas(np.random.rand(5, 2)) + + +empty_list_error = "%s is a list of length 0 which is not valid" +array_type_error = ("%s should be of type np.ndarray but is of type %s") +array_2axis_error = ("%s must have exactly 2 axes but has %i axes") + + +def test_check_imgs(): + with pytest.raises( + ValueError, + match=(r"Since imgs is a list, it should be a list of list " + r"of arrays or a list " + r"of arrays but imgs\[0\] has type ")): + check_imgs(["bla"]) + + with pytest.raises( + ValueError, + match=("Input imgs should either be a list or an array but " + "has type ")): + check_imgs("bla") + + with pytest.raises(ValueError, match=empty_list_error % "imgs"): + check_imgs([]) + + with pytest.raises( + ValueError, + match=r"imgs\[1\] has type whereas imgs\[0\] has " + "type . This is inconsistent."): + check_imgs([0, "bla"]) + + with pytest.raises(ValueError, match=empty_list_error % r"imgs\[0\]"): + check_imgs([[]]) + + with pytest.raises( + ValueError, + match=(r"imgs\[1\] has length 1 whereas imgs\[0\] has length 2." + " All subjects should have the same number of sessions.")): + check_imgs([["a", "a"], ["a"]]) + + with pytest.raises(ValueError, + match=array_type_error % + (r"imgs\[0\]\[0\]", r"")): + check_imgs([["bka"]]) + + with pytest.raises(ValueError, + match=array_2axis_error % (r"imgs\[0\]\[0\]", 1)): + check_imgs([[np.random.rand(5)]]) + + with pytest.raises(ValueError, + match=array_2axis_error % (r"imgs\[0\]", 1)): + check_imgs([np.random.rand(5)]) + + with pytest.raises(ValueError, + match=(r"imgs\[0, 0\] is stored using type " + " which is not a str")): + check_imgs(np.random.rand(5, 3)) + + with pytest.raises(ValueError, match=array_2axis_error % (r"imgs", 1)): + check_imgs(np.random.rand(5)) + + with pytest.raises( + ValueError, + match=("The number of subjects should be greater than 1")): + check_imgs([np.random.rand(5, 3)]) + + with pytest.raises( + ValueError, + match=("Subject 1 Session 0 does not have the same number " + "of timeframes as Subject 0 Session 0")): + check_imgs([np.random.rand(10, 5), np.random.rand(10, 10)]) + + with pytest.raises( + ValueError, + match=("Subject 1 Session 0 does not have the same number " + "of voxels as Subject 0 Session 0")): + check_imgs([np.random.rand(10, 5), np.random.rand(20, 5)]) + + with pytest.raises( + ValueError, + match=("Total number of timeframes is shorter than number " + r"of components \(5 < 8\)")): + check_imgs([np.random.rand(10, 5), + np.random.rand(10, 5)], + n_components=8) + + with pytest.raises( + ValueError, + match=("Number of voxels in the atlas is not the same as " + r"the number of voxels in input data \(11 != 10\)")): + check_imgs([np.random.rand(10, 5), + np.random.rand(10, 5)], + n_components=3, + atlas_shape=(8, 11)) + + +def test_check_shared(): + n_subjects = 2 + n_sessions = 2 + input_shapes = np.zeros((n_subjects, n_sessions, 2)) + input_shapes[0, 0, 0] = 10 + input_shapes[0, 0, 1] = 3 + input_shapes[0, 1, 0] = 10 + input_shapes[0, 1, 1] = 2 + input_shapes[1, 0, 0] = 10 + input_shapes[1, 0, 1] = 3 + input_shapes[1, 1, 0] = 10 + input_shapes[1, 1, 1] = 2 + + shared_list_list = [[ + np.array([[1, 2, 3], [4, 5, 6]]), + np.array([[1, 2], [4, 5]]), + ], [ + np.array([[2, 3, 4], [5, 6, 7]]), + np.array([[2, 3], [5, 6]]), + ]] + + shared_list_subjects = [ + np.array([[1, 2, 3, 1, 2], [4, 5, 6, 4, 5]]), + np.array([[2, 3, 4, 2, 3], [5, 6, 7, 5, 6]]) + ] + + shared_list_sessions = [ + np.array([[1.5, 2.5, 3.5], [4.5, 5.5, 6.5]]), + np.array([[1.5, 2.5], [4.5, 5.5]]), + ] + + shared_array = np.array([[1.5, 2.5, 3.5, 1.5, 2.5], + [4.5, 5.5, 6.5, 4.5, 5.5]]) + + with pytest.raises(ValueError, + match=(r"shared_response should be either a list or an " + "array but is of type ")): + check_shared_response("bla") + + with pytest.raises( + ValueError, + match=(r"shared_response is a list but shared_response\[0\] " + "is neither a list or an array. This is invalid.")): + check_shared_response(["bla", "bli"]) + + with pytest.raises( + ValueError, + match=(r"shared_response\[0\] is a list but shared_response\[1\] " + "is not a list this is incompatible")): + check_shared_response( + [[np.random.rand(2, 2)], np.array([1])], aggregate=None) + + with pytest.raises(ValueError, + match=(r"shared_response\[1\] has len 1 whereas " + r"shared_response\[0\] has len 2. They should " + "have same len")): + check_shared_response([[np.random.rand(2, 2), + np.random.rand(2, 2)], [np.random.rand(2, 2)]], + aggregate=None) + + with pytest.raises( + ValueError, + match=('Number of timeframes in input images during session 0 ' + 'does not match the number of timeframes during session ' + r'0 of shared_response \(2 != 3\)')): + check_shared_response( + [np.random.rand(2, 2), np.random.rand(2, 2)], + aggregate="mean", + input_shapes=input_shapes) + + with pytest.raises(ValueError, + match=("Number of components in shared_response " + "during session 0 is different than " + "the number of components of the " + r"model \(2 != 4\)")): + check_shared_response(np.random.rand(2, 10), n_components=4) + + with pytest.raises(ValueError, + match=("self.aggregate has value 'mean' but shared " + "response is a list of list. " + "This is incompatible")): + added_session, reshaped_shared = check_shared_response( + shared_list_list, + aggregate="mean", + n_components=2, + input_shapes=input_shapes) + + added_session, reshaped_shared = check_shared_response( + shared_list_subjects, + aggregate=None, + n_components=2, + input_shapes=input_shapes) + assert added_session + assert_array_almost_equal(np.array(reshaped_shared), + shared_array.reshape(1, 2, 5)) + + added_session, reshaped_shared = check_shared_response( + shared_list_sessions, + aggregate="mean", + n_components=2, + input_shapes=input_shapes) + assert not added_session + for j in range(len(reshaped_shared)): + assert_array_almost_equal(reshaped_shared[j], shared_list_sessions[j]) + + added_session, reshaped_shared = check_shared_response( + shared_array, + aggregate="mean", + n_components=2, + input_shapes=input_shapes) + assert added_session + assert_array_almost_equal(np.array(reshaped_shared), + shared_array.reshape(1, 2, 5)) + + +def test_reduce_data_dummyatlases(): + n_jobs = 1 + with tempfile.TemporaryDirectory() as datadir: + for n_timeframes in ([25, 24], [25, 25]): + # We authorize different timeframes for different sessions + # but they should be the same across subject + n_voxels = 10 + n_subjects = 2 + n_components = 3 # number of components used for SRM model + n_sessions = len(n_timeframes) + + np.random.seed(0) + paths, _, _ = generate_data(n_voxels, n_timeframes, n_subjects, + n_components, datadir) + + # test atlas that reduces nothing + atlas = np.arange(1, n_voxels + 1) + data = reduce_data(paths, + atlas=atlas, + n_jobs=n_jobs, + low_ram=False) + for i in range(n_subjects): + for j in range(n_sessions): + assert_array_almost_equal(data[i, j].T, + np.load(paths[i, j])) + + # test atlas that reduces everything + atlas = np.ones(n_voxels) + data = reduce_data(paths, + atlas=atlas, + n_jobs=n_jobs, + low_ram=False) + for i in range(n_subjects): + for j in range(n_sessions): + assert_array_almost_equal( + data[i, j].T.flatten(), + np.mean(np.load(paths[i, j]), axis=0)) + + +def test_reduce_data_outputshapes(): + n_jobs = 1 + with tempfile.TemporaryDirectory() as datadir: + for n_timeframes in ([25, 24], [25, 25]): + # We authorize different timeframes for different sessions + # but they should be the same across subject + n_voxels = 10 + n_subjects = 2 + n_components = 3 # number of components used for SRM model + n_supervoxels = 5 # number of components of the atlas + n_sessions = len(n_timeframes) + + np.random.seed(0) + paths, _, _ = generate_data(n_voxels, n_timeframes, n_subjects, + n_components, datadir) + + # Test if reduced data has the good shape + # probabilistic atlas + atlas = np.random.rand(n_supervoxels, n_voxels) + data = reduce_data(paths, + atlas=atlas, + n_jobs=n_jobs, + low_ram=False, + temp_dir=None) + + for subject in range(n_subjects): + for session in range(n_sessions): + assert data[subject, session].shape == ( + n_timeframes[session], n_supervoxels) + + # deterministic atlas + det_atlas = np.round(np.random.rand(n_voxels) * n_supervoxels) + n_unique = len(np.unique(det_atlas)[1:]) + while n_unique != n_supervoxels: + det_atlas = np.round(np.random.rand(n_voxels) * n_supervoxels) + n_unique = len(np.unique(det_atlas)[1:]) + + data = reduce_data(paths, + atlas=det_atlas, + n_jobs=n_jobs, + low_ram=True, + temp_dir=datadir) + + for subject in range(n_subjects): + for session in range(n_sessions): + assert (np.load(data[subject, session]).shape == ( + n_timeframes[session], n_supervoxels)) + + +def test_reduced_data_srm(): + n_jobs = 1 + with tempfile.TemporaryDirectory() as datadir: + np.random.seed(0) + + # We authorize different timeframes for different sessions but + # they should be the same across subject + n_voxels = 10 + n_timeframes = [25, 24] + n_subjects = 5 + n_components = 3 # number of components used for SRM model + n_sessions = len(n_timeframes) + + paths, W, S = generate_data(n_voxels, n_timeframes, n_subjects, + n_components, datadir, 0) + + atlas = np.arange(1, n_voxels + 1) + data = reduce_data(paths, + atlas=atlas, + n_jobs=n_jobs, + low_ram=False, + temp_dir=None) + + # Test if shared response has the good shape + shared_response_list = \ + _reduced_space_compute_shared_response( + data, + reduced_basis_list=None, + n_components=n_components + ) + assert len(shared_response_list) == n_sessions + + for session in range(n_sessions): + assert (shared_response_list[session].shape == ( + n_timeframes[session], n_components)) + + # Test basis from shared response + for i, sessions in enumerate(paths): + basis = _compute_basis_subject_online( + sessions, [S[k].T for k in range(len(S))]) + # test shape + assert basis.shape == (n_components, n_voxels) + # test orthogonality + assert np.allclose(basis.dot(basis.T), np.eye(n_components)) + # test correctness + assert_array_almost_equal(basis, W[i], 2) + + # Test reduced_data_shared_response + shared_response_list = _reduced_space_compute_shared_response( + data, reduced_basis_list=W, n_components=n_components) + for session in range(n_sessions): + S_real = np.mean( + [data[i, session].dot(W[i].T) for i in range(n_subjects)], + axis=0) + assert_array_almost_equal(shared_response_list[session], S_real) + assert_array_almost_equal(shared_response_list[session], + S[session].T) + + shared_response_list = fast_srm(data, n_components=n_components) + + for i, sessions in enumerate(paths): + basis = _compute_basis_subject_online(sessions, + shared_response_list) + for j, session in enumerate(sessions): + assert_array_almost_equal(shared_response_list[j].dot(basis), + np.load(paths[i, j]).T) + + +def test_compute_and_save(): + with tempfile.TemporaryDirectory() as datadir: + np.random.seed(0) + n_voxels = 10 + n_timeframes = [25, 24] + n_subjects = 5 + n_components = 3 # number of components used for SRM model + + paths, W, S = generate_data(n_voxels, n_timeframes, n_subjects, + n_components, datadir, 0) + + for m, subjects in enumerate(paths.T): + for subject in subjects: + _compute_and_save_corr_mat(subject, S[m].T, datadir) + + for i, sessions in enumerate(paths): + basis = _compute_and_save_subject_basis(i, sessions, datadir) + + assert_array_almost_equal(np.load(basis), W[i]) + + +def test_fastsrm_class(): + n_jobs = 1 + with tempfile.TemporaryDirectory() as datadir: + np.random.seed(0) + + # We authorize different timeframes for different sessions + # but they should be the same across subject + n_voxels = 10 + n_timeframes = [25, 24] + n_subjects = 5 + n_components = 3 # number of components used for SRM model + + np.random.seed(0) + paths, W, S = generate_data(n_voxels, n_timeframes, n_subjects, + n_components, datadir, 0) + + atlas = np.arange(1, n_voxels + 1) + srm = FastSRM(atlas=atlas, + n_components=n_components, + n_iter=10, + temp_dir=datadir, + low_ram=True, + verbose=True, + n_jobs=n_jobs) + + # Raises an error because model is not fitted yet + with pytest.raises(NotFittedError): + srm.transform(paths) + + srm.fit(paths) + # An error can occur if temporary directory already exists + with pytest.raises(ValueError, + match=("Path %s already exists. When a model " + "is used, filesystem should be " + r"cleaned by using the .clean\(\) " + "method" % srm.temp_dir)): + # Error can occur if the filesystem is uncleaned + create_temp_dir(srm.temp_dir) + create_temp_dir(srm.temp_dir) + + shared_response = srm.transform(paths) + + # Raise error when wrong index + with pytest.raises(ValueError, + match=("subjects_indexes should be either " + "a list, an array or None but " + "received type ")): + srm.transform(paths, subjects_indexes=1000) + + with pytest.raises(ValueError, + match=("subjects_indexes should be either " + "a list, an array or None but " + "received type ")): + srm.inverse_transform(shared_response, subjects_indexes=1000) + + with pytest.raises(ValueError, + match=("sessions_indexes should be either " + "a list, an array or None but " + "received type ")): + srm.inverse_transform(shared_response, sessions_indexes=1000) + + with pytest.raises(ValueError, + match=("Input data imgs has len 5 whereas " + "subject_indexes has len 1. " + "The number of basis used to compute " + "the shared response should be equal to " + "the number of subjects in imgs")): + srm.transform(paths, subjects_indexes=[0]) + + with pytest.raises(ValueError, + match=("Index 1 of subjects_indexes has value 8 " + "whereas value should be between 0 and 4")): + srm.transform(paths[:2], subjects_indexes=[0, 8]) + + with pytest.raises(ValueError, + match=("Index 1 of sessions_indexes has value 8 " + "whereas value should be between 0 and 1")): + srm.inverse_transform(shared_response, sessions_indexes=[0, 8]) + + # Check behavior of .clean + assert os.path.exists(srm.temp_dir) + srm.clean() + assert not os.path.exists(srm.temp_dir) + + +n_voxels = 10 +n_subjects = 5 +n_components = 3 # number of components used for SRM model + + +def apply_aggregate(shared_response, aggregate, input_format): + if aggregate is None: + if input_format == "list_of_array": + return [np.mean(shared_response, axis=0)] + else: + return [ + np.mean([ + shared_response[i][j] for i in range(len(shared_response)) + ], + axis=0) for j in range(len(shared_response[0])) + ] + else: + if input_format == "list_of_array": + return [shared_response] + else: + return shared_response + + +def apply_input_format(X, input_format): + if input_format == "array": + n_sessions = len(X[0]) + XX = [[np.load(X[i, j]) for j in range(len(X[i]))] + for i in range(len(X))] + elif input_format == "list_of_array": + XX = [[x] for x in X] + n_sessions = 1 + else: + XX = X + n_sessions = len(X[0]) + return XX, n_sessions + + +@pytest.mark.parametrize( + "input_format, low_ram, tempdir, atlas, n_jobs, n_timeframes, aggregate", + [("array", True, True, None, 1, [25, 25], "mean"), + ("list_of_list", False, False, np.arange(1, n_voxels + 1), 1, [25, 24 + ], None), + ("list_of_array", True, False, np.eye(n_voxels), 1, [25, 25], None), + ("list_of_array", False, True, None, 1, [25, 24], "mean")]) +def test_fastsrm_class_correctness(input_format, low_ram, tempdir, atlas, + n_jobs, n_timeframes, aggregate): + with tempfile.TemporaryDirectory() as datadir: + np.random.seed(0) + X, W, S = generate_data(n_voxels, n_timeframes, n_subjects, + n_components, datadir, 0, input_format) + + XX, n_sessions = apply_input_format(X, input_format) + + if tempdir: + temp_dir = datadir + else: + temp_dir = None + + srm = FastSRM(atlas=atlas, + n_components=n_components, + n_iter=10, + temp_dir=temp_dir, + low_ram=low_ram, + verbose=True, + n_jobs=n_jobs, + aggregate=aggregate, + seed=0) + + # Check that there is no difference between fit_transform + # and fit then transform + + srm.fit(X) + basis = [safe_load(b) for b in srm.basis_list] + shared_response_raw = srm.transform(X) + shared_response = apply_aggregate(shared_response_raw, aggregate, + input_format) + shared_response_fittransform = apply_aggregate(srm.fit_transform(X), + aggregate, input_format) + + for j in range(n_sessions): + assert_array_almost_equal(shared_response_fittransform[j], + shared_response[j]) + + # Check that the decomposition works + for i in range(n_subjects): + for j in range(n_sessions): + assert_array_almost_equal(shared_response[j].T.dot(basis[i]), + XX[i][j].T) + + # Check that if we use all subjects but one if gives almost the + # same shared response + shared_response_partial_raw = srm.transform(X[1:5], + subjects_indexes=list( + range(1, 5))) + + shared_response_partial = apply_aggregate(shared_response_partial_raw, + aggregate, input_format) + for j in range(n_sessions): + assert_array_almost_equal(shared_response_partial[j], + shared_response[j]) + + # Check that if we perform add 2 times the same subject we + # obtain the same decomposition + srm.add_subjects(X[:1], shared_response_raw) + assert_array_almost_equal(safe_load(srm.basis_list[0]), + safe_load(srm.basis_list[-1])) + + +@pytest.mark.parametrize( + "input_format, low_ram, tempdir, atlas, n_jobs, n_timeframes, aggregate", + [("array", True, True, None, 1, [25, 25], "mean"), + ("list_of_list", False, False, np.arange(1, n_voxels + 1), 1, [25, 24 + ], None), + ("list_of_array", True, False, np.eye(n_voxels), 1, [25, 25], None), + ("list_of_array", False, True, None, 1, [25, 24], "mean")]) +def test_class_srm_inverse_transform(input_format, low_ram, tempdir, atlas, + n_jobs, n_timeframes, aggregate): + + with tempfile.TemporaryDirectory() as datadir: + X, W, S = generate_data(n_voxels, n_timeframes, n_subjects, + n_components, datadir, 0, input_format) + + if tempdir: + temp_dir = datadir + else: + temp_dir = None + + srm = FastSRM(atlas=atlas, + n_components=n_components, + n_iter=10, + temp_dir=temp_dir, + low_ram=low_ram, + verbose=True, + n_jobs=n_jobs, + aggregate=aggregate, + seed=0) + + # Check that there is no difference between fit_transform + # and fit then transform + + srm.fit(X) + shared_response_raw = srm.transform(X) + # Check inverse transform + if input_format == "list_of_array": + reconstructed_data = srm.inverse_transform(shared_response_raw, + subjects_indexes=[0, 2]) + for i, ii in enumerate([0, 2]): + assert_array_almost_equal(reconstructed_data[i], X[ii]) + + reconstructed_data = srm.inverse_transform(shared_response_raw, + subjects_indexes=None) + for i in range(len(X)): + assert_array_almost_equal(reconstructed_data[i], X[i]) + else: + reconstructed_data = srm.inverse_transform(shared_response_raw, + sessions_indexes=[1], + subjects_indexes=[0, 2]) + for i, ii in enumerate([0, 2]): + for j, jj in enumerate([1]): + assert_array_almost_equal(reconstructed_data[i][j], + safe_load(X[ii][jj])) + + reconstructed_data = srm.inverse_transform(shared_response_raw, + subjects_indexes=None, + sessions_indexes=None) + + for i in range(len(X)): + for j in range(len(X[i])): + assert_array_almost_equal(reconstructed_data[i][j], + safe_load(X[i][j]))