Skip to content
GKgkml.dev
← LLM mechanisms

Part 5 · Observational mech interp · module 1 of 5 · 22 min

Investigating token embeddings

Measuring the geometry of embedding space — cosine similarity at scale, t-SNE and DBSCAN, RSA, semantic axes, kNN, graphs and the SVD spectrum.

The essence

Embeddings are a cloud of points, and interpreting them means measuring that cloud's structure. Every method here is one of three moves: compare pairs (cosine similarity, kNN), compare whole structures (RSA, graphs), or reduce dimensions to something you can look at (t-SNE, PCA, SVD). None require running the model — the embedding matrix alone is the object of study, which makes this the cheapest interpretability work there is.

Why it matters

This is where you learn what a measurement on a high-dimensional space does and does not license. Cosine similarity looks trivially simple and is full of traps; t-SNE produces beautiful plots that mislead in specific documented ways; RSA is the only sound way to compare two models' representations. Getting these habits right here is what makes the neuron and layer work later trustworthy.

Topics covered (20)
  1. 01Cosine similarity at scale, and its subtleties
  2. 02Cosine similarity across word sequences
  3. 03Heatmaps of cosine similarity matrices
  4. 04Can random embeddings be interpreted? — the null model
  5. 05t-SNE projection and DBSCAN clustering in theory
  6. 06t-SNE and DBSCAN in Python
  7. 07Clustering a chosen semantic category
  8. 08Tokenizing, embedding and clustering emoji
  9. 09Representational similarity analysis (RSA)
  10. 10Comparing two embedding spaces with RSA
  11. 11word2vec vs. GPT-2 representational structure
  12. 12Graph representations of similarity matrices
  13. 13Embedding arithmetic and analogies
  14. 14Soft-coded analogies at scale
  15. 15Creating and interpreting linear semantic axes
  16. 16kNN synonym search in BERT
  17. 17Comparing kNN neighbourhoods between BERT and GPT
  18. 18Research on translating between embedding spaces
  19. 19The singular value spectrum of embedding submatrices
  20. 20SVD projections of related embeddings

Cosine similarity, past the basics

The formula is simple and the interpretation is not. In high dimensions, random vectors are nearly orthogonal, so a similarity of 0.1 between two random 768-dimensional vectors is already notable. The absolute value means little; what means something is the value relative to a baseline you computed on the same data.

There is also an anisotropy problem. Contextual embeddings from GPT-2 and BERT occupy a narrow cone rather than filling the space, so almost any two of them have a high cosine similarity — often above 0.9 for entirely unrelated tokens. Comparing raw similarities across such vectors mostly measures the shared mean direction, not the tokens. Subtracting the mean vector first, or standardising each dimension, removes that dominant component and is essential before any conclusion.

Similarity at scale, with baseline correction
import numpy as np, matplotlib.pyplot as plt

def cosine_matrix(X, center=True):
    """Pairwise cosine similarity. center=True removes the shared mean
    direction, without which contextual embeddings all look ~0.95 similar."""
    X = np.asarray(X, dtype=np.float64)
    if center:
        X = X - X.mean(axis=0, keepdims=True)
    Xn = X / np.linalg.norm(X, axis=1, keepdims=True)
    return Xn @ Xn.T

def null_distribution(shape, n=200, center=True):
    """What similarity looks like with no structure at all."""
    out = []
    for _ in range(n):
        R = np.random.randn(*shape)
        S = cosine_matrix(R, center)
        out.append(S[np.triu_indices_from(S, k=1)])
    return np.concatenate(out)

S = cosine_matrix(embeddings)                 # [n_tokens, n_tokens]
null = null_distribution(embeddings.shape)

observed = S[np.triu_indices_from(S, k=1)]
print(f"observed mean {observed.mean():+.3f}   null mean {null.mean():+.3f}")
print(f"95th pct of null: {np.percentile(np.abs(null), 95):.3f}")
# Only pairs exceeding that percentile are worth discussing.

fig, ax = plt.subplots(figsize=(7, 6))
im = ax.imshow(S, cmap="RdBu_r", vmin=-1, vmax=1)   # diverging map, symmetric
ax.set_xticks(range(len(labels))); ax.set_xticklabels(labels, rotation=90)
ax.set_yticks(range(len(labels))); ax.set_yticklabels(labels)
plt.colorbar(im)

Note: Use a diverging colormap centred at zero for similarity. A sequential map makes −0.4 and +0.1 look similarly 'low' and hides the sign.

Similarity across a sequence

Taking consecutive tokens in a sentence and measuring the similarity of adjacent contextual embeddings gives a running signal that tracks structure: it dips at clause and sentence boundaries and rises within a phrase.

Doing the same at successive layers shows something more interesting — early layers give similarity dominated by token identity, and deeper layers give similarity dominated by role in the sentence. The same measurement at different depths is measuring a different thing, which is the recurring theme of this whole part.

t-SNE and DBSCAN

t-SNE reduces high-dimensional points to two by converting distances to probabilities and finding a low-dimensional arrangement whose probability structure matches. It is optimised to preserve local neighbourhoods, and it deliberately sacrifices global structure to do so.

That trade-off produces three rules you must respect. Cluster sizes in the plot are meaningless — t-SNE expands dense regions and contracts sparse ones. Distances between clusters are meaningless. And perplexity, its main hyperparameter, changes the picture substantially, so any claim should hold across a range of values.

DBSCAN then finds clusters by density: a point with at least min_samples neighbours within eps is a core point, connected core points form a cluster, and everything else is labelled noise. Unlike k-means it needs no cluster count and can return arbitrary shapes, which suits embedding data. Its weakness is that eps is scale-dependent and hard to choose — the standard method is to plot sorted k-nearest-neighbour distances and put eps at the elbow.

t-SNE plus DBSCAN, done carefully
import numpy as np, matplotlib.pyplot as plt
from sklearn.manifold import TSNE
from sklearn.decomposition import PCA
from sklearn.cluster import DBSCAN
from sklearn.neighbors import NearestNeighbors

# PCA first: denoises and makes t-SNE much faster on 768 dimensions.
X = PCA(n_components=50, random_state=0).fit_transform(embeddings)

# Cluster in the ORIGINAL space, not in the 2-D projection. Clustering the
# projection means clustering an artefact of the projection.
nn = NearestNeighbors(n_neighbors=5).fit(X)
d, _ = nn.kneighbors(X)
plt.plot(np.sort(d[:, -1]))          # elbow of this curve is a good eps

labels_ = DBSCAN(eps=chosen_eps, min_samples=5).fit_predict(X)
print(f"{len(set(labels_)) - (1 in labels_)} clusters, "
      f"{(labels_ == -1).sum()} noise points")

# Then project only for viewing, and check stability across perplexity.
fig, axes = plt.subplots(1, 3, figsize=(15, 5))
for ax, perp in zip(axes, [5, 30, 50]):
    Y = TSNE(n_components=2, perplexity=perp, init="pca",
             random_state=0).fit_transform(X)
    ax.scatter(Y[:, 0], Y[:, 1], c=labels_, cmap="tab10", s=12)
    ax.set_title(f"perplexity {perp}")
    ax.set_xticks([]); ax.set_yticks([])
# If your clusters only appear at one perplexity, they are not a finding.

# UMAP is the usual alternative: faster, retains a little more global
# structure, and has exactly the same interpretive caveats.

Representational similarity analysis

RSA solves the problem that two models' spaces are not comparable. Their dimensions mean different things and may not even be equal in number, so no vector-to-vector comparison is valid.

The trick is to compare structures instead. For each model, compute the pairwise similarity matrix over the same set of stimuli. That matrix lives in stimulus space, not model space, so the two are directly comparable — correlate their upper triangles and you have a single number for how similarly the two models organise those stimuli.

This is the workhorse for cross-model comparison, and it generalises: compare word2vec against GPT-2, compare layer 3 against layer 10 of the same model, or compare a model against human similarity judgements. Use Spearman correlation rather than Pearson, since only the ordering of similarities is meaningful.

RSA between two models
import numpy as np
from scipy.stats import spearmanr

def rdm(X):
    """Representational dissimilarity matrix: 1 - centred cosine similarity."""
    Xc = X - X.mean(axis=0, keepdims=True)
    Xn = Xc / np.linalg.norm(Xc, axis=1, keepdims=True)
    return 1 - Xn @ Xn.T

def rsa(X, Y):
    """X and Y: embeddings of the SAME stimuli from two models.
    Dimensions need not match — that is the whole point."""
    a, b = rdm(X), rdm(Y)
    iu = np.triu_indices_from(a, k=1)          # upper triangle only
    return spearmanr(a[iu], b[iu]).statistic

print("word2vec vs GPT-2:", rsa(w2v_vectors, gpt2_vectors))

# Laminar profile: how does each layer's structure relate to word2vec's?
profile = [rsa(w2v_vectors, layer_vectors[L]) for L in range(n_layers)]
# Typically high early (token identity dominates), falling with depth as
# contextual and task-specific structure takes over.

# Category selectivity by layer: is the RDM block-diagonal by category?
def category_selectivity(X, categories):
    D = rdm(X)
    same = np.equal.outer(categories, categories)
    np.fill_diagonal(same, False)
    iu = np.triu_indices_from(D, k=1)
    within  = D[iu][same[iu]].mean()
    between = D[iu][~same[iu]].mean()
    return between - within        # larger = more categorical organisation

Graphs from similarity

A similarity matrix is a weighted graph. Threshold it — keep edges above some similarity, or keep each node's k strongest — and you can apply graph tools: connected components as clusters, degree as a measure of how generic a token is, betweenness to find tokens bridging semantic regions, and community detection as an alternative to distance-based clustering.

The advantage over t-SNE is that nothing is distorted by a projection: the graph is the similarity structure. The cost is that the threshold is a free parameter, and the picture changes with it, so report the range over which your conclusion holds.

Similarity as a graph
import networkx as nx, numpy as np

def similarity_graph(S, labels, k=5):
    """k-nearest-neighbour graph — more stable than a global threshold,
    because it adapts to locally varying density."""
    G = nx.Graph()
    G.add_nodes_from(labels)
    np.fill_diagonal(S, -np.inf)
    for i, label in enumerate(labels):
        for j in np.argsort(S[i])[-k:]:
            G.add_edge(label, labels[j], weight=float(S[i, j]))
    return G

G = similarity_graph(cosine_matrix(embeddings), labels, k=5)

print(nx.number_connected_components(G))
print(sorted(nx.degree_centrality(G).items(), key=lambda kv: -kv[1])[:5])
print([sorted(c)[:6] for c in nx.community.greedy_modularity_communities(G)])

# High-degree nodes are usually generic or high-frequency tokens; they connect
# to everything and are worth excluding before interpreting communities.

Analogies and semantic axes

The analogy result — king − man + woman ≈ queen — says that a consistent relation corresponds to a consistent direction. It is real but weaker than usually presented: the nearest neighbour of the result is often one of the input words, which is why the standard evaluation excludes them. Testing hundreds of analogy quadruples rather than a hand-picked one gives an honest accuracy figure, typically well below the impression the single example creates.

Semantic axes are the more useful version of the same idea. Average several pairs that differ along one attribute — good/bad, hot/cold, formal/informal — and take the mean difference as an axis. Project any token onto it and you get a scalar score for that attribute. Averaging over pairs is what makes it robust: a single pair's difference is dominated by everything else that distinguishes those two words.

Semantic axes, and honest analogy scoring
import numpy as np

def semantic_axis(model, positive_pairs):
    """positive_pairs: [('hot','cold'), ('warm','cool'), ...]
    Average many differences so the axis reflects the attribute, not the words."""
    diffs = [model[a] - model[b] for a, b in positive_pairs]
    axis = np.mean(diffs, axis=0)
    return axis / np.linalg.norm(axis)

temperature = semantic_axis(glove, [("hot", "cold"), ("warm", "cool"),
                                     ("boiling", "freezing")])

for word in ["fire", "ice", "soup", "snow", "desk"]:
    v = glove[word]
    print(f"{word:6} {float(v @ temperature / np.linalg.norm(v)):+.3f}")
# 'desk' should score near zero. If it does not, the axis is contaminated —
# usually by frequency or by a topic the pairs share.

def analogy_accuracy(model, quadruples, k=1):
    """a:b :: c:d — score over MANY quadruples, not one."""
    hits = 0
    for a, b, c, d in quadruples:
        if any(w not in model for w in (a, b, c, d)):
            continue
        got = model.most_similar(positive=[b, c], negative=[a], topn=k)
        hits += int(d in [w for w, _ in got])
    return hits / len(quadruples)

kNN for synonyms, and comparing models

Nearest-neighbour search in embedding space is the simplest useful query: give me the tokens most like this one. On static embeddings it returns near-synonyms mixed with antonyms, because opposites share almost all their context — 'hot' and 'cold' appear in the same sentences. That is a well-known limitation, not a bug in your code.

On contextual embeddings you can do better, because you query an occurrence rather than a word type. Encode a sentence, take the vector for the target token, and search a corpus of encoded occurrences: the neighbours are usages in the same sense. Comparing the neighbour sets BERT and GPT return for the same query — the overlap at k, per layer — is a clean way to quantify how differently two architectures organise meaning.

Contextual kNN, and neighbourhood overlap between models
import torch, numpy as np

@torch.no_grad()
def occurrence_vectors(model, tok, sentences, target, layer=-1):
    """One vector per occurrence of `target`, in context."""
    out = []
    for s in sentences:
        ids = tok(s, return_tensors="pt")
        hs = model(**ids, output_hidden_states=True).hidden_states[layer]
        pieces = tok.convert_ids_to_tokens(ids["input_ids"][0])
        for i, p in enumerate(pieces):
            if target in p:
                out.append((s, hs[0, i].numpy()))
    return out

def knn(query, bank, k=5):
    V = np.stack([v for _, v in bank])
    V = V / np.linalg.norm(V, axis=1, keepdims=True)
    q = query / np.linalg.norm(query)
    return [bank[i][0] for i in np.argsort(-(V @ q))[:k]]

def neighbourhood_overlap(bank_a, bank_b, k=10):
    """Jaccard overlap of the k-NN sets two models give for each item.
    A per-layer profile of this is a clean cross-architecture comparison."""
    scores = []
    for i in range(len(bank_a)):
        a = set(knn(bank_a[i][1], bank_a, k))
        b = set(knn(bank_b[i][1], bank_b, k))
        scores.append(len(a & b) / len(a | b))
    return float(np.mean(scores))

The SVD spectrum

Singular value decomposition factorises the embedding matrix into orthogonal directions ordered by how much variance each explains. The spectrum — the sorted singular values — tells you how the representational capacity is distributed.

A spectrum that decays fast means a few directions carry most of the structure: the effective dimensionality is far below the nominal one. A flat spectrum means variance is spread across many directions. Trained embeddings sit between the two, typically with a steep initial decay and a long shallow tail, and often with one or two enormous leading components corresponding to frequency and to the shared mean direction — which is exactly why centring before analysis matters so much.

Taking submatrices makes this a tool rather than a summary. Compute the spectrum for the embeddings of one semantic category and compare it to another: a category whose members are near-interchangeable has a steeper spectrum than one spanning a genuinely multidimensional distinction. Projecting two related sets onto their shared leading singular vectors then shows the axes along which they differ.

Spectrum, effective dimensionality, and projection
import numpy as np, matplotlib.pyplot as plt

def spectrum(X, center=True):
    X = np.asarray(X, dtype=np.float64)
    if center:
        X = X - X.mean(axis=0, keepdims=True)
    return np.linalg.svd(X, compute_uv=False)

def effective_dim(s):
    """Participation ratio: how many directions genuinely matter.
    Equals n for a flat spectrum, ~1 when one direction dominates."""
    p = s ** 2 / (s ** 2).sum()
    return float(1.0 / (p ** 2).sum())

for name, subset in categories.items():
    s = spectrum(subset)
    print(f"{name:12} eff.dim {effective_dim(s):5.1f}  "
          f"top-10 variance {(s[:10]**2).sum() / (s**2).sum():.2%}")

plt.semilogy(spectrum(embeddings), label="real")
plt.semilogy(spectrum(np.random.randn(*embeddings.shape)), label="random")
plt.legend(); plt.xlabel("component"); plt.ylabel("singular value")
# The gap between the two curves IS the structure. A random matrix's spectrum
# (the Marchenko-Pastur law) is the null you are comparing against.

# Project two related sets onto shared leading directions to see how they differ.
both = np.vstack([set_a, set_b])
U, s, Vt = np.linalg.svd(both - both.mean(0), full_matrices=False)
A, B = set_a @ Vt[:2].T, set_b @ Vt[:2].T

Worth remembering

  • Always compare a measurement against a random-embedding null — high-dimensional geometry produces structure by accident.
  • t-SNE preserves local neighbourhoods only: cluster sizes and between-cluster distances in the plot are meaningless.
  • RSA compares similarity structures rather than vectors, which is the only valid way to compare two different models.