JALE/jale/core/utils/compute.py

677 lines
19 KiB
Python

import logging
import numpy as np
from scipy import ndimage
from scipy.special import comb
from scipy.stats import norm
from jale.core.utils.kernel import kernel_convolution
from jale.core.utils.template import BRAIN_ARRAY_SHAPE, GM_PRIOR, GM_SAMPLE_SPACE
EPS = np.finfo(float).eps
logger = logging.getLogger("ale_logger")
""" Main Effect Computations """
def illustrate_foci(foci):
"""
Create a brain array marking the locations of foci.
Parameters
----------
foci : list of arrays
Coordinates of foci across experiments.
Returns
-------
numpy.ndarray
3D brain array with foci locations marked.
"""
foci_arr = np.zeros(BRAIN_ARRAY_SHAPE)
# Load all foci associated with study
foci = np.concatenate(foci)
# Set all points in foci_arr that are foci for the study to 1
foci_arr[tuple(foci.T)] += 1
return foci_arr
def compute_ma(foci, kernels):
"""
Compute modeled activation (MA) maps by convolving foci with kernels.
Parameters
----------
foci : list of arrays
Coordinates of foci for each study.
kernels : list of numpy.ndarray
Smoothing kernels for each study.
Returns
-------
numpy.ndarray
Modeled activation maps (4D array).
"""
ma = np.zeros(
(len(kernels), BRAIN_ARRAY_SHAPE[0], BRAIN_ARRAY_SHAPE[1], BRAIN_ARRAY_SHAPE[2])
)
for i, kernel in enumerate(kernels):
ma[i, :] = kernel_convolution(foci=foci[i], kernel=kernel)
return ma
def compute_hx(ma, bin_edges):
"""
Calculate histograms for modeled activation (MA) maps.
Parameters
----------
ma : numpy.ndarray
Modeled activation maps.
bin_edges : numpy.ndarray
Edges for histogram bins.
Returns
-------
numpy.ndarray
Histogram values for each map.
"""
hx = np.zeros((ma.shape[0], len(bin_edges)))
for i in range(ma.shape[0]):
data = ma[i, :]
bin_idxs, counts = np.unique(
np.digitize(data[GM_PRIOR], bin_edges), return_counts=True
)
hx[i, bin_idxs] = counts
return hx
def compute_ale(ma):
"""
Compute ALE (Activation Likelihood Estimation) values from modeled activation (MA) maps.
Parameters
----------
ma : numpy.ndarray
Modeled activation maps.
Returns
-------
numpy.ndarray
ALE values across all voxels.
"""
return 1 - np.prod(1 - ma, axis=0)
def compute_hx_conv(hx, bin_centers, step):
"""
Convolve histograms for ALE threshold estimation from modeled activation maps.
Parameters
----------
hx : numpy.ndarray
Histogram values from modeled activation maps.
bin_centers : numpy.ndarray
Centers of histogram bins.
step : int
Step size for histogram binning.
Returns
-------
numpy.ndarray
Convolved histogram for ALE estimation.
"""
ale_hist = hx[0, :] / np.sum(hx[0, :])
for x in range(1, hx.shape[0]):
v1 = ale_hist
# normalize hist
v2 = hx[x, :] / np.sum(hx[x, :])
# Get indices of non-zero bins
da1, da2 = np.where(v1 > 0)[0], np.where(v2 > 0)[0]
# Compute outer products for probabilities and scores
p = np.outer(v2[da2], v1[da1])
score = 1 - (1 - bin_centers[da2])[:, None] * (1 - bin_centers[da1])
ale_bin = np.round(score * step).astype(int)
ale_hist = np.zeros(len(bin_centers))
# Add probabilities to respective bins
np.add.at(ale_hist, ale_bin, p)
# Compute cumulative sum up to the last non-zero bin
last_used = np.max(np.where(ale_hist > 0)[0])
hx_conv = np.flip(np.cumsum(np.flip(ale_hist[: last_used + 1])))
return hx_conv
def compute_z(ale, hx_conv, step):
"""
Calculate z-values from ALE values and a convolved histogram.
This function computes z-values by determining the p-values corresponding to
ALE values and then applying the inverse of the normal cumulative distribution function.
Parameters
----------
ale : numpy.ndarray
Array of ALE values.
hx_conv : numpy.ndarray
Convolved histogram values for ALE thresholding.
step : int
Step size for histogram binning.
Returns
-------
numpy.ndarray
Array of z-values derived from ALE values.
"""
# computing the corresponding histogram bin for each ale value
ale_step = np.round(ale * step).astype(int)
# replacing histogram bin number
# with corresponding histogram value (= p-value)
p = np.array([hx_conv[i] for i in ale_step])
p[p < EPS] = EPS
# calculate z-values by plugging 1-p into a probability density function
z = norm.ppf(1 - p)
z[z < 0] = 0
z = np.nan_to_num(z, nan=0)
return z
def compute_tfce(z, E=0.5, H=2, delta_t_steps=100):
"""
Compute Threshold-Free Cluster Enhancement (TFCE).
Parameters:
z (np.ndarray): Input 3D array of z-values.
E (float): TFCE enhancement exponent for cluster extent.
H (float): TFCE enhancement exponent for intensity.
delta_t_steps (int): Number of steps for the intensity thresholding.
Returns:
np.ndarray: TFCE-enhanced 3D array.
"""
max_z = np.max(z)
delta_t = max_z / delta_t_steps # Intensity step size
tfce = np.zeros_like(z, dtype=np.float64) # Initialize TFCE array
# Iterate over intensity thresholds
for h in np.arange(0, max_z, delta_t):
thresh = z > h # Suprathreshold binary mask
labels, cluster_count = ndimage.label(thresh) # Identify clusters
# Calculate cluster sizes
_, sizes = np.unique(labels, return_counts=True)
sizes[0] = 0 # Ignore background cluster (label 0)
# Apply mask for suprathreshold voxels
mask = labels > 0
cluster_sizes = sizes[labels[mask]]
# Update TFCE values for suprathreshold clusters
tfce[mask] += np.power(h, H) * delta_t * np.power(cluster_sizes, E)
return tfce
def compute_clusters(z, cluster_forming_threshold, cfwe_threshold=None):
"""
Identify significant clusters in a z-map based on a cluster-forming threshold.
This function applies thresholding to identify clusters of significant z-values
and, optionally, filters clusters based on a cluster-wise family-wise error
(cFWE) threshold.
Parameters
----------
z : numpy.ndarray
Array of z-values.
cluster_forming_threshold : float
Threshold for forming clusters in the z-map.
cfwe_threshold : int, optional
Minimum cluster size for cFWE correction, by default None.
Returns
-------
tuple
- numpy.ndarray : Thresholded z-map with significant clusters.
- int : Size of the largest cluster found.
"""
# Threshold z-values based on the specified cluster threshold
z = np.nan_to_num(z)
sig_arr = (z > norm.ppf(1 - cluster_forming_threshold)).astype(int)
# Find clusters of significant z-values
labels, cluster_count = ndimage.label(sig_arr)
voxel_count_clusters = np.bincount(labels[labels > 0])
# Determine the size of the largest cluster (if any clusters exist)
max_clust = np.max(voxel_count_clusters) if cluster_count >= 1 else 0
# Apply the cluster size cutoff if provided
if cfwe_threshold:
significant_clusters = voxel_count_clusters > cfwe_threshold
sig_clust_labels = np.where(significant_clusters)[0]
z = z * np.isin(labels, sig_clust_labels)
return z, max_clust
def compute_null_ale(num_foci, kernels):
"""
Generate a null ALE map by randomly assigning foci and computing ALE.
Creates a null activation map (ALE) by assigning foci locations randomly within
the sample space and calculating the ALE based on the provided kernels.
Parameters
----------
num_foci : list of int
List of foci counts for each experiment.
kernels : list of numpy.ndarray
Smoothing kernels for each experiment.
Returns
-------
tuple
- numpy.ndarray : Null modeled activation maps (MA).
- numpy.ndarray : Null ALE map.
"""
null_foci = [
GM_SAMPLE_SPACE[:, np.random.randint(0, GM_SAMPLE_SPACE.shape[1], num_focus)].T
for num_focus in num_foci
]
null_ma = compute_ma(null_foci, kernels)
null_ale = compute_ale(null_ma)
return null_ma, null_ale
def compute_monte_carlo_null(
num_foci,
kernels,
bin_edges=None,
bin_centers=None,
step=10000,
cluster_forming_threshold=0.001,
target_n=None,
hx_conv=None,
tfce_enabled=True,
):
"""
Generate a null distribution for ALE using Monte Carlo sampling.
This function computes vFWE, cFWE and TFCE thresholds based on
randomly sampled foci.
Parameters
----------
num_foci : list of int
Number of foci for each study.
kernels : list of numpy.ndarray
Smoothing kernels for each study.
bin_edges : numpy.ndarray, optional
Edges for histogram bins, by default None.
bin_centers : numpy.ndarray, optional
Centers of histogram bins, by default None.
step : int, optional
Step size for histogram binning, by default 10000.
cluster_forming_threshold : float, optional
Threshold for forming clusters in ALE, by default 0.001.
target_n : int, optional
Specifies if the full dataset is used or a subsample, by default None.
hx_conv : numpy.ndarray, optional
Convolved histogram for ALE thresholding, by default None.
tfce_enabled : bool, optional
Whether to compute TFCE thresholds, by default True.
Returns
-------
tuple
- float : Maximum null ALE value.
- int : Maximum null cluster size.
- float : Maximum null TFCE value.
"""
if target_n:
subsample = np.random.permutation(np.arange(len(num_foci)))
subsample = subsample[:target_n]
num_foci = num_foci[subsample]
kernels = kernels[subsample]
# compute ALE values based on random peak locations sampled from grey matter
null_ma, null_ale = compute_null_ale(num_foci, kernels)
# Peak ALE threshold
null_max_ale = np.max(null_ale)
if hx_conv is None:
null_hx = compute_hx(null_ma, bin_edges)
hx_conv = compute_hx_conv(null_hx, bin_centers, step)
null_z = compute_z(null_ale, hx_conv, step)
# Cluster level threshold
_, null_max_cluster = compute_clusters(null_z, cluster_forming_threshold)
null_max_tfce = 0
if tfce_enabled:
null_tfce = compute_tfce(null_z)
# TFCE threshold
null_max_tfce = np.max(null_tfce)
return null_max_ale, null_max_cluster, null_max_tfce
""" CV/Subsampling ALE Computations """
def generate_unique_subsamples(total_n, target_n, sample_n):
"""
Generate unique random subsamples of indices for probabilistic ALE.
Parameters
----------
total_n : int
Total number of studies or experiments.
target_n : int
Target number of studies for each subsample.
sample_n : int
Desired number of unique subsamples to generate.
Returns
-------
list of numpy.ndarray
List of unique subsample arrays.
"""
# Calculate the maximum number of unique subsamples (combinations)
max_combinations = int(comb(total_n, target_n, exact=True))
# If sample_n exceeds max_combinations, limit it
if sample_n > max_combinations:
sample_n = max_combinations
subsamples = set()
while len(subsamples) < sample_n:
# Generate a random subsample of size `target_n` from `total_n`
subsample = np.sort(np.random.choice(total_n, target_n, replace=False))
# Add the tuple version of the sorted subsample to ensure uniqueness
subsamples.add(tuple(subsample))
# Convert the set of tuples back to a list of NumPy arrays
return [np.array(subsample) for subsample in subsamples]
def compute_sub_ale_single(
ma,
cfwe_threshold,
bin_edges,
bin_centers,
step=10000,
cluster_forming_threshold=0.001,
):
"""
Compute a single subsample ALE map with cFWE thresholding.
This function calculates a thresholded ALE map from modeled activation maps
for a single subsample, applying voxel-wise and cluster-wise corrections.
Parameters
----------
ma : numpy.ndarray
Modeled activation maps for the subsample.
cfwe_threshold : float
Threshold for cluster-wise FWE correction.
bin_edges : numpy.ndarray
Edges for histogram bins.
bin_centers : numpy.ndarray
Centers of histogram bins.
step : int, optional
Step size for histogram binning, by default 10000.
cluster_forming_threshold : float, optional
Threshold for forming clusters in ALE, by default 0.001.
Returns
-------
numpy.ndarray
Thresholded ALE map for the subsample.
"""
hx = compute_hx(ma, bin_edges)
hx_conv = compute_hx_conv(hx, bin_centers, step)
ale = compute_ale(ma)
z = compute_z(ale, hx_conv, step)
z, _ = compute_clusters(z, cluster_forming_threshold, cfwe_threshold=cfwe_threshold)
z[z > 0] = 1
return z
def compute_sub_ale(
samples,
ma,
cfwe_threshold,
bin_edges,
bin_centers,
step=10000,
cluster_forming_threshold=0.001,
):
"""
Compute the mean ALE map from multiple subsamples.
Aggregates thresholded ALE maps from a set of subsamples to produce a mean
ALE map, applying voxel-wise and cluster-wise corrections.
Parameters
----------
samples : list of numpy.ndarray
List of subsamples (each a subset of indices).
ma : numpy.ndarray
Modeled activation maps for all studies.
cfwe_threshold : float
Threshold for cluster-wise FWE correction.
bin_edges : numpy.ndarray
Edges for histogram bins.
bin_centers : numpy.ndarray
Centers of histogram bins.
step : int, optional
Step size for histogram binning, by default 10000.
cluster_forming_threshold : float, optional
Threshold for forming clusters in ALE, by default 0.001.
Returns
-------
numpy.ndarray
Mean ALE map across all subsamples.
"""
ale_mean = np.zeros(BRAIN_ARRAY_SHAPE)
for idx, sample in enumerate(samples):
if idx % 500 == 0:
logger.info(f"Calculated {idx} subsample ALEs")
ale_mean += compute_sub_ale_single(
ma[sample],
cfwe_threshold,
bin_edges,
bin_centers,
step,
cluster_forming_threshold,
)
return ale_mean / len(samples)
""" Legacy Contrast Computations"""
def compute_permuted_ale_diff(ma_merge, nexp):
"""
Calculate the difference in ALE maps based on a random permutation of studies.
Randomly splits the combined modeled activation maps into two groups and computes
the ALE difference between these groups.
Parameters
----------
ma_merge : numpy.ndarray
Combined modeled activation maps from two groups.
nexp : int
Number of experiments in the first group.
Returns
-------
numpy.ndarray
Permuted difference in ALE values between the two groups.
"""
permutation = np.random.permutation(np.arange(ma_merge.shape[0]))
ale_perm1 = compute_ale(ma_merge[permutation[:nexp]])
ale_perm2 = compute_ale(ma_merge[permutation[nexp:]])
permuted_diff = ale_perm1 - ale_perm2
return permuted_diff
def compute_sig_diff(ale_difference, null_difference, significance_threshold=0.05):
"""
Identify significant differences in ALE values compared to a null distribution.
This function computes z-scores for differences in ALE values and determines
significant differences based on a specified threshold.
Parameters
----------
ale_difference : numpy.ndarray
Observed ALE difference values.
null_difference : numpy.ndarray
Null distribution of ALE differences.
significance_threshold : float, optional
Significance level for identifying significant differences, by default 0.05.
Returns
-------
tuple
- numpy.ndarray : z-scores of significant ALE differences.
- numpy.ndarray : Indices of significant differences.
"""
p_diff = np.average((null_difference > ale_difference), axis=0)
EPS = np.finfo(float).eps
p_diff[p_diff < EPS] = EPS
z_diff = norm.ppf(1 - p_diff)
z_threshold = norm.ppf(1 - significance_threshold)
if np.max(z_diff) < z_threshold:
z_diff = 0
sig_diff_idxs = 0
else:
sig_diff_idxs = np.where(z_diff > z_threshold)
z_diff = z_diff[sig_diff_idxs]
return z_diff, sig_diff_idxs
""" Balanced Contrast Computations"""
def compute_balanced_ale_diff(ma1, ma2, prior, target_n):
"""
Compute the ALE difference for balanced subsamples from two groups.
This function randomly subsamples studies from two groups to calculate a balanced
ALE difference.
Parameters
----------
ma1 : numpy.ndarray
Modeled activation maps for the first group.
ma2 : numpy.ndarray
Modeled activation maps for the second group.
prior : numpy.ndarray
Mask indicating relevant brain regions (e.g., grey matter).
target_n : int
Number of studies to include in each subsample.
Returns
-------
numpy.ndarray
Difference in ALE values between the two subsamples.
"""
# subALE1
subsample1 = np.random.choice(np.arange(ma1.shape[0]), target_n, replace=False)
ale1 = compute_ale(ma1[subsample1, :][:, prior])
# subALE2
subsample2 = np.random.choice(np.arange(ma2.shape[0]), target_n, replace=False)
ale2 = compute_ale(ma2[subsample2, :][:, prior])
r_diff = ale1 - ale2
return r_diff
def compute_balanced_null_diff(
nfoci1, kernels1, nfoci2, kernels2, prior, target_n, difference_iterations
):
"""
Generate a null distribution for balanced ALE differences.
Creates a null distribution of ALE differences between two groups by randomly
assigning foci locations and computing the ALE difference over multiple iterations.
Parameters
----------
nfoci1 : list of int
Number of foci for each study in the first group.
kernels1 : list of numpy.ndarray
Smoothing kernels for each study in the first group.
nfoci2 : list of int
Number of foci for each study in the second group.
kernels2 : list of numpy.ndarray
Smoothing kernels for each study in the second group.
prior : numpy.ndarray
Mask indicating relevant brain regions (e.g., grey matter).
target_n : int
Number of studies to include in each subsample.
difference_iterations : int
Number of iterations to compute the null distribution.
Returns
-------
tuple
- float : Minimum ALE difference in the null distribution.
- float : Maximum ALE difference in the null distribution.
"""
null_foci1 = [
GM_SAMPLE_SPACE[:, np.random.randint(0, GM_SAMPLE_SPACE.shape[1], nfoci)].T
for nfoci in nfoci1
]
null_ma1 = compute_ma(null_foci1, kernels1)
null_foci2 = [
GM_SAMPLE_SPACE[:, np.random.randint(0, GM_SAMPLE_SPACE.shape[1], nfoci)].T
for nfoci in nfoci2
]
null_ma2 = compute_ma(null_foci2, kernels2)
null_diff = np.zeros((np.sum(prior),))
for _ in range(difference_iterations):
null_diff += compute_balanced_ale_diff(null_ma1, null_ma2, prior, target_n)
null_diff = null_diff / difference_iterations
min_diff, max_diff = np.min(null_diff), np.max(null_diff)
return min_diff, max_diff