Перейти к содержимому
Ступень 99.3 Интерпретируемость, безопасность и этика

Заглядываем внутрь трансформера: logit lens, индукционные головы, patching

Вводный урок модуля об интерпретируемости и безопасности. Раскладываем остаточный поток на вклады голов, читаем промежуточные слои через logit lens, находим индукционные головы и доказываем их роль абляцией и activation patching. Практика — модель в браузере, своя GPT из 4.6 и GPT-2 small в TransformerLens.

УРОКstage-9/9-3-interpretability-safety/01-mech-interp.mdx2 ч 30 минУрок 1 из 5
После урока вы сможете
  • Объяснить, почему остаточный поток позволяет разложить предсказание модели на вклады отдельных голов и слоёв, и применить logit lens
  • Найти индукционную голову по паттерну внимания и подтвердить абляцией, что она работает в паре с головой предыдущего токена
  • Отличать наблюдательные методы (паттерны, зонды) от причинных (абляция, activation patching) и знать ловушки каждого
  • Запустить logit lens, индукционный тест и activation patching на своей GPT из 4.6 и на GPT-2 small

Модуль 9.3: что внутри и можно ли этому доверять

Заголовок раздела «Модуль 9.3: что внутри и можно ли этому доверять»

К этому моменту вы умеете собрать LLM от токенизатора до агента. Последний модуль ступени задаёт неудобные вопросы: что модель на самом деле вычисляет, как заставить её вести себя так, как мы хотим, как проверить, что она не опасна, и что можно и нельзя делать с данными и моделями по закону. Пять уроков идут от устройства модели к обществу:

УрокВопросГлавные инструменты
9.3.1 (этот)Что происходит внутри трансформера?logit lens, паттерны внимания, абляция, activation patching, зонды
9.3.2Почему нейроны многозначны и как это распутать?суперпозиция, разреженные автоэнкодеры, графы атрибуции
9.3.3Чего мы хотим от модели и как этого добиться?спецификации, RLHF/DPO, Constitutional AI, масштабируемый надзор
9.3.4Как проверить, что модель не опасна?оценки опасных возможностей, red teaming, guard-модели, политики лабораторий
9.3.5Что говорят этика и право?смещения, авторское право, персональные данные, регулирование ИИ

Интерпретируемость идёт первой: выравнивание и оценки безопасности смотрят на модель по её ответам, а она пытается заглянуть внутрь. Здесь создатели моделей делятся результатами охотнее всего: Anthropic публикует интерактивные статьи серии Transformer Circuits, Google DeepMind выложила открытые разреженные автоэнкодеры для Gemma, а сообщество сделало бесплатный практикум ARENA.

В уроке 4.6.2 вы написали GPT своими руками. Но код описывает только архитектуру. Алгоритм, по которому обученная модель ставит запятую или вспоминает столицу, нашёл градиентный спуск, и записан он в сотнях тысяч чисел, которые нельзя просто прочитать.

Механистическая интерпретируемостьмеханистическая интерпретируемостьEN mechanistic interpretabilityОбратная разработка обученной нейросети — поиск внутри неё понятных алгоритмов (схем) и доказательство вмешательством, что модель действительно их использует. пытается провести обратную разработку (reverse engineering) этих алгоритмов: найти внутри модели понятные шаги и доказать, что модель действительно использует именно их. Это нужно по трём причинам:

  1. Отладка: какой компонент виноват в ошибке?
  2. Безопасность: если модель научится обманывать, по ответам это может быть не видно, а по внутренним состояниям — возможно.
  3. Наука: понять, как устроены обучение в контексте, память фактов, рассуждения.

Поле молодое: полностью «прочитать» большую модель пока не умеет никто. Но несколько механизмов поняты по-настоящему, и инструменты для их поиска вы освоите в этом уроке.

В уроке 3.4.2 и уроке 4.5.1 мы назвали вектор, который течёт сквозь модель, остаточным потокомостаточный потокEN residual streamВектор шириной d, который течёт сквозь все блоки. Каждый подслой читает из него и дописывает поправку, так что выход = вход + сумма всех поправок.. Каждый подслой читает из него и прибавляет свою поправку. Если развернуть все блоки, итоговый вектор последней позиции — это сумма:

xL=x0+∑головы houth+∑MLP moutm.x_L = x_0 + \sum_{\text{головы } h} \mathrm{out}_h + \sum_{\text{MLP } m} \mathrm{out}_m .

Словами: эмбеддинг плюс вклад каждой головы каждого слоя плюс вклад каждого MLP. В уроке 4.4.3 мы проверили, что выход многоголового внимания — тоже сумма вкладов отдельных голов: склейка голов и умножение на WOW_O равны сумме headhWO(h)\mathrm{head}_h W_O^{(h)}.

Главное следствие: прямая атрибуция логитов

Заголовок раздела «Главное следствие: прямая атрибуция логитов»

Логиты получаются так: logits=LN(xL) WU\text{logits} = \mathrm{LN}(x_L)\, W_U, где WUW_U — матрица LM-головы, а LN\mathrm{LN} — финальная нормализация. Вычитание среднего и умножение на γ\gamma в ней линейны, нелинейно только деление на стандартное отклонение σ\sigma. Если считать σ\sigma константой (она одна на всю позицию), логит токена tt раскладывается на слагаемые:

logitt≈1σ(x0⋅ut+∑coutc⋅ut),\text{logit}_t \approx \frac{1}{\sigma}\Big(x_0 \cdot u_t + \sum_c \mathrm{out}_c \cdot u_t\Big),

где utu_t — столбец WUW_U для токена tt, а cc пробегает все головы и MLP. Точнее, utu_t — этот столбец, умноженный поэлементно на γ\gamma и центрированный: так линейные части нормализации «вшивают» в WUW_U, а сдвиг β\beta даёт слагаемое, одинаковое для всех компонентов, и в сумму вкладов не входит. Каждое слагаемое outc⋅ut\mathrm{out}_c \cdot u_t отвечает на вопрос «насколько компонент cc сам, напрямую, толкает модель к токену tt». Это прямая атрибуция логитовпрямая атрибуция логитов (DLA)EN direct logit attributionВклад компонента в логит токена t — скалярное произведение его выхода со столбцом W_U для t (при замороженном масштабе финальной нормализации). Видит только прямые вклады. (direct logit attribution, DLA).

Обычно смотрят не на один логит, а на разность двух: «Paris, а не Rome». Разность убирает всё, что одинаково поднимает оба токена. Посчитаем на игрушечных числах с d=4d = 4 (для простоты без нормализации):

import numpy as np
# Вклады трёх «авторов» в остаточный поток последней позиции (d = 4, без нормализации)
parts = {
"эмбеддинг": np.array([1.0, 0.0, 0.5, 0.0]),
"голова 1": np.array([0.0, 2.0, 0.0, 0.5]),
"MLP 2": np.array([0.5, 0.5, 1.0, 0.0]),
}
x = sum(parts.values()) # итоговый поток = сумма вкладов
u_paris = np.array([0.0, 1.0, 0.5, 0.0]) # столбцы W_U для двух токенов
u_rome = np.array([0.5, 0.0, 0.0, 1.0])
u = u_paris - u_rome # направление «Paris, а не Rome»
print(f"logit(Paris) - logit(Rome) = {x @ u:.2f}")
for name, p in parts.items():
print(f" вклад «{name}»: {p @ u:+.2f}")
logit(Paris) - logit(Rome) = 2.00
вклад «эмбеддинг»: -0.25
вклад «голова 1»: +1.50
вклад «MLP 2»: +0.75

Три вклада в сумме дают ровно 2.00 — линейность работает. Голова 1 сделала большую часть работы, эмбеддинг даже немного мешал. В настоящей модели таких слагаемых сотни, и DLA за один прогон показывает, какие головы напрямую пишут ответ.

Но запомните слово «напрямую». Голова может не писать ответ сама, а готовить данные для другой головы. DLA такой вклад не увидит, и в практике вы встретите именно этот случай.

Раз все слои пишут в одно и то же пространство, можно взять остаточный поток после промежуточного слоя и применить к нему финальную нормализацию и LM-голову, как будто модель на этом слое закончилась. Получится распределение «что бы модель предсказала, если бы остановилась здесь». Этот приём назвали logit lenslogit lens («логит-линза»)EN logit lensПрименить финальную нормализацию и LM-голову к остаточному потоку промежуточного слоя — «что модель предсказала бы, если бы остановилась здесь» (nostalgebraist, 2020).. Его описал автор под ником nostalgebraist в посте «interpreting GPT: the logit lens» на LessWrong (2020). На GPT-2 он увидел, что первые слои угадывают самые частые слова вроде «the» и «of», а правильный ответ появляется в верхушке списка только на определённой глубине и дальше держится.

Посмотрим на вашу модель из урока 4.6.2 (полный скрипт — в разделе «Практика»). Средний loss на валидации, если предсказывать по потоку после каждого блока:

эмбеддинги: loss 12.82
после блока 0: loss 3.24
после блока 1: loss 2.94
после блока 2: loss 2.47
после блока 3: loss 1.64

Предсказание уточняется слой за слоем: 3.24 → 2.94 → 2.47 → 1.64. Каждый блок добавляет в поток то, что приближает ответ. Странное число в первой строке тоже поучительно. Loss 12.82 намного хуже случайного угадывания (ln⁡137≈4.92\ln 137 \approx 4.92). Причина — weight tying из урока 4.6.2: у LM-головы та же матрица, что у эмбеддингов, поэтому «голый» эмбеддинг символа сильнее всего похож на самого себя. Модель уверенно предсказывает, что текущий символ повторится, а символы почти никогда не повторяются подряд.

Ограничения метода:

  • Ранние слои могут хранить «черновик» ответа в другом базисе, и финальная LM-голова его не прочитает. Поэтому Белроуз и соавторы (2023) предложили tuned lens: для каждого слоя обучают маленькое линейное преобразование, которое переводит поток в «язык» последнего слоя.
  • Logit lens показывает, что закодировано в потоке. Почему оно там оказалось и какой компонент это сделал, он не говорит.

Паттерн вниманияпаттерн вниманияEN attention patternМатрица весов внимания одной головы размера T×T; строка i показывает, как позиция-запрос i распределила внимание между позициями 0…i. — это матрица весов T×TT \times T одной головы из урока 4.4.2: строка ii показывает, как позиция-запрос ii распределила внимание между позициями 0…i0 \dots i. Если нарисовать её как картинку, у разных голов проступают узнаваемые узоры:

  • голова предыдущего токена — яркая полоса сразу под диагональю: каждая позиция смотрит на соседа слева;
  • «голова начала» — яркий первый столбец: всё внимание уходит на первый токен (часто это способ «никуда не смотреть»);
  • голова дубликатов — смотрит на прошлые появления того же токена;
  • индукционная голова — самая интересная, о ней дальше.

Индукция: как модель продолжает уже виденное

Заголовок раздела «Индукция: как модель продолжает уже виденное»

В уроке 4.4.3 мы коротко упомянули индукционные головыиндукционная головаEN induction headПара голов, которая находит в контексте прошлое появление текущего токена и копирует токен, шедший после него ([A][B] … [A] → [B]). Механизм in-context learning (Olsson и др., 2022). (Olsson и соавт., Anthropic, 2022). Теперь разберём механизм целиком. Задача: в тексте уже было «… Гарри Поттер …», и снова встретилось «Гарри». Хорошая модель должна предсказать «Поттер». Общее правило: [A][B]…[A]→[B][A][B] \dots [A] \to [B].

Одной головой это не сделать. Чтобы найти позицию BB, нужно знать, что перед ней стоял AA, а внимание видит только содержимое позиций, но не их соседей. Поэтому работают две головы в разных слоях:

flowchart TD
P["Слой 0, голова предыдущего токена:<br/>позиция B смотрит на соседа слева<br/>и пишет в свой поток «передо мной был A»"]
Q["Слой 1, индукционная голова:<br/>запрос со второго A — «кто стоял после A?»"]
K["ключ позиции B: «передо мной был A»<br/>совпадение — внимание на B"]
O["копирует токен B в поток второго A"]
R["предсказание: B"]
P -->|"метка в остаточном потоке"| K
Q --> K --> O --> R
Индукционная схема: вторая голова ищет по метке, которую оставила первая

Здесь полезен язык из «A Mathematical Framework for Transformer Circuits». У каждой головы две независимые части:

  • QK-цепь WQWK⊤W_Q W_K^\top решает, куда смотреть: какие пары «запрос — ключ» получат большой вес.
  • OV-цепь WVWOW_V W_O решает, что переносить: какой вектор попадёт в поток позиции-запроса.

В индукционной схеме OV-цепь первой головы переносит «кто я» соседу справа, а QK-цепь второй головы сравнивает «кто я» запроса с «кто был до тебя» ключа. Ключи второй головы читают то, что записала первая. Такое сцепление голов называют K-композицией. Совокупность компонентов, которые вместе реализуют один алгоритм, называют схемойсхема (цепь) в нейросетиEN circuitНабор компонентов (головы, нейроны, признаки) и связей между ними, которые вместе реализуют один алгоритм, например индукцию. (circuit).

Самый надёжный тест — повторённые случайные токены: A B C…A B C…A\,B\,C \dots A\,B\,C \dots длиной 2L2L. В случайной последовательности нет ни грамматики, ни смысла, поэтому вторую половину можно предсказать единственным способом — скопировать из первой. Индукционная голова на позиции ii второй половины смотрит на позицию i−L+1i - L + 1: туда, где в первый раз стоял следующий токен. На картинке паттерна это яркая полоса, сдвинутая на L−1L - 1 влево от диагонали. Индукционный счёт головы — средний вес на этой полосе:

scoreh=1L∑i=L2L−1Ah[i, i−L+1].\text{score}_h = \frac{1}{L} \sum_{i = L}^{2L - 1} A_h[i,\ i - L + 1].

Числа: при L=8L = 8 позиция i=10i = 10 должна смотреть на позицию 33. У идеальной индукционной головы счёт равен 1, у головы, размазывающей внимание равномерно, — около 1/i1/i, то есть меньше 0.1.

  • Индукционные головы появляются скачком, в узком окне в начале обучения — авторы называют это фазовым переходом. В тот же момент на кривой loss виден небольшой «бугорок», и резко улучшается обучение в контексте (их метрика: насколько loss на 500-м токене контекста ниже, чем на 50-м). В их моделях это происходило примерно после 2.5–5 млрд токенов.
  • У однослойных моделей фазового перехода нет: схеме нужны минимум две головы в разных слоях.
  • Для маленьких моделей только из внимания доказательства причинные (выключаешь головы — пропадает обучение в контексте). Для больших моделей с MLP — корреляционные. Авторы прямо пишут об этой разнице.

В GPT-2 small индукционными головами обычно называют 5.1, 5.5, 6.9, 7.2 и 7.10 (номер слоя, точка, номер головы), а головой предыдущего токена — 4.11. Эти номера вы проверите сами в «🖥 На вашей машине».

А вот вашей модели из 4.6.2 повезло меньше. Скрипт из практики даёт на повторённых фрагментах текста:

loss на 1-м повторе 1.73, на 2-м 1.82
блок 0: индукционный счёт голов [0. 0. 0. 0.]
блок 1: индукционный счёт голов [0. 0. 0. 0.]
блок 2: индукционный счёт голов [0. 0. 0. 0.]
блок 3: индукционный счёт голов [0. 0. 0. 0.]

Второй повтор модель предсказывает не лучше первого: копировать из контекста она не умеет, индукционных голов нет. Это не ошибка, а масштаб. Наша модель видела около 8 млн символов, на сотни раз меньше, чем модели Olsson к моменту фазового перехода. Она выучила статистику соседних символов и слов, а до индукции не доросла.

Корреляция или причина: абляция и activation patching

Заголовок раздела «Корреляция или причина: абляция и activation patching»

Паттерн показывает, куда смотрит голова, но не важно ли это: её выход может никто не читать. Чтобы доказать роль компонента, в него нужно вмешаться и посмотреть, изменится ли поведение — как в контролируемом эксперименте.

Самый простой способ — выключить компонент (об абляциях как о разделе статьи — в уроке 9.2.2) и измерить, насколько вырос loss. Варианты:

  • zero-ablation — заменить выход головы нулём. Просто, но модель никогда не видела здесь нулей, и падение качества может объясняться «шоком», а не потерей функции;
  • mean-ablation — заменить выход средним по многим текстам: голова перестаёт различать примеры, а масштаб остаётся привычным. Обычно это честнее.

В практике вы увидите эффект, который DLA пропускает: голова предыдущего токена ничего не пишет в выход, её прямой вклад в логиты нулевой. Но если её выключить, индукционная голова теряет метки и ломается. Причинный метод видит композицию, наблюдательный — нет.

Абляция грубая. Чтобы узнать, где именно (слой, позиция) лежит информация для ответа, придумали activation patchingподмена активаций (activation patching)EN activation patchingПричинный метод. Активацию в одной точке (слой, позиция) из «чистого» прогона подставляют в «испорченный» и меряют, какую долю правильного ответа это восстанавливает. (подмену активаций). Возьмём два почти одинаковых промпта:

  • чистый (clean): «When John and Mary went to the shops, John gave the bag to» → правильный ответ « Mary»;
  • испорченный (corrupted): «When John and Mary went to the shops, Mary gave the bag to» → теперь ответ « John».

Отличается одно слово, длина в токенах одинаковая. Метрика — разность логитов m=logit(Mary)−logit(John)m = \text{logit}(\text{Mary}) - \text{logit}(\text{John}): на чистом промпте она положительная, на испорченном отрицательная. Запускаем испорченный промпт, но в одной точке (слой ll, позиция pp) подставляем активацию из чистого прогона. Если метрика вернулась к «чистому» значению, вся нужная информация была в этой точке. Доля восстановления:

r=mpatched−mcorruptedmclean−mcorrupted.r = \frac{m_\text{patched} - m_\text{corrupted}}{m_\text{clean} - m_\text{corrupted}}.

Словами: какую часть пути от испорченного ответа к чистому прошла метрика после подмены. Пример: mclean=3.2m_\text{clean} = 3.2, mcorrupted=−2.8m_\text{corrupted} = -2.8, после подмены m=1.6m = 1.6. Тогда r=(1.6+2.8)/(3.2+2.8)=4.4/6≈0.73r = (1.6 + 2.8) / (3.2 + 2.8) = 4.4 / 6 \approx 0.73: эта точка объясняет почти три четверти эффекта.

Пройдя по всем слоям и позициям, получают карту «где живёт ответ». Метод прославили две работы:

  • ROME (Meng, Bau, Andonian, Belinkov, NeurIPS 2022). Подменяя активации (авторы называют это causal tracing), они нашли, что для фактов вроде «Эйфелева башня находится в …» решающую роль играют MLP средних слоёв на последнем токене названия объекта. Затем они научились править факт изменением весов одного такого MLP (Rank-One Model Editing).
  • IOI (Wang и соавт., Redwood Research, 2022). Для задачи с Джоном и Мэри выше в GPT-2 small нашли схему из 26 голов в 7 функциональных группах. Головы, которые переносят имя в ответ, назвали name movers.

Линейный зондлинейный зондEN linear probeЛогистическая или линейная регрессия на замороженных активациях модели. Показывает, что свойство линейно извлекаемо, но не доказывает, что модель его использует. мы упоминали в уроке 2.2.2: на скрытых состояниях замороженной модели учат логистическую регрессию предсказывать свойство — часть речи, истинность утверждения, клетку на доске. Если зонд справляется, информация линейно закодирована в активациях. Подвохов два:

  1. Зонд может выучить задачу сам. Хьюитт и Лян (EMNLP 2019) дают тому же зонду бессмысленную контрольную разметку (случайную, но согласованную метку для каждого слова). Если зонд хорошо учит и её, высокая точность говорит о силе зонда, а не о модели. Разницу точностей они назвали селективностью.
  2. Извлекаемо — не значит используется. Зонд корреляционный: модель может решать по другим признакам. Проверка та же — вмешаться: сдвинуть активацию вдоль направления зонда и посмотреть, меняется ли поведение.

Поучительная история — Othello-GPT. Модель учили только предсказывать ходы в игре «Отелло», без доски. Исходная работа нашла в ней представление доски, но извлекалось оно нелинейными зондами. Нил Нанда затем показал, что представление линейно, если кодировать клетки не как «чёрная/белая», а как «моя/чужая» (относительно игрока, который сейчас ходит). Вывод: неудача линейного зонда может означать, что вы ищете не те признаки, а не что их нет. Идея «понятия — это направления в пространстве активаций» станет центральной в следующем уроке.

МетодЧто показываетТип доказательстваГлавная ловушка
DLAкто напрямую пишет ответнаблюдениене видит косвенных вкладов
Logit lensчто закодировано на глубине llнаблюдениеранние слои в «другом базисе»
Паттерн вниманиякуда смотрит голованаблюдение«смотрит» ≠ «важно»
Линейный зондизвлекаема ли информациякорреляцияизвлекаемо ≠ используется
Абляциянужен ли компонентвмешательствовыводит модель из привычного режима
Activation patchingгде лежит нужная информациявмешательствозависит от пары промптов и метрики

В задании — двухслойный трансформер только из внимания, по три головы в слое. Веса заданы заранее, головы перемешаны. По паттернам найдите голову предыдущего токена и индукционную, через logit lens посмотрите, на какой глубине появляется предсказание, а абляцией докажите, что схема держится ровно на двух головах. После решения откройте load_model: там QK- и OV-цепи из раздела выше записаны числами.

Индукционная голова: паттерны, logit lens, абляцияnumpy · нужен интернет6 тестов
import numpy as np


# ── Ваша часть ─────────────────────────────────────────────────────────────────
def prev_token_score(pattern):
    """Насколько каждая голова смотрит на ПРЕДЫДУЩИЙ токен.

    pattern — веса внимания одного слоя, форма (H, T, T): pattern[h, i, j] — сколько
    внимания позиция-запрос i отдаёт позиции-ключу j в голове h.
    Вернуть массив (H,): среднее pattern[h, i, i-1] по позициям i = 1 … T-1.
    """
    # TODO
    ...


def induction_score(pattern, period):
    """Индукционный счёт: последовательность — два одинаковых куска длины period.

    Позиция i во втором куске (i = period … T-1) видит тот же токен, что на позиции i - period,
    а индукционная голова смотрит на токен ПОСЛЕ него — на позицию i - period + 1.
    Вернуть массив (H,): среднее pattern[h, i, i - period + 1] по i = period … T-1.
    """
    # TODO
    ...


def loss_on_repeats(logits, tokens, period):
    """Средняя cross-entropy предсказания следующего токена на ВТОРОМ куске.

    logits — (T, V), tokens — (T,). Позиция i предсказывает tokens[i + 1].
    Берём позиции i = period … T-2 (у последней позиции нет «следующего» токена).
    Считайте устойчиво: вычтите максимум строки перед exp (урок 1.3.2).
    """
    # TODO
    ...


def logit_lens_losses(model, tokens, period):
    """Logit lens: loss на втором куске, если «остановить» модель после 0, 1, …, N_LAYERS слоёв.

    run(model, tokens) возвращает (logits, cache); cache["resid"][k] — остаточный поток (T, D_MODEL)
    после k слоёв (k = 0 — только эмбеддинги). Логиты «на глубине k» = cache["resid"][k] @ model["W_U"].
    Вернуть список из N_LAYERS + 1 чисел (loss_on_repeats на каждой глубине).
    """
    # TODO
    ...


def ablation_effects(model, tokens, period):
    """Абляция: насколько вырастет loss на втором куске, если выключить одну голову.

    run(model, tokens, ablate=(слой, голова)) обнуляет выход этой головы.
    Вернуть словарь {(слой, голова): loss_с_абляцией - loss_без_абляции} для всех N_LAYERS * N_HEADS голов.
    """
    # TODO
    ...


# ── Готовая модель (писать не нужно) ───────────────────────────────────────────
# Двухслойный трансформер только из внимания: без MLP и без нормализации, по 3 головы в слое.
# Остаточный поток шириной D_MODEL = 72; каждая голова читает из него и дописывает свой вклад.
V, T_MAX, D_HEAD, N_LAYERS, N_HEADS = 12, 24, 24, 2, 3
D_MODEL = 72


def softmax(x):
    x = x - x.max(axis=-1, keepdims=True)
    e = np.exp(x)
    return e / e.sum(axis=-1, keepdims=True)


def run(model, tokens, ablate=None):
    """Прямой проход. Возвращает (logits (T, V), cache).

    cache["pattern"][l] — веса внимания слоя l, (H, T, T);
    cache["head_out"][l] — вклад каждой головы в поток, (H, T, D_MODEL);
    cache["resid"][k]   — остаточный поток после k слоёв, (T, D_MODEL).
    ablate=(l, h) — обнулить выход головы h в слое l.
    """
    T = len(tokens)
    x = model["W_E"][tokens] + model["W_pos"][:T]                 # эмбеддинг токена + позиции
    cache = {"resid": [x], "pattern": [], "head_out": []}
    mask = np.tril(np.ones((T, T), dtype=bool))                    # причинная маска
    for l in range(N_LAYERS):
        q = np.einsum("td,hde->hte", x, model["W_Q"][l])           # (H, T, D_HEAD)
        k = np.einsum("td,hde->hte", x, model["W_K"][l])
        v = np.einsum("td,hde->hte", x, model["W_V"][l])
        scores = q @ k.transpose(0, 2, 1) / np.sqrt(D_HEAD)        # (H, T, T)
        pattern = softmax(np.where(mask, scores, -np.inf))
        out = np.einsum("hts,hse,hed->htd", pattern, v, model["W_O"][l])   # вклад каждой головы
        if ablate is not None and ablate[0] == l:
            out[ablate[1]] = 0.0
        x = x + out.sum(axis=0)                                    # головы ДОПИСЫВАЮТ в поток
        cache["pattern"].append(pattern)
        cache["head_out"].append(out)
        cache["resid"].append(x)
    return x @ model["W_U"], cache


def make_repeated(period, seed=0):
    """Случайный кусок из period разных токенов, повторённый дважды: A B C … A B C …"""
    rng = np.random.default_rng(seed)
    half = rng.choice(V, size=period, replace=False)
    return np.concatenate([half, half])


def load_model(seed=2055):
    """Веса заданы заранее. Не заглядывайте сюда, пока не найдёте головы по их поведению!"""
    rng = np.random.default_rng(seed)
    TOK, POS, PREV, OUT, JUNK = 0, 12, 36, 48, slice(60, 72)       # подпространства потока
    eps = 0.02
    W_E, W_pos = eps * rng.normal(size=(V, D_MODEL)), eps * rng.normal(size=(T_MAX, D_MODEL))
    W_U = eps * rng.normal(size=(D_MODEL, V))
    shp = (N_LAYERS, N_HEADS)
    W_Q, W_K = eps * rng.normal(size=shp + (D_MODEL, D_HEAD)), eps * rng.normal(size=shp + (D_MODEL, D_HEAD))
    W_V, W_O = eps * rng.normal(size=shp + (D_MODEL, D_HEAD)), eps * rng.normal(size=shp + (D_HEAD, D_MODEL))
    junk = rng.normal(size=(D_HEAD, 12)) / 8
    for v in range(V):
        W_E[v, TOK + v] += 1.0
        W_U[OUT + v, v] += 6.0
    for t in range(T_MAX):
        W_pos[t, POS + t] += 1.0
        W_Q[0, 0, POS + t, t] += 4.0                               # слой 0, голова A: ищет позицию t-1
        if t + 1 < D_HEAD:
            W_K[0, 0, POS + t, t + 1] += 10.0
        W_Q[0, 1, POS + t, t] += 4.0                               # слой 0, голова B: смотрит на себя
        W_K[0, 1, POS + t, t] += 10.0
        W_Q[0, 2, POS + t, 0] += 4.0                               # слой 0, голова C: смотрит на начало
    W_K[0, 2, POS, 0] += 10.0
    for v in range(V):
        W_V[0, :, TOK + v, v] += 1.0
        W_O[0, 0, v, PREV + v] += 1.0                              # пишет «кто был до меня»
        W_Q[1, 0, TOK + v, v] += 4.0                               # слой 1, голова A: «мой токен» ↔
        W_K[1, 0, PREV + v, v] += 10.0                             # «кто был до тебя»
        W_V[1, 0, TOK + v, v] += 1.0
        W_O[1, 0, v, OUT + v] += 1.0                               # копирует токен в выход
        W_Q[1, 1, TOK + v, v] += 4.0                               # слой 1, голова B: такой же токен
        W_K[1, 1, TOK + v, v] += 10.0
        W_V[1, 1, TOK + v, v] += 1.0
    W_K[1, 0, POS, :V] -= 10.0                                     # 1A не смотрит на позицию 0
    W_O[0, 1, :, JUNK] += junk
    W_O[0, 2, :, JUNK] += junk[::-1]
    W_O[1, 1, :, JUNK] += junk
    for l in range(N_LAYERS):                                      # перемешиваем головы: A, B, C → номера 0–2
        p = rng.permutation(N_HEADS)
        W_Q[l], W_K[l], W_V[l], W_O[l] = W_Q[l][p], W_K[l][p], W_V[l][p], W_O[l][p]
    return {"W_E": W_E, "W_pos": W_pos, "W_Q": W_Q, "W_K": W_K, "W_V": W_V, "W_O": W_O, "W_U": W_U}


# ── Проверьте себя кнопкой «Запустить» ─────────────────────────────────────────
model = load_model()
P = 8
runs = [run(model, make_repeated(P, s)) for s in range(20)]
if prev_token_score(runs[0][1]["pattern"][0]) is not None:
    for l in range(N_LAYERS):
        prev = np.mean([prev_token_score(c["pattern"][l]) for _, c in runs], axis=0)
        print(f"слой {l}: счёт «предыдущий токен» {prev.round(2)}")
if induction_score(runs[0][1]["pattern"][1], P) is not None:
    for l in range(N_LAYERS):
        ind = np.mean([induction_score(c["pattern"][l], P) for _, c in runs], axis=0)
        print(f"слой {l}: индукционный счёт        {ind.round(2)}")
tokens = make_repeated(P, 0)
if loss_on_repeats(runs[0][0], tokens, P) is not None:
    print("loss на втором куске:", round(loss_on_repeats(runs[0][0], tokens, P), 3), " ln V =", round(np.log(V), 3))
lens = logit_lens_losses(model, tokens, P)
if lens is not None:
    print("logit lens по глубине:", [round(x, 2) for x in lens])
eff = ablation_effects(model, tokens, P)
if eff is not None:
    for (l, h), d in sorted(eff.items()):
        print(f"выключили голову {l}.{h}: loss {d:+.2f}")

Посмотрите на вывод абляции. Головы с большим эффектом — ровно те две, что вы нашли по паттернам. Обратите внимание на голову предыдущего токена: в выход она не пишет ничего, а без неё loss на повторе вырастает больше чем на 2 ната.

Скрипт работает на CPU за полминуты. Положите его рядом с model.py, gpt_ru.pt и input.txt из урока 4.6.2. Хуки (register_forward_hook) — стандартный механизм PyTorch: функция, которую модуль вызывает после своего forward, передавая ей вход и выход. Так можно подсмотреть любую промежуточную активацию, не меняя код модели.

"""look_inside.py — logit lens и индукционный тест для GPT из урока 4.6.2. Запуск: python look_inside.py"""
import torch
import torch.nn.functional as F
from model import GPT
torch.manual_seed(0)
ckpt = torch.load("gpt_ru.pt", weights_only=False) # чекпоинт из урока 4.6.2
model = GPT(ckpt["cfg"])
model.load_state_dict(ckpt["model"])
model.eval()
cfg = model.cfg
stoi = {ch: i for i, ch in enumerate(ckpt["chars"])}
text = open("input.txt", encoding="utf-8").read()
data = torch.tensor([stoi[c] for c in text if c in stoi]) # только символы из словаря модели
val = data[int(0.9 * len(data)):] # валидационная часть, как при обучении
# ── 1. Logit lens: применяем ln_f и lm_head к потоку после каждого блока ──
starts = torch.arange(64) * cfg.block_size # 64 непересекающихся окна
x = torch.stack([val[s:s + cfg.block_size] for s in starts])
y = torch.stack([val[s + 1:s + 1 + cfg.block_size] for s in starts])
resid = {} # номер блока -> его выход (B, T, C)
hooks = [blk.register_forward_hook(lambda mod, inp, out, i=i: resid.__setitem__(i, out))
for i, blk in enumerate(model.blocks)]
with torch.no_grad():
model(x)
resid[-1] = model.wte(x) + model.wpe(torch.arange(cfg.block_size)) # «после 0 блоков»
for h in hooks:
h.remove()
for i in range(-1, cfg.n_layer):
logits = model.lm_head(model.ln_f(resid[i])) # та же голова, что в конце модели
loss = F.cross_entropy(logits.flatten(0, 1), y.flatten())
print(f"{'эмбеддинги' if i < 0 else f'после блока {i}':>14}: loss {loss.item():.2f}")
# ── 2. Индукционный тест: фрагмент текста, повторённый дважды ──
L = 60
idx = torch.randint(len(val) - L, (64,))
seq = torch.stack([val[s:s + L] for s in idx])
rep = torch.cat([seq, seq], dim=1) # A B C … A B C … (длина 120 ≤ 128)
qk = {} # выход c_attn = [Q | K | V] каждого блока
hooks = [blk.attn.c_attn.register_forward_hook(lambda mod, inp, out, i=i: qk.__setitem__(i, out))
for i, blk in enumerate(model.blocks)]
with torch.no_grad():
logits, _ = model(rep)
for h in hooks:
h.remove()
nll = F.cross_entropy(logits[:, :-1].transpose(1, 2), rep[:, 1:], reduction="none") # (64, 119)
print(f"\nloss на 1-м повторе {nll[:, :L - 1].mean():.2f}, на 2-м {nll[:, L:].mean():.2f}")
# ── 3. Индукционный счёт голов: внимание с позиции t на t - L + 1 ──
T, H, d = rep.shape[1], cfg.n_head, cfg.n_embd // cfg.n_head
mask = torch.tril(torch.ones(T, T, dtype=torch.bool))
for i in range(cfg.n_layer):
q, k, _ = qk[i].split(cfg.n_embd, dim=2) # как в CausalSelfAttention.forward
q = q.view(-1, T, H, d).transpose(1, 2) # (B, H, T, d)
k = k.view(-1, T, H, d).transpose(1, 2)
att = (q @ k.transpose(-2, -1) / d ** 0.5).masked_fill(~mask, float("-inf")).softmax(-1)
stripe = att.diagonal(offset=1 - L, dim1=-2, dim2=-1)[..., 1:] # запросы t = L … 2L-1
print(f"блок {i}: индукционный счёт голов {stripe.mean(dim=(0, 2)).numpy().round(2)}")
эмбеддинги: loss 12.82
после блока 0: loss 3.24
после блока 1: loss 2.94
после блока 2: loss 2.47
после блока 3: loss 1.64
loss на 1-м повторе 1.73, на 2-м 1.82
блок 0: индукционный счёт голов [0. 0. 0. 0.]
блок 1: индукционный счёт голов [0. 0. 0. 0.]
блок 2: индукционный счёт голов [0. 0. 0. 0.]
блок 3: индукционный счёт голов [0. 0. 0. 0.]

Это вывод для нашей модели (val loss 1.71 после 2000 шагов). У вас числа будут немного другими, но картина та же. Две детали кода. Внимание в model.py считает F.scaled_dot_product_attention, а он не возвращает веса. Поэтому мы перехватываем выход c_attn, то есть QQ, KK и VV, и пересчитываем паттерн сами по формуле из урока 4.4.2. А diagonal(offset=1 - L) вынимает из матрицы T×TT \times T ровно элементы [t, t−L+1][t,\ t - L + 1] — ту самую индукционную полосу.

Проверь себя6 вопросов
1 Почему предсказание трансформера можно разложить на вклады отдельных голов и MLP?
2 Последовательность — два одинаковых куска по L=10L = 10 токенов. На какую позицию смотрит индукционная голова с позиции i=15i = 15?
3 Вы выключили голову предыдущего токена в слое 0, и индукция в слое 1 сломалась, хотя прямой вклад этой головы в логиты (DLA) равен нулю. Что это значит?
4 В activation patching mclean=4m_\text{clean} = 4, mcorrupted=−2m_\text{corrupted} = -2, после подмены одной точки m=2.5m = 2.5. Какую долю эффекта восстановила подмена? (до двух знаков)
5 Какие утверждения о линейных зондах верны?Можно выбрать несколько ответов
6 Logit lens на модели из 4.6.2 дал по потоку сразу после эмбеддингов loss 12.8, хотя случайное угадывание даёт ln⁡137≈4.9\ln 137 \approx 4.9. Почему так плохо?
  • Остаточный поток — сумма вкладов эмбеддинга, голов и MLP, поэтому предсказание раскладывается на слагаемые (DLA), а промежуточные слои можно «прочитать» через финальную LM-голову (logit lens).
  • Индукционная схема [A][B]…[A]→[B][A][B] \dots [A] \to [B] — две головы в разных слоях: голова предыдущего токена оставляет метку, индукционная ищет по ней и копирует. Её находят по полосе со сдвигом L−1L - 1 на повторённых случайных токенах. Появляется она скачком, а у маленькой модели из 4.6.2 её ещё нет.
  • Паттерны, DLA и зонды — наблюдения. Доказательство роли даёт вмешательство: абляция (лучше средним, а не нулём) и activation patching с долей восстановления rr.
  • Инструменты: TransformerLens (с версии 4.0 — TransformerBridge) и nnsight. Практикум следующего уровня — ARENA, глава 1.

Дальше: урок 9.3.2 «Суперпозиция и разреженные автоэнкодеры». Почему отдельный нейрон часто отвечает сразу за несколько несвязанных понятий и как Anthropic научилась извлекать из модели миллионы понятных признаков.