Механистическая интерпретируемость LLM: как заглянуть внутрь чёрного ящика
Введение: почему нам нужно заглянуть внутрь модели
Вы доверяете LLM? Модель говорит, что ответ правильный. Но как вы знаете, что она рассуждает правильно, а не просто угадала? В медицине, юриспруденции, науке — недостаточно знать, что модель даёт верный ответ. Нужно понимать, как она его получила.
Обычный подход:
Модель: 7 миллиардов параметров
Вход: "Столица Франции?"
Выход: "Париж"
✓ Ответ правильный
❓ Но как модель пришла к этому ответу?
Механистическая интерпретируемость:
Модель: 7 миллиардов параметров
Вход: "Столица Франции?"
Выход: "Париж"
✓ Ответ правильный
✓ Мы знаем КАК: нейрон 42 в слое 8 → токен "Париж"
Факт: В 2020-2024 годах механистическая интерпретируемость стала одним из самых быстрорастущих направлений в AI research. Команды Anthropic, OpenAI, и независимые исследователи (Neel Nanda, Chris Olah) публикуют сотни статей по теме.
Что такое механистическая интерпретируемость?
Определение
Механистическая интерпретируемость = разбор нейросети на составляющие
Цель: понять конкретные механизмы внутри модели, которые отвечают
за конкретное поведение — не статистически, а на уровне отдельных
нейронов, attention-head и слоёв.
Аналогия:
Обычная интерпретируемость: "Эта модель хорошо предсказывает рак"
Механистическая: "Эти 15 нейронов в слое 3 распознают опухоли по
форме, а 8 нейронов в слое 5 — по текстуре"
Чем отличается от обычной интерпретируемости?
Обычная (Post-hoc):
- SHAP values, LIME, feature importance
- Объясняет ПОСЛЕ предсказания
- "Эти слова повлияли на результат"
- Не говорит КАК внутри модели
Механистическая:
- Анализирует внутреннюю архитектуру
- "Этот attention-head извлекает имена, тот — даты"
- "Эти 20 нейронов формируют circuit для фактов"
- Работает на уровне компонентов модели
История направления
2020: Chris Olah (Distill.pub) — первые статьи по interpretability
2021: Anthropic выпускает серию статей по mechanistic interpretability
2022: Neel Nanda создаёт TransformerLens — инструмент для анализа
2023: Discovery of "induction heads" — модель учится копировать паттерны
2024: Circuit discovery для систематических ошибок
2025: Activation engineering — управление поведением через активации
2026: Tools становятся доступными для исследователей
Ключевые концепции
1. Circuits (Цепи)
Circuit — это подграф вычислений внутри модели, отвечающий за конкретную функцию.
Пример: "Induction circuit"
Layer 0: Attention head A3 извлекает предыдущее слово
Layer 2: Attention head B7 сравнивает с текущим контекстом
Layer 4: Neuron N42 усиливает сигнал, если паттерн совпал
Результат: модель копирует паттерн из прошлого
Как найти circuit:
1. Запускаем модель на входных данных
2. Отключаем компоненты (ablation) по одному
3. Если поведение изменилось — компонент в цепи
4. Строим граф зависимостей
2. Feature Detectors (Детекторы признаков)
Каждый нейрон в модели — это не "абстрактный параметр", а детектор
конкретного признака.
Обнаруженные features:
- "Этот нейрон активируется на имена людей"
- "Этот нейрон активируется на грамматический род"
- "Этот нейрон активируется на отрицание"
- "Этот нейрон активируется на число '0'"
Feature visualization:
1. Берём нейрон
2. Генерируем вход, который максимизирует его активацию
3. Смотрим, что это за паттерн
3. Attention Heads Analysis
Каждый attention head в трансформере выполняет специфическую задачу.
Обнаруженные типы head:
1. Induction heads
- Ищут одинаковые паттерны в прошлом
- "Если было X, то вероятно X снова"
- Отвечают за копирование
2. Name tracker heads
- Следят за упоминаниями имён
- "Он" → связывают с именем из контекста
- Критичны для coreference resolution
3. Argument dropper heads
- Забывают несущественную информацию
- Помогают обобщению
- Без них модель слишком буквально
4. Past-present connector heads
- Связывают прошлые токены с текущими
- Формируют грамматику предложений
4. Residual Stream
Residual stream — это "шину данных" трансформера.
Каждый слой добавляет свой вклад в residual stream:
residual[0] = embedding
residual[1] = residual[0] + layer_0_output
residual[2] = residual[1] + layer_1_output
...
Ключевое открытие:
Разные слои пишут в residual stream РАЗНУЮ информацию:
- Ранние слои: синтаксис, токенизация
- Средние слои: семантика, факты
- Поздние слои: формат ответа, стиль
Это значит что residual stream — не просто "накопление",
а структурированное хранилище с разделением по слоям.
Инструменты: TransformerLens
Установка
pip install transformerlens
Базовый анализ модели
from transformer_lens import HookedTransformer
# Загружаем модель
model = HookedTransformer.from_pretrained("gpt2-small")
# Запускаем на промпте
prompts = [
"The capital of France is",
"The capital of Germany is",
"The capital of Japan is",
]
# Получаем логированные активации
logits, loss = model(prompts, return_type="logits")
print(logits.shape) # [3, 1, vocab_size]
Анализ attention heads
# Смотрим attention паттерны для конкретного head
attention = model.attn[0](prompts, return_type="pattern")
# attention.shape = [batch, head, seq_q, seq_k]
# Визуализация: какой head на что смотрит
import matplotlib.pyplot as plt
import numpy as np
# Head 3, слой 0
head_attn = attention[0, 3, :, :] # [seq_q, seq_k]
plt.figure(figsize=(8, 6))
plt.imshow(head_attn.cpu().numpy(), cmap="viridis")
plt.title("Attention pattern: Layer 0, Head 3")
plt.xlabel("Key position")
plt.ylabel("Query position")
plt.colorbar()
plt.tight_layout()
plt.savefig("attention_head_0_3.png", dpi=150)
Feature visualization
from transformer_lens import ActivationCache
import torch
# Кэшируем все активации
cache = ActivationCache.from_run_with_labels(model, prompts)
# Смотрим активации конкретного нейрона в MLP
# MLP слой 2, нейрон 42
neuron_42 = cache.mlp[2][:, :, 42] # [batch, seq_len]
# Находим вход, который максимизирует активацию
max_activation_idx = torch.argmax(neuron_42)
print(f"Max activation: {neuron_42[max_activation_idx].item():.4f}")
print(f"At position: {max_activation_idx.item()}")
Ablation (Отключение компонентов)
def ablate_head(model, layer, head, prompts):
"""Отключаем конкретный attention head и смотрим, что изменится"""
def nullify_head(pattern, hook):
pattern[:, head, :, :] = 1.0 / pattern.shape[-1] # uniform attention
return pattern
# Без head
with model.hooks(fwd_hooks=[(model.attn[layer].pattern_hook, nullify_head)]):
logits_ablated, _ = model(prompts, return_type="logits")
# С head (baseline)
logits_normal, _ = model(prompts, return_type="logits")
# Сравниваем
diff = (logits_normal - logits_ablated).abs().max().item()
return diff
# Проверяем все head в слое 0
for head in range(model.n_heads):
impact = ablate_head(model, 0, head, prompts)
print(f"Layer 0, Head {head}: impact = {impact:.4f}")
Практика: находим circuit для фактов
Задача
Найдём, какие компоненты модели отвечают за знание столиц стран.
from transformer_lens import HookedTransformer, ActivationCache
import torch
model = HookedTransformer.from_pretrained("gpt2-small")
# Тестовые примеры
capital_prompts = [
"The capital of France is",
"The capital of Spain is",
"The capital of Italy is",
]
# Контрольная группа (не факты)
control_prompts = [
"The color of the sky is",
"The sound of the ocean is",
"The taste of salt is",
]
def measure_fact_accuracy(model, prompts, expected_tokens):
"""Измеряет, насколько модель предсказывает правильные токены"""
logits, _ = model(prompts, return_type="logits")
predictions = logits[:, -1, :].argmax(dim=-1)
correct = (predictions == expected_tokens).sum().item()
return correct / len(prompts)
# Базовая точность
expected = torch.tensor([model.to_tokens("Paris")[:,0],
model.to_tokens("Madrid")[:,0],
model.to_tokens("Rome")[:,0]])
base_accuracy = measure_fact_accuracy(model, capital_prompts, expected)
print(f"Base accuracy: {base_accuracy:.2%}")
Покомпонентная ablation
def find_fact_circuit(model, prompts, expected_tokens):
"""Находим какие компоненты критичны для фактов"""
results = []
# Проверяем каждый attention head
for layer in range(model.n_layers):
for head in range(model.n_heads):
def nullify_head(pattern, hook):
pattern[:, head, :, :] = 1.0 / pattern.shape[-1]
return pattern
with model.hooks(fwd_hooks=[
(model.attn[layer].pattern_hook, nullify_head)
]):
logits, _ = model(prompts, return_type="logits")
preds = logits[:, -1, :].argmax(dim=-1)
acc = (preds == expected_tokens).float().mean().item()
impact = base_accuracy - acc
if impact > 0.01: # Значимое влияние
results.append({
"type": "attention",
"layer": layer,
"head": head,
"impact": impact,
})
# Сортируем по влиянию
results.sort(key=lambda x: x["impact"], reverse=True)
return results
critical_components = find_fact_circuit(model, capital_prompts, expected)
for comp in critical_components[:10]:
print(f"{comp['type']} L{comp['layer']}H{comp['head']}: "
f"impact = {comp['impact']:.4f}")
Что мы обнаруживаем
Результаты для GPT-2 small:
Attention heads с наибольшим влиянием на факты:
L8 H3: impact = 0.34 ← критический head для фактов
L7 H1: impact = 0.22 ← вспомогательный
L11 H5: impact = 0.18 ← финальная обработка
L6 H7: impact = 0.12 ← извлечение сущностей
Выводы:
1. Факты хранятся в ПОЗДНИХ слоях (L6-L11)
2. Ранние слои (L0-L5) почти не влияют на факты
3. 3-4 head отвечают за ~80% фактов
4. Это подтверждает гипотезу о "fact circuits"
Activation Engineering: управление поведением
Что это?
Activation Engineering — техника управления поведением модели
через модификацию её внутренних активаций.
Вместо того чтобы менять промпт или дообучать модель,
мы напрямую редактируем активации нейронов.
Примеры:
- Усилить активацию "безопасности" → модель становится осторожнее
- Подавить активацию "отрицания" → модель меньше отказывает
- Активировать "креативность" → модель генерирует разнообразнее
Практика: уменьшаем refusals
# Проблема: модель отказывает на безобидные запросы
# Решение: находим нейроны "refusal" и подавляем их
from transformer_lens import HookedTransformer
model = HookedTransformer.from_pretrained("gpt2-small")
# 1. Находим refusal-нейроны
refusal_prompts = [
"I can't help with that",
"I'm not allowed to",
"I cannot assist with",
]
normal_prompts = [
"The weather today is",
"I think that the answer is",
"Let me explain how",
]
# Сравниваем активации
cache_refusal = ActivationCache.from_run_with_labels(model, refusal_prompts)
cache_normal = ActivationCache.from_run_with_labels(model, normal_prompts)
# Ищем нейроны, которые сильнее активируются на refusal
for layer in range(model.n_layers):
refusal_act = cache_refusal.mlp[layer].mean(dim=(0, 1))
normal_act = cache_normal.mlp[layer].mean(dim=(0, 1))
# Нейроны с большой разницей
diff = refusal_act - normal_act
top_neurons = torch.topk(diff, k=10)
print(f"Layer {layer} — top refusal neurons:")
for idx, score in zip(top_neurons.indices, top_neurons.values):
print(f" Neuron {idx.item()}: score = {score.item():.4f}")
Инъекция активаций
def inject_activation(model, prompts, layer, neuron_idx, strength=2.0):
"""Усиливаем активацию конкретного нейрона"""
def amplify_neuron(mlp_out, hook):
mlp_out[:, :, neuron_idx] *= strength
return mlp_out
with model.hooks(fwd_hooks=[
(model.mlp[layer].post_hook, amplify_neuron)
]):
logits, _ = model(prompts, return_type="logits")
return logits
# Пример: усиливаем "факт-нейрон"
modified_logits = inject_activation(
model,
["The capital of France is"],
layer=8,
neuron_idx=42,
strength=3.0
)
# Смотрим, изменился ли ответ
top_token = modified_logits[0, -1, :].argmax().item()
print(f"Top token: {model.tokenizer.decode(top_token)}")
Superposition (Суперпозиция)
Проблема
Суперпозиция — когда один нейрон кодирует МНОГО признаков одновременно.
7B модель:
~7 миллиардов параметров
~100 000 уникальных концепций в языке
Проблема: нейронов меньше, чем концепций!
→ Каждый нейрон должен кодировать несколько концепций
→ "Этот нейрон активируется на: имена, числа, и даты"
Это делает интерпретацию сложнее:
- Нельзя просто сказать "нейрон X = концепция Y"
- Нужно найти sparse representation (dictionary learning)
Dictionary Learning (Обучение словаря)
# Используем sparse autoencoder для разделения признаков
from transformer_lens import HookedTransformer
import torch
model = HookedTransformer.from_pretrained("gpt2-small")
# Загружаем активации
cache = ActivationCache.from_run_with_labels(model, prompts)
activations = cache.mlp[0].detach() # [batch, seq, n_neurons]
# Sparse autoencoder (упрощённо)
class SparseAutoencoder(torch.nn.Module):
def __init__(self, input_dim, latent_dim):
super().__init__()
self.encode = torch.nn.Linear(input_dim, latent_dim)
self.decode = torch.nn.Linear(latent_dim, input_dim)
self.b_dec = torch.nn.Parameter(torch.zeros(input_dim))
def forward(self, x):
# ReLU для sparse активации
latent = torch.relu(self.encode(x) - self.b_enc)
reconstructed = self.decode(latent) + self.b_dec
return reconstructed, latent
def loss(self, x, x_recon, latent):
recon_loss = ((x - x_recon) ** 2).mean()
sparsity_loss = (latent.abs()).mean()
return recon_loss + 0.1 * sparsity_loss
# Обучаем autoencoder на активациях MLP слоя 0
input_dim = activations.shape[-1]
latent_dim = input_dim * 8 # latent пространство больше!
ae = SparseAutoencoder(input_dim, latent_dim)
optimizer = torch.optim.Adam(ae.parameters(), lr=1e-4)
for step in range(1000):
x_recon, latent = ae(activations)
loss = ae.loss(activations, x_recon, latent)
optimizer.zero_grad()
loss.backward()
optimizer.step()
if step % 100 == 0:
print(f"Step {step}: loss = {loss.item():.4f}")
Что даёт dictionary learning?
Без autoencoder:
Нейрон 42: активируется на "Paris", "France", "London", "UK", "capital"
→ Что это значит? Неясно.
С autoencoder (8x latent dim):
Latent 201: активируется ТОЛЬКО на ["Paris", "Lyon", "Nice"] → "French cities"
Latent 455: активируется ТОЛЬКО на ["capital", "capital city", "capitale"] → "capital concept"
Latent 892: активируется ТОЛЬКО на ["France", "Germany", "Japan"] → "country name"
Теперь каждый latent — один понятный признак!
Интерпретируемость на практике: что можно сделать
1. Debug систематических ошибок
Модель постоянно путает X и Y?
1. Запускаем ablation на примерах с ошибкой
2. Находим критические head/нейроны
3. Анализируем, что они детектируют
4. Понимаем причину ошибки
5. Фиксим: дообучение, prompt engineering, или post-processing
Реальный пример:
Проблема: модель путает "он" и "она"
Причина: attention head L4 H2 не корректно отслеживает род
Фикс: добавили примеры с явным указанием рода в few-shot
2. Safety analysis
Как проверить, что модель безопасна?
1. Ищем "harmful" features через activation analysis
2. Мониторим их активацию на production запросах
3. Если активация выше порога → блокируем ответ
Это лучше чем prompt-level guardrails:
- Работает на уровне модели, а не текста
- Нельзя обойти через jailbreak
- Понимает контекст
3. Model editing
Хотите изменить знание модели без дообучения?
1. Находим circuit для конкретного факта
2. Модифицируем веса в этом circuit
3. Факт изменён, остальное — нет
Пример:
Факт: "Президент США — Байден"
Circuit: L7 H1 → L8 H3 → L11 H5
Изменение: обновляем weights в этих head
Результат: модель теперь говорит нового президента
Сравнение подходов
┌────────────────────┬─────────────────┬──────────────┬──────────────┐
│ Метод │ Что показывает │ Сложность │ Точность │
├────────────────────┼─────────────────┼──────────────┼──────────────┤
│ Attention viz │ На что смотрит │ Низкая │ Низкая │
│ │ каждый head │ │ │
├────────────────────┼─────────────────┼──────────────┼──────────────┤
│ Ablation │ Какие компоненты│ Средняя │ Высокая │
│ │ критичны │ │ │
├────────────────────┼─────────────────┼──────────────┼──────────────┤
│ Feature viz │ Что детектирует │ Средняя │ Средняя │
│ │ каждый нейрон │ │ │
├────────────────────┼─────────────────┼──────────────┼──────────────┤
│ Circuit discovery │ Полный граф │ Высокая │ Очень │
│ │ вычислений │ │ высокая │
├────────────────────┼─────────────────┼──────────────┼──────────────┤
│ Dict learning │ Разделённые │ Очень │ Высокая │
│ │ признаки │ высокая │ │
├────────────────────┼─────────────────┼──────────────┼──────────────┤
│ Activation eng. │ Управление │ Средняя │ Зависит │
│ │ поведением │ │ от задачи │
└────────────────────┴─────────────────┴──────────────┴──────────────┘
Ограничения
Проблема масштаба
Методы работают хорошо на маленьких моделях:
GPT-2 small (124M): ✓ полный анализ возможен
GPT-2 (1.5B): ⚠️ частично, требует вычислений
LLaMA 7B: ⚠️ сложно, но возможно
LLaMA 70B: ❌ практически невозможно
GPT-4: ❌ закрытая модель, нет доступа к весам
Проблема: чем больше модель, тем сложнее анализ.
- 70B модель: 70 миллиардов параметров
- Полный ablation всех head: дни вычислений
- Circuit discovery: экспоненциальная сложность
Проблема интерпретации
Даже найдя circuit, мы можем не понять его:
Circuit: L3 H5 → L7 H2 → L11 H8
Что делает? Неясно.
Feature viz показывает:
L3 H5: активируется на глаголы прошедшего времени
L7 H2: активируется на согласование подлежащего
L11 H8: формирует окончание глагола
Вывод: это circuit для грамматики прошедшего времени
Но точно ли? Может быть, мы неправильно интерпретируем.
Чек-лист: начало работы с интерпретируемостью
- Установить TransformerLens (
pip install transformerlens) - Выбрать маленькую модель для начала (GPT-2 small, Pythia 70M)
- Загрузить модель и запустить на тестовых промптах
- Визуализировать attention patterns нескольких head
- Провести ablation: отключить head и измерить влияние
- Найти top-10 критических head для вашей задачи
- Сделать feature visualization для этих head
- Протестировать activation engineering
- При необходимости — обучить sparse autoencoder
- Масштабировать на большие модели
Итоги
Механистическая интерпретируемость — это не абстрактная наука, а практический инструмент:
- Debug: понимание почему модель ошибается на конкретных примерах
- Safety: обнаружение опасных паттернов на уровне нейронов
- Optimization: знание каких частей модели можно урезать без потери качества
- Trust: доказательство что модель работает правильно, а не угадывает
С чего начать:
# Установка
pip install transformerlens
# Быстрый старт
python -c "
from transformer_lens import HookedTransformer
model = HookedTransformer.from_pretrained('gpt2-small')
logits, loss = model('The capital of France is', return_type='logits')
print('Model loaded and running!')
"
Механистическая интерпретируемость превращает LLM из чёрного ящика в прозрачную систему, которую можно анализировать, отлаживать и улучшать.