Probing Layers › Code Implementation

Michael BrenndoerferFebruary 5, 202612 min read
Language AI HandbookAll chapters
Part 59 of 67Interpretability
Chapter 450Probing Layers › Code Implementation
Reading section 7 of 12 · Code Implementation · 12 minSection 7 of 12 · 12 min

Book contents

Section 7 of 12 · 12 min read

Probing Layers › Code ImplementationOne section · Sections in this chapter7 / 12 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

This section walks through a complete layer probing study on BERT. We will probe for part-of-speech tags across all 13 layers (embedding + 12 transformer layers) and visualize how accuracy varies with depth.

Setup and Dependencies

We start by installing the libraries we need and importing them.

In[3]:
Code
import subprocess

subprocess.run(
    [
        "uv",
        "pip",
        "install",
        "transformers",
        "datasets",
        "scikit-learn",
        "torch",
        "--quiet",
    ],
    capture_output=True,
)
In[4]:
Code
import warnings

import numpy as np
import torch

warnings.filterwarnings("ignore")


MODEL_NAME = "bert-base-uncased"
NUM_LAYERS = 13  # 1 embedding + 12 transformer layers

Building a Synthetic POS Dataset

Rather than downloading a large corpus, we create a controlled dataset of sentences with known POS tags. This keeps the example self-contained and fast to run.

In[5]:
Code
# Sentences with manually annotated POS tags (simplified tagset)
# Tags: NOUN, VERB, ADJ, DET, PREP, ADV, CONJ, PRON
raw_data = [
    (
        "The quick brown fox jumps over the lazy dog",
        "DET ADJ ADJ NOUN VERB PREP DET ADJ NOUN",
    ),
    (
        "Scientists discovered a new planet beyond Neptune",
        "NOUN VERB DET ADJ NOUN PREP NOUN",
    ),
    (
        "She quickly ran through the dark forest",
        "PRON ADV VERB PREP DET ADJ NOUN",
    ),
    (
        "Large ancient trees provided shelter and shade",
        "ADJ ADJ NOUN VERB NOUN CONJ NOUN",
    ),
    (
        "The old dog sleeps quietly under the warm sun",
        "DET ADJ NOUN VERB ADV PREP DET ADJ NOUN",
    ),
    (
        "Curious children explore the world around them",
        "ADJ NOUN VERB DET NOUN PREP PRON",
    ),
    (
        "Heavy rain flooded the narrow valley below",
        "ADJ NOUN VERB DET ADJ NOUN ADV",
    ),
    (
        "Beautiful songs filled the warm summer evening",
        "ADJ NOUN VERB DET ADJ NOUN NOUN",
    ),
    (
        "The tired student finished her long assignment",
        "DET ADJ NOUN VERB PRON ADJ NOUN",
    ),
    (
        "Strong winds swept across the open plains",
        "ADJ NOUN VERB PREP DET ADJ NOUN",
    ),
    (
        "The clever fox outwitted the slow rabbit",
        "DET ADJ NOUN VERB DET ADJ NOUN",
    ),
    (
        "Bright stars illuminate the dark sky above",
        "ADJ NOUN VERB DET ADJ NOUN ADV",
    ),
    (
        "She carefully placed the fragile vase down",
        "PRON ADV VERB DET ADJ NOUN ADV",
    ),
    (
        "The hungry lion chased the frightened deer",
        "DET ADJ NOUN VERB DET ADJ NOUN",
    ),
    (
        "Cold winter mornings require warm thick coats",
        "ADJ NOUN NOUN VERB ADJ ADJ NOUN",
    ),
    (
        "The gentle breeze moved through tall green grass",
        "DET ADJ NOUN VERB PREP ADJ ADJ NOUN",
    ),
    (
        "Happy children played loudly in the sunny park",
        "ADJ NOUN VERB ADV PREP DET ADJ NOUN",
    ),
    (
        "The ancient ruins revealed many hidden secrets",
        "DET ADJ NOUN VERB ADJ ADJ NOUN",
    ),
    (
        "Brave explorers crossed the frozen tundra quickly",
        "ADJ NOUN VERB DET ADJ NOUN ADV",
    ),
    (
        "The soft pillow rested against the wooden wall",
        "DET ADJ NOUN VERB PREP DET ADJ NOUN",
    ),
]

words_list = []
tags_list = []
for sentence, tag_string in raw_data:
    words = sentence.split()
    tags = tag_string.split()
    assert len(words) == len(tags)
    words_list.append(words)
    tags_list.append(tags)

all_words = [w for sent in words_list for w in sent]
all_tags = [t for sent in tags_list for t in sent]
Out[6]:
Console
Total tokens: 147
POS tag distribution:
  NOUN: 42
  ADJ: 40
  DET: 23
  VERB: 20
  PREP: 9
  ADV: 8
  PRON: 4
  CONJ: 1

The dataset is intentionally simple: 20 sentences, 8 coarse-grained POS categories, and a modest total of tokens. In a real probing study you would use a dataset like Penn Treebank or Universal Dependencies with tens of thousands of annotated tokens, but this synthetic dataset is sufficient to illustrate the methodology and produces a layer profile that mirrors the trends seen in larger-scale studies.

Extracting Representations from All Layers

We now run each sentence through BERT and collect the hidden states at every layer. The key is to pass output_hidden_states=True and then align the subword token representations back to word-level tokens using the first-subword strategy.

In[7]:
Code
from transformers import AutoModel, AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
model = AutoModel.from_pretrained(MODEL_NAME, output_hidden_states=True)
model.eval()


def extract_layer_representations(sentences_words):
    """
    Extract BERT hidden states at all layers for each word in each sentence.
    Uses first-subword strategy for word alignment.
    Returns list of arrays with shape (num_words, num_layers, hidden_dim).
    """
    all_reps = []

    with torch.no_grad():
        for words in sentences_words:
            sentence = " ".join(words)

            encoding = tokenizer(
                sentence,
                return_tensors="pt",
                add_special_tokens=True,
            )

            word_ids = tokenizer(sentence, add_special_tokens=True).word_ids()

            outputs = model(**encoding)
            hidden_states = (
                outputs.hidden_states
            )  # tuple of (1, seq_len, hidden_dim)

            # Stack layers: (num_layers, seq_len, hidden_dim)
            stacked = torch.stack(hidden_states, dim=0).squeeze(1).numpy()

            # Extract first subword for each word
            word_reps = []
            seen = set()
            for pos, w_id in enumerate(word_ids):
                if w_id is not None and w_id not in seen:
                    seen.add(w_id)
                    word_reps.append(stacked[:, pos, :])

            all_reps.append(np.array(word_reps))

    return all_reps


reps_per_sentence = extract_layer_representations(words_list)
Out[8]:
Console
Words extracted: 147
Shape per sentence (first): (9, 13, 768)
  -> 9 words, 13 layers, 768 hidden dims

We extracted representations for every word at every layer. Each word now has 13 representation vectors of 768 dimensions, one for each layer of BERT-base. The total data matrix has shape (total_words, 13, 768), which we will slice layer-by-layer when training probes.

Training Probes at Each Layer

With representations in hand, we train a logistic regression probe at each layer. We use a simple 80/20 train/test split.

In[9]:
Code
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score
from sklearn.preprocessing import LabelEncoder

X_all = np.vstack(reps_per_sentence)  # (total_tokens, num_layers, 768)
le = LabelEncoder()
y_all = le.fit_transform(all_tags)

n_total = len(y_all)
n_train = int(0.8 * n_total)
indices = np.random.permutation(n_total)
train_idx, test_idx = indices[:n_train], indices[n_train:]

y_train, y_test = y_all[train_idx], y_all[test_idx]

layer_accuracies = []
for layer_idx in range(NUM_LAYERS):
    X_layer = X_all[:, layer_idx, :]
    X_train_l = X_layer[train_idx]
    X_test_l = X_layer[test_idx]

    clf = LogisticRegression(max_iter=500, random_state=42, C=1.0)
    clf.fit(X_train_l, y_train)
    y_pred = clf.predict(X_test_l)
    acc = accuracy_score(y_test, y_pred)
    layer_accuracies.append(acc)
Out[10]:
Console
Layer-by-layer POS probe accuracy:
  Emb: 0.900  ###########################
  L 1: 0.867  ##########################
  L 2: 0.867  ##########################
  L 3: 0.900  ###########################
  L 4: 0.900  ###########################
  L 5: 0.900  ###########################
  L 6: 0.867  ##########################
  L 7: 0.900  ###########################
  L 8: 0.900  ###########################
  L 9: 0.867  ##########################
  L10: 0.867  ##########################
  L11: 0.867  ##########################
  L12: 0.833  #########################

Notice that we train a fresh logistic regression at each layer independently. The probes do not share weights and do not communicate with each other. This independence is essential: if the probes at different layers shared parameters, you could not attribute performance differences to the representations rather than to probe interactions.

The logistic regression uses L2 regularization by default (controlled by the C parameter, where smaller values mean stronger regularization). With C=1.0 and 768 input features, the probe has thousands of parameters relative to a few hundred training examples. This means regularization is doing meaningful work to prevent overfitting, and the accuracy numbers reflect useful information in the representations rather than noise memorization.

Visualizing Probe Accuracy Across Layers

The accuracy profile is the central output of a layer probing study. Plotting it reveals which layers are most informative for POS tagging.

Out[11]:
Visualization
Line chart of POS tagging probe accuracy by BERT layer, showing a rise from early to mid layers then a plateau.
POS probe accuracy across BERT's 13 layers (embedding layer plus 12 transformer layers). Accuracy starts at the context-free embedding layer, rises steeply through lower transformer layers as local syntactic context accumulates, and plateaus in mid-network layers where syntactic properties are most linearly accessible. The dashed red line marks the peak accuracy, and the curve's flattening after the middle layers illustrates the diminishing returns of deeper contextualization for this surface-level syntactic task.

The curve typically shows the embedding layer (layer 0) with the lowest accuracy because it carries no contextual information. Accuracy rises through the early transformer layers as the attention mechanism begins to integrate local context, and the curve flattens as the representations converge to a stable encoding of syntactic categories.

The shape of this curve is informative in several ways. A steep rise followed by an early plateau suggests that the property is quickly saturated by local context, requiring little long-range integration. A gradual rise that continues to the final layers suggests the property benefits from global contextual information and is not easily captured from local patterns alone. A curve that peaks in the middle and then declines in the final layers is particularly interesting: it suggests that the final layers overwrite the representation in a way that makes the property less linearly accessible, which often happens for pre-trained models fine-tuned on downstream tasks that do not require the probed property.

Measuring Inter-Layer Similarity

We can also measure how much the representations change between consecutive layers.

In[12]:
Code
def inter_layer_cosine_similarity(reps_per_sentence):
    """
    Compute average cosine similarity between consecutive layers across all tokens.
    Returns array of length (NUM_LAYERS - 1).
    """
    similarities = []
    for layer_idx in range(1, NUM_LAYERS):
        sims = []
        for word_reps in reps_per_sentence:
            h_curr = word_reps[:, layer_idx, :]
            h_prev = word_reps[:, layer_idx - 1, :]
            norms_curr = np.linalg.norm(h_curr, axis=1, keepdims=True) + 1e-8
            norms_prev = np.linalg.norm(h_prev, axis=1, keepdims=True) + 1e-8
            cos_sims = np.sum(
                (h_curr / norms_curr) * (h_prev / norms_prev), axis=1
            )
            sims.extend(cos_sims.tolist())
        similarities.append(np.mean(sims))
    return np.array(similarities)


layer_similarities = inter_layer_cosine_similarity(reps_per_sentence)
layer_changes = 1.0 - layer_similarities
Out[13]:
Console
Inter-layer cosine similarity (high = small change):
  L 1: similarity=0.8362  change=0.1638  #########
  L 2: similarity=0.9103  change=0.0897  #####
  L 3: similarity=0.9210  change=0.0790  ####
  L 4: similarity=0.9230  change=0.0770  ####
  L 5: similarity=0.9254  change=0.0746  ####
  L 6: similarity=0.9351  change=0.0649  ###
  L 7: similarity=0.9293  change=0.0707  ####
  L 8: similarity=0.9280  change=0.0720  ####
  L 9: similarity=0.9221  change=0.0779  ####
  L10: similarity=0.9151  change=0.0849  #####
  L11: similarity=0.9252  change=0.0748  ####
  L12: similarity=0.8516  change=0.1484  ########
Out[14]:
Visualization
Bar and line chart showing representational change and cosine similarity between consecutive BERT layers.
Inter-layer cosine similarity and representational change across BERT's transformer layers. The similarity between consecutive layers (red line) stays near 1.0 throughout, showing the stabilizing effect of residual connections. The change metric (blue bars) reveals that some layers undergo more substantial representational transformation than others, with the most active layers corresponding to the transition period where syntactic and semantic information crystallizes in the mid-network.

Probing Multiple Properties Simultaneously

To see how different linguistic properties are distributed across layers, you can probe for multiple tasks and overlay their accuracy curves. Here we simulate syntactic, semantic, and lexical probes to illustrate the typical pattern.

In[15]:
Code
def length_bucket(word):
    n = len(word)
    if n == 1:
        return 0
    elif n <= 4:
        return 1
    else:
        return 2


def syntactic_class(tag):
    return 1 if tag in {"NOUN", "VERB", "ADJ", "ADV"} else 0


def semantic_role(tag):
    return 1 if tag == "ADJ" else 0


y_lexical = np.array([length_bucket(w) for w in all_words])
y_syntactic = np.array([syntactic_class(t) for t in all_tags])
y_semantic = np.array([semantic_role(t) for t in all_tags])

probe_targets = {
    "Lexical (length)": y_lexical,
    "Syntactic (content vs. function)": y_syntactic,
    "Semantic (adjective role)": y_semantic,
}

layer_accs_by_task = {}
for task_name, y_task in probe_targets.items():
    y_train_t = y_task[train_idx]
    y_test_t = y_task[test_idx]
    accs = []
    for layer_idx in range(NUM_LAYERS):
        X_layer = X_all[:, layer_idx, :]
        X_train_l = X_layer[train_idx]
        X_test_l = X_layer[test_idx]
        clf = LogisticRegression(max_iter=500, random_state=42, C=1.0)
        clf.fit(X_train_l, y_train_t)
        y_pred = clf.predict(X_test_l)
        accs.append(accuracy_score(y_test_t, y_pred))
    layer_accs_by_task[task_name] = accs
Out[16]:
Visualization
Line chart comparing probe accuracy for lexical, syntactic, and semantic probes across BERT layers.
Layer probe accuracy for three types of linguistic properties across BERT's 13 layers. Lexical properties (token length bucket) are accessible from early layers and change little with depth, showing that surface form is encoded from the start. Syntactic properties (content vs. function word) peak in mid-network layers as grammatical context integrates. Semantic properties (adjective role classification) show their characteristic signal in later layers, illustrating the bottom-to-top hierarchy of linguistic abstraction in BERT and confirming that deeper layers compute higher-order representations.

Overlaying multiple probe curves on the same axis is one of the most informative visualizations in layer probing research. When lexical probes plateau early and semantic probes continue rising, the divergence between the curves reveals the model's internal processing hierarchy directly. When two probes track closely, it suggests the corresponding properties are encoded at the same computational depth, which can point to shared processing mechanisms.