Skip to content

Similarity and Distance

Open in molab

Run this tutorial

This page is rendered from the marimo notebook docs/tutorials/analysis/04_similarity.py. Click the badge to run it in the cloud (free, no install), or locally: download 04_similarity.py and run uvx marimo edit --sandbox 04_similarity.py. The outputs below were produced when this page was built.

Comparing images to each other needs no model at all. Two questions come up constantly:

  • How far is every image from every other one? That is a distance matrix, and it is what representational similarity analysis works on.
  • How much does one image look like a particular pattern? That is a pattern response, the number a published brain signature produces when you apply it to new data.

Both run on the pain dataset: 28 subjects, three intensities each.

import matplotlib.pyplot as plt
import numpy as np
from joblib import Memory

from nltools.datasets import fetch_pain

memory = Memory(".tutorial-cache", verbose=0)

data = memory.cache(fetch_pain)()
data
nltools.data.braindata.BrainData(data=(84, 238955), resolution=2.0mm, space=mni, mask=2mm-MNI152-2009fsl-mask.nii.gz)

Distance between every pair of images

distance computes the pairwise spatial distance between the images in a BrainData, using any metric scipy.spatial.distance.cdist supports. Correlation distance — one minus the spatial correlation — is the usual choice, because it ignores differences in overall scale between images.

The result is an Adjacency, the class for matrices over a set of nodes. It stores the 3,486 unique pairs rather than the full square, and knows that it is a distance matrix.

distances = data.distance(metric="correlation")
print(distances)
distances.plot()
nltools.data.adjacency.Adjacency(shape=(84, 84), Y=(0, 0), is_symmetric=True, matrix_type=distance)
2026-09-13T00:41:10.562287 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ 0 3 6 9 12 15 18 21 24 27 30 33 36 39 42 45 48 51 54 57 60 63 66 69 72 75 78 81 0 4 8 12 16 20 24 28 32 36 40 44 48 52 56 60 64 68 72 76 80 0.0 0.2 0.4 0.6 0.8 1.0 1.2 1.4

The block structure along the diagonal is the subjects: three consecutive images belong to one person, and a person's images resemble each other more than they resemble anybody else's. That similarity is a fact about the individual, not about pain, and it is the reason cross-validation has to hold subjects out together.

square = distances.squareform()
same_subject = (
    data.X["SubjectID"].to_numpy()[:, None]
    == data.X["SubjectID"].to_numpy()[None, :]
)
off_diagonal = ~np.eye(len(data), dtype=bool)

print(f"within subject:  {square[same_subject & off_diagonal].mean():.3f}")
print(f"between subject: {square[~same_subject].mean():.3f}")
within subject:  0.319
between subject: 0.860

Similarity to one pattern

similarity compares every image in a BrainData to a single map and returns one value per image. Average the high-intensity images across subjects and you have a rough pain pattern; the similarity of each image to it is that image's pattern response.

high_pain = data[data.X["PainLevel"] == 3].mean()
response = data.similarity(high_pain, metric="correlation")

print(f"one value per image: {response.shape}")
high_pain.plot(title="Mean high-intensity pain image")
one value per image: (84,)
2026-09-13T00:41:11.715169 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ L R L R -1.2 -0.58 -0.098 0.098 0.58 1.2 Mean high-intensity pain image
intensity = data.X["PainLevel"].to_numpy()

similarity_figure, axes = plt.subplots(ncols=2, figsize=(10, 4))
axes[0].hist(response, bins=20)
axes[0].set_xlabel("spatial similarity")
axes[0].set_ylabel("images")
axes[0].set_title("Similarity to the mean high-pain image")

jitter = np.random.default_rng(0).normal(0, 0.04, intensity.size)
axes[1].scatter(intensity + jitter, response, alpha=0.6)
axes[1].set_xticks([1, 2, 3], ["low", "medium", "high"])
axes[1].set_xlabel("pain intensity")
axes[1].set_ylabel("spatial similarity")
axes[1].set_title("Pattern response by intensity")
similarity_figure.tight_layout()
similarity_figure
2026-09-13T00:41:11.909229 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ −0.2 0.0 0.2 0.4 0.6 spatial similarity 0 1 2 3 4 5 6 7 8 images Similarity to the mean high-pain image low medium high pain intensity −0.2 0.0 0.2 0.4 0.6 spatial similarity Pattern response by intensity

Response rises with intensity, which is the point — the pattern carries information about the manipulation. The 28 high-intensity images are part of the average they are being compared to, so their similarity is inflated; a real pattern-response analysis uses a pattern estimated on other people.

Metrics

metric selects what "similar" means. Correlation and its 'pearson' alias center both maps first; 'cosine' and 'dot_product' do not, so they are sensitive to a map's overall offset and scale. 'rank_correlation' (alias 'spearman') compares the orderings, which blunts the influence of a few extreme voxels.

for metric in ["correlation", "rank_correlation", "cosine", "dot_product"]:
    values = data.similarity(high_pain, metric=metric)
    print(
        f"{metric:18s} range [{values.min():8.2f}, {values.max():8.2f}]   "
        f"r with intensity {np.corrcoef(values, intensity)[0, 1]:.2f}"
    )
correlation        range [   -0.28,     0.74]   r with intensity 0.51
rank_correlation   range [   -0.35,     0.76]   r with intensity 0.52
cosine             range [   -0.69,     0.90]   r with intensity 0.46
dot_product        range [-31691.44, 59859.22]   r with intensity 0.43

All four order the intensities the same way; they differ in scale and in how much a handful of high-magnitude voxels can move them.

Recap

Step Call
Pairwise distance between images data.distance(metric="correlation")Adjacency
Full square from the stored pairs adjacency.squareform()
Response of every image to one map data.similarity(pattern, metric=)
Build a pattern from a subset data[mask].mean()

Next steps