Source code for spectralbrain.statistics.atlas_stats

"""Cluster-vs-atlas comparison statistics.

Compare SpectralBrain's data-driven partitions against reference atlases with the
size-matched-null framework that the parcellation-evaluation literature
(Arslan 2018; Eickhoff 2015; Craddock 2012) requires: ARI/AMI/NMI/VI and
within-parcel homogeneity are all confounded by parcel number and size, so every
quality number is reported against a size-matched random contiguous
parcellation, never on its own.

This module complements :mod:`spectralbrain.utils.atlas` (which provides atlas
*label* lookups) with the *statistical* comparison of partitions.

- :func:`compare_partitions` -- ARI, AMI, NMI, VI.
- :func:`parcel_overlap` -- Dice/Jaccard contingency + best-match mapping.
- :func:`spectral_homogeneity` -- within-parcel descriptor homogeneity.
- :func:`random_parcellation` -- contiguous size-matched null (graph Voronoi).
- :func:`homogeneity_vs_null` -- observed homogeneity vs the null distribution.
- :func:`cluster_atlas_concordance` -- full report with a size-matched null z/p.
- :func:`aggregate_across_subjects` -- median + bootstrap CI for a cohort.
- :func:`spatial_rand_index` -- spatially-aware ARI (spARI).
"""

from __future__ import annotations

from typing import Optional, Sequence

import numpy as np

# Re-export the numpy-clean cores directly (harmonised names).
from spectralbrain.statistics._clustercore.atlas import (  # noqa: F401
    aggregate_across_subjects,
    compare_partitions,
    parcel_overlap,
    spectral_homogeneity,
)
from spectralbrain.statistics._clustercore.atlas import (
    random_parcellation as _random_parcellation,
    homogeneity_vs_null as _homogeneity_vs_null,
    cluster_atlas_concordance as _cluster_atlas_concordance,
)
from spectralbrain.statistics._clustercore.partition import (  # noqa: F401
    adjusted_rand_index,
    normalized_mutual_info,
    spatial_rand_index,
    variation_of_information,
)

__all__ = [
    "compare_partitions",
    "parcel_overlap",
    "spectral_homogeneity",
    "random_parcellation",
    "homogeneity_vs_null",
    "cluster_atlas_concordance",
    "aggregate_across_subjects",
    "spatial_rand_index",
    "adjusted_rand_index",
    "normalized_mutual_info",
    "variation_of_information",
]


def _adjacency(faces, adjacency_list, n_vertices):
    if adjacency_list is not None:
        return adjacency_list
    if faces is not None:
        from spectralbrain.statistics._clustercore import adjacency_list_from_faces
        return adjacency_list_from_faces(np.asarray(faces), n_vertices)
    raise ValueError("Provide `faces` or `adjacency_list` for the size-matched null.")


[docs] def random_parcellation(n_parcels: int, *, faces: Optional[np.ndarray] = None, adjacency_list: Optional[Sequence[np.ndarray]] = None, n_vertices: Optional[int] = None, seed: int = 0): """Contiguous size-matched random parcellation (graph Voronoi). Accepts either ``faces`` (preferred) or a precomputed ``adjacency_list``. """ if n_vertices is None and faces is not None: n_vertices = int(np.asarray(faces).max()) + 1 adj = _adjacency(faces, adjacency_list, n_vertices) return _random_parcellation(adj, n_parcels, seed=seed)
[docs] def homogeneity_vs_null(features: np.ndarray, labels: np.ndarray, *, faces: Optional[np.ndarray] = None, adjacency_list: Optional[Sequence[np.ndarray]] = None, n_null: int = 100, method: str = "correlation", seed: int = 0, progress: bool = False) -> dict: """Within-parcel homogeneity vs a size-matched random-parcellation null. Accepts ``faces`` or ``adjacency_list``. Returns ``observed``, ``null_mean``, ``null_std``, ``z`` and one-sided empirical ``p``. """ adj = _adjacency(faces, adjacency_list, np.asarray(features).shape[0]) return _homogeneity_vs_null(features, labels, adj, n_null=n_null, method=method, seed=seed, progress=progress)
[docs] def cluster_atlas_concordance(cluster_labels: np.ndarray, atlas_labels: np.ndarray, *, faces: Optional[np.ndarray] = None, adjacency_list: Optional[Sequence[np.ndarray]] = None, distance: Optional[np.ndarray] = None, coords: Optional[np.ndarray] = None, background: Optional[int] = None, n_null: int = 100, seed: int = 0, progress: bool = False) -> dict: """Full cluster-vs-atlas concordance with a size-matched ARI null. Computes ARI/AMI/NMI/VI and mean best-Dice between the data-driven clusters and the atlas, optionally spARI (if ``distance``/``coords`` given), and the distribution of ARI between a size-matched random contiguous parcellation and the atlas. A significant positive ARI z-score means the clusters match the atlas more than a random parcellation of the same granularity would (Arslan 2018). Accepts ``faces`` or ``adjacency_list`` for the null. """ adj = None if faces is not None or adjacency_list is not None: adj = _adjacency(faces, adjacency_list, np.asarray(cluster_labels).shape[0]) return _cluster_atlas_concordance( cluster_labels, atlas_labels, adjacency_list=adj, distance=distance, coords=coords, background=background, n_null=n_null, seed=seed, progress=progress)