''' NeuroLearn Simulator Tools ========================== Tools to simulate multivariate data. ''' __all__ = ['Simulator', 'SimulateGrid'] __author__ = ["Sam Greydanus", "Luke Chang"] __license__ = "MIT" import os import six import numpy as np import nibabel as nib import pandas as pd import matplotlib.pyplot as plt from nilearn.input_data import NiftiMasker from scipy.stats import multivariate_normal, binom, ttest_1samp from nltools.data import Brain_Data from nltools.stats import fdr, one_sample_permutation from nltools.prefs import MNI_Template, resolve_mni_path import csv from copy import deepcopy class Simulator: def __init__(self, brain_mask=None, output_dir=None): # no scoring param # self.resource_folder = os.path.join(os.getcwd(),'resources') if output_dir is None: self.output_dir = os.path.join(os.getcwd()) else: self.output_dir = output_dir if isinstance(brain_mask, str): brain_mask = nib.load(brain_mask) elif brain_mask is None: brain_mask = nib.load(resolve_mni_path(MNI_Template)['mask']) elif ~isinstance(brain_mask, nib.nifti1.Nifti1Image): raise ValueError("brain_mask is not a string or a nibabel instance") self.brain_mask = brain_mask self.nifti_masker = NiftiMasker(mask_img=self.brain_mask) def gaussian(self, mu, sigma, i_tot): """ create a 3D gaussian signal normalized to a given intensity Args: mu: average value of the gaussian signal (usually set to 0) sigma: standard deviation i_tot: sum total of activation (numerical integral over the gaussian returns this value) """ x, y, z = np.mgrid[0:self.brain_mask.shape[0], 0:self.brain_mask.shape[1], 0:self.brain_mask.shape[2]] # Need an (N, 3) array of (x, y) pairs. xyz = np.column_stack([x.flat, y.flat, z.flat]) covariance = np.diag(sigma**2) g = multivariate_normal.pdf(xyz, mean=mu, cov=covariance) # Reshape back to a 3D grid. g = g.reshape(x.shape).astype(float) # select only the regions within the brain mask g = np.multiply(self.brain_mask.get_data(), g) # adjust total intensity of gaussian g = np.multiply(i_tot/np.sum(g), g) return g def sphere(self, r, p): """ create a sphere of given radius at some point p in the brain mask Args: r: radius of the sphere p: point (in coordinates of the brain mask) of the center of the sphere """ dims = self.brain_mask.shape x, y, z = np.ogrid[-p[0]:dims[0]-p[0], -p[1]:dims[1]-p[1], -p[2]:dims[2]-p[2]] mask = x*x + y*y + z*z <= r*r activation = np.zeros(dims) activation[mask] = 1 activation = np.multiply(activation, self.brain_mask.get_data()) activation = nib.Nifti1Image(activation, affine=np.eye(4)) # return the 3D numpy matrix of zeros containing the sphere as a region of ones return activation.get_data() def normal_noise(self, mu, sigma): """ produce a normal noise distribution for all all points in the brain mask Args: mu: average value of the gaussian signal (usually set to 0) sigma: standard deviation """ self.nifti_masker.fit(self.brain_mask) vlength = int(np.sum(self.brain_mask.get_data())) if sigma != 0: n = np.random.normal(mu, sigma, vlength) else: n = [mu]*vlength m = self.nifti_masker.inverse_transform(n) # return the 3D numpy matrix of zeros containing the brain mask filled with noise produced over a normal distribution return m.get_data() def to_nifti(self, m): """ convert a numpy matrix to the nifti format and assign to it the brain_mask's affine matrix Args: m: the 3D numpy matrix we wish to convert to .nii """ if not (type(m) == np.ndarray and len(m.shape) >= 3): # try 4D # if not (type(m) == np.ndarray and len(m.shape) == 3): raise ValueError("ERROR: need 3D np.ndarray matrix to create the nifti file") m = m.astype(np.float32) ni = nib.Nifti1Image(m, affine=self.brain_mask.affine) return ni def n_spheres(self, radius, center): """ generate a set of spheres in the brain mask space Args: radius: vector of radius. Will create multiple spheres if len(radius) > 1 centers: a vector of sphere centers of the form [px, py, pz] or [[px1, py1, pz1], ..., [pxn, pyn, pzn]] """ # initialize useful values dims = self.brain_mask.get_data().shape # Initialize Spheres with options for multiple radii and centers of the spheres (or just an int and a 3D list) if isinstance(radius, int): radius = [radius] if center is None: center = [[dims[0]/2, dims[1]/2, dims[2]/2] * len(radius)] # default value for centers elif isinstance(center, list) and isinstance(center[0], int) and len(radius) == 1: centers = [center] if (type(radius)) is list and (type(center) is list) and (len(radius) == len(center)): A = np.zeros_like(self.brain_mask.get_data()) for i in range(len(radius)): A = np.add(A, self.sphere(radius[i], center[i])) return A else: raise ValueError("Data type for sphere or radius(ii) or center(s) not recognized.") def create_data(self, levels, sigma, radius=5, center=None, reps=1, output_dir=None): """ create simulated data with integers Args: levels: vector of intensities or class labels sigma: amount of noise to add radius: vector of radius. Will create multiple spheres if len(radius) > 1 center: center(s) of sphere(s) of the form [px, py, pz] or [[px1, py1, pz1], ..., [pxn, pyn, pzn]] reps: number of data repetitions useful for trials or subjects output_dir: string path of directory to output data. If None, no data will be written **kwargs: Additional keyword arguments to pass to the prediction algorithm """ # Create reps nlevels = len(levels) y = levels rep_id = [1] * len(levels) for i in range(reps - 1): y = y + levels rep_id.extend([i+2] * nlevels) # Initialize Spheres with options for multiple radii and centers of the spheres (or just an int and a 3D list) A = self.n_spheres(radius, center) # for each intensity A_list = [] for i in y: A_list.append(np.multiply(A, i)) # generate a different gaussian noise profile for each mask mu = 0 # values centered around 0 N_list = [] for i in range(len(y)): N_list.append(self.normal_noise(mu, sigma)) # add noise and signal together, then convert to nifti files NF_list = [] for i in range(len(y)): NF_list.append(self.to_nifti(np.add(N_list[i], A_list[i]))) NF_list = Brain_Data(NF_list) # Assign variables to object self.data = NF_list self.y = pd.DataFrame(data=y) self.rep_id = pd.DataFrame(data=rep_id) dat = self.data dat.Y = self.y # Write Data to files if requested if output_dir is not None and isinstance(output_dir, six.string_types): NF_list.write(os.path.join(output_dir, 'data.nii.gz')) self.y.to_csv(os.path.join(output_dir, 'y.csv'), index=None, header=False) self.rep_id.to_csv(os.path.join(output_dir, 'rep_id.csv'), index=None, header=False) return dat def create_cov_data(self, cor, cov, sigma, mask=None, reps=1, n_sub=1, output_dir=None): """ create continuous simulated data with covariance Args: cor: amount of covariance between each voxel and Y variable cov: amount of covariance between voxels sigma: amount of noise to add radius: vector of radius. Will create multiple spheres if len(radius) > 1 center: center(s) of sphere(s) of the form [px, py, pz] or [[px1, py1, pz1], ..., [pxn, pyn, pzn]] reps: number of data repetitions n_sub: number of subjects to simulate output_dir: string path of directory to output data. If None, no data will be written **kwargs: Additional keyword arguments to pass to the prediction algorithm """ if mask is None: # Initialize Spheres with options for multiple radii and centers of the spheres (or just an int and a 3D list) A = self.n_spheres(10, None) # parameters are (radius, center) mask = nib.Nifti1Image(A.astype(np.float32), affine=self.brain_mask.affine) # Create n_reps with cov for each voxel within sphere # Build covariance matrix with each variable correlated with y amount 'cor' and each other amount 'cov' flat_sphere = self.nifti_masker.fit_transform(mask) n_vox = np.sum(flat_sphere == 1) cov_matrix = np.ones([n_vox+1, n_vox+1]) * cov cov_matrix[0, :] = cor # set covariance with y cov_matrix[:, 0] = cor # set covariance with all other voxels np.fill_diagonal(cov_matrix, 1) # set diagonal to 1 mv_sim = np.random.multivariate_normal(np.zeros([n_vox+1]), cov_matrix, size=reps) print(mv_sim) y = mv_sim[:, 0] self.y = y mv_sim = mv_sim[:, 1:] new_dat = np.ones([mv_sim.shape[0], flat_sphere.shape[1]]) new_dat[:, np.where(flat_sphere == 1)[1]] = mv_sim self.data = self.nifti_masker.inverse_transform(np.add(new_dat, np.random.standard_normal(size=new_dat.shape)*sigma)) # add noise scaled by sigma self.rep_id = [1] * len(y) if n_sub > 1: self.y = list(self.y) for s in range(1, n_sub): self.data = nib.concat_images([self.data, self.nifti_masker.inverse_transform(np.add(new_dat, np.random.standard_normal(size=new_dat.shape)*sigma))], axis=3) # add noise scaled by sigma noise_y = list(y + np.random.randn(len(y))*sigma) self.y = self.y + noise_y self.rep_id = self.rep_id + [s+1] * len(mv_sim[:, 0]) self.y = np.array(self.y) # # Old method in 4 D space - much slower # x,y,z = np.where(A==1) # cov_matrix = np.ones([len(x)+1,len(x)+1]) * cov # cov_matrix[0,:] = cor # set covariance with y # cov_matrix[:,0] = cor # set covariance with all other voxels # np.fill_diagonal(cov_matrix,1) # set diagonal to 1 # mv_sim = np.random.multivariate_normal(np.zeros([len(x)+1]),cov_matrix, size=reps) # simulate data from multivariate covar # self.y = mv_sim[:,0] # mv_sim = mv_sim[:,1:] # A_4d = np.resize(A,(reps,A.shape[0],A.shape[1],A.shape[2])) # for i in xrange(len(x)): # A_4d[:,x[i],y[i],z[i]]=mv_sim[:,i] # A_4d = np.rollaxis(A_4d,0,4) # reorder shape of matrix so that time is in 4th dimension # self.data = self.to_nifti(np.add(A_4d,np.random.standard_normal(size=A_4d.shape)*sigma)) # add noise scaled by sigma # self.rep_id = ??? # need to add this later # Write Data to files if requested if output_dir is not None: if isinstance(output_dir, six.string_types): if not os.path.isdir(output_dir): os.makedirs(output_dir) self.data.to_filename(os.path.join(output_dir, 'maskdata_cor' + str(cor) + "_cov" + str(cov) + '_sigma' + str(sigma) + '.nii.gz')) y_file = open(os.path.join(output_dir, 'y.csv'), 'wb') wr = csv.writer(y_file, quoting=csv.QUOTE_ALL) wr.writerow(self.y) rep_id_file = open(os.path.join(output_dir, 'rep_id.csv'), 'wb') wr = csv.writer(rep_id_file, quoting=csv.QUOTE_ALL) wr.writerow(self.rep_id) def create_ncov_data(self, cor, cov, sigma, masks=None, reps=1, n_sub=1, output_dir=None): """ create continuous simulated data with covariance Args: cor: amount of covariance between each voxel and Y variable (an int or a vector) cov: amount of covariance between voxels (an int or a matrix) sigma: amount of noise to add mask: region(s) where we will have activations (list if more than one) reps: number of data repetitions n_sub: number of subjects to simulate output_dir: string path of directory to output data. If None, no data will be written **kwargs: Additional keyword arguments to pass to the prediction algorithm """ if masks is None: # Initialize Spheres with options for multiple radii and centers of the spheres (or just an int and a 3D list) A = self.n_spheres(10, None) # parameters are (radius, center) masks = nib.Nifti1Image(A.astype(np.float32), affine=self.brain_mask.affine) if type(masks) is nib.nifti1.Nifti1Image: masks = [masks] if type(cor) is float or type(cor) is int: cor = [cor] if type(cov) is float or type(cor) is int: cov = [cov] if not len(cor) == len(masks): raise ValueError("cor matrix has incompatible dimensions for mask list of length " + str(len(masks))) if not len(cov) == len(masks) or len(masks) == 0 or not len(cov[0]) == len(masks): raise ValueError("cov matrix has incompatible dimensions for mask list of length " + str(len(masks))) # Create n_reps with cov for each voxel within sphere # Build covariance matrix with each variable correlated with y amount 'cor' and each other amount 'cov' flat_masks = self.nifti_masker.fit_transform(masks) print("Building correlation/covariation matrix...") n_vox = np.sum(flat_masks == 1, axis=1) # this is a list, each entry contains number voxels for given mask if 0 in n_vox: raise ValueError("one or more processing mask does not fit inside the brain mask") cov_matrix = np.zeros([np.sum(n_vox)+1, np.sum(n_vox)+1]) # one big covariance matrix for i, nv in enumerate(n_vox): cstart = np.sum(n_vox[:i]) + 1 cstop = cstart + nv cov_matrix[0, cstart:cstop] = cor[i] # set covariance with y cov_matrix[cstart:cstop, 0] = cor[i] # set covariance with all other voxels for j in range(len(masks)): rstart = np.sum(n_vox[:j]) + 1 rstop = rstart + nv cov_matrix[cstart:cstop, rstart:rstop] = cov[i][j] # set covariance of this mask's voxels with each of other masks np.fill_diagonal(cov_matrix, 1) # set diagonal to 1 # these operations happen in one vector that we'll later split into the separate regions print("Generating multivariate normal distribution...") mv_sim_l = np.random.multivariate_normal(np.zeros([np.sum(n_vox)+1]), cov_matrix, size=reps) print(mv_sim_l) self.y = mv_sim_l[:, 0] mv_sim = mv_sim_l[:, 1:] new_dats = np.ones([mv_sim.shape[0], flat_masks.shape[1]]) for rep in range(reps): for mask_i in range(len(masks)): start = int(np.sum(n_vox[:mask_i])) stop = int(start + n_vox[mask_i]) print(rep, start, stop) new_dats[rep, np.where(flat_masks[mask_i, :] == 1)] = mv_sim[rep, start:stop] noise = np.random.standard_normal(size=new_dats.shape[1])*sigma self.data = self.nifti_masker.inverse_transform(np.add(new_dats, noise)) # append 3d simulated data to list self.rep_id = [1] * len(self.y) print("Generating subject-level noise...") print("y == " + str(self.y.shape)) if n_sub > 1: self.y = list(self.y) y = list(self.y) for s in range(1, n_sub): # ask Luke about this new version noise = np.random.standard_normal(size=new_dats.shape[1])*sigma next_subj = self.nifti_masker.inverse_transform(np.add(new_dats, noise)) self.data = nib.concat_images([self.data, next_subj], axis=3) y += list(self.y + np.random.randn(len(self.y))*sigma) print("y == " + str(len(y))) self.rep_id += [s+1] * len(mv_sim[:, 0]) self.y = np.array(y) print("Saving to " + str(output_dir)) print("dat == " + str(self.data.shape)) print("y == " + str(self.y.shape)) if output_dir is not None: if type(output_dir) is str: if not os.path.isdir(output_dir): os.makedirs(output_dir) self.data.to_filename(os.path.join(output_dir, 'simulated_data_' + str(sigma) + 'sigma_' + str(n_sub) + 'subj.nii.gz')) y_file = open(os.path.join(output_dir, 'y.csv'), 'wb') wr = csv.writer(y_file, quoting=csv.QUOTE_ALL) wr.writerow(self.y) rep_id_file = open(os.path.join(output_dir, 'rep_id.csv'), 'wb') wr = csv.writer(rep_id_file, quoting=csv.QUOTE_ALL) wr.writerow(self.rep_id) class SimulateGrid(object): def __init__(self, grid_width=100, signal_width=20, n_subjects=20, sigma=1, signal_amplitude=None): self.isfit = False self.thresholded = None self.threshold = None self.threshold_type = None self.correction = None self.t_values = None self.p_values = None self.n_subjects = n_subjects self.sigma = sigma self.grid_width = grid_width self.data = self._create_noise() if signal_amplitude is not None: self.add_signal(signal_amplitude=signal_amplitude, signal_width=signal_width) else: self.signal_amplitude = None self.signal_mask = None def _create_noise(self): '''Generate simualted data using object parameters Returns: simulated_data (np.array): simulated noise using object parameters ''' return np.random.randn(self.grid_width, self.grid_width, self.n_subjects) * self.sigma def add_signal(self, signal_width=20, signal_amplitude=1): '''Add rectangular signal to self.data Args: signal_width (int): width of signal box signal_amplitude (int): intensity of signal ''' if signal_width >= self.grid_width: raise ValueError('Signal width must be smaller than total grid.') self.signal_amplitude = signal_amplitude self.create_mask(signal_width) signal = np.repeat(np.expand_dims(self.signal_mask, axis=2), self.n_subjects, axis=2) self.data = deepcopy(self.data) + signal * self.signal_amplitude def create_mask(self, signal_width): '''Create a mask for where the signal is located in grid.''' mask = np.zeros((self.grid_width, self.grid_width)) mask[int((np.floor((self.grid_width/2)-(signal_width/2)))):int(np.ceil((self.grid_width/2)+(signal_width/2))), int((np.floor((self.grid_width/2)-(signal_width/2)))):int(np.ceil((self.grid_width/2)+(signal_width/2)))] = 1 self.signal_width = signal_width self.signal_mask = mask def _run_ttest(self, data): '''Helper function to run ttest on data''' flattened = data.reshape(self.grid_width*self.grid_width, self.n_subjects) t, p = ttest_1samp(flattened.T, 0) t = np.reshape(t, (self.grid_width, self.grid_width)) p = np.reshape(p, (self.grid_width, self.grid_width)) return (t, p) def _run_permutation(self, data): '''Helper function to run a nonparametric one-sample permutation test''' flattened = data.reshape(self.grid_width*self.grid_width, self.n_subjects) stats_all = [] for i in range(flattened.shape[0]): stats = one_sample_permutation(flattened[i,:]) stats_all.append(stats) mean = np.reshape(np.array([x['mean'] for x in stats_all]), (self.grid_width, self.grid_width)) p = np.reshape(np.array([x['p'] for x in stats_all]), (self.grid_width, self.grid_width)) return (mean, p) def fit(self): '''Run ttest on self.data''' if self.isfit: raise ValueError("Can't fit because ttest has already been run.") self.t_values, self.p_values = self._run_ttest(self.data) self.isfit = True def _threshold_simulation(self, t, p, threshold, threshold_type, correction=None): '''Helper function to threshold simulation Args: threshold (float): threshold to apply to simulation threshhold_type (str): type of threshold to use can be a specific t-value or p-value ['t', 'p'] Returns: threshold_data (np.array): thresholded data ''' if correction == 'fdr': if threshold_type != 'q': raise ValueError("Must specify a q value when using fdr") if correction == 'permutation': if threshold_type != 'p': raise ValueError("Must specify a p value when using permutation") thresholded = deepcopy(t) if threshold_type == 't': thresholded[np.abs(t) < threshold] = 0 elif threshold_type == 'p': thresholded[p > threshold] = 0 elif threshold_type == 'q': fdr_threshold = fdr(p.flatten(), q=threshold) if fdr_threshold < 0: thresholded = np.zeros(thresholded.shape) else: thresholded[p > fdr_threshold] = 0 else: raise ValueError("Threshold type must be ['t','p','q']") return thresholded def threshold_simulation(self, threshold, threshold_type, correction=None): '''Threshold simulation Args: threshold (float): threshold to apply to simulation threshhold_type (str): type of threshold to use can be a specific t-value or p-value ['t', 'p', 'q'] ''' if not self.isfit: raise ValueError("Must fit model before thresholding.") if correction == 'fdr': self.corrected_threshold = fdr(self.p_values.flatten()) self.correction = correction self.thresholded = self._threshold_simulation(self.t_values, self.p_values, threshold, threshold_type, correction) self.threshold = threshold self.threshold_type = threshold_type self.fp_percent = self._calc_false_positives(self.thresholded) if self.signal_mask is not None: self.tp_percent = self._calc_true_positives(self.thresholded) def _calc_false_positives(self, thresholded): '''Calculate percent of grid containing false positives Args: thresholded (np.array): thresholded grid Returns: fp_percent (float): percentage of grid that contains false positives ''' if self.signal_mask is None: fp_percent = np.sum(thresholded != 0)/(self.grid_width**2) else: fp_percent = np.sum(thresholded[self.signal_mask != 1] != 0)/(self.grid_width**2 - self.signal_width**2) return fp_percent def _calc_true_positives(self, thresholded): '''Calculate percent of mask containing true positives Args: thresholded (np.array): thresholded grid Returns: tp_percent (float): percentage of grid that contains true positives ''' if self.signal_mask is None: raise ValueError('No mask exists, run add_signal() first.') tp_percent = np.sum(thresholded[self.signal_mask == 1] != 0)/(self.signal_width**2) return tp_percent def _calc_false_discovery_rate(self, thresholded): '''Calculate percent of activated voxels that are false positives Args: thresholded (np.array): thresholded grid Returns: fp_percent (float): percentage of activated voxels that are false positives ''' if self.signal_mask is None: raise ValueError('No mask exists, run add_signal() first.') fp_percent = np.sum(thresholded[self.signal_mask == 0] > 0)/np.sum(thresholded > 0) return fp_percent def run_multiple_simulations(self, threshold, threshold_type, n_simulations=100, correction=None): '''This method will run multiple simulations to calculate overall false positive rate''' if self.signal_mask is None: simulations = [self._run_ttest(self._create_noise()) for x in range(n_simulations)] else: signal = np.repeat(np.expand_dims(self.signal_mask, axis=2), self.n_subjects, axis=2) * self.signal_amplitude simulations = [self._run_ttest(self._create_noise() + signal) for x in range(n_simulations)] self.multiple_thresholded = [self._threshold_simulation(s[0], s[1], threshold, threshold_type, correction=correction) for s in simulations] self.multiple_fp = np.array([self._calc_false_positives(x) for x in self.multiple_thresholded]) self.fpr = np.mean(np.array([x for x in self.multiple_fp]) > 0) if self.signal_mask is not None: self.multiple_tp = np.array([self._calc_true_positives(x) for x in self.multiple_thresholded]) self.multiple_fdr = np.array([self._calc_false_discovery_rate(x) for x in self.multiple_thresholded]) def plot_grid_simulation(self, threshold, threshold_type, n_simulations=100, correction=None): '''Create a plot of the simulations''' if not self.isfit: self.fit() self.threshold_simulation(threshold=threshold, threshold_type=threshold_type, correction=correction) self.run_multiple_simulations(threshold=threshold, threshold_type=threshold_type, n_simulations=n_simulations) if self.signal_mask is None: f,a = plt.subplots(ncols=3, figsize=(15, 5)) else: f,a = plt.subplots(ncols=4, figsize=(18, 5)) a[3].hist(self.multiple_tp) a[3].set_ylabel('Frequency', fontsize=18) a[3].set_xlabel('Percent Signal Recovery', fontsize=18) a[3].set_title('Average Signal Recovery', fontsize=18) a[0].imshow(self.t_values) a[0].set_title('Random Noise', fontsize=18) a[0].axes.get_xaxis().set_visible(False) a[0].axes.get_yaxis().set_visible(False) a[1].imshow(self.thresholded) a[1].set_title(f'Threshold: {threshold_type} = {threshold}', fontsize=18) a[1].axes.get_xaxis().set_visible(False) a[1].axes.get_yaxis().set_visible(False) a[2].plot(binom.pmf(np.arange(0, n_simulations, 1), n_simulations, np.mean(self.multiple_fp>0))) a[2].axvline(x=np.mean(self.fpr) * n_simulations, color='r', linestyle='dashed', linewidth=2) a[2].set_title(f'False Positive Rate = {self.fpr:.2f}', fontsize=18) a[2].set_ylabel('Probability', fontsize=18) a[2].set_xlabel('False Positive Rate', fontsize=18) plt.tight_layout()