Source code for mrsiprep.connectivity.connectivity

"""Perturbation-based metabolite connectivity.

Builds a per-parcel feature vector from many CRLB-scaled noise perturbations of
each metabolite map (z-scored per metabolite before parcel averaging, so no single
metabolite's scale dominates the correlation), then correlates parcels over that
vector. This mirrors the statistical approach used in the mrsitoolbox MetSiM
pipeline (``Randomize.perturbate`` + ``MeSiM.parcellate_vectorized``), but is a
from-scratch, vectorized implementation rather than a port of that code.
"""

from __future__ import annotations

from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from pathlib import Path

import numpy as np
import pandas as pd
from rich.progress import BarColumn, MofNCompleteColumn, Progress, TextColumn, TimeElapsedColumn
from scipy.spatial.distance import pdist, squareform

from mrsiprep.utils.images import load_3d_data


[docs] def perturb_metabolite_map( signal: np.ndarray, crlb: np.ndarray, brainmask: np.ndarray, sigma_scale: float = 2.0, rng: np.random.Generator | None = None, ) -> np.ndarray: rng = rng or np.random.default_rng() sigma = signal * crlb / 100 scale = np.clip(np.nan_to_num(sigma * sigma_scale, nan=0.0), 0, None) upper = signal.mean() + 3 * signal.std() noisy = rng.normal(signal, scale) noisy = np.clip(noisy, 0, upper) noisy[brainmask == 0] = 0 return noisy
[docs] def build_parcel_indexer(atlas_3d: np.ndarray, parcel_ids: np.ndarray) -> np.ndarray: """Map each voxel to a 0-based row index into ``parcel_ids`` (or -1 if the voxel's label isn't in ``parcel_ids``), so per-parcel reductions for every metabolite/perturbation can share one ``np.bincount`` call per array instead of one ``scipy.ndimage`` pass per metabolite per parcel. """ lookup = np.full(int(atlas_3d.max()) + 1, -1, dtype=np.int64) lookup[parcel_ids] = np.arange(len(parcel_ids)) flat = atlas_3d.reshape(-1) safe = np.clip(flat, 0, lookup.size - 1) return np.where(flat == safe, lookup[safe], -1)
[docs] def parcellate_means( image_4d: np.ndarray, atlas_3d: np.ndarray, parcel_ids: np.ndarray, parcel_index: np.ndarray | None = None, voxel_weights: np.ndarray | None = None, ) -> np.ndarray: """Per-metabolite, per-parcel mean that skips NaN voxels (matching the reference implementation's ``np.nanmean`` over a boolean mask), computed for all metabolites in one vectorized pass via ``np.bincount`` rather than one ``scipy.ndimage`` label-reduction pass per metabolite. If ``voxel_weights`` is given (e.g. a GM partial-volume fraction map), the per-parcel value is a weighted mean instead of a plain mean — voxels with low GM fraction (partial WM/CSF contamination at the parcel boundary) contribute proportionally less, since chimera cortical parcels are GM structures and connectivity should reflect GM signal specifically. """ n_metabolites = image_4d.shape[0] n_parcels = len(parcel_ids) parcel_index = build_parcel_indexer(atlas_3d, parcel_ids) if parcel_index is None else parcel_index in_parcel = parcel_index >= 0 safe_index = np.where(in_parcel, parcel_index, 0) flat = image_4d.reshape(n_metabolites, -1) weights_flat = np.ones(flat.shape[1]) if voxel_weights is None else np.nan_to_num(voxel_weights.reshape(-1), nan=0.0) valid = in_parcel[None, :] & ~np.isnan(flat) weighted_valid = valid * weights_flat[None, :] filled = np.where(valid, flat, 0.0) * weights_flat[None, :] out = np.empty((n_metabolites, n_parcels), dtype=np.float64) for met_idx in range(n_metabolites): sums = np.bincount(safe_index, weights=filled[met_idx], minlength=n_parcels)[:n_parcels] counts = np.bincount(safe_index, weights=weighted_valid[met_idx], minlength=n_parcels)[:n_parcels] with np.errstate(invalid="ignore", divide="ignore"): out[met_idx] = np.where(counts > 0, sums / counts, np.nan) return out
[docs] def parcellate_zscored( perturbed_4d: np.ndarray, atlas_3d: np.ndarray, parcel_ids: np.ndarray, parcel_index: np.ndarray | None = None, voxel_weights: np.ndarray | None = None, ) -> np.ndarray: means = perturbed_4d.mean(axis=(1, 2, 3), keepdims=True) stds = perturbed_4d.std(axis=(1, 2, 3), keepdims=True) stds = np.where(stds == 0, 1, stds) zscored = (perturbed_4d - means) / stds return parcellate_means(zscored, atlas_3d, parcel_ids, parcel_index=parcel_index, voxel_weights=voxel_weights)
def _sample_one( signals: np.ndarray, crlbs: np.ndarray, brainmask: np.ndarray, atlas: np.ndarray, parcel_ids: np.ndarray, parcel_index: np.ndarray, sigma_scale: float, seed: int, voxel_weights: np.ndarray | None = None, ) -> np.ndarray: rng = np.random.default_rng(seed) perturbed = np.stack([perturb_metabolite_map(signals[idx], crlbs[idx], brainmask, sigma_scale, rng) for idx in range(signals.shape[0])]) return parcellate_zscored(perturbed, atlas, parcel_ids, parcel_index=parcel_index, voxel_weights=voxel_weights) def _rank_rows(features: np.ndarray) -> np.ndarray: """Vectorized row-wise ranking via double argsort, replacing a per-row ``scipy.stats.rankdata`` Python loop. Perturbation-averaged floating-point parcel means essentially never tie exactly, so plain (non-tie-averaged) ranks are used; this matches ``rankdata``'s output for the tie-free case that occurs in practice here. """ order = np.argsort(features, axis=1, kind="mergesort") ranks = np.empty_like(order, dtype=np.float64) row_index = np.arange(features.shape[0])[:, None] ranks[row_index, order] = np.arange(1, features.shape[1] + 1) return ranks def _correlate(features: np.ndarray, method: str) -> np.ndarray: if method == "pearson": return np.corrcoef(features) if method == "spearman": ranked = _rank_rows(features) return np.corrcoef(ranked) if method == "cosine": norms = np.linalg.norm(features, axis=1, keepdims=True) normed = np.divide(features, norms, out=np.zeros_like(features), where=norms > 0) return normed @ normed.T if method == "euclidean_distance": return squareform(pdist(np.nan_to_num(features), metric="euclidean")) raise ValueError(f"Unsupported connectivity method: {method}")
[docs] @dataclass class MetabolicProfileResult: """Perturbation-augmented regional metabolic profiles (features), the shared input to any downstream analysis that treats a parcel's metabolite panel as a single feature vector -- notably connectivity (``compute_metabolite_connectivity``), but reusable independently of it. """ features: np.ndarray # shape (n_parcels, n_metabolites * n_perturbations) parcel_concentrations: np.ndarray # shape (n_metabolites, n_parcels), raw (un-zscored) parcel means metabolites: list[str] parcel_ids: np.ndarray n_perturbations: int sigma_scale: float gm_weighted: bool
[docs] @dataclass class ConnectivityResult: similarity: pd.DataFrame parcel_concentrations: np.ndarray # shape (n_metabolites, n_parcels), raw (un-zscored) parcel means metabolites: list[str] parcel_ids: np.ndarray method: str n_perturbations: int sigma_scale: float gm_weighted: bool
[docs] def compute_metabolic_profiles( metabolite_maps: dict[str, Path], crlb_maps: dict[str, Path], brainmask_path: Path, atlas_path: Path, parcel_ids: list[int], n_perturbations: int = 50, sigma_scale: float = 2.0, nthreads: int = 1, seed: int | None = None, gm_fraction_path: Path | None = None, ) -> MetabolicProfileResult: """CRLB-scaled Monte Carlo perturbation of every metabolite map, z-scored and parcellated ``n_perturbations`` times (Instrella & Juchem 2024 uncertainty propagation), yielding the augmented per-parcel feature matrix that both regional-profile consumers and ``compute_metabolite_connectivity`` build on. """ metabolites = [met for met in metabolite_maps if met in crlb_maps] if not metabolites: raise ValueError("No metabolites with both signal and CRLB maps available for metabolic profile computation.") signals = np.stack([load_3d_data(metabolite_maps[met], label=f"{met} map")[1] for met in metabolites]) crlbs = np.stack([load_3d_data(crlb_maps[met], label=f"{met} CRLB map")[1] for met in metabolites]) brainmask = load_3d_data(brainmask_path, label="brainmask")[1] atlas = load_3d_data(atlas_path, label="MRSI atlas")[1].astype(int) parcel_ids_arr = np.asarray(parcel_ids, dtype=int) parcel_index = build_parcel_indexer(atlas, parcel_ids_arr) gm_fraction = load_3d_data(gm_fraction_path, label="GM partial volume fraction")[1] if gm_fraction_path is not None and Path(gm_fraction_path).exists() else None seed = seed if seed is not None else np.random.SeedSequence().entropy seeds = [int(seed) + index for index in range(n_perturbations)] progress_columns = (TextColumn("[progress.description]{task.description}"), BarColumn(), MofNCompleteColumn(), TimeElapsedColumn()) with Progress(*progress_columns, transient=True) as progress: task = progress.add_task("Regional metabolite extraction (perturbations)", total=n_perturbations) if nthreads <= 1: samples = [] for task_seed in seeds: samples.append(_sample_one(signals, crlbs, brainmask, atlas, parcel_ids_arr, parcel_index, sigma_scale, task_seed, voxel_weights=gm_fraction)) progress.advance(task) else: samples = [None] * len(seeds) with ThreadPoolExecutor(max_workers=nthreads) as executor: futures = { executor.submit(_sample_one, signals, crlbs, brainmask, atlas, parcel_ids_arr, parcel_index, sigma_scale, task_seed, voxel_weights=gm_fraction): index for index, task_seed in enumerate(seeds) } for future in as_completed(futures): samples[futures[future]] = future.result() progress.advance(task) # shape (n_perturbations, n_metabolites, n_parcels) -> (n_parcels, n_metabolites * n_perturbations) stacked = np.stack(samples) features = stacked.transpose(2, 1, 0).reshape(len(parcel_ids_arr), -1) parcel_concentrations = parcellate_means(signals, atlas, parcel_ids_arr, parcel_index=parcel_index, voxel_weights=gm_fraction) return MetabolicProfileResult( features=features, parcel_concentrations=parcel_concentrations, metabolites=metabolites, parcel_ids=parcel_ids_arr, n_perturbations=n_perturbations, sigma_scale=sigma_scale, gm_weighted=gm_fraction is not None, )
[docs] def correlate_metabolic_profiles(profiles: MetabolicProfileResult, method: str = "spearman") -> ConnectivityResult: """Metabolic similarity matrix from an already-computed :class:`MetabolicProfileResult` -- the optional add-on step over profile estimation.""" matrix = _correlate(profiles.features, method) similarity = pd.DataFrame(matrix, index=profiles.parcel_ids, columns=profiles.parcel_ids) return ConnectivityResult( similarity=similarity, parcel_concentrations=profiles.parcel_concentrations, metabolites=profiles.metabolites, parcel_ids=profiles.parcel_ids, method=method, n_perturbations=profiles.n_perturbations, sigma_scale=profiles.sigma_scale, gm_weighted=profiles.gm_weighted, )
[docs] def compute_metabolite_connectivity( metabolite_maps: dict[str, Path], crlb_maps: dict[str, Path], brainmask_path: Path, atlas_path: Path, parcel_ids: list[int], method: str = "spearman", n_perturbations: int = 50, sigma_scale: float = 2.0, nthreads: int = 1, seed: int | None = None, gm_fraction_path: Path | None = None, ) -> ConnectivityResult: """Convenience wrapper: profile estimation immediately followed by correlation. Prefer calling :func:`compute_metabolic_profiles` once and reusing its result across multiple ``--connectivity-method`` values, or when profiles are needed without a similarity matrix.""" profiles = compute_metabolic_profiles( metabolite_maps, crlb_maps, brainmask_path, atlas_path, parcel_ids, n_perturbations=n_perturbations, sigma_scale=sigma_scale, nthreads=nthreads, seed=seed, gm_fraction_path=gm_fraction_path, ) return correlate_metabolic_profiles(profiles, method=method)