Similarity and Distance¶
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.
nltools.data.adjacency.Adjacency(shape=(84, 84), Y=(0, 0), is_symmetric=True, matrix_type=distance)
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,)
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
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
- Functional Alignment — put subjects in a common functional space before comparing their patterns.
- Adjacency Matrices — what else the
matrix
distancereturns can do. - Multivariate Pattern Analysis — representational similarity analysis, built on exactly this distance matrix.