Sign Flipping#

Source reconstruction leaves the sign of each parcel (channel) time course arbitrary: the same parcel can have opposite polarity in different sessions. This is an identifiability problem in the source reconstruction of M/EEG that cannot be avoided.

This matters when we pool sessions to train a model. The HMM and DyNeMo model dynamic changes in the covariance of the data — and the sign of the off-diagonal elements of the covariance depends on the (arbitrary) parcel signs. If the signs are not aligned across sessions, the model sees spurious covariance differences that are due to nothing more than a flipped sign. See the FAQ for more.

Sign flipping removes this ambiguity: for each session we search for the per-parcel +1/-1 vector whose (time-delay embedded) covariance best matches a common template, and flip the parcels accordingly. This tutorial covers:

  1. Simulating parcellated data.

  2. Visualising the sign ambiguity across sessions.

  3. Aligning the signs to a fixed template covariance.

  4. Using the median session as a template instead.

  5. Sign flipping arrays and fif files inside a processing pipeline.

Note, sign flipping is a cross-session step — it aligns sessions to each other — so it is applied to a group of parcellated sessions after preprocessing, source reconstruction and parcellation (see the MEG Processing tutorial).

Simulating parcellated data#

Rather than download a dataset, we simulate parcellated data for 5 sessions of 38 parcels. Each session is a mix of the same underlying spatial sources — so the sessions share a common covariance structure — but we give each session its own random per-parcel signs. This reproduces the situation in parcellated data: the covariance structure is shared across sessions, but the sign of each parcel is arbitrary and differs from session to session.

We fix the random seed so the example is reproducible.

import numpy as np
from osl_dynamics.data import Data

rng = np.random.default_rng(42)

n_sessions = 5
n_samples = 8000
n_parcels = 38

# Shared spatial mixing, so every session has the same covariance structure
mixing = rng.standard_normal([n_parcels, n_parcels])

arrays = []
for _ in range(n_sessions):
    sources = rng.standard_normal([n_samples, n_parcels])
    parcels = sources @ mixing.T
    parcels *= rng.choice([-1, 1], size=n_parcels)  # arbitrary per-parcel signs
    arrays.append(parcels.astype(np.float32))

data = Data(arrays)
print(data)
Loading files:   0%|          | 0/5 [00:00<?, ?it/s]
Loading files: 100%|██████████| 5/5 [00:00<00:00, 1386.91it/s]
Data
 id: 124720730392720
 n_sessions: 5
 n_samples: 40000
 n_channels: 38

Visualising the sign ambiguity#

The sign ambiguity shows up in the covariance. We compare sessions using the covariance of the time-delay embedded data (adding time-lagged copies of each channel), which is the representation the HMM and DyNeMo are trained on.

osl_dynamics.data.sign_flipping.calc_cov() computes this covariance for a session, and osl_dynamics.data.sign_flipping.calc_corr() measures how similar two covariances are (the correlation of their off-diagonal entries). Passing mode="abs" compares the absolute values, which is insensitive to the parcel signs.

from osl_dynamics.data import sign_flipping

# Settings for the covariance used in the sign-flip search
n_embeddings = 15
standardize = True

# Covariance of the time-delay embedded data for each session
ts = data.time_series()  # list of (n_samples, n_channels) arrays
covs = [sign_flipping.calc_cov(x, n_embeddings, standardize) for x in ts]

# Session-by-session similarity of the covariances
n_sessions = len(covs)
signed = np.zeros([n_sessions, n_sessions])
absolute = np.zeros([n_sessions, n_sessions])
for i in range(n_sessions):
    for j in range(n_sessions):
        signed[i, j] = sign_flipping.calc_corr(covs[i], covs[j], n_embeddings)
        absolute[i, j] = sign_flipping.calc_corr(covs[i], covs[j], n_embeddings, mode="abs")

Let’s plot both similarity matrices side by side.

import matplotlib.pyplot as plt

fig, axes = plt.subplots(1, 2, figsize=(10, 4.5))
for ax, matrix, title in zip(
    axes,
    [signed, absolute],
    ["Signed (sign-sensitive)", "Absolute (sign-invariant)"],
):
    im = ax.imshow(matrix, vmin=-1, vmax=1, cmap="RdBu_r")
    ax.set_title(title)
    ax.set_xlabel("Session")
    ax.set_ylabel("Session")
    ax.set_xticks(range(n_sessions))
    ax.set_yticks(range(n_sessions))
    fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
fig.tight_layout()
Signed (sign-sensitive), Absolute (sign-invariant)

The absolute matrix (right) is high everywhere: the sessions share the same underlying covariance structure. The signed matrix (left) is mixed — off-diagonal session pairs are weakly or negatively correlated. That difference is exactly the sign ambiguity: the structure is shared, but the signs are not aligned. Sign flipping aligns them.

Aligning to a template covariance#

We align every session to a fixed template covariance. We build the template once from a chosen reference session and save it to disk.

Using a fixed, saved template (rather than the median session of the current batch, which is the default) means the sign convention is identical every time you run the pipeline — so you can add more sessions later, or pool this dataset with another, and the signs still match. This reproducibility is why it is worth saving the template.

# Build the template from a reference session and save it
template_index = 0
template_cov = sign_flipping.calc_cov(ts[template_index], n_embeddings, standardize)
np.save("template_cov.npy", template_cov)

# Correlation of each session with the template *before* flipping
before = np.array([sign_flipping.calc_corr(c, template_cov, n_embeddings) for c in covs])

We do the sign flipping with the Data.align_channel_signs method. We pass the path to the saved template covariance and the same embedding settings we used to build it. The method searches for the best per-parcel flips for each session and applies them in place.

data.align_channel_signs(
    template_cov="template_cov.npy",
    n_embeddings=n_embeddings,
    standardize=standardize,
)
Calculating covariances:   0%|          | 0/5 [00:00<?, ?it/s]
Calculating covariances:  40%|████      | 2/5 [00:00<00:00, 11.73it/s]
Calculating covariances:  80%|████████  | 4/5 [00:00<00:00, 10.60it/s]
Calculating covariances: 100%|██████████| 5/5 [00:00<00:00, 10.91it/s]

Sign flipping:   0%|          | 0/5 [00:00<?, ?it/s]
Sign flipping: 100%|██████████| 5/5 [00:00<00:00, 79.00it/s]
2026-07-08 12:25:22 INFO osl-dynamics [base.py:1187:align_channel_signs]: Mean correlation with template: 0.952 (min: 0.938)

<osl_dynamics.data.base.Data object at 0x716eced4c890>

Let’s check the effect. We recompute each session’s covariance from the (now flipped) data and measure its correlation with the template again.

ts_flipped = data.time_series()
covs_flipped = [sign_flipping.calc_cov(x, n_embeddings, standardize) for x in ts_flipped]
after = np.array([sign_flipping.calc_corr(c, template_cov, n_embeddings) for c in covs_flipped])

print("Correlation with template before:", np.round(before, 3))
print("Correlation with template after: ", np.round(after, 3))
Correlation with template before: [ 1.     0.016  0.115  0.132 -0.002]
Correlation with template after:  [1.    0.94  0.938 0.94  0.941]

The reference session (session 0) is the template, so its correlation is 1 and unchanged. The other sessions move up towards the template after flipping. Let’s plot it.

fig, ax = plt.subplots(figsize=(6, 4))
x = np.arange(n_sessions)
width = 0.4
ax.bar(x - width / 2, before, width, label="Before")
ax.bar(x + width / 2, after, width, label="After")
ax.set_xlabel("Session")
ax.set_ylabel("Correlation with template")
ax.set_xticks(x)
ax.legend()
fig.tight_layout()
0 4 sign flipping

The sessions are now sign aligned and ready to be pooled for training a model.

Using the median session as a template#

If you do not pass a template, align_channel_signs picks the median session of the batch as the template automatically. This is convenient for a one-off analysis, but the choice of template then depends on which sessions happen to be in the batch — so it is not reproducible if you add or remove sessions later.

data = Data(arrays)
data.align_channel_signs(n_embeddings=15, standardize=True)

Sign flipping arrays#

In a processing pipeline you often hold a single session’s parcel data in memory as a (parcels, time) array — for example straight out of parcellation, before it is saved to a fif. sign_flipping.sign_flip flips such an array directly against a template covariance. It returns the flipped data, the per-parcel +1/-1 signs, and the correlation with the template.

Here we flip one session against the template we saved earlier, transposing our simulated data to (parcels, time).

# Parcel data for one session, shape (parcels, time)
parcel_data = ts[1].T

flipped_data, flips, corr = sign_flipping.sign_flip(
    parcel_data,
    template_cov="template_cov.npy",
    n_embeddings=n_embeddings,
    standardize=standardize,
)

print("Correlation with template before flipping:", round(float(before[1]), 3))
print("Correlation with template after flipping: ", round(float(corr), 3))
print("Signs applied:", flips)
Correlation with template before flipping: 0.016
Correlation with template after flipping:  0.94
Signs applied: [-1 -1 -1 -1  1 -1  1 -1  1  1  1  1 -1 -1 -1  1 -1  1 -1  1 -1  1  1 -1
  1 -1 -1 -1  1 -1  1 -1  1 -1 -1 -1  1 -1]

The correlation jumps from near zero to close to the template, using the same search that Data.align_channel_signs runs for every session.

MNE-Python FIF Files#

In a batch processing pipeline you often want to sign flip each session’s parcellated fif independently against a template you saved earlier — for example inside osl_dynamics.meeg.parallel.run. sign_flipping.sign_flip_mne_raw wraps sign_flip for this: pass a parcellated fif (a path or an mne.io.Raw) and a template covariance (a path or an array), and it reads the parcel data, flips it, and saves the sign-flipped fif.

from glob import glob
from osl_dynamics.data import sign_flipping

for parc_fif in sorted(glob("derivatives/*/lcmv-parc-raw.fif")):
    sflip_parc_fif = parc_fif.replace("lcmv-parc-raw.fif", "sflip-lcmv-parc-raw.fif")
    sign_flipping.sign_flip_mne_raw(
        parc_fif,
        template_cov="template_cov.npy",
        output_file=sflip_parc_fif,
    )

If you omit the output file, the sign-flipped data is returned as an mne.io.Raw object instead of being written to disk:

raw = sign_flipping.sign_flip_mne_raw(parc_fif, "template_cov.npy")

Next steps#

The sign-flipped data can now be prepared and used to train a model:

Total running time of the script: (0 minutes 5.185 seconds)

Gallery generated by Sphinx-Gallery