Skip to content
GKgkml.dev
← LLM mechanisms

Part 5 · Observational mech interp · module 4 of 5 · 15 min

Embedding trajectories through the model

Treating a token's representation as a path — rotation angles, path length, residual-stream decomposition and parts-of-speech comparisons.

The essence

A token's representation at layer 0 and at layer 12 are points in the same space, so the sequence of representations across layers is a trajectory. Measuring that path — how far it travels, how much it turns at each step, which components pushed it — converts depth from an index into a geometry you can quantify.

Why it matters

This reframing yields measurements the layerwise view misses. Path length correlates with how much processing a token required, angular change localises where its role was decided, and decomposing the path into per-component contributions is the observational counterpart to the causal patching in the next part. It is also the clearest demonstration of why the residual stream's additivity matters.

Topics covered (7)
  1. 01Calculating rotations of embedding vectors
  2. 02Laminar evolution of sequential angular changes
  3. 03Path length and its relation to logit prediction
  4. 04Residual-stream decomposition of path lengths
  5. 05State-space trajectories through embedding space
  6. 06Parts of speech with spaCy
  7. 07Comparing trajectory lengths across parts of speech

The trajectory view

Take one token position and collect its residual-stream vector at every layer. Those points form a path through a 768-dimensional space, starting at the token's static embedding plus its position embedding and ending at whatever the final layernorm feeds to the unembedding.

Two quantities describe the path. Step size is the norm of the difference between consecutive layers — how far the representation moved. Turning is the angle between consecutive steps — whether it continued in the same direction or changed course. Together they distinguish a token that was smoothly refined from one that was substantially reinterpreted at some depth.

Rotation and step size along a trajectory
import torch, numpy as np

@torch.no_grad()
def trajectory(model, tok, text, position):
    out = model(**tok(text, return_tensors="pt"), output_hidden_states=True)
    return np.stack([h[0, position].numpy() for h in out.hidden_states])

def angle_between(u, v):
    c = u @ v / (np.linalg.norm(u) * np.linalg.norm(v) + 1e-12)
    return float(np.degrees(np.arccos(np.clip(c, -1, 1))))

def describe(traj):
    rows = []
    for i in range(len(traj) - 1):
        step = traj[i + 1] - traj[i]
        rows.append({
            "layer": i,
            "norm": float(np.linalg.norm(traj[i])),
            # Relative step: scale-free, so comparable across depth.
            "rel_step": float(np.linalg.norm(step) / np.linalg.norm(traj[i])),
            # How much did this layer rotate the representation?
            "rotation": angle_between(traj[i], traj[i + 1]),
            # Did this step continue the previous direction, or turn?
            "turn": angle_between(traj[i] - traj[i - 1], step) if i else None,
        })
    return rows

def path_length(traj, normalise=True):
    steps = np.diff(traj, axis=0)
    lengths = np.linalg.norm(steps, axis=1)
    if normalise:
        lengths = lengths / np.linalg.norm(traj[:-1], axis=1)
    return float(lengths.sum())

# Total displacement vs. path length: their ratio measures directness.
# Close to 1 means steady refinement; much less means the representation
# wandered and was revised.
def directness(traj):
    return float(np.linalg.norm(traj[-1] - traj[0]) /
                 np.linalg.norm(np.diff(traj, axis=0), axis=1).sum())

Decomposing the path

The residual stream's defining property is additivity: hidden[i+1] = hidden[i] + attn_out[i] + mlp_out[i]. That means each step in the trajectory is exactly the sum of two identifiable contributions, and attention's contribution decomposes further into per-head terms.

So you can attribute a token's movement to specific components without any intervention. Capture each head's output and the MLP output at every layer, and you have the full decomposition — which head pushed hardest, whether attention or the MLP dominated at each depth, and which components pushed in the direction that increases the correct token's logit.

That last measurement is direct logit attribution: project each component's contribution onto the unembedding direction of the target token. It is the sharpest observational tool available, and it agrees with causal patching often enough to be a good first pass and disagrees often enough that you still need the causal test.

Residual-stream decomposition and direct logit attribution
import torch, numpy as np

class Decomposer:
    """Captures each layer's attention and MLP contributions separately."""
    def __init__(self, model):
        self.model, self.parts, self.handles = model, {}, []

    def __enter__(self):
        for i, block in enumerate(self.model.transformer.h):
            self.handles.append(block.attn.register_forward_hook(self._save(f"attn{i}")))
            self.handles.append(block.mlp.register_forward_hook(self._save(f"mlp{i}")))
        return self

    def _save(self, name):
        def hook(module, inputs, output):
            t = output[0] if isinstance(output, tuple) else output
            self.parts[name] = t.detach()
        return hook

    def __exit__(self, *exc):
        for h in self.handles:
            h.remove()

@torch.no_grad()
def logit_attribution(model, tok, prompt, target, position=-1):
    """How much did each component push toward the target token?"""
    tid = tok(target)["input_ids"][0]
    # The unembedding row for the target IS the direction that raises its logit.
    direction = model.lm_head.weight[tid]

    with Decomposer(model) as dec:
        model(**tok(prompt, return_tensors="pt"))

    rows = []
    for name, contribution in dec.parts.items():
        rows.append((name, float(contribution[0, position] @ direction)))
    rows.sort(key=lambda kv: -abs(kv[1]))
    return rows

for name, score in logit_attribution(model, tok,
        "The Eiffel Tower is in", " Paris")[:8]:
    print(f"{name:8} {score:+.3f}")

# Sanity check: because the stream is additive, the contributions plus the
# embedding term should reconstruct the final logit up to the layernorm's
# non-linearity. If they do not, you are capturing the wrong tensor.

Parts of speech, and a testable hypothesis

Trajectory statistics become a real experiment once you have a grouping variable. Parts of speech are the natural one: tag a corpus with spaCy, then compare path length, total rotation and directness across tags.

The hypothesis worth testing is that words whose contribution depends heavily on context — pronouns, ambiguous nouns, function words whose role is structural — travel further and turn more than words that are largely self-contained. Content words with a single dominant sense should need less revision.

The controls are what make the result meaningful, and they are the same ones as in the neuron module. Frequency correlates with part of speech and with almost everything else. Token count correlates with both. Position in the sentence matters, since a token near the start has less context to integrate. Match or regress out all three, or you will publish a frequency effect wearing a syntax costume.

Trajectories by part of speech, with controls
import spacy, numpy as np, pandas as pd
import statsmodels.formula.api as smf

nlp = spacy.load("en_core_web_sm")

def collect(model, tok, sentences):
    rows = []
    for sentence in sentences:
        doc = nlp(sentence)
        ids = tok(sentence, return_tensors="pt")
        pieces = tok.convert_ids_to_tokens(ids["input_ids"][0])

        for token in doc:
            span = find_span(pieces, token.text, tok)
            if span is None:
                continue
            traj = trajectory(model, tok, sentence, span[1] - 1)   # last piece
            rows.append({
                "pos": token.pos_,
                "path": path_length(traj),
                "rotation": angle_between(traj[0], traj[-1]),
                "directness": directness(traj),
                # Controls — every one of these confounds the comparison.
                "n_tokens": span[1] - span[0],
                "log_freq": np.log1p(corpus_frequency.get(token.text.lower(), 1)),
                "position": span[0] / len(pieces),
            })
    return pd.DataFrame(rows)

df = collect(model, tok, sentences)
print(df.groupby("pos")[["path", "rotation", "directness"]].agg(["mean", "count"]))

# The honest analysis: does part of speech still predict path length once the
# confounds are in the model?
fit = smf.ols("path ~ C(pos) + n_tokens + log_freq + position", data=df).fit()
print(fit.summary().tables[1])
# If the C(pos) terms lose significance here, the raw group difference was a
# frequency or length effect. That is the result, and it is worth reporting.

Worth remembering

  • Because every block adds to the residual stream, a token's path decomposes exactly into per-component steps.
  • Angular change per layer localises where a token's representation was substantially revised.
  • Normalise by vector norm before comparing paths, or the growing residual norm dominates everything.