diff --git a/brainiak/reprsimil/brsa.py b/brainiak/reprsimil/brsa.py index dd7ba6497..3f06f82d8 100755 --- a/brainiak/reprsimil/brsa.py +++ b/brainiak/reprsimil/brsa.py @@ -19,8 +19,25 @@ .. [Cai2016] "A Bayesian method for reducing bias in neural representational similarity analysis", M.B. Cai, N. Schuck, J. Pillow, Y. Niv, - Neural Information Processing Systems (NIPS) 29, 2016. - http://papers.nips.cc/paper/6131-a-bayesian-method-for-reducing-bias-in-neural-representational-similarity-analysis + Advances in Neural Information Processing Systems 29, 2016, 4952--4960 + Available at: + http://papers.nips.cc/paper/6131-a-bayesian-method-for-reducing-bias-in-neural-representational-similarity-analysis.pdf + Some extensions beyond the paper have been made here. + More specifically: + (1) spatial noise correlation (or alternatively + considered as signals of intrinsic fluctuation not related to tasks); + (2) new fitting procedure which marginalizes all voxel-specific + parameters such as pseudo-SNR, noise variance, auto-regressive + coefficients, in `.GBRSA` class; + (3) capacity to jointly fit to data of multiple participants, + in `.GBRSA` class; + (4) cross-validation score between a full model and a null model + in `.BRSA.score` and `.GBRSA.score`; + (5) capability of decoding task-related signals and intrinsic + fluctuation from new data based on model fitted from training data + in `.BRSA.transform` and `.GBRSA.transform`. + `.GBRSA` may perform better than `.BRSA` due to (2). It can be + use for single participant as well. """ # Authors: Mingbo Cai @@ -30,52 +47,208 @@ import scipy import scipy.optimize import scipy.stats +import scipy.special import time -from sklearn.base import BaseEstimator +from sklearn.base import BaseEstimator, TransformerMixin from sklearn.utils import assert_all_finite, check_random_state +from sklearn.decomposition import PCA, FactorAnalysis, SparsePCA, FastICA import logging import brainiak.utils.utils as utils import scipy.spatial.distance as spdist +from nitime import algorithms as alg +import copy logger = logging.getLogger(__name__) __all__ = [ - "BRSA", + "BRSA", "GBRSA", "prior_GP_var_inv_gamma", "prior_GP_var_half_cauchy", + "Ncomp_SVHT_MG_DLD_approx" ] -class BRSA(BaseEstimator): +def prior_GP_var_inv_gamma(y_invK_y, n_y, tau_range): + """ Imposing an inverse-Gamma prior onto the variance (tau^2) + parameter of a Gaussian Process, which is in turn a prior + imposed over an unknown function y = f(x). + The inverse-Gamma prior of tau^2, tau^2 ~ invgamma(shape, scale) + is described by a shape parameter alpha=2 and a scale parameter + beta=tau_range^2. tau_range describes the reasonable range of + tau in the inverse-Gamma prior. + The data y's at locations x's are assumed to follow Gaussian Process: + f(x, x') ~ N(0, K(x, x') / 2 tau^2), where K is a kernel + function defined on x. For n observations, K(x1, x2, ..., xn) is + an n by n positive definite matrix. + Given the prior parameter tau_range, number of observations + n_y, and y_invK_y = y * inv(K) * y', + the function returns the MAP estimate of tau^2 and + the log posterior probability of tau^2 at the MAP value: + log(p(tau^2|tau_range)). + This function is written primarily for BRSA but can also + be used elsewhere. y in this case corresponds to the log of + SNR in each voxel. GBRSA does not rely on this function. + An alternative form of prior is half-Cauchy prior on tau. + Inverse-Gamma prior penalizes for both very small and very + large values of tau, while half-Cauchy prior only penalizes + for very large values of tau. + For more information on usage, see description in BRSA class: + `.BRSA` + + See also: `.prior_GP_var_half_cauchy` + + Parameters + ---------- + y_invK_y: float + y * inv(K) * y^T, where y=f(x) is a vector of observations + of unknown function f at different locations x. + K is correlation matrix of f between different locations, based + on a Gaussian Process (GP) describing the smoothness property + of f. K fully incorporates the form of the kernel + and the length scale of the GP, but not the variance of the GP + (the purpose of this function is to estimate the variance). + n_y: int, number of observations + tau_range: float, + The reasonable range of tau, the standard deviation of the + Gaussian Process imposed on y=f(x). tau_range is parameter + of the inverse-Gamma prior. Say, if you expect the standard + deviation of the Gaussian process to be around 3, tau_range + can be set to 3. + The smaller it is, the more penalization is imposed + on large variation of y. + Returns + ------- + tau2: The MAP estimation of tau^2 based on the prior on tau + and y_invK_y. + log_ptau: log(p(tau)) of the returned tau^2 based on the + inverse-Gamma prior. + """ + alpha = 2 + tau2 = (y_invK_y + 2 * tau_range**2) / (alpha * 2 + 2 + n_y) + log_ptau = scipy.stats.invgamma.logpdf( + tau2, scale=tau_range**2, a=2) + return tau2, log_ptau + + +def prior_GP_var_half_cauchy(y_invK_y, n_y, tau_range): + """ Imposing a half-Cauchy prior onto the standard deviation (tau) + of the Gaussian Process which is in turn a prior imposed over + a function y = f(x). + The scale parameter of the half-Cauchy prior is tau_range. + The function returns the MAP estimate of tau^2 and + log(p(tau|tau_range)) for the MAP value of tau^2, + where tau_range describes the reasonable range of tau + in the half-Cauchy prior. + An alternative form of prior is inverse-Gamma prior on tau^2. + Inverse-Gamma prior penalizes for both very small and very + large values of tau, while half-Cauchy prior only penalizes + for very large values of tau. + For more information on usage, see description in BRSA class: + `.BRSA` + """ + tau2 = (y_invK_y - n_y * tau_range**2 + + np.sqrt(n_y**2 * tau_range**4 + (2 * n_y + 8) + * tau_range**2 * y_invK_y + y_invK_y**2))\ + / 2 / (n_y + 2) + log_ptau = scipy.stats.halfcauchy.logpdf( + tau2**0.5, scale=tau_range) + return tau2, log_ptau + + +def Ncomp_SVHT_MG_DLD_approx(X, zscore=True): + """ This function implements the approximate calculation of the + optimal hard threshold for singular values, by Matan Gavish + and David L. Donoho: + "The optimal hard threshold for singular values is 4 / sqrt(3)" + http://ieeexplore.ieee.org/stamp/stamp.jsp?arnumber=6846297 + + Parameters + ---------- + X: 2-D numpy array of size [n_T, n_V] + The data to estimate the optimal rank for selecting principal + components. + zscore: Boolean, default: True + Whether to z-score the data before calculating number of components. + + Returns + ------- + ncomp: integer + The optimal number of components determined by the method of MG + and DLD + """ + beta = X.shape[0] / X.shape[1] + if beta > 1: + beta = 1 / beta + omega = 0.56 * beta ** 3 - 0.95 * beta ** 2 + 1.82 * beta + 1.43 + if zscore: + sing = np.linalg.svd(_zscore(X), False, False) + else: + sing = np.linalg.svd(X, False, False) + thresh = omega * np.median(sing) + ncomp = int(np.sum(np.logical_and(sing > thresh, np.logical_not( + np.isclose(sing, thresh))))) + # In the line above, we look for the singular values larger than + # the threshold but excluding those that happen to be "just" larger + # than the threshold by an amount close to the numerical precision. + # This is to prevent close-to-zero singular values to be included if + # the median of the eigenvalues is close to 0 (which could happen + # when the input X has lower rank than its minimal size. + return ncomp + + +def _zscore(a): + """ Calculating z-score of data on the first axis. + If the numbers in any column are all equal, scipy.stats.zscore + will return NaN for this column. We shall correct them all to + be zeros. + + Parameters + ---------- + a: numpy array + + Returns + ------- + zscore: numpy array + The z-scores of input "a", with any columns including non-finite + numbers replaced by all zeros. + """ + assert a.ndim > 1, 'a must have more than one dimensions' + zscore = scipy.stats.zscore(a, axis=0) + zscore[:, np.logical_not(np.all(np.isfinite(zscore), axis=0))] = 0 + return zscore + + +class BRSA(BaseEstimator, TransformerMixin): """Bayesian representational Similarity Analysis (BRSA) Given the time series of neural imaging data in a region of interest (ROI) and the hypothetical neural response (design matrix) to each experimental condition of interest, - calculate the shared covariance matrix of - the voxels(recording unit)' response to each condition, + calculate the shared covariance matrix U of + the voxels(recording unit)' response profiles \\beta_i to each condition, and the relative SNR of each voxels. The relative SNR could be considered as the degree of contribution of each voxel to this shared covariance matrix. - A correlation matrix converted from the covariance matrix + A correlation matrix converted from the covariance matrix U will be provided as a quantification of neural representational similarity. .. math:: - Y = X \\cdot \\beta + \\epsilon + Y = X \\cdot \\beta + X_0 \\cdot \\beta_0 + \\epsilon \\beta_i \\sim N(0,(s_{i} \\sigma_{i})^2 U) + \\epsilon_i \\sim AR(1) + Parameters ---------- - n_iter : int, default: 200 + n_iter : int. Default: 50 Number of maximum iterations to run the algorithm. - rank : int, default: None + rank : int. Default: None The rank of the covariance matrix. If not provided, the covariance matrix will be assumed to be full rank. When you have many conditions (e.g., calculating the similarity matrix of responses to each event), - you might want to start with specifying a lower rank and use metrics - such as AIC or BIC to decide the optimal rank. - auto_nuisance: boolean, default: True + you might try specifying a lower rank. + auto_nuisance: boolean. Default: True In order to model spatial correlation between voxels that cannot be accounted for by common response captured in the design matrix, we assume that a set of time courses not related to the task @@ -85,34 +258,80 @@ class BRSA(BaseEstimator): The other way is to take the first n_nureg principal components in the residual after subtracting the response to the design matrix from the data, and use these components as the nuisance regressor. - If this flag is turned on, the nuisance regressor provided by the - user is used only in the first round of fitting. The PCs from - residuals will be used in the next round of fitting. + This flag is for the second approach. If turned on, + PCA or factor analysis will be applied to the residuals + to obtain new nuisance regressors in each round of fitting. + These two approaches can be combined. If the users provide nuisance + regressors and set this flag as True, then the first n_nureg + principal components of the residuals after subtracting + both the responses to design matrix and the user-supplied nuisance + regressors will be used in addition to the nuisance regressors + provided by the users. Note that nuisance regressor is not required from user. If it is - not provided, DC components for each run will be used as nuisance - regressor in the initial fitting. - n_nureg: int, default: 6 + not provided, DC components for each run will be included as nuisance + regressor regardless of the auto_nuisance parameter. + n_nureg: Optional[int]. Default: None Number of nuisance regressors to use in order to model signals shared across voxels not captured by the design matrix. - This parameter will not be effective in the first round of fitting. - GP_space: boolean, default: False + This number is in addition to any nuisance regressor that the user + has already provided. + If set to None, the number of nuisance regressors will be + automatically determined based on M Gavish + and D Donoho's approximate estimation of optimal hard + threshold for singular values. + This only takes effect if auto_nuisance is True. + nureg_zscore: boolean, default: True + A flag to tell the algorithm whether data is z-scored before + estimating the number of nuisance regressor components necessary to + account for spatial noise correlation. It also determinie whether + the residual noise is z-scored before estimating the nuisance + regressors from residual. + This only takes effect if auto_nuisance is True. + nureg_method: string, naming a method from sklearn.decomposition. + 'PCA', 'ICA', 'FA' or 'SPCA' are currently supported. Default: 'PCA' + The method to estimate the shared component in noise across voxels. + This only takes effect if auto_nuisance is True. + baseline_single: boolean, default: False + A time course of constant 1 will be included to the nuisance + regressor regardless of whether the user requests. + If baseline_single is set to False, one such regressor is included + for each fMRI run, but a single component in beta0\_ will be + computed as the average of the weight maps corresponding to + these regressors. This might cause underestimation of noise variance. + If baseline_single is True, only one regressor of constant 1 will be + used for the whole dataset. This might be desirable if you + believe the average image intensity might not scale with the + same proportion for different voxels across scan. In other words, + it is possible that some part of the brain is more vulnerable to + change in baseline intensity due to facts such as + field inhomogeneity. Setting baseline_single to True will force the + nuisance regressors automatically estimated from residuals to + capture this. However, when each task condition only occurs in one + run and when the design matrix in each run sums together close to + a flat line, this option can cause the estimated similarity to be + extremely high between conditions occuring in the same run. + GP_space: boolean. Default: False Whether to impose a Gaussion Process (GP) prior on the log(pseudo-SNR). - If true, the GP has a kernel defined over spatial coordinate. - This is experimental and slow. I find that when SNR is genrally low, - smoothness can be overestimated. But I think this is better than - not imposing any regularization. - GP_inten: boolean, defualt: False - Whether to include a kernel definved over the intensity of image. + If true, the GP has a kernel defined over spatial coordinate + of each voxel. The idea behind this option is that + adjacent voxels should have similar SNRs. + This is relatively slow for big ROI. We find that when SNR + is generally low, smoothness can be overestimated. + But such regularization may reduce variance in the estimated + SNR map and similarity matrix. + GP_inten: boolean. Defualt: False + Whether to include a kernel defined over the intensity of image. GP_space should be True as well if you want to use this, because the smoothness should be primarily in space. - Smoothness in intensity is just complementary. - tol: tolerance parameter passed to the minimizer. - verbose : boolean, default: False - Verbose mode flag. - eta: a small number added to the diagonal element of the - covariance matrix in the Gaussian Process prior. This is - to ensure that the matrix is invertible. - space_smooth_range: the distance (in unit the same as what + Smoothness in intensity is just complementary. The idea + behind this option is that voxels should have similar + SNRs when they are both adjacent (imposed by GP_space) + and are of the same tissue type (when their image intensities + are close). If you accept the second assumption, then + you can set GP_inten as True and provide an array to the `inten` + variable, expressing the intensities (brightness) for each voxel. + space_smooth_range: float. Default: None + The distance (in unit the same as what you would use when supplying the spatial coordiates of each voxel, typically millimeter) which you believe is the maximum range of the length scale parameter of @@ -120,70 +339,177 @@ class BRSA(BaseEstimator): used to impose a half-Cauchy prior on the length scale. If not provided, the program will set it to half of the maximum distance between all voxels. - inten_smooth_range: the difference in image intensity which + inten_smooth_range: float. Default: None + The difference in image intensity which you believe is the maximum range of plausible length scale for the Gaussian Process defined over image intensity. Length scales larger than this are allowed, but will be penalized. If not supplied, this parameter will be set to half of the maximal intensity difference. - tau_range: the reasonable range of the standard deviation - of the Gaussian Process. Since the Gaussian Process is - imposed on the log(SNR), this range should not be too - large. 10 is a pretty loose range. This parameter is - used in a half-Cauchy prior on the standard deviation - init_iter: how many initial iterations to fit the model + tau_range: float. Default: 5.0 + The reasonable range of the standard deviation + of log(SNR). This range should not be too + large. 5 is a loose range. + When a Gaussian Process is imposed on the log(SNR), + this parameter is used in a half-Cauchy prior + on the standard deviation, or an inverse-Gamma prior + on the variance of the GP. + tau2_prior: Callable[[float, int, float]], [float, float]], + Default: prior_GP_var_inv_gamma. + Can be prior_GP_var_inv_gamma or prior_GP_var_half_cauchy, + or a custom function. + The function which impose a prior for tau^2, the variance of the + GP prior on log(SNR), and returns the MAP estimate of tau^2. + It can be either prior_GP_var_inv_gamma for inverse-Gamma + or prior_GP_var_half_cauchy for half-Cauchy. + half-Cauchy prior is in fact imposed on tau. + But tau_range describes the range of tau in the prior in both cases. + Both functions are part of brsa module. + See also `.prior_GP_var_inv_gamma` and + `.prior_GP_var_half_cauchy` + To use the default inverse-Gamma prior, you can ignore this argument:: + + from brainiak.reprsimil.brsa import BRSA + brsa = BRSA() + + If you want to try the alternative half-Cauchy prior, + then you need to import it in addition to BRSA:: + + from brainiak.reprsimil.brsa import BRSA, prior_GP_var_half_cauchy + brsa = BRSA(tau2_prior=prior_GP_var_half_cauchy) + + eta: float. Default: 0.0001 + A small number added to the diagonal element of the + covariance matrix in the Gaussian Process prior. This is + to ensure that the matrix is invertible. + init_iter: int. Default: 20 + How many initial iterations to fit the model without introducing the GP prior before fitting with it, if GP_space or GP_inten is requested. This initial fitting is to give the parameters a good starting point. - optimizer: the optimizer to use for minimizing cost function. - We use 'BFGS' as a default. Users can try other optimizer - coming with scipy.optimize.minimize, or a custom - optimizer. + optimizer: str or callable. Default: 'BFGS' + The optimizer to use for minimizing cost function which + scipy.optimize.minimize can accept. + We use 'L-BFGS-B' as a default. Users can try other strings + corresponding to optimizer provided by scipy.optimize.minimize, + or a custom optimizer, such as 'L-BFGS-B' or 'CG'. + Note that BRSA fits a lot of parameters. So a chosen optimizer + should accept gradient (Jacobian) of the cost function. Otherwise + the fitting is likely to be unbarely slow. We do not calculate + Hessian of the objective function. So an optimizer which requires + Hessian cannot be used. random_state : RandomState or an int seed. Default: None A random number generator instance to define the state of the random permutations generator whenever the module needs to generate random number (e.g., initial parameter of the Cholesky factor). + anneal_speed: float. Default: 20 + Annealing is introduced in fitting of the Cholesky + decomposition of the shared covariance matrix. The amount + of perturbation decays exponentially. This parameter sets + the ratio of the maximum number of iteration to the + time constant of the exponential. + anneal_speed=10 means by n_iter/10 iterations, + the amount of perturbation is reduced by 2.713 times. + minimize_options: dictionary. + Default: {'gtol': 1e-4, 'disp': False, 'maxiter': 6} + This is the dictionary passed as the options argument to + scipy.optimize.minize which minimizes the cost function during + fitting. Notice that the minimization is performed for many times, + alternating between optimizing the covariance matrix U underlying + the pattern similarity matrix, and SNR. At most n_iter times + of this alternation is performed. So within each step of fitting, + the step of iteration performed by scipy.optimize.minize does not + have to be very large. In other words, scipy.optimize.minize does + not need to converge within each step of the alternating fitting + procedure. + tol: float. Default: 1e-4. + Tolerance parameter passed to scipy.optimize.minimize. It is also + used for determining convergence of the alternating fitting + procedure. Attributes ---------- - U_ : The shared covariance matrix, shape=[condition,condition]. - L_ : The Cholesky factor of the shared covariance matrix - (lower-triangular matrix), shape=[condition,condition]. - C_: the correlation matrix derived from the shared covariance matrix, - shape=[condition,condition] - nSNR_ : array, shape=[voxels,] - The pseuso-SNR of all voxels. - They are normalized such that the geometric mean is 1 - sigma_ : array, shape=[voxels,] + U_ : numpy array, shape=[condition,condition]. + The shared covariance matrix. + L_ : numpy array, shape=[condition,rank]. + The Cholesky factor of the shared covariance matrix + (lower-triangular matrix). + C_: numpy array, shape=[condition,condition]. + The correlation matrix derived from the shared covariance matrix. + This is the estimated similarity matrix between neural patterns + to your task conditions. Notice that it is recommended that + you also check U\_, which is the covariance matrix underlying + this correlation matrix. In cases there is almost no response + to your task conditions, the diagonal values of U\_ would become + very small and C\_ might contain many correlation coefficients + close to 1 or -1. This might not reflect true strong correlation + or strong negative correlation, but a result of lack of + task-related neural activity, design matrix that does not match + true neural response, or not enough data. + It is also recommended to check nSNR\_ after mapping it back to + the brain. A "reasonable" map should at least have higher values + in gray matter in than white matter. + nSNR_ : numpy array, shape=[voxels,]. + The normalized pseuso-SNR of all voxels. + They are normalized such that the geometric mean is 1. + Note that this attribute can not be interpreted as true SNR, + but the relative ratios between voxel indicates the contribution + of each voxel to the representational similarity structure. + sigma_ : numpy array, shape=[voxels,]. The estimated standard deviation of the noise in each voxel Assuming AR(1) model, this means the standard deviation - of the refreshing noise. - rho_ : array, shape=[voxels,] + of the innovation noise. + rho_ : numpy array, shape=[voxels,]. The estimated autoregressive coefficient of each voxel - bGP_ : scalar, only if GP_space or GP_inten is True. - the standard deviation of the GP prior - lGPspace_ : scalar, only if GP_space or GP_inten is True - the length scale of Gaussian Process prior of log(SNR) - lGPinten_: scalar, only if GP_inten is True - the length scale in fMRI intensity of the GP prior of log(SNR) + bGP_ : float, only if GP_space or GP_inten is True. + The standard deviation of the GP prior + lGPspace_ : float, only if GP_space or GP_inten is True + The length scale of Gaussian Process prior of log(SNR) + lGPinten_: float, only if GP_inten is True + The length scale in fMRI intensity of the GP prior of log(SNR) beta_: array, shape=[conditions, voxels] The maximum a posterior estimation of the response amplitudes of each voxel to each task condition. - beta0_: array, shape=[n_nureg, voxels] + beta0_: numpy array, shape=[n_nureg + n_base, voxels] The loading weights of each voxel for the shared time courses - not captured by the design matrix. + not captured by the design matrix. This helps capture the + structure of spatial covariance of task-unrelated signal. + n_base is the number of columns of the user-supplied nuisance + regressors plus one for DC component + X0_: numpy array, shape=[time_points, n_nureg + n_base] + The estimated time course that is shared across voxels but + unrelated to the events of interest (design matrix). + beta0_null_: numpy array, shape=[n_nureg + n_base, voxels] + The equivalent of beta0\_ in a null model which does not + include the design matrix and response pattern beta. + X0_null_: numpy array, shape=[time_points, n_nureg + n_base] + The equivalent of X0\_ in a null model which does not + include the design matrix and response pattern beta + n_nureg_: int + Number of nuisance regressor in addition to such + regressors provided by the user (if any), if auto_nuisance + is set to True. If n_nureg is set to 'opt', + this will be estimated from data. 'opt' will use M Gavish + and D Donoho's approximate estimation of optimal hard + threshold for singular values. random_state_: `RandomState` Random number generator initialized using random_state. """ def __init__( - self, n_iter=50, rank=None, GP_space=False, GP_inten=False, - tol=2e-3, auto_nuisance=True, n_nureg=6, verbose=False, - eta=0.0001, space_smooth_range=None, inten_smooth_range=None, - tau_range=10.0, init_iter=20, optimizer='BFGS', - random_state=None): + self, n_iter=50, rank=None, + auto_nuisance=True, n_nureg=None, nureg_zscore=True, + nureg_method='PCA', baseline_single=False, + GP_space=False, GP_inten=False, + space_smooth_range=None, inten_smooth_range=None, + tau_range=5.0, + tau2_prior=prior_GP_var_inv_gamma, + eta=0.0001, init_iter=20, optimizer='BFGS', + random_state=None, anneal_speed=10, tol=1e-4, + minimize_options={'gtol': 1e-4, 'disp': False, + 'maxiter': 6}): self.n_iter = n_iter self.rank = rank self.GP_space = GP_space @@ -191,7 +517,31 @@ def __init__( self.tol = tol self.auto_nuisance = auto_nuisance self.n_nureg = n_nureg - self.verbose = verbose + self.nureg_zscore = nureg_zscore + if auto_nuisance: + assert (n_nureg is None) \ + or (isinstance(n_nureg, int) and n_nureg > 0), \ + 'n_nureg should be a positive integer or None'\ + ' if auto_nuisance is True.' + if self.nureg_zscore: + self.preprocess_residual = lambda x: _zscore(x) + else: + self.preprocess_residual = lambda x: x + if nureg_method == 'FA': + self.nureg_method = lambda x: FactorAnalysis(n_components=x) + elif nureg_method == 'PCA': + self.nureg_method = lambda x: PCA(n_components=x, whiten=True) + elif nureg_method == 'SPCA': + self.nureg_method = lambda x: SparsePCA(n_components=x, + max_iter=20, tol=tol) + elif nureg_method == 'ICA': + self.nureg_method = lambda x: FastICA(n_components=x, + whiten=True) + else: + raise ValueError('nureg_method can only be FA, PCA, ' + 'SPCA(for sparse PCA) or ICA') + self.baseline_single = baseline_single + self.minimize_options = minimize_options self.eta = eta # This is a tiny ridge added to the Gaussian Process # covariance matrix template to gaurantee that it is invertible. @@ -203,11 +553,13 @@ def __init__( # defined on spatial coordinate and a kernel defined on # image intensity. self.tau_range = tau_range + self.tau2_prior = tau2_prior self.init_iter = init_iter # When imposing smoothness prior, fit the model without this # prior for this number of iterations. self.optimizer = optimizer self.random_state = random_state + self.anneal_speed = anneal_speed return def fit(self, X, design, nuisance=None, scan_onsets=None, coords=None, @@ -216,22 +568,25 @@ def fit(self, X, design, nuisance=None, scan_onsets=None, coords=None, Parameters ---------- - X: 2-D numpy array, shape=[time_points, voxels] + X: numpy array, shape=[time_points, voxels] If you have multiple scans of the same participants that you want to analyze together, you should concatenate them along the time dimension after proper preprocessing (e.g. spatial alignment), and specify the onsets of each scan in scan_onsets. - design: 2-D numpy array, shape=[time_points, conditions] + design: numpy array, shape=[time_points, conditions] This is the design matrix. It should only include the hypothetic - response for task conditions. You do not need to include - regressors for a DC component or motion parameters, unless with - a strong reason. If you want to model head motion, + response for task conditions. You should not include + regressors for a DC component or motion parameters, unless you + want to estimate their pattern similarity with response patterns + to your task conditions. If you want to model head motion, you should include them in nuisance regressors. If you have multiple run, the design matrix of all runs should be concatenated along the time dimension, - with one column across runs for each condition. - nuisance: optional, 2-D numpy array, - shape=[time_points, nuisance_factors] + with every column for one condition across runs. + For example, if you have 3 runs of experiment of one participant, + with each run lasting 200 TR. And you have 4 conditions, + then design should be a 600 x 4 numpy array. + nuisance: optional, numpy array, shape=[time_points, nuisance_factors] The responses to these regressors will be marginalized out from each voxel, which means they are considered, but won't be assumed to share the same pseudo-SNR map with the design matrix. @@ -239,15 +594,17 @@ def fit(self, X, design, nuisance=None, scan_onsets=None, coords=None, relative contribution of design matrix to each voxel. You can provide time courses such as those for head motion to this parameter. - Note that if auto_nuisance is set to True, this input - will only be used in the first round of fitting. The first + Note that if auto_nuisance is set to True, the first n_nureg principal components of residual (excluding the response - to the design matrix) will be used as the nuisance regressor - for the second round of fitting. + to the design matrix and the user-provided nuisance regressors + and a constant baseline) + will be included as additional nuisance regressor after the + first round of fitting. If auto_nuisance is set to False, the nuisance regressors supplied by the users together with DC components will be used as nuisance time series. - scan_onsets: optional, an 1-D numpy array, shape=[runs,] + Please do not include time course of constant baseline in nuisance. + scan_onsets: optional, numpy array, shape=[runs,] This specifies the indices of X which correspond to the onset of each scanning run. For example, if you have two experimental runs of the same subject, each with 100 TRs, then scan_onsets @@ -256,10 +613,10 @@ def fit(self, X, design, nuisance=None, scan_onsets=None, coords=None, assume all data are from the same run. The effect of them is to make the inverse matrix of the temporal covariance matrix of noise block-diagonal. - coords: optional, 2-D numpy array, shape=[voxels,3] + coords: optional, numpy array, shape=[voxels,3] This is the coordinate of each voxel, used for implementing Gaussian Process prior. - inten: optional, 1-D numpy array, shape=[voxel,] + inten: optional, numpy array, shape=[voxel,] This is the average fMRI intensity in each voxel. It should be calculated from your data without any preprocessing such as z-scoring. Because it should reflect @@ -272,6 +629,9 @@ def fit(self, X, design, nuisance=None, scan_onsets=None, coords=None, """ logger.info('Running Bayesian RSA') + self.random_state_ = check_random_state(self.random_state) + # setting random seed + logger.debug('RandState set to {}'.format(self.random_state_)) assert not self.GP_inten or (self.GP_inten and self.GP_space),\ 'You must speficiy GP_space to True'\ @@ -279,7 +639,7 @@ def fit(self, X, design, nuisance=None, scan_onsets=None, coords=None, # Check input data assert_all_finite(X) - assert X.ndim == 2, 'The data should be 2 dimension ndarray' + assert X.ndim == 2, 'The data should be 2-dimensional ndarray' assert np.all(np.std(X, axis=0) > 0),\ 'The time courses of some voxels do not change at all.'\ @@ -288,7 +648,7 @@ def fit(self, X, design, nuisance=None, scan_onsets=None, coords=None, # check design matrix assert_all_finite(design) assert design.ndim == 2,\ - 'The design matrix should be 2 dimension ndarray' + 'The design matrix should be 2-dimensional ndarray' assert np.linalg.matrix_rank(design) == design.shape[1], \ 'Your design matrix has rank smaller than the number of'\ ' columns. Some columns can be explained by linear '\ @@ -303,7 +663,7 @@ def fit(self, X, design, nuisance=None, scan_onsets=None, coords=None, if nuisance is not None: assert_all_finite(nuisance) assert nuisance.ndim == 2,\ - 'The nuisance regressor should be 2 dimension ndarray' + 'The nuisance regressor should be 2-dimensional ndarray' assert np.linalg.matrix_rank(nuisance) == nuisance.shape[1], \ 'The nuisance regressor has rank smaller than the number of'\ 'columns. Some columns can be explained by linear '\ @@ -325,7 +685,7 @@ def fit(self, X, design, nuisance=None, scan_onsets=None, coords=None, 'But the voxel number of coords does not match that of '\ 'data X, or voxel coordinates are not provided. '\ 'Please make sure that coords is in the shape of '\ - '[ n_voxel x 3].' + '[n_voxel x 3].' assert coords.ndim == 2,\ 'The coordinate matrix should be a 2-d array' if self.GP_inten: @@ -340,6 +700,28 @@ def fit(self, X, design, nuisance=None, scan_onsets=None, coords=None, ' but GP_space or GP_inten is not set ' 'to True. The coordinates or intensity are' ' ignored.') + # Estimate the number of necessary nuisance regressors + if self.auto_nuisance: + if self.n_nureg is None: + logger.info('number of nuisance regressors is determined ' + 'automatically.') + run_TRs, n_runs = self._run_TR_from_scan_onsets( + X.shape[0], scan_onsets) + ts_dc = self._gen_legendre(run_TRs, [0]) + _, ts_base, _ = self._merge_DC_to_base( + ts_dc, nuisance, False) + ts_reg = np.concatenate((ts_base, design), axis=1) + beta_hat = np.linalg.lstsq(ts_reg, X)[0] + residuals = X - np.dot(ts_reg, beta_hat) + self.n_nureg_ = np.max( + [1, Ncomp_SVHT_MG_DLD_approx(residuals, + self.nureg_zscore)]) + logger.info('Use {} nuisance regressors to model the spatial ' + 'correlation in noise.'.format(self.n_nureg_)) + self.n_nureg_ = np.int32(self.n_nureg_) + else: + self.n_nureg_ = self.n_nureg + self.n_nureg_ = np.int32(self.n_nureg_) # Run Bayesian RSA # Note that we have a change of notation here. Within _fit_RSA_UV, # design matrix is named X and data is named Y, to reflect the @@ -348,34 +730,188 @@ def fit(self, X, design, nuisance=None, scan_onsets=None, coords=None, # However, in fit(), we keep the tradition of scikit-learn that # X is the input data to fit and y, a reserved name not used, is # the label to map to from X. + if not self.GP_space: # If GP_space is not requested, then the model is fitted # without imposing any Gaussian Process prior on log(SNR^2) self.U_, self.L_, self.nSNR_, self.beta_, self.beta0_,\ - self.sigma_, self.rho_ = \ - self._fit_RSA_UV(X=design, Y=X, X0=nuisance, - scan_onsets=scan_onsets) + self._beta_latent_, self.sigma_, self.rho_, _, _, _,\ + self.X0_ = self._fit_RSA_UV(X=design, Y=X, X_base=nuisance, + scan_onsets=scan_onsets) elif not self.GP_inten: # If GP_space is requested, but GP_inten is not, a GP prior # based on spatial locations of voxels will be imposed. self.U_, self.L_, self.nSNR_, self.beta_, self.beta0_,\ - self.sigma_, self.rho_, self.lGPspace_, self.bGP_ \ - = self._fit_RSA_UV( - X=design, Y=X, X0=nuisance, + self._beta_latent_, self.sigma_, self.rho_, \ + self.lGPspace_, self.bGP_, _, \ + self.X0_ = self._fit_RSA_UV( + X=design, Y=X, X_base=nuisance, scan_onsets=scan_onsets, coords=coords) else: # If both self.GP_space and self.GP_inten are True, # a GP prior based on both location and intensity is imposed. self.U_, self.L_, self.nSNR_, self.beta_, self.beta0_,\ - self.sigma_, self.rho_, self.lGPspace_, self.bGP_,\ - self.lGPinten_ = \ - self._fit_RSA_UV(X=design, Y=X, X0=nuisance, + self._beta_latent_, self.sigma_, self.rho_, \ + self.lGPspace_, self.bGP_, self.lGPinten_, self.X0_ = \ + self._fit_RSA_UV(X=design, Y=X, X_base=nuisance, scan_onsets=scan_onsets, coords=coords, inten=inten) self.C_ = utils.cov2corr(self.U_) + self.design_ = design.copy() + self._rho_design_, self._sigma2_design_ = \ + self._est_AR1(self.design_, same_para=True) + self._rho_X0_, self._sigma2_X0_ = self._est_AR1(self.X0_) + # AR(1) parameters of the design matrix and nuisance regressors, + # which will be used in transform or score. + + # Finally, we fit a null model with the same setting except + # that there is no response to X + self.beta0_null_, self.sigma_null_, self.rho_null_, \ + self.X0_null_ = self._fit_null(Y=X, X_base=nuisance, + scan_onsets=scan_onsets) + self._rho_X0_null_, self._sigma2_X0_null_ =\ + self._est_AR1(self.X0_null_) return self + def transform(self, X, y=None, scan_onsets=None): + """ Use the model to estimate the time course of response to + each condition (ts), and the time course unrelated to task + (ts0) which is spread across the brain. + This is equivalent to "decoding" the design matrix and + nuisance regressors from a new dataset different from the + training dataset on which fit() was applied. An AR(1) smooth + prior is imposed on the decoded ts and ts0 with the AR(1) + parameters learnt from the corresponding time courses in the + training data. + Notice: if you set the rank to be lower than the number of + experimental conditions (number of columns in the design + matrix), the recovered task-related activity will have + collinearity (the recovered time courses of some conditions + can be linearly explained by the recovered time courses + of other conditions). + + Parameters + ---------- + X : numpy arrays, shape=[time_points, voxels] + fMRI data of new data of the same subject. The voxels should + match those used in the fit() function. If data are z-scored + (recommended) when fitting the model, data should be z-scored + as well when calling transform() + y : not used (as it is unsupervised learning) + scan_onsets : numpy array, shape=[number of runs]. Default: None. + A list of indices corresponding to the onsets of + scans in the data X. If not provided, data will be assumed + to be acquired in a continuous scan. + Returns + ------- + ts : numpy arrays, shape = [time_points, condition] + The estimated response to the task conditions which have the + response amplitudes estimated during the fit step. + ts0: numpy array, shape = [time_points, n_nureg] + The estimated time course spread across the brain, with the + loading weights estimated during the fit step. + """ + + assert X.ndim == 2 and X.shape[1] == self.beta_.shape[1], \ + 'The shape of X is not consistent with the shape of data '\ + 'used in the fitting step. They should have the same number '\ + 'of voxels' + assert scan_onsets is None or (scan_onsets.ndim == 1 and + 0 in scan_onsets), \ + 'scan_onsets should either be None or an array of indices '\ + 'If it is given, it should include at least 0' + + if scan_onsets is None: + scan_onsets = np.array([0], dtype=int) + else: + scan_onsets = np.int32(scan_onsets) + ts, ts0, log_p = self._transform( + Y=X, scan_onsets=scan_onsets, beta=self.beta_, + beta0=self.beta0_, rho_e=self.rho_, sigma_e=self.sigma_, + rho_X=self._rho_design_, sigma2_X=self._sigma2_design_, + rho_X0=self._rho_X0_, sigma2_X0=self._sigma2_X0_) + return ts, ts0 + + def score(self, X, design, scan_onsets=None): + """ Use the model and parameters estimated by fit function + from some data of a participant to evaluate the log + likelihood of some new data of the same participant. + Design matrix of the same set of experimental + conditions in the testing data should be provided, with each + column corresponding to the same condition as that column + in the design matrix of the training data. + Unknown nuisance time series will be marginalized, assuming + they follow the same spatial pattern as in the training + data. The hypothetical response captured by the design matrix + will be subtracted from data before the marginalization + when evaluating the log likelihood. For null model, + nothing will be subtracted before marginalization. + + There is a difference between the form of likelihood function + used in fit() and score(). In fit(), the response amplitude + beta to design matrix X and the modulation beta0 by nuisance + regressor X0 are both marginalized, with X provided and X0 + estimated from data. In score(), posterior estimation of + beta and beta0 from the fitting step are assumed unchanged + to testing data and X0 is marginalized. + The logic underlying score() is to transfer + as much as what we can learn from training data when + calculating a likelihood score for testing data. + + If you z-scored your data during fit step, you should + z-score them for score function as well. If you did not + z-score in fitting, you should not z-score here either. + + Parameters + ---------- + X : numpy arrays, shape=[time_points, voxels] + fMRI data of new data of the same subject. The voxels should + match those used in the fit() function. If data are z-scored + (recommended) when fitting the model, data should be z-scored + as well when calling transform() + design : numpy array, shape=[time_points, conditions] + Design matrix expressing the hypothetical response of + the task conditions in data X. + scan_onsets : numpy array, shape=[number of runs]. Default: None. + A list of indices corresponding to the onsets of + scans in the data X. If not provided, data will be assumed + to be acquired in a continuous scan. + Returns + ------- + ll: float, + The log likelihood of the new data based on the model and its + parameters fit to the training data. + ll_null: float, + The log likelihood of the new data based on a null model + which assumes the same as the full model for everything + except for that there is no response to any of the + task conditions. + """ + + assert X.ndim == 2 and X.shape[1] == self.beta_.shape[1], \ + 'The shape of X is not consistent with the shape of data '\ + 'used in the fitting step. They should have the same number '\ + 'of voxels' + assert scan_onsets is None or (scan_onsets.ndim == 1 and + 0 in scan_onsets), \ + 'scan_onsets should either be None or an array of indices '\ + 'If it is given, it should include at least 0' + if scan_onsets is None: + scan_onsets = np.array([0], dtype=int) + else: + scan_onsets = np.int32(scan_onsets) + ll = self._score(Y=X, design=design, beta=self.beta_, + scan_onsets=scan_onsets, beta0=self.beta0_, + rho_e=self.rho_, sigma_e=self.sigma_, + rho_X0=self._rho_X0_, sigma2_X0=self._sigma2_X0_) + ll_null = self._score(Y=X, design=None, beta=None, + scan_onsets=scan_onsets, beta0=self.beta0_, + rho_e=self.rho_, sigma_e=self.sigma_, + rho_X0=self._rho_X0_, + sigma2_X0=self._sigma2_X0_) + return ll, ll_null + # The following 2 functions _D_gen and _F_gen generate templates used # for constructing inverse of covariance matrix of AR(1) noise # The inverse of covarian matrix is @@ -414,18 +950,11 @@ def _F_gen(self, TR): else: return np.empty([0, 0]) - def _prepare_DF(self, n_T, scan_onsets=None): - """ Prepare the essential template matrices D and F for - pre-calculating some terms to be re-used. - The inverse covariance matrix of AR(1) noise is - sigma^-2 * (I - rho1*D + rho1**2 * F). - And we denote A = I - rho1*D + rho1**2 * F""" + def _run_TR_from_scan_onsets(self, n_T, scan_onsets=None): if scan_onsets is None: # assume that all data are acquired within the same scan. - D = self._D_gen(n_T) - F = self._F_gen(n_T) n_run = 1 - run_TRs = np.array([n_T]) + run_TRs = np.array([n_T], dtype=int) else: # Each value in the scan_onsets tells the index at which # a new scan starts. For example, if n_T = 500, and @@ -438,13 +967,21 @@ def _prepare_DF(self, n_T, scan_onsets=None): # delete run length of 0 in case of duplication in scan_onsets. logger.info('I infer that the number of volumes' ' in each scan are: {}'.format(run_TRs)) + return run_TRs, n_run - D_ele = map(self._D_gen, run_TRs) - F_ele = map(self._F_gen, run_TRs) - D = scipy.linalg.block_diag(*D_ele) - F = scipy.linalg.block_diag(*F_ele) - # D and F above are templates for constructing - # the inverse of temporal covariance matrix of noise + def _prepare_DF(self, n_T, scan_onsets=None): + """ Prepare the essential template matrices D and F for + pre-calculating some terms to be re-used. + The inverse covariance matrix of AR(1) noise is + sigma^-2 * (I - rho1*D + rho1**2 * F). + And we denote A = I - rho1*D + rho1**2 * F""" + run_TRs, n_run = self._run_TR_from_scan_onsets(n_T, scan_onsets) + D_ele = map(self._D_gen, run_TRs) + F_ele = map(self._F_gen, run_TRs) + D = scipy.linalg.block_diag(*D_ele) + F = scipy.linalg.block_diag(*F_ele) + # D and F above are templates for constructing + # the inverse of temporal covariance matrix of noise return D, F, run_TRs, n_run def _prepare_data_XY(self, X, Y, D, F): @@ -468,56 +1005,102 @@ def _prepare_data_XY(self, X, Y, D, F): return XTY, XTDY, XTFY, YTY_diag, YTDY_diag, YTFY_diag, XTX, \ XTDX, XTFX - def _prepare_data_XYX0(self, X, Y, X0, D, F, run_TRs, no_DC=False): + def _gen_X_DC(self, run_TRs): + if self.baseline_single: + X_DC = np.ones((np.sum(run_TRs), 1)) + else: + X_DC = scipy.linalg.block_diag(*map(np.ones, run_TRs)).T + return X_DC + + def _gen_legendre(self, run_TRs, orders): + def reg(x): + return np.concatenate( + [scipy.special.legendre(o)(np.linspace(-1, 1, x))[None, :] + for o in orders], axis=0) + reg_poly = scipy.linalg.block_diag( + *map(reg, run_TRs)).T + return reg_poly + + def _prepare_data_XYX0(self, X, Y, X_base, X_res, D, F, run_TRs, + no_DC=False): """Prepares different forms of products between design matrix X or - data Y or nuisance regressors X0 and X0. + data Y or nuisance regressors X0. These products are re-used a lot during fitting. So we pre-calculate them. no_DC means not inserting regressors for DC components into nuisance regressor. - It will only take effect if X0 is not None. + It will only take effect if X_base is not None. """ - - X_base = scipy.linalg.block_diag(*map(np.ones, run_TRs)).T - res = np.linalg.lstsq(X_base, X) - if np.any(np.isclose(res[1], 0)): + X_DC = self._gen_X_DC(run_TRs) + reg_sol = np.linalg.lstsq(X_DC, X) + if np.any(np.isclose(reg_sol[1], 0)): raise ValueError('Your design matrix appears to have ' 'included baseline time series.' - 'Either remove them, or indicates which' - ' columns in your design matrix are for ' - ' conditions of interest.') - if X0 is not None: + 'Either remove them, or move them to' + ' nuisance regressors.') + X_DC, X_base, idx_DC = self._merge_DC_to_base(X_DC, X_base, + no_DC) + if X_res is None: + X0 = X_base + else: + X0 = np.concatenate((X_base, X_res), axis=1) + n_X0 = X0.shape[1] + X0TX0, X0TDX0, X0TFX0 = self._make_templates(D, F, X0, X0) + XTX0, XTDX0, XTFX0 = self._make_templates(D, F, X, X0) + X0TY, X0TDY, X0TFY = self._make_templates(D, F, X0, Y) + + return X0TX0, X0TDX0, X0TFX0, XTX0, XTDX0, XTFX0, \ + X0TY, X0TDY, X0TFY, X0, X_base, n_X0, idx_DC + + def _merge_DC_to_base(self, X_DC, X_base, no_DC): + """ Merge DC components X_DC to the baseline time series + X_base (By baseline, this means any fixed nuisance + regressors not updated during fitting, including DC + components and any nuisance regressors provided by + the user. + X_DC is always in the first few columns of X_base. + """ + if X_base is not None: + reg_sol = np.linalg.lstsq(X_DC, X_base) if not no_DC: - res0 = np.linalg.lstsq(X_base, X0) - if not np.any(np.isclose(res0[1], 0)): - # No columns in X0 can be explained by the + if not np.any(np.isclose(reg_sol[1], 0)): + # No columns in X_base can be explained by the # baseline regressors. So we insert them. - X0 = np.concatenate((X_base, X0), axis=1) + X_base = np.concatenate((X_DC, X_base), axis=1) + idx_DC = np.arange(0, X_DC.shape[1]) else: - logger.warning('Provided regressors for non-interesting ' + logger.warning('Provided regressors for uninteresting ' 'time series already include baseline. ' 'No additional baseline is inserted.') + idx_DC = np.where(np.isclose(reg_sol[1], 0))[0] + else: + idx_DC = np.where(np.isclose(reg_sol[1], 0))[0] else: # If a set of regressors for non-interested signals is not # provided, then we simply include one baseline for each run. - X0 = X_base + X_base = X_DC + idx_DC = np.arange(0, X_base.shape[1]) logger.info('You did not provide time series of no interest ' - 'such as DC component. One trivial regressor of' - ' DC component is included for further modeling.' + 'such as DC component. Trivial regressors of' + ' DC component are included for further modeling.' ' The final covariance matrix won''t ' - 'reflet them.') - n_base = X0.shape[1] - X0TX0, X0TDX0, X0TFX0 = self._make_templates(D, F, X0, X0) - XTX0, XTDX0, XTFX0 = self._make_templates(D, F, X, X0) - X0TY, X0TDY, X0TFY = self._make_templates(D, F, X0, Y) - - return X0TX0, X0TDX0, X0TFX0, XTX0, XTDX0, XTFX0, \ - X0TY, X0TDY, X0TFY, X0, n_base - - def _make_sandwidge(self, XTX, XTDX, XTFX, rho1): + 'reflect these components.') + return X_DC, X_base, idx_DC + + def _make_ar1_quad_form(self, XTX, XTDX, XTFX, rho1): + # Calculate the matrix X'AX = X'X - rho1 * X'DX + rho1^2 * X'FX + # Here, rho1 is the AR(1) coefficient. X is a matrix of time series + # with each row corresponding to a vector at one + # time point. The forms of matrices D and F are defined in _prepare_DF + # function. sigma^-2 * A would be the inverse of covariance matrix + # of AR(1) process (precision matrix) with rho1 as the AR coefficient + # and sigma^2 as the variance of independent noise at each time point. return XTX - rho1 * XTDX + rho1**2 * XTFX - def _make_sandwidge_grad(self, XTDX, XTFX, rho1): + def _make_ar1_quad_form_grad(self, XTDX, XTFX, rho1): + # Calculate the derivative of the quadratic form X'AX with respect to + # AR1 coefficient rho1, given precalculated terms X'DX and X'FX, + # and rho1. return - XTDX + 2 * rho1 * XTFX def _make_templates(self, D, F, X, Y): @@ -526,36 +1109,36 @@ def _make_templates(self, D, F, X, Y): XTFY = np.dot(np.dot(X.T, F), Y) return XTY, XTDY, XTFY - def _calc_sandwidge(self, XTY, XTDY, XTFY, YTY_diag, YTDY_diag, YTFY_diag, - XTX, XTDX, XTFX, X0TX0, X0TDX0, X0TFX0, - XTX0, XTDX0, XTFX0, X0TY, X0TDY, X0TFY, - L, rho1, n_V, n_base): - # Calculate the sandwidge terms which put A between X, Y and X0 + def _precompute_ar1_quad_forms(self, XTY, XTDY, XTFY, YTY_diag, YTDY_diag, + YTFY_diag, XTX, XTDX, XTFX, X0TX0, X0TDX0, + X0TFX0, XTX0, XTDX0, XTFX0, X0TY, X0TDY, + X0TFY, L, rho1, n_V, n_X0): + # Calculate the sandwich terms which put A between X, Y and X0 # These terms are used a lot in the likelihood. But in the _fitV # step, they only need to be calculated once, since A is fixed. # In _fitU step, they need to be calculated at each iteration, # because rho1 changes. - XTAY = self._make_sandwidge(XTY, XTDY, XTFY, rho1) + XTAY = self._make_ar1_quad_form(XTY, XTDY, XTFY, rho1) # dimension: feature*space - YTAY = self._make_sandwidge(YTY_diag, YTDY_diag, YTFY_diag, rho1) + YTAY = self._make_ar1_quad_form(YTY_diag, YTDY_diag, YTFY_diag, rho1) # dimension: space, # A/sigma2 is the inverse of noise covariance matrix in each voxel. # YTAY means Y'AY - XTAX = XTX[np.newaxis, :, :] - rho1[:, np.newaxis, np.newaxis] \ - * XTDX[np.newaxis, :, :] \ - + rho1[:, np.newaxis, np.newaxis]**2 * XTFX[np.newaxis, :, :] + XTAX = XTX[None, :, :] - rho1[:, None, None] \ + * XTDX[None, :, :] \ + + rho1[:, None, None]**2 * XTFX[None, :, :] # dimension: space*feature*feature - X0TAX0 = X0TX0[np.newaxis, :, :] - rho1[:, np.newaxis, np.newaxis] \ - * X0TDX0[np.newaxis, :, :] \ - + rho1[:, np.newaxis, np.newaxis]**2 * X0TFX0[np.newaxis, :, :] + X0TAX0 = X0TX0[None, :, :] - rho1[:, None, None] \ + * X0TDX0[None, :, :] \ + + rho1[:, None, None]**2 * X0TFX0[None, :, :] # dimension: space*#baseline*#baseline - XTAX0 = XTX0[np.newaxis, :, :] - rho1[:, np.newaxis, np.newaxis] \ - * XTDX0[np.newaxis, :, :] \ - + rho1[:, np.newaxis, np.newaxis]**2 * XTFX0[np.newaxis, :, :] + XTAX0 = XTX0[None, :, :] - rho1[:, None, None] \ + * XTDX0[None, :, :] \ + + rho1[:, None, None]**2 * XTFX0[None, :, :] # dimension: space*feature*#baseline - X0TAY = self._make_sandwidge(X0TY, X0TDY, X0TFY, rho1) + X0TAY = self._make_ar1_quad_form(X0TY, X0TDY, X0TFY, rho1) # dimension: #baseline*space - X0TAX0_i = np.linalg.solve(X0TAX0, np.identity(n_base)[None, :, :]) + X0TAX0_i = np.linalg.solve(X0TAX0, np.identity(n_X0)[None, :, :]) # dimension: space*#baseline*#baseline XTAcorrX = XTAX # dimension: space*feature*feature @@ -582,7 +1165,7 @@ def _calc_sandwidge(self, XTY, XTDY, XTFY, YTY_diag, YTDY_diag, YTFY_diag, XTAcorrX, XTAcorrY, YTAcorrY, LTXTAcorrY, XTAcorrXL, LTXTAcorrXL def _calc_LL(self, rho1, LTXTAcorrXL, LTXTAcorrY, YTAcorrY, X0TAX0, SNR2, - n_V, n_T, n_run, rank, n_base): + n_V, n_T, n_run, rank, n_X0): # Calculate the log likelihood (excluding the GP prior of log(SNR)) # for both _loglike_AR1_diagV_fitU and _loglike_AR1_diagV_fitV, # in addition to a few other terms. @@ -600,13 +1183,13 @@ def _calc_LL(self, rho1, LTXTAcorrXL, LTXTAcorrY, YTAcorrY, X0TAX0, SNR2, YTAcorrXL_LAMBDA = np.einsum('ji,ijk->ik', LTXTAcorrY, LAMBDA) # dimension: space*rank sigma2 = (YTAcorrY - np.sum(LTXTAcorrY * YTAcorrXL_LAMBDA.T, axis=0) - * SNR2) / (n_T - n_base) + * SNR2) / (n_T - n_X0) # dimension: space - LL = - np.sum(np.log(sigma2)) * (n_T - n_base) * 0.5 \ + LL = - np.sum(np.log(sigma2)) * (n_T - n_X0) * 0.5 \ + np.sum(np.log(1 - rho1**2)) * n_run * 0.5 \ - - np.sum(np.log(np.linalg.det(X0TAX0))) * 0.5 \ - - np.sum(np.log(np.linalg.det(LAMBDA_i))) * 0.5 \ - - (n_T - n_base) * n_V * (1 + np.log(2 * np.pi)) * 0.5 + - np.sum(self._half_log_det(X0TAX0)) \ + - np.sum(self._half_log_det(LAMBDA_i)) \ + - (n_T - n_X0) * n_V * (1 + np.log(2 * np.pi)) * 0.5 # Log likelihood return LL, LAMBDA_i, LAMBDA, YTAcorrXL_LAMBDA, sigma2 @@ -670,30 +1253,21 @@ def _build_index_param(self, n_l, n_V, n_smooth): # each voxel) return idx_param_sing, idx_param_fitU, idx_param_fitV - def _fit_RSA_UV(self, X, Y, X0, - scan_onsets=None, coords=None, inten=None): - """ The major utility of fitting Bayesian RSA. - Note that there is a naming change of variable. X in fit() - is changed to Y here, and design in fit() is changed to X here. - This is because we follow the tradition that X expresses the - variable defined (controlled) by the experimenter, i.e., the - time course of experimental conditions convolved by an HRF, - and Y expresses data. - However, in wrapper function fit(), we follow the naming - routine of scikit-learn. + def _half_log_det(self, M): + """ Return log(|M|)*0.5. For positive definite matrix M + of more than 2 dimensions, calculate this for the + last two dimension and return a value corresponding + to each element in the first few dimensions. """ - GP_inten = self.GP_inten - GP_space = self.GP_space - rank = self.rank - n_V = np.size(Y, axis=1) - n_T = np.size(Y, axis=0) - n_C = np.size(X, axis=1) - l_idx = np.tril_indices(n_C) - - self.random_state_ = check_random_state(self.random_state) - # setting random seed - t_start = time.time() + chol = np.linalg.cholesky(M) + if M.ndim == 2: + return np.sum(np.log(np.abs(np.diag(chol)))) + else: + return np.sum(np.log(np.abs(np.diagonal( + chol, axis1=-2, axis2=-1))), axis=-1) + def _chol_idx(self, n_C, rank): + l_idx = np.tril_indices(n_C) if rank is not None: # The rank of covariance matrix is specified idx_rank = np.where(l_idx[1] < rank) @@ -714,17 +1288,40 @@ def _fit_RSA_UV(self, X, Y, X0, 'If you have a good reason to specify a rank ' 'lower than the number of experiment conditions,' ' do so.') + return l_idx, rank + def _fit_RSA_UV(self, X, Y, X_base, + scan_onsets=None, coords=None, inten=None): + """ The major utility of fitting Bayesian RSA. + Note that there is a naming change of variable. X in fit() + is changed to Y here, and design in fit() is changed to X here. + This is because we follow the tradition that X expresses the + variable defined (controlled) by the experimenter, i.e., the + time course of experimental conditions convolved by an HRF, + and Y expresses data. + However, in wrapper function fit(), we follow the naming + routine of scikit-learn. + """ + GP_inten = self.GP_inten + GP_space = self.GP_space + rank = self.rank + n_V = np.size(Y, axis=1) + n_T = np.size(Y, axis=0) + n_C = np.size(X, axis=1) + l_idx, rank = self._chol_idx(n_C, rank) n_l = np.size(l_idx[0]) # the number of parameters for L + t_start = time.time() + D, F, run_TRs, n_run = self._prepare_DF( n_T, scan_onsets=scan_onsets) XTY, XTDY, XTFY, YTY_diag, YTDY_diag, YTFY_diag, XTX, \ XTDX, XTFX = self._prepare_data_XY(X, Y, D, F) X0TX0, X0TDX0, X0TFX0, XTX0, XTDX0, XTFX0, \ - X0TY, X0TDY, X0TFY, X0, n_base = self._prepare_data_XYX0( - X, Y, X0, D, F, run_TRs, no_DC=False) + X0TY, X0TDY, X0TFY, X0, X_base, n_X0, idx_DC = \ + self._prepare_data_XYX0( + X, Y, X_base, None, D, F, run_TRs, no_DC=False) # Prepare the data for fitting. These pre-calculated matrices # will be re-used a lot in evaluating likelihood function and # gradient. @@ -771,23 +1368,24 @@ def _fit_RSA_UV(self, X, Y, X0, XTY, XTDY, XTFY, X0TX0, X0TDX0, X0TFX0, XTX0, XTDX0, XTFX0, X0TY, X0TDY, X0TFY, X, Y, X0, idx_param_sing, - l_idx, n_C, n_T, n_V, n_l, n_run, n_base, rank) + l_idx, n_C, n_T, n_V, n_l, n_run, n_X0, rank) current_logSNR2 = -current_logSigma2 norm_factor = np.mean(current_logSNR2) current_logSNR2 = current_logSNR2 - norm_factor + X_res = None # Step 2 fitting, which only happens if # GP prior is requested if GP_space: - current_vec_U_chlsk_l, current_a1, current_logSNR2, X0 \ + current_vec_U_chlsk_l, current_a1, current_logSNR2, X_res\ = self._fit_diagV_noGP( XTY, XTDY, XTFY, YTY_diag, YTDY_diag, YTFY_diag, - XTX, XTDX, XTFX, X, Y, X0, D, F, run_TRs, + XTX, XTDX, XTFX, X, Y, X_base, X_res, D, F, run_TRs, current_vec_U_chlsk_l, current_a1, current_logSNR2, idx_param_fitU, idx_param_fitV, - l_idx, n_C, n_T, n_V, n_l, n_run, n_base, rank) + l_idx, n_C, n_T, n_V, n_l, n_run, n_X0, rank) current_GP[0] = np.log(np.min( dist2[np.tril_indices_from(dist2, k=-1)])) @@ -802,30 +1400,30 @@ def _fit_RSA_UV(self, X, Y, X0, if GP_inten: current_GP[1] = np.log(np.maximum( np.percentile(inten_diff2[np.tril_indices_from( - inten_diff2, k=-1)], 5), 0.5)) + inten_diff2, k=-1)], 2), 0.5)) logger.debug( 'current GP[1]:{}'.format(current_GP[1])) # We start the length scale for intensity with - # a small value. A heuristic is 5 percentile of + # a small value. A heuristic is 2 percentile of # all the square differences. But it should not be # smaller than 0.5. This limit is set in case # many voxels have close to equal intensities, - # which might render 5 percential to 0. + # which might render 2 percentile to 0. # Step 3 fitting. GP prior is imposed if requested. - # In this step, unless auto_nuisance is set to False, X0 + # In this step, unless auto_nuisance is set to False, X_res # will be re-estimated from the residuals after each step - # of fitting. + # of fitting. And X0 will be concatenation of X_base and X_res logger.debug('indexing:{}'.format(idx_param_fitV)) logger.debug('initial GP parameters:{}'.format(current_GP)) current_vec_U_chlsk_l, current_a1, current_logSNR2,\ - current_GP, X0 = self._fit_diagV_GP( + current_GP, X_res = self._fit_diagV_GP( XTY, XTDY, XTFY, YTY_diag, YTDY_diag, YTFY_diag, - XTX, XTDX, XTFX, X, Y, X0, D, F, run_TRs, + XTX, XTDX, XTFX, X, Y, X_base, X_res, D, F, run_TRs, current_vec_U_chlsk_l, current_a1, current_logSNR2, current_GP, n_smooth, idx_param_fitU, idx_param_fitV, - l_idx, n_C, n_T, n_V, n_l, n_run, n_base, rank, + l_idx, n_C, n_T, n_V, n_l, n_run, n_X0, rank, GP_space, GP_inten, dist2, inten_diff2, space_smooth_range, inten_smooth_range) @@ -840,28 +1438,49 @@ def _fit_RSA_UV(self, X, Y, X0, # Calculating est_sigma_AR1_UV, est_sigma_AR1_UV, # est_beta_AR1_UV and est_beta0_AR1_UV X0TX0, X0TDX0, X0TFX0, XTX0, XTDX0, XTFX0, \ - X0TY, X0TDY, X0TFY, X0, n_base = self._prepare_data_XYX0( - X, Y, X0, D, F, run_TRs, no_DC=True) + X0TY, X0TDY, X0TFY, X0, X_base, n_X0, _ \ + = self._prepare_data_XYX0( + X, Y, X_base, X_res, D, F, run_TRs, no_DC=True) X0TAX0, XTAX0, X0TAY, X0TAX0_i, \ XTAcorrX, XTAcorrY, YTAcorrY, LTXTAcorrY, XTAcorrXL, LTXTAcorrXL\ - = self._calc_sandwidge(XTY, XTDY, XTFY, - YTY_diag, YTDY_diag, YTFY_diag, - XTX, XTDX, XTFX, X0TX0, X0TDX0, X0TFX0, - XTX0, XTDX0, XTFX0, X0TY, X0TDY, X0TFY, - estU_chlsk_l_AR1_UV, est_rho1_AR1_UV, - n_V, n_base) + = self._precompute_ar1_quad_forms(XTY, XTDY, XTFY, + YTY_diag, YTDY_diag, YTFY_diag, + XTX, XTDX, XTFX, X0TX0, X0TDX0, + X0TFX0, XTX0, XTDX0, XTFX0, X0TY, + X0TDY, X0TFY, + estU_chlsk_l_AR1_UV, + est_rho1_AR1_UV, n_V, n_X0) LL, LAMBDA_i, LAMBDA, YTAcorrXL_LAMBDA, sigma2 \ = self._calc_LL(est_rho1_AR1_UV, LTXTAcorrXL, LTXTAcorrY, YTAcorrY, X0TAX0, est_SNR_AR1_UV**2, - n_V, n_T, n_run, rank, n_base) + n_V, n_T, n_run, rank, n_X0) est_sigma_AR1_UV = sigma2**0.5 est_beta_AR1_UV = est_SNR_AR1_UV**2 \ * np.dot(estU_chlsk_l_AR1_UV, YTAcorrXL_LAMBDA.T) + est_beta_AR1_UV_latent = \ + est_SNR_AR1_UV**2 * YTAcorrXL_LAMBDA.T + # the latent term means that X*L multiplied by this term + # is the same as X*beta. This will be used for decoding + # and cross-validating, in case L is low-rank est_beta0_AR1_UV = np.einsum( 'ijk,ki->ji', X0TAX0_i, (X0TAY - np.einsum('ikj,ki->ji', XTAX0, est_beta_AR1_UV))) + # Now we want to collapse all beta0 corresponding to DC components + # of different runs to a single map, and preserve only one DC component + # across runs. This is because they should express the same component + # and the new data to transform do not necessarily have the same + # numbers of runs as the training data. + + if idx_DC.size > 1: + collapsed_DC = np.sum(X0[:, idx_DC], axis=1) + X0 = np.insert(np.delete(X0, idx_DC, axis=1), 0, + collapsed_DC, axis=1) + collapsed_beta0 = np.mean(est_beta0_AR1_UV[idx_DC, :], axis=0) + est_beta0_AR1_UV = np.insert( + np.delete(est_beta0_AR1_UV, idx_DC, axis=0), + 0, collapsed_beta0, axis=0) t_finish = time.time() logger.info( 'total time of fitting: {} seconds'.format(t_finish - t_start)) @@ -871,38 +1490,275 @@ def _fit_RSA_UV(self, X, Y, X0, if GP_inten: est_intensity_kernel_r = np.exp(current_GP[1] / 2.0) K_major = np.exp(- (dist2 / est_space_smooth_r**2 + - inten_diff2 / est_intensity_kernel_r**2) + inten_diff2 / est_intensity_kernel_r**2) / 2.0) - K = K_major + np.diag(np.ones(n_V) * self.eta) - est_std_log_SNR = (np.dot(current_logSNR2, np.dot( - np.linalg.inv(K), current_logSNR2)) / n_V / 4)**0.5 - # divided by 4 because we used - # log(SNR^2) instead of log(SNR) - return est_cov_AR1_UV, estU_chlsk_l_AR1_UV, est_SNR_AR1_UV, \ - est_beta_AR1_UV, est_beta0_AR1_UV, est_sigma_AR1_UV, \ - est_rho1_AR1_UV, est_space_smooth_r, \ - est_std_log_SNR, est_intensity_kernel_r - # When GP_inten is True, the following lines won't be reached else: + est_intensity_kernel_r = None K_major = np.exp(- dist2 / est_space_smooth_r**2 / 2.0) - K = K_major + np.diag(np.ones(n_V) * self.eta) - est_std_log_SNR = (np.dot(current_logSNR2, np.dot( - np.linalg.inv(K), current_logSNR2)) / n_V / 4)**0.5 - return est_cov_AR1_UV, estU_chlsk_l_AR1_UV, est_SNR_AR1_UV, \ - est_beta_AR1_UV, est_beta0_AR1_UV, \ - est_sigma_AR1_UV, est_rho1_AR1_UV, est_space_smooth_r, \ - est_std_log_SNR + K = K_major + np.diag(np.ones(n_V) * self.eta) + invK_tilde_log_SNR = np.linalg.solve(K, current_logSNR2) / 2 + log_SNR_invK_tilde_log_SNR = np.dot(current_logSNR2, + invK_tilde_log_SNR) / 2 + tau2, _ = self.tau2_prior(log_SNR_invK_tilde_log_SNR, n_V, + self.tau_range) + est_std_log_SNR = tau2 ** 0.5 else: - return est_cov_AR1_UV, estU_chlsk_l_AR1_UV, est_SNR_AR1_UV, \ - est_beta_AR1_UV, est_beta0_AR1_UV, \ - est_sigma_AR1_UV, est_rho1_AR1_UV + est_space_smooth_r = None + est_intensity_kernel_r = None + est_std_log_SNR = None + return est_cov_AR1_UV, estU_chlsk_l_AR1_UV, est_SNR_AR1_UV, \ + est_beta_AR1_UV, est_beta0_AR1_UV, est_beta_AR1_UV_latent,\ + est_sigma_AR1_UV, est_rho1_AR1_UV, est_space_smooth_r, \ + est_std_log_SNR, est_intensity_kernel_r, X0 + + def _transform(self, Y, scan_onsets, beta, beta0, + rho_e, sigma_e, rho_X, sigma2_X, rho_X0, sigma2_X0): + """ Given the data Y and the response amplitudes beta and beta0 + estimated in the fit step, estimate the corresponding X and X0. + It is done by a forward-backward algorithm. + We assume X and X0 both are vector autoregressive (VAR) + processes, to capture temporal smoothness. Their VAR + parameters are estimated from training data at the fit stage. + """ + logger.info('Transforming new data.') + # Constructing the transition matrix and the variance of + # innovation noise as prior for the latent variable X and X0 + # in new data. + + n_C = beta.shape[0] + n_T = Y.shape[0] + weight = np.concatenate((beta, beta0), axis=0) + T_X = np.diag(np.concatenate((rho_X, rho_X0))) + Var_X = np.concatenate((sigma2_X / (1 - rho_X**2), + sigma2_X0 / (1 - rho_X0**2))) + Var_dX = np.concatenate((sigma2_X, sigma2_X0)) + sigma2_e = sigma_e ** 2 + scan_onsets = np.setdiff1d(scan_onsets, n_T) + n_scan = scan_onsets.size + X = [None] * scan_onsets.size + X0 = [None] * scan_onsets.size + total_log_p = 0 + for scan, onset in enumerate(scan_onsets): + # Forward step + if scan == n_scan - 1: + offset = n_T + else: + offset = scan_onsets[scan + 1] + mu, mu_Gamma_inv, Gamma_inv, log_p_data, Lambda_0, \ + Lambda_1, H, deltaY, deltaY_sigma2inv_rho_weightT = \ + self._forward_step(Y[onset:offset, :], + T_X, Var_X, Var_dX, rho_e, sigma2_e, + weight) + total_log_p += log_p_data + # Backward step + mu_hat, mu_Gamma_inv_hat, Gamma_inv_hat \ + = self._backward_step( + deltaY, deltaY_sigma2inv_rho_weightT, sigma2_e, + weight, mu, mu_Gamma_inv, Gamma_inv, + Lambda_0, Lambda_1, H) + X[scan] = np.concatenate( + [mu_t[None, :n_C] for mu_t in mu_hat]) + X0[scan] = np.concatenate( + [mu_t[None, n_C:] for mu_t in mu_hat]) + X = np.concatenate(X) + X0 = np.concatenate(X0) + return X, X0, total_log_p + + def _score(self, Y, design, beta, scan_onsets, beta0, rho_e, sigma_e, + rho_X0, sigma2_X0): + """ Given the data Y, and the spatial pattern beta0 + of nuisance time series, return the cross-validated score + of the data Y given all parameters of the subject estimated + during the first step. + It is assumed that the user has design matrix built for the + data Y. Both beta and beta0 are posterior expectation estimated + from training data with the estimated covariance matrix U and + SNR serving as prior. We marginalize X0 instead of fitting + it in this function because this function is for the purpose + of evaluating model no new data. We should avoid doing any + additional fitting when performing cross-validation. + The hypothetic response to the task will be subtracted, and + the unknown nuisance activity which contributes to the data + through beta0 will be marginalized. + """ + logger.info('Estimating cross-validated score for new data.') + n_T = Y.shape[0] + if design is not None: + Y = Y - np.dot(design, beta) + # The function works for both full model and null model. + # If design matrix is not provided, the whole data is + # used as input for _forward_step. If design matrix is provided, + # residual after subtracting design * beta is fed to _forward_step + T_X = np.diag(rho_X0) + Var_X = sigma2_X0 / (1 - rho_X0**2) + Var_dX = sigma2_X0 + # Prior parmeters for X0: T_X is transitioning matrix, Var_X + # is the marginal variance of the first time point. Var_dX is the + # variance of the updating noise. + sigma2_e = sigma_e ** 2 + # variance of voxel-specific updating noise component + scan_onsets = np.setdiff1d(scan_onsets, n_T).astype(int) + n_scan = scan_onsets.size + total_log_p = 0 + for scan, onset in enumerate(scan_onsets): + # Forward step + if scan == n_scan - 1: + offset = n_T + else: + offset = scan_onsets[scan + 1] + _, _, _, log_p_data, _, _, _, _, _ = \ + self._forward_step( + Y[onset:offset, :], T_X, Var_X, Var_dX, rho_e, sigma2_e, + beta0) + total_log_p += log_p_data + return total_log_p + + def _est_AR1(self, x, same_para=False): + """ Estimate the AR(1) parameters of input x. + Each column of x is assumed as independent from other columns, + and each column is treated as an AR(1) process. + If same_para is set as True, then all columns of x + are concatenated and a single set of AR(1) parameters + is estimated. Strictly speaking the breaking point + between each concatenated column should be considered. + But for long time series, this is ignored. + """ + if same_para: + n_c = x.shape[1] + x = np.reshape(x, x.size, order='F') + rho, sigma2 = alg.AR_est_YW(x, 1) + # We concatenate all the design matrix to estimate common AR(1) + # parameters. This creates some bias because the end of one column + # and the beginning of the next column of the design matrix are + # treated as consecutive samples. + rho = np.ones(n_c) * rho + sigma2 = np.ones(n_c) * sigma2 + else: + rho = np.zeros(np.shape(x)[1]) + sigma2 = np.zeros(np.shape(x)[1]) + for c in np.arange(np.shape(x)[1]): + rho[c], sigma2[c] = alg.AR_est_YW(x[:, c], 1) + return rho, sigma2 + + def _forward_step(self, Y, T_X, Var_X, Var_dX, rho_e, sigma2_e, weight): + """ forward step for HMM, assuming both the hidden state and noise + have 1-step dependence on the previous value. + """ + # We currently only implement diagonal form + # of covariance matrix for Var_X, Var_dX and T_X, which means + # each dimension of X is independent and their innovation noise + # are also independent. Note that log_p_data takes this assumption. + if Var_X.ndim == 1: + inv_Var_X = np.diag(1 / Var_X) + half_log_det_Var_X = np.sum(np.log(Var_X)) / 2.0 + Var_X = np.diag(Var_X) + # the marginal variance of X + else: + half_log_det_Var_X = self._half_log_det(Var_X) + inv_Var_X = np.linalg.inv(Var_X) + if Var_dX.ndim == 1: + inv_Var_dX = np.diag(1 / Var_dX) + half_log_det_Var_dX = np.sum(np.log(Var_dX)) / 2.0 + Var_dX = np.diag(Var_dX) + # the marginal variance of Delta X (the change of X from + # previous time point) + else: + inv_Var_dX = np.linalg.inv(Var_dX) + half_log_det_Var_dX = self._half_log_det(Var_dX) + if T_X.ndim == 1: + T_X = np.diag(T_X) + # Transfer function of X: the expected mean of X at t+1 + # time point is T_x * X + [n_T, n_V] = np.shape(Y) + # numbers of time points and voxels + mu = [None] * n_T + # posterior mean of X, conditioned on all data up till the current + # time point + Gamma_inv = [None] * n_T + # inverse of poterior Gamma. + mu_Gamma_inv = [None] * n_T + # mu * inv(Gamma) + log_p_data = - np.log(np.pi * 2) * (n_T * n_V) / 2 \ + - half_log_det_Var_X - np.sum(np.log(sigma2_e)) * n_T / 2.0\ + + np.sum(np.log(1 - rho_e**2)) / 2.0 - half_log_det_Var_dX \ + * (n_T - 1) + # This is the term to be incremented by c_n at each time step. + # We first add all the fixed terms to it. + + # The following are a few fixed terms. + Lambda_0 = np.dot(T_X, np.dot(inv_Var_dX, T_X.T)) \ + + np.dot(weight * rho_e**2 / sigma2_e, weight.T) + H = np.dot(inv_Var_dX, T_X.T) + np.dot(weight * rho_e / sigma2_e, + weight.T) + Lambda_1 = inv_Var_dX + np.dot(weight / sigma2_e, weight.T) + + Gamma_inv[0] = inv_Var_X + np.dot( + weight * (1 - rho_e**2) / sigma2_e, weight.T) + # We might not need this and only use linalg.solve for related terms. + mu_Gamma_inv[0] = np.dot( + Y[0, :] * (1 - rho_e**2) / sigma2_e, weight.T) + mu[0] = np.linalg.solve(Gamma_inv[0], mu_Gamma_inv[0]) + log_p_data -= 0.5 * np.sum(Y[0, :]**2 * (1 - rho_e**2) / sigma2_e) + # This is the term added for the first time point. + + deltaY = Y[1:, :] - rho_e * Y[:-1, :] + deltaY_sigma2inv_rho_weightT = np.dot( + deltaY / sigma2_e * rho_e, weight.T) + for t in np.arange(1, n_T): + Gamma_tilde_inv = Lambda_0 + Gamma_inv[t - 1] + tmp = np.linalg.solve(Gamma_tilde_inv, H.T) + Gamma_inv[t] = Lambda_1 - np.dot(H, tmp) + mu_Gamma_inv[t] = np.dot(deltaY[t - 1, :] / sigma2_e, weight.T) \ + + np.dot(mu_Gamma_inv[t - 1] + - deltaY_sigma2inv_rho_weightT[t - 1, :], tmp) + mu[t] = np.linalg.solve(Gamma_inv[t], mu_Gamma_inv[t]) + tmp2 = mu_Gamma_inv[t - 1] - deltaY_sigma2inv_rho_weightT[t - 1, :] + log_p_data += -self._half_log_det(Gamma_tilde_inv) \ + + np.dot(tmp2, np.linalg.solve(Gamma_tilde_inv, tmp2)) / 2.0 + log_p_data += -self._half_log_det(Gamma_inv[-1]) \ + + np.dot(mu_Gamma_inv[-1], mu[-1]) / 2.0 \ + - np.sum(deltaY**2 / sigma2_e) / 2.0 + return mu, mu_Gamma_inv, Gamma_inv, log_p_data, Lambda_0, \ + Lambda_1, H, deltaY, deltaY_sigma2inv_rho_weightT + + def _backward_step(self, deltaY, deltaY_sigma2inv_rho_weightT, + sigma2_e, weight, mu, mu_Gamma_inv, Gamma_inv, + Lambda_0, Lambda_1, H): + """ backward step for HMM, assuming both the hidden state and noise + have 1-step dependence on the previous value. + """ + n_T = len(Gamma_inv) + # All the terms with hat before are parameters of posterior + # distributions of X conditioned on data from all time points, + # whereas the ones without hat calculated by _forward_step + # are mean and covariance of posterior of X conditioned on + # data up to the time point. + Gamma_inv_hat = [None] * n_T + mu_Gamma_inv_hat = [None] * n_T + mu_hat = [None] * n_T + + mu_hat[-1] = mu[-1].copy() + mu_Gamma_inv_hat[-1] = mu_Gamma_inv[-1].copy() + Gamma_inv_hat[-1] = Gamma_inv[-1].copy() + + for t in np.arange(n_T - 2, -1, -1): + tmp = np.linalg.solve(Gamma_inv_hat[t + 1] - Gamma_inv[t + 1] + + Lambda_1, H) + Gamma_inv_hat[t] = Gamma_inv[t] + Lambda_0 - np.dot(H.T, tmp) + mu_Gamma_inv_hat[t] = mu_Gamma_inv[t] \ + - deltaY_sigma2inv_rho_weightT[t, :] + np.dot( + mu_Gamma_inv_hat[t + 1] - mu_Gamma_inv[t + 1] + + np.dot(deltaY[t, :] / sigma2_e, weight.T), tmp) + mu_hat[t] = np.linalg.solve(Gamma_inv_hat[t], + mu_Gamma_inv_hat[t]) + return mu_hat, mu_Gamma_inv_hat, Gamma_inv_hat def _initial_fit_singpara(self, XTX, XTDX, XTFX, YTY_diag, YTDY_diag, YTFY_diag, XTY, XTDY, XTFY, X0TX0, X0TDX0, X0TFX0, XTX0, XTDX0, XTFX0, X0TY, X0TDY, X0TFY, X, Y, X0, idx_param_sing, l_idx, - n_C, n_T, n_V, n_l, n_run, n_base, rank): + n_C, n_T, n_V, n_l, n_run, n_X0, rank): """ Perform initial fitting of a simplified model, which assumes that all voxels share exactly the same temporal covariance matrix for their noise (the same noise variance and @@ -920,15 +1776,13 @@ def _initial_fit_singpara(self, XTX, XTDX, XTFX, # There are several possible ways of initializing the covariance. # (1) start from the point estimation of covariance - # cov_point_est = np.cov(beta_hat) - # current_vec_U_chlsk_l = \ - # np.linalg.cholesky(cov_point_est + \ - # np.eye(n_C) * 1e-6)[l_idx] + cov_point_est = np.cov(beta_hat[n_X0:, :]) / np.var(residual) + current_vec_U_chlsk_l = \ + np.linalg.cholesky((cov_point_est + np.eye(n_C)) / 2)[l_idx] - # We add a tiny diagonal element to the point - # estimation of covariance, just in case - # the user provides data in which - # n_V is smaller than n_C + # We use the average of covariance of point estimation and an identity + # matrix as the initial value of the covariance matrix, just in case + # the user provides data in which n_V is smaller than n_C. # (2) start from identity matrix @@ -936,7 +1790,8 @@ def _initial_fit_singpara(self, XTX, XTDX, XTFX, # (3) random initialization - current_vec_U_chlsk_l = self.random_state_.randn(n_l) + # current_vec_U_chlsk_l = self.random_state_.randn(n_l) + # vectorized version of L, Cholesky factor of U, the shared # covariance matrix of betas across voxels. @@ -946,7 +1801,7 @@ def _initial_fit_singpara(self, XTX, XTDX, XTFX, # Estimate of auto correlation assuming data includes pure noise. log_sigma2 = np.log(np.var( residual[1:, :] - residual[0:-1, :] * rho1, axis=0)) - # log of estimates of the variance of the "refreshing" noise + # log of estimates of the variance of the "innovation" noise # of AR(1) process at each time point. param0 = np.empty(np.sum(np.size(v) for v in idx_param_sing.values())) @@ -961,10 +1816,11 @@ def _initial_fit_singpara(self, XTX, XTDX, XTFX, args=(XTX, XTDX, XTFX, YTY_diag, YTDY_diag, YTFY_diag, XTY, XTDY, XTFY, X0TX0, X0TDX0, X0TFX0, XTX0, XTDX0, XTFX0, X0TY, X0TDY, X0TFY, - l_idx, n_C, n_T, n_V, n_run, n_base, + l_idx, n_C, n_T, n_V, n_run, n_X0, idx_param_sing, rank), method=self.optimizer, jac=True, tol=self.tol, - options={'disp': self.verbose, 'maxiter': 100}) + options={'disp': self.minimize_options['disp'], + 'maxiter': 100}) current_vec_U_chlsk_l = res.x[idx_param_sing['Cholesky']] current_a1 = res.x[idx_param_sing['a1']] * np.ones(n_V) # log(sigma^2) assuming the data include no signal is returned, @@ -976,11 +1832,11 @@ def _initial_fit_singpara(self, XTX, XTDX, XTFX, def _fit_diagV_noGP( self, XTY, XTDY, XTFY, YTY_diag, YTDY_diag, YTFY_diag, - XTX, XTDX, XTFX, X, Y, X0, D, F, run_TRs, + XTX, XTDX, XTFX, X, Y, X_base, X_res, D, F, run_TRs, current_vec_U_chlsk_l, current_a1, current_logSNR2, idx_param_fitU, idx_param_fitV, - l_idx, n_C, n_T, n_V, n_l, n_run, n_base, rank): + l_idx, n_C, n_T, n_V, n_l, n_run, n_X0, rank): """ (optional) second step of fitting, full model but without GP prior on log(SNR). This step is only done if GP prior is requested. @@ -1005,12 +1861,16 @@ def _fit_diagV_noGP( tol = self.tol * 5 for it in range(0, init_iter): X0TX0, X0TDX0, X0TFX0, XTX0, XTDX0, XTFX0, \ - X0TY, X0TDY, X0TFY, X0, n_base = self._prepare_data_XYX0( - X, Y, X0, D, F, run_TRs, no_DC=True) + X0TY, X0TDY, X0TFY, X0, X_base, n_X0, _ \ + = self._prepare_data_XYX0( + X, Y, X_base, X_res, D, F, run_TRs, no_DC=True) # fit U, the covariance matrix, together with AR(1) param param0_fitU[idx_param_fitU['Cholesky']] = \ - current_vec_U_chlsk_l + current_vec_U_chlsk_l \ + + self.random_state_.randn(n_l) \ + * np.linalg.norm(current_vec_U_chlsk_l) \ + / n_l**0.5 * np.exp(-it / init_iter * self.anneal_speed - 1) param0_fitU[idx_param_fitU['a1']] = current_a1 res_fitU = scipy.optimize.minimize( self._loglike_AR1_diagV_fitU, param0_fitU, @@ -1018,10 +1878,9 @@ def _fit_diagV_noGP( XTY, XTDY, XTFY, X0TX0, X0TDX0, X0TFX0, XTX0, XTDX0, XTFX0, X0TY, X0TDY, X0TFY, current_logSNR2, l_idx, n_C, - n_T, n_V, n_run, n_base, idx_param_fitU, rank), + n_T, n_V, n_run, n_X0, idx_param_fitU, rank), method=self.optimizer, jac=True, tol=tol, - options={'xtol': tol, 'disp': self.verbose, - 'maxiter': 4}) + options=self.minimize_options) current_vec_U_chlsk_l = \ res_fitU.x[idx_param_fitU['Cholesky']] current_a1 = res_fitU.x[idx_param_fitU['a1']] @@ -1036,13 +1895,13 @@ def _fit_diagV_noGP( X0TAX0, XTAX0, X0TAY, X0TAX0_i, \ XTAcorrX, XTAcorrY, YTAcorrY, \ LTXTAcorrY, XTAcorrXL, LTXTAcorrXL = \ - self._calc_sandwidge(XTY, XTDY, XTFY, - YTY_diag, YTDY_diag, YTFY_diag, - XTX, XTDX, XTFX, - X0TX0, X0TDX0, X0TFX0, - XTX0, XTDX0, XTFX0, - X0TY, X0TDY, X0TFY, - L, rho1, n_V, n_base) + self._precompute_ar1_quad_forms(XTY, XTDY, XTFY, + YTY_diag, YTDY_diag, YTFY_diag, + XTX, XTDX, XTFX, + X0TX0, X0TDX0, X0TFX0, + XTX0, XTDX0, XTFX0, + X0TY, X0TDY, X0TFY, + L, rho1, n_V, n_X0) res_fitV = scipy.optimize.minimize( self._loglike_AR1_diagV_fitV, param0_fitV, args=(X0TAX0, XTAX0, X0TAY, @@ -1050,11 +1909,10 @@ def _fit_diagV_noGP( LTXTAcorrY, XTAcorrXL, LTXTAcorrXL, current_vec_U_chlsk_l, current_a1, l_idx, n_C, n_T, n_V, n_run, - n_base, idx_param_fitV, rank, + n_X0, idx_param_fitV, rank, False, False), method=self.optimizer, jac=True, tol=tol, - options={'xtol': tol, 'disp': self.verbose, - 'maxiter': 4}) + options=self.minimize_options) current_logSNR2[0:n_V - 1] = res_fitV.x current_logSNR2[-1] = - np.sum(current_logSNR2[0:n_V - 1]) @@ -1063,7 +1921,7 @@ def _fit_diagV_noGP( logger.debug('norm of parameter change after fitting V: ' '{}'.format(norm_fitVchange)) logger.debug('E[log(SNR2)^2]: {}'.format( - np.mean(current_logSNR2**2))) + np.mean(current_logSNR2**2))) # The lines below are for debugging purpose. # If any voxel's log(SNR^2) gets to non-finite number, @@ -1077,31 +1935,35 @@ def _fit_diagV_noGP( param0_fitV = res_fitV.x.copy() - # Re-estimating X0 from residuals + # Re-estimating X_res from residuals current_SNR2 = np.exp(current_logSNR2) if self.auto_nuisance: LL, LAMBDA_i, LAMBDA, YTAcorrXL_LAMBDA, current_sigma2 \ = self._calc_LL(rho1, LTXTAcorrXL, LTXTAcorrY, YTAcorrY, X0TAX0, current_SNR2, - n_V, n_T, n_run, rank, n_base) + n_V, n_T, n_run, rank, n_X0) betas = current_SNR2 * np.dot(L, YTAcorrXL_LAMBDA.T) - residuals = Y - np.dot(X, betas) - u, s, v = np.linalg.svd(residuals) - X0 = u[:, :self.n_nureg] - + beta0s = np.einsum( + 'ijk,ki->ji', X0TAX0_i, + (X0TAY - np.einsum('ikj,ki->ji', XTAX0, betas))) + residuals = Y - np.dot(X, betas) - np.dot( + X_base, beta0s[:np.shape(X_base)[1], :]) + X_res = self.nureg_method( + self.n_nureg_).fit_transform( + self.preprocess_residual(residuals)) if norm_fitVchange / np.sqrt(param0_fitV.size) < tol \ and norm_fitUchange / np.sqrt(param0_fitU.size) \ < tol: break - return current_vec_U_chlsk_l, current_a1, current_logSNR2, X0 + return current_vec_U_chlsk_l, current_a1, current_logSNR2, X_res def _fit_diagV_GP( self, XTY, XTDY, XTFY, YTY_diag, YTDY_diag, YTFY_diag, - XTX, XTDX, XTFX, X, Y, X0, D, F, run_TRs, + XTX, XTDX, XTFX, X, Y, X_base, X_res, D, F, run_TRs, current_vec_U_chlsk_l, current_a1, current_logSNR2, current_GP, n_smooth, idx_param_fitU, idx_param_fitV, l_idx, - n_C, n_T, n_V, n_l, n_run, n_base, rank, GP_space, GP_inten, + n_C, n_T, n_V, n_l, n_run, n_X0, rank, GP_space, GP_inten, dist2, inten_diff2, space_smooth_range, inten_smooth_range): """ Last step of fitting. If GP is not requested, this step will still be done, just without GP prior on log(SNR). @@ -1130,13 +1992,17 @@ def _fit_diagV_GP( for it in range(0, n_iter): X0TX0, X0TDX0, X0TFX0, XTX0, XTDX0, XTFX0, \ - X0TY, X0TDY, X0TFY, X0, n_base = self._prepare_data_XYX0( - X, Y, X0, D, F, run_TRs, no_DC=True) + X0TY, X0TDY, X0TFY, X0, X_base, n_X0, _ = \ + self._prepare_data_XYX0( + X, Y, X_base, X_res, D, F, run_TRs, no_DC=True) # fit U param0_fitU[idx_param_fitU['Cholesky']] = \ - current_vec_U_chlsk_l + current_vec_U_chlsk_l \ + + self.random_state_.randn(n_l) \ + * np.linalg.norm(current_vec_U_chlsk_l) \ + / n_l**0.5 * np.exp(-it / n_iter * self.anneal_speed - 1) param0_fitU[idx_param_fitU['a1']] = current_a1 res_fitU = scipy.optimize.minimize( @@ -1145,11 +2011,10 @@ def _fit_diagV_GP( XTY, XTDY, XTFY, X0TX0, X0TDX0, X0TFX0, XTX0, XTDX0, XTFX0, X0TY, X0TDY, X0TFY, current_logSNR2, l_idx, n_C, n_T, n_V, - n_run, n_base, idx_param_fitU, rank), + n_run, n_X0, idx_param_fitU, rank), method=self.optimizer, jac=True, tol=tol, - options={'xtol': tol, - 'disp': self.verbose, 'maxiter': 6}) + options=self.minimize_options) current_vec_U_chlsk_l = \ res_fitU.x[idx_param_fitU['Cholesky']] current_a1 = res_fitU.x[idx_param_fitU['a1']] @@ -1165,27 +2030,26 @@ def _fit_diagV_GP( X0TAX0, XTAX0, X0TAY, X0TAX0_i, \ XTAcorrX, XTAcorrY, YTAcorrY, \ LTXTAcorrY, XTAcorrXL, LTXTAcorrXL = \ - self._calc_sandwidge(XTY, XTDY, XTFY, - YTY_diag, YTDY_diag, YTFY_diag, - XTX, XTDX, XTFX, - X0TX0, X0TDX0, X0TFX0, - XTX0, XTDX0, XTFX0, - X0TY, X0TDY, X0TFY, - L, rho1, n_V, n_base) + self._precompute_ar1_quad_forms(XTY, XTDY, XTFY, + YTY_diag, YTDY_diag, YTFY_diag, + XTX, XTDX, XTFX, + X0TX0, X0TDX0, X0TFX0, + XTX0, XTDX0, XTFX0, + X0TY, X0TDY, X0TFY, + L, rho1, n_V, n_X0) res_fitV = scipy.optimize.minimize( self._loglike_AR1_diagV_fitV, param0_fitV, args=( X0TAX0, XTAX0, X0TAY, X0TAX0_i, XTAcorrX, XTAcorrY, YTAcorrY, LTXTAcorrY, XTAcorrXL, LTXTAcorrXL, current_vec_U_chlsk_l, current_a1, - l_idx, n_C, n_T, n_V, n_run, n_base, + l_idx, n_C, n_T, n_V, n_run, n_X0, idx_param_fitV, rank, GP_space, GP_inten, dist2, inten_diff2, space_smooth_range, inten_smooth_range), method=self.optimizer, jac=True, tol=tol, - options={'xtol': tol, - 'disp': self.verbose, 'maxiter': 6}) + options=self.minimize_options) current_logSNR2[0:n_V - 1] = \ res_fitV.x[idx_param_fitV['log_SNR2']] @@ -1201,18 +2065,22 @@ def _fit_diagV_GP( logger.debug('E[log(SNR2)^2]: {}'.format( np.mean(current_logSNR2**2))) - # Re-estimating X0 from residuals + # Re-estimating X_res from residuals current_SNR2 = np.exp(current_logSNR2) if self.auto_nuisance: LL, LAMBDA_i, LAMBDA, YTAcorrXL_LAMBDA, current_sigma2 \ = self._calc_LL(rho1, LTXTAcorrXL, LTXTAcorrY, YTAcorrY, X0TAX0, current_SNR2, - n_V, n_T, n_run, rank, n_base) - betas = current_SNR2 * np.dot(L, YTAcorrXL_LAMBDA.T) - residuals = Y - np.dot(X, betas) - u, s, v = np.linalg.svd(residuals) - X0 = u[:, :self.n_nureg] - + n_V, n_T, n_run, rank, n_X0) + betas = current_SNR2 \ + * np.dot(L, YTAcorrXL_LAMBDA.T) + beta0s = np.einsum( + 'ijk,ki->ji', X0TAX0_i, + (X0TAY - np.einsum('ikj,ki->ji', XTAX0, betas))) + residuals = Y - np.dot(X, betas) - np.dot( + X_base, beta0s[:np.shape(X_base)[1], :]) + X_res = self.nureg_method(self.n_nureg_).fit_transform( + self.preprocess_residual(residuals)) if GP_space: logger.debug('current GP[0]: {}'.format(current_GP[0])) logger.debug('gradient for GP[0]: {}'.format( @@ -1226,7 +2094,98 @@ def _fit_diagV_GP( break return current_vec_U_chlsk_l, current_a1, current_logSNR2,\ - current_GP, X0 + current_GP, X_res + + def _fit_null(self, Y, X_base, scan_onsets=None): + """ Fit a null model. + """ + n_V = np.size(Y, axis=1) + n_T = np.size(Y, axis=0) + t_start = time.time() + + D, F, run_TRs, n_run = self._prepare_DF( + n_T, scan_onsets=scan_onsets) + YTY_diag = np.sum(Y * Y, axis=0) + YTDY_diag = np.sum(Y * np.dot(D, Y), axis=0) + YTFY_diag = np.sum(Y * np.dot(F, Y), axis=0) + + tol = self.tol + n_iter = self.n_iter + logger.info('Fitting null model' + ' for maximum {} times'.format(n_iter)) + + # Add DC components capturing run-specific baselines. + X_DC = self._gen_X_DC(run_TRs) + X_DC, X_base, idx_DC = self._merge_DC_to_base( + X_DC, X_base, no_DC=False) + X_res = None + param0 = np.zeros(n_V) + for it in range(0, n_iter): + if X_res is None: + X0 = X_base + else: + X0 = np.concatenate((X_base, X_res), axis=1) + n_X0 = X0.shape[1] + X0TX0, X0TDX0, X0TFX0 = self._make_templates(D, F, X0, X0) + X0TY, X0TDY, X0TFY = self._make_templates(D, F, X0, Y) + res_null = scipy.optimize.minimize( + self._loglike_AR1_null, param0, args=( + YTY_diag, YTDY_diag, YTFY_diag, + X0TX0, X0TDX0, X0TFX0, X0TY, X0TDY, X0TFY, + n_T, n_V, n_run, n_X0), + method=self.optimizer, jac=True, tol=tol, + options=self.minimize_options) + param_change = res_null.x - param0 + param0 = res_null.x.copy() + est_rho1_AR1_null = 2.0 / np.pi * np.arctan(param0) + if self.auto_nuisance: + X0TAX0 = X0TX0[None, :, :] \ + - est_rho1_AR1_null[:, None, None] \ + * X0TDX0[None, :, :] \ + + est_rho1_AR1_null[:, None, None]**2 \ + * X0TFX0[None, :, :] + # dimension: space*#baseline*#baseline + X0TAY = self._make_ar1_quad_form(X0TY, X0TDY, X0TFY, + est_rho1_AR1_null) + # dimension: #baseline*space + beta0s = np.linalg.solve(X0TAX0, X0TAY.T).T + residuals = Y - np.dot(X_base, beta0s[:np.shape(X_base)[1], :]) + X_res = self.nureg_method(self.n_nureg_).fit_transform( + self.preprocess_residual(residuals)) + if np.max(np.abs(param_change)) < self.tol: + logger.info('The change of parameters is smaller than ' + 'the tolerance value {}. Fitting is finished ' + 'after {} iterations'.format(self.tol, it + 1)) + break + X0TAX0 = X0TX0[None, :, :] \ + - est_rho1_AR1_null[:, None, None] \ + * X0TDX0[None, :, :] \ + + est_rho1_AR1_null[:, None, None]**2 \ + * X0TFX0[None, :, :] + # dimension: space*#baseline*#baseline + X0TAY = self._make_ar1_quad_form(X0TY, X0TDY, X0TFY, + est_rho1_AR1_null) + # dimension: #baseline*space + est_beta0_AR1_null = np.linalg.solve(X0TAX0, X0TAY.T).T + + YTAY = self._make_ar1_quad_form(YTY_diag, YTDY_diag, YTFY_diag, + est_rho1_AR1_null) + # dimension: space, + YTAcorrY = YTAY - np.sum(X0TAY * est_beta0_AR1_null, axis=0) + # dimension: space, + est_sigma_AR1_null = (YTAcorrY / (n_T - n_X0)) ** 0.5 + if idx_DC.size > 1: + collapsed_DC = np.sum(X0[:, idx_DC], axis=1) + X0 = np.insert(np.delete(X0, idx_DC, axis=1), 0, + collapsed_DC, axis=1) + collapsed_beta0 = np.mean(est_beta0_AR1_null[idx_DC, :], axis=0) + est_beta0_AR1_null = np.insert( + np.delete(est_beta0_AR1_null, idx_DC, axis=0), + 0, collapsed_beta0, axis=0) + t_finish = time.time() + logger.info( + 'total time of fitting: {} seconds'.format(t_finish - t_start)) + return est_beta0_AR1_null, est_sigma_AR1_null, est_rho1_AR1_null, X0 # We fit two parts of the parameters iteratively. # The following are the corresponding negative log likelihood functions. @@ -1235,7 +2194,7 @@ def _loglike_AR1_diagV_fitU(self, param, XTX, XTDX, XTFX, YTY_diag, YTDY_diag, YTFY_diag, XTY, XTDY, XTFY, X0TX0, X0TDX0, X0TFX0, XTX0, XTDX0, XTFX0, X0TY, X0TDY, X0TFY, - log_SNR2, l_idx, n_C, n_T, n_V, n_run, n_base, + log_SNR2, l_idx, n_C, n_T, n_V, n_run, n_X0, idx_param_fitU, rank): # This function calculates the log likelihood of data given cholesky # decomposition of U and AR(1) parameters of noise as free parameters. @@ -1281,17 +2240,18 @@ def _loglike_AR1_diagV_fitU(self, param, XTX, XTDX, XTFX, YTY_diag, X0TAX0, XTAX0, X0TAY, X0TAX0_i, \ XTAcorrX, XTAcorrY, YTAcorrY, \ LTXTAcorrY, XTAcorrXL, LTXTAcorrXL = \ - self._calc_sandwidge(XTY, XTDY, XTFY, - YTY_diag, YTDY_diag, YTFY_diag, - XTX, XTDX, XTFX, X0TX0, X0TDX0, X0TFX0, - XTX0, XTDX0, XTFX0, X0TY, X0TDY, X0TFY, - L, rho1, n_V, n_base) + self._precompute_ar1_quad_forms(XTY, XTDY, XTFY, + YTY_diag, YTDY_diag, YTFY_diag, + XTX, XTDX, XTFX, X0TX0, X0TDX0, + X0TFX0, XTX0, XTDX0, XTFX0, + X0TY, X0TDY, X0TFY, + L, rho1, n_V, n_X0) # Only starting from this point, SNR2 is involved LL, LAMBDA_i, LAMBDA, YTAcorrXL_LAMBDA, sigma2 \ = self._calc_LL(rho1, LTXTAcorrXL, LTXTAcorrY, YTAcorrY, - X0TAX0, SNR2, n_V, n_T, n_run, rank, n_base) + X0TAX0, SNR2, n_V, n_T, n_run, rank, n_X0) if not np.isfinite(LL): logger.warning('NaN detected!') logger.warning('LL: {}'.format(LL)) @@ -1311,20 +2271,20 @@ def _loglike_AR1_diagV_fitU(self, param, XTX, XTDX, XTFX, YTY_diag, # The following are for calculating the derivative to a1 deriv_a1 = np.empty(n_V) - dXTAX_drho1 = -XTDX + 2 * rho1[:, np.newaxis, np.newaxis] * XTFX + dXTAX_drho1 = -XTDX + 2 * rho1[:, None, None] * XTFX # dimension: space*feature*feature - dXTAY_drho1 = self._make_sandwidge_grad(XTDY, XTFY, rho1) + dXTAY_drho1 = self._make_ar1_quad_form_grad(XTDY, XTFY, rho1) # dimension: feature*space - dYTAY_drho1 = self._make_sandwidge_grad(YTDY_diag, YTFY_diag, rho1) + dYTAY_drho1 = self._make_ar1_quad_form_grad(YTDY_diag, YTFY_diag, rho1) # dimension: space, dX0TAX0_drho1 = - X0TDX0 \ - + 2 * rho1[:, np.newaxis, np.newaxis] * X0TFX0 + + 2 * rho1[:, None, None] * X0TFX0 # dimension: space*rank*rank dXTAX0_drho1 = - XTDX0 \ - + 2 * rho1[:, np.newaxis, np.newaxis] * XTFX0 + + 2 * rho1[:, None, None] * XTFX0 # dimension: space*feature*rank - dX0TAY_drho1 = self._make_sandwidge_grad(X0TDY, X0TFY, rho1) + dX0TAY_drho1 = self._make_ar1_quad_form_grad(X0TDY, X0TFY, rho1) # dimension: rank*space # The following are executed for each voxel. @@ -1358,7 +2318,7 @@ def _loglike_AR1_diagV_fitU(self, param, XTX, XTDX, XTFX, YTY_diag, dX0TAX0_drho1[i_v, :, :]) * 0.5 - np.einsum('ij,ij', LAMBDA[i_v, :, :], np.dot(np.dot( - L.T, dXTAcorrX_drho1_ele), L)) + L.T, dXTAcorrX_drho1_ele), L)) * (SNR2[i_v] * 0.5) - dYTAcorrY_drho1_ele * 0.5 / sigma2[i_v] + SNR2[i_v] / sigma2[i_v] @@ -1380,7 +2340,7 @@ def _loglike_AR1_diagV_fitV(self, param, XTAcorrX, XTAcorrY, YTAcorrY, LTXTAcorrY, XTAcorrXL, LTXTAcorrXL, L_l, a1, l_idx, n_C, n_T, n_V, n_run, - n_base, idx_param_fitV, rank=None, + n_X0, idx_param_fitV, rank=None, GP_space=False, GP_inten=False, dist2=None, inten_dist2=None, space_smooth_range=None, @@ -1434,13 +2394,10 @@ def _loglike_AR1_diagV_fitV(self, param, # AR(1) coefficient, dimension: space LL, LAMBDA_i, LAMBDA, YTAcorrXL_LAMBDA, sigma2 \ = self._calc_LL(rho1, LTXTAcorrXL, LTXTAcorrY, YTAcorrY, X0TAX0, - SNR2, n_V, n_T, n_run, rank, n_base) + SNR2, n_V, n_T, n_run, rank, n_X0) # Log likelihood of data given parameters, without the GP prior. deriv_log_SNR2 = (-rank + np.trace(LAMBDA, axis1=1, axis2=2)) * 0.5\ - + YTAcorrY / (sigma2 * 2.0) - (n_T - n_base) * 0.5 \ - - np.einsum('ij,ijk,ik->i', YTAcorrXL_LAMBDA, - LTXTAcorrXL, YTAcorrXL_LAMBDA)\ - / (sigma2 * 2.0) * (SNR2**2) + + np.sum(YTAcorrXL_LAMBDA**2, axis=1) * SNR2 / sigma2 / 2 # Partial derivative of log likelihood over log(SNR^2) # dimension: space, # The second term above is due to the equation for calculating @@ -1485,18 +2442,12 @@ def _loglike_AR1_diagV_fitV(self, param, log_SNR_invK_tilde_log_SNR = np.dot(log_SNR2, invK_tilde_log_SNR) / 2 - # ML estimate of the variance of the Gaussian Process given + # MAP estimate of the variance of the Gaussian Process given # other parameters. - tau2 = (log_SNR_invK_tilde_log_SNR - n_V * self.tau_range**2 - + np.sqrt(n_V**2 * self.tau_range**4 + (2 * n_V + 8) - * self.tau_range**2 - * log_SNR_invK_tilde_log_SNR - + log_SNR_invK_tilde_log_SNR**2))\ - / 2 / (n_V + 2) - # Note that this derivation is based on the assumption that - # half-Cauchy prior on the standard deviation of the GP is - # imposed. If a different prior is imposed, this term needs - # to be changed accordingly. + tau2, log_ptau = self.tau2_prior(log_SNR_invK_tilde_log_SNR, n_V, + self.tau_range) + # log_ptau is log(p(tau)) given the form of prior for tau + LL += log_ptau # GP prior terms added to the log likelihood LL = LL - log_det_K_tilde / 2.0 - n_V / 2.0 * np.log(tau2) \ @@ -1523,12 +2474,6 @@ def _loglike_AR1_diagV_fitV(self, param, deriv_c_space -= 1 / (l2_space + space_smooth_range**2)\ * dl2_dc_space - # Prior on the standar deviation of GP - LL += scipy.stats.halfcauchy.logpdf( - tau2**0.5, scale=self.tau_range) - # Note that the form of the maximum likelihood estimate - # of tau2 depends on the form of prior imposed. - if GP_inten: dK_tilde_dl2_inten = inten_dist2 * K_major \ / 2.0 / l2_inten**2 @@ -1563,7 +2508,7 @@ def _loglike_AR1_singpara(self, param, XTX, XTDX, XTFX, YTY_diag, YTDY_diag, YTFY_diag, XTY, XTDY, XTFY, X0TX0, X0TDX0, X0TFX0, XTX0, XTDX0, XTFX0, X0TY, X0TDY, X0TFY, - l_idx, n_C, n_T, n_V, n_run, n_base, + l_idx, n_C, n_T, n_V, n_run, n_X0, idx_param_sing, rank=None): # In this version, we assume that beta is independent # between voxels and noise is also independent. @@ -1606,11 +2551,11 @@ def _loglike_AR1_singpara(self, param, XTX, XTDX, XTFX, YTY_diag, sigma2 = np.mean(YTAcorrY - np.sum(LTXTAcorrY * LAMBDA_LTXTAcorrY, axis=0))\ - / (n_T - n_base) - LL = n_V * (-np.log(sigma2) * (n_T - n_base) * 0.5 + / (n_T - n_X0) + LL = n_V * (-np.log(sigma2) * (n_T - n_X0) * 0.5 + np.log(1 - rho1**2) * n_run * 0.5 - - np.log(np.linalg.det(X0TAX0)) * 0.5 - - np.log(np.linalg.det(LAMBDA_i)) * 0.5) + - self._half_log_det(X0TAX0) + - self._half_log_det(LAMBDA_i)) deriv_L = np.dot(XTAcorrY, LAMBDA_LTXTAcorrY.T) / sigma2 \ - np.dot(np.dot(XTAcorrXL, LAMBDA_LTXTAcorrY), @@ -1650,9 +2595,9 @@ def _loglike_AR1_singpara(self, param, XTX, XTDX, XTFX, YTY_diag, deriv_a1 = 2.0 / (np.pi * (1 + a1**2)) \ * (n_V * (- n_run * rho1 / (1 - rho1**2) - 0.5 * np.trace(np.linalg.solve( - X0TAX0, dX0TAX0_drho1)) + X0TAX0, dX0TAX0_drho1)) - 0.5 * np.trace(np.linalg.solve( - LAMBDA_i, dLTXTAcorrXL_drho1))) + LAMBDA_i, dLTXTAcorrXL_drho1))) - 0.5 * np.sum(dYTAcorrY_drho1) / sigma2 + np.sum(dXTAcorrY_drho1 * L_LAMBDA_LTXTAcorrY) / sigma2 - 0.5 * np.sum(np.dot(dXTAcorrX_drho1, L_LAMBDA_LTXTAcorrY) @@ -1663,3 +2608,1555 @@ def _loglike_AR1_singpara(self, param, XTX, XTDX, XTFX, YTY_diag, deriv[idx_param_sing['a1']] = deriv_a1 return -LL, -deriv + + def _loglike_AR1_null(self, param, YTY_diag, YTDY_diag, YTFY_diag, + X0TX0, X0TDX0, X0TFX0, X0TY, X0TDY, X0TFY, + n_T, n_V, n_run, n_X0): + # This function calculates the log likelihood of data given AR(1) + # parameters of noise as free parameters. + # Free parameters are in param. + # It serves as a null model which assumes no response to design + # matrix. + a1 = param + rho1 = 2.0 / np.pi * np.arctan(a1) # auto-regressive coefficients + + YTAY = self._make_ar1_quad_form(YTY_diag, YTDY_diag, YTFY_diag, rho1) + # dimension: space, + # A/sigma2 is the inverse of noise covariance matrix in each voxel. + # YTAY means Y'AY + X0TAX0 = X0TX0[None, :, :] - rho1[:, None, None] \ + * X0TDX0[None, :, :] \ + + rho1[:, None, None]**2 * X0TFX0[None, :, :] + # dimension: space*#baseline*#baseline + X0TAY = self._make_ar1_quad_form(X0TY, X0TDY, X0TFY, rho1) + # dimension: #baseline*space + # X0TAX0_i = np.linalg.solve(X0TAX0, np.identity(n_X0)[None, :, :]) + X0TAX0_i = np.linalg.inv(X0TAX0) + # dimension: space*#baseline*#baseline + YTAcorrY = YTAY - np.sum(X0TAY * np.einsum('ijk,ki->ji', + X0TAX0_i, X0TAY), axis=0) + # dimension: space, + sigma2 = YTAcorrY / (n_T - n_X0) + # dimension: space, + LL = - np.sum(np.log(sigma2)) * (n_T - n_X0) * 0.5 \ + + np.sum(np.log(1 - rho1**2)) * n_run * 0.5 \ + - np.sum(self._half_log_det(X0TAX0)) \ + - (n_T - n_X0) * n_V * (1 + np.log(2 * np.pi)) * 0.5 + # The following are for calculating the derivative to a1 + deriv_a1 = np.empty(n_V) + dYTAY_drho1 = self._make_ar1_quad_form_grad(YTDY_diag, YTFY_diag, rho1) + # dimension: space, + dX0TAX0_drho1 = - X0TDX0 \ + + 2 * rho1[:, None, None] * X0TFX0 + # dimension: space*rank*rank + dX0TAY_drho1 = self._make_ar1_quad_form_grad(X0TDY, X0TFY, rho1) + # dimension: rank*space + + # The following are executed for each voxel. + for i_v in range(n_V): + # All variables with _ele as suffix are for data of just one voxel + invX0TAX0_X0TAY_ele = np.dot(X0TAX0_i[i_v, :, :], X0TAY[:, i_v]) + # preparation for the variable below + dYTAcorrY_drho1_ele = dYTAY_drho1[i_v] \ + - np.dot(dX0TAY_drho1[:, i_v], invX0TAX0_X0TAY_ele) * 2\ + + np.dot(np.dot(invX0TAX0_X0TAY_ele, dX0TAX0_drho1[i_v, :, :]), + invX0TAX0_X0TAY_ele) + deriv_a1[i_v] = 2 / np.pi / (1 + a1[i_v]**2) \ + * (- n_run * rho1[i_v] / (1 - rho1[i_v]**2) + - np.einsum('ij,ij', X0TAX0_i[i_v, :, :], + dX0TAX0_drho1[i_v, :, :]) * 0.5 + - dYTAcorrY_drho1_ele * 0.5 / sigma2[i_v]) + deriv = deriv_a1 + + return -LL, -deriv + + +class GBRSA(BRSA): + """Group Bayesian representational Similarity Analysis (GBRSA) + + Given the time series of neural imaging data in a region of interest + (ROI) and the hypothetical neural response (design matrix) to + each experimental condition of interest, + calculate the shared covariance matrix of + the voxels(recording unit)' response to each condition, + and the relative SNR of each voxels. + The relative SNR could be considered as the degree of contribution + of each voxel to this shared covariance matrix. + A correlation matrix converted from the covariance matrix + will be provided as a quantification of neural representational similarity. + Both tools provide estimation of SNR and noise parameters at the end, + and both tools provide empirical Bayesian estimates of activity patterns + beta, together with weight map of nuisance signals beta0. + + The differences of this tool from BRSA are: + (1) It allows fitting a shared covariance matrix (which can be converted + to similarity matrix) across multiple subjects. + This is analogous to SRM under funcalign submodule. Because of using + multiple subjects, the result is less noisy. + (2) In the fitting process, the SNR and noise parameters are marginalized + for each voxel. Therefore, this tool should be faster than BRSA + when analyzing an ROI of hundreds to thousands voxels. It does not + provide a spatial smoothness prior on SNR though. + (3) The voxel-wise pseudo-SNR and noise parameters estimated are + posterior mean estimates, while those estimated by BRSA are + maximum-a-posterior estimates. + If your goal is to perform searchlight RSA with relatively fewer voxels + on single subject, BRSA should be faster. However, GBRSA can in principle + be used together with searchlight in a template space such as MNI. + + .. math:: + Y = X \\cdot \\beta + X_0 \\cdot \\beta_0 + \\epsilon + + \\beta_i \\sim N(0,(s_{i} \\sigma_{i})^2 U) + + See also `.BRSA`. + + Parameters + ---------- + n_iter : int. Default: 50 + Number of maximum iterations to run the algorithm. + rank : int. Default: None + The rank of the covariance matrix. + If not provided, the covariance matrix will be assumed + to be full rank. When you have many conditions + (e.g., calculating the similarity matrix of responses to each event), + you might want to start with specifying a lower rank and use metrics + such as AIC or BIC to decide the optimal rank. The log likelihood + for the fitted data can be retrieved through private attributes + _LL_train\_. Note that this log likelihood score is only used + here for selecting hyperparameters such as rank. For any formal + model comparison, we recommend using score() function on left-out + data. + auto_nuisance: boolean. Default: True + In order to model spatial correlation between voxels that cannot + be accounted for by common response captured in the design matrix, + we assume that a set of time courses not related to the task + conditions are shared across voxels with unknown amplitudes. + One approach is for users to provide time series which they consider + as nuisance but exist in the noise (such as head motion). + The other way is to take the first n_nureg principal components + in the residual after subtracting the response to the design matrix + from the data, and use these components as the nuisance regressor. + This flag is for the second approach. If turned on, + PCA or factor analysis will be applied to the residuals + to obtain new nuisance regressors in each round of fitting. + These two approaches can be combined. If the users provide nuisance + regressors and set this flag as True, then the first n_nureg + principal components of the residuals after subtracting + both the responses to design matrix and the user-supplied nuisance + regressors will be used in addition to the nuisance regressors + provided by the users. + Note that nuisance regressor is not required from user. If it is + not provided, DC components for each run will be included as nuisance + regressor regardless of the auto_nuisance parameter. + n_nureg: Optional[int]. Default: None + Number of nuisance regressors to use in order to model signals + shared across voxels not captured by the design matrix. + This number is in addition to any nuisance regressor that the user + has already provided. + If set to None, the number of nuisance regressors will be + automatically determined based on M Gavish + and D Donoho's approximate estimation of optimal hard + threshold for singular values. (Gavish & Donoho, + IEEE Transactions on Information Theory 60.8 (2014): 5040-5053.) + This only takes effect if auto_nuisance is True. + nureg_zscore: boolean, default: True + A flag to tell the algorithm whether data is z-scored before + estimating the number of nuisance regressor components necessary to + account for spatial noise correlation. It also determinie whether + the residual noise is z-scored before estimating the nuisance + regressors from residual. + This only takes effect if auto_nuisance is True. + nureg_method: string, naming a method from sklearn.decomposition. + 'PCA', 'ICA', 'FA' or 'SPCA' are currently supported. Default: 'PCA' + The method to estimate the shared component in noise across voxels. + This only takes effect if auto_nuisance is True. + baseline_single: boolean. Default: False + A time course of constant 1 will be included to the nuisance + regressor for each participant. If baseline_single is set to False, + one such regressor is included for each fMRI run, but at the end of + fitting, a single component in beta0\_ will be computed as the average + of the weight maps corresponding to these regressors. This might + cause underestimation of noise variance. + If baseline_single is True, only one regressor of constant 1 will be + used for the whole dataset. This might be desirable if you + believe the average image intensity might not scale with the + same proportion for different voxels across scan. In other words, + it is possible that some part of the brain is more vulnerable to + change in baseline intensity due to facts such as + field inhomogeneity. Setting baseline_single to True will force the + nuisance regressors automatically estimated from residuals to + capture this. However, when each task condition only occurs in one + run and when the design matrix in each run sums together close to + a flat line, this option can cause the estimated similarity to be + extremely high between conditions occuring in the same run. + SNR_prior: string. Default: 'exp' + The type of prior for pseudo-SNR. + If set to 'exp', truncated exponential distribution with scale + parameter of 1 is imposed on pseudo-SNR. + If set to 'lognorm', a truncated log normal prior is imposed. + In this case, the standard deviation of log(SNR) is set + by the parameter logS_range. + If set to 'unif', a uniform prior in [0,1] is imposed. + In all these cases, SNR is numerically + marginalized on a grid of parameters. So the parameter SNR_bins + determines how accurate the numerical integration is. The more + number of bins are used, the more accurate the numerical + integration becomes. + In all the cases, the grids used for pseudo-SNR do not really + set an upper bound for SNR, because the real SNR is determined + by both pseudo-SNR and U, the shared covariance structure. + logS_range: float. Default: 1.0 + The reasonable range of the spread of SNR in log scale. + This parameter only takes effect if SNR_prior is set to 'lognorm'. + The code will in fact evaluate pseudo-SNR in the range of + exp(-3*logS_range) to exp(3*logS_range), on a grid equally spread + in log scale. The number of grids evaluated is determined by + SNR_bins. logS_range specifies how variable you believe the SNRs + to vary across voxels in log scale. + This range should not be set too large, otherwise the fitting + may encounter numerical issue. + If it is set too small, the estimated SNR will turn to be too + close to each other and the estimated similarity matrix might + overfit to voxels of low SNR. + If you increase logS_range, it is recommended to increase + SNR_bins accordingly, otherwise the pseudo-SNR values evaluated might + be too sparse, causing the posterior pseudo-SNR estimations + to be clustered around the bins. + SNR_bins: integer. Default: 21 + The number of bins used to numerically marginalize the pseudo-SNR + parameter. In general, you should try to choose a large number + to the degree that decreasing SNR_bins does not change the result + of fitting result. However, very large number of bins also causes + slower computation and larger memory consumption. + For SNR_prior='lognorm', the default value 21 is based on + the default value of logS_range=1.0 and bin width of 0.3 on log scale. + But it is also a reasonable choice for the other two options + for SNR_prior. + rho_bins: integer. Default: 20 + The number of bins to divide the region of (-1, 1) for rho. + This only takes effect for fitting the marginalized version. + If set to 20, discrete numbers of {-0.95, -0.85, ..., 0.95} will + be used to numerically integrate rho from -1 to 1. + optimizer: str or callable. Default: 'BFGS' + The optimizer to use for minimizing cost function which + scipy.optimize.minimize can accept. + We use 'L-BFGS-B' as a default. Users can try other strings + corresponding to optimizer provided by scipy.optimize.minimize, + or a custom optimizer, such as 'L-BFGS-B' or 'CG'. + Note that BRSA fits a lot of parameters. So a chosen optimizer + should accept gradient (Jacobian) of the cost function. Otherwise + the fitting is likely to be unbarely slow. We do not calculate + Hessian of the objective function. So an optimizer which requires + Hessian cannot be used. + minimize_options: dictionary. + Default: {'gtol': 1e-4, 'disp': False, + 'maxiter': 20} + This is the dictionary passed as the options argument to + scipy.optimize.minize which minimizes the cost function during + fitting. Notice that the minimization is performed for up to + n_iter times, with the nuisance regressor re-estimated each time. + So within each of the n_iter steps of fitting, + scipy.optimize.minize does not need to fully converge. The key + 'maxiter' in this dictionary determines the maximum number of + iteration done by scipy.optimize.minimize within each of the n_iter + steps of fitting. + tol: float. Default: 1e-4. + Tolerance parameter passed to scipy.optimize.minimize. It is also + used for determining convergence of the alternating fitting + procedure. + random_state : RandomState or an int seed. Default: None + A random number generator instance to define the state of + the random permutations generator whenever the module + needs to generate random number (e.g., initial parameter + of the Cholesky factor). + anneal_speed: float. Default: 10 + Annealing is introduced in fitting of the Cholesky + decomposition of the shared covariance matrix. The amount + of perturbation decays exponentially. This parameter sets + the ratio of the maximum number of iteration to the + time constant of the exponential. + anneal_speed=10 means by n_iter/10 iterations, + the amount of perturbation is reduced by 2.713 times. + + Attributes + ---------- + U_ : numpy array, shape=[condition,condition]. + The shared covariance matrix + L_ : numpy array, shape=[condition,condition]. + The Cholesky factor of the shared covariance matrix + (lower-triangular matrix). + C_: numpy array, shape=[condition,condition]. + The correlation matrix derived from the shared covariance matrix. + This is the estimated similarity matrix between neural patterns + to your task conditions. Notice that it is recommended that + you also check U\_, which is the covariance matrix underlying + this correlation matrix. In cases there is almost no response + to your task conditions, the diagonal values of U\_ would become + very small and C\_ might contain many correlation coefficients + close to 1 or -1. This might not reflect true strong correlation + or strong negative correlation, but a result of lack of + task-related neural activity, design matrix that does not match + true neural response, or not enough data. + It is also recommended to check nSNR\_ after mapping it back to + the brain. A "reasonable" map should at least have higher values + in gray matter in than white matter. + nSNR_ : list of numpy arrays, shape=[voxels,] for each subject in the list. + The pseuso-SNR of all voxels. If SNR_prior='lognormal', + the geometric mean of nSNR\_ would be approximately 1. + If SNR_prior='unif', all nSNR\_ would be in the range of (0,1). + If SNR_prior='exp' (default), the range of values would vary + depending on the data and SNR_bins, but many should have low + values with few voxels with high values. + Note that this attribute can not be interpreted as true SNR, + but the relative ratios between voxels indicate the contribution + of each voxel to the representational similarity structure. + sigma_ : list of numpy arrays, shape=[voxels,] for each subject. + The estimated standard deviation of the noise in each voxel + Assuming AR(1) model, this means the standard deviation + of the innovation noise. + rho_ : list of numpy arrays, shape=[voxels,] for each subject. + The estimated autoregressive coefficient of each voxel + beta_: list of numpy arrays, shape=[conditions, voxels] for each subject. + The posterior mean estimation of the response amplitudes + of each voxel to each task condition. + beta0_: list of numpy arrays, shape=[n_nureg + n_base, voxels] + for each subject. + The loading weights of each voxel for the shared time courses + not captured by the design matrix. + n_base is the number of columns of the user-supplied nuisance + regressors plus one for DC component. + X0_: list of numpy arrays, shape=[time_points, n_nureg + n_base] + for each subject. + The estimated time course that is shared across voxels but + unrelated to the events of interest (design matrix). + beta0_null_: list of numpy arrays, shape=[n_nureg + n_base, voxels] + for each subject. + The equivalent of beta0\_ in a null model which does not + include the design matrix and response pattern beta + X0_null_: list of numpy arrays, shape=[time_points, n_nureg + n_base] + for each subject. + The equivalent of X0\_ in a null model which does not + include the design matrix and response pattern beta + n_nureg_: 1-d numpy array + Number of nuisance regressor used to model the spatial noise + correlation of each participant. + random_state_: `RandomState` + Random number generator initialized using random_state. + """ + + def __init__( + self, n_iter=50, rank=None, + auto_nuisance=True, n_nureg=None, nureg_zscore=True, + nureg_method='PCA', + baseline_single=False, logS_range=1.0, SNR_prior='exp', + SNR_bins=21, rho_bins=20, tol=1e-4, optimizer='BFGS', + minimize_options={'gtol': 1e-4, 'disp': False, + 'maxiter': 20}, random_state=None, + anneal_speed=10): + + self.n_iter = n_iter + self.rank = rank + self.auto_nuisance = auto_nuisance + self.n_nureg = n_nureg + self.nureg_zscore = nureg_zscore + if auto_nuisance: + assert (n_nureg is None) \ + or (isinstance(n_nureg, int) and n_nureg > 0), \ + 'n_nureg should be a positive integer or None'\ + ' if auto_nuisance is True.' + if self.nureg_zscore: + self.preprocess_residual = lambda x: _zscore(x) + else: + self.preprocess_residual = lambda x: x + if nureg_method == 'FA': + self.nureg_method = lambda x: FactorAnalysis(n_components=x) + elif nureg_method == 'PCA': + self.nureg_method = lambda x: PCA(n_components=x, whiten=True) + elif nureg_method == 'SPCA': + self.nureg_method = lambda x: SparsePCA(n_components=x, + max_iter=20, tol=tol) + elif nureg_method == 'ICA': + self.nureg_method = lambda x: FastICA(n_components=x, + whiten=True) + else: + raise ValueError('nureg_method can only be FA, PCA, ' + 'SPCA(for sparse PCA) or ICA') + self.baseline_single = baseline_single + if type(logS_range) is int: + logS_range = float(logS_range) + self.logS_range = logS_range + assert SNR_prior in ['unif', 'lognorm', 'exp'], \ + 'SNR_prior can only be chosen from ''unif'', ''lognorm''' \ + ' and ''exp''' + self.SNR_prior = SNR_prior + self.SNR_bins = SNR_bins + self.rho_bins = rho_bins + self.tol = tol + self.optimizer = optimizer + self.minimize_options = minimize_options + self.random_state = random_state + self.anneal_speed = anneal_speed + return + + def fit(self, X, design, nuisance=None, scan_onsets=None): + """ Fit the model to data of all participants jointly. + + Parameters + ---------- + X: list of numpy arrays, shape=[time_points, voxels] for each entry. + Data to be fitted. Each participant corresponds to one item in + the list. If you have multiple scans of the same participants + that you want to analyze together, you should concatenate them + along the time dimension after proper preprocessing (e.g. spatial + alignment), and specify the onsets of each scan in scan_onsets. + design: list of numpy arrays, shape=[time_points, conditions] for each. + This is the design matrix of each participant. + It should only include the hypothetic response for task conditions. + You should not include regressors for a DC component or + motion parameters, unless with a strong reason. + If you want to model head motion, you should include them + in nuisance regressors. + If you have multiple run, the design matrix + of all runs should be concatenated along the time dimension for + each participant, with every column for one condition across runs. + If the design matrix is the same for all subjects, + either provide a list as required, or provide single numpy array. + nuisance: optional, list of numpy arrays, + shape=[time_points, nuisance_factors] for each subject in the list. + Nuisance regressors of each participant. + The responses to these regressors will be marginalized out from + each voxel, which means they are considered, but won't be assumed + to share the same pseudo-SNR map with the design matrix. + Therefore, the pseudo-SNR map will only reflect the + relative contribution of design matrix to each voxel. + You can provide time courses such as those for head motion + to this parameter. + Note that if auto_nuisance is set to True, the first + n_nureg principal components of residual (excluding the response + to the design matrix and the user-provided nuisance regressors) + will be included as additional nuisance regressor after the + first round of fitting. + If auto_nuisance is set to False, the nuisance regressors supplied + by the users together with DC components will be used as + nuisance time series. + scan_onsets: optional, list numpy arrays, shape=[runs,] for each. + Each item in the list specifies the indices of X which correspond + to the onset of each scanning run for one participant. + For example, if you have two experimental runs of + the first participant, each with 100 TRs, and one run of the + second participant, with 150 TR, then scan_onsets should be + [ndarry([0, 100]), ndarry([150])]. + The effect of this argument is to make the inverse matrix + of the temporal covariance matrix of noise block-diagonal. + If you do not provide the argument, the program will + assume all data are from the same run for each participant. + """ + + logger.info('Running Group Bayesian RSA (which can also analyze' + ' data of a single participant). Voxel-specific parameters' + 'are all marginalized.') + self.random_state_ = check_random_state(self.random_state) + # setting random seed + logger.debug('RandState set to {}'.format(self.random_state_)) + # Checking all inputs. + X = self._check_data_GBRSA(X) + design = self._check_design_GBRSA(design, X) + nuisance = self._check_nuisance_GBRSA( + copy.deepcopy(nuisance), X) + # The reason that we use copy of nuisance is because they + # may be modified inside our code. + + scan_onsets = self._check_scan_onsets_GBRSA(scan_onsets, X) + # Run Marginalized Bayesian RSA + # Note that we have a change of notation here. + # Within _fit_RSA_marginalized, design matrix is named X + # and data is named Y, to reflect the + # generative model that data Y is generated by mixing the response + # X to experiment conditions and other neural activity. + # However, in fit(), we keep the scikit-learn API that + # X is the input data to fit and y, a reserved name not used, is + # the label to map to from X. + assert self.SNR_bins >= 10 and self.rho_bins >= 10, \ + 'At least 10 bins are required to perform the numerical'\ + ' integration over SNR and rho' + assert self.logS_range * 6 / self.SNR_bins < 0.5 \ + or self.SNR_prior != 'lognorm', \ + 'The minimum grid of log(SNR) should not be larger than 0.5 '\ + 'if log normal prior is chosen for SNR.' \ + ' Please consider increasing SNR_bins or reducing logS_range' + self.n_subj_ = len(X) + self.n_V_ = [None] * self.n_subj_ + for subj, x in enumerate(X): + self.n_V_[subj] = x.shape[1] + if self.auto_nuisance: + if self.n_nureg is None: + logger.info('numbers of nuisance regressors are determined ' + 'automatically.') + n_runs = np.zeros(self.n_subj_) + n_comps = np.ones(self.n_subj_) + for s_id in np.arange(self.n_subj_): + # For each subject, determine the number of nuisance + # regressors needed to account for the covariance + # in residuals. + # Residual is calculated by regrssing + # out the design matrix and DC component and linear trend + # from data of each run. + run_TRs, n_runs[s_id] = self._run_TR_from_scan_onsets( + X[s_id].shape[0], scan_onsets[s_id]) + ts_dc = self._gen_legendre(run_TRs, [0]) + _, ts_base, _ = self._merge_DC_to_base( + ts_dc, nuisance[s_id], False) + ts_reg = np.concatenate((ts_base, design[s_id]), axis=1) + beta_hat = np.linalg.lstsq(ts_reg, X[s_id])[0] + residuals = X[s_id] - np.dot(ts_reg, beta_hat) + + n_comps[s_id] = np.min( + [np.max([Ncomp_SVHT_MG_DLD_approx( + residuals, self.nureg_zscore), 1]), + np.linalg.matrix_rank(residuals) - 1]) + # n_nureg_ should not exceed the rank of + # residual minus 1. + self.n_nureg_ = n_comps + logger.info('Use {} nuisance regressors to model the spatial ' + 'correlation in noise.'.format(self.n_nureg_)) + else: + self.n_nureg_ = self.n_nureg * np.ones(self.n_subj_) + self.n_nureg_ = np.int32(self.n_nureg_) + + self.beta0_null_, self.sigma_null_, self.rho_null_, self.X0_null_,\ + self._LL_null_train_ = self._fit_RSA_marginalized_null( + Y=X, X_base=nuisance, scan_onsets=scan_onsets) + self.U_, self.L_, self.nSNR_, self.beta_, self.beta0_,\ + self.sigma_, self.rho_, self.X0_, self._LL_train_ = \ + self._fit_RSA_marginalized( + X=design, Y=X, X_base=nuisance, + scan_onsets=scan_onsets) + + self.C_ = utils.cov2corr(self.U_) + self.design_ = design.copy() + + self._rho_design_ = [None] * self.n_subj_ + self._sigma2_design_ = [None] * self.n_subj_ + self._rho_X0_ = [None] * self.n_subj_ + self._sigma2_X0_ = [None] * self.n_subj_ + self._rho_X0_null_ = [None] * self.n_subj_ + self._sigma2_X0_null_ = [None] * self.n_subj_ + for subj in np.arange(self.n_subj_): + self._rho_design_[subj], self._sigma2_design_[subj] = \ + self._est_AR1(self.design_[subj], same_para=True) + self._rho_X0_[subj], self._sigma2_X0_[subj] = \ + self._est_AR1(self.X0_[subj]) + self._rho_X0_null_[subj], self._sigma2_X0_null_[subj] =\ + self._est_AR1(self.X0_null_[subj]) + # AR(1) parameters of the design matrix and nuisance regressors, + # which will be used in transform or score. + + return self + + def transform(self, X, y=None, scan_onsets=None): + """ Use the model to estimate the time course of response to + each condition (ts), and the time course unrelated to task + (ts0) which is spread across the brain. + This is equivalent to "decoding" the design matrix and + nuisance regressors from a new dataset different from the + training dataset on which fit() was applied. An AR(1) smooth + prior is imposed on the decoded ts and ts0 with the AR(1) + parameters learnt from the corresponding time courses in the + training data. + + Parameters + ---------- + X : list of 2-D arrays. For each item, shape=[time_points, voxels] + New fMRI data of the same subjects. The voxels should + match those used in the fit() function. + The size of the list should match the size of the list X fed + to fit(), with each item in the list corresponding to data + from the same subject in the X fed to fit(). If you do not + need to transform some subjects' data, leave the entry + corresponding to that subject as None. + If data are z-scored when fitting the model, + data should be z-scored as well when calling transform() + y : not used (as it is unsupervised learning) + scan_onsets : list of 1-D numpy arrays, + Each array corresponds to the onsets of + scans in the data X for the particular subject. + If not provided, data will be assumed + to be acquired in a continuous scan. + + Returns + ------- + ts : list of 2-D arrays. For each, shape = [time_points, condition] + The estimated response to the cognitive dimensions + (task dimensions) whose response amplitudes were estimated + during the fit step. + One item for each subject. If some subjects' data are + not provided, None will be returned. + ts0: list of 2-D array. For each, shape = [time_points, n_nureg] + The estimated time courses spread across the brain, with the + loading weights estimated during the fit step. + One item for each subject. If some subjects' data are + not provided, None will be returned. + """ + X = self._check_data_GBRSA(X, for_fit=False) + scan_onsets = self._check_scan_onsets_GBRSA(scan_onsets, X) + assert len(X) == self.n_subj_ + ts = [None] * self.n_subj_ + ts0 = [None] * self.n_subj_ + log_p = [None] * self.n_subj_ + for i, x in enumerate(X): + if x is not None: + s = scan_onsets[i] + ts[i], ts0[i], log_p[i] = self._transform( + Y=x, scan_onsets=s, beta=self.beta_[i], + beta0=self.beta0_[i], rho_e=self.rho_[i], + sigma_e=self.sigma_[i], rho_X=self._rho_design_[i], + sigma2_X=self._sigma2_design_[i], + rho_X0=self._rho_X0_[i], sigma2_X0=self._sigma2_X0_[i]) + return ts, ts0 + + def score(self, X, design, scan_onsets=None): + """ After fit() is applied to the data of a group of participants, + use the parameters estimated by fit() function to evaluate + from some data of a set of participants to evaluate + the log likelihood of some new data of the same participants + given these estimated parameters. + Design matrices of the same set of experimental + conditions in the testing data should be provided, with each + column corresponding to the same condition as that column + in the design matrix of the training data. + Unknown nuisance time series will be marginalized, assuming + they follow the same spatial pattern as in the training + data. The hypothetical response captured by the design matrix + will be subtracted from data before the marginalization + when evaluating the log likelihood. For null model, + nothing will be subtracted before marginalization. + + There is a difference between the form of likelihood function + used in fit() and score(). In fit(), the response amplitude + beta to design matrix X and the modulation beta0 by nuisance + regressor X0 are both marginalized, with X provided and X0 + estimated from data. In score(), posterior estimation of + beta and beta0 from the fitting step are assumed unchanged + in testing data; X is assumed given by the user, + and X0 is marginalized. + The logic underlying score() is to transfer + as much as what we can learn from training data when + calculating a likelihood score for testing data. This is done + at the cost of using point estimation for beta and beta0. + + If you z-scored your data during fit step, you should + z-score them for score function as well. If you did not + z-score in fitting, you should not z-score here either. + + Parameters + ---------- + X : List of 2-D arrays. For each item, shape=[time_points, voxels] + fMRI data of new data of the same participants. + The voxels of each participants should + match those used in the fit() function. If data are z-scored + (recommended) when fitting the model, data should be z-scored + as well when calling transform() + design : List of 2-D arrays. shape=[time_points, conditions] for each + Each corresponds to one participant. + Design matrices expressing the hypothetical response of + the task conditions in data X. + scan_onsets : List of 2-D arrays, shape=[#fMRI runs] for each + Each array corresponds to one participant. + Lists of indices corresponding to the onsets of + scans in the data X. + If not provided, data will be assumed + to be acquired in a continuous scan. + + Returns + ------- + ll: list, shape=[number of participants] + The log likelihoods of the new data based on the model and its + parameters fit to the training data. + If data of some participants are not provided, the corresponding + entry will be None. + ll_null: list, shape=[number of participants] + The log likelihood of the new data based on a null model + which assumes the same as the full model for everything + except for that there is no response to any of the + task conditions. + """ + X = self._check_data_GBRSA(X, for_fit=False) + scan_onsets = self._check_scan_onsets_GBRSA(scan_onsets, X) + design = self._check_design_GBRSA(design, X) + assert len(X) == self.n_subj_ + ll = [None] * self.n_subj_ + ll_null = [None] * self.n_subj_ + for subj in np.arange(self.n_subj_): + if X[subj] is not None: + ll[subj] = self._score( + Y=X[subj], design=design[subj], beta=self.beta_[subj], + scan_onsets=scan_onsets[subj], beta0=self.beta0_[subj], + rho_e=self.rho_[subj], sigma_e=self.sigma_[subj], + rho_X0=self._rho_X0_[subj], + sigma2_X0=self._sigma2_X0_[subj]) + ll_null[subj] = self._score( + Y=X[subj], design=None, beta=None, + scan_onsets=scan_onsets[subj], beta0=self.beta0_[subj], + rho_e=self.rho_[subj], sigma_e=self.sigma_[subj], + rho_X0=self._rho_X0_[subj], + sigma2_X0=self._sigma2_X0_[subj]) + return ll, ll_null + + def _precompute_ar1_quad_forms_marginalized( + self, XTY, XTDY, XTFY, YTY_diag, YTDY_diag, YTFY_diag, + XTX, XTDX, XTFX, X0TX0, X0TDX0, X0TFX0, + XTX0, XTDX0, XTFX0, X0TY, X0TDY, X0TFY, + rho1, n_V, n_X0): + # Calculate the sandwich terms which put Acorr between X, Y and X0 + # These terms are used a lot in the likelihood. This function + # is used for the marginalized version. + XTAY = XTY - rho1[:, None, None] * XTDY \ + + rho1[:, None, None]**2 * XTFY + # dimension: #rho*feature*space + YTAY_diag = YTY_diag - rho1[:, None] * YTDY_diag \ + + rho1[:, None]**2 * YTFY_diag + # dimension: #rho*space, + # A/sigma2 is the inverse of noise covariance matrix in each voxel. + # YTAY means Y'AY + XTAX = XTX - rho1[:, None, None] * XTDX \ + + rho1[:, None, None]**2 * XTFX + # dimension: n_rho*feature*feature + X0TAX0 = X0TX0[None, :, :] - rho1[:, None, None] \ + * X0TDX0[None, :, :] \ + + rho1[:, None, None]**2 * X0TFX0[None, :, :] + # dimension: #rho*#baseline*#baseline + XTAX0 = XTX0[None, :, :] - rho1[:, None, None] \ + * XTDX0[None, :, :] \ + + rho1[:, None, None]**2 * XTFX0[None, :, :] + # dimension: n_rho*feature*#baseline + X0TAY = X0TY - rho1[:, None, None] * X0TDY \ + + rho1[:, None, None]**2 * X0TFY + # dimension: #rho*#baseline*space + X0TAX0_i = np.linalg.solve(X0TAX0, np.identity(n_X0)[None, :, :]) + # dimension: #rho*#baseline*#baseline + XTAcorrX = XTAX + # dimension: #rho*feature*feature + XTAcorrY = XTAY + # dimension: #rho*feature*space + YTAcorrY_diag = YTAY_diag + for i_r in range(np.size(rho1)): + XTAcorrX[i_r, :, :] -= \ + np.dot(np.dot(XTAX0[i_r, :, :], X0TAX0_i[i_r, :, :]), + XTAX0[i_r, :, :].T) + XTAcorrY[i_r, :, :] -= np.dot(np.dot(XTAX0[i_r, :, :], + X0TAX0_i[i_r, :, :]), + X0TAY[i_r, :, :]) + YTAcorrY_diag[i_r, :] -= np.sum( + X0TAY[i_r, :, :] * np.dot(X0TAX0_i[i_r, :, :], + X0TAY[i_r, :, :]), axis=0) + + return X0TAX0, X0TAX0_i, XTAcorrX, XTAcorrY, YTAcorrY_diag, \ + X0TAY, XTAX0 + + def _fit_RSA_marginalized(self, X, Y, X_base, + scan_onsets=None): + """ The major utility of fitting Bayesian RSA + (marginalized version). + Note that there is a naming change of variable. X in fit() + is changed to Y here, and design in fit() is changed to X here. + This is because we follow the tradition that X expresses the + variable defined (controlled) by the experimenter, i.e., the + time course of experimental conditions convolved by an HRF, + and Y expresses data. + However, in wrapper function fit(), we follow the naming + routine of scikit-learn. + """ + rank = self.rank + n_subj = len(Y) + n_V = [np.size(y, axis=1) for y in Y] + n_T = [np.size(y, axis=0) for y in Y] + n_C = np.size(X[0], axis=1) + l_idx, rank = self._chol_idx(n_C, rank) + n_l = np.size(l_idx[0]) # the number of parameters for L + + t_start = time.time() + + logger.info('Starting to fit the model. Maximum iteration: ' + '{}.'.format(self.n_iter)) + + # log_SNR_grids, SNR_weights \ + # = np.polynomial.hermite.hermgauss(SNR_bins) + # SNR_weights = SNR_weights / np.pi**0.5 + # SNR_grids = np.exp(log_SNR_grids * self.logS_range * 2**.5) + + SNR_grids, SNR_weights = self._set_SNR_grids() + logger.info('The grids of pseudo-SNR used for numerical integration ' + 'is {}.'.format(SNR_grids)) + assert np.max(SNR_grids) < 1e10, \ + 'ATTENTION!! The range of grids of pseudo-SNR' \ + ' to be marginalized is too large. Please ' \ + 'consider reducing logS_range to 1 or 2' + rho_grids, rho_weights = self._set_rho_grids() + logger.info('The grids of rho used to do numerical integration ' + 'is {}.'.format(rho_grids)) + n_grid = self.SNR_bins * self.rho_bins + log_weights = np.reshape( + np.log(SNR_weights[:, None]) + np.log(rho_weights), n_grid) + all_rho_grids = np.reshape(np.repeat( + rho_grids[None, :], self.SNR_bins, axis=0), n_grid) + all_SNR_grids = np.reshape(np.repeat( + SNR_grids[:, None], self.rho_bins, axis=1), n_grid) + # Prepare the data for fitting. These pre-calculated matrices + # will be re-used a lot in evaluating likelihood function and + # gradient. + D = [None] * n_subj + F = [None] * n_subj + run_TRs = [None] * n_subj + n_run = [None] * n_subj + XTY = [None] * n_subj + XTDY = [None] * n_subj + XTFY = [None] * n_subj + YTY_diag = [None] * n_subj + YTDY_diag = [None] * n_subj + YTFY_diag = [None] * n_subj + XTX = [None] * n_subj + XTDX = [None] * n_subj + XTFX = [None] * n_subj + + X0TX0 = [None] * n_subj + X0TDX0 = [None] * n_subj + X0TFX0 = [None] * n_subj + XTX0 = [None] * n_subj + XTDX0 = [None] * n_subj + XTFX0 = [None] * n_subj + X0TY = [None] * n_subj + X0TDY = [None] * n_subj + X0TFY = [None] * n_subj + X0 = [None] * n_subj + X_res = [None] * n_subj + n_X0 = [None] * n_subj + idx_DC = [None] * n_subj + log_fixed_terms = [None] * n_subj + + # Initialization for L. + # There are several possible ways of initializing the covariance. + # (1) start from the point estimation of covariance + + cov_point_est = np.zeros((n_C, n_C)) + + for subj in range(n_subj): + D[subj], F[subj], run_TRs[subj], n_run[subj] = self._prepare_DF( + n_T[subj], scan_onsets=scan_onsets[subj]) + XTY[subj], XTDY[subj], XTFY[subj], YTY_diag[subj], \ + YTDY_diag[subj], YTFY_diag[subj], XTX[subj], XTDX[subj], \ + XTFX[subj] = self._prepare_data_XY( + X[subj], Y[subj], D[subj], F[subj]) + # The contents above stay fixed during fitting. + + # Initializing X0 as DC baseline + # DC component will be added to the nuisance regressors. + # In later steps, we do not need to add DC components again + X0TX0[subj], X0TDX0[subj], X0TFX0[subj], XTX0[subj], XTDX0[subj], \ + XTFX0[subj], X0TY[subj], X0TDY[subj], X0TFY[subj], X0[subj], \ + X_base[subj], n_X0[subj], idx_DC[subj] = \ + self._prepare_data_XYX0( + X[subj], Y[subj], X_base[subj], None, D[subj], F[subj], + run_TRs[subj], no_DC=False) + + X_joint = np.concatenate((X0[subj], X[subj]), axis=1) + beta_hat = np.linalg.lstsq(X_joint, Y[subj])[0] + residual = Y[subj] - np.dot(X_joint, beta_hat) + # point estimates of betas and fitting residuals without assuming + # the Bayesian model underlying RSA. + + cov_point_est += np.cov(beta_hat[n_X0[subj]:, :] + / np.std(residual, axis=0)) + log_fixed_terms[subj] = - (n_T[subj] - n_X0[subj]) \ + / 2 * np.log(2 * np.pi) + n_run[subj] \ + / 2 * np.log(1 - all_rho_grids**2) \ + + scipy.special.gammaln( + (n_T[subj] - n_X0[subj] - 2) / 2) \ + + (n_T[subj] - n_X0[subj] - 2) / 2 * np.log(2) + # These are terms in the log likelihood that do not + # depend on L. Notice that the last term comes from + # ther term of marginalizing sigma. We take the 2 in + # the denominator out. Accordingly, the "denominator" + # variable in the _raw_loglike_grids() function is not + # divided by 2 + cov_point_est = cov_point_est / n_subj + current_vec_U_chlsk_l = np.linalg.cholesky( + (cov_point_est + np.eye(n_C)) / 2)[l_idx] + # We use the average of covariance of point estimation and an identity + # matrix as the initial value of the covariance matrix, just in case + # the user provides data in which n_V is smaller than n_C. + + # (2) start from identity matrix + + # current_vec_U_chlsk_l = np.eye(n_C)[l_idx] + + # (3) random initialization + + # current_vec_U_chlsk_l = self.random_state_.randn(n_l) + # vectorized version of L, Cholesky factor of U, the shared + # covariance matrix of betas across voxels. + L = np.zeros((n_C, rank)) + L[l_idx] = current_vec_U_chlsk_l + + X0TAX0 = [None] * n_subj + X0TAX0_i = [None] * n_subj + XTAcorrX = [None] * n_subj + s2XTAcorrX = [None] * n_subj + YTAcorrY_diag = [None] * n_subj + XTAcorrY = [None] * n_subj + sXTAcorrY = [None] * n_subj + X0TAY = [None] * n_subj + XTAX0 = [None] * n_subj + half_log_det_X0TAX0 = [None] * n_subj + s_post = [None] * n_subj + rho_post = [None] * n_subj + sigma_post = [None] * n_subj + beta_post = [None] * n_subj + beta0_post = [None] * n_subj + # The contents below can be updated during fitting. + # e.g., X0 will be re-estimated + logger.info('start real fitting') + LL = np.zeros(n_subj) + for it in range(self.n_iter): + logger.info('Iteration {}'.format(it)) + # Re-estimate part of X0: X_res + for subj in range(n_subj): + if self.auto_nuisance and it > 0: + residuals = Y[subj] - np.dot(X[subj], beta_post[subj]) \ + - np.dot( + X_base[subj], + beta0_post[subj][:np.shape(X_base[subj])[1], :]) + X_res[subj] = self.nureg_method( + self.n_nureg_[subj]).fit_transform( + self.preprocess_residual(residuals)) + X0TX0[subj], X0TDX0[subj], X0TFX0[subj], XTX0[subj],\ + XTDX0[subj], XTFX0[subj], X0TY[subj], X0TDY[subj], \ + X0TFY[subj], X0[subj], X_base[subj], n_X0[subj], _ = \ + self._prepare_data_XYX0( + X[subj], Y[subj], X_base[subj], X_res[subj], + D[subj], F[subj], run_TRs[subj], no_DC=True) + X0TAX0[subj], X0TAX0_i[subj], XTAcorrX[subj], XTAcorrY[subj],\ + YTAcorrY_diag[subj], X0TAY[subj], XTAX0[subj] \ + = self._precompute_ar1_quad_forms_marginalized( + XTY[subj], XTDY[subj], XTFY[subj], YTY_diag[subj], + YTDY_diag[subj], YTFY_diag[subj], XTX[subj], + XTDX[subj], XTFX[subj], X0TX0[subj], X0TDX0[subj], + X0TFX0[subj], XTX0[subj], XTDX0[subj], XTFX0[subj], + X0TY[subj], X0TDY[subj], X0TFY[subj], rho_grids, + n_V[subj], n_X0[subj]) + + # Now we expand to another dimension including SNR + # and collapse the dimension again. + half_log_det_X0TAX0[subj], X0TAX0[subj], X0TAX0_i[subj], \ + s2XTAcorrX[subj], YTAcorrY_diag[subj], sXTAcorrY[subj], \ + X0TAY[subj], XTAX0[subj] = self._matrix_flattened_grid( + X0TAX0[subj], X0TAX0_i[subj], SNR_grids, + XTAcorrX[subj], YTAcorrY_diag[subj], XTAcorrY[subj], + X0TAY[subj], XTAX0[subj], n_C, n_V[subj], n_X0[subj], + n_grid) + + res = scipy.optimize.minimize( + self._sum_loglike_marginalized, current_vec_U_chlsk_l + + self.random_state_.randn(n_l) * + np.linalg.norm(current_vec_U_chlsk_l) + / n_l**0.5 * np.exp(-it / self.n_iter + * self.anneal_speed - 1), + args=(s2XTAcorrX, YTAcorrY_diag, sXTAcorrY, + half_log_det_X0TAX0, + log_weights, log_fixed_terms, + l_idx, n_C, n_T, n_V, n_X0, + n_grid, rank), + method=self.optimizer, jac=True, tol=self.tol, + options=self.minimize_options) + param_change = res.x - current_vec_U_chlsk_l + current_vec_U_chlsk_l = res.x.copy() + + # Estimating a few parameters. + L[l_idx] = current_vec_U_chlsk_l + for subj in range(n_subj): + LL_raw, denominator, L_LAMBDA, L_LAMBDA_LT = \ + self._raw_loglike_grids( + L, s2XTAcorrX[subj], YTAcorrY_diag[subj], + sXTAcorrY[subj], half_log_det_X0TAX0[subj], + log_weights, log_fixed_terms[subj], n_C, n_T[subj], + n_V[subj], n_X0[subj], n_grid, rank) + result_sum, max_value, result_exp = utils.sumexp_stable(LL_raw) + LL[subj] = np.sum(np.log(result_sum) + max_value) + weight_post = result_exp / result_sum + s_post[subj] = np.sum(all_SNR_grids[:, None] * weight_post, + axis=0) + # Mean-posterior estimate of SNR. + rho_post[subj] = np.sum(all_rho_grids[:, None] * weight_post, + axis=0) + # Mean-posterior estimate of rho. + sigma_means = denominator ** 0.5 \ + * (np.exp(scipy.special.gammaln( + (n_T[subj] - n_X0[subj] - 3) / 2) + - scipy.special.gammaln( + (n_T[subj] - n_X0[subj] - 2) / 2)) / 2**0.5) + sigma_post[subj] = np.sum(sigma_means * weight_post, axis=0) + # The mean of inverse-Gamma distribution is beta/(alpha-1) + # The mode is beta/(alpha+1). Notice that beta here does not + # refer to the brain activation, but the scale parameter of + # inverse-Gamma distribution. In the _UV version, we use the + # maximum likelihood estimate of sigma^2. So we divide by + # (alpha+1), which is (n_T - n_X0). + beta_post[subj] = np.zeros((n_C, n_V[subj])) + beta0_post[subj] = np.zeros((n_X0[subj], n_V[subj])) + for grid in range(n_grid): + beta_post[subj] += np.dot(L_LAMBDA_LT[grid, :, :], + sXTAcorrY[subj][grid, :, :])\ + * all_SNR_grids[grid] \ + * weight_post[grid, :] + beta0_post[subj] += weight_post[grid, :] * np.dot( + X0TAX0_i[subj][grid, :, :], + (X0TAY[subj][grid, :, :] + - np.dot(np.dot(XTAX0[subj][grid, :, :].T, + L_LAMBDA_LT[grid, :, :]), + sXTAcorrY[subj][grid, :, :]) + * all_SNR_grids[grid])) + if np.max(np.abs(param_change)) < self.tol: + logger.info('The change of parameters is smaller than ' + 'the tolerance value {}. Fitting is finished ' + 'after {} iterations'.format(self.tol, it + 1)) + break + for subj in range(n_subj): + if idx_DC[subj].size > 1: + collapsed_DC = np.sum(X0[subj][:, idx_DC[subj]], axis=1) + X0[subj] = np.insert(np.delete(X0[subj], idx_DC[subj], axis=1), + 0, collapsed_DC, axis=1) + collapsed_beta0 = np.mean(beta0_post[subj][idx_DC[subj], :], + axis=0) + beta0_post[subj] = np.insert( + np.delete(beta0_post[subj], idx_DC[subj], axis=0), + 0, collapsed_beta0, axis=0) + t_finish = time.time() + logger.info( + 'total time of fitting: {} seconds'.format(t_finish - t_start)) + return np.dot(L, L.T), L, s_post, \ + beta_post, beta0_post, sigma_post, \ + rho_post, X0, LL + + def _fit_RSA_marginalized_null(self, Y, X_base, + scan_onsets): + """ The marginalized version of the null model for Bayesian RSA. + The null model assumes no task-related response to the + design matrix. + Note that there is a naming change of variable. X in fit() + is changed to Y here. + This is because we follow the tradition that Y corresponds + to data. + However, in wrapper function fit(), we follow the naming + routine of scikit-learn. + """ + # Because there is nothing to learn that is shared across + # participants, we can run each subject in serial. + # The only fitting required is to re-estimate X0 after + # each iteration + n_subj = len(Y) + t_start = time.time() + logger.info('Starting to fit the model. Maximum iteration: ' + '{}.'.format(self.n_iter)) + + rho_grids, rho_weights = self._set_rho_grids() + logger.info('The grids of rho used to do numerical integration ' + 'is {}.'.format(rho_grids)) + n_grid = self.rho_bins + log_weights = np.log(rho_weights) + rho_post = [None] * n_subj + sigma_post = [None] * n_subj + beta0_post = [None] * n_subj + X0 = [None] * n_subj + LL_null = np.zeros(n_subj) + for subj in range(n_subj): + logger.debug('Running on subject {}.'.format(subj)) + [n_T, n_V] = np.shape(Y[subj]) + D, F, run_TRs, n_run = self._prepare_DF( + n_T, scan_onsets=scan_onsets[subj]) + YTY_diag = np.sum(Y[subj] * Y[subj], axis=0) + YTDY_diag = np.sum(Y[subj] * np.dot(D, Y[subj]), axis=0) + YTFY_diag = np.sum(Y[subj] * np.dot(F, Y[subj]), axis=0) + + # Add DC components capturing run-specific baselines. + X_DC = self._gen_X_DC(run_TRs) + X_DC, X_base[subj], idx_DC = self._merge_DC_to_base( + X_DC, X_base[subj], no_DC=False) + X_res = np.empty((n_T, 0)) + for it in range(0, self.n_iter): + X0[subj] = np.concatenate( + (X_base[subj], X_res), axis=1) + n_X0 = X0[subj].shape[1] + X0TX0, X0TDX0, X0TFX0 = self._make_templates( + D, F, X0[subj], X0[subj]) + X0TY, X0TDY, X0TFY = self._make_templates( + D, F, X0[subj], Y[subj]) + + YTAY_diag = YTY_diag - rho_grids[:, None] * YTDY_diag \ + + rho_grids[:, None]**2 * YTFY_diag + # dimension: #rho*space, + # A/sigma2 is the inverse of noise covariance matrix. + # YTAY means Y'AY + + X0TAX0 = X0TX0[None, :, :] \ + - rho_grids[:, None, None] \ + * X0TDX0[None, :, :] \ + + rho_grids[:, None, None]**2 \ + * X0TFX0[None, :, :] + # dimension: #rho*#baseline*#baseline + X0TAY = X0TY - rho_grids[:, None, None] * X0TDY \ + + rho_grids[:, None, None]**2 * X0TFY + # dimension: #rho*#baseline*space + X0TAX0_i = np.linalg.solve( + X0TAX0, np.identity(n_X0)[None, :, :]) + # dimension: #rho*#baseline*#baseline + YTAcorrY_diag = np.empty(np.shape(YTAY_diag)) + for i_r in range(np.size(rho_grids)): + YTAcorrY_diag[i_r, :] = YTAY_diag[i_r, :] \ + - np.sum(X0TAY[i_r, :, :] * np.dot( + X0TAX0_i[i_r, :, :], X0TAY[i_r, :, :]), + axis=0) + + log_fixed_terms = - (n_T - n_X0) / 2 * np.log(2 * np.pi)\ + + n_run / 2 * np.log(1 - rho_grids**2) \ + + scipy.special.gammaln((n_T - n_X0 - 2) / 2) \ + + (n_T - n_X0 - 2) / 2 * np.log(2) + # These are terms in the log likelihood that do not + # depend on L. Notice that the last term comes from + # ther term of marginalizing sigma. We take the 2 in + # the denominator out. Accordingly, the "denominator" + # variable in the _raw_loglike_grids() function is not + # divided by 2 + half_log_det_X0TAX0 = self._half_log_det(X0TAX0) + + LL_raw = -half_log_det_X0TAX0[:, None] \ + - (n_T - n_X0 - 2) / 2 * np.log(YTAcorrY_diag) \ + + log_weights[:, None] + log_fixed_terms[:, None] + # dimension: n_grid * space + # The log likelihood at each pair of values of rho1. + # half_log_det_X0TAX0 is 0.5*log(det(X0TAX0)) with the size of + # number of parameter grids. So is the size of log_weights + + result_sum, max_value, result_exp = utils.sumexp_stable(LL_raw) + weight_post = result_exp / result_sum + rho_post[subj] = np.sum(rho_grids[:, None] * weight_post, + axis=0) + # Mean-posterior estimate of rho. + sigma_means = YTAcorrY_diag ** 0.5 \ + * (np.exp(scipy.special.gammaln((n_T - n_X0 - 3) / 2) + - scipy.special.gammaln((n_T - n_X0 - 2) / 2)) + / 2**0.5) + sigma_post[subj] = np.sum(sigma_means * weight_post, axis=0) + beta0_post[subj] = np.zeros((n_X0, n_V)) + for grid in range(n_grid): + beta0_post[subj] += weight_post[grid, :] * np.dot( + X0TAX0_i[grid, :, :], X0TAY[grid, :, :]) + if self.auto_nuisance: + residuals = Y[subj] - np.dot( + X_base[subj], + beta0_post[subj][:np.size(X_base[subj], 1), :]) + X_res_new = self.nureg_method( + self.n_nureg_[subj]).fit_transform( + self.preprocess_residual(residuals)) + if it >= 1: + if np.max(np.abs(X_res_new - X_res)) <= self.tol: + logger.info('The change of X_res is ' + 'smaller than the tolerance value {}.' + 'Fitting is finished after {} ' + 'iterations'.format(self.tol, it + 1)) + break + X_res = X_res_new + if idx_DC.size > 1: + collapsed_DC = np.sum(X0[subj][:, idx_DC], axis=1) + X0[subj] = np.insert(np.delete(X0[subj], idx_DC, axis=1), 0, + collapsed_DC, axis=1) + collapsed_beta0 = np.mean(beta0_post[subj][idx_DC, :], axis=0) + beta0_post[subj] = np.insert( + np.delete(beta0_post[subj], idx_DC, axis=0), + 0, collapsed_beta0, axis=0) + LL_null[subj] = np.sum(np.log(result_sum) + max_value) + t_finish = time.time() + logger.info( + 'total time of fitting: {} seconds'.format(t_finish - t_start)) + return beta0_post, sigma_post, rho_post, X0, LL_null + + def _raw_loglike_grids(self, L, s2XTAcorrX, YTAcorrY_diag, + sXTAcorrY, half_log_det_X0TAX0, + log_weights, log_fixed_terms, + n_C, n_T, n_V, n_X0, + n_grid, rank): + # LAMBDA_i = np.dot(np.einsum('ijk,jl->ilk', s2XTAcorrX, L), L) \ + # + np.identity(rank) + LAMBDA_i = np.empty((n_grid, rank, rank)) + for grid in np.arange(n_grid): + LAMBDA_i[grid, :, :] = np.dot(np.dot(L.T, + s2XTAcorrX[grid, :, :]), L) + LAMBDA_i += np.identity(rank) + # dimension: n_grid * rank * rank + Chol_LAMBDA_i = np.linalg.cholesky(LAMBDA_i) + # dimension: n_grid * rank * rank + half_log_det_LAMBDA_i = np.sum( + np.log(np.abs(np.diagonal(Chol_LAMBDA_i, axis1=1, axis2=2))), + axis=1) + # dimension: n_grid + L_LAMBDA = np.empty((n_grid, n_C, rank)) + L_LAMBDA_LT = np.empty((n_grid, n_C, n_C)) + s2YTAcorrXL_LAMBDA_LTXTAcorrY = np.empty((n_grid, n_V)) + # dimension: space * n_grid + + for grid in np.arange(n_grid): + L_LAMBDA[grid, :, :] = scipy.linalg.cho_solve( + (Chol_LAMBDA_i[grid, :, :], True), L.T).T + L_LAMBDA_LT[grid, :, :] = np.dot(L_LAMBDA[grid, :, :], L.T) + s2YTAcorrXL_LAMBDA_LTXTAcorrY[grid, :] = np.sum( + sXTAcorrY[grid, :, :] * np.dot(L_LAMBDA_LT[grid, :, :], + sXTAcorrY[grid, :, :]), + axis=0) + denominator = (YTAcorrY_diag - s2YTAcorrXL_LAMBDA_LTXTAcorrY) + # dimension: n_grid * space + # Not necessary the best name for it. But this term appears + # as the denominator within the gradient wrt L + # In the equation of the log likelihood, this "denominator" + # term is in fact divided by 2. But we absorb that into the + # log fixted term. + LL_raw = -half_log_det_X0TAX0[:, None] \ + - half_log_det_LAMBDA_i[:, None] \ + - (n_T - n_X0 - 2) / 2 * np.log(denominator) \ + + log_weights[:, None] + log_fixed_terms[:, None] + # dimension: n_grid * space + # The log likelihood at each pair of values of SNR and rho1. + # half_log_det_X0TAX0 is 0.5*log(det(X0TAX0)) with the size of + # number of parameter grids. So is the size of log_weights + return LL_raw, denominator, L_LAMBDA, L_LAMBDA_LT + + def _sum_loglike_marginalized(self, L_vec, s2XTAcorrX, YTAcorrY_diag, + sXTAcorrY, half_log_det_X0TAX0, + log_weights, log_fixed_terms, + l_idx, n_C, n_T, n_V, n_X0, + n_grid, rank=None): + sum_LL_total = 0 + sum_grad_L = np.zeros(np.size(l_idx[0])) + for subj in range(len(YTAcorrY_diag)): + LL_total, grad_L = self._loglike_marginalized( + L_vec, s2XTAcorrX[subj], YTAcorrY_diag[subj], + sXTAcorrY[subj], half_log_det_X0TAX0[subj], log_weights, + log_fixed_terms[subj], l_idx, n_C, n_T[subj], + n_V[subj], n_X0[subj], n_grid, rank) + sum_LL_total += LL_total + sum_grad_L += grad_L + return sum_LL_total, sum_grad_L + + def _loglike_marginalized(self, L_vec, s2XTAcorrX, YTAcorrY_diag, + sXTAcorrY, half_log_det_X0TAX0, + log_weights, log_fixed_terms, + l_idx, n_C, n_T, n_V, n_X0, + n_grid, rank=None): + # In this version, we assume that beta is independent + # between voxels and noise is also independent. X0 captures the + # co-flucturation between voxels that is + # not captured by design matrix X. + # marginalized version marginalize sigma^2, s and rho1 + # for all voxels. n_grid is the number of grid on which the numeric + # integration is performed to marginalize s and rho1 for each voxel. + # The log likelihood is an inverse-Gamma distribution sigma^2, + # so we can analytically marginalize it assuming uniform prior. + # n_grid is the number of grid in the parameter space of (s, rho1) + # that is used for numerical integration over (s, rho1). + + n_l = np.size(l_idx[0]) + # the number of parameters in the index of lower-triangular matrix + + if rank is None: + rank = int((2 * n_C + 1 + - np.sqrt(n_C**2 * 4 + n_C * 4 + 1 - 8 * n_l)) / 2) + + L = np.zeros([n_C, rank]) + L[l_idx] = L_vec + + LL_raw, denominator, L_LAMBDA, _ = self._raw_loglike_grids( + L, s2XTAcorrX, YTAcorrY_diag, sXTAcorrY, half_log_det_X0TAX0, + log_weights, log_fixed_terms, n_C, n_T, n_V, n_X0, n_grid, rank) + + result_sum, max_value, result_exp = utils.sumexp_stable(LL_raw) + LL_total = np.sum(np.log(result_sum) + max_value) + + # Now we start the gradient with respect to L + # s2XTAcorrXL_LAMBDA = np.einsum('ijk,ikl->ijl', + # s2XTAcorrX, L_LAMBDA) + s2XTAcorrXL_LAMBDA = np.empty((n_grid, n_C, rank)) + for grid in range(n_grid): + s2XTAcorrXL_LAMBDA[grid, :, :] = np.dot(s2XTAcorrX[grid, :, :], + L_LAMBDA[grid, :, :]) + # dimension: n_grid * condition * rank + I_minus_s2XTAcorrXL_LAMBDA_LT = np.identity(n_C) \ + - np.dot(s2XTAcorrXL_LAMBDA, L.T) + # dimension: n_grid * condition * condition + # The step above may be calculated by einsum. Not sure + # which is faster. + weight_grad = result_exp / result_sum + weight_grad_over_denominator = weight_grad / denominator + # dimension: n_grid * space + weighted_sXTAcorrY = sXTAcorrY \ + * weight_grad_over_denominator[:, None, :] + # dimension: n_grid * condition * space + # sYTAcorrXL_LAMBDA = np.einsum('ijk,ijl->ikl', sXTAcorrY, L_LAMBDA) + # dimension: n_grid * space * rank + grad_L = np.zeros([n_C, rank]) + for grid in range(n_grid): + grad_L += np.dot( + np.dot(I_minus_s2XTAcorrXL_LAMBDA_LT[grid, :, :], + sXTAcorrY[grid, :, :]), + np.dot(weighted_sXTAcorrY[grid, :, :].T, + L_LAMBDA[grid, :, :])) * (n_T - n_X0 - 2) + grad_L -= np.sum(s2XTAcorrXL_LAMBDA + * np.sum(weight_grad, axis=1)[:, None, None], + axis=0) + # dimension: condition * rank + + return -LL_total, -grad_L[l_idx] + + def _check_data_GBRSA(self, X, for_fit=True): + # Check input data + if type(X) is np.ndarray: + X = [X] + assert type(X) is list, 'Input data X must be either a list '\ + 'with each entry for one participant, or a numpy arrary '\ + 'for single participant.' + if for_fit: + for i, x in enumerate(X): + assert_all_finite(x) + assert x.ndim == 2, 'Each participants'' data should be ' \ + '2 dimension ndarray' + assert np.all(np.std(x, axis=0) > 0),\ + 'The time courses of some voxels in participant {} '\ + 'do not change at all. Please make sure all voxels '\ + 'are within the brain'.format(i) + else: + for i, x in enumerate(X): + if x is not None: + assert x.ndim == 2, 'Each participants'' data should be ' \ + '2 dimension ndarray' + assert x.shape[1] == self.n_V_[i], 'Number of voxels '\ + 'does not match that in the data used for fitting: '\ + 'subject {}'.format(i) + # This program allows to fit a single subject. But to have a consistent + # data structure, we make sure X and design are both lists. + return X + + def _check_design_GBRSA(self, design, X): + # check design matrix + if type(design) is np.ndarray: + design = [design] * len(X) + if len(X) > 1: + logger.warning('There are multiple subjects while ' + 'there is only one design matrix. ' + 'I assume that the design matrix ' + 'is shared across all subjects.') + assert type(design) is list, 'design matrix must be either a list '\ + 'with each entry for one participant, or an numpy arrary '\ + 'for single participant.' + + for i, d in enumerate(design): + if X[i] is not None: + assert_all_finite(d) + assert d.ndim == 2,\ + 'The design matrix should be 2 dimension ndarray' + assert np.linalg.matrix_rank(d) == d.shape[1], \ + 'Your design matrix of subject {} has rank ' \ + 'smaller than the number of columns. Some columns '\ + 'can be explained by linear combination of other columns.'\ + 'Please check your design matrix.'.format(i) + assert np.size(d, axis=0) == np.size(X[i], axis=0),\ + 'Design matrix and data of subject {} do not '\ + 'have the same number of time points.'.format(i) + assert self.rank is None or self.rank <= d.shape[1],\ + 'Your design matrix of subject {} '\ + 'has fewer columns than the rank you set'.format(i) + if i == 0: + n_C = np.shape(d)[1] + else: + assert n_C == np.shape(d)[1], \ + 'In Group Bayesian RSA, all subjects should have'\ + ' the same set of experiment conditions, t'\ + 'hus the same number of columns in design matrix' + if X[i].shape[1] <= d.shape[1]: + logger.warning('Your data have fewer voxels than the ' + 'number of task conditions. This might ' + 'cause problem in fitting. Please consider ' + 'increasing the size of your ROI, or set ' + 'the rank parameter to a lower number to ' + 'estimate a low-rank representational ' + 'structure.') + return design + + def _check_nuisance_GBRSA(sef, nuisance, X): + # Check the nuisance regressors. + if nuisance is not None: + if type(nuisance) is np.ndarray: + nuisance = [nuisance] * len(X) + if len(X) > 1: + logger.warning('ATTENTION! There are multiple subjects ' + 'while there is only one nuisance matrix. ' + 'I assume that the nuisance matrix ' + 'is shared across all subjects. ' + 'Please double check.') + assert type(nuisance) is list, \ + 'nuisance matrix must be either a list '\ + 'with each entry for one participant, or an numpy arrary '\ + 'for single participant.' + for i, n in enumerate(nuisance): + assert_all_finite(n) + if n is not None: + assert n.ndim == 2,\ + 'The nuisance regressor should be '\ + '2 dimension ndarray or None' + assert np.linalg.matrix_rank(n) == n.shape[1], \ + 'The nuisance regressor of subject {} has rank '\ + 'smaller than the number of columns.'\ + 'Some columns can be explained by linear '\ + 'combination of other columns. Please check your' \ + ' nuisance regressors.'.format(i) + assert np.size(n, axis=0) == np.size(X[i], axis=0), \ + 'Nuisance regressor and data do not have the same '\ + 'number of time points.' + else: + nuisance = [None] * len(X) + logger.info('None was provided for nuisance matrix. Replicating ' + 'it for all subjects.') + return nuisance + + def _check_scan_onsets_GBRSA(self, scan_onsets, X): + # check scan_onsets validity + if scan_onsets is None or type(scan_onsets) is np.ndarray: + if scan_onsets is None: + scan_onsets = np.array([0], dtype=int) + scan_onsets = [scan_onsets] * len(X) + if len(X) > 1: + logger.warning('There are multiple subjects while ' + 'there is only one set of scan_onsets. ' + 'I assume that it is the same for all' + ' subjects. Please double check') + for i in np.arange(len(scan_onsets)): + if X[i] is not None: + if scan_onsets[i] is None: + scan_onsets[i] = np.array([0], dtype=int) + logger.warning('No scan onsets were provided for subject' + ' {}. Treating all data of this subject as' + ' coming from the same run.') + else: + scan_onsets[i] = np.int32(scan_onsets[i]) + assert (np.max(scan_onsets[i]) <= X[i].shape[0] + and np.min(scan_onsets[i]) >= 0 + and 0 in scan_onsets[i] + and scan_onsets[i].ndim == 1), \ + 'Scan onsets of subject {} has formatting ' \ + 'issues: {}'.format(i, scan_onsets[i]) + return scan_onsets + + def _bin_exp(self, n_bin, scale=1.0): + """ Calculate the bin locations to approximate exponential distribution. + It breaks the cumulative probability of exponential distribution + into n_bin equal bins, each covering 1 / n_bin probability. Then it + calculates the center of mass in each bins and returns the + centers of mass. So, it approximates the exponential distribution + with n_bin of Delta function weighted by 1 / n_bin, at the + locations of these centers of mass. + Parameters: + ----------- + n_bin: int + The number of bins to approximate the exponential distribution + scale: float, default: 1.0 + The scale parameter of the exponential distribution, defined in + the same way as scipy.stats. It does not influence the ratios + between the bins, but just controls the spacing between the bins. + So generally users should not change its default. + Returns: + -------- + bins: numpy array of size [n_bin,] + The centers of mass for each segment of the + exponential distribution. + """ + boundaries = np.flip(scipy.stats.expon.isf( + np.linspace(0, 1, n_bin + 1), + scale=scale), axis=0) + bins = np.empty(n_bin) + for i in np.arange(n_bin): + bins[i] = utils.center_mass_exp( + boundaries[i], boundaries[i + 1], scale=scale) + return bins + + def _set_SNR_grids(self): + """ Set the grids and weights for SNR used in numerical integration + of SNR parameters. + """ + if self.SNR_prior == 'unif': + SNR_grids = np.linspace(0, 1, self.SNR_bins) + SNR_weights = np.ones(self.SNR_bins) / (self.SNR_bins - 1) + SNR_weights[0] = SNR_weights[0] / 2.0 + SNR_weights[-1] = SNR_weights[-1] / 2.0 + elif self.SNR_prior == 'lognorm': + log_SNR_grids = ((np.arange(self.SNR_bins) + - (self.SNR_bins - 1) / 2)) \ + / self.SNR_bins * self.logS_range * 6 + SNR_grids = np.exp(log_SNR_grids) + log_SNR_grids_upper = log_SNR_grids + self.logS_range * 3 \ + / self.SNR_bins + SNR_weights = np.empty(self.SNR_bins) + SNR_weights[1:-1] = np.diff( + scipy.stats.norm.cdf(log_SNR_grids_upper[:-1], + scale=self.logS_range)) + SNR_weights[0] = scipy.stats.norm.cdf(log_SNR_grids_upper[0], + scale=self.logS_range) + SNR_weights[-1] = 1 - scipy.stats.norm.cdf(log_SNR_grids_upper[-2], + scale=self.logS_range) + SNR_grids[0] = 0 + else: # SNR_prior == 'exp' + SNR_grids = self._bin_exp(self.SNR_bins) + SNR_weights = np.ones(self.SNR_bins) / self.SNR_bins + SNR_weights = SNR_weights / np.sum(SNR_weights) + return SNR_grids, SNR_weights + + def _set_rho_grids(self): + """ Set the grids and weights for rho used in numerical integration + of AR(1) parameters. + """ + rho_grids = np.arange(self.rho_bins) * 2 / self.rho_bins - 1 \ + + 1 / self.rho_bins + rho_weights = np.ones(self.rho_bins) / self.rho_bins + return rho_grids, rho_weights + + def _matrix_flattened_grid(self, X0TAX0, X0TAX0_i, SNR_grids, XTAcorrX, + YTAcorrY_diag, XTAcorrY, X0TAY, XTAX0, + n_C, n_V, n_X0, n_grid): + """ We need to integrate parameters SNR and rho on 2-d discrete grids. + This function generates matrices which have only one dimension for + these two parameters, with each slice in that dimension + corresponding to each combination of the discrete grids of SNR + and discrete grids of rho. + """ + half_log_det_X0TAX0 = np.reshape( + np.repeat(self._half_log_det(X0TAX0)[None, :], + self.SNR_bins, axis=0), n_grid) + X0TAX0 = np.reshape( + np.repeat(X0TAX0[None, :, :, :], + self.SNR_bins, axis=0), + (n_grid, n_X0, n_X0)) + X0TAX0_i = np.reshape(np.repeat( + X0TAX0_i[None, :, :, :], + self.SNR_bins, axis=0), + (n_grid, n_X0, n_X0)) + s2XTAcorrX = np.reshape( + SNR_grids[:, None, None, None]**2 * XTAcorrX, + (n_grid, n_C, n_C)) + YTAcorrY_diag = np.reshape(np.repeat( + YTAcorrY_diag[None, :, :], + self.SNR_bins, axis=0), (n_grid, n_V)) + sXTAcorrY = np.reshape(SNR_grids[:, None, None, None] + * XTAcorrY, (n_grid, n_C, n_V)) + X0TAY = np.reshape(np.repeat(X0TAY[None, :, :, :], + self.SNR_bins, axis=0), + (n_grid, n_X0, n_V)) + XTAX0 = np.reshape(np.repeat(XTAX0[None, :, :, :], + self.SNR_bins, axis=0), + (n_grid, n_C, n_X0)) + return half_log_det_X0TAX0, X0TAX0, X0TAX0_i, s2XTAcorrX, \ + YTAcorrY_diag, sXTAcorrY, X0TAY, XTAX0 diff --git a/brainiak/utils/utils.py b/brainiak/utils/utils.py index 2a6b3b8c2..12fb0fb1a 100644 --- a/brainiak/utils/utils.py +++ b/brainiak/utils/utils.py @@ -319,6 +319,7 @@ def read_afni(self, fname): def gen_design(stimtime_files, scan_duration, TR, style='FSL', + temp_res=0.01, hrf_para={'response_delay': 6, 'undershoot_delay': 12, 'response_dispersion': 0.9, 'undershoot_dispersion': 0.9, @@ -389,6 +390,14 @@ def gen_design(stimtime_files, scan_duration, TR, style='FSL', means an event starting at 3.0s, lasting for 1.0s, with amplitude modulation of 1.0. + temp_res: float, default: 0.01 + Temporal resolution of fMRI, in second. + + hrf_para: dictionary + The parameters of the double-Gamma hemodynamic response function. + To set different parameters, supply a dictionary with + the same set of keys as the default, and replace the corresponding + values with the new values. Returns ------- @@ -433,21 +442,28 @@ def gen_design(stimtime_files, scan_duration, TR, style='FSL', onsets=design_info[i_s][i_c]['onset'], event_durations=design_info[i_s][i_c]['duration'], total_time=scan_duration[i_s], - weights=design_info[i_s][i_c]['weight']) + weights=design_info[i_s][i_c]['weight'], + temporal_resolution=1.0/temp_res) design[i_s][:, i_c] = double_gamma_hrf( stimfunction, TR, response_delay=response_delay, undershoot_delay=undershoot_delay, response_dispersion=response_dispersion, undershoot_dispersion=undershoot_dispersion, - undershoot_scale=undershoot_scale, scale_function=0) + undershoot_scale=undershoot_scale, scale_function=0, + temporal_resolution=1.0/temp_res) * temp_res + # We multiply the resulting design matrix with + # the temporal resolution to normalize it. + # We do not use the internal normalization + # in double_gamma_hrf because it does not guarantee + # normalizing with the same constant. return np.concatenate(design, axis=0) def _read_stimtime_FSL(stimtime_files, n_C, n_S, scan_onoff): - """Utility called by gen_design. It reads in one or more - stimulus timing file comforming to FSL style, - and return a list (size of [#run \* #condition]) - of dictionary including onsets, durations and weights of each event. + """ Utility called by gen_design. It reads in one or more + stimulus timing file comforming to FSL style, + and return a list (size of [#run \* #condition]) + of dictionary including onsets, durations and weights of each event. Parameters ---------- @@ -512,10 +528,10 @@ def _read_stimtime_FSL(stimtime_files, n_C, n_S, scan_onoff): def _read_stimtime_AFNI(stimtime_files, n_C, n_S, scan_onoff): - """Utility called by gen_design. It reads in one or more stimulus timing - file comforming to AFNI style, and return a list - (size of ``[number of runs \* number of conditions]``) - of dictionary including onsets, durations and weights of each event. + """ Utility called by gen_design. It reads in one or more stimulus timing + file comforming to AFNI style, and return a list + (size of ``[number of runs \* number of conditions]``) + of dictionary including onsets, durations and weights of each event. Parameters ---------- @@ -585,3 +601,35 @@ def _read_stimtime_AFNI(stimtime_files, n_C, n_S, scan_onoff): design_info[i_s][i_c]['duration'].append(duration) design_info[i_s][i_c]['weight'].append(weight) return design_info + + +def center_mass_exp(a, b, scale=1.0): + """ Calculate the center of mass of negative exponential distribution + p(x) = exp(-x / scale) / scale + in the interval of (a, b). scale is the same scale + parameter as scipy.stats.expon.pdf + + Parameters + ---------- + a: float + The starting point of the interval in which the center of mass + is calculated for exponential distribution. + b: float + The ending point of the interval in which the center of mass + is calculated for exponential distribution. + scale: float + The scale parameter of the exponential distribution. See above. + + Returns + ------- + m: float + The center of mass in the interval of (a, b) for exponential + distribution. + """ + assert a < b, 'a must be smaller than b' + if b < np.inf: + return ((a + scale) * np.exp(-a / scale) + - (scale + b) * np.exp(-b / scale)) \ + / (np.exp(-a / scale) - np.exp(-b / scale)) + else: + return a + scale diff --git a/docs/newsfragments/194.feature b/docs/newsfragments/194.feature new file mode 100644 index 000000000..f7e161956 --- /dev/null +++ b/docs/newsfragments/194.feature @@ -0,0 +1,2 @@ +reprsimil: add Group Bayesian RSA, add transform (decoding) and score (model selection) functions, add automatic determination of the number of necessary nuisance regressors. +utils: modified gen_design to make the generated design matrix approximately scaled in amplitudes. diff --git a/examples/reprsimil/bayesian_rsa_example.ipynb b/examples/reprsimil/bayesian_rsa_example.ipynb index c6c7620ac..544d3305a 100644 --- a/examples/reprsimil/bayesian_rsa_example.ipynb +++ b/examples/reprsimil/bayesian_rsa_example.ipynb @@ -3,25 +3,35 @@ { "cell_type": "markdown", "metadata": { - "collapsed": false + "collapsed": false, + "deletable": true, + "editable": true }, "source": [ - "# This demo shows how to use the Bayesian Representational Similarity Analysis method in brainiak with a simulated dataset." + "# This demo shows how to use the Bayesian Representational Similarity Analysis method in brainiak with a simulated dataset.\n", + "### The brainik.reprsimil.brsa module has two estimators named BRSA and GBRSA. Both of them can be used to estimate representational similarity from a single participant, but with some differences in the assumptions of the models and fitting procedure. The basic usages are similar. We now generally recommend using GBRSA over BRSA for most of the cases. This document shows how to use BRSA for most of the part. At the end of the document, the usage of GBRSA is shown as well. You are encouranged to go through the example and try both estimators for your data.\n", + "### The group_brsa_example.ipynb in the same directory demonstrates how to use GBRSA to estimate shared representational structure from multiple participants." ] }, { "cell_type": "markdown", - "metadata": {}, + "metadata": { + "deletable": true, + "editable": true + }, "source": [ "#### Load some package which we will use in this demo.\n", - "If you see error related to loading any package, you can install that package. For example, if you use Anaconda, you can use \"conda install matplotlib\" to install matplotlib." + "If you see error related to loading any package, you can install that package. For example, if you use Anaconda, you can use \"conda install matplotlib\" to install matplotlib.\n", + "##### Notice that due to current implementation, you need to import either prior_GP_var_inv_gamma or prior_GP_var_half_cauchy from brsa module, in order to use the smooth prior imposed onto SNR in BRSA (see below). They are forms of priors imposed on the variance of Gaussian Process prior on log(SNR). (If you think these sentences are confusing, just import them like below and forget about this). " ] }, { "cell_type": "code", "execution_count": null, "metadata": { - "collapsed": false + "collapsed": false, + "deletable": true, + "editable": true }, "outputs": [], "source": [ @@ -29,7 +39,8 @@ "import scipy.stats\n", "import scipy.spatial.distance as spdist\n", "import numpy as np\n", - "from brainiak.reprsimil.brsa import BRSA\n", + "from brainiak.reprsimil.brsa import BRSA, prior_GP_var_inv_gamma, prior_GP_var_half_cauchy\n", + "from brainiak.reprsimil.brsa import GBRSA\n", "import brainiak.utils.utils as utils\n", "import matplotlib.pyplot as plt\n", "import logging\n", @@ -38,7 +49,10 @@ }, { "cell_type": "markdown", - "metadata": {}, + "metadata": { + "deletable": true, + "editable": true + }, "source": [ "#### You might want to keep a log of the output." ] @@ -47,7 +61,9 @@ "cell_type": "code", "execution_count": null, "metadata": { - "collapsed": false + "collapsed": false, + "deletable": true, + "editable": true }, "outputs": [], "source": [ @@ -59,14 +75,20 @@ }, { "cell_type": "markdown", - "metadata": {}, + "metadata": { + "deletable": true, + "editable": true + }, "source": [ "# We want to simulate some data in which each voxel responds to different task conditions differently, but following a common covariance structure" ] }, { "cell_type": "markdown", - "metadata": {}, + "metadata": { + "deletable": true, + "editable": true + }, "source": [ "## Load an example design matrix.\n", "The user should prepare their design matrix with their favorate software, such as using 3ddeconvolve of AFNI, or using SPM or FSL.\n", @@ -75,7 +97,8 @@ "For each type of event, such a convoluted time course can be generated. These time courses, put together, are called design matrix, reflecting what we believe a temporal signal would look like, if it exists in any voxel.\n", "Our goal is to figure out how the (spatial) response pattern of a population of voxels (in an Region of Interest, ROI) are similar or disimilar to different types of tasks (e.g., watching face vs. house, watching different categories of animals, different conditions of a cognitive task). So we need the design matrix in order to estimate the similarity matrix we are interested.\n", "\n", - "We can use the utility called ReadDesign to read a design matrix generated from AFNI. For design matrix saved as Matlab data file by SPM or or other toolbox, you can use scipy.io.loadmat('YOURFILENAME') and extract the design matrix from the dictionary returned. Basically, the Bayesian RSA in this toolkit just needs a numpy array which is in size of {time points} * {condition}\n", + "We can use the utility called ReadDesign in brainiak.utils to read a design matrix generated from AFNI. For design matrix saved as Matlab data file by SPM or or other toolbox, you can use scipy.io.loadmat('YOURFILENAME') and extract the design matrix from the dictionary returned. Basically, the Bayesian RSA in this toolkit just needs a numpy array which is in size of {time points} * {condition}\n", + "You can also generate design matrix using the function gen_design which is in brainiak.utils. It takes in (name of) event timing files in AFNI or FSL format (denoting onsets, duration, and weight for each event belongning to the same condition) and outputs the design matrix as numpy array.\n", "\n", "In typical fMRI analysis, some nuisance regressors such as head motion, baseline time series and slow drift are also entered into regression. In using our method, you should not include such regressors into the design matrix, because the spatial spread of such nuisance regressors might be quite different from the spatial spread of task related signal. Including such nuisance regressors in design matrix might influence the pseudo-SNR map, which in turn influence the estimation of the shared covariance matrix. \n", "\n", @@ -86,13 +109,15 @@ "cell_type": "code", "execution_count": null, "metadata": { - "collapsed": false + "collapsed": false, + "deletable": true, + "editable": true }, "outputs": [], "source": [ "design = utils.ReadDesign(fname=\"example_design.1D\")\n", "\n", - "n_run = 2\n", + "n_run = 3\n", "design.n_TR = design.n_TR * n_run\n", "design.design_task = np.tile(design.design_task[:,:-1],\n", " [n_run, 1])\n", @@ -106,16 +131,16 @@ "plt.plot(design.design_task)\n", "plt.ylim([-0.2, 0.4])\n", "plt.title('hypothetic fMRI response time courses '\n", - " 'of all conditions in addition to a DC component\\n'\n", + " 'of all conditions\\n'\n", " '(design matrix)')\n", "plt.xlabel('time')\n", "plt.show()\n", "\n", "n_C = np.size(design.design_task, axis=1)\n", "# The total number of conditions.\n", - "ROI_edge = 20\n", - "# We simulate \"ROI\" of a square shape\n", - "n_V = ROI_edge**2\n", + "ROI_edge = 15\n", + "# We simulate \"ROI\" of a rectangular shape\n", + "n_V = ROI_edge**2 * 2\n", "# The total number of simulated voxels\n", "n_T = design.n_TR\n", "# The total number of time points,\n", @@ -124,7 +149,10 @@ }, { "cell_type": "markdown", - "metadata": {}, + "metadata": { + "deletable": true, + "editable": true + }, "source": [ "## simulate data: noise + signal\n", "### First, we start with noise, which is Gaussian Process in space and AR(1) in time" @@ -134,7 +162,9 @@ "cell_type": "code", "execution_count": null, "metadata": { - "collapsed": false + "collapsed": false, + "deletable": true, + "editable": true }, "outputs": [], "source": [ @@ -153,7 +183,7 @@ " * (rho1_top - rho1_bot) + rho1_bot\n", "\n", "noise_smooth_width = 10.0\n", - "coords = np.mgrid[0:ROI_edge, 0:ROI_edge, 0:1]\n", + "coords = np.mgrid[0:ROI_edge, 0:ROI_edge*2, 0:1]\n", "coords_flat = np.reshape(coords,[3, n_V]).T\n", "dist2 = spdist.squareform(spdist.pdist(coords_flat, 'sqeuclidean'))\n", "\n", @@ -166,8 +196,8 @@ "# defined over the coordinates.\n", "plt.pcolor(K_noise)\n", "plt.colorbar()\n", - "plt.xlim([0, ROI_edge * ROI_edge])\n", - "plt.ylim([0, ROI_edge * ROI_edge])\n", + "plt.xlim([0, n_V])\n", + "plt.ylim([0, n_V])\n", "plt.title('Spatial covariance matrix of noise')\n", "plt.show()\n", "L_noise = np.linalg.cholesky(K_noise)\n", @@ -180,7 +210,11 @@ "# For each voxel, the noise follows AR(1) process:\n", "# fresh noise plus a dampened version of noise at\n", "# the previous time point.\n", - "noise = noise + np.random.randn(n_V)\n", + "# In this simulation, we also introduced spatial smoothness resembling a Gaussian Process.\n", + "# Notice that we simulated in this way only to introduce spatial noise correlation.\n", + "# This does not represent the assumption of the form of spatial noise correlation in the model.\n", + "# Instead, the model is designed to capture structured noise correlation manifested\n", + "# as a few spatial maps each modulated by a time course, which appears as spatial noise correlation. \n", "fig = plt.figure(num=None, figsize=(12, 2), dpi=150,\n", " facecolor='w', edgecolor='k')\n", "plt.plot(noise[:, 0])\n", @@ -190,7 +224,10 @@ }, { "cell_type": "markdown", - "metadata": {}, + "metadata": { + "deletable": true, + "editable": true + }, "source": [ "### Then, we simulate signals, assuming the magnitude of response to each condition follows a common covariance matrix. \n", "#### Our model allows to impose a Gaussian Process prior on the log(SNR) of each voxels. \n", @@ -205,7 +242,10 @@ }, { "cell_type": "markdown", - "metadata": {}, + "metadata": { + "deletable": true, + "editable": true + }, "source": [ "##### Note: the following code won't work if you just installed Brainiak and try this demo because ROI.nii does not exist. It just serves as an example for you to retrieve coordinates of voxels in an ROI. You can use the ROI_coords for the argument coords in BRSA.fit()" ] @@ -214,7 +254,9 @@ "cell_type": "code", "execution_count": null, "metadata": { - "collapsed": false + "collapsed": false, + "deletable": true, + "editable": true }, "outputs": [], "source": [ @@ -231,7 +273,10 @@ }, { "cell_type": "markdown", - "metadata": {}, + "metadata": { + "deletable": true, + "editable": true + }, "source": [ "#### Let's keep in mind of the pattern of the ideal covariance / correlation below and see how well BRSA can recover their patterns." ] @@ -240,14 +285,16 @@ "cell_type": "code", "execution_count": null, "metadata": { - "collapsed": false + "collapsed": false, + "deletable": true, + "editable": true }, "outputs": [], "source": [ "# ideal covariance matrix\n", "ideal_cov = np.zeros([n_C, n_C])\n", "ideal_cov = np.eye(n_C) * 0.6\n", - "ideal_cov[8:12, 8:12] = 0.8\n", + "ideal_cov[8:12, 8:12] = 0.6\n", "for cond in range(8, 12):\n", " ideal_cov[cond,cond] = 1\n", "\n", @@ -276,30 +323,36 @@ }, { "cell_type": "markdown", - "metadata": {}, + "metadata": { + "deletable": true, + "editable": true + }, "source": [ - "#### In the following, pseudo-SNR is generated from a Gaussian Process defined on a \"square\" ROI, just for simplicity of code" + "#### In the following, pseudo-SNR is generated from a Gaussian Process defined on a \"rectangular\" ROI, just for simplicity of code" ] }, { "cell_type": "code", "execution_count": null, "metadata": { - "collapsed": false + "collapsed": false, + "deletable": true, + "editable": true, + "scrolled": false }, "outputs": [], "source": [ "L_full = np.linalg.cholesky(ideal_cov) \n", "\n", "# generating signal\n", - "snr_level = 0.6\n", + "snr_level = 1.0\n", "# Notice that accurately speaking this is not SNR.\n", "# The magnitude of signal depends not only on beta but also on x.\n", "# (noise_level*snr_level)**2 is the factor multiplied\n", "# with ideal_cov to form the covariance matrix from which\n", "# the response amplitudes (beta) of a voxel are drawn from.\n", "\n", - "tau = 0.8\n", + "tau = 1.0\n", "# magnitude of Gaussian Process from which the log(SNR) is drawn\n", "smooth_width = 3.0\n", "# spatial length scale of the Gaussian Process, unit: voxel\n", @@ -330,22 +383,22 @@ "# A tiny amount is added to the diagonal of\n", "# the GP covariance matrix to make sure it can be inverted\n", "L = np.linalg.cholesky(K)\n", - "snr = np.exp(np.dot(L, np.random.randn(n_V))) * snr_level\n", + "snr = np.abs(np.dot(L, np.random.randn(n_V))) * snr_level\n", "sqrt_v = noise_level * snr\n", "betas_simulated = np.dot(L_full, np.random.randn(n_C, n_V)) * sqrt_v\n", "signal = np.dot(design.design_task, betas_simulated)\n", "\n", "\n", - "Y = signal + noise \n", + "Y = signal + noise + inten\n", "# The data to be fed to the program.\n", "\n", "\n", "fig = plt.figure(num=None, figsize=(4, 4), dpi=100)\n", - "plt.pcolor(np.reshape(snr, [ROI_edge, ROI_edge]))\n", + "plt.pcolor(np.reshape(snr, [ROI_edge, ROI_edge*2]))\n", "plt.colorbar()\n", "ax = plt.gca()\n", "ax.set_aspect(1)\n", - "plt.title('pseudo-SNR in a square \"ROI\"')\n", + "plt.title('pseudo-SNR in a rectangular \"ROI\"')\n", "plt.show()\n", "\n", "idx = np.argmin(np.abs(snr - np.median(snr)))\n", @@ -353,7 +406,7 @@ "fig = plt.figure(num=None, figsize=(12, 4), dpi=150,\n", " facecolor='w', edgecolor='k')\n", "noise_plot, = plt.plot(noise[:,idx],'g')\n", - "signal_plot, = plt.plot(signal[:,idx],'r')\n", + "signal_plot, = plt.plot(signal[:,idx],'b')\n", "plt.legend([noise_plot, signal_plot], ['noise', 'signal'])\n", "plt.title('simulated data in an example voxel'\n", " ' with pseudo-SNR of {}'.format(snr[idx]))\n", @@ -362,7 +415,7 @@ "\n", "fig = plt.figure(num=None, figsize=(12, 4), dpi=150,\n", " facecolor='w', edgecolor='k')\n", - "data_plot, = plt.plot(Y[:,idx],'b')\n", + "data_plot, = plt.plot(Y[:,idx],'r')\n", "plt.legend([data_plot], ['observed data of the voxel'])\n", "plt.xlabel('time')\n", "plt.show()\n", @@ -372,7 +425,7 @@ "fig = plt.figure(num=None, figsize=(12, 4), dpi=150,\n", " facecolor='w', edgecolor='k')\n", "noise_plot, = plt.plot(noise[:,idx],'g')\n", - "signal_plot, = plt.plot(signal[:,idx],'r')\n", + "signal_plot, = plt.plot(signal[:,idx],'b')\n", "plt.legend([noise_plot, signal_plot], ['noise', 'signal'])\n", "plt.title('simulated data in the voxel with the highest'\n", " ' pseudo-SNR of {}'.format(snr[idx]))\n", @@ -381,7 +434,7 @@ "\n", "fig = plt.figure(num=None, figsize=(12, 4), dpi=150,\n", " facecolor='w', edgecolor='k')\n", - "data_plot, = plt.plot(Y[:,idx],'b')\n", + "data_plot, = plt.plot(Y[:,idx],'r')\n", "plt.legend([data_plot], ['observed data of the voxel'])\n", "plt.xlabel('time')\n", "plt.show()\n" @@ -389,7 +442,10 @@ }, { "cell_type": "markdown", - "metadata": {}, + "metadata": { + "deletable": true, + "editable": true + }, "source": [ "#### The reason that the pseudo-SNRs in the example voxels are not too small, while the signal looks much smaller is because we happen to have low amplitudes in our design matrix. The true SNR depends on both the amplitudes in design matrix and the pseudo-SNR. Therefore, be aware that pseudo-SNR does not directly reflects how much signal the data have, but rather a map indicating the relative strength of signal in differerent voxels.\n", "#### When you have multiple runs, the noise won't be correlated between runs. Therefore, you should tell BRSA when is the onset of each scan. \n", @@ -400,7 +456,9 @@ "cell_type": "code", "execution_count": null, "metadata": { - "collapsed": false + "collapsed": false, + "deletable": true, + "editable": true }, "outputs": [], "source": [ @@ -410,39 +468,46 @@ }, { "cell_type": "markdown", - "metadata": {}, + "metadata": { + "deletable": true, + "editable": true + }, "source": [ "# Fit Bayesian RSA to our simulated data\n", "\n", "The nuisance regressors in typical fMRI analysis (such as head motion signal) are replaced by principal components estimated from residuals after subtracting task-related response. n_nureg tells the model how many principal components to keep from the residual as nuisance regressors, in order to account for spatial correlation in noise. \n", - "If you prefer not using this approach based on principal components of residuals, you can set auto_nuisance=False, and optionally provide your own nuisance regressors as nuisance argument to BRSA.fit()" + "If you prefer not using this approach based on principal components of residuals, you can set auto_nuisance=False, and optionally provide your own nuisance regressors as nuisance argument to BRSA.fit(). In practice, we find that the result is much better with auto_nuisance=True." ] }, { "cell_type": "code", "execution_count": null, "metadata": { - "collapsed": false + "collapsed": false, + "deletable": true, + "editable": true }, "outputs": [], "source": [ "\n", - "brsa = BRSA(GP_space=True, GP_inten=True,\n", - " n_nureg=10)\n", + "brsa = BRSA(GP_space=True, GP_inten=True)\n", "# Initiate an instance, telling it\n", "# that we want to impose Gaussian Process prior\n", "# over both space and intensity.\n", "\n", - "\n", "brsa.fit(X=Y, design=design.design_task,\n", " coords=coords_flat, inten=inten, scan_onsets=scan_onsets)\n", + "\n", "# The data to fit should be given to the argument X.\n", "# Design matrix goes to design. And so on.\n" ] }, { "cell_type": "markdown", - "metadata": {}, + "metadata": { + "deletable": true, + "editable": true + }, "source": [ "### We can have a look at the estimated similarity in matrix brsa.C_. \n", "#### We can also compare the ideal covariance above with the one recovered, brsa.U_" @@ -452,14 +517,16 @@ "cell_type": "code", "execution_count": null, "metadata": { - "collapsed": false + "collapsed": false, + "deletable": true, + "editable": true }, "outputs": [], "source": [ "fig = plt.figure(num=None, figsize=(4, 4), dpi=100)\n", "plt.pcolor(brsa.C_, vmin=-0.1, vmax=1)\n", - "plt.xlim([0, 16])\n", - "plt.ylim([0, 16])\n", + "plt.xlim([0, n_C])\n", + "plt.ylim([0, n_C])\n", "plt.colorbar()\n", "ax = plt.gca()\n", "ax.set_aspect(1)\n", @@ -480,7 +547,10 @@ }, { "cell_type": "markdown", - "metadata": {}, + "metadata": { + "deletable": true, + "editable": true + }, "source": [ "### In contrast, we can have a look of the similarity matrix based on Pearson correlation between point estimates of betas of different conditions.\n", "#### This is what vanila RSA might give" @@ -490,7 +560,9 @@ "cell_type": "code", "execution_count": null, "metadata": { - "collapsed": false + "collapsed": false, + "deletable": true, + "editable": true }, "outputs": [], "source": [ @@ -524,7 +596,10 @@ }, { "cell_type": "markdown", - "metadata": {}, + "metadata": { + "deletable": true, + "editable": true + }, "source": [ "### We can make a comparison between the estimated SNR map and the true SNR map (normalized)" ] @@ -533,12 +608,14 @@ "cell_type": "code", "execution_count": null, "metadata": { - "collapsed": false + "collapsed": false, + "deletable": true, + "editable": true }, "outputs": [], "source": [ "fig = plt.figure(num=None, figsize=(5, 5), dpi=100)\n", - "plt.pcolor(np.reshape(brsa.nSNR_, [ROI_edge, ROI_edge]), vmin=0, vmax=5)\n", + "plt.pcolor(np.reshape(brsa.nSNR_, [ROI_edge, ROI_edge*2]))\n", "plt.colorbar()\n", "ax = plt.gca()\n", "ax.set_aspect(1)\n", @@ -547,7 +624,7 @@ "\n", "fig = plt.figure(num=None, figsize=(5, 5), dpi=100)\n", "plt.pcolor(np.reshape(snr / np.exp(np.mean(np.log(snr))),\n", - " [ROI_edge, ROI_edge]), vmin=0, vmax=5)\n", + " [ROI_edge, ROI_edge*2]))\n", "plt.colorbar()\n", "ax = plt.gca()\n", "ax.set_aspect(1)\n", @@ -559,7 +636,9 @@ "cell_type": "code", "execution_count": null, "metadata": { - "collapsed": false + "collapsed": false, + "deletable": true, + "editable": true }, "outputs": [], "source": [ @@ -577,14 +656,20 @@ }, { "cell_type": "markdown", - "metadata": {}, + "metadata": { + "deletable": true, + "editable": true + }, "source": [ "#### Empirically, the smoothness turns to be over-estimated when signal is weak." ] }, { "cell_type": "markdown", - "metadata": {}, + "metadata": { + "deletable": true, + "editable": true + }, "source": [ "### We can also look at how other parameters are recovered." ] @@ -593,7 +678,9 @@ "cell_type": "code", "execution_count": null, "metadata": { - "collapsed": false + "collapsed": false, + "deletable": true, + "editable": true }, "outputs": [], "source": [ @@ -617,7 +704,10 @@ }, { "cell_type": "markdown", - "metadata": {}, + "metadata": { + "deletable": true, + "editable": true + }, "source": [ "#### Even though the variation reduced in estimated pseudo-SNR (due to overestimation of smoothness of the GP prior under low SNR situation), betas recovered by the model has higher correlation with true betas than doing simple regression, shown below. Obiously there is shrinkage of the estimated betas, as a result of variance-bias tradeoff. But we think such shrinkage does preserve the patterns of betas, and therefore the result is suitable to be further used for decoding purpose." ] @@ -626,7 +716,9 @@ "cell_type": "code", "execution_count": null, "metadata": { - "collapsed": false + "collapsed": false, + "deletable": true, + "editable": true }, "outputs": [], "source": [ @@ -634,6 +726,7 @@ "plt.xlabel('true betas (response amplitudes)')\n", "plt.ylabel('recovered betas by Bayesian RSA')\n", "ax = plt.gca()\n", + "ax.set_aspect(1)\n", "plt.show()\n", "\n", "\n", @@ -641,38 +734,377 @@ "plt.xlabel('true betas (response amplitudes)')\n", "plt.ylabel('recovered betas by simple regression')\n", "ax = plt.gca()\n", + "ax.set_aspect(1)\n", "plt.show()" ] }, { "cell_type": "markdown", - "metadata": {}, + "metadata": { + "deletable": true, + "editable": true + }, "source": [ - "### The singular decomposition of noise, and the comparison between the first principal component of noise and the first principal component returned by the model.\n", - "Apparently one can imagine that the choice of the number of principal components used as nuisance regressors can influence the result. If you just choose 1 or 2, perhaps only the baseline and drift would be captured. But including too many nuisance regressors would slow the fitting speed and might have risk of overfitting. The users might consider starting in the range of 5-20. In future, we may consider using cross validation to determine the number of nuisance regressors, similar as in GLMdenoise (http://kendrickkay.net/GLMdenoise/)" + "### The singular decomposition of noise, and the comparison between the first two principal component of noise and the patterns of the first two nuisance regressors, returned by the model. \n", + "The principal components may not look exactly the same. The first principal components both capture the baseline image intensities (although they may sometimes appear counter-phase)\n", + "\n", + "Apparently one can imagine that the choice of the number of principal components used as nuisance regressors can influence the result. If you just choose 1 or 2, perhaps only the global drift would be captured. But including too many nuisance regressors would slow the fitting speed and might have risk of overfitting. The users might consider starting in the range of 5-20. We do not have automatic cross-validation built in. But you can use the score() function to do cross-validation and select the appropriate number. The idea here is similar to that in GLMdenoise (http://kendrickkay.net/GLMdenoise/)" ] }, { "cell_type": "code", "execution_count": null, "metadata": { - "collapsed": false + "collapsed": false, + "deletable": true, + "editable": true, + "scrolled": false }, "outputs": [], "source": [ - "u, s, v = np.linalg.svd(Y - signal)\n", + "u, s, v = np.linalg.svd(noise + inten)\n", "plt.plot(s)\n", "plt.xlabel('principal component')\n", - "plt.ylabel('singular value of simulated noise')\n", + "plt.ylabel('singular value of unnormalized noise')\n", + "plt.show()\n", + "\n", + "plt.pcolor(np.reshape(v[0,:], [ROI_edge, ROI_edge*2]))\n", + "ax = plt.gca()\n", + "ax.set_aspect(1)\n", + "plt.title('Weights of the first principal component in unnormalized noise')\n", + "plt.colorbar()\n", + "plt.show()\n", + "\n", + "\n", + "plt.pcolor(np.reshape(brsa.beta0_[0,:], [ROI_edge, ROI_edge*2]))\n", + "ax = plt.gca()\n", + "ax.set_aspect(1)\n", + "plt.title('Weights of the DC component in noise')\n", + "plt.colorbar()\n", + "plt.show()\n", + "\n", + "plt.pcolor(np.reshape(inten, [ROI_edge, ROI_edge*2]))\n", + "ax = plt.gca()\n", + "ax.set_aspect(1)\n", + "plt.title('The baseline intensity of the ROI')\n", + "plt.colorbar()\n", + "plt.show()\n", + "\n", + "plt.pcolor(np.reshape(v[1,:], [ROI_edge, ROI_edge*2]))\n", + "ax = plt.gca()\n", + "ax.set_aspect(1)\n", + "plt.title('Weights of the second principal component in unnormalized noise')\n", + "plt.colorbar()\n", + "plt.show()\n", + "\n", + "plt.pcolor(np.reshape(brsa.beta0_[1,:], [ROI_edge, ROI_edge*2]))\n", + "ax = plt.gca()\n", + "ax.set_aspect(1)\n", + "plt.title('Weights of the first recovered noise pattern\\n not related to DC component in noise')\n", + "plt.colorbar()\n", + "plt.show()\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "deletable": true, + "editable": true + }, + "source": [ + "## \"Decoding\" from new data\n", + "### Now we generate a new data set, assuming signal is the same but noise is regenerated. We want to use the transform() function of brsa to estimate the \"design matrix\" in this new dataset." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false, + "deletable": true, + "editable": true + }, + "outputs": [], + "source": [ + "noise_new = np.zeros([n_T, n_V])\n", + "noise_new[0, :] = np.dot(L_noise, np.random.randn(n_V))\\\n", + " / np.sqrt(1 - rho1**2)\n", + "for i_t in range(1, n_T):\n", + " noise_new[i_t, :] = noise_new[i_t - 1, :] * rho1 \\\n", + " + np.dot(L_noise,np.random.randn(n_V))\n", + "Y_new = signal + noise_new + inten\n", + "\n", + "\n", + "ts, ts0 = brsa.transform(Y_new,scan_onsets=scan_onsets)\n", + "# ts, ts0 = brsa.transform(Y_new,scan_onsets=scan_onsets)\n", + "recovered_plot, = plt.plot(ts[:200, 8], 'b')\n", + "design_plot, = plt.plot(design.design_task[:200, 8], 'g')\n", + "plt.legend([design_plot, recovered_plot],\n", + " ['design matrix for one condition', 'recovered time course for the condition'])\n", + "plt.show()\n", + "# We did not plot the whole time series for the purpose of seeing closely how much the two\n", + "# time series overlap\n", + "\n", + "c = np.corrcoef(design.design_task.T, ts.T)\n", + "\n", + "\n", + "# plt.pcolor(c[0:n_C, n_C:],vmin=-0.5,vmax=1)\n", + "plt.pcolor(c[0:16, 16:],vmin=-0.5,vmax=1)\n", + "ax = plt.gca()\n", + "ax.set_aspect(1)\n", + "plt.title('correlation between true design matrix \\nand the recovered task-related activity')\n", + "plt.colorbar()\n", + "plt.xlabel('recovered task-related activity')\n", + "plt.ylabel('true design matrix')\n", + "plt.show()\n", + "\n", + "\n", + "# plt.pcolor(c[n_C:, n_C:],vmin=-0.5,vmax=1)\n", + "plt.pcolor(c[16:, 16:],vmin=-0.5,vmax=1)\n", + "ax = plt.gca()\n", + "ax.set_aspect(1)\n", + "plt.title('correlation within the recovered task-related activity')\n", + "plt.colorbar()\n", + "plt.show()\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "deletable": true, + "editable": true + }, + "source": [ + "## Model selection by cross-validataion:\n", + "You can compare different models by cross-validating the parameters of one model learnt from some training data\n", + "on some testing data. BRSA provides a score() function, which provides you a pair of cross-validated log likelihood\n", + "for testing data. The first value is the cross-validated log likelihood of the model you have specified. The second\n", + "value is a null model which assumes everything else the same except that there is no task-related activity.\n", + "### Notice that comparing the score of your model of interest against its corresponding null model is not the single way to compare models. You might also want to compare against a model using the same set of design matrix, but a different rank (especially rank 1, which means all task conditions have the same response pattern, only differing in the magnitude).\n", + "In general, in the context of BRSA, a model means the timing of each event and the way these events are grouped, together with other trivial parameters such as the rank of the covariance matrix and the number of nuisance regressors. All these parameters can influence model performance.\n", + "In future, we will provide interface to test the performance of a model with predefined similarity matrix or covariance matrix." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false, + "deletable": true, + "editable": true + }, + "outputs": [], + "source": [ + "[score, score_null] = brsa.score(X=Y_new, design=design.design_task, scan_onsets=scan_onsets)\n", + "print(\"Score of full model based on the correct esign matrix, assuming {} nuisance\"\n", + " \" components in the noise: {}\".format(brsa.n_nureg, score))\n", + "print(\"Score of a null model with the same assumption except that there is no task-related response: {}\".format(\n", + " score_null))\n", + "plt.bar([0,1],[score, score_null], width=0.5)\n", + "plt.ylim(np.min([score, score_null])-100, np.max([score, score_null])+100)\n", + "plt.xticks([0,1],['Model','Null model'])\n", + "plt.ylabel('cross-validated log likelihood')\n", + "plt.title('cross validation on new data')\n", "plt.show()\n", "\n", - "plt.pcolor(np.reshape(v[0,:], [ROI_edge, ROI_edge]))\n", - "plt.title('Weights of the first principal component in noise')\n", + "[score_noise, score_noise_null] = brsa.score(X=noise_new+inten, design=design.design_task, scan_onsets=scan_onsets)\n", + "print(\"Score of full model for noise, based on the correct design matrix, assuming {} nuisance\"\n", + " \" components in the noise: {}\".format(brsa.n_nureg, score_noise))\n", + "print(\"Score of a null model for noise: {}\".format(\n", + " score_noise_null))\n", + "plt.bar([0,1],[score_noise, score_noise_null], width=0.5)\n", + "plt.ylim(np.min([score_noise, score_noise_null])-100, np.max([score_noise, score_noise_null])+100)\n", + "plt.xticks([0,1],['Model','Null model'])\n", + "plt.ylabel('cross-validated log likelihood')\n", + "plt.title('cross validation on noise')\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "deletable": true, + "editable": true + }, + "source": [ + "As can be seen above, the model with the correct design matrix explains new data with signals generated from the true model better than the null model, but explains pure noise worse than the null model." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "deletable": true, + "editable": true + }, + "source": [ + "# We can also try the version which marginalize SNR and rho for each voxel. \n", + "### This version is intended for analyzing data of a group of participants and estimating their shared similarity matrix. But it also allows analyzing single participant. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false, + "deletable": true, + "editable": true, + "scrolled": false + }, + "outputs": [], + "source": [ + "\n", + "gbrsa = GBRSA(nureg_method='PCA', auto_nuisance=True, logS_range=1,\n", + " anneal_speed=20, n_iter=50)\n", + "# Initiate an instance, telling it\n", + "# that we want to impose Gaussian Process prior\n", + "# over both space and intensity.\n", + "\n", + "gbrsa.fit(X=Y, design=design.design_task,scan_onsets=scan_onsets)\n", + "\n", + "# The data to fit should be given to the argument X.\n", + "# Design matrix goes to design. And so on.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false, + "deletable": true, + "editable": true, + "scrolled": false + }, + "outputs": [], + "source": [ + "plt.pcolor(np.reshape(gbrsa.nSNR_, (ROI_edge, ROI_edge*2)))\n", + "plt.colorbar()\n", + "ax = plt.gca()\n", + "ax.set_aspect(1)\n", + "plt.title('SNR map estimated by marginalized BRSA')\n", "plt.show()\n", "\n", + "plt.pcolor(np.reshape(snr, (ROI_edge, ROI_edge*2)))\n", + "ax = plt.gca()\n", + "ax.set_aspect(1)\n", + "plt.colorbar()\n", + "plt.title('true SNR map')\n", + "plt.show()\n", + "\n", + "plt.scatter(snr, gbrsa.nSNR_)\n", + "ax = plt.gca()\n", + "ax.set_aspect(1)\n", + "plt.xlabel('simulated pseudo-SNR')\n", + "plt.ylabel('estimated pseudo-SNR')\n", + "plt.show()\n", + "\n", + "plt.scatter(np.log(snr), np.log(gbrsa.nSNR_))\n", + "ax = plt.gca()\n", + "ax.set_aspect(1)\n", + "plt.xlabel('simulated log(pseudo-SNR)')\n", + "plt.ylabel('estimated log(pseudo-SNR)')\n", + "plt.show()\n", + "\n", + "plt.pcolor(gbrsa.U_)\n", + "plt.colorbar()\n", + "plt.title('covariance matrix estimated by marginalized BRSA')\n", + "plt.show()\n", + "plt.pcolor(ideal_cov)\n", + "plt.colorbar()\n", + "plt.title('true covariance matrix')\n", + "plt.show()\n", + "\n", + "plt.scatter(betas_simulated, gbrsa.beta_)\n", + "ax = plt.gca()\n", + "ax.set_aspect(1)\n", + "plt.xlabel('simulated betas')\n", + "plt.ylabel('betas estimated by marginalized BRSA')\n", + "plt.show()\n", + "\n", + "plt.scatter(rho1, gbrsa.rho_)\n", + "ax = plt.gca()\n", + "ax.set_aspect(1)\n", + "plt.xlabel('simulated AR(1) coefficients')\n", + "plt.ylabel('AR(1) coefficients estimated by marginalized BRSA')\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "deletable": true, + "editable": true + }, + "source": [ + "We can also do \"decoding\" and cross-validating using the marginalized version in GBRSA" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false, + "deletable": true, + "editable": true + }, + "outputs": [], + "source": [ + "# \"Decoding\"\n", + "ts, ts0 = gbrsa.transform([Y_new],scan_onsets=[scan_onsets])\n", + "\n", + "recovered_plot, = plt.plot(ts[0][:200, 8], 'b')\n", + "design_plot, = plt.plot(design.design_task[:200, 8], 'g')\n", + "plt.legend([design_plot, recovered_plot],\n", + " ['design matrix for one condition', 'recovered time course for the condition'])\n", + "plt.show()\n", + "# We did not plot the whole time series for the purpose of seeing closely how much the two\n", + "# time series overlap\n", + "\n", + "c = np.corrcoef(design.design_task.T, ts[0].T)\n", + "\n", + "\n", + "plt.pcolor(c[0:n_C, n_C:],vmin=-0.5,vmax=1)\n", + "ax = plt.gca()\n", + "ax.set_aspect(1)\n", + "plt.title('correlation between true design matrix \\nand the recovered task-related activity')\n", + "plt.colorbar()\n", + "plt.xlabel('recovered task-related activity')\n", + "plt.ylabel('true design matrix')\n", + "plt.show()\n", + "\n", + "\n", + "plt.pcolor(c[n_C:, n_C:],vmin=-0.5,vmax=1)\n", + "ax = plt.gca()\n", + "ax.set_aspect(1)\n", + "plt.title('correlation within the recovered task-related activity')\n", + "plt.colorbar()\n", + "plt.show()\n", + "\n", + "# cross-validataion\n", + "[score, score_null] = gbrsa.score(X=[Y_new], design=[design.design_task], scan_onsets=[scan_onsets])\n", + "print(\"Score of full model based on the correct esign matrix, assuming {} nuisance\"\n", + " \" components in the noise: {}\".format(gbrsa.n_nureg_, score))\n", + "print(\"Score of a null model with the same assumption except that there is no task-related response: {}\".format(\n", + " score_null))\n", + "plt.bar([0,1],[score[0], score_null[0]], width=0.5)\n", + "plt.ylim(np.min([score[0], score_null[0]])-100, np.max([score[0], score_null[0]])+100)\n", + "plt.xticks([0,1],['Model','Null model'])\n", + "plt.ylabel('cross-validated log likelihood')\n", + "plt.title('cross validation on new data')\n", + "plt.show()\n", "\n", - "plt.pcolor(np.reshape(brsa.beta0_[0,:], [ROI_edge, ROI_edge]))\n", - "plt.title('Weights of the first recovered principal component in noise')\n", + "[score_noise, score_noise_null] = gbrsa.score(X=[noise_new+inten], design=[design.design_task],\n", + " scan_onsets=[scan_onsets])\n", + "print(\"Score of full model for noise, based on the correct design matrix, assuming {} nuisance\"\n", + " \" components in the noise: {}\".format(gbrsa.n_nureg_, score_noise))\n", + "print(\"Score of a null model for noise: {}\".format(\n", + " score_noise_null))\n", + "plt.bar([0,1],[score_noise[0], score_noise_null[0]], width=0.5)\n", + "plt.ylim(np.min([score_noise[0], score_noise_null[0]])-100, np.max([score_noise[0], score_noise_null[0]])+100)\n", + "plt.xticks([0,1],['Model','Null model'])\n", + "plt.ylabel('cross-validated log likelihood')\n", + "plt.title('cross validation on noise')\n", "plt.show()" ] } @@ -680,7 +1112,7 @@ "metadata": { "anaconda-cloud": {}, "kernelspec": { - "display_name": "Python [default]", + "display_name": "Python 3", "language": "python", "name": "python3" }, diff --git a/examples/reprsimil/group_brsa_example.ipynb b/examples/reprsimil/group_brsa_example.ipynb new file mode 100644 index 000000000..090979bc8 --- /dev/null +++ b/examples/reprsimil/group_brsa_example.ipynb @@ -0,0 +1,1024 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": { + "collapsed": false, + "deletable": true, + "editable": true + }, + "source": [ + "# This demo shows how to use the Group Bayesian Representational Similarity Analysis (GBRSA) method in brainiak with a simulated dataset.\n", + "\n", + "## Note that although the name has \"group\", it is also suitable for analyzing data of a single participant\n", + "\n", + "## When you apply this tool to real fMRI data, it is required that the data of each participant to be motion corrected. If multiple runs are acquired for each participant, they should be spatially aligned. You might want to do slice-timing correction. \n", + "\n", + "## You will need to have the mask of the Region of Interest (ROI) ready (defined anatomically or by independent tasks, which is up to you). nilearn provides tools to extract signal from mask. You can refer to http://nilearn.github.io/manipulating_images/manipulating_images.html\n", + "\n", + "## When analyzing an ROI of hundreds to thousands voxels, it is expected to be faster than the non-group version BRSA (refer to the other example). The reason is that GBRSA marginalize the SNR and AR(1) coefficient parameters of each voxel by numerical integration, thus eliminating hundreds to thousands of free parameters and reducing computation. However, if you are doing searchlight analysis with tens of voxels in each searchlight, it is possible that BRSA is faster.\n", + "\n", + "## GBRSA and BRSA might not return exactly the same result. Which one is more accurate might depend on the parameter choice, as well as the property of data." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "deletable": true, + "editable": true + }, + "source": [ + "#### Load some package which we will use in this demo.\n", + "If you see error related to loading any package, you can install that package. For example, if you use Anaconda, you can use \"conda install matplotlib\" to install matplotlib." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false, + "deletable": true, + "editable": true + }, + "outputs": [], + "source": [ + "%matplotlib inline\n", + "import scipy.stats\n", + "import scipy.spatial.distance as spdist\n", + "import numpy as np\n", + "from brainiak.reprsimil.brsa import GBRSA\n", + "import brainiak.utils.utils as utils\n", + "import matplotlib.pyplot as plt\n", + "import matplotlib as mpl\n", + "import logging\n", + "np.random.seed(10)\n", + "import copy" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "deletable": true, + "editable": true + }, + "source": [ + "#### You might want to keep a log of the output." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false, + "deletable": true, + "editable": true + }, + "outputs": [], + "source": [ + "logging.basicConfig(\n", + " level=logging.DEBUG,\n", + " filename='gbrsa_example.log',\n", + " format='%(relativeCreated)6d %(threadName)s %(message)s')" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "deletable": true, + "editable": true + }, + "source": [ + "# We want to simulate some data in which each voxel responds to different task conditions differently, but following a common covariance structure" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "deletable": true, + "editable": true + }, + "source": [ + "## Load an example design matrix.\n", + "The user should prepare their design matrix with their favorate software, such as using 3ddeconvolve of AFNI, or using SPM or FSL.\n", + "The design matrix reflects your belief of how fMRI signal should respond to a task (if a voxel does respond).\n", + "The common assumption is that a neural event that you are interested in will elicit a slow hemodynamic response in some voxels. The response peaks around 4-6 seconds after the event onset and dies down more than 12 seconds after the event. Therefore, typically you convolve a time series A, composed of delta (stem) functions reflecting the time of each neural event belonging to the same category (e.g. all trials in which a participant sees a face), with a hemodynamic response function B, to form the hypothetic response of any voxel to such type of neural event.\n", + "For each type of event, such a convoluted time course can be generated. These time courses, put together, are called design matrix, reflecting what we believe a temporal signal would look like, if it exists in any voxel.\n", + "Our goal is to figure out how the (spatial) response pattern of a population of voxels (in an Region of Interest, ROI) are similar or disimilar to different types of tasks (e.g., watching face vs. house, watching different categories of animals, different conditions of a cognitive task). So we need the design matrix in order to estimate the similarity matrix we are interested.\n", + "\n", + "We can use the utility called ReadDesign from brainiak.utils to read a design matrix generated from AFNI. For design matrix saved as Matlab data file by SPM or or other toolbox, you can use scipy.io.loadmat('YOURFILENAME') and extract the design matrix from the dictionary returned. Basically, the Bayesian RSA in this toolkit just needs a numpy array which is in size of {time points} * {condition}\n", + "You can also generate design matrix using the function gen_design which is in brainiak.utils. It takes in (names of) event timing files in AFNI or FSL format (denoting onsets, duration, and weight for each event belongning to the same condition) and outputs the design matrix as numpy array.\n", + "\n", + "In typical fMRI analysis, some nuisance regressors such as head motion, baseline time series and slow drift are also entered into regression. In using our method, you should not include such regressors into the design matrix, because the spatial spread of such nuisance regressors might be quite different from the spatial spread of task related signal. Including such nuisance regressors in design matrix might influence the pseudo-SNR map, which in turn influence the estimation of the shared covariance matrix. But you may include motion time course in the nuisance parameter.\n", + "\n", + "### We concatenate the design matrix by 2 to 3 times, mimicking 2 to 3 runs of identical timing\n", + "### Note that different subjects do not have to have the same number of voxels or time points. The timing of the task conditions of them can also differ. The simulation below reflects this\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false, + "deletable": true, + "editable": true + }, + "outputs": [], + "source": [ + "n_subj = 5\n", + "n_run = np.random.random_integers(2, 4, n_subj)\n", + "ROI_edge = np.random.random_integers(20, 40, n_subj)\n", + "# We simulate \"ROI\" of a square shape\n", + "design = [None] * n_subj\n", + "for subj in range(n_subj):\n", + " design[subj] = utils.ReadDesign(fname=\"example_design.1D\")\n", + " design[subj].n_TR = design[subj].n_TR * n_run[subj]\n", + " design[subj].design_task = np.tile(design[subj].design_task[:,:-1],\n", + " [n_run[subj], 1])\n", + " # The last \"condition\" in design matrix\n", + " # codes for trials subjects made an error.\n", + " # We ignore it here.\n", + "n_C = np.size(design[0].design_task, axis=1)\n", + "# The total number of conditions.\n", + "n_V = [int(roi_e**2) for roi_e in ROI_edge]\n", + "# The total number of simulated voxels\n", + "n_T = [d.n_TR for d in design]\n", + "# The total number of time points,\n", + "# after concatenating all fMRI runs\n", + "fig = plt.figure(num=None, figsize=(12, 3),\n", + " dpi=150, facecolor='w', edgecolor='k')\n", + "plt.plot(design[0].design_task)\n", + "plt.ylim([-0.2, 0.4])\n", + "plt.title('hypothetic fMRI response time courses '\n", + " 'of all conditions for one subject\\n'\n", + " '(design matrix)')\n", + "plt.xlabel('time')\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "deletable": true, + "editable": true + }, + "source": [ + "## simulate data: noise + signal\n", + "### First, we start with noise, which is Gaussian Process in space and AR(1) in time" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false, + "deletable": true, + "editable": true + }, + "outputs": [], + "source": [ + "noise_bot = 0.5\n", + "noise_top = 1.5\n", + "noise_level = [None] * n_subj\n", + "noise = [None] * n_subj\n", + "rho1 = [None] * n_subj\n", + "for subj in range(n_subj):\n", + " noise_level[subj] = np.random.rand(n_V[subj]) * \\\n", + " (noise_top - noise_bot) + noise_bot\n", + " # The standard deviation of the noise is in the range of [noise_bot, noise_top]\n", + " # In fact, we simulate autocorrelated noise with AR(1) model. So the noise_level reflects\n", + " # the independent additive noise at each time point (the \"fresh\" noise)\n", + "\n", + "# AR(1) coefficient\n", + "rho1_top = 0.8\n", + "rho1_bot = -0.2\n", + "for subj in range(n_subj):\n", + " rho1[subj] = np.random.rand(n_V[subj]) \\\n", + " * (rho1_top - rho1_bot) + rho1_bot\n", + "\n", + "noise_smooth_width = 10.0\n", + "dist2 = [None] * n_subj\n", + "for subj in range(n_subj):\n", + " coords = np.mgrid[0:ROI_edge[subj], 0:ROI_edge[subj], 0:1]\n", + " coords_flat = np.reshape(coords,[3, n_V[subj]]).T\n", + " dist2[subj] = spdist.squareform(spdist.pdist(coords_flat, 'sqeuclidean'))\n", + "\n", + " # generating noise\n", + " K_noise = noise_level[subj][:, np.newaxis] \\\n", + " * (np.exp(-dist2[subj] / noise_smooth_width**2 / 2.0) \\\n", + " + np.eye(n_V[subj]) * 0.1) * noise_level[subj]\n", + " # We make spatially correlated noise by generating\n", + " # noise at each time point from a Gaussian Process\n", + " # defined over the coordinates.\n", + " L_noise = np.linalg.cholesky(K_noise)\n", + " noise[subj] = np.zeros([n_T[subj], n_V[subj]])\n", + " noise[subj][0, :] = np.dot(L_noise, np.random.randn(n_V[subj]))\\\n", + " / np.sqrt(1 - rho1[subj]**2)\n", + " for i_t in range(1, n_T[subj]):\n", + " noise[subj][i_t, :] = noise[subj][i_t - 1, :] * rho1[subj] \\\n", + " + np.dot(L_noise,np.random.randn(n_V[subj]))\n", + " # For each voxel, the noise follows AR(1) process:\n", + " # fresh noise plus a dampened version of noise at\n", + " # the previous time point.\n", + " # In this simulation, we also introduced spatial smoothness resembling a Gaussian Process.\n", + " # Notice that we simulated in this way only to introduce spatial noise correlation.\n", + " # This does not represent the assumption of the form of spatial noise correlation in the model.\n", + " # Instead, the model is designed to capture structured noise correlation manifested\n", + " # as a few spatial maps each modulated by a time course, which appears as spatial noise correlation. \n", + "plt.pcolor(K_noise)\n", + "plt.colorbar()\n", + "plt.xlim([0, ROI_edge[-1] * ROI_edge[-1]])\n", + "plt.ylim([0, ROI_edge[-1] * ROI_edge[-1]])\n", + "plt.title('Spatial covariance matrix of noise\\n of the last participant')\n", + "plt.show()\n", + "\n", + "fig = plt.figure(num=None, figsize=(12, 2), dpi=150,\n", + " facecolor='w', edgecolor='k')\n", + "plt.plot(noise[-1][:, 0])\n", + "plt.title('noise in an example voxel')\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "deletable": true, + "editable": true + }, + "source": [ + "### Then, we simulate signals, assuming the magnitude of response to each condition follows a common covariance matrix. \n", + "#### Note that Group Bayesian Representational Similarity Analysis (GBRSA) does not impose Gaussian Process prior on log(SNR) as BRSA does, for two reasons: (1) computational speed, (2) we numerically marginalize SNR for each voxel in GBRSA" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "deletable": true, + "editable": true + }, + "source": [ + "#### Let's keep in mind of the pattern of the ideal covariance / correlation below and see how well BRSA can recover their patterns." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false, + "deletable": true, + "editable": true + }, + "outputs": [], + "source": [ + "# ideal covariance matrix\n", + "ideal_cov = np.zeros([n_C, n_C])\n", + "ideal_cov = np.eye(n_C) * 0.6\n", + "ideal_cov[8:12, 8:12] = 0.6\n", + "for cond in range(8, 12):\n", + " ideal_cov[cond,cond] = 1\n", + "\n", + "fig = plt.figure(num=None, figsize=(4, 4), dpi=100)\n", + "plt.pcolor(ideal_cov)\n", + "plt.colorbar()\n", + "plt.xlim([0, 16])\n", + "plt.ylim([0, 16])\n", + "ax = plt.gca()\n", + "ax.set_aspect(1)\n", + "plt.title('ideal covariance matrix')\n", + "plt.show()\n", + "\n", + "std_diag = np.diag(ideal_cov)**0.5\n", + "ideal_corr = ideal_cov / std_diag / std_diag[:, None]\n", + "fig = plt.figure(num=None, figsize=(4, 4), dpi=100)\n", + "plt.pcolor(ideal_corr)\n", + "plt.colorbar()\n", + "plt.xlim([0, 16])\n", + "plt.ylim([0, 16])\n", + "ax = plt.gca()\n", + "ax.set_aspect(1)\n", + "plt.title('ideal correlation matrix')\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "deletable": true, + "editable": true + }, + "source": [ + "#### In the following, pseudo-SNR is generated from a Gaussian Process defined on a \"square\" ROI, just for simplicity of code\n", + "#### Notice that GBRSA does not make assumption of smoothness of SNR, so it won't utilize this fact." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false, + "deletable": true, + "editable": true, + "scrolled": false + }, + "outputs": [], + "source": [ + "L_full = np.linalg.cholesky(ideal_cov) \n", + "\n", + "# generating signal\n", + "snr_level = np.random.rand(n_subj) * 0.6 + 0.4\n", + "# Notice that accurately speaking this is not SNR.\n", + "# The magnitude of signal depends not only on beta but also on x.\n", + "# (noise_level*snr_level)**2 is the factor multiplied\n", + "# with ideal_cov to form the covariance matrix from which\n", + "# the response amplitudes (beta) of a voxel are drawn from.\n", + "\n", + "tau = np.random.rand(n_subj) * 0.8 + 0.2\n", + "# magnitude of Gaussian Process from which the log(SNR) is drawn\n", + "smooth_width = np.random.rand(n_subj) * 5.0 + 3.0\n", + "# spatial length scale of the Gaussian Process, unit: voxel\n", + "inten_kernel = np.random.rand(n_subj) * 4.0 + 2.0\n", + "# intensity length scale of the Gaussian Process\n", + "# Slightly counter-intuitively, if this parameter is very large,\n", + "# say, much larger than the range of intensities of the voxels,\n", + "# then the smoothness has much small dependency on the intensity.\n", + "\n", + "Y = [None] * n_subj\n", + "snr = [None] * n_subj\n", + "signal = [None] * n_subj\n", + "betas_simulated = [None] * n_subj\n", + "inten = [None] * n_subj\n", + "for subj in range(n_subj):\n", + " inten[subj] = np.random.rand(n_V[subj]) * 20.0\n", + " # For simplicity, we just assume that the intensity\n", + " # of all voxels are uniform distributed between 0 and 20\n", + " # parameters of Gaussian process to generate pseuso SNR\n", + " # For curious user, you can also try the following commond\n", + " # to see what an example snr map might look like if the intensity\n", + " # grows linearly in one spatial direction\n", + "\n", + "\n", + " inten_tile = np.tile(inten[subj], [n_V[subj], 1])\n", + " inten_diff2 = (inten_tile - inten_tile.T)**2\n", + "\n", + " K = np.exp(-dist2[subj] / smooth_width[subj]**2 / 2.0 \n", + " - inten_diff2 / inten_kernel[subj]**2 / 2.0) * tau[subj]**2 \\\n", + " + np.eye(n_V[subj]) * tau[subj]**2 * 0.001\n", + " # A tiny amount is added to the diagonal of\n", + " # the GP covariance matrix to make sure it can be inverted\n", + " L = np.linalg.cholesky(K)\n", + " snr[subj] = np.exp(np.dot(L, np.random.randn(n_V[subj]))) * snr_level[subj]\n", + " sqrt_v = noise_level[subj] * snr[subj]\n", + " betas_simulated[subj] = np.dot(L_full, np.random.randn(n_C, n_V[subj])) * sqrt_v\n", + " signal[subj] = np.dot(design[subj].design_task, betas_simulated[subj])\n", + "\n", + "\n", + " Y[subj] = signal[subj] + noise[subj] + inten[subj]\n", + " # The data to be fed to the program.\n", + "\n", + "\n", + "\n", + "fig = plt.figure(num=None, figsize=(4, 4), dpi=100)\n", + "plt.pcolor(np.reshape(snr[0], [ROI_edge[0], ROI_edge[0]]))\n", + "plt.colorbar()\n", + "ax = plt.gca()\n", + "ax.set_aspect(1)\n", + "plt.title('pseudo-SNR in a square \"ROI\" \\nof participant 0')\n", + "plt.show()\n", + "\n", + "snr_all = np.concatenate(snr)\n", + "idx = np.argmin(np.abs(snr_all - np.median(snr_all)))\n", + "median_subj = np.min(np.where(idx - np.cumsum(n_V) < 0))\n", + "\n", + "idx = idx - np.cumsum(np.concatenate([[0], n_V]))[median_subj]\n", + "# choose a voxel of medium level SNR.\n", + "fig = plt.figure(num=None, figsize=(12, 4), dpi=150,\n", + " facecolor='w', edgecolor='k')\n", + "noise_plot, = plt.plot(noise[median_subj][:,idx],'g')\n", + "signal_plot, = plt.plot(signal[median_subj][:,idx],'b')\n", + "plt.legend([noise_plot, signal_plot], ['noise', 'signal'])\n", + "plt.title('simulated data in an example voxel'\n", + " ' with pseudo-SNR of {} in participant {}'.format(snr[median_subj][idx], median_subj))\n", + "plt.xlabel('time')\n", + "plt.show()\n", + "\n", + "fig = plt.figure(num=None, figsize=(12, 4), dpi=150,\n", + " facecolor='w', edgecolor='k')\n", + "data_plot, = plt.plot(Y[median_subj][:,idx],'r')\n", + "plt.legend([data_plot], ['observed data of the voxel'])\n", + "plt.xlabel('time')\n", + "plt.show()\n", + "\n", + "idx = np.argmin(np.abs(snr_all - np.max(snr_all)))\n", + "highest_subj = np.min(np.where(idx - np.cumsum(n_V) < 0))\n", + "idx = idx - np.cumsum(np.concatenate([[0], n_V]))[highest_subj]\n", + "# display the voxel of the highest level SNR.\n", + "fig = plt.figure(num=None, figsize=(12, 4), dpi=150,\n", + " facecolor='w', edgecolor='k')\n", + "noise_plot, = plt.plot(noise[highest_subj][:,idx],'g')\n", + "signal_plot, = plt.plot(signal[highest_subj][:,idx],'b')\n", + "plt.legend([noise_plot, signal_plot], ['noise', 'signal'])\n", + "plt.title('simulated data in the voxel with the highest'\n", + " ' pseudo-SNR of {} in subject {}'.format(snr[highest_subj][idx], highest_subj))\n", + "plt.xlabel('time')\n", + "plt.show()\n", + "\n", + "fig = plt.figure(num=None, figsize=(12, 4), dpi=150,\n", + " facecolor='w', edgecolor='k')\n", + "data_plot, = plt.plot(Y[highest_subj][:,idx],'r')\n", + "plt.legend([data_plot], ['observed data of the voxel'])\n", + "plt.xlabel('time')\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "deletable": true, + "editable": true + }, + "source": [ + "#### The reason that the pseudo-SNRs in the example voxels are not too small, while the signal looks much smaller is because we happen to have low amplitudes in our design matrix. The true SNR depends on both the amplitudes in design matrix and the pseudo-SNR. Therefore, be aware that pseudo-SNR does not directly reflects how much signal the data have, but rather a map indicating the relative strength of signal in differerent voxels.\n", + "#### When you have multiple runs, the noise won't be correlated between runs. Therefore, you should tell BRSA when is the onset of each scan. \n", + "#### Note that the data (variable Y above) you feed to BRSA is the concatenation of data from all runs along the time dimension, as a 2-D matrix of time x space" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false, + "deletable": true, + "editable": true + }, + "outputs": [], + "source": [ + "scan_onsets = [np.int32(np.linspace(0, design[i].n_TR,num=n_run[i] + 1)[: -1]) for i in range(n_subj)]\n", + "print('scan onsets: {}'.format(scan_onsets))" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "deletable": true, + "editable": true + }, + "source": [ + "# Fit Group Bayesian RSA to our simulated data\n", + "\n", + "\n", + "The nuisance regressors in typical fMRI analysis (such as head motion signal) are replaced by principal components estimated from residuals after subtracting task-related response. n_nureg tells the model how many principal components to keep from the residual as nuisance regressors, in order to account for spatial correlation in noise. \n", + "If you prefer not using this approach based on principal components of residuals, you can set auto_nuisance=False, and optionally provide your own nuisance regressors as a list (one numpy array per subject) as nuisance argument to GBRSA.fit(). In practice, we find that the result is much better with auto_nuisance=True" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false, + "deletable": true, + "editable": true + }, + "outputs": [], + "source": [ + "\n", + "gbrsa = GBRSA() # n_nureg=6\n", + "# Initiate an instance\n", + "\n", + "gbrsa.fit(X=Y, design=[d.design_task for d in design],scan_onsets=scan_onsets)\n", + "# The data to fit should be given to the argument X.\n", + "# Design matrix goes to design. And so on.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "deletable": true, + "editable": true + }, + "source": [ + "### We can have a look at the estimated similarity in matrix gbrsa.C_. \n", + "### We can also compare the ideal covariance above with the one recovered, gbrsa.U_" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false, + "deletable": true, + "editable": true + }, + "outputs": [], + "source": [ + "fig = plt.figure(num=None, figsize=(4, 4), dpi=100)\n", + "plt.pcolor(gbrsa.C_, vmin=-0.1, vmax=1)\n", + "plt.xlim([0, 16])\n", + "plt.ylim([0, 16])\n", + "plt.colorbar()\n", + "ax = plt.gca()\n", + "ax.set_aspect(1)\n", + "plt.title('Estimated correlation structure\\n shared between voxels\\n'\n", + " 'This constitutes the output of Bayesian RSA\\n')\n", + "plt.show()\n", + "\n", + "fig = plt.figure(num=None, figsize=(4, 4), dpi=100)\n", + "plt.pcolor(gbrsa.U_)\n", + "plt.xlim([0, 16])\n", + "plt.ylim([0, 16])\n", + "plt.colorbar()\n", + "ax = plt.gca()\n", + "ax.set_aspect(1)\n", + "plt.title('Estimated covariance structure\\n shared between voxels\\n')\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "deletable": true, + "editable": true + }, + "source": [ + "### In contrast, we can have a look of the similarity matrix based on Pearson correlation between point estimates of betas of different conditions.\n", + "#### This is what vanila RSA might give" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false, + "deletable": true, + "editable": true + }, + "outputs": [], + "source": [ + "sum_point_corr = np.zeros((n_C, n_C))\n", + "sum_point_cov = np.zeros((n_C, n_C))\n", + "betas_point = [None] * n_subj\n", + "for subj in range(n_subj):\n", + " regressor = np.insert(design[subj].design_task,\n", + " 0, 1, axis=1)\n", + " betas_point[subj] = np.linalg.lstsq(regressor, Y[subj])[0]\n", + " point_corr = np.corrcoef(betas_point[subj][1:, :])\n", + " point_cov = np.cov(betas_point[subj][1:, :])\n", + " sum_point_corr += point_corr\n", + " sum_point_cov += point_cov\n", + " if subj == 0:\n", + " fig = plt.figure(num=None, figsize=(4, 4), dpi=100)\n", + " plt.pcolor(point_corr, vmin=-0.1, vmax=1)\n", + " plt.xlim([0, 16])\n", + " plt.ylim([0, 16])\n", + " plt.colorbar()\n", + " ax = plt.gca()\n", + " ax.set_aspect(1)\n", + " plt.title('Correlation structure estimated\\n'\n", + " 'based on point estimates of betas\\n'\n", + " 'for subject {}'.format(subj))\n", + " plt.show()\n", + "\n", + " fig = plt.figure(num=None, figsize=(4, 4), dpi=100)\n", + " plt.pcolor(point_cov)\n", + " plt.xlim([0, 16])\n", + " plt.ylim([0, 16])\n", + " plt.colorbar()\n", + " ax = plt.gca()\n", + " ax.set_aspect(1)\n", + " plt.title('Covariance structure of\\n'\n", + " 'point estimates of betas\\n'\n", + " 'for subject {}'.format(subj))\n", + " plt.show()\n", + "\n", + "fig = plt.figure(num=None, figsize=(4, 4), dpi=100)\n", + "plt.pcolor(sum_point_corr / n_subj, vmin=-0.1, vmax=1)\n", + "plt.xlim([0, 16])\n", + "plt.ylim([0, 16])\n", + "plt.colorbar()\n", + "ax = plt.gca()\n", + "ax.set_aspect(1)\n", + "plt.title('Correlation structure estimated\\n'\n", + " 'based on point estimates of betas\\n'\n", + " 'averaged over subjects')\n", + "plt.show()\n", + "\n", + "fig = plt.figure(num=None, figsize=(4, 4), dpi=100)\n", + "plt.pcolor(sum_point_cov / n_subj)\n", + "plt.xlim([0, 16])\n", + "plt.ylim([0, 16])\n", + "plt.colorbar()\n", + "ax = plt.gca()\n", + "ax.set_aspect(1)\n", + "plt.title('Covariance structure of\\n'\n", + " 'point estimates of betas\\n'\n", + " 'averaged over subjects')\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "deletable": true, + "editable": true + }, + "source": [ + "### We can make a comparison between the estimated SNR map and the true SNR map" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false, + "deletable": true, + "editable": true + }, + "outputs": [], + "source": [ + "subj = highest_subj\n", + "\n", + "\n", + "\n", + "fig, axes = plt.subplots(nrows=1, ncols=n_subj, figsize=(25, 5))\n", + "\n", + "\n", + "vmax = np.max([np.max(gbrsa.nSNR_[s]) for s in range(n_subj)])\n", + "for s in range(n_subj):\n", + " im = axes[s].pcolor(np.reshape(gbrsa.nSNR_[s], [ROI_edge[s], ROI_edge[s]]),\n", + " vmin=0,vmax=vmax)\n", + " axes[s].set_aspect(1)\n", + " \n", + "fig.colorbar(im, ax=axes.ravel().tolist(), shrink=0.75)\n", + "\n", + "plt.suptitle('estimated pseudo-SNR',fontsize=\"xx-large\" )\n", + "plt.show()\n", + "\n", + "fig, axes = plt.subplots(nrows=1, ncols=n_subj, figsize=(25, 5))\n", + "vmax = np.max([np.max(snr[s]) for s in range(n_subj)])\n", + "for s in range(n_subj):\n", + " im = axes[s].pcolor(np.reshape(snr[s], [ROI_edge[s], ROI_edge[s]]),\n", + " vmin=0,vmax=vmax)\n", + " axes[s].set_aspect(1)\n", + "fig.colorbar(im, ax=axes.ravel().tolist(), shrink=0.75)\n", + "plt.suptitle('simulated pseudo-SNR',fontsize=\"xx-large\" )\n", + "plt.show()\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false, + "deletable": true, + "editable": true + }, + "outputs": [], + "source": [ + "RMS_GBRSA = np.mean((gbrsa.C_ - ideal_corr)**2)**0.5\n", + "RMS_RSA = np.mean((point_corr - ideal_corr)**2)**0.5\n", + "print('RMS error of group Bayesian RSA: {}'.format(RMS_GBRSA))\n", + "print('RMS error of standard RSA: {}'.format(RMS_RSA))" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "deletable": true, + "editable": true + }, + "source": [ + "### We can also look at how SNRs are recovered.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false, + "deletable": true, + "editable": true + }, + "outputs": [], + "source": [ + "\n", + "\n", + "fig, axes = plt.subplots(nrows=1, ncols=n_subj, figsize=(25, 5))\n", + "for s in range(n_subj):\n", + " im = axes[s].scatter(np.log(snr[s]) - np.mean(np.log(snr[s])),\n", + " np.log(gbrsa.nSNR_[s]))\n", + " if s == 0:\n", + " axes[s].set_ylabel('recovered log pseudo-SNR',fontsize='xx-large')\n", + " if s == int(n_subj/2):\n", + " axes[s].set_xlabel('true normalized log SNR',fontsize='xx-large')\n", + " axes[s].set_aspect(1)\n", + "plt.suptitle('estimated vs. simulated normalized log SNR',fontsize=\"xx-large\" )\n", + "plt.show()\n", + "\n", + "fig, axes = plt.subplots(nrows=1, ncols=n_subj, figsize=(25, 5))\n", + "for s in range(n_subj):\n", + " im = axes[s].scatter(snr[s], gbrsa.nSNR_[s])\n", + " if s == 0:\n", + " axes[s].set_ylabel('recovered pseudo-SNR',fontsize='xx-large')\n", + " if s == int(n_subj/2):\n", + " axes[s].set_xlabel('true normalized SNR',fontsize='xx-large')\n", + " axes[s].set_aspect(1)\n", + "plt.suptitle('estimated vs. simulated SNR',fontsize=\"xx-large\" )\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "deletable": true, + "editable": true + }, + "source": [ + "#### We typically observe that low SNR voxels' SNRs are overestimated. This might be due to the normal prior we imposed on the log(SNR). \n", + "#### We can also examine the relation between recovered betas and true betas" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false, + "deletable": true, + "editable": true, + "scrolled": false + }, + "outputs": [], + "source": [ + "fig, axes = plt.subplots(nrows=1, ncols=n_subj, figsize=(25, 5))\n", + "for s in range(n_subj):\n", + " im = axes[s].scatter(betas_simulated[s] , gbrsa.beta_[s])\n", + " if s == 0:\n", + " axes[s].set_ylabel('recovered betas by GBRSA',fontsize='xx-large')\n", + " if s == int(n_subj/2):\n", + " axes[s].set_xlabel('true betas',fontsize='xx-large')\n", + " axes[s].set_aspect(1)\n", + "plt.suptitle('estimated vs. simulated betas, \\nby GBRSA',fontsize=\"xx-large\" )\n", + "plt.show()\n", + "\n", + "\n", + "fig, axes = plt.subplots(nrows=1, ncols=n_subj, figsize=(25, 5))\n", + "for s in range(n_subj):\n", + " im = axes[s].scatter(betas_simulated[s] , betas_point[s][1:, :])\n", + " if s == 0:\n", + " axes[s].set_ylabel('recovered betas by simple regression',fontsize='xx-large')\n", + " if s == int(n_subj/2):\n", + " axes[s].set_xlabel('true betas',fontsize='xx-large')\n", + " axes[s].set_aspect(1)\n", + "plt.suptitle('estimated vs. simulated betas, \\nby simple regression',fontsize=\"xx-large\" )\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "deletable": true, + "editable": true + }, + "source": [ + "### The singular decomposition of noise, and the comparison between the first two principal component of noise and the patterns of the first two nuisance regressors, returned by the model. \n", + "The principal components may not look exactly the same. The first principal components both capture the baseline image intensities (although they may sometimes appear counter-phase)\n", + "\n", + "Apparently one can imagine that the choice of the number of principal components used as nuisance regressors can influence the result. If you just choose 1 or 2, perhaps only the global drift would be captured. But including too many nuisance regressors would slow the fitting speed and might have risk of overfitting. The users might consider starting in the range of 5-20. We do not have automatic cross-validation built in. But you can use the score() function to do cross-validation and select the appropriate n_nureg. The idea here is similar to that in GLMdenoise (http://kendrickkay.net/GLMdenoise/)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "deletable": true, + "editable": true + }, + "source": [ + "## \"Decoding\" from new data\n", + "### Now we generate a new data set, assuming signal is the same but noise is regenerated. We want to use the transform() function of gbrsa to estimate the \"design matrix\" in this new dataset.\n", + "We keep the signal the same as in training data, but generate new noise.\n", + "Note that we did this purely for simplicity of simulation. It is totally fine and encouraged for the event timing to be different in your training and testing data. You just need to capture them in your design matrix" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false, + "deletable": true, + "editable": true + }, + "outputs": [], + "source": [ + "noise_new = [None] * n_subj\n", + "Y_new = [None] * n_subj\n", + "for subj in range(n_subj):\n", + " \n", + " # generating noise\n", + " K_noise = noise_level[subj][:, np.newaxis] \\\n", + " * (np.exp(-dist2[subj] / noise_smooth_width**2 / 2.0) \\\n", + " + np.eye(n_V[subj]) * 0.1) * noise_level[subj]\n", + " # We make spatially correlated noise by generating\n", + " # noise at each time point from a Gaussian Process\n", + " # defined over the coordinates.\n", + " L_noise = np.linalg.cholesky(K_noise)\n", + " noise_new[subj] = np.zeros([n_T[subj], n_V[subj]])\n", + " noise_new[subj][0, :] = np.dot(L_noise, np.random.randn(n_V[subj]))\\\n", + " / np.sqrt(1 - rho1[subj]**2)\n", + " for i_t in range(1, n_T[subj]):\n", + " noise_new[subj][i_t, :] = noise_new[subj][i_t - 1, :] * rho1[subj] \\\n", + " + np.dot(L_noise,np.random.randn(n_V[subj]))\n", + " Y_new[subj] = signal[subj] + noise_new[subj] + inten[subj]\n", + "\n", + "\n", + "\n", + "ts, ts0 = gbrsa.transform(Y_new,scan_onsets=scan_onsets)\n", + "# ts is the estimated task-related time course, with each column corresponding to the task condition of the same\n", + "# column in design matrix.\n", + "# ts0 is the estimated time courses that have the same spatial spread as those in the training data (X0).\n", + "# It is possible some task related signal is still in X0 or ts0, but not captured by the design matrix.\n", + "\n", + "fig, axes = plt.subplots(nrows=1, ncols=n_subj, figsize=(25, 5))\n", + "for s in range(n_subj):\n", + " recovered_plot, = axes[s].plot(ts[s][:200, 8], 'b')\n", + " design_plot, = axes[s].plot(design[s].design_task[:200, 8], 'g')\n", + " if s == int(n_subj/2):\n", + " axes[s].set_xlabel('time',fontsize='xx-large')\n", + " \n", + "fig.legend([design_plot, recovered_plot],\n", + " ['design matrix for one condition', 'recovered time course for the condition'],\n", + " fontsize='xx-large')\n", + "\n", + "plt.show()\n", + "\n", + "# We did not plot the whole time series for the purpose of seeing closely how much the two\n", + "# time series overlap\n", + "\n", + "fig, axes = plt.subplots(nrows=1, ncols=n_subj, figsize=(25, 5))\n", + "for s in range(n_subj):\n", + " c = np.corrcoef(design[s].design_task.T, ts[s].T)\n", + " im = axes[s].pcolor(c[0:16, 16:],vmin=-0.5,vmax=1)\n", + " axes[s].set_aspect(1)\n", + " if s == int(n_subj/2):\n", + " axes[s].set_xlabel('recovered time course',fontsize='xx-large')\n", + " if s == 0:\n", + " axes[s].set_ylabel('true design matrix',fontsize='xx-large')\n", + "fig.suptitle('correlation between true design matrix \\nand the recovered task-related activity')\n", + "fig.colorbar(im, ax=axes.ravel().tolist(), shrink=0.75)\n", + "plt.show()\n", + "\n", + "\n", + "print('average SNR level:', snr_level)\n", + "print('Apparently how much the recovered time course resembles the true design matrix depends on SNR')" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "collapsed": false, + "deletable": true, + "editable": true + }, + "source": [ + "## Model selection by cross-validataion:\n", + "Similar to BRSA, you can compare different models by cross-validating the parameters of one model learnt from some training data\n", + "on some testing data. GBRSA provides a score() function, which provides you a pair of cross-validated log likelihood\n", + "for testing data. The first returned item is a numpy array of the cross-validated log likelihood of the model you have specified, for all the subjects.\n", + "The second is a numpy arrary of those of a null model which assumes everything else the same except that there is no task-related activity.\n", + "### Notice that comparing the score of your model of interest against its corresponding null model is not the single way to compare models. You might also want to compare against a model using the same set of design matrix, but a different rank (especially rank 1, which means all task conditions have the same response pattern, only differing in the magnitude).\n", + "In general, in the context of GBRSA, a model means the timing of each event and the way these events are grouped, together with other trivial parameters such as the rank of the covariance matrix and the number of nuisance regressors. All these parameters can influence model performance.\n", + "In future, we will provide interface to test the performance of a model with predefined similarity matrix or covariance matrix." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false, + "deletable": true, + "editable": true, + "scrolled": false + }, + "outputs": [], + "source": [ + "\n", + "width = 0.35\n", + "[score, score_null] = gbrsa.score(X=Y_new, design=[d.design_task for d in design], scan_onsets=scan_onsets)\n", + "\n", + "plt.bar(np.arange(n_subj),np.asarray(score)-np.asarray(score_null), width=width)\n", + "plt.ylim(np.min([np.asarray(score)-np.asarray(score_null)])-100, np.max([np.asarray(score)-np.asarray(score_null)])+100)\n", + "plt.ylabel('cross-validated log likelihood')\n", + "plt.xlabel('partipants')\n", + "plt.title('Difference between cross-validated log likelihoods\\n of full model and null model\\non new data containing signal')\n", + "plt.show()\n", + "\n", + "Y_nosignal = [noise_new[s] + inten[s] for s in range(n_subj)]\n", + "[score_noise, score_null_noise] = gbrsa.score(X=Y_nosignal, design=[d.design_task for d in design], scan_onsets=scan_onsets)\n", + "\n", + "plt.bar(np.arange(n_subj),np.asarray(score_noise)-np.asarray(score_null_noise), width=width)\n", + "plt.ylim(np.min([np.asarray(score_noise)-np.asarray(score_null_noise)])-100,\n", + " np.max([np.asarray(score_noise)-np.asarray(score_null_noise)])+100)\n", + "plt.ylabel('cross-validated log likelihood')\n", + "plt.xlabel('partipants')\n", + "plt.title('Difference between cross-validated log likelihoods\\n of full model and null model\\non pure noise')\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "deletable": true, + "editable": true + }, + "source": [ + "#### Full model performs better on testing data that has the same property of signal and noise with training data.\n", + "#### Below, we fit the model to data containing only noise and test how it performs on data with signal." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false, + "deletable": true, + "editable": true + }, + "outputs": [], + "source": [ + "gbrsa_noise = GBRSA()\n", + "gbrsa_noise.fit(X=[noise[s] + inten[s] for s in range(n_subj)],\n", + " design=[d.design_task for d in design],scan_onsets=scan_onsets)\n", + "Y_nosignal = [noise_new[s] + inten[s] for s in range(n_subj)]\n", + "[score_noise, score_null_noise] = gbrsa_noise.score(X=Y_nosignal,\n", + " design=[d.design_task for d in design], scan_onsets=scan_onsets)\n", + "\n", + "plt.bar(np.arange(n_subj),np.asarray(score_noise)-np.asarray(score_null_noise), width=width)\n", + "plt.ylim(np.min([np.asarray(score_noise)-np.asarray(score_null_noise)])-100,\n", + " np.max([np.asarray(score_noise)-np.asarray(score_null_noise)])+100)\n", + "plt.ylabel('cross-validated log likelihood')\n", + "plt.xlabel('partipants')\n", + "plt.title('Difference between cross-validated log likelihoods\\n of full model and null model\\ntrained on pure noise')\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "deletable": true, + "editable": true + }, + "source": [ + "#### We can see that the difference is smaller but full model generally performs slightly worse, because of overfitting. This is expected.\n", + "## So, after fitting a model to your data, you should also check cross-validated log likelihood on separate runs from the same group of participants, and make sure your model is at least better than a null model before you trust your similarity matrix. \n", + "## Another diagnostic of bad model to your data is very small diagonal values in the shared covariance structure U_\n", + "#### Shown below:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false, + "deletable": true, + "editable": true + }, + "outputs": [], + "source": [ + "plt.pcolor(gbrsa_noise.U_)\n", + "plt.colorbar()\n", + "ax = plt.gca()\n", + "ax.set_aspect(1)\n", + "plt.title('covariance matrix of task conditions estimated from pure noise')" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "deletable": true, + "editable": true + }, + "source": [ + "## Lastly, the memory demand might be high in case of big ROI, many participants, or fine grain in parameter space of log(SNR) and rho. If this happens, one can reduce parameters of logS_range and SNR_bins simultaneously, and consider running on clusters with bigger memory. But keep in mind reducing logS_range means we are putting a prior that the variance of SNR across voxels and participants is smaller." + ] + } + ], + "metadata": { + "anaconda-cloud": {}, + "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.5.2" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} diff --git a/examples/reprsimil/requirements.txt b/examples/reprsimil/requirements.txt new file mode 100644 index 000000000..4b43f7e68 --- /dev/null +++ b/examples/reprsimil/requirements.txt @@ -0,0 +1 @@ +matplotlib \ No newline at end of file diff --git a/tests/reprsimil/test_brsa.py b/tests/reprsimil/test_brsa.py index 1d5a9a46e..aed825fcf 100755 --- a/tests/reprsimil/test_brsa.py +++ b/tests/reprsimil/test_brsa.py @@ -22,14 +22,18 @@ def test_can_instantiate(): s = brainiak.reprsimil.brsa.BRSA() assert s, "Invalid BRSA instance!" - voxels = 100 - samples = 500 - features = 3 - - s = brainiak.reprsimil.brsa.BRSA(n_iter=50, rank=5, GP_space=True, GP_inten=True, tol=2e-3,\ - eta=0.001,space_smooth_range=10.0,inten_smooth_range=100.0) + s = brainiak.reprsimil.brsa.BRSA(n_iter=50, rank=5, + auto_nuisance=False, n_nureg=2, nureg_method='ICA', + baseline_single=False, init_iter=5, + GP_space=True, GP_inten=True, tol=2e-3, + eta=0.001, space_smooth_range=10.0, + inten_smooth_range=100.0, tau_range=2.0, + tau2_prior=brainiak.reprsimil.brsa.prior_GP_var_inv_gamma, + optimizer='CG', + random_state=100, anneal_speed=20) assert s, "Invalid BRSA instance!" + def test_fit(): from brainiak.reprsimil.brsa import BRSA import brainiak.utils.utils as utils @@ -41,153 +45,180 @@ def test_fit(): # Load an example design matrix design = utils.ReadDesign(fname=file_path) - # concatenate it by 2 times, mimicking 2 runs of itenditcal timing n_run = 2 - design.design_task = np.tile(design.design_task[:,:-1],[n_run,1]) + design.design_task = np.tile(design.design_task[:, :-1], [n_run, 1]) design.n_TR = design.n_TR * n_run # start simulating some data n_V = 50 - n_C = np.size(design.design_task,axis=1) + n_C = np.size(design.design_task, axis=1) n_T = design.n_TR noise_bot = 0.5 noise_top = 5.0 - noise_level = np.random.rand(n_V)*(noise_top-noise_bot)+noise_bot + noise_level = np.random.rand(n_V) * (noise_top - noise_bot) + noise_bot # noise level is random. # AR(1) coefficient rho1_top = 0.8 rho1_bot = -0.2 - rho1 = np.random.rand(n_V)*(rho1_top-rho1_bot)+rho1_bot + rho1 = np.random.rand(n_V) * (rho1_top - rho1_bot) + rho1_bot # generating noise - noise = np.zeros([n_T,n_V]) - noise[0,:] = np.random.randn(n_V) * noise_level / np.sqrt(1-rho1**2) - for i_t in range(1,n_T): - noise[i_t,:] = noise[i_t-1,:] * rho1 + np.random.randn(n_V) * noise_level - - noise = noise + np.random.rand(n_V) - # Random baseline + noise = np.zeros([n_T, n_V]) + noise[0, :] = np.random.randn(n_V) * noise_level / np.sqrt(1 - rho1**2) + for i_t in range(1, n_T): + noise[i_t, :] = noise[i_t - 1, :] * rho1 + \ + np.random.randn(n_V) * noise_level # ideal covariance matrix - ideal_cov = np.zeros([n_C,n_C]) - ideal_cov = np.eye(n_C)*0.6 - ideal_cov[0:4,0:4] = 0.2 - for cond in range(0,4): - ideal_cov[cond,cond] = 2 - ideal_cov[5:9,5:9] = 0.9 - for cond in range(5,9): - ideal_cov[cond,cond] = 1 - idx = np.where(np.sum(np.abs(ideal_cov),axis=0)>0)[0] - L_full = np.linalg.cholesky(ideal_cov) + ideal_cov = np.zeros([n_C, n_C]) + ideal_cov = np.eye(n_C) * 0.6 + ideal_cov[0:4, 0:4] = 0.2 + for cond in range(0, 4): + ideal_cov[cond, cond] = 2 + ideal_cov[5:9, 5:9] = 0.9 + for cond in range(5, 9): + ideal_cov[cond, cond] = 1 + idx = np.where(np.sum(np.abs(ideal_cov), axis=0) > 0)[0] + L_full = np.linalg.cholesky(ideal_cov) # generating signal - snr_level = 5.0 # test with high SNR + snr_level = 5.0 # test with high SNR # snr = np.random.rand(n_V)*(snr_top-snr_bot)+snr_bot # Notice that accurately speaking this is not snr. the magnitude of signal depends # not only on beta but also on x. - inten = np.random.randn(n_V) * 20.0 + inten = np.random.rand(n_V) * 20.0 # parameters of Gaussian process to generate pseuso SNR - tau = 0.8 + tau = 1.0 smooth_width = 5.0 inten_kernel = 1.0 - - coords = np.arange(0,n_V)[:,None] - dist2 = np.square(coords-coords.T) + coords = np.arange(0, n_V)[:, None] + + dist2 = np.square(coords - coords.T) - inten_tile = np.tile(inten,[n_V,1]) - inten_diff2 = (inten_tile-inten_tile.T)**2 + inten_tile = np.tile(inten, [n_V, 1]) + inten_diff2 = (inten_tile - inten_tile.T)**2 - K = np.exp(-dist2/smooth_width**2/2.0 -inten_diff2/inten_kernel**2/2.0) * tau**2 + np.eye(n_V)*tau**2*0.001 + K = np.exp(-dist2 / smooth_width**2 / 2.0 - inten_diff2 / + inten_kernel**2 / 2.0) * tau**2 + np.eye(n_V) * tau**2 * 0.001 L = np.linalg.cholesky(K) - snr = np.exp(np.dot(L,np.random.randn(n_V))) * snr_level - sqrt_v = noise_level*snr - betas_simulated = np.dot(L_full,np.random.randn(n_C,n_V)) * sqrt_v - signal = np.dot(design.design_task,betas_simulated) + snr = np.exp(np.dot(L, np.random.randn(n_V))) * snr_level + sqrt_v = noise_level * snr + betas_simulated = np.dot(L_full, np.random.randn(n_C, n_V)) * sqrt_v + signal = np.dot(design.design_task, betas_simulated) # Adding noise to signal as data Y = signal + noise + inten - - scan_onsets = np.linspace(0,design.n_TR,num=5) - + scan_onsets = np.linspace(0, design.n_TR, num=n_run + 1) # Test fitting with GP prior. - brsa = BRSA(GP_space=True,GP_inten=True,verbose=False,n_iter = 5,auto_nuisance=False) + brsa = BRSA(GP_space=True, GP_inten=True, n_iter=5, + init_iter=10, auto_nuisance=False, tol=2e-3) - # We also test that it can detect baseline regressor included in the design matrix for task conditions + # We also test that it can detect baseline regressor included in the + # design matrix for task conditions wrong_design = np.insert(design.design_task, 0, 1, axis=1) with pytest.raises(ValueError) as excinfo: brsa.fit(X=Y, design=wrong_design, scan_onsets=scan_onsets, - coords=coords, inten=inten) - assert 'Your design matrix appears to have included baseline time series.' in str(excinfo.value) + coords=coords, inten=inten) + assert 'Your design matrix appears to have included baseline time series.' in str( + excinfo.value) # Now we fit with the correct design matrix. brsa.fit(X=Y, design=design.design_task, scan_onsets=scan_onsets, coords=coords, inten=inten) - - # Check that result is significantly correlated with the ideal covariance matrix + + # Check that result is significantly correlated with the ideal covariance + # matrix u_b = brsa.U_ u_i = ideal_cov p = scipy.stats.spearmanr(u_b[np.tril_indices_from(u_b)], u_i[np.tril_indices_from(u_i)])[1] assert p < 0.01, "Fitted covariance matrix does not correlate with ideal covariance matrix!" # check that the recovered SNRs makes sense - p = scipy.stats.pearsonr(brsa.nSNR_,snr)[1] + p = scipy.stats.pearsonr(brsa.nSNR_, snr)[1] assert p < 0.01, "Fitted SNR does not correlate with simulated SNR!" - assert np.isclose(np.mean(np.log(brsa.nSNR_)),0), "nSNR_ not normalized!" - p = scipy.stats.pearsonr(brsa.sigma_,noise_level)[1] + assert np.isclose(np.mean(np.log(brsa.nSNR_)), 0), "nSNR_ not normalized!" + p = scipy.stats.pearsonr(brsa.sigma_, noise_level)[1] assert p < 0.01, "Fitted noise level does not correlate with simulated noise level!" - p = scipy.stats.pearsonr(brsa.rho_,rho1)[1] + p = scipy.stats.pearsonr(brsa.rho_, rho1)[1] assert p < 0.01, "Fitted AR(1) coefficient does not correlate with simulated values!" + noise_new = np.zeros([n_T, n_V]) + noise_new[0, :] = np.random.randn(n_V) * noise_level / np.sqrt(1 - rho1**2) + for i_t in range(1, n_T): + noise_new[i_t, :] = noise_new[i_t - 1, :] * \ + rho1 + np.random.randn(n_V) * noise_level + + Y_new = signal + noise_new + inten + ts, ts0 = brsa.transform(Y_new, scan_onsets=scan_onsets) + p = scipy.stats.pearsonr(ts[:, 0], design.design_task[:, 0])[1] + assert p < 0.01, "Recovered time series does not correlate with true time series!" + assert np.shape(ts) == (n_T, n_C) and np.shape(ts0) == (n_T, 1),\ + "Wrong shape in returned time series by transform function!" + + [score, score_null] = brsa.score( + X=Y_new, design=design.design_task, scan_onsets=scan_onsets) + assert score > score_null, "Full model does not win over null model on data containing signal" - # Test fitting with lower rank and without GP prior + [score, score_null] = brsa.score( + X=noise_new + inten, design=design.design_task, scan_onsets=scan_onsets) + assert score < score_null, "Null model does not win over full model on data without signal" + + # Test fitting with lower rank, nuisance regressors and without GP prior rank = n_C - 1 n_nureg = 1 - brsa = BRSA(rank=rank,n_nureg=n_nureg) + brsa = BRSA(rank=rank, n_nureg=n_nureg, tol=2e-3, + n_iter=4, init_iter=4, auto_nuisance=True) brsa.fit(X=Y, design=design.design_task, scan_onsets=scan_onsets) - u_b = brsa.U_ + # u_b = brsa.U_ u_i = ideal_cov - p = scipy.stats.spearmanr(u_b[np.tril_indices_from(u_b)],u_i[np.tril_indices_from(u_i)])[1] + p = scipy.stats.spearmanr(u_b[np.tril_indices_from(u_b)], u_i[ + np.tril_indices_from(u_i)])[1] assert p < 0.01, "Fitted covariance matrix does not correlate with ideal covariance matrix!" # check that the recovered SNRs makes sense - p = scipy.stats.pearsonr(brsa.nSNR_,snr)[1] + p = scipy.stats.pearsonr(brsa.nSNR_, snr)[1] assert p < 0.01, "Fitted SNR does not correlate with simulated SNR!" - assert np.isclose(np.mean(np.log(brsa.nSNR_)),0), "nSNR_ not normalized!" - p = scipy.stats.pearsonr(brsa.sigma_,noise_level)[1] + assert np.isclose(np.mean(np.log(brsa.nSNR_)), 0), "nSNR_ not normalized!" + p = scipy.stats.pearsonr(brsa.sigma_, noise_level)[1] assert p < 0.01, "Fitted noise level does not correlate with simulated noise level!" - p = scipy.stats.pearsonr(brsa.rho_,rho1)[1] + p = scipy.stats.pearsonr(brsa.rho_, rho1)[1] assert p < 0.01, "Fitted AR(1) coefficient does not correlate with simulated values!" - assert not hasattr(brsa,'bGP_') and not hasattr(brsa,'lGPspace_') and not hasattr(brsa,'lGPinten_'),\ + assert not hasattr(brsa, 'bGP_') and not hasattr(brsa, 'lGPspace_') and not hasattr(brsa, 'lGPinten_'),\ 'the BRSA object should not have parameters of GP if GP is not requested.' # GP parameters are not set if not requested - assert brsa.beta0_.shape[0] == n_nureg, 'Shape of beta0 incorrect' - p = scipy.stats.pearsonr(brsa.beta0_[0,:],inten)[1] - assert p < 0.05, 'recovered beta0 does not correlate with the baseline of voxels.' + assert brsa.beta0_.shape[0] == n_nureg + 1, 'Shape of beta0 incorrect' + p = scipy.stats.pearsonr(brsa.beta0_[0, :], inten)[1] + assert p < 0.01, 'recovered beta0 does not correlate with the baseline of voxels.' + assert np.shape(brsa.L_) == ( + n_C, rank), 'Cholesky factor should have shape of (n_C, rank)' # Test fitting with GP over just spatial coordinates. - brsa = BRSA(GP_space=True) - brsa.fit(X=Y, design=design.design_task, scan_onsets=scan_onsets, coords=coords) - # Check that result is significantly correlated with the ideal covariance matrix + brsa = BRSA(GP_space=True, baseline_single=False, + tol=2e-3, n_iter=4, init_iter=4) + brsa.fit(X=Y, design=design.design_task, + scan_onsets=scan_onsets, coords=coords) + # # Check that result is significantly correlated with the ideal covariance matrix u_b = brsa.U_ u_i = ideal_cov - p = scipy.stats.spearmanr(u_b[np.tril_indices_from(u_b)],u_i[np.tril_indices_from(u_i)])[1] + p = scipy.stats.spearmanr(u_b[np.tril_indices_from(u_b)], u_i[ + np.tril_indices_from(u_i)])[1] assert p < 0.01, "Fitted covariance matrix does not correlate with ideal covariance matrix!" # check that the recovered SNRs makes sense - p = scipy.stats.pearsonr(brsa.nSNR_,snr)[1] + p = scipy.stats.pearsonr(brsa.nSNR_, snr)[1] assert p < 0.01, "Fitted SNR does not correlate with simulated SNR!" - assert np.isclose(np.mean(np.log(brsa.nSNR_)),0), "nSNR_ not normalized!" - p = scipy.stats.pearsonr(brsa.sigma_,noise_level)[1] + assert np.isclose(np.mean(np.log(brsa.nSNR_)), 0), "nSNR_ not normalized!" + p = scipy.stats.pearsonr(brsa.sigma_, noise_level)[1] assert p < 0.01, "Fitted noise level does not correlate with simulated noise level!" - p = scipy.stats.pearsonr(brsa.rho_,rho1)[1] + p = scipy.stats.pearsonr(brsa.rho_, rho1)[1] assert p < 0.01, "Fitted AR(1) coefficient does not correlate with simulated values!" - assert not hasattr(brsa,'lGPinten_'),\ + assert not hasattr(brsa, 'lGPinten_'),\ 'the BRSA object should not have parameters of lGPinten_ if only smoothness in space is requested.' # GP parameters are not set if not requested @@ -206,42 +237,43 @@ def test_gradient(): design = utils.ReadDesign(fname=file_path) n_run = 4 # concatenate it by 4 times, mimicking 4 runs of itenditcal timing - design.design_task = np.tile(design.design_task[:,:-1],[n_run,1]) + design.design_task = np.tile(design.design_task[:, :-1], [n_run, 1]) design.n_TR = design.n_TR * n_run # start simulating some data n_V = 30 - n_C = np.size(design.design_task,axis=1) + n_C = np.size(design.design_task, axis=1) n_T = design.n_TR noise_bot = 0.5 noise_top = 1.5 - noise_level = np.random.rand(n_V)*(noise_top-noise_bot)+noise_bot + noise_level = np.random.rand(n_V) * (noise_top - noise_bot) + noise_bot # noise level is random. # AR(1) coefficient rho1_top = 0.8 rho1_bot = -0.2 - rho1 = np.random.rand(n_V)*(rho1_top-rho1_bot)+rho1_bot + rho1 = np.random.rand(n_V) * (rho1_top - rho1_bot) + rho1_bot # generating noise - noise = np.zeros([n_T,n_V]) - noise[0,:] = np.random.randn(n_V) * noise_level / np.sqrt(1-rho1**2) - for i_t in range(1,n_T): - noise[i_t,:] = noise[i_t-1,:] * rho1 + np.random.randn(n_V) * noise_level + noise = np.zeros([n_T, n_V]) + noise[0, :] = np.random.randn(n_V) * noise_level / np.sqrt(1 - rho1**2) + for i_t in range(1, n_T): + noise[i_t, :] = noise[i_t - 1, :] * rho1 + \ + np.random.randn(n_V) * noise_level # ideal covariance matrix - ideal_cov = np.zeros([n_C,n_C]) - ideal_cov = np.eye(n_C)*0.6 - ideal_cov[0,0] = 0.2 - ideal_cov[5:9,5:9] = 0.6 - for cond in range(5,9): - ideal_cov[cond,cond] = 1 - idx = np.where(np.sum(np.abs(ideal_cov),axis=0)>0)[0] + ideal_cov = np.zeros([n_C, n_C]) + ideal_cov = np.eye(n_C) * 0.6 + ideal_cov[0, 0] = 0.2 + ideal_cov[5:9, 5:9] = 0.6 + for cond in range(5, 9): + ideal_cov[cond, cond] = 1 + idx = np.where(np.sum(np.abs(ideal_cov), axis=0) > 0)[0] L_full = np.linalg.cholesky(ideal_cov) # generating signal - snr_level = 5.0 # test with high SNR + snr_level = 5.0 # test with high SNR inten = np.random.randn(n_V) * 20.0 # parameters of Gaussian process to generate pseuso SNR @@ -249,42 +281,49 @@ def test_gradient(): smooth_width = 5.0 inten_kernel = 1.0 - coords = np.arange(0,n_V)[:,None] + coords = np.arange(0, n_V)[:, None] - dist2 = np.square(coords-coords.T) + dist2 = np.square(coords - coords.T) - inten_tile = np.tile(inten,[n_V,1]) - inten_diff2 = (inten_tile-inten_tile.T)**2 + inten_tile = np.tile(inten, [n_V, 1]) + inten_diff2 = (inten_tile - inten_tile.T)**2 - K = np.exp(-dist2/smooth_width**2/2.0 -inten_diff2/inten_kernel**2/2.0) * tau**2 + np.eye(n_V)*tau**2*0.001 + K = np.exp(-dist2 / smooth_width**2 / 2.0 - inten_diff2 / + inten_kernel**2 / 2.0) * tau**2 + np.eye(n_V) * tau**2 * 0.001 L = np.linalg.cholesky(K) - snr = np.exp(np.dot(L,np.random.randn(n_V))) * snr_level + snr = np.exp(np.dot(L, np.random.randn(n_V))) * snr_level # Notice that accurately speaking this is not snr. the magnitude of signal depends # not only on beta but also on x. - sqrt_v = noise_level*snr - betas_simulated = np.dot(L_full,np.random.randn(n_C,n_V)) * sqrt_v - signal = np.dot(design.design_task,betas_simulated) + sqrt_v = noise_level * snr + betas_simulated = np.dot(L_full, np.random.randn(n_C, n_V)) * sqrt_v + signal = np.dot(design.design_task, betas_simulated) # Adding noise to signal as data Y = signal + noise - scan_onsets = np.linspace(0,design.n_TR,num=n_run+1) + scan_onsets = np.linspace(0, design.n_TR, num=n_run + 1) # Test fitting with GP prior. - brsa = BRSA(GP_space=True,GP_inten=True,verbose=False,n_iter = 200,rank=n_C) + brsa = BRSA(GP_space=True, GP_inten=True, rank=n_C) # Additionally, we test the generation of re-used terms. X0 = np.ones(n_T)[:, None] D, F, run_TRs, n_run_returned = brsa._prepare_DF( n_T, scan_onsets=scan_onsets) + assert np.shape(D) == (n_T, n_T), 'D has wrong shape' + assert np.shape(F) == (n_T, n_T), 'F has wrong shape' + assert np.sum(D) == (n_T - n_run) * 2, 'D is initialized incorrectly.' + assert np.sum(F) == n_T - n_run * 2, 'F is initialized incorrectly.' assert n_run_returned == n_run, 'There is mistake in counting number of runs' - assert np.sum(run_TRs) == n_T, 'The segmentation of the total experiment duration is wrong' + assert np.sum( + run_TRs) == n_T, 'The segmentation of the total experiment duration is wrong' XTY, XTDY, XTFY, YTY_diag, YTDY_diag, YTFY_diag, XTX, \ XTDX, XTFX = brsa._prepare_data_XY(design.design_task, Y, D, F) X0TX0, X0TDX0, X0TFX0, XTX0, XTDX0, XTFX0, \ - X0TY, X0TDY, X0TFY, X0, n_base = brsa._prepare_data_XYX0( - design.design_task, Y, X0, D, F, run_TRs, no_DC=False) + X0TY, X0TDY, X0TFY, X0, X_base, n_X0, idx_DC \ + = brsa._prepare_data_XYX0( + design.design_task, Y, X0, np.random.randn(n_T)[:, None], D, F, run_TRs, no_DC=False) assert np.shape(XTY) == (n_C, n_V) and np.shape(XTDY) == (n_C, n_V) \ and np.shape(XTFY) == (n_C, n_V),\ 'Dimension of XTY etc. returned from _prepare_data is wrong' @@ -298,12 +337,15 @@ def test_gradient(): 'Dimension of XTX0 etc. returned from _prepare_data is wrong' assert np.ndim(X0TY) == 2 and np.ndim(X0TDY) == 2 and np.ndim(X0TFY) == 2,\ 'Dimension of X0TY etc. returned from _prepare_data is wrong' + assert np.shape(X0) == (n_T, n_X0) and np.shape(X_base) == (n_T, np.size(idx_DC)) and np.max(idx_DC) < n_X0\ + and np.size(idx_DC) + 1 == n_X0,\ + 'Dimension of X0 or X_base, or n_X0 or indices of DC components are wrong.' l_idx = np.tril_indices(n_C) n_l = np.size(l_idx[0]) - # Make sure all the fields are in the indices. - idx_param_sing, idx_param_fitU, idx_param_fitV = brsa._build_index_param(n_l, n_V, 2) + idx_param_sing, idx_param_fitU, idx_param_fitV = brsa._build_index_param( + n_l, n_V, 2) assert 'Cholesky' in idx_param_sing and 'a1' in idx_param_sing, \ 'The dictionary for parameter indexing misses some keys' assert 'Cholesky' in idx_param_fitU and 'a1' in idx_param_fitU, \ @@ -311,22 +353,23 @@ def test_gradient(): assert 'log_SNR2' in idx_param_fitV and 'c_space' in idx_param_fitV \ and 'c_inten' in idx_param_fitV and 'c_both' in idx_param_fitV, \ 'The dictionary for parameter indexing misses some keys' - + # Initial parameters are correct parameters with some perturbation - param0_fitU = np.random.randn(n_l+n_V) * 0.1 - param0_fitV = np.random.randn(n_V+1) * 0.1 - param0_sing = np.random.randn(n_l+1) * 0.1 + param0_fitU = np.random.randn(n_l + n_V) * 0.1 + param0_fitV = np.random.randn(n_V + 1) * 0.1 + param0_sing = np.random.randn(n_l + 1) * 0.1 param0_sing[idx_param_sing['a1']] += np.mean(np.tan(rho1 * np.pi / 2)) - param0_fitV[idx_param_fitV['log_SNR2']] += np.log(snr[:n_V-1])*2 - param0_fitV[idx_param_fitV['c_space']] += np.log(smooth_width)*2 - param0_fitV[idx_param_fitV['c_inten']] += np.log(inten_kernel)*2 + param0_fitV[idx_param_fitV['log_SNR2']] += np.log(snr[:n_V - 1]) * 2 + param0_fitV[idx_param_fitV['c_space']] += np.log(smooth_width) * 2 + param0_fitV[idx_param_fitV['c_inten']] += np.log(inten_kernel) * 2 # test if the gradients are correct # log likelihood and derivative of the _singpara function + ll0, deriv0 = brsa._loglike_AR1_singpara(param0_sing, XTX, XTDX, XTFX, YTY_diag, YTDY_diag, YTFY_diag, XTY, XTDY, XTFY, X0TX0, X0TDX0, X0TFX0, - XTX0, XTDX0, XTFX0, X0TY, X0TDY, X0TFY, - l_idx, n_C, n_T, n_V, n_run, n_base, + XTX0, XTDX0, XTFX0, X0TY, X0TDY, X0TFY, + l_idx, n_C, n_T, n_V, n_run, n_X0, idx_param_sing) # We test the gradient to the Cholesky factor vec = np.zeros(np.size(param0_sing)) @@ -334,10 +377,11 @@ def test_gradient(): dd = nd.directionaldiff(lambda x: brsa._loglike_AR1_singpara(x, XTX, XTDX, XTFX, YTY_diag, YTDY_diag, YTFY_diag, XTY, XTDY, XTFY, X0TX0, X0TDX0, X0TFX0, XTX0, XTDX0, XTFX0, X0TY, X0TDY, X0TFY, - l_idx, n_C, n_T, n_V, n_run, n_base, + l_idx, n_C, n_T, n_V, n_run, n_X0, idx_param_sing)[0], param0_sing, vec) - assert np.isclose(dd, np.dot(deriv0, vec), rtol=1e-5), 'gradient of singpara wrt Cholesky is incorrect' + assert np.isclose(dd, np.dot(deriv0, vec), + rtol=1e-5), 'gradient of singpara wrt Cholesky is incorrect' # We test the gradient to a1 vec = np.zeros(np.size(param0_sing)) @@ -345,152 +389,193 @@ def test_gradient(): dd = nd.directionaldiff(lambda x: brsa._loglike_AR1_singpara(x, XTX, XTDX, XTFX, YTY_diag, YTDY_diag, YTFY_diag, XTY, XTDY, XTFY, X0TX0, X0TDX0, X0TFX0, XTX0, XTDX0, XTFX0, X0TY, X0TDY, X0TFY, - l_idx, n_C, n_T, n_V, n_run, n_base, + l_idx, n_C, n_T, n_V, n_run, n_X0, idx_param_sing)[0], param0_sing, vec) - assert np.isclose(dd, np.dot(deriv0, vec), rtol=1e-5), 'gradient of singpara wrt a1 is incorrect' + assert np.isclose(dd, np.dot(deriv0, vec), + rtol=1e-5), 'gradient of singpara wrt a1 is incorrect' - - # log likelihood and derivative of the fitU function. ll0, deriv0 = brsa._loglike_AR1_diagV_fitU(param0_fitU, XTX, XTDX, XTFX, YTY_diag, YTDY_diag, YTFY_diag, XTY, XTDY, XTFY, X0TX0, X0TDX0, X0TFX0, XTX0, XTDX0, XTFX0, X0TY, X0TDY, X0TFY, - np.log(snr)*2, l_idx,n_C,n_T,n_V,n_run,n_base,idx_param_fitU,n_C) + np.log(snr) * 2, l_idx, n_C, n_T, n_V, n_run, n_X0, idx_param_fitU, n_C) - - # We test the gradient wrt the reparametrization of AR(1) coefficient of noise. + # We test the gradient wrt the reparametrization of AR(1) coefficient of + # noise. vec = np.zeros(np.size(param0_fitU)) vec[idx_param_fitU['a1'][0]] = 1 dd = nd.directionaldiff(lambda x: brsa._loglike_AR1_diagV_fitU(x, XTX, XTDX, XTFX, YTY_diag, YTDY_diag, YTFY_diag, XTY, XTDY, XTFY, X0TX0, X0TDX0, X0TFX0, XTX0, XTDX0, XTFX0, X0TY, X0TDY, X0TFY, - np.log(snr)*2, l_idx, n_C, n_T, n_V, n_run, n_base, + np.log( + snr) * 2, l_idx, n_C, n_T, n_V, n_run, n_X0, idx_param_fitU, n_C)[0], param0_fitU, vec) - assert np.isclose(dd, np.dot(deriv0,vec), rtol=1e-5), 'gradient of fitU wrt to AR(1) coefficient incorrect' + assert np.isclose(dd, np.dot( + deriv0, vec), rtol=1e-5), 'gradient of fitU wrt to AR(1) coefficient incorrect' - # We test if the numerical and analytical gradient wrt to the first element of Cholesky factor is correct + # We test if the numerical and analytical gradient wrt to the first + # element of Cholesky factor is correct vec = np.zeros(np.size(param0_fitU)) vec[idx_param_fitU['Cholesky'][0]] = 1 dd = nd.directionaldiff(lambda x: brsa._loglike_AR1_diagV_fitU(x, XTX, XTDX, XTFX, YTY_diag, YTDY_diag, YTFY_diag, XTY, XTDY, XTFY, X0TX0, X0TDX0, X0TFX0, XTX0, XTDX0, XTFX0, X0TY, X0TDY, X0TFY, - np.log(snr)*2, l_idx, n_C, n_T, n_V, n_run,n_base, + np.log( + snr) * 2, l_idx, n_C, n_T, n_V, n_run, n_X0, idx_param_fitU, n_C)[0], param0_fitU, vec) - assert np.isclose(dd, np.dot(deriv0,vec), rtol=1e-5), 'gradient of fitU wrt Cholesky factor incorrect' - - - # We test if the numerical and analytical gradient wrt to the first element of Cholesky factor is correct - vec = np.zeros(np.size(param0_fitU)) - vec[idx_param_fitU['Cholesky'][0]] = 1 - dd = nd.directionaldiff(lambda x: brsa._loglike_AR1_diagV_fitU(x, XTX, XTDX, XTFX, YTY_diag, YTDY_diag, YTFY_diag, - XTY, XTDY, XTFY, X0TX0, X0TDX0, X0TFX0, - XTX0, XTDX0, XTFX0, X0TY, X0TDY, X0TFY, - np.log(snr)*2, l_idx, n_C, n_T, n_V, n_run,n_base, - idx_param_fitU, n_C)[0], param0_fitU, vec) - assert np.isclose(dd, np.dot(deriv0,vec), rtol=0.01), 'gradient of fitU wrt Cholesky factor incorrect' + assert np.isclose(dd, np.dot(deriv0, vec), + rtol=1e-5), 'gradient of fitU wrt Cholesky factor incorrect' # Test on a random direction vec = np.random.randn(np.size(param0_fitU)) vec = vec / np.linalg.norm(vec) dd = nd.directionaldiff(lambda x: brsa._loglike_AR1_diagV_fitU(x, XTX, XTDX, XTFX, YTY_diag, YTDY_diag, YTFY_diag, XTY, XTDY, XTFY, X0TX0, X0TDX0, X0TFX0, - XTX0, XTDX0, XTFX0, X0TY, X0TDY, X0TFY, - np.log(snr)*2, l_idx, n_C, n_T, n_V, n_run, n_base, + XTX0, XTDX0, XTFX0, X0TY, X0TDY, X0TFY, + np.log( + snr) * 2, l_idx, n_C, n_T, n_V, n_run, n_X0, idx_param_fitU, n_C)[0], param0_fitU, vec) - assert np.isclose(dd, np.dot(deriv0,vec), rtol=1e-5), 'gradient of fitU incorrect' - + assert np.isclose(dd, np.dot(deriv0, vec), + rtol=1e-5), 'gradient of fitU incorrect' # We test the gradient of _fitV wrt to log(SNR^2) assuming no GP prior. X0TAX0, XTAX0, X0TAY, X0TAX0_i, \ XTAcorrX, XTAcorrY, YTAcorrY, LTXTAcorrY, XTAcorrXL, LTXTAcorrXL = \ - brsa._calc_sandwidge(XTY, XTDY, XTFY, - YTY_diag, YTDY_diag, YTFY_diag, - XTX, XTDX, XTFX, - X0TX0, X0TDX0, X0TFX0, - XTX0, XTDX0, XTFX0, - X0TY, X0TDY, X0TFY, - L_full, rho1, n_V, n_base) - assert np.shape(XTAcorrX) == (n_V, n_C, n_C), 'Dimension of XTAcorrX is wrong by _calc_sandwidge()' - assert XTAcorrY.shape == XTY.shape, 'Shape of XTAcorrY is wrong by _calc_sandwidge()' - assert YTAcorrY.shape == YTY_diag.shape, 'Shape of YTAcorrY is wrong by _calc_sandwidge()' - assert np.shape(X0TAX0) == (n_V, n_base, n_base), 'Dimension of X0TAX0 is wrong by _calc_sandwidge()' - assert np.shape(XTAX0) == (n_V, n_C, n_base), 'Dimension of XTAX0 is wrong by _calc_sandwidge()' - assert X0TAY.shape == X0TY.shape, 'Shape of X0TAX0 is wrong by _calc_sandwidge()' - assert np.all(np.isfinite(X0TAX0_i)), 'Inverse of X0TAX0 includes NaN or Inf' + brsa._precompute_ar1_quad_forms(XTY, XTDY, XTFY, + YTY_diag, YTDY_diag, YTFY_diag, + XTX, XTDX, XTFX, + X0TX0, X0TDX0, X0TFX0, + XTX0, XTDX0, XTFX0, + X0TY, X0TDY, X0TFY, + L_full, rho1, n_V, n_X0) + assert np.shape(XTAcorrX) == ( + n_V, n_C, n_C), 'Dimension of XTAcorrX is wrong by _precompute_ar1_quad_forms()' + assert XTAcorrY.shape == XTY.shape, 'Shape of XTAcorrY is wrong by _precompute_ar1_quad_forms()' + assert YTAcorrY.shape == YTY_diag.shape, 'Shape of YTAcorrY is wrong by _precompute_ar1_quad_forms()' + assert np.shape(X0TAX0) == ( + n_V, n_X0, n_X0), 'Dimension of X0TAX0 is wrong by _precompute_ar1_quad_forms()' + assert np.shape(XTAX0) == ( + n_V, n_C, n_X0), 'Dimension of XTAX0 is wrong by _precompute_ar1_quad_forms()' + assert X0TAY.shape == X0TY.shape, 'Shape of X0TAX0 is wrong by _precompute_ar1_quad_forms()' + assert np.all(np.isfinite(X0TAX0_i) + ), 'Inverse of X0TAX0 includes NaN or Inf' ll0, deriv0 = brsa._loglike_AR1_diagV_fitV(param0_fitV[idx_param_fitV['log_SNR2']], X0TAX0, XTAX0, X0TAY, - X0TAX0_i, XTAcorrX, XTAcorrY, YTAcorrY, + X0TAX0_i, XTAcorrX, XTAcorrY, YTAcorrY, LTXTAcorrY, XTAcorrXL, LTXTAcorrXL, - L_full[l_idx], np.tan(rho1*np.pi/2), - l_idx,n_C,n_T,n_V,n_run,n_base, - idx_param_fitV,n_C,False,False) + L_full[l_idx], np.tan( + rho1 * np.pi / 2), + l_idx, n_C, n_T, n_V, n_run, n_X0, + idx_param_fitV, n_C, False, False) vec = np.zeros(np.size(param0_fitV[idx_param_fitV['log_SNR2']])) vec[idx_param_fitV['log_SNR2'][0]] = 1 dd = nd.directionaldiff(lambda x: brsa._loglike_AR1_diagV_fitV(x, X0TAX0, XTAX0, X0TAY, - X0TAX0_i, XTAcorrX, XTAcorrY, YTAcorrY, + X0TAX0_i, XTAcorrX, XTAcorrY, YTAcorrY, LTXTAcorrY, XTAcorrXL, LTXTAcorrXL, - L_full[l_idx], np.tan(rho1*np.pi/2), - l_idx, n_C, n_T, n_V, n_run, n_base, + L_full[l_idx], np.tan( + rho1 * np.pi / 2), + l_idx, n_C, n_T, n_V, n_run, n_X0, idx_param_fitV, n_C, False, False)[0], param0_fitV[idx_param_fitV['log_SNR2']], vec) - assert np.isclose(dd, np.dot(deriv0,vec), rtol=1e-5), 'gradient of fitV wrt log(SNR2) incorrect for model without GP' + assert np.isclose(dd, np.dot( + deriv0, vec), rtol=1e-5), 'gradient of fitV wrt log(SNR2) incorrect for model without GP' # We test the gradient of _fitV wrt to log(SNR^2) assuming GP prior. ll0, deriv0 = brsa._loglike_AR1_diagV_fitV(param0_fitV, X0TAX0, XTAX0, X0TAY, - X0TAX0_i, XTAcorrX, XTAcorrY, YTAcorrY, + X0TAX0_i, XTAcorrX, XTAcorrY, YTAcorrY, LTXTAcorrY, XTAcorrXL, LTXTAcorrXL, - L_full[l_idx], np.tan(rho1*np.pi/2), - l_idx,n_C,n_T,n_V,n_run,n_base, - idx_param_fitV,n_C,True,True, - dist2,inten_diff2,100,100) + L_full[l_idx], np.tan( + rho1 * np.pi / 2), + l_idx, n_C, n_T, n_V, n_run, n_X0, + idx_param_fitV, n_C, True, True, + dist2, inten_diff2, 100, 100) vec = np.zeros(np.size(param0_fitV)) vec[idx_param_fitV['log_SNR2'][0]] = 1 dd = nd.directionaldiff(lambda x: brsa._loglike_AR1_diagV_fitV(x, X0TAX0, XTAX0, X0TAY, - X0TAX0_i, XTAcorrX, XTAcorrY, YTAcorrY, + X0TAX0_i, XTAcorrX, XTAcorrY, YTAcorrY, LTXTAcorrY, XTAcorrXL, LTXTAcorrXL, - L_full[l_idx], np.tan(rho1*np.pi/2), - l_idx, n_C, n_T, n_V, n_run, n_base, + L_full[l_idx], np.tan( + rho1 * np.pi / 2), + l_idx, n_C, n_T, n_V, n_run, n_X0, idx_param_fitV, n_C, True, True, dist2, inten_diff2, 100, 100)[0], param0_fitV, vec) - assert np.isclose(dd, np.dot(deriv0,vec), rtol=1e-5), 'gradient of fitV srt log(SNR2) incorrect for model with GP' + assert np.isclose(dd, np.dot( + deriv0, vec), rtol=1e-5), 'gradient of fitV srt log(SNR2) incorrect for model with GP' # We test the graident wrt spatial length scale parameter of GP prior vec = np.zeros(np.size(param0_fitV)) vec[idx_param_fitV['c_space']] = 1 dd = nd.directionaldiff(lambda x: brsa._loglike_AR1_diagV_fitV(x, X0TAX0, XTAX0, X0TAY, - X0TAX0_i, XTAcorrX, XTAcorrY, YTAcorrY, + X0TAX0_i, XTAcorrX, XTAcorrY, YTAcorrY, LTXTAcorrY, XTAcorrXL, LTXTAcorrXL, - L_full[l_idx], np.tan(rho1*np.pi/2), - l_idx, n_C, n_T, n_V, n_run, n_base, + L_full[l_idx], np.tan( + rho1 * np.pi / 2), + l_idx, n_C, n_T, n_V, n_run, n_X0, idx_param_fitV, n_C, True, True, dist2, inten_diff2, 100, 100)[0], param0_fitV, vec) - assert np.isclose(dd, np.dot(deriv0,vec), rtol=1e-5), 'gradient of fitV wrt spatial length scale of GP incorrect' + assert np.isclose(dd, np.dot( + deriv0, vec), rtol=1e-5), 'gradient of fitV wrt spatial length scale of GP incorrect' # We test the graident wrt intensity length scale parameter of GP prior vec = np.zeros(np.size(param0_fitV)) vec[idx_param_fitV['c_inten']] = 1 dd = nd.directionaldiff(lambda x: brsa._loglike_AR1_diagV_fitV(x, X0TAX0, XTAX0, X0TAY, - X0TAX0_i, XTAcorrX, XTAcorrY, YTAcorrY, + X0TAX0_i, XTAcorrX, XTAcorrY, YTAcorrY, LTXTAcorrY, XTAcorrXL, LTXTAcorrXL, - L_full[l_idx], np.tan(rho1*np.pi/2), - l_idx, n_C, n_T, n_V, n_run, n_base, + L_full[l_idx], np.tan( + rho1 * np.pi / 2), + l_idx, n_C, n_T, n_V, n_run, n_X0, idx_param_fitV, n_C, True, True, dist2, inten_diff2, 100, 100)[0], param0_fitV, vec) - assert np.isclose(dd, np.dot(deriv0,vec), rtol=1e-5), 'gradient of fitV wrt intensity length scale of GP incorrect' + assert np.isclose(dd, np.dot( + deriv0, vec), rtol=1e-5), 'gradient of fitV wrt intensity length scale of GP incorrect' # We test the graident on a random direction vec = np.random.randn(np.size(param0_fitV)) vec = vec / np.linalg.norm(vec) dd = nd.directionaldiff(lambda x: brsa._loglike_AR1_diagV_fitV(x, X0TAX0, XTAX0, X0TAY, - X0TAX0_i, XTAcorrX, XTAcorrY, YTAcorrY, + X0TAX0_i, XTAcorrX, XTAcorrY, YTAcorrY, LTXTAcorrY, XTAcorrXL, LTXTAcorrXL, - L_full[l_idx], np.tan(rho1*np.pi/2), - l_idx, n_C, n_T, n_V, n_run, n_base, + L_full[l_idx], np.tan( + rho1 * np.pi / 2), + l_idx, n_C, n_T, n_V, n_run, n_X0, idx_param_fitV, n_C, True, True, dist2, inten_diff2, 100, 100)[0], param0_fitV, vec) - assert np.isclose(dd, np.dot(deriv0,vec), rtol=1e-5), 'gradient of fitV incorrect' + assert np.isclose(dd, np.dot(deriv0, vec), + rtol=1e-5), 'gradient of fitV incorrect' + + +def test_nureg_determine(): + from brainiak.reprsimil.brsa import Ncomp_SVHT_MG_DLD_approx + import numpy as np + x = np.dot(np.random.randn(100, 5), np.random.randn(5, 40)) + \ + np.random.randn(100, 40) * 0.01 + ncomp = Ncomp_SVHT_MG_DLD_approx(x) + assert ncomp >= 3 and ncomp <= 8, 'recovered number of components should be in a reasonable range' + + +def test_half_log_det(): + import numpy as np + from brainiak.reprsimil.brsa import BRSA + a = np.asarray([[1, 0.2], [0.2, 1]]) + brsa = BRSA() + half_log_det = np.log(np.linalg.det(a)) / 2 + assert np.isclose(half_log_det, brsa._half_log_det( + a)), 'half log determinant function is wrong' + + +def test_n_nureg(): + import brainiak.reprsimil.brsa + import numpy as np + # noise = np.random.randn(100,30) + noise = np.dot(np.random.randn(100, 8), np.random.randn( + 8, 30)) + np.random.randn(100, 30) * 0.01 + design = np.random.randn(100, 2) + s = brainiak.reprsimil.brsa.BRSA(n_iter=2) + s.fit(X=noise, design=design) + assert s.n_nureg_ > 2 and s.n_nureg_ < 16, 'n_nureg_ estimation is wrong in BRSA' diff --git a/tests/reprsimil/test_gbrsa.py b/tests/reprsimil/test_gbrsa.py new file mode 100644 index 000000000..a6b8d3e61 --- /dev/null +++ b/tests/reprsimil/test_gbrsa.py @@ -0,0 +1,592 @@ +# Copyright 2016 Mingbo Cai, Princeton Neuroscience Instititute, +# Princeton University +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from sklearn.utils.validation import NotFittedError +import pytest + + +def test_can_instantiate(): + import brainiak.reprsimil.brsa + import numpy as np + s = brainiak.reprsimil.brsa.GBRSA() + assert s, "Invalid GBRSA instance!" + + s = brainiak.reprsimil.brsa.GBRSA(n_iter=40, rank=4, + auto_nuisance=False, nureg_method='PCA', + baseline_single=False, logS_range=1.0, SNR_bins=11, + rho_bins=40, tol=2e-3, + optimizer='CG', random_state=0, anneal_speed=20, SNR_prior='unif') + assert s, "Invalid GBRSA instance!" + + +def test_fit(): + from brainiak.reprsimil.brsa import GBRSA + import brainiak.utils.utils as utils + import scipy.stats + import numpy as np + import os.path + np.random.seed(10) + file_path = os.path.join(os.path.dirname(__file__), "example_design.1D") + # Load an example design matrix + design = utils.ReadDesign(fname=file_path) + + # concatenate it by 1, 2, and 3 times, mimicking different length + # of experiments for different participants + n_run = [2, 1, 1] + design_mat = [None] * 3 + n_T = [None] * 3 + n_V = [40, 60, 60] + for i in range(3): + design_mat[i] = np.tile(design.design_task[:, :-1], [n_run[i], 1]) + n_T[i] = n_run[i] * design.n_TR + + # start simulating some data + n_C = np.size(design_mat[0], axis=1) + + noise_bot = 0.5 + noise_top = 1.5 + noise_level = [None] * 3 + for i in range(3): + noise_level[i] = np.random.rand( + n_V[i]) * (noise_top - noise_bot) + noise_bot + # noise level is random. + + # AR(1) coefficient + rho1_top = 0.8 + rho1_bot = -0.2 + rho1 = [None] * 3 + for i in range(3): + rho1[i] = np.random.rand(n_V[i]) * (rho1_top - rho1_bot) + rho1_bot + + # generating noise + noise = [None] * 3 + for i in range(3): + noise[i] = np.zeros([n_T[i], n_V[i]]) + noise[i][0, :] = np.random.randn( + n_V[i]) * noise_level[i] / np.sqrt(1 - rho1[i]**2) + for i_t in range(1, n_T[i]): + noise[i][i_t, :] = noise[i][i_t - 1, :] * rho1[i] + \ + np.random.randn(n_V[i]) * noise_level[i] + noise[i] = noise[i] + \ + np.dot(np.random.randn(n_T[i], 2), np.random.randn(2, n_V[i])) + # baseline + inten = [None] * 3 + for i in range(3): + inten[i] = np.random.rand(n_V[i]) * 20.0 + + # ideal covariance matrix + ideal_cov = np.zeros([n_C, n_C]) + ideal_cov = np.eye(n_C) * 0.6 + ideal_cov[0:4, 0:4] = 0.2 + for cond in range(0, 4): + ideal_cov[cond, cond] = 2 + ideal_cov[5:9, 5:9] = 0.9 + for cond in range(5, 9): + ideal_cov[cond, cond] = 1 + idx = np.where(np.sum(np.abs(ideal_cov), axis=0) > 0)[0] + L_full = np.linalg.cholesky(ideal_cov) + + # generating signal + snr_top = 5.0 # test with high SNR + snr_bot = 1.0 + # snr = np.random.rand(n_V)*(snr_top-snr_bot)+snr_bot + # Notice that accurately speaking this is not snr. the magnitude of signal depends + # not only on beta but also on x. + + snr = [None] * 3 + signal = [None] * 3 + betas_simulated = [None] * 3 + scan_onsets = [None] * 3 + Y = [None] * 3 + for i in range(3): + snr[i] = np.random.rand(n_V[i]) * (snr_top - snr_bot) + snr_bot + sqrt_v = noise_level[i] * snr[i] + betas_simulated[i] = np.dot( + L_full, np.random.randn(n_C, n_V[i])) * sqrt_v + signal[i] = np.dot(design_mat[i], betas_simulated[i]) + + # Adding noise to signal as data + Y[i] = signal[i] + noise[i] + inten[i] + + scan_onsets[i] = np.linspace(0, n_T[i], num=n_run[i] + 1) + + # Test fitting. + n_nureg = 2 + gbrsa = GBRSA(n_iter=15, auto_nuisance=True, logS_range=0.5, SNR_bins=11, rho_bins=16, n_nureg=n_nureg, + optimizer='L-BFGS-B') + + gbrsa.fit(X=Y, design=design_mat, scan_onsets=scan_onsets) + + # Check that result is significantly correlated with the ideal covariance + # matrix + u_b = gbrsa.U_ + u_i = ideal_cov + p = scipy.stats.spearmanr(u_b[np.tril_indices_from(u_b)], + u_i[np.tril_indices_from(u_i)])[1] + assert p < 0.01, "Fitted covariance matrix does not correlate with ideal covariance matrix!" + # check that the recovered SNRs makes sense + p = scipy.stats.pearsonr(gbrsa.nSNR_[0], snr[0])[1] + assert p < 0.01, "Fitted SNR does not correlate with simulated SNR!" + p = scipy.stats.pearsonr(gbrsa.sigma_[1], noise_level[1])[1] + assert p < 0.01, "Fitted noise level does not correlate with simulated noise level!" + p = scipy.stats.pearsonr(gbrsa.rho_[2], rho1[2])[1] + assert p < 0.01, "Fitted AR(1) coefficient does not correlate with simulated values!" + assert np.shape(gbrsa.X0_[1]) == (n_T[1], n_nureg + 1), "Wrong size of X0" + + Y_new = [None] * 3 + noise_new = [None] * 3 + for i in range(3): + noise_new[i] = np.zeros([n_T[i], n_V[i]]) + noise_new[i][0, :] = np.random.randn( + n_V[i]) * noise_level[i] / np.sqrt(1 - rho1[i]**2) + for i_t in range(1, n_T[i]): + noise_new[i][i_t, :] = noise_new[i][i_t - 1, :] * \ + rho1[i] + np.random.randn(n_V[i]) * noise_level[i] + + Y_new[i] = signal[i] + noise_new[i] + inten[i] + ts, ts0 = gbrsa.transform(Y_new, scan_onsets=scan_onsets) + [score, score_null] = gbrsa.score( + X=Y_new, design=design_mat, scan_onsets=scan_onsets) + [score_noise, score_null_noise] = gbrsa.score( + X=noise_new, design=design_mat, scan_onsets=scan_onsets) + for i in range(3): + assert np.shape(ts[i]) == (n_T[i], n_C) and np.shape( + ts0[i]) == (n_T[i], n_nureg + 1) + p = scipy.stats.pearsonr(ts[i][:, 0], design_mat[i][:, 0])[1] + assert p < 0.01, "Recovered time series does not correlate with true time series!" + + assert score[i] > score_null[ + i], "Full model does not win over null model on data containing signal" + + assert score_noise[i] < score_null_noise[ + i], "Null model does not win over full model on data without signal" + + [score, score_null] = gbrsa.score( + X=[None] * 3, design=design_mat, scan_onsets=scan_onsets) + assert score == [None] * 3 and score_null == [None] * \ + 3, "score did not return list of None when data is None" + ts, ts0 = gbrsa.transform(X=[None] * 3, scan_onsets=scan_onsets) + assert ts == [None] * 3 and ts0 == [None] * \ + 3, "transform did not return list of None when data is None" + + +def test_gradient(): + from brainiak.reprsimil.brsa import GBRSA + import brainiak.utils.utils as utils + import scipy.stats + import numpy as np + import os.path + import numdifftools as nd + + np.random.seed(100) + file_path = os.path.join(os.path.dirname(__file__), "example_design.1D") + # Load an example design matrix + design = utils.ReadDesign(fname=file_path) + + # concatenate it by 1, 2, and 3 times, mimicking different length + # of experiments for different participants + n_run = [1, 2, 1] + design_mat = [None] * 3 + n_T = [None] * 3 + n_V = [30, 30, 20] + for i in range(3): + design_mat[i] = np.tile(design.design_task[:, :-1], [n_run[i], 1]) + n_T[i] = n_run[i] * design.n_TR + + # start simulating some data + n_C = np.size(design_mat[0], axis=1) + + noise_bot = 0.5 + noise_top = 1.5 + noise_level = [None] * 3 + for i in range(3): + noise_level[i] = np.random.rand( + n_V[i]) * (noise_top - noise_bot) + noise_bot + # noise level is random. + + # AR(1) coefficient + rho1_top = 0.8 + rho1_bot = -0.2 + rho1 = [None] * 3 + for i in range(3): + rho1[i] = np.random.rand(n_V[i]) * (rho1_top - rho1_bot) + rho1_bot + + # generating noise + noise = [None] * 3 + for i in range(3): + noise[i] = np.zeros([n_T[i], n_V[i]]) + noise[i][0, :] = np.random.randn( + n_V[i]) * noise_level[i] / np.sqrt(1 - rho1[i]**2) + for i_t in range(1, n_T[i]): + noise[i][i_t, :] = noise[i][i_t - 1, :] * rho1[i] + \ + np.random.randn(n_V[i]) * noise_level[i] + noise[i] = noise[i] + \ + np.dot(np.random.randn(n_T[i], 2), np.random.randn(2, n_V[i])) + # baseline + inten = [None] * 3 + for i in range(3): + inten[i] = np.random.rand(n_V[i]) * 20.0 + + # ideal covariance matrix + ideal_cov = np.zeros([n_C, n_C]) + ideal_cov = np.eye(n_C) * 0.6 + ideal_cov[0:4, 0:4] = 0.2 + for cond in range(0, 4): + ideal_cov[cond, cond] = 2 + ideal_cov[5:9, 5:9] = 0.9 + for cond in range(5, 9): + ideal_cov[cond, cond] = 1 + idx = np.where(np.sum(np.abs(ideal_cov), axis=0) > 0)[0] + L_full = np.linalg.cholesky(ideal_cov) + + # generating signal + snr_top = 5.0 # test with high SNR + snr_bot = 1.0 + # snr = np.random.rand(n_V)*(snr_top-snr_bot)+snr_bot + # Notice that accurately speaking this is not snr. the magnitude of signal depends + # not only on beta but also on x. + + snr = [None] * 3 + signal = [None] * 3 + betas_simulated = [None] * 3 + scan_onsets = [None] * 3 + Y = [None] * 3 + for i in range(3): + snr[i] = np.random.rand(n_V[i]) * (snr_top - snr_bot) + snr_bot + sqrt_v = noise_level[i] * snr[i] + betas_simulated[i] = np.dot( + L_full, np.random.randn(n_C, n_V[i])) * sqrt_v + signal[i] = np.dot(design_mat[i], betas_simulated[i]) + + # Adding noise to signal as data + Y[i] = signal[i] + noise[i] + inten[i] + + scan_onsets[i] = np.linspace(0, n_T[i], num=n_run[i] + 1) + + # Get some initial fitting. + SNR_bins = 11 + rho_bins = 20 + gbrsa = GBRSA(n_iter=3, rank=n_C, SNR_bins=SNR_bins, + rho_bins=rho_bins, logS_range=0.5) + + n_grid = SNR_bins * rho_bins + half_log_det_X0TAX0 = [np.random.randn(n_grid) for i in range(3)] + log_weights = np.random.randn(n_grid) + log_fixed_terms = [np.random.randn(n_grid) for i in range(3)] + l_idx = np.tril_indices(n_C) + L_vec = np.random.randn(int(n_C * (n_C + 1) / 2)) + n_X0 = [2, 2, 2] + s = np.linspace(1, SNR_bins, n_grid) + a = np.linspace(0.5, 1, n_grid) + s2XTAcorrX = [None] * 3 + YTAcorrY_diag = [None] * 3 + sXTAcorrY = [None] * 3 + XTX = [None] * 3 + # The calculations below are quite arbitrary and do not conform + # to the model. They simply conform to the symmetry property and shape of + # the matrix indicated by the model + for i in range(3): + YTAcorrY_diag[i] = np.sum(Y[i] * Y[i], axis=0) * a[:, None] + s2XTAcorrX[i] = np.dot(design_mat[i].T, design_mat[ + i]) * s[:, None, None]**2 * a[:, None, None] + sXTAcorrY[i] = np.dot(design_mat[i].T, Y[i]) * \ + s[:, None, None] * a[:, None, None] + + # test if the gradients are correct + print(log_fixed_terms) + ll0, deriv0 = gbrsa._sum_loglike_marginalized(L_vec, s2XTAcorrX, YTAcorrY_diag, + sXTAcorrY, half_log_det_X0TAX0, + log_weights, log_fixed_terms, + l_idx, n_C, n_T, n_V, n_X0, + n_grid, rank=None) + # We test the gradient to the Cholesky factor + vec = np.random.randn(np.size(L_vec)) + vec = vec / np.linalg.norm(vec) + dd = nd.directionaldiff(lambda x: gbrsa._sum_loglike_marginalized(x, s2XTAcorrX, YTAcorrY_diag, + sXTAcorrY, half_log_det_X0TAX0, + log_weights, log_fixed_terms, + l_idx, n_C, n_T, n_V, n_X0, + n_grid, rank=None)[0], + L_vec, vec) + assert np.isclose(dd, np.dot(deriv0, vec), rtol=1e-5), 'gradient incorrect' + + +def test_SNR_grids(): + import brainiak.reprsimil.brsa + import numpy as np + + s = brainiak.reprsimil.brsa.GBRSA(SNR_prior='unif', SNR_bins=10) + SNR_grids, SNR_weights = s._set_SNR_grids() + assert np.isclose(np.sum(SNR_weights), 1) and np.isclose(np.std(SNR_weights[1:-1]), 0) and np.all(SNR_weights > 0)\ + and np.isclose(np.min(SNR_grids), 0) and np.all(SNR_grids >= 0) and np.isclose(np.max(SNR_grids), 1), \ + 'SNR_weights or SNR_grids are incorrect for uniform prior' + assert np.isclose(np.ptp(np.diff(SNR_grids[1:-1])), 0), \ + 'SNR grids are not equally spaced for uniform prior' + assert np.size(SNR_grids) == np.size(SNR_weights) and np.size(SNR_grids) == 10 ,\ + 'size of SNR_grids or SNR_weights is not correct for uniform prior' + + + s = brainiak.reprsimil.brsa.GBRSA(SNR_prior='lognorm', SNR_bins=35) + SNR_grids, SNR_weights = s._set_SNR_grids() + assert np.all(SNR_grids >= 0) and np.isclose(np.sum(SNR_weights), 1) and np.all(SNR_weights > 0) \ + and np.isclose(np.min(SNR_grids), 0) and np.all(np.diff(SNR_grids) > 0), \ + 'SNR_grids or SNR_weights not correct for log normal prior' + + + s = brainiak.reprsimil.brsa.GBRSA(SNR_prior='exp') + SNR_grids, SNR_weights = s._set_SNR_grids() + assert np.all(SNR_grids >= 0) and np.isclose(np.sum(SNR_weights), 1) and np.all(SNR_weights > 0) \ + and np.all(np.diff(SNR_grids) > 0), \ + 'SNR_grids or SNR_weights not correct for exponential prior' + + +def test_n_nureg(): + import brainiak.reprsimil.brsa + import numpy as np + noise = np.dot(np.random.randn(100, 8), np.random.randn( + 8, 30)) + np.random.randn(100, 30) * 0.001 + design = np.random.randn(100, 2) + s = brainiak.reprsimil.brsa.GBRSA(n_iter=2) + s.fit(X=noise, design=design) + assert s.n_nureg_[0] == 8, 'n_nureg_ estimation is wrong in GBRSA' + +def test_grid_flatten_num_int(): + # Check for numeric integration of SNR, and correctly flattening 2-D grids + # to 1-D grid. + import brainiak.reprsimil.brsa + import brainiak.utils.utils as utils + import numpy as np + import scipy.special + n_V = 30 + n_T = 50 + n_C = 3 + design = np.random.randn(n_T, n_C) + U_simu = np.asarray([[1.0, 0.1, 0.0],[0.1, 1.0, 0.2],[0.0, 0.2, 1.0]]) + L_simu = np.linalg.cholesky(U_simu) + SNR = np.random.exponential(size=n_V) + beta = np.dot(L_simu, np.random.randn(n_C, n_V)) * SNR + noise = np.random.randn(n_T, n_V) + Y = np.dot(design, beta) + noise + X = design + X_base = None + scan_onsets = [0] + + s = brainiak.reprsimil.brsa.GBRSA(n_iter=1, auto_nuisance=False, SNR_prior='exp') + s.fit(X=[Y], design=[design]) + rank = n_C + l_idx, rank = s._chol_idx(n_C, rank) + L = np.zeros((n_C, rank)) + n_l = np.size(l_idx[0]) + current_vec_U_chlsk_l = s.random_state_.randn(n_l) * 10 + L[l_idx] = current_vec_U_chlsk_l + + # Now we change the grids for SNR and rho for testing. + s.SNR_bins = 2 + s.rho_bins = 2 + SNR_grids, SNR_weights = s._set_SNR_grids() + # rho_grids, rho_weights = s._set_rho_grids() + rho_grids = np.ones(2) * 0.1 + rho_weights = np.ones(2) / 2 + # We purposefully set all rhos to be equal to test flattening of + # grids. + n_grid = s.SNR_bins * s.rho_bins + + + D, F, run_TRs, n_run = s._prepare_DF( + n_T, scan_onsets=scan_onsets) + XTY, XTDY, XTFY, YTY_diag, YTDY_diag, YTFY_diag, XTX, XTDX, XTFX \ + =s._prepare_data_XY( X, Y, D, F) + X0TX0, X0TDX0, X0TFX0, XTX0, XTDX0, XTFX0, X0TY, X0TDY, X0TFY, X0, \ + X_base, n_X0, idx_DC = s._prepare_data_XYX0( + X, Y, X_base, None, D, F, run_TRs, no_DC=False) + + X0TAX0, X0TAX0_i, XTAcorrX, XTAcorrY, YTAcorrY_diag, X0TAY, XTAX0 \ + = s._precompute_ar1_quad_forms_marginalized( + XTY, XTDY, XTFY, YTY_diag, YTDY_diag, YTFY_diag, XTX, + XTDX, XTFX, X0TX0, X0TDX0, X0TFX0, XTX0, XTDX0, XTFX0, + X0TY, X0TDY, X0TFY, rho_grids, n_V, n_X0) + + half_log_det_X0TAX0, X0TAX0, X0TAX0_i, s2XTAcorrX, YTAcorrY_diag, \ + sXTAcorrY, X0TAY, XTAX0 = s._matrix_flattened_grid( + X0TAX0, X0TAX0_i, SNR_grids, XTAcorrX, YTAcorrY_diag, XTAcorrY, + X0TAY, XTAX0, n_C, n_V, n_X0, n_grid) + + assert half_log_det_X0TAX0[0] == half_log_det_X0TAX0[1] and \ + half_log_det_X0TAX0[2] == half_log_det_X0TAX0[3] and \ + half_log_det_X0TAX0[0] == half_log_det_X0TAX0[2], '_matrix_flattened_grid has mistake with half_log_det_X0TAX0' + assert np.array_equal(X0TAX0[0, :, :], X0TAX0[1, :, :]) and \ + np.array_equal(X0TAX0[2, :, :], X0TAX0[3, :, :]) and \ + np.array_equal(X0TAX0[0, :, :], X0TAX0[2, :, :]), '_matrix_flattened_grid has mistake X0TAX0' + assert np.array_equal(X0TAX0_i[0, :, :], X0TAX0_i[1, :, :]) and \ + np.array_equal(X0TAX0_i[2, :, :], X0TAX0_i[3, :, :]) and \ + np.array_equal(X0TAX0_i[0, :, :], X0TAX0_i[2, :, :]), '_matrix_flattened_grid has mistake X0TAX0_i' + assert np.allclose(np.dot(X0TAX0[0, :, :], X0TAX0_i[0, :, :]), np.eye(n_X0)), 'X0TAX0_i is not inverse of X0TAX0' + assert np.array_equal(YTAcorrY_diag[0, :], YTAcorrY_diag[1, :]) and \ + np.array_equal(YTAcorrY_diag[2, :], YTAcorrY_diag[3, :]) and \ + np.array_equal(YTAcorrY_diag[0, :], YTAcorrY_diag[2, :]), '_matrix_flattened_grid has mistake YTAcorrY_diag' + assert np.array_equal(sXTAcorrY[0, :, :], sXTAcorrY[1, :, :]) and \ + np.array_equal(sXTAcorrY[2, :, :], sXTAcorrY[3, :, :]) and \ + not np.array_equal(sXTAcorrY[0, :, :], sXTAcorrY[2, :, :]), '_matrix_flattened_grid has mistake sXTAcorrY' + assert np.array_equal(X0TAY[0, :, :], X0TAY[1, :, :]) and \ + np.array_equal(X0TAY[2, :, :], X0TAY[3, :, :]) and \ + np.array_equal(X0TAY[0, :, :], X0TAY[2, :, :]), '_matrix_flattened_grid has mistake X0TAY' + assert np.array_equal(XTAX0[0, :, :], XTAX0[1, :, :]) and \ + np.array_equal(XTAX0[2, :, :], XTAX0[3, :, :]) and \ + np.array_equal(XTAX0[0, :, :], XTAX0[2, :, :]), '_matrix_flattened_grid has mistake XTAX0' + + + # Now we test the other way + rho_grids, rho_weights = s._set_rho_grids() + # rho_grids, rho_weights = s._set_rho_grids() + SNR_grids = np.ones(2) * 0.1 + SNR_weights = np.ones(2) / 2 + # We purposefully set all SNR to be equal to test flattening of + # grids. + X0TAX0, X0TAX0_i, XTAcorrX, XTAcorrY, YTAcorrY_diag, X0TAY, XTAX0 \ + = s._precompute_ar1_quad_forms_marginalized( + XTY, XTDY, XTFY, YTY_diag, YTDY_diag, YTFY_diag, XTX, + XTDX, XTFX, X0TX0, X0TDX0, X0TFX0, XTX0, XTDX0, XTFX0, + X0TY, X0TDY, X0TFY, rho_grids, n_V, n_X0) + + half_log_det_X0TAX0, X0TAX0, X0TAX0_i, s2XTAcorrX, YTAcorrY_diag, \ + sXTAcorrY, X0TAY, XTAX0 = s._matrix_flattened_grid( + X0TAX0, X0TAX0_i, SNR_grids, XTAcorrX, YTAcorrY_diag, XTAcorrY, + X0TAY, XTAX0, n_C, n_V, n_X0, n_grid) + + assert half_log_det_X0TAX0[0] == half_log_det_X0TAX0[2] and \ + half_log_det_X0TAX0[1] == half_log_det_X0TAX0[3] and \ + not half_log_det_X0TAX0[0] == half_log_det_X0TAX0[1], '_matrix_flattened_grid has mistake with half_log_det_X0TAX0' + assert np.array_equal(X0TAX0[0, :, :], X0TAX0[2, :, :]) and \ + np.array_equal(X0TAX0[1, :, :], X0TAX0[3, :, :]) and \ + not np.array_equal(X0TAX0[0, :, :], X0TAX0[1, :, :]), '_matrix_flattened_grid has mistake X0TAX0' + assert np.array_equal(X0TAX0_i[0, :, :], X0TAX0_i[2, :, :]) and \ + np.array_equal(X0TAX0_i[1, :, :], X0TAX0_i[3, :, :]) and \ + not np.array_equal(X0TAX0_i[0, :, :], X0TAX0_i[1, :, :]), '_matrix_flattened_grid has mistake X0TAX0_i' + assert np.allclose(np.dot(X0TAX0[0, :, :], X0TAX0_i[0, :, :]), np.eye(n_X0)), 'X0TAX0_i is not inverse of X0TAX0' + assert np.array_equal(YTAcorrY_diag[0, :], YTAcorrY_diag[2, :]) and \ + np.array_equal(YTAcorrY_diag[1, :], YTAcorrY_diag[3, :]) and \ + not np.array_equal(YTAcorrY_diag[0, :], YTAcorrY_diag[1, :]), '_matrix_flattened_grid has mistake YTAcorrY_diag' + assert np.array_equal(sXTAcorrY[0, :, :], sXTAcorrY[2, :, :]) and \ + np.array_equal(sXTAcorrY[1, :, :], sXTAcorrY[3, :, :]) and \ + not np.array_equal(sXTAcorrY[0, :, :], sXTAcorrY[1, :, :]), '_matrix_flattened_grid has mistake sXTAcorrY' + assert np.array_equal(X0TAY[0, :, :], X0TAY[2, :, :]) and \ + np.array_equal(X0TAY[1, :, :], X0TAY[3, :, :]) and \ + not np.array_equal(X0TAY[0, :, :], X0TAY[1, :, :]), '_matrix_flattened_grid has mistake X0TAY' + assert np.array_equal(XTAX0[0, :, :], XTAX0[2, :, :]) and \ + np.array_equal(XTAX0[1, :, :], XTAX0[3, :, :]) and \ + not np.array_equal(XTAX0[0, :, :], XTAX0[1, :, :]), '_matrix_flattened_grid has mistake XTAX0' + + + # Now test the integration over SNR + s.SNR_bins = 50 + s.rho_bins = 1 + SNR_grids, SNR_weights = s._set_SNR_grids() + rho_grids, rho_weights = s._set_rho_grids() + n_grid = s.SNR_bins * s.rho_bins + + def setup_for_test(): + # This function will be re-used to set up the variables necessary for testing. + + X0TAX0, X0TAX0_i, XTAcorrX, XTAcorrY, YTAcorrY_diag, X0TAY, XTAX0 \ + = s._precompute_ar1_quad_forms_marginalized( + XTY, XTDY, XTFY, YTY_diag, YTDY_diag, YTFY_diag, XTX, + XTDX, XTFX, X0TX0, X0TDX0, X0TFX0, XTX0, XTDX0, XTFX0, + X0TY, X0TDY, X0TFY, rho_grids, n_V, n_X0) + + half_log_det_X0TAX0, X0TAX0, X0TAX0_i, s2XTAcorrX, YTAcorrY_diag, \ + sXTAcorrY, X0TAY, XTAX0 = s._matrix_flattened_grid( + X0TAX0, X0TAX0_i, SNR_grids, XTAcorrX, YTAcorrY_diag, XTAcorrY, + X0TAY, XTAX0, n_C, n_V, n_X0, n_grid) + + log_weights = np.reshape( + np.log(SNR_weights[:, None]) + np.log(rho_weights), n_grid) + all_rho_grids = np.reshape(np.repeat( + rho_grids[None, :], s.SNR_bins, axis=0), n_grid) + all_SNR_grids = np.reshape(np.repeat( + SNR_grids[:, None], s.rho_bins, axis=1), n_grid) + log_fixed_terms = - (n_T - n_X0) / 2 * np.log(2 * np.pi) + n_run \ + / 2 * np.log(1 - all_rho_grids**2) + scipy.special.gammaln( + (n_T - n_X0 - 2) / 2) + (n_T - n_X0 - 2) / 2 * np.log(2) + return s2XTAcorrX, YTAcorrY_diag, sXTAcorrY, half_log_det_X0TAX0, \ + log_weights, log_fixed_terms + + s2XTAcorrX, YTAcorrY_diag, sXTAcorrY, half_log_det_X0TAX0, \ + log_weights, log_fixed_terms = setup_for_test() + LL_total, _ = s._loglike_marginalized(current_vec_U_chlsk_l, s2XTAcorrX, + YTAcorrY_diag, sXTAcorrY, half_log_det_X0TAX0, + log_weights, log_fixed_terms, + l_idx, n_C, n_T, n_V, n_X0, + n_grid, rank=rank) + LL_total = - LL_total + # Now we re-calculate using scipy.integrate + s.SNR_bins = 100 + SNR_grids = np.linspace(0,12,s.SNR_bins) + SNR_weights = np.exp(- SNR_grids) + SNR_weights = SNR_weights / np.sum(SNR_weights) + n_grid = s.SNR_bins * s.rho_bins + + s2XTAcorrX, YTAcorrY_diag, sXTAcorrY, half_log_det_X0TAX0, \ + log_weights, log_fixed_terms = setup_for_test() + LL_raw, _, _, _ = s._raw_loglike_grids(L, s2XTAcorrX, YTAcorrY_diag, + sXTAcorrY, half_log_det_X0TAX0, + log_weights, log_fixed_terms, + n_C, n_T, n_V, n_X0, + n_grid, rank) + result_sum, max_value, result_exp = utils.sumexp_stable(LL_raw) + scipy_sum = scipy.integrate.simps(y=result_exp, axis=0) + LL_total_scipy = np.sum(np.log(scipy_sum) + max_value) + + tol = 1e-3 + assert(np.isclose(LL_total_scipy, LL_total, rtol=tol)), \ + 'Error of log likelihood calculation exceeds the tolerance' + + + # Now test the log normal prior + s = brainiak.reprsimil.brsa.GBRSA(n_iter=1, auto_nuisance=False, SNR_prior='lognorm') + s.SNR_bins = 50 + s.rho_bins = 1 + SNR_grids, SNR_weights = s._set_SNR_grids() + rho_grids, rho_weights = s._set_rho_grids() + n_grid = s.SNR_bins * s.rho_bins + + s2XTAcorrX, YTAcorrY_diag, sXTAcorrY, half_log_det_X0TAX0, \ + log_weights, log_fixed_terms = setup_for_test() + LL_total, _ = s._loglike_marginalized(current_vec_U_chlsk_l, s2XTAcorrX, + YTAcorrY_diag, sXTAcorrY, half_log_det_X0TAX0, + log_weights, log_fixed_terms, + l_idx, n_C, n_T, n_V, n_X0, + n_grid, rank=rank) + LL_total = - LL_total + # Now we re-calculate using scipy.integrate + s.SNR_bins = 400 + SNR_grids = np.linspace(1e-8,20,s.SNR_bins) + log_SNR_weights = scipy.stats.lognorm.logpdf(SNR_grids,s=s.logS_range) + result_sum, max_value, result_exp = utils.sumexp_stable(log_SNR_weights[:, None]) + SNR_weights = np.squeeze(result_exp / result_sum) + n_grid = s.SNR_bins * s.rho_bins + + s2XTAcorrX, YTAcorrY_diag, sXTAcorrY, half_log_det_X0TAX0, \ + log_weights, log_fixed_terms = setup_for_test() + LL_raw, _, _, _ = s._raw_loglike_grids(L, s2XTAcorrX, YTAcorrY_diag, + sXTAcorrY, half_log_det_X0TAX0, + log_weights, log_fixed_terms, + n_C, n_T, n_V, n_X0, + n_grid, rank) + result_sum, max_value, result_exp = utils.sumexp_stable(LL_raw) + scipy_sum = scipy.integrate.simps(y=result_exp, axis=0) + LL_total_scipy = np.sum(np.log(scipy_sum) + max_value) + + tol = 1e-3 + assert(np.isclose(LL_total_scipy, LL_total, rtol=tol)), \ + 'Error of log likelihood calculation exceeds the tolerance' \ No newline at end of file