Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,5 @@ dist
.vscode

.DS_Store
.coverage
coverage.xml
109 changes: 108 additions & 1 deletion CHANGELOG.txt
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
2 changes: 1 addition & 1 deletion brainspace/_version.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""BrainSpace version"""

__version__ = '0.1.22'
__version__ = '0.2.0'
88 changes: 80 additions & 8 deletions brainspace/gradient/alignment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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):
Expand All @@ -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,
Expand Down Expand Up @@ -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
22 changes: 21 additions & 1 deletion brainspace/gradient/gradient.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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',
Expand All @@ -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):
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading