Tutorial part 1: RSA on sensor-level MEG data#

In this tutorial, we will perform sensor-level RSA analysis on MEG data.

We will explore how representational similarity analysis (RSA) can be used to study the neural representational code within visual cortex. We will start with performing RSA on the sensor level data, followed by source level and finally we will perform group level statistical analysis. Along the way, we will encounter many of the functions and classes offered by MNE-RSA, which will always be presented in the form of links to the API Documentation which you are encouraged to explore.

The dataset we will be working with today is the MEG data of the Wakeman & Nelson (2015) “faces” dataset. During this experiment, participants were presented with a series of images, containing:

  • Faces of famous people that the participants likely knew

  • Faces of people that the participants likely did not know

  • Scrambled faces: the images were cut-up and randomly put together again

As a first step, you need to download and extract the dataset: rsa-data.zip. You can either do this by executing the cell below, or you can do so manually. In any case, make sure that the data_path variable points to where you have extracted the rsa-data.zip file to.

# ruff: noqa: E402
# sphinx_gallery_thumbnail_number=8

import os

import pooch

# Download and unzip the data
pooch.retrieve(
    url="https://github.com/wmvanvliet/neuroscience_tutorials/releases/download/2/rsa-data.zip",
    known_hash="md5:859c0684dd25f8b82d011840725cbef6",
    progressbar=True,
    processor=pooch.Unzip(members=["data"], extract_dir=os.getcwd()),
)
# Set this to where you've extracted `rsa-data.zip` to
data_path = "data"

A representational code for the stimuli#

Let’s start by taking a look at the stimuli that were presented during the experiment. They reside in the stimuli folder for you as .bmp image files. The Python Imaging Library (PIL) can open them and we can use matplotlib to display them.

import matplotlib.pyplot as plt
from PIL import Image

# Show the first "famous" face and the first "scrambled" face
img_famous = Image.open(f"{data_path}/stimuli/f001.bmp")
img_scrambled = Image.open(f"{data_path}/stimuli/s001.bmp")

fig, axes = plt.subplots(1, 2, figsize=(10, 5))
axes[0].imshow(img_famous, cmap="gray")
axes[0].set_title(f"Famous face: {img_famous.width} x {img_famous.height} pixels")
axes[1].imshow(img_scrambled, cmap="gray")
axes[1].set_title(
    f"Scrambled face: {img_scrambled.width} x {img_scrambled.height} pixels"
)
axes[0].axis("off")
axes[1].axis("off")
Famous face: 128 x 162 pixels, Scrambled face: 128 x 162 pixels
(np.float64(-0.5), np.float64(127.5), np.float64(161.5), np.float64(-0.5))

Loaded like this, the stimuli are in a representational space defined by their pixels. Each image is represented by 128 x 162 = 20736 values between 0 (black) and 255 (white). Let’s create a Representational Dissimilarity Matrix (RDM) where images are compared based on the difference between their pixels. To get the pixels of an image, you can convert it to a NumPy array like this:

import numpy as np

pixels_famous = np.array(img_famous)
pixels_scrambled = np.array(img_scrambled)

print("Shape of the pixel array for the famous face:", pixels_famous.shape)
print("Shape of the pixel array for the scrambled face:", pixels_scrambled.shape)
Shape of the pixel array for the famous face: (162, 128)
Shape of the pixel array for the scrambled face: (162, 128)

We can now compute the “dissimilarity” between the two images, based on their pixels. For this, we need to decide on a metric to use. The default metric used in the original publication (Kiegeskorte et al. 2008) was Pearson Correlation, so let’s use that. Of course, correlation is a metric of similarity and we want a metric of dissimilarity. Let’s make it easy on ourselves and just do \(1 - r\).

from scipy.stats import pearsonr

similarity, _ = pearsonr(pixels_famous.flatten(), pixels_scrambled.flatten())
dissimilarity = 1 - similarity
print(
    "The dissimilarity between the pixels of the famous and scrambled faces is:",
    dissimilarity,
)
The dissimilarity between the pixels of the famous and scrambled faces is: 0.4176835093510478

To construct the full RDM, we need to do this for all pairs of images. In the cell below, we make a list of all image files and load all of them (there are 450), convert them to NumPy arrays and concatenate them all together in a single big array called pixels of shape n_images x width x height.

from glob import glob

files = sorted(glob(f"{data_path}/stimuli/*.bmp"))
print(f"There are {len(files)} images to read.")

pixels = np.array([np.array(Image.open(f)) for f in files])
print("The dimensions of the `pixel` array are:", pixels.shape)
There are 450 images to read.
The dimensions of the `pixel` array are: (450, 162, 128)

Your first RDM#

Now that you have all the images loaded in, computing the pairwise dissimilarities is a matter of looping over them and computing correlations. We could do this manually, but we can make our life a lot easier by using MNE-RSA’s mne_rsa.compute_rdm() function. It wants the big matrix as input and also takes a metric parameter to select which dissimilarity metric to use. Setting it to metric="correlation", which is also the default by the way, will make it use (1 - Pearson correlation) as a metric like we did manually above.

from mne_rsa import compute_rdm, plot_rdms

pixel_rdm = compute_rdm(pixels)
plot_rdms(pixel_rdm, names="pixels")
pixels
<Figure size 200x200 with 2 Axes>

Staring deeply into this RDM will reveal to you which images belonged to the “scrambled faces” class, as those pixels are quite different from the actual faces and each other. We also see that for some reason, the famous faces are a little more alike than the unknown faces.

The RDM is symmetric along the diagonal, which is all zeros. Take a moment to ponder why that would be.

Note

The mne_rsa.compute_rdm() function is a wrapper around scipy.spatial.distance.pdist(). This means that all the metrics supported by pdist() are also valid for mne_rsa.compute_rdm(). This also means that in MNE-RSA, the native format for an RDM is the so-called “condensed” form. Since RDMs are symmetric, only the upper triangle is stored. The scipy.spatial.distance.squareform() function can be used to go from a square matrix to its condensed form and back.

Your second RDM#

There are many sensible representations possible for images. One intriguing one is to create them using convolutional neural networks (CNNs). For example, there is the FaceNet model by Schroff et al. (2015) that can generate high-level representations, such that different photos of the same face have similar representations. I have run the stimulus images through FaceNet and recorded the generated embeddings for you to use:

store = np.load(f"{data_path}/stimuli/facenet_embeddings.npz")
filenames = store["filenames"]
embeddings = store["embeddings"]
print(
    "For each of the 450 images, the embedding is a vector of length 512:",
    embeddings.shape,
)

facenet_rdm = compute_rdm(embeddings)
For each of the 450 images, the embedding is a vector of length 512: (450, 512)

Lets plot both RDMs side-by-side:

plot_rdms([pixel_rdm, facenet_rdm], names=["pixels", "facenet"])
pixels, facenet
<Figure size 400x200 with 3 Axes>

A look at the brain data#

We’ve seen how we can create RDMs using properties of the images or embeddings generated by a model. Now it’s time to see how we create RDMs based on the MEG data. For that, we first load the epochs from a single participant.

import mne

epochs = mne.read_epochs(f"{data_path}/sub-02/sub-02-epo.fif")
epochs
Reading /home/vanvlm1/projects/mne-rsa/examples/tutorials/data/sub-02/sub-02-epo.fif ...
    Found the data of interest:
        t =    -200.00 ...    2900.00 ms
        0 CTF compensation matrices available
Adding metadata with 2 columns
879 matching events found
No baseline correction applied
0 projection items activated
General
Filename(s) sub-02-epo.fif
MNE object type EpochsFIF
Measurement date 2009-04-09 at 11:04:14 UTC
Participant
Experimenter MEG
Acquisition
Total number of events 879
Events counts face/famous/first: 147
face/famous/immediate: 78
face/famous/long: 66
face/unfamiliar/first: 149
face/unfamiliar/immediate: 65
face/unfamiliar/long: 79
scrambled/first: 150
scrambled/immediate: 71
scrambled/long: 74
Time range -0.200 – 2.900 s
Baseline -0.200 – 0.000 s
Sampling frequency 220.00 Hz
Time points 683
Metadata 879 rows × 2 columns
Channels
Magnetometers
Gradiometers
EOG
ECG
Stimulus
Head & sensor digitization 137 points
Filters
Highpass 1.00 Hz
Lowpass 40.00 Hz


Each epoch corresponds to the presentation of an image, and the signal across the sensors over time can be used as the neural representation of that image. Hence, one could make a neural RDM, of for example the gradiometers in the time window 100 to 200 ms after stimulus onset, like this:

neural_rdm = compute_rdm(epochs.copy().pick("grad").crop(0.1, 0.2).get_data())
plot_rdms(neural_rdm)
01 sensor level tutorial
<Figure size 200x200 with 2 Axes>

To compute RSA scores, we want to compare the resulting neural RDM with the RDMs we’ve created earlier. However, if we inspect the neural RDM closely, we see that its rows and column don’t line up with those of the previous RDMs. There are too many (879 vs. 450) and they are in the wrong order. Making sure that the RDMs match is an important and sometimes tricky part of RSA.

To help us out, a useful feature of MNE-Python is that epochs have an associated mne.Epochs.metadata field. This metadata is a pandas.DataFrame where each row contains information about the corresponding epoch. The epochs in this tutorial come with some useful .metadata already:

epochs.metadata
trigger file
0 13 u032.bmp
1 14 u032.bmp
2 13 u088.bmp
3 13 u084.bmp
4 5 f123.bmp
... ... ...
882 5 f016.bmp
883 6 f016.bmp
884 5 f002.bmp
885 6 f002.bmp
886 7 f150.bmp

879 rows × 2 columns



While the trigger codes only indicate what type of stimulus was shown, the file column of the metadata tells us the exact image. Couple of challenges here: the stimuli where shown in a random order, stimuli were repeated twice during the experiment, and some epochs were dropped during preprocessing so not every image is necessarily present twice in the epochs data. 😩

Luckily, MNE-RSA has a way to make our lives easier. Let’s take a look at the mne_rsa.rdm_epochs() function, the Swiss army knife for computing RDMs from an MNE-Python mne.Epochs object.

In MNE-Python tradition, the function has a lot of parameters, but all-but-one have a default so you only have to specify the ones that are relevant to you. For example, to redo the neural RDM we created above, we could do something like:

from mne_rsa import rdm_epochs

neural_rdm_gen = rdm_epochs(epochs, tmin=0.1, tmax=0.2)

# `rdm_epochs` returns a generator of RDMs
# unpacking the first (and only) RDM from the generator
neural_rdm = next(neural_rdm_gen)
plot_rdms(neural_rdm)
01 sensor level tutorial
<Figure size 200x200 with 2 Axes>

Take note that mne_rsa.rdm_epochs() returns a generator of RDMs. This is because one of the main use-cases for MNE-RSA is to produce RDMs using sliding windows (in time and also in space), which can produce a large amount of RDMs that can take up a lot of memory of you’re not careful.

The y parameter that solves most alignment problems#

Looking at the neural RDM above, something is clearly different from the one we made before. This one has 9 rows and columns. Closely inspecting the docstring of mne_rsa.rdm_epochs reveals that it is the y parameter that is responsible for this:

y : ndarray of int, shape (n_items,) | None
    For each Epoch, a number indicating the item to which it belongs. When
    None, the event codes are used to differentiate between items.
    Defaults to None.

Instead of producing one row per epoch, mne_rsa.rdm_epochs() produced one row per event type, averaging across epochs of the same type before computing dissimilarity. This is not quite what we want though. If we want to match pixel_rdm and facenet_rdm, we want every single one of the 450 images to be its own stimulus type. We can achieve this by setting the y parameter of mne_rsa.rdm_epochs() to a list that assigns each of the 879 epochs to a number from 1-450 (or 0-449) indicating which image was shown. We must take care to assign the numbers according to the order in which they appear in pixel_rdm and facenet_rdm. An image is identified by its filename, and we have the filenames variable left over from earlier that contains all the images in the proper order. The epochs.metadata["file"] column contains the filenames corresponding to the epochs and we can use NumPy’s numpy.searchsorted() function to align it with filenames.

y = np.searchsorted(filenames, epochs.metadata.file)
neural_rdm = next(rdm_epochs(epochs, y=y, tmin=0.1, tmax=0.2))

# This plots your RDM
plot_rdms(neural_rdm)
01 sensor level tutorial
<Figure size 200x200 with 2 Axes>

The cell below will compure RSA between the neural RDM and the pixel and FaceNet RDMs we created earlier. The RSA score will be the Spearman correlation between the RDMs, which is the default metric used in the original RSA paper.

from mne_rsa import rsa

rsa_pixel = rsa(neural_rdm, pixel_rdm, metric="spearman")
rsa_facenet = rsa(neural_rdm, facenet_rdm, metric="spearman")

print("RSA score between neural RDM and pixel RDM:", rsa_pixel)
print("RSA score between neural RDM and FaceNet RDM:", rsa_facenet)
RSA score between neural RDM and pixel RDM: 0.07869920694906636
RSA score between neural RDM and FaceNet RDM: 0.07529582461337744

Slippin’ and slidin’ across time#

The neural representation of a stimulus is different across brain regions and evolves over time. For example, we would expect that the pixel RDM would be more similar to a neural RDM that we computed across the visual cortex at an early time point, and that the FaceNET RDM might be more similar to a neural RDM that we computed at a later time point.

For the remainder of this tutorial, we’ll restrict the epochs to only contain the sensors over the left occipital cortex.

Warning

Just because we select sensors over a certain brain region, does not mean the magnetic fields originate from that region. This is especially true for magnetometers. To make it a bit more accurate, we only select gradiometers.

picks = mne.channels.read_vectorview_selection("Left-occipital")
picks = ["".join(p.split(" ")) for p in picks]
epochs.pick(picks).pick("grad").crop(-0.1, 1)
General
Filename(s) sub-02-epo.fif
MNE object type EpochsFIF
Measurement date 2009-04-09 at 11:04:14 UTC
Participant
Experimenter MEG
Acquisition
Total number of events 879
Events counts face/famous/first: 147
face/famous/immediate: 78
face/famous/long: 66
face/unfamiliar/first: 149
face/unfamiliar/immediate: 65
face/unfamiliar/long: 79
scrambled/first: 150
scrambled/immediate: 71
scrambled/long: 74
Time range -0.100 – 1.000 s
Baseline -0.200 – 0.000 s
Sampling frequency 220.00 Hz
Time points 243
Metadata 879 rows × 2 columns
Channels
Gradiometers
Head & sensor digitization 137 points
Filters
Highpass 1.00 Hz
Lowpass 40.00 Hz


In the cell below, we use mne_rsa.rdm_epochs() to compute RDMs using a sliding window by setting the temporal_radius parameter to 0.1 seconds. We use the entire time range (tmin=None and tmax=None) and leave the result as a generator (so no next() calls).

neural_rdms_gen = rdm_epochs(epochs, y=y, temporal_radius=0.1)

And now we can consume the generator (with a nice progress bar) and plot a few of the generated RDMs:

from tqdm import tqdm

times = epochs.times[(epochs.times >= 0) & (epochs.times <= 0.9)]
neural_rdms_list = list(tqdm(neural_rdms_gen, total=len(times)))
plot_rdms(neural_rdms_list[::10], names=[f"t={t:.2f}" for t in times[::10]])
t=0.00, t=0.05, t=0.09, t=0.14, t=0.18, t=0.23, t=0.27, t=0.32, t=0.36, t=0.41, t=0.45, t=0.50, t=0.55, t=0.59, t=0.64, t=0.68, t=0.73, t=0.77, t=0.82, t=0.86
  0%|          | 0/199 [00:00<?, ?it/s]Creating temporal searchlight patches

  2%|▏         | 3/199 [00:00<00:07, 26.71it/s]
  4%|▎         | 7/199 [00:00<00:05, 32.70it/s]
  6%|▌         | 11/199 [00:00<00:05, 34.70it/s]
  8%|▊         | 15/199 [00:00<00:05, 35.54it/s]
 10%|▉         | 19/199 [00:00<00:04, 36.28it/s]
 12%|█▏        | 23/199 [00:00<00:04, 36.78it/s]
 14%|█▎        | 27/199 [00:00<00:04, 37.08it/s]
 16%|█▌        | 31/199 [00:00<00:04, 36.84it/s]
 18%|█▊        | 35/199 [00:00<00:04, 36.61it/s]
 20%|█▉        | 39/199 [00:01<00:04, 36.60it/s]
 22%|██▏       | 43/199 [00:01<00:04, 36.69it/s]
 24%|██▎       | 47/199 [00:01<00:04, 36.80it/s]
 26%|██▌       | 51/199 [00:01<00:04, 36.98it/s]
 28%|██▊       | 55/199 [00:01<00:03, 37.14it/s]
 30%|██▉       | 59/199 [00:01<00:03, 37.29it/s]
 32%|███▏      | 63/199 [00:01<00:03, 37.43it/s]
 34%|███▎      | 67/199 [00:01<00:03, 37.35it/s]
 36%|███▌      | 71/199 [00:01<00:03, 37.16it/s]
 38%|███▊      | 75/199 [00:02<00:03, 37.24it/s]
 40%|███▉      | 79/199 [00:02<00:03, 37.12it/s]
 42%|████▏     | 83/199 [00:02<00:03, 37.12it/s]
 44%|████▎     | 87/199 [00:02<00:03, 37.19it/s]
 46%|████▌     | 91/199 [00:02<00:02, 37.19it/s]
 48%|████▊     | 95/199 [00:02<00:02, 37.23it/s]
 50%|████▉     | 99/199 [00:02<00:02, 37.22it/s]
 52%|█████▏    | 103/199 [00:02<00:02, 37.11it/s]
 54%|█████▍    | 107/199 [00:02<00:02, 36.93it/s]
 56%|█████▌    | 111/199 [00:03<00:02, 36.99it/s]
 58%|█████▊    | 115/199 [00:03<00:02, 37.01it/s]
 60%|█████▉    | 119/199 [00:03<00:02, 37.00it/s]
 62%|██████▏   | 123/199 [00:03<00:02, 37.05it/s]
 64%|██████▍   | 127/199 [00:03<00:01, 37.20it/s]
 66%|██████▌   | 131/199 [00:03<00:01, 37.32it/s]
 68%|██████▊   | 135/199 [00:03<00:01, 37.44it/s]
 70%|██████▉   | 139/199 [00:03<00:01, 37.51it/s]
 72%|███████▏  | 143/199 [00:03<00:01, 37.25it/s]
 74%|███████▍  | 147/199 [00:03<00:01, 37.27it/s]
 76%|███████▌  | 151/199 [00:04<00:01, 37.38it/s]
 78%|███████▊  | 155/199 [00:04<00:01, 37.46it/s]
 80%|███████▉  | 159/199 [00:04<00:01, 37.53it/s]
 82%|████████▏ | 163/199 [00:04<00:00, 37.56it/s]
 84%|████████▍ | 167/199 [00:04<00:00, 37.62it/s]
 86%|████████▌ | 171/199 [00:04<00:00, 37.64it/s]
 88%|████████▊ | 175/199 [00:04<00:00, 37.68it/s]
 90%|████████▉ | 179/199 [00:04<00:00, 37.51it/s]
 92%|█████████▏| 183/199 [00:04<00:00, 37.34it/s]
 94%|█████████▍| 187/199 [00:05<00:00, 37.45it/s]
 96%|█████████▌| 191/199 [00:05<00:00, 37.51it/s]
 98%|█████████▊| 195/199 [00:05<00:00, 37.56it/s]
100%|██████████| 199/199 [00:05<00:00, 37.59it/s]
100%|██████████| 199/199 [00:05<00:00, 37.05it/s]

<Figure size 4000x200 with 21 Axes>

Putting it altogether for sensor-level RSA#

Now all that is left to do is compute RSA scored between the neural RDMs you’ve just created and the pixel and FaceNet RDMs. We could do this using the mne_rsa.rsa_gen() function, but I’d rather directly show you the mne_rsa.rsa_epochs() function that combines computing the neural RDMs with computing the RSA scores.

The signature of mne_rsa.rsa_epochs() is very similar to that of mne_rsa.rdm_epochs() The main difference is that we also give it the “model” RDMs, in our case the pixel and FaceNet RDMs. mne_rsa.rsa_epochs() will return the RSA scores as a list of mne.Evoked objects: one for each model RDM we gave it.

We compute the RSA scores for epochs against [pixel_rdm, facenet_rdm] and do this in a sliding windows across time, with a temporal radius of 0.1 seconds. Setting verbose=True will activate a progress bar. We can optionally set n_jobs=-1 to use multiple CPU cores to speed things up.

from mne_rsa import rsa_epochs

ev_rsa = rsa_epochs(
    epochs, [pixel_rdm, facenet_rdm], y=y, temporal_radius=0.1, verbose=True, n_jobs=-1
)

# Create a nice plot of the result
ev_rsa[0].comment = "pixels"
ev_rsa[1].comment = "facenet"
mne.viz.plot_compare_evokeds(
    ev_rsa, picks=[0], ylim=dict(misc=[-0.02, 0.2]), show_sensors=False
)
rsa
Performing RSA between Epochs and 2 model RDM(s)
    Temporal radius: 22 samples
    Time interval: None-None seconds

  0%|          | 0/199 [00:00<?, ?patch/s]Creating temporal searchlight patches

 32%|███▏      | 64/199 [00:01<00:03, 38.09patch/s]
 48%|████▊     | 96/199 [00:01<00:01, 54.31patch/s]
 64%|██████▍   | 128/199 [00:02<00:00, 75.50patch/s]
 80%|████████  | 160/199 [00:02<00:00, 102.14patch/s]
 96%|█████████▋| 192/199 [00:02<00:00, 129.51patch/s]
100%|██████████| 199/199 [00:02<00:00, 87.12patch/s]

[<Figure size 800x600 with 1 Axes>]

We see that first, the “pixels” representation is the better match to the representation in the brain, but after around 150 ms the representation produced by the FaceNet model matches better. The best match between the brain and FaceNet is found at around 250 ms.

If you’ve made it this far, you have successfully completed your first sensor-level RSA! 🎉 This is the end of this tutorial. I invite you to join me in the next tutorial where we will do source level RSA: Tutorial part 2: RSA on sourse-level MEG data

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

Gallery generated by Sphinx-Gallery