# Copyright 2026 BrainX Ecosystem Limited. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
# -*- coding: utf-8 -*-
"""Dimensionality, alignment and representational geometry of population activity.
Every function here is JAX-native: shapes depend only on the input shapes and
on static Python arguments, so all of them run under ``jax.jit`` and
``jax.vmap``.
"""
import operator
from typing import Any, NamedTuple, Optional, Tuple
import brainunit as u
import jax
import jax.numpy as jnp
from braintools._misc import set_module_as
__all__ = [
'PCAResult',
'CCAResult',
'pca',
'participation_ratio',
'cca',
'rdm',
'rsa',
]
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def _split(x: Any) -> Tuple[Any, Optional[u.Unit]]:
"""Split ``x`` into ``(mantissa, unit)``; ``unit`` is None for unitless input."""
if isinstance(x, u.Quantity):
if x.unit.is_unitless:
return x.mantissa, None
return x.mantissa, x.unit
return x, None
def _wrap(mantissa: Any, unit: Optional[u.Unit]) -> Any:
"""Attach ``unit`` to ``mantissa`` (no-op for unitless data)."""
if unit is None:
return mantissa
return u.Quantity(mantissa, unit=unit)
def _as_float(x: Any) -> jax.Array:
x = jnp.asarray(x)
if not jnp.issubdtype(x.dtype, jnp.floating):
x = x.astype(jnp.result_type(float))
return x
def _as_2d(x: jax.Array, name: str) -> jax.Array:
if x.ndim == 1:
x = x[:, None]
if x.ndim != 2:
raise ValueError(f'`{name}` must have shape (n_samples, n_features); got shape {x.shape}.')
return x
def _n_components(n_components: Any, maximum: int) -> int:
if n_components is None:
return maximum
if isinstance(n_components, bool):
raise TypeError(f'`n_components` must be a static int; got {n_components!r}.')
try:
n_components = operator.index(n_components)
except TypeError:
raise TypeError(f'`n_components` must be a static int; got {n_components!r}.') from None
if not 1 <= n_components <= maximum:
raise ValueError(f'`n_components` must be in [1, {maximum}]; got {n_components}.')
return int(n_components)
def _is_concrete(value: Any) -> bool:
return not isinstance(value, jax.core.Tracer)
def _inv_sqrt_psd(c: jax.Array) -> jax.Array:
"""Pseudo-inverse square root of a symmetric PSD matrix (eigh based)."""
w, v = jnp.linalg.eigh(c)
tol = jnp.max(jnp.abs(w)) * c.shape[0] * jnp.finfo(c.dtype).eps
ok = w > tol
inv = jnp.where(ok, 1.0 / jnp.sqrt(jnp.where(ok, w, 1.0)), 0.0)
return (v * inv) @ v.T
def _sign_of_max_abs(m: jax.Array) -> jax.Array:
"""Sign of the largest-magnitude entry of every row of ``m`` (never 0)."""
idx = jnp.argmax(jnp.abs(m), axis=1)
s = jnp.sign(jnp.take_along_axis(m, idx[:, None], axis=1)[:, 0])
return jnp.where(s == 0, 1.0, s).astype(m.dtype)
# ---------------------------------------------------------------------------
# PCA
# ---------------------------------------------------------------------------
[docs]
class PCAResult(NamedTuple):
"""Result of :func:`pca` (a pytree, so it passes through ``jax.jit``).
Attributes
----------
components : jax.Array
Principal axes, shape ``(k, D)``, unit-norm rows ordered by decreasing
explained variance (sklearn's ``components_``).
explained_variance : jax.Array or brainunit.Quantity
Variance along each axis, shape ``(k,)``, with ``ddof=1``. Carries the
squared input unit for Quantity input.
explained_variance_ratio : jax.Array
Fraction of the total variance explained by each axis, shape ``(k,)``.
mean : jax.Array or brainunit.Quantity
Per-feature mean that was subtracted, shape ``(D,)``.
projection : jax.Array or brainunit.Quantity
Mean-centred data projected onto the axes, shape ``(T, k)``.
"""
components: Any
explained_variance: Any
explained_variance_ratio: Any
mean: Any
projection: Any
@set_module_as('braintools.analysis.population')
def pca(x, n_components: Optional[int] = None) -> PCAResult:
r"""Principal component analysis by mean-centred SVD.
The data matrix is centred per feature and decomposed as
:math:`X_c = U S V^\top` [1]_. The rows of :math:`V^\top` are the principal
axes; the variance along axis :math:`i` is :math:`s_i^2 / (T - 1)`.
Signs are made deterministic as in sklearn's ``svd_flip`` with
``u_based_decision=False``: the largest-magnitude entry of every component
is positive.
Parameters
----------
x : array_like or brainunit.Quantity
Activity of shape ``(T, D)``: rows are samples (time points or trials),
columns are features (neurons). A 1-D array is treated as ``(T, 1)``.
n_components : int, optional
Number of components ``k`` to keep (static). Defaults to
``min(T, D)``, which is also the maximum.
Returns
-------
PCAResult
Named tuple ``(components (k, D), explained_variance (k,),
explained_variance_ratio (k,), mean (D,), projection (T, k))``.
``explained_variance`` carries ``unit**2`` and ``mean``/``projection``
carry ``unit`` when ``x`` is a Quantity. The ratio is relative to the
total variance over *all* ``min(T, D)`` components, as in sklearn; it
is ``nan`` for constant data (zero total variance).
Raises
------
ValueError
If ``x`` is not 1-D/2-D, has fewer than two samples, or
``n_components`` is out of range.
See Also
--------
participation_ratio : Effective dimensionality from the same spectrum.
cca : Alignment between two populations.
Notes
-----
Output shapes depend only on ``x.shape`` and the static ``n_components``,
so the function is jittable and vmappable (e.g. over trials). It is
differentiable through ``jnp.linalg.svd`` when the non-zero singular values
are distinct; repeated singular values make the gradient of the components
undefined. Matches ``sklearn.decomposition.PCA(n_components=k)`` (with
``whiten=False``): ``components_``, ``explained_variance_``,
``explained_variance_ratio_``, ``mean_`` and ``transform(x)``.
References
----------
.. [1] Jolliffe, I. T. and Cadima, J. (2016). "Principal component
analysis: a review and recent developments." Philosophical
Transactions of the Royal Society A 374: 20150202.
Examples
--------
.. code-block:: python
>>> import jax.numpy as jnp
>>> from braintools.analysis import population
>>> x = jnp.array([[1., 2.], [3., 4.], [5., 7.], [7., 9.]])
>>> res = population.pca(x)
>>> res.components.shape, res.projection.shape
((2, 2), (4, 2))
>>> [round(float(v), 3) for v in res.explained_variance_ratio]
[0.998, 0.002]
>>>
>>> # Quantities keep their units.
>>> import brainunit as u
>>> res = population.pca(x * u.mV, n_components=1)
>>> res.explained_variance.unit
Unit("mV^2")
"""
xm, unit = _split(x)
xm = _as_2d(_as_float(xm), 'x')
n_samples, n_features = xm.shape
if n_samples < 2:
raise ValueError(f'`pca` needs at least 2 samples; got {n_samples}.')
k_max = min(n_samples, n_features)
k = _n_components(n_components, k_max)
mean = jnp.mean(xm, axis=0)
xc = xm - mean
_, s, vt = jnp.linalg.svd(xc, full_matrices=False)
vt = vt * _sign_of_max_abs(vt)[:, None]
ev = s ** 2 / (n_samples - 1)
ratio = ev / jnp.sum(ev)
components = vt[:k]
projection = xc @ components.T
return PCAResult(
components=components,
explained_variance=_wrap(ev[:k], None if unit is None else unit ** 2),
explained_variance_ratio=ratio[:k],
mean=_wrap(mean, unit),
projection=_wrap(projection, unit),
)
@set_module_as('braintools.analysis.population')
def participation_ratio(x) -> jax.Array:
r"""Participation ratio (effective dimensionality) of population activity.
.. math::
\mathrm{PR} = \frac{\left(\sum_i \lambda_i\right)^2}{\sum_i \lambda_i^2}
= \frac{\operatorname{tr}(C)^2}{\operatorname{tr}(C^2)},
where :math:`\lambda_i` are the eigenvalues of the feature covariance
matrix :math:`C` [1]_ [2]_. It ranges from 1 (one dominant dimension) to
``D`` (isotropic variance).
Parameters
----------
x : array_like or brainunit.Quantity
Activity of shape ``(T, D)`` (samples by features). Units cancel.
Returns
-------
jax.Array
0-d participation ratio. ``nan`` for constant data (zero variance).
Raises
------
ValueError
If ``x`` is not 2-D or has fewer than two samples.
See Also
--------
pca : The covariance spectrum itself.
Notes
-----
Computed from traces without an eigendecomposition, using the smaller of
the ``(D, D)`` covariance and the ``(T, T)`` Gram matrix (both have the same
non-zero spectrum). The normalisation (``ddof``) cancels. Jittable,
vmappable and differentiable (except for constant data).
References
----------
.. [1] Gao, P., Trautmann, E., Yu, B., Santhanam, G., Ryu, S., Shenoy, K.
and Ganguli, S. (2017). "A theory of multineuronal dimensionality,
dynamics and measurement." bioRxiv 214262.
.. [2] Litwin-Kumar, A., Harris, K. D., Axel, R., Sompolinsky, H. and
Abbott, L. F. (2017). "Optimal degrees of synaptic connectivity."
Neuron 93(5): 1153-1164.
Examples
--------
.. code-block:: python
>>> import jax
>>> import jax.numpy as jnp
>>> from braintools.analysis import population
>>> x = jax.random.normal(jax.random.PRNGKey(0), (2000, 4))
>>> round(float(population.participation_ratio(x)))
4
>>> latent = jax.random.normal(jax.random.PRNGKey(1), (2000, 1))
>>> round(float(population.participation_ratio(latent @ jnp.ones((1, 4)))), 3)
1.0
"""
xm, _ = _split(x)
xm = _as_float(xm)
if xm.ndim != 2:
raise ValueError(f'`x` must have shape (n_samples, n_features); got shape {xm.shape}.')
n_samples, n_features = xm.shape
if n_samples < 2:
raise ValueError(f'`participation_ratio` needs at least 2 samples; got {n_samples}.')
xc = xm - jnp.mean(xm, axis=0)
gram = xc.T @ xc if n_features <= n_samples else xc @ xc.T
return jnp.trace(gram) ** 2 / jnp.sum(gram * gram)
# ---------------------------------------------------------------------------
# CCA
# ---------------------------------------------------------------------------
[docs]
class CCAResult(NamedTuple):
"""Result of :func:`cca` (a pytree, so it passes through ``jax.jit``).
Attributes
----------
correlations : jax.Array
Canonical correlations, shape ``(k,)``, in decreasing order.
x_weights : jax.Array or brainunit.Quantity
Canonical weights for ``x``, shape ``(p, k)`` (unit ``1/unit_x``).
y_weights : jax.Array or brainunit.Quantity
Canonical weights for ``y``, shape ``(q, k)`` (unit ``1/unit_y``).
"""
correlations: Any
x_weights: Any
y_weights: Any
@set_module_as('braintools.analysis.population')
def cca(x, y, n_components: Optional[int] = None, reg=0.0) -> CCAResult:
r"""Canonical correlation analysis via SVD of the whitened cross-covariance.
With centred data, covariances
:math:`C_{xx} = X_c^\top X_c/(T-1) + \text{reg}\,I`,
:math:`C_{yy}` likewise and :math:`C_{xy} = X_c^\top Y_c/(T-1)`, the
whitened cross-covariance
:math:`M = C_{xx}^{-1/2} C_{xy} C_{yy}^{-1/2} = U S V^\top` gives the
canonical correlations :math:`S` and weights
:math:`W_x = C_{xx}^{-1/2} U`, :math:`W_y = C_{yy}^{-1/2} V` [1]_ [2]_.
Parameters
----------
x : array_like or brainunit.Quantity
First data set, shape ``(T, p)`` (a 1-D array is treated as ``(T, 1)``).
y : array_like or brainunit.Quantity
Second data set, shape ``(T, q)``, same number of samples.
n_components : int, optional
Number of canonical pairs ``k`` (static). Defaults to ``min(p, q)``,
which is also the maximum.
reg : float, default=0.0
Ridge added to the diagonal of both covariance matrices (in the squared
data units). Must be non-negative. Use ``reg > 0`` when ``T`` is not
much larger than ``p + q``.
Returns
-------
CCAResult
Named tuple ``(correlations (k,), x_weights (p, k), y_weights (q, k))``.
With ``reg=0`` the canonical variates ``(x - x.mean(0)) @ x_weights``
have unit variance (``ddof=1``) and are mutually uncorrelated. Signs are
deterministic: the largest-magnitude entry of each ``x_weights`` column
is positive (and the paired ``y_weights`` column follows, so the
correlations are non-negative).
Raises
------
ValueError
If the inputs are not 1-D/2-D, their sample counts differ, there are
fewer than two samples, ``n_components`` is out of range or a concrete
``reg`` is negative.
See Also
--------
pca : Variance-maximising projection of a single data set.
Notes
-----
Rank-deficient covariances (e.g. collinear features with ``reg=0``) are
whitened with a pseudo-inverse square root, so the corresponding directions
get zero weight instead of producing ``inf``/``nan``. Jittable and
vmappable; differentiable for generic data (the ``eigh``-based whitening
has an undefined derivative when a covariance has repeated eigenvalues).
``sklearn.cross_decomposition.CCA`` is an iterative PLS-style algorithm and
only agrees approximately.
References
----------
.. [1] Hotelling, H. (1936). "Relations between two sets of variates."
Biometrika 28(3/4): 321-377.
.. [2] Uurtio, V., Monteiro, J. M., Kandola, J., Shawe-Taylor, J.,
Fernandez-Reyes, D. and Rousu, J. (2017). "A tutorial on canonical
correlation methods." ACM Computing Surveys 50(6): 95.
Examples
--------
.. code-block:: python
>>> import jax
>>> import jax.numpy as jnp
>>> from braintools.analysis import population
>>> x = jax.random.normal(jax.random.PRNGKey(0), (500, 3))
>>> y = x @ jnp.array([[1., 0.], [2., 1.], [0., -1.]]) # linear transform
>>> res = population.cca(x, y)
>>> [round(float(c), 4) for c in res.correlations]
[1.0, 1.0]
>>> res.x_weights.shape, res.y_weights.shape
((3, 2), (2, 2))
"""
xm, ux = _split(x)
ym, uy = _split(y)
xm = _as_2d(_as_float(xm), 'x')
ym = _as_2d(_as_float(ym), 'y')
if xm.shape[0] != ym.shape[0]:
raise ValueError(f'`x` and `y` must have the same number of samples; '
f'got {xm.shape[0]} and {ym.shape[0]}.')
n_samples, p = xm.shape
q = ym.shape[1]
if n_samples < 2:
raise ValueError(f'`cca` needs at least 2 samples; got {n_samples}.')
k = _n_components(n_components, min(p, q))
if _is_concrete(reg) and float(jnp.asarray(reg)) < 0:
raise ValueError(f'`reg` must be non-negative; got {reg}.')
dtype = jnp.result_type(xm.dtype, ym.dtype)
xm = xm.astype(dtype)
ym = ym.astype(dtype)
reg = jnp.asarray(reg, dtype=dtype)
xc = xm - jnp.mean(xm, axis=0)
yc = ym - jnp.mean(ym, axis=0)
denom = n_samples - 1
cxx = xc.T @ xc / denom + reg * jnp.eye(p, dtype=dtype)
cyy = yc.T @ yc / denom + reg * jnp.eye(q, dtype=dtype)
cxy = xc.T @ yc / denom
wx = _inv_sqrt_psd(cxx)
wy = _inv_sqrt_psd(cyy)
uu, s, vt = jnp.linalg.svd(wx @ cxy @ wy, full_matrices=False)
xw = wx @ uu[:, :k]
yw = wy @ vt[:k].T
signs = _sign_of_max_abs(xw.T)
xw = xw * signs
yw = yw * signs
return CCAResult(
correlations=jnp.minimum(s[:k], 1.0),
x_weights=_wrap(xw, None if ux is None else ux ** -1),
y_weights=_wrap(yw, None if uy is None else uy ** -1),
)
# ---------------------------------------------------------------------------
# RDM / RSA
# ---------------------------------------------------------------------------
_RDM_METRICS = ('correlation', 'euclidean', 'cosine')
@set_module_as('braintools.analysis.population')
def rdm(x, metric: str = 'correlation'):
r"""Representational dissimilarity matrix between conditions.
Entry ``(i, j)`` is the dissimilarity between the activity patterns of
conditions ``i`` and ``j`` (rows of ``x``) [1]_:
- ``'correlation'``: :math:`1 - r(x_i, x_j)`, one minus the Pearson
correlation across features;
- ``'cosine'``: :math:`1 - x_i \cdot x_j / (\lVert x_i\rVert \lVert x_j\rVert)`;
- ``'euclidean'``: :math:`\lVert x_i - x_j \rVert_2`.
Parameters
----------
x : array_like or brainunit.Quantity
Condition-by-feature patterns, shape ``(C, D)``.
metric : {'correlation', 'euclidean', 'cosine'}, default='correlation'
Dissimilarity measure (same definitions as
``scipy.spatial.distance.pdist``).
Returns
-------
jax.Array or brainunit.Quantity
Symmetric ``(C, C)`` matrix with a zero diagonal. ``'euclidean'``
carries the unit of ``x``; the other metrics are dimensionless.
Off-diagonal entries involving a zero-norm (``'cosine'``) or constant
(``'correlation'``) pattern are ``nan``, as in scipy.
Raises
------
ValueError
If ``x`` is not 2-D or ``metric`` is unknown.
See Also
--------
rsa : Compare two RDMs.
Notes
-----
Equal to ``scipy.spatial.distance.squareform(pdist(x, metric))``.
The Euclidean metric is computed from explicit differences (memory
``O(C^2 D)``) for accuracy. Jittable, vmappable and differentiable (the
Euclidean branch has a zero subgradient at zero distance).
References
----------
.. [1] Kriegeskorte, N., Mur, M. and Bandettini, P. (2008).
"Representational similarity analysis - connecting the branches of
systems neuroscience." Frontiers in Systems Neuroscience 2: 4.
Examples
--------
.. code-block:: python
>>> import jax.numpy as jnp
>>> from braintools.analysis import population
>>> x = jnp.array([[0., 0.], [3., 4.], [1., 0.]])
>>> population.rdm(x, metric='euclidean')
Array([[0. , 5. , 1. ],
[5. , 0. , 4.472136],
[1. , 4.472136, 0. ]], dtype=float32)
"""
if metric not in _RDM_METRICS:
raise ValueError(f'`metric` must be one of {_RDM_METRICS}; got {metric!r}.')
xm, unit = _split(x)
xm = _as_float(xm)
if xm.ndim != 2:
raise ValueError(f'`x` must have shape (n_conditions, n_features); got shape {xm.shape}.')
n = xm.shape[0]
if metric == 'euclidean':
def row(a):
sq = jnp.sum((a[None, :] - xm) ** 2, axis=1)
pos = sq > 0
return jnp.where(pos, jnp.sqrt(jnp.where(pos, sq, 1.0)), 0.0)
d = jax.vmap(row)(xm)
else:
z = xm - jnp.mean(xm, axis=1, keepdims=True) if metric == 'correlation' else xm
z = z / jnp.linalg.norm(z, axis=1, keepdims=True)
d = 1.0 - z @ z.T
d = 0.5 * (d + d.T)
d = jnp.where(jnp.eye(n, dtype=bool), 0.0, d)
return _wrap(d, unit if metric == 'euclidean' else None)
def _average_ranks(v: jax.Array) -> jax.Array:
"""1-based ranks with ties replaced by their average (``scipy.stats.rankdata``)."""
s = jnp.sort(v)
lo = jnp.searchsorted(s, v, side='left')
hi = jnp.searchsorted(s, v, side='right')
return (lo + hi + 1).astype(v.dtype) / 2
def _pearson(a: jax.Array, b: jax.Array) -> jax.Array:
a = a - jnp.mean(a)
b = b - jnp.mean(b)
return jnp.sum(a * b) / jnp.sqrt(jnp.sum(a * a) * jnp.sum(b * b))
def _upper(m: jax.Array, name: str) -> jax.Array:
if m.ndim == 1:
return m
if m.ndim != 2 or m.shape[0] != m.shape[1]:
raise ValueError(f'`{name}` must be a square (C, C) RDM or a condensed 1-D vector; '
f'got shape {m.shape}.')
iu = jnp.triu_indices(m.shape[0], k=1)
return m[iu]
@set_module_as('braintools.analysis.population')
def rsa(rdm_a, rdm_b, method: str = 'spearman') -> jax.Array:
r"""Representational similarity: correlation between two RDMs.
The strict upper triangles of the two dissimilarity matrices are correlated
[1]_ with Spearman's rank correlation (ranks with ties averaged) or with
Pearson's correlation.
Parameters
----------
rdm_a, rdm_b : array_like or brainunit.Quantity
Square ``(C, C)`` RDMs (e.g. from :func:`rdm`), or condensed vectors of
shape ``(C (C - 1) / 2,)`` as returned by ``scipy.spatial.distance.pdist``.
Both must have the same shape. Units cancel.
method : {'spearman', 'pearson'}, default='spearman'
Correlation coefficient.
Returns
-------
jax.Array
0-d correlation in ``[-1, 1]``. ``nan`` if either upper triangle is
constant or contains ``nan`` (scipy's ``nan_policy='propagate'``).
Raises
------
ValueError
If the shapes differ, are not square/1-D, or ``method`` is unknown.
See Also
--------
rdm : Build an RDM from condition patterns.
Notes
-----
Matches ``scipy.stats.spearmanr(a, b).statistic`` and
``scipy.stats.pearsonr(a, b).statistic`` on the condensed vectors. Ranks
are computed with a sort and two ``searchsorted`` calls, so the function is
jittable and vmappable (e.g. over a batch of model RDMs). ``'pearson'`` is
differentiable; ``'spearman'`` has a zero gradient almost everywhere.
References
----------
.. [1] Kriegeskorte, N., Mur, M. and Bandettini, P. (2008).
"Representational similarity analysis - connecting the branches of
systems neuroscience." Frontiers in Systems Neuroscience 2: 4.
Examples
--------
.. code-block:: python
>>> import jax.numpy as jnp
>>> from braintools.analysis import population
>>> x = jnp.array([[0., 1.], [1., 3.], [4., 2.], [5., 5.]])
>>> a = population.rdm(x, metric='euclidean')
>>> round(float(population.rsa(a, a ** 2)), 4) # monotone transform
1.0
>>> round(float(population.rsa(a, a ** 2, method='pearson')), 4)
0.9882
"""
if method not in ('spearman', 'pearson'):
raise ValueError(f"`method` must be 'spearman' or 'pearson'; got {method!r}.")
a, _ = _split(rdm_a)
b, _ = _split(rdm_b)
a = _as_float(a)
b = _as_float(b)
if a.shape != b.shape:
raise ValueError(f'`rdm_a` and `rdm_b` must have the same shape; got {a.shape} and {b.shape}.')
dtype = jnp.result_type(a.dtype, b.dtype)
va = _upper(a, 'rdm_a').astype(dtype)
vb = _upper(b, 'rdm_b').astype(dtype)
has_nan = jnp.any(jnp.isnan(va)) | jnp.any(jnp.isnan(vb))
if method == 'spearman':
va = _average_ranks(va)
vb = _average_ranks(vb)
return jnp.where(has_nan, jnp.nan, _pearson(va, vb))