Source code for osl_dynamics.data.sign_flipping

"""Sign flipping.

Source reconstruction leaves the sign of each channel (parcel) arbitrary, so the
same parcel can have opposite polarity across sessions. Before pooling sessions
we align these signs: for each session we search for the ``+1``/``-1`` per-channel
vector whose (time-delay embedded, standardized) covariance best matches a
template.

For the common case of sign flipping a single parcellated fif file (or
:class:`mne.io.Raw`) to match a saved template covariance, see
:func:`sign_flip_mne_raw`.
"""

from typing import Optional, Tuple, Union

import mne
import numpy as np

from osl_dynamics.data import processing
from osl_dynamics.meeg.parcellation import convert_to_mne_raw, save_as_fif


[docs] def calc_cov( array: np.ndarray, n_embeddings: int, standardize: bool = True ) -> np.ndarray: """Covariance of time-delay embedded (optionally standardized) data. Parameters ---------- array : np.ndarray Data, shape (n_samples, n_channels). n_embeddings : int Number of time-delay embeddings. standardize : bool, optional Standardize the embedded data before computing the covariance. Returns ------- cov : np.ndarray Covariance, shape (n_channels * n_embeddings, n_channels * n_embeddings). """ array = processing.time_embed(array, n_embeddings) if standardize: array = processing.standardize(array, create_copy=False) return np.cov(array.T)
[docs] def calc_corr( M1: np.ndarray, M2: np.ndarray, n_embeddings: int, mode: Optional[str] = None ) -> float: """Correlation between the upper triangles of two covariances. The first ``n_embeddings`` diagonals are skipped (``k=n_embeddings`` in :func:`numpy.triu_indices`) so within-channel, near-lag terms — which do not depend on the sign — are excluded from the comparison. Parameters ---------- M1, M2 : np.ndarray Covariance matrices of the same shape. n_embeddings : int Number of time-delay embeddings (sets the skipped diagonal offset). mode : str, optional If ``"abs"``, compare the absolute values (sign-invariant), used when picking a median template session. Returns ------- corr : float Pearson correlation between the selected entries. """ if mode == "abs": M1 = np.abs(M1) M2 = np.abs(M2) m, n = np.triu_indices(M1.shape[0], k=n_embeddings) return np.corrcoef([M1[m, n], M2[m, n]])[0, 1]
[docs] def apply_flips_to_cov( cov: np.ndarray, flips: np.ndarray, n_embeddings: int ) -> np.ndarray: """Apply a ``±1`` per-channel flip vector to an embedded covariance.""" f = np.repeat(flips, n_embeddings)[np.newaxis, ...] return cov * (f.T @ f)
class _FlipObjective: """Reduced ``O(n_channels²)`` evaluator of the sign-flip correlation. The correlation :func:`calc_corr` compares depends on the ``±1`` per-channel flip vector ``f`` only through two quadratic forms, P(f) = sum_{p<q} f_p f_q A[p, q] (agreement with the template) Q(f) = sum_{p<q} f_p f_q B[p, q] (mean of the flipped covariance) where ``A`` and ``B`` are ``n_channels × n_channels`` matrices built once from the selected entries of the off-diagonal covariance blocks. The correlation is then a closed-form function of ``P(f)``, ``Q(f)`` and a few constants, so it can be evaluated exactly without rebuilding the full covariance. This reproduces ``calc_corr(apply_flips_to_cov(cov, f, E), template_cov, E)`` to machine precision. Parameters ---------- cov, template_cov : np.ndarray Session and template embedded covariances (from :func:`calc_cov`). n_channels : int Number of channels (parcels). n_embeddings : int Number of time-delay embeddings used to build the covariances. """ def __init__( self, cov: np.ndarray, template_cov: np.ndarray, n_channels: int, n_embeddings: int, ): C = np.asarray(cov) T = np.asarray(template_cov) E = n_embeddings N = n_channels * E # Validate shapes so misuse fails with a clear message rather than a # confusing error deeper in the search. if C.ndim != 2 or C.shape[0] != C.shape[1]: raise ValueError(f"cov must be a square 2D array, got shape {C.shape}.") if C.shape != T.shape: raise ValueError( f"cov {C.shape} and template_cov {T.shape} must have the same shape." ) if C.shape[0] != N: raise ValueError( f"cov side length {C.shape[0]} must equal " f"n_channels * n_embeddings = {n_channels} * {E} = {N}." ) # The exact entries calc_corr compares (strict upper triangle, k=E). All # of these fall in off-diagonal channel blocks; within-channel blocks are # skipped and do not depend on the sign. rows, cols = np.triu_indices(N, k=E) c = C[rows, cols] t = T[rows, cols] pc = rows // E # channel of each selected row qc = cols // E # channel of each selected column A = np.zeros((n_channels, n_channels)) B = np.zeros((n_channels, n_channels)) np.add.at(A, (pc, qc), c * t) np.add.at(B, (pc, qc), c) # Symmetrize; the diagonal stays zero (within-channel entries excluded). self.A = A + A.T self.B = B + B.T self.n_channels = n_channels self.S = float(len(rows)) # number of compared entries, |S| self.mT = t.sum() / self.S # mean of the template over S self.sigT = np.sqrt((t * t).sum() / self.S - self.mT**2) # std of template self.K = (c * c).sum() / self.S # mean of C^2 over S (flip-invariant) if self.sigT == 0.0 or self.K == 0.0: raise ValueError( "Degenerate covariance: zero variance across the compared " "off-diagonal entries of cov or template_cov." ) def corr_from_PQ(self, P, Q): """Correlation given the two quadratic forms ``P``, ``Q``. ``P`` and ``Q`` may each be a scalar or an array (the coordinate-ascent sweep evaluates every single-channel flip at once). """ mean_CF = Q / self.S # Clip tiny negatives from rounding; the true variance is non-negative. std_CF = np.sqrt(np.maximum(self.K - mean_CF**2, 0.0)) num = P / self.S - mean_CF * self.mT denom = std_CF * self.sigT # denom is 0 only for a (near-)constant flipped covariance; report 0 there # so the search sees no correlation instead of propagating nan/inf. with np.errstate(divide="ignore", invalid="ignore"): corr = num / denom return np.where(denom > 0, corr, 0.0) def corr(self, f: np.ndarray) -> np.ndarray: """Correlation with the template for flip vector ``f``.""" f = np.asarray(f, dtype=float) return self.corr_from_PQ(0.5 * f @ (self.A @ f), 0.5 * f @ (self.B @ f)) def _coordinate_ascent(obj: _FlipObjective, f: np.ndarray) -> Tuple[np.ndarray, float]: """Greedy single-flip coordinate ascent on the exact correlation. Repeatedly flip the single channel that most increases the correlation until no single flip helps. Fields ``u = A f`` and ``v = B f`` are maintained so each sweep costs ``O(n_channels)``. """ f = np.asarray(f, dtype=float).copy() u = obj.A @ f v = obj.B @ f P = 0.5 * f @ u Q = 0.5 * f @ v cur = obj.corr_from_PQ(P, Q) while True: # Flipping channel p: P -> P - 2 f_p u_p, Q -> Q - 2 f_p v_p. P_new = P - 2.0 * f * u Q_new = Q - 2.0 * f * v cand = obj.corr_from_PQ(P_new, Q_new) p = int(np.argmax(cand)) if cand[p] <= cur + 1e-12: break df = -2.0 * f[p] u += obj.A[:, p] * df v += obj.B[:, p] * df f[p] = -f[p] P, Q, cur = P_new[p], Q_new[p], cand[p] return f, float(cur)
[docs] def find_flips( cov: np.ndarray, template_cov: np.ndarray, n_channels: int, n_embeddings: int, n_random_starts: int = 3, n_spectral: int = 2, ) -> Tuple[np.ndarray, float]: """Find the ``±1`` per-channel flip vector aligning ``cov`` to a template. The objective is reduced to a small ``n_channels × n_channels`` problem that is exactly the correlation :func:`calc_corr` maximizes. In that form aligning the signs is a Z2 (Ising / MAX-CUT) synchronization problem, solved by: 1. warm starts: the sign of the leading eigenvector(s) of the reduced matrix (the spectral relaxation), plus the all-``+1`` start and ``n_random_starts`` random restarts; 2. exact greedy coordinate ascent on the true correlation from each start, keeping the best local optimum. Parameters ---------- cov : np.ndarray This session's embedded covariance (from :func:`calc_cov`). template_cov : np.ndarray Template embedded covariance to align to (same shape as ``cov``). n_channels : int Number of channels (parcels). n_embeddings : int Number of time-delay embeddings used to build ``cov``. n_random_starts : int, optional Number of random restarts (in addition to the spectral and all-``+1`` starts). The restarts use a fixed internal seed, so the whole search is deterministic and reproducible. n_spectral : int, optional Number of leading eigenvectors of the reduced matrix used as warm starts. Returns ------- flips : np.ndarray Per-channel flips in ``{+1, -1}``, shape (n_channels,), dtype int. metric : float The achieved correlation with ``template_cov``. """ # Fixed seed: the restarts only need to be diverse, not run-to-run random, # so the search stays fully deterministic and reproducible. rng = np.random.default_rng(0) obj = _FlipObjective(cov, template_cov, n_channels, n_embeddings) # Candidate starting points: all-+1, spectral warm starts, random restarts. starts = [np.ones(n_channels)] n_spectral = min(n_spectral, n_channels) if n_spectral > 0: _, evecs = np.linalg.eigh(obj.A) for k in range(1, n_spectral + 1): s = np.sign(evecs[:, -k]) s[s == 0] = 1.0 starts.append(s) for _ in range(n_random_starts): starts.append(rng.choice([-1.0, 1.0], size=n_channels).astype(float)) # Exact coordinate ascent from each start; keep the best local optimum. best_flips = np.ones(n_channels) best_metric = -np.inf for s in starts: flips, metric = _coordinate_ascent(obj, s) if metric > best_metric: best_flips, best_metric = flips, metric return best_flips.astype(int), float(best_metric)
[docs] def sign_flip( parcel_data: np.ndarray, template_cov: Union[str, np.ndarray], n_embeddings: int = 15, standardize: bool = True, ) -> Tuple[np.ndarray, np.ndarray, float]: """Sign flip parcel time courses to match a template covariance. Finds the per-parcel ``+1``/``-1`` signs whose (time-delay embedded, standardized) covariance best matches ``template_cov`` and applies them. This is the array-level core shared by :func:`sign_flip_mne_raw` and by scripts that already hold the parcel data in memory. Parameters ---------- parcel_data : np.ndarray Parcel time courses, shape ``(n_parcels, n_samples)``. template_cov : str or np.ndarray Template covariance to align to, or the path to a ``.npy`` file containing it. This must be the covariance of the time-delay embedded data (see :func:`calc_cov`), built with the same ``n_embeddings`` and ``standardize`` settings passed here. n_embeddings : int, optional Number of time-delay embeddings used to build the covariances. standardize : bool, optional Standardize the embedded data before computing the covariance. Returns ------- flipped_data : np.ndarray ``parcel_data`` with the per-parcel signs applied, same shape as the input. flips : np.ndarray The per-parcel ``+1``/``-1`` signs that were applied, shape ``(n_parcels,)``. corr : float Correlation between the flipped covariance and ``template_cov`` (the metric maximised by the flip search). """ if not isinstance(template_cov, np.ndarray): template_cov = np.load(str(template_cov)) cov = calc_cov(parcel_data.T, n_embeddings, standardize) flips, corr = find_flips( cov, template_cov, n_channels=parcel_data.shape[0], n_embeddings=n_embeddings, ) return parcel_data * flips[:, np.newaxis], flips, corr
[docs] def sign_flip_mne_raw( fif, template_cov: Union[str, np.ndarray], output_file: Optional[str] = None, n_embeddings: int = 15, standardize: bool = True, picks: str = "misc", extra_chans: Union[str, list, None] = "stim", ) -> Union[Tuple[np.ndarray, float], "mne.io.Raw"]: """Sign flip a parcellated fif file to match a template covariance. Convenience wrapper that loads parcel time courses from a fif file (or :class:`mne.io.Raw`), finds the per-parcel signs that best align its (time-delay embedded) covariance to ``template_cov``, and applies them. The result is either saved to a new fif file or returned as an :class:`mne.io.Raw` object (see ``output_file``). Parameters ---------- fif : str or mne.io.Raw Path to a parcellated fif file, or a loaded :class:`mne.io.Raw` object. The parcel time courses are read from the ``picks`` channels. template_cov : str or np.ndarray Template covariance to align to, or the path to a ``.npy`` file containing it. This must be the covariance of the time-delay embedded data (see :func:`calc_cov`), built with the same ``n_embeddings`` and ``standardize`` settings passed here. output_file : str, optional Path to save the sign-flipped fif file to. If ``None`` (the default), nothing is written to disk and the sign-flipped :class:`mne.io.Raw` object is returned instead (see Returns). n_embeddings : int, optional Number of time-delay embeddings used to build the covariances. standardize : bool, optional Standardize the embedded data before computing the covariance. picks : str, optional Channel type holding the parcel time courses. Parcellated fif files written by osl-dynamics store parcels as ``"misc"`` channels. extra_chans : str or list of str, optional Extra channels (e.g. ``"stim"``) to copy from the input to the output. Passed to :func:`osl_dynamics.meeg.parcellation.save_as_fif`. Examples -------- Save the sign-flipped data to a new fif file:: from osl_dynamics.data import sign_flipping sign_flipping.sign_flip_mne_raw( "lcmv-parc-raw.fif", "template_cov.npy", "sflip-lcmv-parc-raw.fif", ) Or get the sign-flipped data back as an :class:`mne.io.Raw` without saving:: raw = sign_flipping.sign_flip_mne_raw("lcmv-parc-raw.fif", "template_cov.npy") """ if isinstance(fif, mne.io.BaseRaw): raw = fif else: raw = mne.io.read_raw_fif(str(fif), preload=True) parcel_data = raw.get_data(picks=picks, reject_by_annotation="omit") flipped_data, _, _ = sign_flip(parcel_data, template_cov, n_embeddings, standardize) if output_file is None: return convert_to_mne_raw( flipped_data, raw, ch_names=[f"parcel_{i}" for i in range(flipped_data.shape[0])], extra_chans="stim" if extra_chans is None else extra_chans, ) save_as_fif(flipped_data, raw, filename=output_file, extra_chans=extra_chans)