Механистическая интерпретируемость LLM: как заглянуть внутрь чёрного ящика

opensourceaillminterpretabilitymechanistictransformerlensresearchit
← Back to Blog

Введение: почему нам нужно заглянуть внутрь модели

Вы доверяете 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 для грамматики прошедшего времени
  Но точно ли? Может быть, мы неправильно интерпретируем.

Чек-лист: начало работы с интерпретируемостью

  1. Установить TransformerLens (pip install transformerlens)
  2. Выбрать маленькую модель для начала (GPT-2 small, Pythia 70M)
  3. Загрузить модель и запустить на тестовых промптах
  4. Визуализировать attention patterns нескольких head
  5. Провести ablation: отключить head и измерить влияние
  6. Найти top-10 критических head для вашей задачи
  7. Сделать feature visualization для этих head
  8. Протестировать activation engineering
  9. При необходимости — обучить sparse autoencoder
  10. Масштабировать на большие модели

Итоги

Механистическая интерпретируемость — это не абстрактная наука, а практический инструмент:

  • 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 из чёрного ящика в прозрачную систему, которую можно анализировать, отлаживать и улучшать.


Ссылки