Feature Interpretation › Code Implementation: Analyzing Feature Activations

Michael BrenndoerferJanuary 13, 202610 min read
Language AI HandbookAll chapters
Part 59 of 67Interpretability
Chapter 454Feature Interpretation › Code Implementation: Analyzing Feature Activations
Reading section 6 of 12 · Code Implementation: Analyzing Feature Activations · 10 minSection 6 of 12 · 10 min

Book contents

Section 6 of 12 · 10 min read

Feature Interpretation › Code Implementation: Analyzing Feature ActivationsOne section · Sections in this chapter6 / 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

Let's build a practical toolkit for analyzing features from a trained SAE. We'll simulate the workflow using synthetic data, since running a real SAE requires substantial compute, but the analysis code translates directly to real SAE outputs.

Setup and Synthetic Feature Data

We'll create synthetic activation data that mimics the output of a real SAE: a sparse matrix where most values are zero, with a few positive activations per token.

In[3]:
Code
import numpy as np

np.random.seed(42)

# Simulate SAE feature activations over a corpus
# n_tokens: number of token positions in the dataset
# n_features: number of SAE features
n_tokens = 10_000
n_features = 512

# Each token activates a small number of features (sparsity ~1%)
# Feature activation follows a power-law: some features are common, most are rare
feature_frequencies = np.random.zipf(a=1.8, size=n_features)
feature_frequencies = feature_frequencies / feature_frequencies.sum()

# Simulate which features activate for each token
activations = np.zeros((n_tokens, n_features), dtype=np.float32)
for token_idx in range(n_tokens):
    # Sample which features fire (sparse: ~5-15 features per token)
    n_active = np.random.randint(5, 15)
    active_features = np.random.choice(
        n_features, size=n_active, p=feature_frequencies, replace=False
    )
    # Activation strength drawn from a gamma distribution
    activation_strengths = np.random.gamma(shape=2.0, scale=1.5, size=n_active)
    activations[token_idx, active_features] = activation_strengths
Out[4]:
Console
Activation matrix shape: (10000, 512)
Total non-zero activations: 94,859
Sparsity: 0.9815 (98.15% of entries are zero)
Average active features per token: 9.5

The activation matrix is highly sparse. Most entries are zero, and each token position activates only a small fraction of all features. This sparsity is the defining characteristic of SAE representations and is what makes them tractable for interpretation. If every feature fired on every token, you could not identify any meaningful pattern from the activation profile alone. The sparsity is both a computational convenience and an epistemic tool: it makes the question "why did this feature fire here?" answerable rather than trivially true everywhere.

Computing Feature Statistics

In[5]:
Code
# Compute per-feature statistics
activation_frequencies = (activations > 0).mean(
    axis=0
)  # Fraction of tokens that activate each feature
mean_active_value = np.where(activations > 0, activations, np.nan)
mean_active_values = np.nanmean(mean_active_value, axis=0)
max_active_values = activations.max(axis=0)

# Sort features by activation frequency
sorted_by_freq = np.argsort(activation_frequencies)[::-1]

# Identify "dead features" (never activate)
dead_features = np.sum(activation_frequencies == 0)
Out[6]:
Console
Feature frequency stats:
  Min frequency: 0.00110
  Max frequency: 0.97340
  Mean frequency: 0.01853
  Median frequency: 0.00290
  Dead features (never activate): 0

Top-5 most frequent features (indices): [426 448 442 154 435]
Top-5 least frequent features (indices): [143 274 201 341 328]

Most features have low activation frequency, consistent with the Zipf-like distribution we built in. The top features fire much more often than the median, and a handful of features may be "dead" (never activating), which is a known failure mode in SAE training. In a real SAE trained on a large corpus, the frequency distribution would span roughly six orders of magnitude: from features that fire on nearly every token (structural or syntactic catch-alls) to features that fire on fewer than one in a million tokens (highly specialized, rare concepts).

Activation Histogram Visualization

Out[7]:
Visualization
Log-log histogram of SAE feature activation frequencies showing a power-law distribution.
Activation frequency distribution across all SAE features, shown on a log-log scale. Most features activate rarely (left side of the distribution), while a small number of high-frequency features (right side) fire on a substantial fraction of tokens. This power-law distribution mirrors Zipf's law in natural language and is a characteristic signature of well-trained sparse autoencoders, where the SAE has learned to allocate rare, precise features to specific concepts and common features to broad syntactic or structural properties.

Finding Max-Activating Examples

In a real workflow you would look at actual text tokens; here we simulate by assigning each token a "concept label" drawn from a set of semantic categories. The key logic, however, is identical: sort by activation value, take the top kk, and inspect the associated metadata.

In[8]:
Code
from collections import Counter

# Simulate semantic labels for each token (what concept each token represents)
concepts = [
    "capital_city",
    "financial_term",
    "scientific_unit",
    "emotion_word",
    "discourse_marker",
    "person_name",
    "number",
    "punctuation",
    "verb_past",
    "adjective",
]

# Assign concept labels to tokens
token_concepts = np.random.choice(
    concepts,
    size=n_tokens,
    p=[0.05, 0.10, 0.07, 0.08, 0.06, 0.09, 0.15, 0.12, 0.13, 0.15],
)


def get_top_k_examples(feature_idx, k=20):
    """Return the top-k activating token indices and their activation values."""
    feature_acts = activations[:, feature_idx]
    top_k_indices = np.argsort(feature_acts)[::-1][:k]
    top_k_values = feature_acts[top_k_indices]
    active_mask = top_k_values > 0
    return top_k_indices[active_mask], top_k_values[active_mask]


# Analyze a single feature
feature_to_analyze = sorted_by_freq[0]  # Most frequent feature
top_indices, top_values = get_top_k_examples(feature_to_analyze, k=50)
top_concepts = [token_concepts[i] for i in top_indices]
concept_counts = Counter(top_concepts)
Out[9]:
Console
Feature 426 -- Top-50 activating example concept distribution:
  number              :   9  #########
  adjective           :   9  #########
  verb_past           :   7  #######
  punctuation         :   6  ######
  person_name         :   5  #####
  capital_city        :   4  ####
  financial_term      :   4  ####
  discourse_marker    :   3  ###
  emotion_word        :   2  ##
  scientific_unit     :   1  #

The concept distribution reveals which semantic categories dominate the feature's top-activating examples. If one concept accounts for 70% or more of the top examples, the feature is likely monosemantic with respect to that concept. If the examples split roughly equally, the feature may be polysemantic or tracking a more abstract property that cuts across several surface-level categories. The ASCII bar chart representation is a quick visual heuristic: a feature with one very long bar is a good candidate for a clean label; a feature with many roughly equal bars needs further investigation.

Measuring Monosemanticity via Entropy

The entropy score provides a quantitative summary of the concept-distribution analysis, allowing you to rank and filter features by their degree of monosemanticity without inspecting each one manually.

In[10]:
Code
from scipy.stats import entropy


def compute_monosemanticity_score(feature_idx, k=50):
    """
    Compute entropy-based monosemanticity: lower entropy = more monosemantic.
    Returns (entropy_value, dominant_concept, dominant_fraction).
    """
    top_indices, top_values = get_top_k_examples(feature_idx, k=k)
    if len(top_indices) == 0:
        return None, None, 0.0
    top_concepts_local = [token_concepts[i] for i in top_indices]
    counts = Counter(top_concepts_local)
    total = sum(counts.values())
    probabilities = np.array([c / total for c in counts.values()])
    h = entropy(probabilities, base=2)
    dominant = counts.most_common(1)[0]
    return h, dominant[0], dominant[1] / total


# Analyze monosemanticity for a sample of features
sample_features = sorted_by_freq[:100]  # Top-100 by frequency
entropy_scores = []
dominant_concepts = []
dominant_fractions = []

for feat_idx in sample_features:
    h, dom_concept, dom_frac = compute_monosemanticity_score(feat_idx, k=50)
    if h is not None:
        entropy_scores.append(h)
        dominant_concepts.append(dom_concept)
        dominant_fractions.append(dom_frac)

entropy_scores = np.array(entropy_scores)
dominant_fractions = np.array(dominant_fractions)
Out[11]:
Console
Monosemanticity analysis (top-100 features by frequency):
  Mean entropy: 3.096 bits
  Median entropy: 3.106 bits
  Features with entropy < 1.5 bits (near-monosemantic): 0
  Features with entropy > 2.5 bits (polysemantic): 100
  Mean dominant concept fraction: 0.195

Features with entropy below 1.5 bits have a dominant concept covering at least 70% of their top examples; these are candidates for clean, monosemantic labels. Features above 2.5 bits distribute broadly across categories and require more careful investigation to determine whether they are polysemantic or whether the concept categories are too coarse for the feature's actual function. The mean dominant concept fraction gives another view of the same information: when this value is close to 1.0, most features are near-monosemantic; when it is close to 1/ncategories1/n_{\text{categories}}, features are broadly polysemantic.

Monosemanticity Scatter Plot

Out[12]:
Visualization
Scatter plot of feature frequency vs semantic entropy showing monosemanticity distribution.
Relationship between feature activation frequency and monosemanticity, measured as the Shannon entropy of the top-50 activating examples distributed across semantic categories. Lower entropy corresponds to more monosemantic features, tracking a single concept. Color encodes the dominant concept fraction: green indicates one concept dominates, red indicates broad spread. The dashed horizontal lines mark operational thresholds for near-monosemantic (1.5 bits) and polysemantic (2.5 bits) classification.

A monosemantic feature appears in the lower region of this plot: low entropy and green coloring indicate that a dominant concept covers most of the top-kk examples. Polysemantic features cluster in the upper portion. The relationship between frequency and monosemanticity is not tight in this synthetic data; in real SAEs, frequent features often turn out to be polysemantic (they fire broadly because they track a structural property shared by many contexts), while rare features can be either highly specific (monosemantic) or underspecified (poorly trained).

Out[13]:
Visualization
Histogram showing concentrated activations for a monosemantic feature.
Activation value histogram for a near-monosemantic feature: strong, peaked activations concentrated at a small number of token positions, with most values near zero. The narrow, peaked distribution and low overall activation frequency are signatures of a feature tracking a specific, rare concept with little co-activation noise.
Histogram showing spread activations for a polysemantic feature.
Activation value histogram for a polysemantic feature: weaker activations spread across many more token positions, producing a broader histogram. The flatter distribution and higher overall firing rate indicate the feature co-activates across multiple unrelated contexts, consistent with polysemanticity.

The side-by-side comparison illustrates the difference in activation profiles. The monosemantic feature (left) shows a narrow, peaked distribution: when it fires, it fires with a consistent strength, because it is responding to a single, well-defined concept. The polysemantic feature (right) shows a broader, flatter distribution: its activations are more variable because it is responding to multiple distinct contexts, each of which may activate it to a different degree.