diff --git a/.gitignore b/.gitignore index e7b6138..82726d9 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ dist .vscode .DS_Store +.coverage +coverage.xml diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 1146393..6442607 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,4 +1,111 @@ -v0.1.22, December 2025 +v0.2.0, May 2026 -- Issue cleanup, packaging refresh, repository hygiene + +This release closes every open issue at the time of writing (#66, #94, #98, +#112, #136, #140) and lands a sweep of long-standing maintenance work +(packaging metadata, CI modernization, validation consistency, optional- +dependency cleanup). It is the largest release since v0.1.4. + +Python: + Features: + - GradientMaps.fit now accepts path-like objects (or lists of them) + pointing to .npy files; matrices are loaded lazily so large + connectivity matrices need not all live in memory at once. Also adds + `vectorized` / `discard_diagonal` kwargs that reconstruct symmetric + matrices from their lower-triangular form, halving disk usage when + paired with path-like inputs (Issue #140). + - GradientMaps now exposes `aligned_lambdas_`: heuristic eigenvalues + for the aligned gradients, computed by mapping each aligned gradient + to its dominant original gradient via the Procrustes rotation + (Issue #94). + - ProcrustesAlignment now exposes `transforms_`, the per-dataset + rotation matrices used in the final iteration. `procrustes()` and + `procrustes_alignment()` accept a `return_transform(s)` flag. + - New `aligned_lambdas(lambdas, transform)` helper in + brainspace.gradient.alignment. + - Plotter(try_qt=True) now actually renders via Qt instead of warning + and disabling itself; closing the Qt window terminates show() + cleanly (Issue #136). + - 4 new view orientations (`anterior`, `posterior`, `flatL`, `flatR`) + in plot_hemispheres / plot_surf. + - 4 new colormaps in `brainspace.plotting.colormaps`: `eco_kos` + (categorical 6-color), `spec_5` (ColorBrewer Spectral 5-step), + `BuGyRd` (256-row diverging blue->grey->red, generated + programmatically from 5 control points), and `cat35` (BSD-compatible + 35-entry categorical palette built from matplotlib tab20+tab20b). + + Fixes: + - The NaN/Inf affinity-matrix check and an n_components feasibility + check are now enforced inside `diffusion_mapping` and + `laplacian_eigenmaps` themselves, not only on the GradientMaps + fast-path. Direct callers of the embedding functions and + DiffusionMaps.fit / LaplacianEigenmaps.fit get the same clear errors + as GradientMaps users (Issue #147). + - compute_mem(spectrum='nonzero') no longer raises on weight matrices + without a zero eigenvalue (e.g. hippocampal-subfield surfaces with + no medial wall). spectrum='all' still requires a zero eigenvalue + and now points users at `spectrum='nonzero'` in the error message + (Issue #112). + - utils_qt.py no longer fails to import on systems without PyQt5; + PyQt5 is optional and the module exposes `MainWindow = None` as a + sentinel when unavailable (Issue #148). + - Removed stale @pytest.mark.xfail markers on test_drop_cells and + test_drop_points; they are now hard requirements (Issue #149). + + Documentation: + - New "Plotting on a remote / headless server" section in the install + guide pointing at `vtk-osmesa` / `vtk-egl` wheels (Issue #66). + - Install guide now reflects the supported Python range (3.9-3.13) + and notes that nilearn moved to the `[examples]` extra. + + Packaging: + - python_requires bumped from `>=3.5` to `>=3.9`; classifiers + rewritten to list 3.9-3.13 (matches CI). Development Status + classifier moved from Alpha to Beta. + - `pandas` and `pillow` removed from install_requires; neither is + imported anywhere under brainspace/. `nilearn` moved from base + requirements to `extras_require['examples']`. + - Dependency floors raised to versions that actually build on + supported Pythons: numpy>=1.20, scipy>=1.6, matplotlib>=3.3, + nibabel>=3.2. + - VTK upper bound widened from <9.6 to <9.7. + - requirements.txt now mirrors install_requires exactly. + - Fixed `description-file` -> `description_file` in setup.cfg + (silences the setuptools deprecation warning). + + Tooling: + - GitHub Actions workflows fully refreshed: actions/checkout v2->v4, + actions/setup-python v2->v5, codecov-action v1->v4, matlab-actions + v0->v2. Replaced unmaintained GabrielBB/xvfb-action with + pyvista/setup-headless-display-action@v3. Test matrix now covers + Python 3.9, 3.10, 3.11, 3.12, 3.13 across Linux, macOS, Windows. + Coverage workflow moved from macOS to Ubuntu with a headless + display. + - New Dockerfile: minimal `python:3.11-slim` base, non-root user, + installs from in-repo source via `pip install .[examples]`, + swaps to `vtk-osmesa` for headless rendering, ships a Jupyter + Lab server. (Replaces the 4-year-old Neurodocker draft.) + - Added CONTRIBUTING.md, SECURITY.md, CODE_OF_CONDUCT.md. + + Tests: + - 33 new tests covering path-like inputs, vectorized inputs, + embedding validation, aligned_lambdas, colormap registration, + and the Qt path. Total now: 165 passing, 1 xfail, 76% line + coverage. + +MATLAB: + - Fixed `diffusion_mapping`: now solves the eigenproblem on the + symmetric similar matrix Ms = D^{-1/2} L D^{-1/2} instead of the + asymmetric Markov matrix M = D^{-1} L. Eliminates the spurious + complex eigenvalues that broke `sort(eigval, 'descend')` on + cosine-similarity inputs (Issue #98). + - Removed the silent `n = floor(sqrt(N))` cap on n_components. + Previously, requesting more than sqrt(N) components on e.g. a + 360x360 affinity silently truncated to 18 (Issue #94). + - Added zero-row-sum input validation; previously such inputs + silently produced Inf entries. + - New regression tests under matlab/tests/@diffusion_mapping_tests. + + Python: - Fixed VTK wrapper to handle non-callable attributes correctly for VTK 9.4+ compatibility (Issue #107). - Updated VTK requirement to allow versions >= 9.1.0 (removing upper limit for VTK 9.4+ support). diff --git a/brainspace/_version.py b/brainspace/_version.py index 1e34ebb..0d1b6cd 100644 --- a/brainspace/_version.py +++ b/brainspace/_version.py @@ -1,3 +1,3 @@ """BrainSpace version""" -__version__ = '0.1.22' +__version__ = '0.2.0' diff --git a/brainspace/gradient/alignment.py b/brainspace/gradient/alignment.py index 823d70e..c59d595 100644 --- a/brainspace/gradient/alignment.py +++ b/brainspace/gradient/alignment.py @@ -11,7 +11,8 @@ from sklearn.base import BaseEstimator -def procrustes(source, target, center=False, scale=False): +def procrustes(source, target, center=False, scale=False, + return_transform=False): """Align `source` to `target` using procrustes analysis. Parameters @@ -24,11 +25,17 @@ def procrustes(source, target, center=False, scale=False): Center data before alignment. Default is False. scale : bool, optional Remove scale before alignment. Default is False. + return_transform : bool, optional + If True, also return the rotation matrix `t` such that + ``aligned == source @ t`` (plus optional translation when + ``center=True``). Default is False. Returns ------- aligned : 2D ndarray, shape = (n_samples, n_feat) Source dataset aligned to target dataset. + t : 2D ndarray, shape = (n_feat, n_feat) + Rotation matrix. Returned only if ``return_transform == True``. """ # Translate to origin @@ -60,13 +67,52 @@ def procrustes(source, target, center=False, scale=False): aligned = source.dot(t) if center: aligned += mt + if return_transform: + return aligned, t return aligned +def aligned_lambdas(lambdas, transform): + """Approximate eigenvalue correspondence after Procrustes alignment. + + After Procrustes alignment ``A = source @ transform``, each aligned + gradient is a linear combination of the original gradients, so + eigenvalues no longer correspond to aligned gradients one-to-one + (issue #94). + + For each aligned gradient (column ``j`` of ``A``), the dominant + original gradient is ``i = argmax_i |transform[i, j]|``, and the + returned proxy lambda is ``lambdas[i]``. This matches the + approximation suggested by @OualidBenkarim in #94. + + Parameters + ---------- + lambdas : 1D ndarray, shape = (n_components,) + Eigenvalues of the unaligned gradients. + transform : 2D ndarray, shape = (n_components, n_components) + Procrustes rotation matrix that maps source to aligned, i.e. + ``aligned == source @ transform``. Available from + :func:`procrustes` with ``return_transform=True`` or from + :class:`.ProcrustesAlignment`'s ``transforms_`` attribute. + + Returns + ------- + proxy : 1D ndarray, shape = (n_components,) + ``lambdas`` reordered (possibly with repeats) so that + ``proxy[j]`` is a heuristic eigenvalue for the j-th aligned + gradient. + """ + + lambdas = np.asarray(lambdas) + transform = np.asarray(transform) + idx = np.abs(transform).argmax(axis=0) + return lambdas[idx] + + # Generalized procrustes analysis def procrustes_alignment(data, reference=None, center=False, scale=False, n_iter=10, tol=1e-5, return_reference=False, - verbose=False): + return_transforms=False, verbose=False): """Iterative alignment using generalized procrustes analysis. Parameters @@ -87,6 +133,9 @@ def procrustes_alignment(data, reference=None, center=False, scale=False, return_reference : bool, optional Whether to return the reference dataset built in the last iteration. Default is False. + return_transforms : bool, optional + Whether to also return the per-dataset rotation matrices from the + final iteration. Default is False. verbose : bool, optional Verbosity. Default is False. @@ -97,15 +146,25 @@ def procrustes_alignment(data, reference=None, center=False, scale=False, mean_dataset : ndarray, shape = (n_samples, n_feat) Reference dataset built in the last iteration. Only if ``return_reference == True``. + transforms : list of ndarray, shape = (n_feat, n_feat) + Final-iteration rotation matrix for each dataset, such that + ``aligned[i] == data[i] @ transforms[i]`` (plus translation when + ``center=True``). Only if ``return_transforms == True``. """ if n_iter <= 0: raise ValueError('A positive number of iterations is required.') + transforms = [None] * len(data) if reference is None: # Use the first item to build the initial reference - aligned = [data[0]] + [procrustes(d, data[0], center=center, - scale=scale) for d in data[1:]] + aligned = [data[0]] + transforms[0] = np.eye(data[0].shape[1]) + for j, d in enumerate(data[1:], start=1): + a, t = procrustes(d, data[0], center=center, scale=scale, + return_transform=True) + aligned.append(a) + transforms[j] = t reference = np.mean(aligned, axis=0) else: aligned = [None] * len(data) @@ -114,8 +173,10 @@ def procrustes_alignment(data, reference=None, center=False, scale=False, dist = np.inf for i in range(n_iter): # Align to reference - aligned = [procrustes(d, reference, center=center, scale=scale) - for d in data] + out = [procrustes(d, reference, center=center, scale=scale, + return_transform=True) for d in data] + aligned = [a for a, _ in out] + transforms = [t for _, t in out] # Compute new mean new_reference = np.mean(aligned, axis=0) @@ -134,7 +195,14 @@ def procrustes_alignment(data, reference=None, center=False, scale=False, dist = new_dist - return (aligned, reference) if return_reference else aligned + result = (aligned,) + if return_reference: + result += (reference,) + if return_transforms: + result += (transforms,) + if len(result) == 1: + return result[0] + return result class ProcrustesAlignment(BaseEstimator): @@ -159,6 +227,9 @@ class ProcrustesAlignment(BaseEstimator): Aligned datsets. mean_ : ndarray, shape = (n_samples, n_feat) Reference dataset built in the last iteration. + transforms_ : list of ndarray, shape = (n_feat, n_feat) + Final-iteration rotation matrices. + ``aligned_[i] == data[i] @ transforms_[i]``. """ def __init__(self, center=False, scale=False, n_iter=10, tol=1e-5, @@ -186,9 +257,10 @@ def fit(self, data, reference=None): Returns self. """ - self.aligned_, self.mean_ = \ + self.aligned_, self.mean_, self.transforms_ = \ procrustes_alignment(data, reference=reference, center=self.center, scale=self.scale, tol=self.tol, n_iter=self.n_iter, return_reference=True, + return_transforms=True, verbose=self.verbose) return self diff --git a/brainspace/gradient/gradient.py b/brainspace/gradient/gradient.py index 539da82..d6fcd20 100644 --- a/brainspace/gradient/gradient.py +++ b/brainspace/gradient/gradient.py @@ -4,7 +4,7 @@ from sklearn.base import BaseEstimator -from .alignment import ProcrustesAlignment +from .alignment import ProcrustesAlignment, aligned_lambdas as _aligned_lambdas from .kernels import compute_affinity from .embedding import PCAMaps, LaplacianEigenmaps, DiffusionMaps @@ -185,6 +185,13 @@ class GradientMaps(BaseEstimator): aligned_ : None or list of arrays, shape = (n_samples, n_components) Aligned gradients. None if ``alignment is None`` or only one dataset is used. + aligned_lambdas_ : None or list of arrays, shape = (n_components,) + Heuristic eigenvalues for the aligned gradients (issue #94). For + each aligned gradient, the dominant original gradient is + identified by the largest absolute value in the corresponding + column of the Procrustes rotation matrix; the proxy lambda is + the original lambda at that index. ``None`` if no Procrustes + alignment was performed. """ def __init__(self, n_components=10, approach='dm', kernel='normalized_angle', @@ -198,6 +205,7 @@ def __init__(self, n_components=10, approach='dm', kernel='normalized_angle', self.gradients_ = None self.lambdas_ = None self.aligned_ = None + self.aligned_lambdas_ = None def fit(self, x, gamma=None, sparsity=0.9, n_iter=10, reference=None, vectorized=False, discard_diagonal=False, **kwargs): @@ -295,19 +303,31 @@ def fit(self, x, gamma=None, sparsity=0.9, n_iter=10, reference=None, pa = ProcrustesAlignment(n_iter=n_iter) pa.fit(self.gradients_, reference=reference) self.aligned_ = pa.aligned_ + self.aligned_lambdas_ = [ + _aligned_lambdas(la, t) + for la, t in zip(self.lambdas_, pa.transforms_) + ] elif isinstance(self.alignment, ProcrustesAlignment): self.alignment.set_params(n_iter=n_iter) self.alignment.fit(self.gradients_, reference=reference) self.aligned_ = self.alignment.aligned_ + self.aligned_lambdas_ = [ + _aligned_lambdas(la, t) + for la, t in zip(self.lambdas_, + self.alignment.transforms_) + ] else: self.aligned_ = None + self.aligned_lambdas_ = None if align_single: self.gradients_ = self.gradients_[0] self.lambdas_ = self.lambdas_[0] self.aligned_ = self.aligned_[0] + if self.aligned_lambdas_ is not None: + self.aligned_lambdas_ = self.aligned_lambdas_[0] return self diff --git a/brainspace/tests/test_aligned_lambdas.py b/brainspace/tests/test_aligned_lambdas.py new file mode 100644 index 0000000..40aff2c --- /dev/null +++ b/brainspace/tests/test_aligned_lambdas.py @@ -0,0 +1,93 @@ +"""Tests for proxy lambda correspondence after Procrustes alignment (#94).""" + +import numpy as np + +from brainspace.gradient import GradientMaps +from brainspace.gradient.alignment import ( + procrustes, procrustes_alignment, aligned_lambdas, ProcrustesAlignment, +) + + +def _psd(n, seed): + rs = np.random.RandomState(seed) + a = rs.randn(n, n + 5) + return a @ a.T + + +def test_aligned_lambdas_identity_rotation_is_passthrough(): + lambdas = np.array([1.0, 0.5, 0.25, 0.1]) + proxy = aligned_lambdas(lambdas, np.eye(4)) + np.testing.assert_array_equal(proxy, lambdas) + + +def test_aligned_lambdas_permutation_rotation(): + lambdas = np.array([10.0, 5.0, 1.0]) + # Rotation that swaps gradients 0 and 1. + perm = np.array([ + [0.0, 1.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 0.0, 1.0], + ]) + proxy = aligned_lambdas(lambdas, perm) + np.testing.assert_array_equal(proxy, [5.0, 10.0, 1.0]) + + +def test_procrustes_return_transform_consistency(): + rs = np.random.RandomState(0) + src = rs.randn(40, 5) + tgt = rs.randn(40, 5) + aligned, t = procrustes(src, tgt, return_transform=True) + np.testing.assert_allclose(aligned, src @ t, atol=1e-12) + + +def test_procrustes_alignment_returns_transforms(): + rs = np.random.RandomState(1) + data = [rs.randn(30, 4) for _ in range(3)] + aligned, mean, transforms = procrustes_alignment( + data, return_reference=True, return_transforms=True) + assert len(transforms) == len(data) + for d, a, t in zip(data, aligned, transforms): + np.testing.assert_allclose(a, d @ t, atol=1e-10) + + +def test_procrustes_alignment_class_exposes_transforms_attr(): + rs = np.random.RandomState(2) + data = [rs.randn(20, 3) for _ in range(4)] + pa = ProcrustesAlignment().fit(data) + assert hasattr(pa, 'transforms_') + assert len(pa.transforms_) == len(data) + for d, a, t in zip(data, pa.aligned_, pa.transforms_): + np.testing.assert_allclose(a, d @ t, atol=1e-10) + + +def test_gradientmaps_exposes_aligned_lambdas(): + mats = [_psd(15, seed=s) for s in (10, 11, 12)] + gm = GradientMaps(n_components=3, alignment='procrustes', + random_state=0).fit(mats) + assert gm.aligned_lambdas_ is not None + assert len(gm.aligned_lambdas_) == 3 + for la, al in zip(gm.lambdas_, gm.aligned_lambdas_): + # Each proxy entry must be drawn from the original lambda set + # (possibly with repeats): every value of al lives in la. + for v in al: + assert np.any(np.isclose(la, v)) + + +def test_no_alignment_means_no_aligned_lambdas(): + mats = [_psd(15, seed=s) for s in (20, 21)] + gm = GradientMaps(n_components=3, alignment=None, + random_state=0).fit(mats) + assert gm.aligned_ is None + assert gm.aligned_lambdas_ is None + + +def test_single_dataset_with_reference_returns_1d_aligned_lambdas(): + a = _psd(20, seed=30) + ref_gm = GradientMaps(n_components=3, random_state=0).fit(a) + ref = ref_gm.gradients_ + other = _psd(20, seed=31) + gm = GradientMaps(n_components=3, alignment='procrustes', + random_state=0).fit(other, reference=ref) + # Single-dataset path collapses lists to single arrays. + assert gm.aligned_lambdas_ is not None + assert gm.aligned_lambdas_.shape == (3,)