Source code for eigenstrapping.base

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Eigenmode resampling on the cortex
"""

from .utils import (
    _get_eigengroups,
    eigen_decomposition,
    transform_to_spheroid,
    transform_to_ellipsoid,
    normalize_data,
    )

import os

from .geometry import (calc_surface_eigenmodes, 
                       get_tkrvox2ras,
                       make_tetra,
                       spin_single,
                       gen_eigensamples,
                       truncate_emodes)

import copy

import numpy as np
import matplotlib.pyplot as plt
from matplotlib import cm
from nilearn import plotting, masking
from .dataio import is_string_like, dataio, load_surface
import tempfile
from joblib import Parallel, delayed
from sklearn.utils.validation import check_random_state
from scipy.stats import normaltest
import nibabel as nib
from lapy import TetMesh, Solver
from scipy.interpolate import griddata
from scipy.spatial.distance import cdist
from sklearn.linear_model import LinearRegression
from .rotations import rotate_matrix

cmap = plt.get_cmap('viridis')

norm_types = ['volume', 'constant', 'area', 'number']

global methods
methods = [
    'matrix',
    'regression',
    ]

def eigengroup_wavelength(mode, radius=60.0, group=False):
    """
    Returns the approximate wavelength of a mode given a radius of the surface
    (radii are approximated for non-spherical surfaces, default 60mm). Radius is 
    specified in mm. Based on the degenerate solution of the Laplace-Beltrami
    operator on a sphere in [1]:
        
                        2 * pi * radius
        wavelength ~= ---------------------
                      [ l ( l + 1 ) ] ^ 1/2

    Parameters
    ----------
    mode : int
        Mode at which to return the (approximate) wavelength.
    radius : float, default 60.0
        The (approximate) radius of the surface, measured in millimeters.
    group : bool, default False
        Return the group membership of `mode`.

    Returns
    -------
    float
        Approximate wavelength of `mode` in mm.
    if group is True: int
        Group membership of `mode`

    """
    if mode < 0 or type(mode) != int:
        raise ValueError('Input mode must be an integer zero or greater')
    if mode == 0:
        l = 0
        print('Wavelength of zeroth mode is infinity.')
        if group:
            return l
    
    total = 0
    l = 0
    while total < mode:
        l += 1
        total += 2 * l + 1
    
    wavelength = 2 * np.pi * radius / np.sqrt(l * (l + 1))  
    if group:
        return l, wavelength
    
    return wavelength

def compute_psd(data, emodes):
    """Compute normalized power spectral density using emodes."""
    mask = np.isnan(data)
    coeffs = eigen_decomposition(data[~mask], emodes[~mask])
    power = np.abs(coeffs)**2
    normalized_power = power/np.sum(power)
    return normalized_power

def gram_schmidt_randomized(A, rs=None):
    """
    Orthogonalize a set of vectors stored as the columns of matrix A.
    Randomize the order of orthonormalization to explore different parameter
    spaces.
    """
    # get the number of vectors
    n = A.shape[1]
    
    # Randomize the column order
    if rs is None:
        rs = check_random_state(rs)
    random_order = rs.permutation(n)
    
    B = copy.deepcopy(A)
    B = B[:, random_order]
    
    for j in range(n):
        # To orthogonalize the vector in column j with respect to the
        # previous vectors, subtract from it its projection onto
        # each of the previous vectors
        for k in range(j):
            B[:, j] -= np.dot(B[:, k], B[:, j]) *B[:, k]
        
        B[:, j] = B[:, j] / np.linalg.norm(B[:, j])
        
    # reverse the randomization to original order
    reverse_order = np.argsort(random_order)
    A = B[:, reverse_order]
        
    return A

def is_gaussian_distribution(data, significance_level=0.05):
    """
    Check if the given data matches a Gaussian distribution using a normality test.

    Parameters:
        data (array-like): The data to be tested.
        significance_level (float, optional): The significance level for the test. Default is 0.05.

    Returns:
        bool: True if the data is consistent with a Gaussian distribution, False otherwise.
    """
    
    data = normalize_data(data)
    _, p_value = normaltest(data)
    if p_value >= significance_level:
        return True

    return False

[docs] class SurfaceEigenstrapping: """ Compute the eigenmodes and eigenvalues of the surface in `surface` and resample the hypersphere bounded by the eigengroups contained within `emodes`, to reconstruct the data using coefficients conditioned on the original data Based on the degenerate solutions of solving the Laplace-Beltrami operator on the cortex. The power spectrum is perfectly retained (the square of the eigenvalues). If evals and emodes are given (i.e., precomputed) then eigenmodes are not computed. Performs amplitude adjustment by default (see `resample`) @author: Nikitas C. Koussis, School of Psychological Sciences, University of Newcastle & Systems Neuroscience Group Newcastle Michael Breakspear, School of Psychological Sciences, University of Newcastle & Systems Neuroscience Group Newcastle Process ------- - the orthonormal eigenvectors n within eigengroup l give the surface of the hyperellipse of dimensions l-1 NOTE: for eigengroup 0 with the zeroth mode, we ignore it, and for eigengroup 1, this is resampling the surface of the 2-sphere - the axes of the hyperellipse are given by the sqrt of the eigenvalues corresponding to eigenvectors n - linear transform the eigenvectors N to the hypersphere by dividing by the ellipsoid axes - finds the set of points `p` on the hypersphere given by the basis modes (the eigenmodes) by normalizing them to unit length - rotate the set of points `p` by cos(angle) for even dimensions and sin(angle) for odd dims (resampling step) - find the unit vectors of the points `p` by dividing by the Euclidean norm (equivalent to the eigenmodes) - make the new unit vectors orthonormal using Gram-Schmidt process - return the new eigenmodes of that eigengroup until all eigengroups are computed - reconstruct the null data by multiplying the original coefficients by the new eigenmodes - resamples the null data by performing rank-ordering to replicate the reconstructed data term, then adds the noise term back into the null data to produce a surrogate that replicates the original variance of the empirical data. * Only performed if `resample` = True. References ---------- 1. Robinson, P. A. et al., (2016), Eigenmodes of brain activity: Neural field theory predictions and comparison with experiment. NeuroImage 142, 79-98. <https://dx.doi.org/10.1016/j.neuroimage.2016.04.050> 2. Jorgensen, M., (2014), Volumes of n-dimensional spheres and ellipsoids. <https://www.whitman.edu/documents/Academics/Mathematics/2014/jorgenmd.pdf> 3. <https://math.stackexchange.com/questions/3316373/ellipsoid-in-n-dimension> 4. Blumenson, L. E. (1960). A derivation of n-dimensional spherical coordinates. The American Mathematical Monthly, 67(1), 63-66. <https://www.jstor.org/stable/2308932> 5. Trefethen, Lloyd N., Bau, David III, (1997). Numerical linear algebra. Philadelphia, PA: Society for Industrial and Applied Mathematics. ISBN 978-0-898713-61-9. 6. https://en.wikipedia.org/wiki/QR_decomposition 7. Chen, Y. C. et al., (2022). The individuality of shape asymmetries of the human cerebral cortex. Elife, 11, e75056. <https://doi.org/10.7554/eLife.75056> Parameters ---------- surface : str of path to file or array_like Filename of surface to resample of surface.darrays[0].data of shape (n_vertices, 3) and surface.darrays[1].data of shape (n_faces, 3). Must be a single hemisphere or bounded surface. data : str of path to file or np.ndarray of (n_vertices,) Filename of empirical data to resample evals : np.ndarray of shape (n,), optional Eigenvalues corresponding to the number of eigenmodes n in `emodes` emodes : np.ndarray of shape (n_vertices, n), optional Eigenmodes that are the solution to the generalized eigenvalue problem or Helmholtz equation in the Laplace-Beltrami operator of the cortex num_modes : int, optional If `evals` and `emodes` are not given, then they are computed on the surface given in `surface`. This variable controls how many modes to compute. Cannot exceed the number of vertices in `surface`. Default is 200. medial : np.logical_array or str of path to file, default None Medial wall mask for the input surface `surface`. Suitable only if surface is cortical hemisphere. Will use the labels for the medial wall to mask out of the surrogates. If None, uses the naive implementation of finding the medial wall by finding 0.0 values in `data` - prone to errors if `data` has zero values outside of the medial wall. Can also pass `False` to not attempt masking of medial wall at all. WARNING: If passing `False` to medial and `True` to resample, resulting surrogates may have strange distributions since the rank-ordering step may assign medial-wall values outside of the medial wall. USE AT YOUR OWN RISK. save_surface : bool, optional As above, if `evals` and `emodes` are computed and medial wall mask is given, then cortical surface with cuts is saved. Default False. seed : None or int or np.random.RandomState instance Specify the seed for random angle generation (or random state instance) default None. decomp_method : str, optional method of calculation of coefficients: 'matrix', 'matrix_separate', 'regression'. The default is 'matrix'. resample : bool, optional Set whether to resample surrogate map from original map to preserve values, default True randomize : bool, optional Set whether to shuffle coefficients calculated from minimizing least-squares error to reconstruct surrogates. Results in better randomization of surrogates at the cost of poorer replication of empirical spatial autocorrelation. find_optimal : bool, optional Overrides `evals` and `emodes` if "True". Computes a large amount of modes (20% of all possible modes of surface) and finds the optimal number of modes to make residual error between least-squares error estimation of coefficients and empirical data "white" (Gaussian), then generates new residuals with the same variance structure. Can increase computation time significantly but results in best balance of empirical variogram and randomization of values. n_jobs : int, optional Number of workers to use for parallelization. Default 1. use_cholmod : bool, optional Specify whether or not to use sksparse subroutines - requires ``scikit-sparse`` and ``libsuitesparse-dev`` to be installed. See https://github.com/scikit-sparse/scikit-sparse for more information. Raises ------ ValueError : Inappropriate inputs """ def __init__(self, data, surface=None, evals=None, emodes=None, num_modes=200, save_surface=False, seed=None, decomp_method='matrix', medial=None, randomize=False, resample=False, n_jobs=1, use_cholmod=False, permute=False, add_res=False, truncate_modes=False, ret_fwhm=False, truncate_args=None, save_rotations=False, parcellation=None, normalize=True, use_nn=False, nn_method='original', eigen_perms=None): # initialization of variables if surface is None: print("No surface given, expecting precomputed eigenvalues and eigenmodes") if evals is None or emodes is None: raise ValueError("Class must have a `surface` input, or eigenvalues `evals` and eigenmodes `emodes`") if surface is not None: if not is_string_like(surface): raise ValueError("Input surface must be a filename") self.surface_file = surface self.surface = load_surface(self.surface_file) self.data = copy.deepcopy(dataio(data)) self.n_vertices = self.data.shape[0] if emodes is not None and evals is not None: self.evals = evals[:num_modes] self.emodes = emodes[:, :num_modes] else: self.evals = self.emodes = None self.num_modes = num_modes self.save_surface = save_surface self._rs = check_random_state(seed) self.decomp_method = decomp_method self.medial = medial self.resample = resample self.randomize = randomize self.n_jobs = n_jobs self.cholmod = use_cholmod self.permute = permute self.save_rotations = save_rotations self.parcellation = parcellation self.normalize = normalize self.truncate_modes = truncate_modes self.use_nn = use_nn self.nn_method = nn_method self.eigen_perms = eigen_perms self.truncate_args = truncate_args self._lm = LinearRegression(fit_intercept=True) self.add_res = add_res if self.permute is True and self.add_res is True: self.add_res = False print('Permuting of residuals passed, `add_res` not passed') if self.decomp_method is not None: if self.decomp_method in methods: self.method = self.decomp_method else: raise ValueError("Eigenmode decomposition method must be 'matrix' or 'regression'") # mask out medial wall if self.medial is None: # get index of medial wall hopefully self.medial_wall = np.logical_and(~np.isnan(self.data), ~np.isclose(self.data, 0.0, rtol=1e-9, atol=1e-9)).astype(int) elif self.medial is False: if self.resample is True: raise RuntimeWarning("Resampling without masking out the medial wall " "may result in erroneous surrogate distributions. " "The authors of this code do not take responsibility for " "improper usage.\n" "USE AT YOUR OWN RISK.") self.medial_wall = np.zeros_like(self.data) else: # if given medial array if is_string_like(self.medial) == True: try: self.medial = dataio(self.medial) except: raise RuntimeError("Could not load medial wall file, please check") if isinstance(self.medial, np.ndarray) == True: if self.medial.ndim != 1: raise ValueError("Medial wall array must be a vector") if self.medial.shape[0] != self.n_vertices: # try transpose if self.medial.shape[1] != self.n_vertices: raise ValueError("Medial wall array must have the same number of vertices as the brain map") else: self.medial_wall = self.medial.T if not np.array_equal(self.medial, self.medial.astype(int)): raise RuntimeError("Medial wall array must be 1 for the ROI (medial wall) and 0 elsewhere") else: self.medial_wall = self.medial else: raise ValueError("Could not use provided medial wall array or " "file, please check") # checks if self.evals is None and self.emodes is None: print(f'Computing eigenmodes on surface using N={num_modes} modes') if self.num_modes >= self.n_vertices: raise ValueError('Number of modes must not exceed the number of vertices in input surface') self.evals, self.emodes = calc_surface_eigenmodes(self.surface_file, self.medial_wall, save_cut=True, num_modes=self.num_modes, use_cholmod=self.cholmod) else: # perform checks if self.evals.ndim != 1: raise ValueError("Eigenvalue array must be 1-dimensional") if self.emodes.shape[1] != self.evals.shape[0]: # try transpose self.emodes = self.emodes.T if self.emodes.shape[1] != self.evals.shape[0]: raise ValueError("There must be as many eigenmodes as there are eigenvalues") # actually the indices that AREN'T medial wall self.medial_wall = self.medial_wall.astype(np.bool_) # actually ARE the indices of medial wall self.medial_mask = np.logical_not(self.medial_wall) self.n_vertices = self.emodes.shape[0] self.data_no_mwall = self.data # deepcopy original data so it's not modified self.data_no_mwall = self.data[self.medial_wall] # truncate eigenmodes at nearest eigengroup if self.truncate_modes: if self.surface is None: raise ValueError('in order to compute kernel density estimate for data re:\n' 'number of modes (groups) to use, surface geometry must be given.') self.fwhm, self.emodes, self.evals = truncate_emodes(self.data_no_mwall, self.surface, self.emodes, self.evals, mask=self.medial_wall, seed=self._rs, ret_fwhm=self.ret_fwhm, **self.truncate_args) self._emodes = copy.deepcopy(self.emodes) self._emodes = self._emodes[self.medial_wall] # try initial modes given self.coeffs = self.eigen_decomposition(method=self.method) self.reconstructed_data = self.coeffs @ self._emodes.T self.reconstructed_data = self.reconstructed_data.squeeze() self.residuals = self.data_no_mwall - self.reconstructed_data # compute original modal PSD #self.psd = compute_psd(self.data_no_mwall, self._emodes) # find eigengroups self.groups = _get_eigengroups(self._emodes) def __call__(self, n=1): """ Generate new surrogate map(s). Parameters ---------- n : int, default 1 Number of surrogate maps to generate Returns ------- (n,N) np.ndarray Generated map(s) resampled from eigenspace of surface map Notes ----- Surrogates are returned with nans in place of the mask given by ``self.medial_wall``. """ rs = self._rs.randint(np.iinfo(np.int32).max, size=n) if self.use_nn is True or self.eigen_perms is not None: if self.eigen_perms is None: self.eigen_perms = gen_eigensamples(self.emodes, self.evals, mask=self.medial_wall, num_modes=self.num_modes, n_rotate=n, method=self.nn_method, n_jobs=self.n_jobs) surrs = np.row_stack( Parallel(self.n_jobs)( delayed(self._call_method)(rs=i) for i in rs ) ) return np.asarray(surrs.squeeze()) def _call_method(self, rs=None): """ Subfunction """ # reset randomstate self._rs = check_random_state(rs) surrogate = self.generate() return surrogate.squeeze()
[docs] def generate(self, output_modes=False): """ Generate eigensphere resampled surrogate. Returns ------- surrogate_data : np.ndarray Eigensphere resampled surrogate """ # initialize data data = copy.deepcopy(self.data_no_mwall) emodes = copy.deepcopy(self._emodes) evals = self.evals groups = self.groups mask = self.medial_wall coeffs = copy.deepcopy(self.coeffs) reconstructed_data = copy.deepcopy(self.reconstructed_data) #residuals = copy.deepcopy(self.residuals) if self.eigen_perms is None: # initialize the new modes new_modes = np.zeros_like(emodes) # resample the hypersphere (except for groups 1 and 2) for idx in range(len(groups)): if idx > 100: gen = True group_modes = emodes[:, groups[idx]] group_evals = evals[groups[idx]] p = group_modes # else, transform to spheroid and index the angles properly if self.normalize: p = transform_to_spheroid(group_evals, group_modes) p_rot = self.rotate_modes(p, gen=True) # transform back to ellipsoid if self.normalize: p_rot = transform_to_ellipsoid(group_evals, p_rot) new_modes[:, groups[idx]] = p_rot if output_modes: return new_modes else: new_modes = spin_single( self.emodes, evals, mask=mask, eigen_perms=self.eigen_perms[self._rs.randint(0, len(self.eigen_perms))], num_modes=self.num_modes, return_masked=True, ) # matrix multiply the estimated coefficients by the new modes surrogate = np.zeros_like(self.data)*np.nan # original data if self.randomize: for i in range(len(groups)): coeffs[groups[i]] = self._rs.permutation(coeffs[groups[i]]) # adjust weights based on matching the power spectral density of # the original data surrogate[mask] = coeffs @ new_modes.T # Mask the data and surrogate_data excluding the medial wall surr_no_mwall = copy.deepcopy(surrogate) surr_no_mwall = surr_no_mwall[mask] # if self.resample: # # Get the rank ordered indices # data_ranks = data.argsort()[::-1] # surr_ranks = surr_no_mwall.argsort()[::-1] # # Resample surr_no_mwall according to the rank ordering of data_no_mwall # surr_no_mwall[surr_ranks] = data[data_ranks] if self.resample: # resample values from empirical map sorted_map = np.sort(data) ii = np.argsort(surr_no_mwall) np.put(surr_no_mwall, ii, sorted_map) else: # force match the minimum and maximum sf = (np.nanmax(data) - np.nanmin(data)) / (np.nanmax(surr_no_mwall) - np.nanmin(surr_no_mwall)) shift = np.nanmin(data) - sf * np.nanmin(surr_no_mwall) surr_no_mwall = surr_no_mwall * sf + shift # now add the residuals of the original data residuals = data - surr_no_mwall if self.permute: surr_no_mwall += self._rs.permutation(residuals) if self.add_res: surr_no_mwall += residuals # else: # force match the minima # indices = np.nonzero(surr_no_mwall)[0] # Indices where s is non-zero # # Compute the normalization # data_selected = data[indices] # surr_selected = surr_no_mwall[indices] # surr_no_mwall[indices] = (surr_selected - surr_selected.min()) * (data_selected.max() - data_selected.min()) / (surr_selected.max() - surr_selected.min()) + data_selected.min() output_surr = np.zeros_like(surrogate)*np.nan output_surr[mask] = surr_no_mwall return output_surr.squeeze()
[docs] def eigen_decomposition(self, method='matrix'): """ Decompose data using eigenmodes and calculate the coefficient of contribution of each vector. Parameters: ----------- method : string method of calculation of coefficients: 'matrix', 'matrix_separate', 'regression' Returns: ------- coeffs : numpy array of shape (N, 3) coefficient values """ if self.parcellation: return eigen_decomposition(self.data_no_mwall, self._emodes, method='regression') return eigen_decomposition(self.data_no_mwall, self._emodes, method=method).squeeze()
def shuffle_modes(self, emodes): return self._rs.permutation(emodes)
[docs] def rotate_modes(self, emodes, gen=True): """ Rotate modes using random rotations. Parameters ---------- vectors : np.ndarray of (n_vertices, mu) Orthogonal vectors of n_vertices and mu modes Returns ------- rotated_vectors : np.ndarray of (n_vertices, mu) Array containing rotated vectors for each pair. """ if gen is True: return rotate_matrix(emodes) mu = emodes.shape[1] if mu % 2 == 0 or mu == 1: return rotate_matrix(emodes) l = (mu - 1) // 2 return np.dot(emodes, self._loader.get_random_rotation(l))
[docs] def resample_eigenspace(self, emodes, distribution='normal'): """ Resample across the eigenspace through linear weighted sums of real modes iteratively. Each iteration returns new orthogonal modes that are reweighted combinations of previous modes. NOTE: should only be applied to modes that have the same eigenvalue, i.e., on the eigensphere. Parameters ---------- modes : np.ndarray of (n_vertices, mu) Orthogonal vectors of n_vertices and mu modes Returns ------- new_modes : np.ndarray of (n_vertices, mu) Array containing resampled vectors for each pair. """ mu = emodes.shape[1] new_modes = np.zeros(emodes.shape) # Derive coefficients for i in range(mu): if distribution == 'uniform': coefficients = self._rs.uniform(low=-1., high=1., size=mu) elif distribution == 'normal': coefficients = self._rs.normal(size=mu) elif distribution == 'none': coefficients = np.ones((mu,)) else: raise ValueError("Unsupported distribution {}".format(str(distribution))) # now combine with new coefficients new_mode = np.dot(emodes, coefficients) new_modes[:, i] = new_mode return new_modes
[docs] def plot_data(self, surface, data, hemi='left', view='lateral', cmap='gray', show=True): """ Plots a data map using nilearn.plotting, returns fig and ax handles from matplotlib.pyplot for further use. Can also plot values on the surface by input to `data`. Parameters ---------- surface : nib.GiftiImage class or np.ndarray of shape (n_vertices, 3) A single surface to plot. data : np.ndarray of shape (n_vertices,) Data to plot on the surface hemi : str, optional Which hemisphere to plot. The default is 'left'. view : str, optional Which view to look at the surface. The default is 'lateral'. Accepted strings are detailed in the docs for nilearn.plotting cmap : str or matplotlib.cm class Which colormap to plot the surface with, default is 'viridis' show : bool, optional Flag whether to show the plot, default is True Returns ------- fig : figure handle ax : axes handle """ # make figure fig = plt.figure(figsize=(15,9), constrained_layout=False) mesh = (surface.darrays[0].data, surface.darrays[1].data) # get colormap cmap = plt.get_cmap(cmap) vmin = np.min(data) vmax = np.max(data) # plot surface ax = fig.add_subplot(projection='3d') plotting.plot_surf(mesh, surf_map=data, hemi=hemi, view=view, vmin=vmin, vmax=vmax, colorbar=False, cmap=cmap, axes=ax) ax.dist = 7 # show figure check if show is True: plt.show() return fig, ax
[docs] def eigen_spectrum(self, data, show=True): """ Plot the modal decomposition power spectrum reconstruction using original modes. See ``eigen_decomposition()``. Returns fig and ax handles from matplotlib.pyplot for further use. Parameters ---------- data : np.ndarray of shape (n_vertices,) Data to decompose and reconstruct for eigen spectrum. show : bool, optional Flag whether to show plot, default True Returns ------- fig : matplotlib.pyplot class Figure handle ax : matplotlib.pyplot class Axes handle """ # compute power spectrum = eval^2 coeffs = eigen_decomposition(data, self._emodes) power = np.abs(coeffs)**2 normalized_power = power/np.sum(power) # now do figure fig = plt.figure(figsize=(12, 12), constrained_layout=False) ax = fig.add_subplot() ax.semilogy(np.arange(1,len(coeffs)+1), normalized_power) #ax.set_ylim((0.00000, 0.1)) ax.set_xlim((0, len(coeffs)+1)) plt.xticks(fontsize=30) plt.yticks(fontsize=30) plt.xlabel(r'Mode', fontdict={'fontsize':30}) plt.ylabel(r'Normalized power (log scale)', fontdict={'fontsize':30}) # show figure check if show is True: plt.show() return fig, ax
[docs] class VolumetricEigenstrapping: """ Compute the eigenmodes and eigenvalues of the volume in `volume` and resample the hypersphere bounded by the eigengroups contained within `emodes`, to reconstruct the data using coefficients conditioned on the original data Based on the degenerate solutions of solving the Laplace-Beltrami operator on the cortex. The power spectrum is perfectly retained (the square of the eigenvalues). If evals and emodes are given (i.e., precomputed) then eigenmodes are not computed. Performs amplitude adjustment by default (see `resample`) @author: Nikitas C. Koussis, School of Psychological Sciences, University of Newcastle & Systems Neuroscience Group Newcastle Michael Breakspear, School of Psychological Sciences, University of Newcastle & Systems Neuroscience Group Newcastle IMPORTANT --------- Several software packages must be installed apart from python dependencies to use this class: - FreeSurfer >5 - Gmsh - fsl >5 (if ``aseg`` not passed) Process ------- - the orthonormal eigenvectors n within eigengroup l give the surface of the hyperellipse of dimensions l-1 NOTE: for eigengroup 0 with the zeroth mode, we ignore it, and for eigengroup 1, this is resampling the surface of the 2-sphere - the axes of the hyperellipse are given by the sqrt of the eigenvalues corresponding to eigenvectors n - linear transform the eigenvectors N to the hypersphere by dividing by the ellipsoid axes - finds the set of points `p` on the hypersphere given by the basis modes (the eigenmodes) by normalizing them to unit length - rotate the set of points `p` by cos(angle) for even dimensions and sin(angle) for odd dims (resampling step) - find the unit vectors of the points `p` by dividing by the Euclidean norm (equivalent to the eigenmodes) - make the new unit vectors orthonormal using Gram-Schmidt process - return the new eigenmodes of that eigengroup until all eigengroups are computed - reconstruct the null data by multiplying the original coefficients by the new eigenmodes - resamples the null data by performing rank-ordering to replicate the reconstructed data term, then adds the noise term back into the null data to produce a surrogate that replicates the original variance of the empirical data. * Only performed if `resample` = True. References ---------- 1. Robinson, P. A. et al., (2016), Eigenmodes of brain activity: Neural field theory predictions and comparison with experiment. NeuroImage 142, 79-98. <https://dx.doi.org/10.1016/j.neuroimage.2016.04.050> 2. Jorgensen, M., (2014), Volumes of n-dimensional spheres and ellipsoids. <https://www.whitman.edu/documents/Academics/Mathematics/2014/jorgenmd.pdf> 3. <https://math.stackexchange.com/questions/3316373/ellipsoid-in-n-dimension> 4. Blumenson, L. E. (1960). A derivation of n-dimensional spherical coordinates. The American Mathematical Monthly, 67(1), 63-66. <https://www.jstor.org/stable/2308932> 5. Trefethen, Lloyd N., Bau, David III, (1997). Numerical linear algebra. Philadelphia, PA: Society for Industrial and Applied Mathematics. ISBN 978-0-898713-61-9. 6. https://en.wikipedia.org/wiki/QR_decomposition 7. Chen, Y. C. et al., (2022). The individuality of shape asymmetries of the human cerebral cortex. Elife, 11, e75056. <https://doi.org/10.7554/eLife.75056> Parameters ---------- volume : str to image Filename of volume ROI with integer intensity values. Must be in the same space (i.e. registered) to the volume file in `data`. Extensions are those commonly accepted by nibabel (see nibabel file types). Volume is extracted using FreeSurfer or fslmaths if ``aseg`` is not passed. data : str to image or str to text file Filename of empirical data to resample label : int or list of ints or array of ints, optional Label of value in ``volume`` for ROI extraction. If default None, volume is considered to be a mask - i.e., all values equal to 1 are extracted and transformed to tetrahedral space, and those equal to 0 or otherwise are left out. Default is None aseg : bool, optional Specify whether file in ``volume`` is FreeSurfer preprocessed "aseg.mgz" file. Extra steps need to be performed for computation, though labels can be combined into structures (e.g., ``label=[11, 12, 26]`` with ``aseg=True`` would combine the left caudate, left putamen, and left NA to make the left striatum. See the FreeSurferColorLUT.txt for more details about labeling). Default is False evals : np.ndarray of shape (n,), optional Eigenvalues corresponding to the number of eigenmodes n in `emodes`. Default is None emodes : np.ndarray of shape (n_vertices, n), optional Eigenmodes that are the solution to the generalized eigenvalue problem or Helmholtz equation in the Laplace-Beltrami operator of the volume (must be in tetrahedral space). Default is None num_modes : int, optional If `evals` and `emodes` are not given, then they are computed on the surface given in `surface`. This variable controls how many modes to compute. Cannot exceed the number of vertices after tetrahedral resampling of ``volume``. Default is 200. seed : None or int or np.random.RandomState instance Specify the seed for random angle generation (or random state instance) default None. decomp_method : str, optional method of calculation of coefficients: 'matrix', 'matrix_separate', 'regression'. The default is 'matrix'. resample : bool, optional Set whether to resample surrogate map from original map to preserve values, default True randomize : bool, optional Set whether to shuffle coefficients calculated from minimizing least-squares error to reconstruct surrogates. Results in better randomization of surrogates at the cost of poorer replication of empirical spatial autocorrelation. n_jobs : int, optional Number of workers to use for parallelization. Default 1. use_cholmod : bool, optional Specify whether or not to use sksparse subroutines - requires ``scikit-sparse`` and ``libsuitesparse-dev`` to be installed. See https://github.com/scikit-sparse/scikit-sparse for more information. Raises ------ ValueError : Inappropriate inputs """ def __init__(self, data, volume, label=None, aseg=False, norm_file=None, normalization=None, normalization_factor=None, evals=None, emodes=None, num_modes=200, seed=None, decomp_method='matrix', randomize=False, resample=False, n_jobs=1, permute=False, verbose=True): # checks if not is_string_like(volume): raise ValueError('Input volume must be filename') self.voldir, self.nifti_input_tail = os.path.split(volume) self.nifti_input_main, self.nifti_input_ext = os.path.splitext(self.nifti_input_tail) if is_string_like(data): self.data_input_head, self.data_input_tail = os.path.split(data) self.data_input_main, self.data_input_ext = os.path.splitext(self.data_input_tail) self.volume = volume self.ROI = nib.load(volume) self.roi = self.ROI.get_fdata() self.affine = self.ROI.affine self.mask = np.logical_not(np.logical_or(np.isclose(self.roi, 0), np.isnan(self.roi))) self.xyz = nib.affines.apply_affine(self.affine, np.column_stack(np.where(self.mask))) self.use_cholmod = False if is_string_like(data): if self.data_input_ext == '.txt': self.data_array = np.loadtxt(data) self.data_array = masking.unmask(self.data_array, self.ROI).get_fdata() self.data = nib.Nifti1Image(self.data_array, self.affine, header=self.ROI.header) else: try: self.data = nib.load(data) if not np.array_equal(nib.load(volume).affine, nib.load(data).affine): raise ValueError('Input volume and input data must have the same affine transformation (i.e., be in the same space') except Exception as e: print(f'Error: {e}') if self.data.ndim > 3: new_data = nib.Nifti1Image(self.data.get_fdata()[:,:,:,0].squeeze(), self.data.affine, header=self.data.header) self.data = new_data self.data_array = self.data.get_fdata() else: self.data_array = masking.unmask(data, self.ROI).get_fdata() self.norm = norm_file if label is None: label = 1 self.label = label self.roi_number, self.roi_vol = self.calc_volume(self.volume) self.method = decomp_method self._rs = check_random_state(seed) self.randomize = randomize self.resample = resample self.permute = permute self.n_jobs = n_jobs self.verbose = verbose # prepare data and masking self.inds_all = np.where(self.roi==self.label) self.xx, self.yy, self.zz = self.inds_all if normalization is not None: if normalization not in norm_types: raise ValueError('Normalization type must be "area", "constant", "volume", or "number"') if normalization == 'constant' and normalization_factor is None: raise ValueError('Normalization type of "constant" but no factor given') self.norm_type = normalization self.norm_factor = normalization_factor self.masked_data = self.data_array[self.mask] # prepare tetra surface self.tetra_file = self.make_tetra(self.volume, label=self.label, verbose=self.verbose) self.points = np.zeros([self.xx.shape[0], 4]) self.points[:,0] = self.xx self.points[:,1] = self.yy self.points[:,2] = self.zz self.points[:,3] = 1 # get transform matrix self.T = get_tkrvox2ras(self.ROI.shape, self.ROI.header.get_zooms()) # apply transform self.points_trans = np.matmul(self.T, np.transpose(self.points)) # load surface self.tetra = TetMesh.read_vtk(self.tetra_file) # calculate number and volume self.roi_number, self.roi_volume = self.calc_volume() # normalize surface self.tetra_norm = self.normalize_vtk(self.tetra, self.norm_type, self.norm_factor) self.num_modes = num_modes if emodes is None: self.evals, self.emodes = self.calc_volume_eigenmodes(self.tetra_norm, self.num_modes, self.use_cholmod) # remove zeroth mode self.evals = self.evals[1:] self.emodes = self.emodes[:, 1:] # reconstruct data in tetrahedral space #self.tetra_data = self.project_to_tetra(self.tetra, self.masked_data) self._emodes = self.resample_data(self.emodes, method='nearest', vector=True) self.coeffs = eigen_decomposition(self.masked_data, self._emodes, method=self.method) self.reconstructed_data = self.coeffs @ self._emodes.T self.reconstructed_data = self.reconstructed_data.squeeze() self.residuals = self.masked_data - self.reconstructed_data self.psd = compute_psd(self.masked_data, self._emodes) # find eigengroups self.groups = _get_eigengroups(self.emodes) self._lm = LinearRegression(fit_intercept=True) def __call__(self, n=1): """ Generate new surrogate map(s) in tetra space. Resample using ``.resample_data(surrs)`` Parameters ---------- n : int, optional Number of surrogates to generate. The default is 1. Returns ------- (n,N) np.ndarray Generated map(s) resampled from eigenspace of tetrahedral surface Notes ----- Surrogates are in tetrahedral surface space. They will need to be resampled to volumetric space with ``.resample_data(surrs)`` """ rs = self._rs.randint(np.iinfo(np.int32).max, size=n) surrs = np.row_stack( Parallel(self.n_jobs)( delayed(self._call_method)(rs=i) for i in rs ) ) return np.asarray(surrs.squeeze()) def _call_method(self, rs=None): # reset randomstate self._rs = check_random_state(rs) surrogate = self.generate() return surrogate.squeeze()
[docs] def rotate_modes(self, emodes): """ Rotate modes using random rotations. Parameters ---------- vectors : np.ndarray of (n_vertices, mu) Orthogonal vectors of n_vertices and mu modes Returns ------- rotated_vectors : np.ndarray of (n_vertices, mu) Array containing rotated vectors for each pair. """ return rotate_matrix(emodes)
[docs] def generate(self, output_modes=False): """ Generate eigensphere resampled surrogate. Parameters ---------- output_modes : bool, optional Output resampled modes for debugging. The default is False Returns ------- (n,) : np.ndarray Surrogate data in tetrahedral space """ # initialize data emodes = copy.deepcopy(self._emodes) evals = self.evals groups = self.groups coeffs = copy.deepcopy(self.coeffs) reconstructed_data = copy.deepcopy(self.reconstructed_data) residuals = copy.deepcopy(self.residuals) # initialize the new modes new_modes = np.zeros_like(emodes) # resample the hypersphere for idx in range(len(groups)): group_modes = emodes[:, groups[idx]] group_evals = evals[groups[idx]] # else, transform to spheroid and index the angles properly group_new_modes = transform_to_spheroid(group_evals, group_modes) p = group_new_modes / np.linalg.norm(group_modes, axis=0) p = self.rotate_modes(p) # transform back to ellipsoid group_ellipsoid_modes = transform_to_ellipsoid(group_evals, p) new_modes[:, groups[idx]] = group_ellipsoid_modes / np.linalg.norm(group_ellipsoid_modes, axis=0) if output_modes: return new_modes if self.randomize: for i in range(len(groups)): coeffs[groups[i]] = self._rs.permutation(coeffs[groups[i]]) surrogate = coeffs @ new_modes.T if self.resample: data_ranks = reconstructed_data.argsort()[::-1] surr_ranks = surrogate.argsort()[::-1] surrogate[surr_ranks] = reconstructed_data[data_ranks] if self.permute: surrogate = surrogate + self._rs.permutation(residuals) return surrogate.squeeze()
[docs] def resample_data(self, data, method='linear', vector=False): """ Resample data on tetrahedral surface to volumetric space. Parameters ---------- data : np.ndarray of shape (n_tetra_points, N) Data to resample method : str, optional Interpolation method for ``scipy.interpolate.griddata``. The default is 'linear'. vector : bool, optional Resample data to masked vector (values inside ROI only). Default False Returns ------- resampled_data : np.ndarray of shape (A,B,C,N) Data in volumetric space. """ points_surface = self.tetra.v new_shape = np.array(self.roi.shape) if self.roi.ndim > 3: new_shape[3] = data.shape[-1] else: new_shape = np.append(new_shape, data.shape[-1]) new_data = np.zeros(new_shape) data_vec = np.zeros((self.xx.shape[0], data.shape[-1])) # perform interpolation of data from tetrahedral space to volume space for idx in range(data.shape[-1]): interpolated_data = griddata(points_surface, data[:, idx], np.transpose(self.points_trans[:3, :]), method=method) data_vec[:, idx] = interpolated_data for ind in range(len(interpolated_data)): new_data[self.xx[ind], self.yy[ind], self.zz[ind]] = interpolated_data[ind] if vector is True: return data_vec return new_data
[docs] def project_to_tetra(self, tetra, data): """ Project data in volumetric space onto tetrahedral surface. Parameters ---------- tetra : lapy compatible object Loaded object corresponding to tetrahedral surface. data : np.ndarray of shape (xx, yy, zz) Data to project to tetrahedral surface with values inside ROI. interpolation : str, optional Interpolation kind. Default is 'linear' Returns ------- tetra_data : np.ndarray of shape (number of tetra points,) Resampled data """ v = tetra.v xyz = self.xyz tetra_data = griddata(xyz, data, v, method='nearest', rescale=True) # points_orig = T @ points.T # points_orig = points_orig.T[:,:3] # mesh = (points_orig, t) # tetra_data = s.vol_to_surf( # data, mesh, interpolation='nearest', radius=3, kind='ball') return tetra_data
[docs] def make_tetra(self, volume, label=None, aseg=False, verbose=True): """ Generate tetrahedral version of the ROI in the nifti file. Can specify label value. Parameters ---------- volume : str Filename of input volume where the relevant ROI has voxel values = 1 or ``label`` label : int or list of ints, optional Label value(s) of input volume. Extracts surface from voxels that have this(ese) intensity value(s). If None, defaults to 1 aseg : bool, optional Specify whether input volume and label is from FreeSurfer aseg.nii.gz (post-FS preprocessing). Returns ------- tetra_file : str Filename of output tetrahedral vtk file Raises ------ RuntimeError Multiple labels given but aseg not passed. """ return make_tetra(volume, label=label, aseg=aseg, norm=self.norm, verbose=verbose)
[docs] def calc_volume_eigenmodes(self, tetra, num_modes=200, use_cholmod=False): """ Calculate the eigenvalues and eigenmodes of the ROI volume. Parameters ---------- tetra : lapy compatible object Loaded lapy object corresponding to tetrahedral surface of ROI in vtk format num_modes : int, optional Number of eigenmodes to compute. The default is 200 use_cholmod : bool, optional Specify whether to use ``scikit-sparse`` libraries to compute eigenmodes. Much faster but requires libraries to be installed. Returns ------- evals : np.ndarray of shape (num_modes,) Eigenvalues emodes : np.ndarray of shape (number of tetra points, num_modes) Eigenmodes """ # calc eigenmodes and eigenvalues fem = Solver(tetra) evals, emodes = fem.eigs(k=num_modes) return evals, emodes
[docs] def calc_volume(self, nifti_volume=None, label=None): """ Calculate the physical volume of the ROI in the nifti file Parameters ---------- nifti_volume : str, optional Filename of input volume in nifti format label : int, optional Label value for input ROI in ``nifti_volume`` Returns ------- ROI_number : int Total number of non-zero voxels ROI_volume : float Total volume of non-zero voxels in physical dimensions """ if nifti_volume is not None: roi = nib.load(nifti_volume) roi_data = roi.get_fdata() else: roi = self.ROI roi_data = self.roi if label is None: label = 1 roi_data = roi_data[np.where(roi_data==label)] # get voxel dimensions in mm voxel_dims = (roi.header["pixdim"])[1:4] voxel_vol = np.prod(voxel_dims) # compute volume ROI_number = np.count_nonzero(roi_data) ROI_volume = ROI_number * voxel_vol return ROI_number, ROI_volume
[docs] def normalize_vtk(self, tetra, normalization_type=None, normalization_factor=None): """ Normalize tetrahedral surface Parameters ---------- tetra : lapy compatible object Loaded vtk object corresponding to a surface tetrahedral mesh normalization_type : str or None, optional Type of normalization. The default is None. normalization_factor : float or None, optional Factor to be used in "constant" normalization. The default is None. Returns ------- tetra_norm : lapy compatible object Loaded vtk object corresponding to normalized tetrahedral surface """ tetra_norm = tetra if normalization_type == 'number': tetra_norm.v = tetra.v/(self.roi_number**(1/3)) elif normalization_type == 'volume': tetra_norm.v = tetra.v/(self.roi_volume**(1/3)) elif normalization_type == 'area': tetra_norm.v = tetra.v/(tetra.vertex_areas()**(1/3)) else: pass if normalization_type in norm_types: surface_output_fname = os.path.join(self.voldir, self.nifti_input_main + '_norm=' + normalization_type + '.tetra.vtk') f = open(surface_output_fname, 'w') f.write('# vtk DataFile Version 2.0\n') f.write(self.nifti_input_tail + '\n') f.write('ASCII\n') f.write('DATASET POLYDATA\n') f.write('POINTS ' + str(np.shape(tetra.v)[0]) + ' float\n') for i in range(np.shape(tetra.v)[0]): f.write(' '.join(map(str, tetra_norm.v[i, :]))) f.write('\n') f.write('\n') f.write('POLYGONS ' + str(np.shape(tetra.t)[0]) + ' ' + str(5 * np.shape(tetra.t)[0]) + '\n') for i in range(np.shape(tetra.t)[0]): f.write(' '.join(map(str, np.append(4, tetra.t[i, :])))) f.write('\n') f.close() return tetra_norm
[docs] def eigen_spectrum(self, show=True): """ Plot the modal decomposition power spectrum. See ``geometry.eigen_decomposition()`` Returns fig and ax handles from matplotlib.pyplot for further use. Parameters ---------- show : bool, optional Flag whether to show plot, default True Returns ------- fig : matplotlib.pyplot class Figure handle ax : matplotlib.pyplot class Axes handle """ # compute power spectrum = eval^2 coeffs = eigen_decomposition(self.tetra_data, self.emodes) power = np.abs(coeffs)**2 normalized_power = power/np.sum(power) # now do figure fig = plt.figure(figsize=(12, 12), constrained_layout=False) ax = fig.add_subplot() ax.semilogy(np.arange(1,len(coeffs)+1), normalized_power) #ax.set_ylim((0.00000, 0.1)) ax.set_xlim((0, len(coeffs)+1)) plt.xticks(fontsize=30) plt.yticks(fontsize=30) plt.xlabel(r'Mode', fontdict={'fontsize':30}) plt.ylabel(r'Normalized power (log scale)', fontdict={'fontsize':30}) # show figure check if show is True: plt.show() return fig, ax
[docs] def calculate_distance_matrix(self, return_data=False): """ Calculates Euclidean distance matrix and index memmap files of ROI, also returns data masked by ROI. Parameters ---------- outdir : str, optional Output directory of distance and index np.memmap files. Returns ------- masked_data : (N,) dist : np.memmap Distance memmap matrix. index : str Distance index memmap matrix """ affine = self.affine mask = self.mask xyz = self.xyz #xyz = self.inds_all distout = tempfile.NamedTemporaryFile(suffix='.mmap') dist = np.memmap(distout, mode='w+', dtype='float32', shape=(np.shape(xyz)[0], np.shape(xyz)[0])) indout = tempfile.NamedTemporaryFile(suffix='.mmap') index = np.memmap(indout, mode='w+', dtype='int32', shape=(np.shape(xyz)[0], np.shape(xyz)[0])) for n, row in enumerate(xyz): xyz_dist = cdist(row[None], xyz).astype('float32') index[n] = np.argsort(xyz_dist, axis=-1).astype('int32') dist[n] = xyz_dist[:, index[n]] dist.flush() index.flush() masked_data = self.data_array[mask] if return_data: return masked_data, dist, index return dist, index
[docs] def surfplot(self, data, view='dorsal', cmap='viridis', vrange=[-4, 4]): """ Plotting helper function for volumetric data to triangular surfaces. Parameters ---------- data : np.ndarray of shape (n_vertices,) Volumetric data projected to tetrahedral mesh. See self.project_to_tetra(). view : str or pair of float, optional Specify which direction to place camera and view from. See ``nilearn.plotting.plot_surf()`` for accepted strings. If pair of float, must be a list or tuple of (elevation, azimuthal) of angles in degrees. The default is 'dorsal'. cmap : str, optional Colormap for values in ``data``. Must be in matplotlib colormaps. The default is 'viridis' vrange : tuple of two floats, optional Range to truncate minimum and maximum values. The default is [-4, 4]. annotations : bool, optional Specify whether to add annotation labels for direction etc Returns ------- fig : matplotlib.pyplot.Figure handle ax : matplotlib.pyplot.Axes handle """ # define mesh tria = self.tria mesh = (tria.v, tria.t) xyz = self.xyz tria_data = griddata(xyz, data, tria.v, method='nearest') # get colormap cmap = plt.get_cmap(cmap) vmin, vmax = vrange # define figure fig = plt.figure(figsize=(8, 6), constrained_layout=False) ax = fig.add_axes([0.0, 0.2, 0.97, 0.7], projection='3d') plotting.plot_surf(mesh, tria_data, view=view, vmin=vmin, vmax=vmax, alpha=1, colorbar=False, cmap=cmap, axes=ax) cax = plt.axes([0.8, 0.3, 0.03, 0.4]) cbar = fig.colorbar(cm.ScalarMappable(norm=None, cmap=cmap), cax=cax) cbar.set_ticks([]) cbar.ax.set_title(f'{vmax:.2f}', fontdict={'fontsize':20}, pad=20) cbar.ax.set_xlabel(f'{vmin:.2f}', fontdict={'fontsize':20}, labelpad=20) plt.show() return fig, ax