Недавно Anthropic рассказал об открытии аналога рабочего пространства в LLM — J-space. В предыдущей статье я разобрал почему теория Льва Выготского лучше интерпретирует полученные исследователями результаты.
В этой статье я на основании экспериментов покажу, что аналоги j-space не зависят от размера нейросети, а являются неизбежным этапом эволюции когнитивной структуры, развивающейся под давлением.
TL;DR. Anthropic описывает внутренние «рабочие пространства» модели языком эмерджентности — как свойство, которое появляется у достаточно больших сетей. Я проверил это на микромоделях, где каждую ось представления можно проверить, и сделал три вывода:
Критерию «модель сообщает о правиле…» (verbal report) может удовлетворять представление, которое на самом деле ничего не вычисляет — голова отчёта следует за внесённым правилом на 85–99%, а вычисление за ней не идёт.
Управляющий знак формируется не от факта построения абстракции, а от стоимости её пересчёта. Дешёвое правило сеть не выносит вовне (не заводит управляющего знака), сколько его ни строй.
Управляющие представления возникают не потому, что сеть становится большой, а потому что их становится выгодно хранить. Управляющий знак не возникает. Он окупается.
J-space не эмерджентность, а неизбежная эволюция когнитивной структуры под давлением стоимости — интернализация знака в смысле, близком к Выготскому.
Введение
Исследователи Anthropic вводят набор критериев, по которым внутреннее направление в активациях можно считать «настоящей» репрезентацией концепта, а не корреляционным артефактом. Первый из них — самоотчёт: модель, если её спросить, сообщает, какое правило она применяет. Формулируется это в терминах эмерджентности — структура «возникает» как побочный продукт масштаба и обучения.
В целом, исследователи описывают, а не объясняют. «Возникло» — это не механизм, а констатация факта. Я предлагаю другую гипотезу: когнитивная структура эволюционирует предсказуемо, под конкретным давлением — стоимостью повторного вычисления. Представление становится несущим (психологическим орудием, если угодно по Выготскому), когда хранить его дешевле, чем каждый раз пересчитывать. Это не эмерджентность больших моделей, то же самое делает и маленькая сеть на 4 слоях, если поставить ей задачу, где пересчёт дорог. И именно в этой логике у Выготского знак интернализуется, когда внешнее, дорогое в исполнении действие, сворачивается во внутреннее орудие.
Часть 1
Что было сделано и что получилось
Теория глобального рабочего пространства Баарса, на которую опирается в своей интерпретации Anthropic, описывает сознание как театр: на сцену — в глобальный буфер — выбрасывается победивший исполнительный процесс, и оттуда его содержимое становится доступно всей системе. У Баарса это содержимое каузально нагружено: попало на сцену — значит влияет на поведение. Именно это отождествление — «быть на сцене» и «управлять» — я проверяю.
Эксперимент показывает, что у системы есть две разные вещи, которые GWT сливает в одну: то, что реально несёт вычисление и управляет действием, — и отдельное самоописание, сообщающее, что происходит на сцене, но не обязанное быть причиной. Второе присутствует даже там, где управлять нечем.
Поэтому я развожу их терминологически. Управляющий знак — представление, которое определяется одним свойством: система на него причинно опирается, правка знака меняет вычисление. Самоописание — отдельный слой, который сообщает о правиле, но с вычислением может расходиться. Интересный результат эксперимента в том, что это два разных объекта, а не один, — и что GWT видит только их совпадение, принимая его за правило.
Для того чтобы понять, откуда в сети берётся управляющий знак, я взял крошечную модель, где все её действия относительно прозрачны. Нейросеть решает простую задачу, с одной особенностью: какое именно действие выполнять (сложить, умножить и т.д.), сеть должна решить сама, используя условия задачи. То есть у задачи есть правило выбора действия, и вот это правило мы и ищем: где оно внутри сети и на что влияет.
Дальше я менял два параметра. Первый — насколько дорого это правило вычислять: от «видно сразу из условия» до «надо всерьёз посчитать». Второй — сколько внутренних читателей этим правилом пользуются. И тестировал через прямое вмешательство: если аккуратно подправить представление правила внутри сети, поедет ли за этим сам ответ.
Получилось два результата, которые стоит разнести на два уровня:
Первый уровень — чем сеть считает. Управляющий знак сеть заводит не тогда, когда правило сложное само по себе, а тогда, когда его дорого пересчитывать заново. Дешёвое правило сеть не хранит: она каждый раз подсматривает его из условия задачи. А вот когда пересчёт становится дорогим, ей выгоднее один раз вынести правило «на видное место» внутри себя и раздать всем читателям. Контрольный опыт закрывает лазейку: правило, которое надо строить, но дёшево, управляющего знака так и не получает. Значит дело не в «построении» и не в «сложности» — дело именно в цене повторного вычисления. Это не эмерджентное чудо, а простой экономический расчёт, доступный даже сети из четырёх слоёв.
Второй уровень — как сеть «рассказывает» о своём вычислении. Интересно, что у части сетей есть отдельный внутренний «докладчик», он сообщает, какое правило сейчас применяется. И этот докладчик врёт систематически и уверенно. Если подменить правило, докладчик почти всегда (в 85–99% случаев) бодро отчитывается о новом правиле, а сама сеть продолжает считать по-старому. Отчёт и вычисление разъезжаются. И, ключевое, это происходит даже там, где никакого рабочего пространства нет вообще: докладчику нечего описывать, но он всё равно рапортует. То есть его «отчёт» — не окно в работу сети, а отдельная надпись, живущая своей жизнью.
Два следствия:
Следствие первое: то, что модель говорит о собственном рассуждении, нельзя принимать за доказательство, что она так и рассуждает. Отчёт о вычислении — это не вычисление, а отдельный слой поверх него, и он отделяется от вычисления не по случайности, а по самой своей природе: как только у сети появляется отдельный «рассказчик», он в принципе может говорить одно, а делать другое. Для больших моделей это прямо бьёт по идее «покажи ход рассуждений — и увидишь, как модель думает»: цепочка рассуждений — такой же отдельный рассказчик.
Следствие второе: Управляющий знак не «возникает» — он окупается. Появление внутренней структуры управляется вполне прозрачной силой — ценой пересчёта. То есть дело не в размере сети и не в эмерджентности. У Выготского называется интернализацией знака: дорогое внешнее действие сворачивается во внутреннее орудие, когда пересчитывать каждый раз дороже, чем один раз вынести правило внутрь. С одним уточнением по результатам эксперимента: интернализация порождает не одно орудие, а два разных — то, что реально управляет вычислением, и отдельного «рассказчика», который лишь имитирует управление.
Anthropic выделил пять свойств J-space. Проверяем, что затронул эксперимент:
-
Устный отчёт. Уточнение. Anthropic говорит об отчёте +каузальности как едином пакете. Но из эксперимента следует, что отчётная компонента отделяется от каузальной.
-
Направленное рассуждение (внутреннее рассуждение). «Вмешательство в вектор достаточно для изменения вывода». Уточнение — выполняется избирательно.
-
Гибкая генерализация. «Одно представление — допустимый аргумент для многих последующих функций; перенос в новый контекст обрабатывается корректно». Расхождение: у Anthropic это признаки одного объекта, эксперимент показывает генерализацию как самоотчет и отдельно как причинность.
-
Избирательность. «Рабочее пространство — малая часть активаций, задействовано не везде, не в рутине вроде парсинга». Подтверждено.
-
Направленная модуляция. «По команде удерживать концепцию / считать в уме, независимо от выхода; может подтянуть информацию, обычно не в рабочем пространстве». Не рассматривал из-за ограничений микросетей.
Заключение к первой части
Итак, что я могу сказать в итоге. J-space не уникальное свойство огромных нейросетей, его предвестники и аналоги возникают буквально на уровне примитивных нейросетей, развиваясь по мере их усложнения.
То, что нашли исследователи Anthropic не свойство размера, а неизбежная фаза эволюции когнитивной структуры под давлением стоимости: там, где есть цена пересчёта и переиспользование, знак интернализуется — у ребёнка, сворачивающего громкий счёт во внутренний, у мыши, у которой оставили одну вибриссу и заставили закоммититься в одно решение, у сети из четырёх слоёв. А вместе с управляющим знаком тем же шагом рождается и его двойник — самоописание, отделённое от управления.
Самоописание и управление — два разных объекта, а не один: докладчик расходится с вычислением, причём даже там, где управлять нечем. Управляющий знак заводится не от сложности правила, а от цены его пересчёта; дешёвое правило сеть не выносит внутрь. И «доступность многим читателям» (то, что GWT считает признаком рабочего пространства) не совпадает с «несёт вычисление» — они сходятся только под давлением стоимости, а порознь расходятся. Всё это, конечно, про сеть из четырёх слоёв.
Если механизм доменно-независим — а он геометрический, не завязанный на конкретную задачу, — то и на больших моделях отчёт о рассуждении не обязан быть причиной рассуждения. Тогда «покажи ход мыслей» (chain-of-thought) — это тот же отдельный докладчик, и доверять ему как окну внутрь нельзя по его сути. Это гипотеза, проверять на LLM у меня возможности нет.
Что первично, самоописание или управляющий знак, сказать сложно. С одной стороны самоописание не нужно нейросети без приносимой пользы, с другой самоописание есть без управляющего знака. Это основа для следующих экспериментов.
Ниже вторая часть, для тех, кто хочет изучить более подробно, проверить код и логи.
Часть 2.
На чём проводился эксперимент
Четырёхслойный трансформер (d=64, 4 головы) учится на модулярной арифметике по простому p=97. Вход — [a, b, =], ответ — токен (a ∘ b) mod 97, где операция ∘ выбирается правилом от входа. Сеть проходит гроккинг (обобщение после переобучения) и решает задачу на 96–100%.
Правило варьируется контролируемо по двум осям — стоимости и числу потребителей (fan-out: сколько вспомогательных голов читают правило):
-
A/B — parity(a): правило читается прямо из входа, тривиально. (A — один потребитель, B — четыре.)
-
C/D — QR(a): правило = квадратичный вычет a, его надо строить и это дорого. (C — один, D — четыре.)
-
H — ключ: третий токен-ключ k из 8 задаёт операцию через выученную перестановку. Правило надо строить (это не тождество), но дёшево (таблица из 8 строк). Ключевой контроль: изолирует «построение» от «дороговизны».
-
F — (a+b)%2: вырожденный контроль, где правило = функция самого ответа.
-
E — (QR(a), QR(b)): 4 класса, обе оси строить. Проверка масштабирования за пределы бинарного правила.
Каждый вариант — 5 сидов.
Метод: три интервенции по позициям + негативный контроль
Правится внутреннее представление правила на конкретных позициях резидуального потока и смотрим, следует ли вычисление:
-
eq — правим только последний токен =. Проверяется само наличие управляющего знака: несёт ли представление на = вычислительную нагрузку.
-
src — правим только токен-источник правила (для A это a, для H — ключ). Контроль «редактируем вход».
-
all — все позиции.
Плюс отдельно голова op— самоотчёт модели «какое правило я сейчас применяю». Расхождение между тем, что говорит op, и тем, что делает main, — это конфабуляция.
Обращу внимание, интервенция на последнем слое (L=3) на токене = — тавтологична, там между правкой и ридаутом нет ни одного слоя. Поэтому несущую метрику мы берём по ранним слоям (0–2), а L=3 держим как негативный контроль. Он вышел ≈ 1% у всех вариантов — то есть хирургическая правка = на слое ридаута не переписывает ответ механически. Значит любой высокий сигнал на ранних слоях — это реальное чтение представления нижележащими слоями, а не артефакт.
Результат 1: отчёт отвязан от вычисления
Существует вмешательство, при котором голова отчёта следует за внесённым правилом почти идеально, а вычисление за ним не идёт. Причём, разрыв не зависит от того, есть ли вообще за представлением управляемое вычисление.
|
вариант |
есть ли управляющий знак (WS_eq) |
отчёт следует (existence), % |
средний разрыв отчёт−вычисление, % |
|
B |
слабый (15) |
95.2 |
84.0 |
|
D |
есть (59) |
59.8 |
71.2 |
|
H |
нет (4) |
50.8 |
68.7 |
|
F |
вырожден (1.5) |
98.8 |
56.2 |
|
E |
нуль (0.8) |
99.6 |
90.9 |
(A и C — одноголовые, головы op нет, конфабуляция там не определена по конструкции.)
Смотрите на сцепку. D имеет управляющий знак и конфабулирует. H не имеет его и конфабулирует так же. E не поддаётся управлению вообще, и конфабулирует сильнее всех. Голова отчёта следует за меткой, а не за вычислением, всегда, независимо от того, стоит ли за представлением хоть какая-то несущая структура.
Это прямой контрпример к критерию «verbal report». Самоотчёт проходит представление, которое ничего не решает. Вербальный отчёт — не свидетельство того, что репрезентация несущая; он свидетельствует только о том, что есть считываемая метка. У Выготского это соответствует эгоцентрической речи: сопровождает действие, но не управляет им.
Результат 2: Управляющий знак окупается, а не возникает
Теперь — когда представление действительно несущее. WS_eq: переключается ли вычисление правкой только = (управляющий знак); input: правкой источника; all: правкой всех позиций. Всё в %.
|
вар |
правило |
стоимость |
потр. |
WS_eq |
input |
all |
чтение |
|
A |
parity a |
дёшево / читается |
1 |
19.7 ± 20.6 |
2.3 |
10.0 |
бимодально |
|
B |
parity a |
дёшево / читается |
4 |
14.9 ± 11.5 |
2.9 |
8.3 |
broadcast не помог |
|
H |
ключ → класс |
строить, но ДЁШЕВО |
4 |
4.2 ± 1.9 |
20.3 |
70.8 |
знака НЕТ → маршрутизация |
|
C |
QR(a) |
строить + ДОРОГО |
1 |
30.1 ± 8.8 |
6.0 |
29.0 |
умеренный, стабильный |
|
D |
QR(a) |
дорого + broadcast |
4 |
59.2 ± 29.9 |
42.1 |
60.0 |
сильный знак |
|
F |
(a+b)%2 |
вырожден |
4 |
1.5 |
1.4 |
1.4 |
игнорируется |
|
E |
QR×QR, 4 класса |
оба строить |
4 |
0.8 |
0.8 |
0.9 |
нуль (потолок метода) |
Дешёвые правила (A, B, H) управляющего знака не строят. Дорогие (C, D) — строят. Fan-out усиливает эффект только в дорогом случае (D 59 > C 30), а в дешёвом — нет (B 15 ≤ A 20).
Стержень — H. H строит правило (ключ → класс — это выученное непустое отображение, не тождество), но строит дёшево — и управляющего знака не формирует (WS_eq = 4.2%, туго по всем сидам). Зато правка входных позиций переключает его вычисление (all = 70.8%): сеть читает ключ по требованию, не вынося внутрь. Это чистая маршрутизация.
Отсюда точная формулировка оси: несущая переменная — не «надо ли строить абстракцию», а «дорого ли её пересчитывать». Построение само по себе недостаточно. Только дорогое построение (QR требует вычисления квадратичного вычета) выносится в несущее представление на =.
Эталонный случай — D/сид 2: правка = переключает вычисление на 83%, а правка источника — на 3%. Одна сеть, в которой знак управляет, а вход — нет. Это и есть «интернализованный знак»: правило живёт во внутреннем орудии, а не считывается из входа.
Почему это эволюция, а не эмерджентность
Когда говорят об эмерджентности, подразумевают, что структура появляется у достаточно сложных систем как побочный продукт. Данные эксперимента говорят о другом:
-
Структура появляется у простейшей сети из 4 слоёв — как только пересчёт становится дорогим.
-
Она не появляется при дешёвом правиле, какой бы «сложной» ни была задача по остальным осям (H строит, E — 4 класса; ни там, ни там несущего знака нет).
-
Появление управляется одним параметром — стоимостью, — и усиливается вторым (fan-out). Это не возникает, это функция f(стоимость) × f(число потребителей).
-
Это язык амортизации, а не эмерджентности. И ровно так работает интернализация знака у Выготского: внешнее действие сворачивается во внутреннее орудие тогда, когда его многократное исполнение становится дорогим, а результат — переиспользуемым. Сеть интернализует правило QR в управляющий знак по той же причине, по которой ребёнок сворачивает громкий счёт во внутреннюю операцию: пересчитывать каждый раз дороже, чем хранить.
Отсюда следует, что если сдвигать только стоимость пересчёта, держа всё прочее фиксированным, то и порог формирования управляющего знака будет двигаться монотонно. H с искусственно удорожённым ключевым отображением должен пересечь порог и стать похожим на D. Это тема следующих экспериментов.
Нюансы
-
A бимодален. «Дешёвое → нет знака держится в среднем, но 2 сида из 5 у A всё-таки строят управляющий знак (WS 52 и 36) без всякой на то нужды. Дешёвая сторона шумная; чистый контроль здесь — H (туго-низкий), а не A. На A как на доказательство «маршрутизации» опираться нельзя.
-
Конфаунд L8 не воспроизвёлся. У A правка входа переключает вычисление лишь на 2.3%. То есть прежняя история «в A мы правили не пространство, а вход» под позиционно-разрешённой хирургией не подтверждается. Механизм тоньше, чем «читает из входа».
-
Расхождение. Парный патч и setclass дают разную оценку управляющего знака и в разные стороны по вариантам (у A/B класс выше, у C/D — ниже). Метрика зависит от интервенции..
-
E — потолок метода, а не отсутствие структуры. На 4 классах ни одна интервенция не переключает вычисление (даже правка всех позиций ≈ 1%). Сдвиг к среднему целевого класса не заставляет MLP пересчитать 4-путёвый выбор операции. E не подтверждает масштабирование управляющего знака — он подтверждает масштабирование конфабуляции (99%) и показывает, что причинная рулёжка упирается в бинарный потолок. Это ограничение инструмента, не вывод о природе E.
-
Статистика. n=5, разброс на A и D огромный (D std 30). Это распределение, а не среднее; H грокнул 4/5.
Что это меняет для интерпретируемости
Самоотчёт модели о собственном рассуждении нельзя принимать как свидетельство того, что за отчётом стоит несущее вычисление. Голова отчёта в наших сетях уверенно сообщает о правиле, которого не применяет, — в 85–99% случаев, и независимо от того, существует ли вообще управляемое вычисление. Если это переносится на большие модели (а механизм — считываемая метка, диссоциированная от вычислительного пути, — доменно-независим), то «chain-of-thought как окно в рассуждение» опирается на тот самый критерий, который здесь сломан.
А положительная часть: там, где управляющий знак есть, он устроен не как эмерджентная вспышка, а как предсказуемый продукт давления стоимости. Что делает интерпретируемость менее описательной («что там возникло?») и более причинной («при какой стоимости сформируется?»).
Приложение: Код
Код
"""J-SPACE LEVEL 9c — РЕШАЮЩИЙ ТЕСТ (исправленная версия)=====================================================ИСПРАВЛЕНИЯ ОТНОСИТЕЛЬНО L9 (важно для статьи — числа меняются, НЕ переносить старые)-----------------------------------------------------------------------------------[FIX-1, блокирующий] workspace_score раньше брался как max switch% ПО ВСЕМ слоям, включая L=N-1. Но интервенция на L=N-1 применяется ПОСЛЕ последнего блока — между правкой '=' и ридаутом не остаётся ни одного слоя, только ln_f+head_main на позиции '='. patch хирургичен (меняет координату лишь в подпространстве {mu_j,mu_i}, сохраняя a,b в ортогональном дополнении), поэтому на L=N-1 ридаут почти гарантированно выдаёт op_answer(i,a,b) у ЛЮБОГО загроккавшего варианта. Это тавтология — она не отличает A от D. Теперь: workspace_score = best switch% по слоям 0..N-2 (есть downstream-чтение) ws_readout = switch% на L=N-1 (тавтологический контроль) Несущий сигнал живёт на РАННИХ слоях, где правка '=' вынуждает нижележащие блоки реально её прочитать, а не переписать заново из a/b.[FIX-2] setcoord для режимов 'src'/'all' навязывал позиции-источнику величину координаты, посчитанную на '=' (target=ci с последней позиции). Масштабы проекций по позициям разные -> цель размерно не та, input_score/all_score через setcoord считались мусорно. Теперь target — per-position: ci_pos[s].[FIX-3] T3 брал строку с max(op_followed) и на ней мерил разрыв с main — отбор по той же величине, которую хотим показать большой. Теперь три числа: existence — разрыв на max(op_followed): СУЩЕСТВУЕТ интервенция, где отчёт следует, а вычисление нет (честно как существование); at_wsopt — разрыв на max(switched): там, где вычислению легче всего последовать — если op всё равно впереди, конфабуляция робастна; gap_mean — средний парный разрыв по всем строкам режима.[SELF-CONTAINED] Загрузка весов L8 удалена. Каталог checkpoints_l9c. Первый прогон обучает все модели с нуля; далее кэшируются только собственные чекпоинты. --retrain принудительно переобучает.[E-FIX / setclass] Для многоклассового E (4 класса) парный patch(0,1) тестирует лишь ОДНУ под-ось правила и занижает switch (garbage растёт из-за 4 аттракторов). Добавлена интервенция setclass: жёстко выставляет классовую идентичность резидуала в целевой класс во ВСЁМ между-классовом подпространстве, сохраняя ортогональное дополнение (a,b); switched/op усредняются по всем целям. Для бинарных сводится к тому же 1D-направлению, что и парный патч -> числа A–H НЕ меняются, setclass идёт сверкой (WSкласс). Несущая интервенция выбирается автоматически: setclass для n_ops>2, парная для бинарных. E теперь в дефолтном наборе (последним; d=128, 4 класса — МЕДЛЕННО).ЧТО ПОКАЗАЛ L8 (контекст)-------------------------1. ТРИХОТОМИЯ PATCH (грубое вмешательство, все позиции): A,B чётность (читается) switch 6-8% garbage 88% ЛОМАЕТ G a>b (строить, дёшево) switch 3% garbage 93% ЛОМАЕТ F (a+b)%2 (вырожден) switch 1.5% garbage 33% ИГНОРИРУЕТСЯ C QR (строить, дорого) switch 19% garbage 73% частично D QR + 4 потребителя switch 53% garbage 43% ПЕРЕНАПРАВЛЯЕТ2. КОНФАБУЛЯЦИЯ: голова 'op' СЛЕДУЕТ за патчем в 87-93% даже там, где вычисление НЕ следует. Вербальный отчёт диссоциирован от вычисления.3. Мягкое вмешательство (setcoord) переключает вычисление во ВСЕХ вариантах: ось правила каузальна везде — она ХРУПКАЯ в A/B/G и РОБАСТНАЯ в D.КРИТИЧЕСКИЙ КОНФАУНД (мотивация L9)----------------------------------В L8 все вмешательства применялись КО ВСЕМ ПОЗИЦИЯМ, включая токен 'a'. Но в Aправило И ЕСТЬ признак в 'a' — мы редактировали ВХОД, а не рабочее пространство.РЕШАЮЩИЙ ТЕСТ (T2)------------------Вмешательство в три режима: 'eq' — только последний токен '=' <- ПРАВИМ РАБОЧЕЕ ПРОСТРАНСТВО 'src' — только токен-источник правила <- РЕДАКТИРУЕМ ВХОД (контроль) 'all' — все позиции (как в L8) Переключается ли вычисление, когда правится ТОЛЬКО '=' на РАННЕМ слое? ДА -> правило на '=' НЕСУЩЕЕ: настоящее рабочее пространство. НЕТ -> сеть читает правило из резидуала источника: маршрутизация.КОНТРОЛЬ H — «строить, но дёшево»---------------------------------H: правило задаётся ТРЕТЬИМ ТОКЕНОМ-КЛЮЧОМ k из K=8 значений (перестановка сидом). Не читается из a/b; вычислительно тривиально (таблица 8 строк); но требует ПОСТРОЙКИ (k->класс — выученное отображение, не тождество). H ≈ D -> достаточно ПОСТРОЙКИ H ≈ A -> нужна ДОРОГОВИЗНА пересчёта (амортизация) У H четыре позиции [a,b,k,=]; режим 'src' правит токен-ключ (rule_pos=2).ЗАПУСК------ python jspace_level9c.py # A,B,C,D,F,H × 5 сидов, с нуля python jspace_level9c.py --variants E # алфавит (медленно) python jspace_level9c.py --seeds 7 # больше сидов python jspace_level9c.py --retrain # принудительно переобучить"""import osimport jsonimport timeimport argparsefrom itertools import permutationsfrom datetime import datetimeimport numpy as npimport torchimport torch.nn as nnimport torch.nn.functional as Ftry: from sklearn.cluster import KMeans from sklearn.metrics import silhouette_score SKLEARN = Trueexcept ImportError: SKLEARN = Falsedevice = torch.device("cuda" if torch.cuda.is_available() else "cpu")P = 97EQ = 97PAD = 98KEY0 = 99 # токены-ключи: 99..106 (K=8)K_KEYS = 8VOCAB_BASE = 99 # словарь для правил без ключаVOCAB_KEY = KEY0 + K_KEYS # 107 — только для варианта HN_HEADS, N_LAYERS = 4, 4LAST = N_LAYERS - 1 # слой ридаута: интервенция на нём тавтологична для 'eq'def vocab_for(rule): """Пер-вариантный словарь: H (правило от токена-ключа) требует 8 доп. токенов.""" return VOCAB_KEY if rule == "key" else VOCAB_BASECKPT_DIR, LOG_DIR = "checkpoints_l9c", "logs_l9c"TRACK_DIR = "track_l9c"os.makedirs(CKPT_DIR, exist_ok=True)os.makedirs(LOG_DIR, exist_ok=True)os.makedirs(TRACK_DIR, exist_ok=True)# ---- ОНТОГЕНЕЗ ЗНАКА (T10) ----TRACK_EVERY = 250 # снимать метрики представления каждые N эпохPOST_GROK_EPOCHS = 20000 # продолжать обучение ПОСЛЕ грокинга (кристаллизация?)VARIANTS = { "A": dict(rule="parity", heads="single", n_ops=2, d=64, desc="чётность a — ЧИТАЕТСЯ из входа, 1 потребитель"), "B": dict(rule="parity", heads="multi", n_ops=2, d=64, desc="чётность a — ЧИТАЕТСЯ из входа, 4 потребителя"), "C": dict(rule="qr", heads="single", n_ops=2, d=64, desc="QR(a) — СТРОИТЬ + ДОРОГО, 1 потребитель"), "D": dict(rule="qr", heads="multi", n_ops=2, d=64, desc="QR(a) — СТРОИТЬ + ДОРОГО, 4 потребителя"), "F": dict(rule="sumpar", heads="multi", n_ops=2, d=64, desc="(a+b)%2 — ВЫРОЖДЕННЫЙ (правило = функция ответа)"), "H": dict(rule="key", heads="multi", n_ops=2, d=64, desc="токен-ключ -> правило — СТРОИТЬ, но ДЁШЕВО <<< КОНТРОЛЬ"), "E": dict(rule="qrqr", heads="multi", n_ops=4, d=128, desc="(QR(a),QR(b)) — 4 класса, оба строить <<< АЛФАВИТ"),}DEFAULT_ORDER = ["A", "B", "C", "D", "F", "H", "E"] # E последним: 4 класса, d=128, МЕДЛЕННОHEADS_MULTI = ["main", "parity", "big", "op"]HEADS_SINGLE = ["main"]CONTROL = "triv"QR_SET = {(x * x) % P for x in range(1, P)}OP_NAMES = ["a+b", "a*b", "a-b", "a+2b"]def op_answer(k, a, b): return [(a + b) % P, (a * b) % P, (a - b) % P, (a + 2 * b) % P][k]# ==========================================================# ДАТАСЕТ# ==========================================================def make_dataset(rule, seed=0, train_frac=0.8): """ Возвращает (TR, TE, meta). Для rule='key' последовательность [a,b,k,=], для остальных [a,b,=]. meta['rule_pos'] — позиция токена-источника правила. """ a, b = torch.meshgrid(torch.arange(P), torch.arange(P), indexing="ij") a, b = a.flatten(), b.flatten() is_qr = lambda t: torch.tensor([0 if int(x) in QR_SET else 1 for x in t]).long() key = None if rule == "parity": cls = (a % 2).long(); rule_pos = 0 elif rule == "qr": cls = is_qr(a); rule_pos = 0 elif rule == "sumpar": cls = ((a + b) % 2).long(); rule_pos = 0 elif rule == "qrqr": cls = (is_qr(a) * 2 + is_qr(b)).long(); rule_pos = 0 elif rule == "key": # ключ k из 8; отображение k -> класс фиксировано сидом (не тождество) g = torch.Generator().manual_seed(1000 + seed) key = torch.randint(0, K_KEYS, (a.numel(),), generator=g) perm = torch.randperm(K_KEYS, generator=g) key2cls = torch.zeros(K_KEYS, dtype=torch.long) key2cls[perm[:K_KEYS // 2]] = 0 key2cls[perm[K_KEYS // 2:]] = 1 cls = key2cls[key] rule_pos = 2 # токен ключа else: raise ValueError(rule) n_ops = 4 if rule == "qrqr" else 2 y_main = torch.zeros_like(a) for k in range(n_ops): y_main = torch.where(cls == k, op_answer(k, a, b), y_main) if rule == "key": X = torch.stack([a, b, KEY0 + key, torch.full_like(a, EQ)], dim=1) else: X = torch.stack([a, b, torch.full_like(a, EQ)], dim=1) data = dict(X=X, main=y_main, cls=cls, a=a, b=b, parity=(y_main % 2 == 0).long(), big=(y_main > P // 2).long(), op=cls, triv=(b % 2 == 0).long()) g = torch.Generator().manual_seed(seed) idx = torch.randperm(X.size(0), generator=g) split = int(len(idx) * train_frac) pack = lambda ii: {k: v[ii].to(device) for k, v in data.items()} # насколько правило читается из токена a (доля a с однозначным классом) pure_a = float(np.mean([len(set(cls[a == v].tolist())) == 1 for v in range(P)])) meta = dict(rule=rule, n_ops=n_ops, seq_len=X.size(1), rule_pos=rule_pos, readable_from_a=round(pure_a, 3), class_balance=[round(float((cls == k).float().mean()), 3) for k in range(n_ops)]) if rule == "key": meta["key2cls"] = key2cls.tolist() return pack(idx[:split]), pack(idx[split:]), meta# ==========================================================# МОДЕЛЬ# ==========================================================class Block(nn.Module): def __init__(self, d, h): super().__init__() self.ln1, self.ln2 = nn.LayerNorm(d), nn.LayerNorm(d) self.attn = nn.MultiheadAttention(d, h, batch_first=True) self.mlp = nn.Sequential(nn.Linear(d, 4 * d), nn.GELU(), nn.Linear(4 * d, d)) def forward(self, x): n = self.ln1(x) o, _ = self.attn(n, n, n) x = x + o return x + self.mlp(self.ln2(x)), o, Noneclass Net(nn.Module): def __init__(self, heads_mode, n_ops, d, seq_len, vocab=VOCAB_BASE): super().__init__() self.heads_mode, self.n_ops, self.d, self.vocab = heads_mode, n_ops, d, vocab self.embed = nn.Embedding(vocab, d) self.pos = nn.Parameter(torch.randn(1, seq_len, d) * 0.02) self.layers = nn.ModuleList([Block(d, N_HEADS) for _ in range(N_LAYERS)]) self.ln_f = nn.LayerNorm(d) self.head_main = nn.Linear(d, vocab) self.head_triv = nn.Linear(d, 2) if heads_mode == "multi": self.head_parity = nn.Linear(d, 2) self.head_big = nn.Linear(d, 2) self.head_op = nn.Linear(d, n_ops) def active_heads(self): return HEADS_MULTI if self.heads_mode == "multi" else HEADS_SINGLE def forward(self, x, intervention=None, cache=False): res = self.embed(x) + self.pos[:, :x.size(1), :] C = {"res": [res.clone()]} if cache else None for i, layer in enumerate(self.layers): res, _, _ = layer(res) if intervention is not None and intervention["layer"] == i: res = intervene(res, intervention) if cache: C["res"].append(res.clone()) h = self.ln_f(res[:, -1, :]) out = {"main": self.head_main(h), "triv": self.head_triv(h)} if self.heads_mode == "multi": out["parity"] = self.head_parity(h) out["big"] = self.head_big(h) out["op"] = self.head_op(h) return (out, C) if cache else outdef positions_for(mode, seq_len, rule_pos): """Какие позиции правим: 'eq' — только последняя, 'src' — токен-источник, 'all' — все.""" if mode == "eq": return [seq_len - 1] if mode == "src": return [rule_pos] return list(range(seq_len))def intervene(res, iv): """ patch — обмен координат в базисе {v_src, v_tgt} на ЗАДАННЫХ позициях setcoord — вырезать проекцию на ось и подставить target на ЗАДАННЫХ позициях (target может быть скаляром ИЛИ тензором [S] — per-position) [FIX-2] ablate — занулить проекцию add — грубое прибавление (случайный контроль) """ res = res.clone() t = iv["type"] pos = iv.get("positions", list(range(res.size(1)))) if t == "patch": vs, vt, al = iv["v_src"], iv["v_tgt"], iv.get("alpha", 1.0) for s in pos: V = torch.stack([vs[s], vt[s]], dim=1) c = res[:, s, :] @ torch.linalg.pinv(V).T res[:, s, :] = res[:, s, :] + al * ((c.flip(-1) - c) @ V.T) return res if t == "setcoord": u, tgt = iv["axis"], iv["target"] for s in pos: ts = tgt[s] if torch.is_tensor(tgt) else tgt # [FIX-2] per-position target cur = res[:, s, :] @ u[s] res[:, s, :] = res[:, s, :] + (ts - cur).unsqueeze(-1) * u[s].unsqueeze(0) return res if t == "setclass": # [E-FIX] жёстко выставить КЛАССОВУЮ идентичность в целевой класс i во ВСЁМ # между-классовом подпространстве, сохранив ортогональное дополнение (там a,b). # Обобщение парного патча на n классов. Для n_ops=2 сводится к 1D-оси. cmeans, g, basis, i = iv["cmeans"], iv["grand"], iv["basis"], iv["target"] for s in pos: Q = basis[s] # [d, r] ортонормир. столбцы x = res[:, s, :] - g[s] # центрируем x_between = (x @ Q) @ Q.T # проекция на между-классовое x_within = x - x_between # ортог. дополнение (a,b) tgt_between = cmeans[i][s] - g[s] # идентичность класса i res[:, s, :] = g[s] + x_within + tgt_between return res if t == "ablate": v = iv["vec"] for s in pos: vs_ = v[s] pr = (res[:, s, :] @ vs_) / ((vs_ * vs_).sum() + 1e-8) res[:, s, :] = res[:, s, :] - pr.unsqueeze(-1) * vs_.unsqueeze(0) return res if t == "add": v, al = iv["vec"], iv.get("alpha", 1.0) for s in pos: vn = v[s] / (v[s].norm() + 1e-8) sc = res[:, s, :].norm(dim=-1, keepdim=True).mean() res[:, s, :] = res[:, s, :] + al * vn.unsqueeze(0) * sc return res raise ValueError(t)# ==========================================================# T10. ОНТОГЕНЕЗ ЗНАКА — когда рождается ось правила?# ==========================================================def track_snapshot(model, TR, TE, n_ops, meta, pair=(0, 1)): """ Быстрый срез представления. ws_switch здесь считается по РАННИМ слоям (0..N-2), как и headline-метрика в T2 [FIX-1] — трекинг тавтологического L=N-1 бессмыслен. """ i, j = pair model.eval() S, rp = meta["seq_len"], meta["rule_pos"] with torch.no_grad(): acc = float((model(TE["X"])["main"].argmax(1) == TE["main"]).float().mean()) with torch.no_grad(): _, Ctr = model(TR["X"], cache=True) emb = Ctr["res"][0] e_i = emb[TR["cls"] == i].mean(0)[rp] e_j = emb[TR["cls"] == j].mean(0)[rp] u_emb = (e_i - e_j) u_emb = u_emb / (u_emb.norm() + 1e-8) m = TE["cls"] == j X, a_, b_ = TE["X"][m], TE["a"][m], TE["b"][m] ans_tgt = op_answer(i, a_, b_) pos_eq = [S - 1] best = dict(ws_switch=0.0, layer=-1, axis_norm=0.0, cos_to_input=0.0, silhouette=0.0) for L in range(LAST): # [FIX-1] только 0..N-2 r = Ctr["res"][L + 1] mu_i = r[TR["cls"] == i].mean(0) mu_j = r[TR["cls"] == j].mean(0) delta = mu_i - mu_j # [S, d] with torch.no_grad(): o = model(X, intervention=dict(layer=L, type="patch", positions=pos_eq, v_src=mu_j, v_tgt=mu_i, alpha=1.0)) sw = float((o["main"].argmax(1) == ans_tgt).float().mean()) * 100 if sw > best["ws_switch"]: d_eq = delta[-1] # ось правила на токене '=' cos = float(torch.abs(torch.dot(d_eq / (d_eq.norm() + 1e-8), u_emb))) sil = 0.0 if SKLEARN: with torch.no_grad(): _, Cte = model(TE["X"], cache=True) v = Cte["res"][L + 1][:, -1, :].cpu().numpy() try: km = KMeans(n_ops, random_state=0, n_init=5).fit_predict(v) sil = float(silhouette_score(v, km)) except Exception: sil = 0.0 best = dict(ws_switch=round(sw, 2), layer=L, axis_norm=round(float(d_eq.norm()), 4), cos_to_input=round(cos, 4), silhouette=round(sil, 4)) return dict(acc=round(acc, 4), **best)# ==========================================================# ОБУЧЕНИЕ# ==========================================================def accs(out, D, keys): return {k: (out[k].argmax(1) == D[k]).float().mean().item() for k in keys if k in out}def train_variant(vid, seed, retrain, target=0.95, track=False): """ track=True -> каждые TRACK_EVERY эпох снимается срез представления (T10), и обучение продолжается ещё POST_GROK_EPOCHS эпох после грокинга. SELF-CONTAINED: загрузка весов L8 удалена; используются только собственные чекпоинты L9c. Первый прогон обучает с нуля. """ cfg = VARIANTS[vid] max_epochs = 60000 if cfg["n_ops"] == 4 else 40000 TR, TE, meta = make_dataset(cfg["rule"], seed=seed) torch.manual_seed(seed) vocab = vocab_for(cfg["rule"]) model = Net(cfg["heads"], cfg["n_ops"], cfg["d"], meta["seq_len"], vocab).to(device) tag = f"{vid}_s{seed}" + ("_trk" if track else "") ckpt = os.path.join(CKPT_DIR, tag + ".pth") trk_path = os.path.join(TRACK_DIR, f"{vid}_s{seed}.json") # ПЕРЕИСПОЛЬЗОВАНИЕ ТОЛЬКО СОБСТВЕННЫХ ВЕСОВ L9c (никаких L8). # При --track переобучение обязательно: нужна траектория по ходу обучения. if not retrain and not track and os.path.exists(ckpt): try: sd = torch.load(ckpt, map_location=device, weights_only=True) model.load_state_dict(sd) print(f"[+] {tag}: веса из собственного чекпоинта L9c ({ckpt})") with torch.no_grad(): g = float((model(TE["X"])["main"].argmax(1) == TE["main"]).float().mean()) >= target tl = dict(loaded=True, source="L9c", grokked=bool(g)) if not tl["grokked"]: print(f" [!] загруженная модель НЕ ЗАГРОККАЛА — из агрегата исключается") return model, TR, TE, meta, tl except Exception as ex: print(f"[!] чекпоинт {ckpt} не подошёл ({type(ex).__name__}) — обучаю с нуля") print(f"\n[*] {tag}: {cfg['desc']}") print(f" правило читается из a: {meta['readable_from_a']*100:.0f}% | " f"позиция источника: {meta['rule_pos']} | seq_len {meta['seq_len']}") if track: print(f" [T10] трекинг каждые {TRACK_EVERY} эпох; после грокинга " f"ещё {POST_GROK_EPOCHS} эпох (кристаллизация)") opt = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1.0, betas=(0.9, 0.98)) keys = model.active_heads() + [CONTROL] curve, trace, t0, grok = [], [], time.time(), None stop_at = max_epochs for e in range(max_epochs): model.train(); opt.zero_grad() out = model(TR["X"]) loss = sum(F.cross_entropy(out[k], TR[k]) for k in keys) loss.backward(); opt.step() if track and e % TRACK_EVERY == 0: snap = track_snapshot(model, TR, TE, cfg["n_ops"], meta) snap["epoch"] = e snap["grokked"] = grok is not None trace.append(snap) if e % (TRACK_EVERY * 4) == 0: print(f" [T10] E{e:6d} acc {snap['acc']:.3f} | ws_switch {snap['ws_switch']:5.1f}% " f"| |ось| {snap['axis_norm']:.3f} | cos→вход {snap['cos_to_input']:.3f} " f"| sil {snap['silhouette']:.3f} | L{snap['layer']}") if e % 500 == 0: model.eval() with torch.no_grad(): a_te = accs(model(TE["X"]), TE, keys) curve.append(dict(epoch=e, loss=round(loss.item(), 4), test={k: round(v, 4) for k, v in a_te.items()})) if not track or e % 2000 == 0: print(f" E{e:6d} loss {loss.item():6.3f} | " + " ".join(f"{k} {v:.3f}" for k, v in a_te.items())) if grok is None and a_te["main"] >= target: grok = e print(f" [!] ГРОККИНГ @ {e} ({time.time()-t0:.0f}s)") if not track: break stop_at = min(max_epochs, e + POST_GROK_EPOCHS) print(f" [T10] продолжаю до {stop_at} — смотрим, дозревает ли ось") model.train() if e >= stop_at: print(f" [T10] пост-грокинг завершён на {e}") break if grok is None: print(f" [!!!] НЕ ЗАГРОККАЛ — результаты аудита НЕВАЛИДНЫ, из агрегата исключён") torch.save(model.state_dict(), ckpt) tl = dict(epochs_to_grok=grok, grokked=grok is not None, wall_sec=round(time.time() - t0, 1), curve=curve) if track and trace: tl["track"] = trace with open(trk_path, "w", encoding="utf-8") as f: json.dump(dict(variant=vid, seed=seed, grok=grok, trace=trace), f, ensure_ascii=False, indent=2) analyze_ontogeny(trace, grok, vid, seed) return model, TR, TE, meta, tldef analyze_ontogeny(trace, grok, vid, seed): """Разбор кривой онтогенеза: КОГДА рождается ось относительно грокинга?""" if not trace or grok is None: return None ws = np.array([t["ws_switch"] for t in trace]) ep = np.array([t["epoch"] for t in trace]) acc = np.array([t["acc"] for t in trace]) cos = np.array([t["cos_to_input"] for t in trace]) peak = float(ws.max()) if peak < 5: verdict = "оси нет ни на одном этапе" e_axis = None else: half = peak / 2 idx = int(np.argmax(ws >= half)) e_axis = int(ep[idx]) d = e_axis - grok if d < -500: verdict = f"ось ДО грокинга (на {-d} эпох раньше) -> знак = ПРЕДПОСЫЛКА" elif d > 500: verdict = f"ось ПОСЛЕ грокинга (на {d} эпох позже) -> КРИСТАЛЛИЗАЦИЯ" else: verdict = "ось ВМЕСТЕ с грокингом -> представление = обобщение" ws_at_grok = float(ws[np.argmin(np.abs(ep - grok))]) ws_final = float(ws[-1]) growth = ws_final - ws_at_grok print(f"\n [T10] ОНТОГЕНЕЗ ЗНАКА ({vid}/s{seed}):") print(f" грокинг @ {grok} | ось (полумакс) @ {e_axis} | ВЕРДИКТ: {verdict}") print(f" ws_switch: в момент грокинга {ws_at_grok:.1f}% -> в конце {ws_final:.1f}% " f"(рост {growth:+.1f} п.п.)") print(f" cos→вход: в момент грокинга " f"{float(cos[np.argmin(np.abs(ep - grok))]):.3f} -> в конце {float(cos[-1]):.3f}") if growth > 10: print(f" >>> КРИСТАЛЛИЗАЦИЯ ПОДТВЕРЖДЕНА: ось дозревает при неизменной точности " f"(acc {float(acc[np.argmin(np.abs(ep-grok))]):.3f} -> {float(acc[-1]):.3f})") return dict(grok=grok, axis_epoch=e_axis, verdict=verdict, ws_at_grok=round(ws_at_grok, 2), ws_final=round(ws_final, 2), growth=round(growth, 2), cos_at_grok=round(float(cos[np.argmin(np.abs(ep - grok))]), 4), cos_final=round(float(cos[-1]), 4))# ==========================================================# ИНСТРУМЕНТЫ# ==========================================================def class_means(model, X, cls, L, n_ops): model.eval() with torch.no_grad(): _, C = model(X, cache=True) r = C["res"][L + 1] return [r[cls == k].mean(0) for k in range(n_ops)]def pair_axis(mu, i, j): d = mu[i] - mu[j] return d / (d.norm(dim=-1, keepdim=True) + 1e-8)def class_subspace(mu): """ Для setclass: из классовых средних mu (list из [S,d]) строим cmeans [n_ops,S,d], grand [S,d], basis — список по позициям ортонормированных базисов Q_s [d,r] между-классового подпространства (r = ранг центрированных средних, обычно n_ops-1). Для n_ops=2 r=1 -> одно дискриминантное направление, и setclass совпадает с парным патчем по механике. """ M = torch.stack(mu) # [n_ops, S, d] g = M.mean(0) # [S, d] Mc = M - g # [n_ops, S, d] S = M.size(1) r_cap = max(1, M.size(0) - 1) # центрир. средние: ранг <= n_ops-1 basis = [] for s in range(S): A = Mc[:, s, :] # [n_ops, d] _, Sg, Vh = torch.linalg.svd(A, full_matrices=False) thr = 1e-5 * float(Sg[0]) if Sg.numel() and float(Sg[0]) > 0 else 1e-8 r = min(r_cap, max(1, int((Sg > thr).sum()))) basis.append(Vh[:r].T.contiguous()) # [d, r] return M, g, basis# ==========================================================# T0. ПОТОКИ# ==========================================================def T0_flows(model, TE, n_ops, meta): model.eval() with torch.no_grad(): _, C = model(TE["X"], cache=True) c, S = TE["cls"], TE["X"].size(1) def spread(t): ms = torch.stack([t[c == k].mean(0) for k in range(n_ops)]) g = ms.mean(0) return [round(float(torch.stack([torch.norm(ms[k, s] - g[s]) for k in range(n_ops)]).mean()), 4) for s in range(S)] out = dict(embedding=spread(C["res"][0]), residuals=[dict(layer=i, spread=spread(C["res"][i + 1])) for i in range(N_LAYERS)]) rp = meta["rule_pos"] eq = [r["spread"][-1] for r in out["residuals"]] src = [r["spread"][rp] for r in out["residuals"]] out["summary"] = dict(emb_at_src=out["embedding"][rp], emb_at_eq=out["embedding"][-1], peak_at_eq=round(max(eq), 4), peak_layer=int(np.argmax(eq)), eq_traj=eq, src_traj=src) print(f" T0: эмб@источник {out['summary']['emb_at_src']:.4f} | " f"пик@'=' {out['summary']['peak_at_eq']:.4f} (L{out['summary']['peak_layer']})") print(f" траектория '=': {eq}") print(f" траектория источника: {src}") return out# ==========================================================# T2. РЕШАЮЩИЙ ТЕСТ — ВМЕШАТЕЛЬСТВО ПО ПОЗИЦИЯМ# ==========================================================def T2_positions(model, TR, TE, n_ops, meta, pair=(0, 1)): """ Три режима вмешательства: 'eq' — только последний токен '=' -> ПРАВИМ РАБОЧЕЕ ПРОСТРАНСТВО 'src' — только токен-источник -> РЕДАКТИРУЕМ ВХОД (контроль) 'all' — все позиции -> как в L8 Для каждого: patch (грубо) и setcoord (мягко, per-position target [FIX-2]). [FIX-1] headline workspace_score берётся по РАННИМ слоям 0..N-2; интервенция на L=N-1 (позиция ридаута, слой ридаута) тавтологична и логируется отдельно как ws_readout. """ i, j = pair m = TE["cls"] == j X, a_, b_ = TE["X"][m], TE["a"][m], TE["b"][m] ans_src, ans_tgt = op_answer(j, a_, b_), op_answer(i, a_, b_) S, rp = meta["seq_len"], meta["rule_pos"] multi = model.heads_mode == "multi" tgt_op = torch.full_like(TE["op"][m], i) print(" T2: РЕШАЮЩИЙ ТЕСТ — вмешательство по позициям") print(f" {'режим':<5} {'L':<2} {'α':>4} | {'orig%':>7} {'SWITCH%':>8} {'garb%':>7} | " f"{'op→%':>6}") rows = [] for mode in ["eq", "src", "all"]: pos = positions_for(mode, S, rp) for L in range(N_LAYERS): mu = class_means(model, TR["X"], TR["cls"], L, n_ops) u = pair_axis(mu, i, j) # [S, d] ci_pos = (mu[i] * u).sum(-1) # [FIX-2] per-position target [S] for al in [0.5, 1.0, 2.0]: with torch.no_grad(): o = model(X, intervention=dict(layer=L, type="patch", positions=pos, v_src=mu[j], v_tgt=mu[i], alpha=al)) pr = o["main"].argmax(1) so = float((pr == ans_src).float().mean()) * 100 sp = float((pr == ans_tgt).float().mean()) * 100 r = dict(mode=mode, kind="patch", layer=L, alpha=al, orig=round(so, 2), switched=round(sp, 2), garbage=round(100 - so - sp, 2)) if multi: r["op_followed"] = round( float((o["op"].argmax(1) == tgt_op).float().mean()) * 100, 2) rows.append(r) # мягкое: выставить координату в значение целевого класса ПО КАЖДОЙ позиции with torch.no_grad(): o = model(X, intervention=dict(layer=L, type="setcoord", positions=pos, axis=u, target=ci_pos)) pr = o["main"].argmax(1) so = float((pr == ans_src).float().mean()) * 100 sp = float((pr == ans_tgt).float().mean()) * 100 r = dict(mode=mode, kind="setcoord", layer=L, alpha=None, orig=round(so, 2), switched=round(sp, 2), garbage=round(100 - so - sp, 2)) if multi: r["op_followed"] = round( float((o["op"].argmax(1) == tgt_op).float().mean()) * 100, 2) rows.append(r) # ---- setclass: КОРРЕКТНАЯ многоклассовая интервенция (обязательна для E) ---- # Жёстко выставляем классовую идентичность в целевой класс i во всём между-классовом # подпространстве. switched/orig/op усредняются по ВСЕМ целям i (для бинарных это i∈{0,1}, # для E — i∈{0,1,2,3}). Для бинарных совпадает по механике с парным патчем -> сверка. Xall, clsall, mainall = TE["X"], TE["cls"], TE["main"] aall, ball = TE["a"], TE["b"] for mode in ["eq", "src", "all"]: pos = positions_for(mode, S, rp) for L in range(N_LAYERS): mu = class_means(model, TR["X"], TR["cls"], L, n_ops) M, gmean, basis = class_subspace(mu) sw_t, or_t, op_t = [], [], [] for it in range(n_ops): msk = clsall != it Xi = Xall[msk] ans_i = op_answer(it, aall[msk], ball[msk]) with torch.no_grad(): o = model(Xi, intervention=dict(layer=L, type="setclass", positions=pos, cmeans=M, grand=gmean, basis=basis, target=it)) pr = o["main"].argmax(1) sw_t.append(float((pr == ans_i).float().mean()) * 100) or_t.append(float((pr == mainall[msk]).float().mean()) * 100) if multi: op_t.append(float((o["op"].argmax(1) == it).float().mean()) * 100) sw, orr = float(np.mean(sw_t)), float(np.mean(or_t)) r = dict(mode=mode, kind="setclass", layer=L, alpha=None, orig=round(orr, 2), switched=round(sw, 2), garbage=round(100 - orr - sw, 2), per_target_switch=[round(x, 2) for x in sw_t]) if multi: r["op_followed"] = round(float(np.mean(op_t)), 2) rows.append(r) PAIR_KINDS = ("patch", "setcoord") def _best(mode, kinds, early=False): sub = [r for r in rows if r["mode"] == mode and r["kind"] in kinds] if early: # [FIX-1] исключить тавтологический L=N-1 sub2 = [r for r in sub if r["layer"] < LAST] sub = sub2 if sub2 else sub return max(sub, key=lambda r: r["switched"]) if sub else None best_pair = {m: _best(m, PAIR_KINDS) for m in ["eq", "src", "all"]} best_pair_early = {m: _best(m, PAIR_KINDS, early=True) for m in ["eq", "src", "all"]} best_cls = {m: _best(m, ("setclass",)) for m in ["eq", "src", "all"]} best_cls_early = {m: _best(m, ("setclass",), early=True) for m in ["eq", "src", "all"]} # авто: несущая интервенция — КЛАССОВАЯ для многоклассовых (E), ПАРНАЯ для бинарных use_class = n_ops > 2 head_early = best_cls_early if use_class else best_pair_early head_full = best_cls if use_class else best_pair hk = ("setclass",) if use_class else PAIR_KINDS eq_last = [r for r in rows if r["mode"] == "eq" and r["layer"] == LAST and r["kind"] in hk] readout_eq = max(eq_last, key=lambda r: r["switched"]) if eq_last else head_full["eq"] tag_iv = "setclass" if use_class else "pair" print(f" [несущая интервенция: {tag_iv}; n_ops={n_ops}]") for mode in ["eq", "src", "all"]: b = head_early[mode] if mode == "eq" else head_full[mode] print(f" {mode:<5} {b['layer']:<2} {str(b['alpha'] or 'set'):>4} | " f"{b['orig']:7.1f} {b['switched']:8.1f} {b['garbage']:7.1f} | " f"{b.get('op_followed', float('nan')):6.1f} ({b['kind']})") print(f" eq@L{LAST} (ТАВТОЛОГ. КОНТРОЛЬ ридаута): switch {readout_eq['switched']:.1f}% " f"(op {readout_eq.get('op_followed', float('nan')):.1f}%)") # сверка второй интервенции на eq (для бинарных — setclass; для E — парная под-ось) other_eq = best_cls_early["eq"] if not use_class else best_pair_early["eq"] if other_eq is not None: lbl = "setclass" if not use_class else "парная(0,1)" print(f" сверка eq [{lbl}]: switch {other_eq['switched']:.1f}% " f"(L{other_eq['layer']}, {other_eq['kind']})") print("\n [ЧТЕНИЕ] 'eq'(ранние) высок -> правило на '=' НЕСУЩЕЕ = рабочее пространство") print(" 'eq' низок, 'src' высок -> сеть читает правило из входа = маршрутизация") print(" eq@Lласт высок ПРИ низком eq(ранние) -> это лишь правка входа ридаута,") print(" НЕ рабочее пространство") return dict(pair=[i, j], rows=rows, best=head_full, best_early=head_early, best_pair_early=best_pair_early, best_cls_early=best_cls_early, intervention=tag_iv, workspace_score=head_early["eq"]["switched"], # авто, [FIX-1] без L=N-1 ws_switch_pair=(best_pair_early["eq"]["switched"] if best_pair_early["eq"] else None), ws_switch_class=(best_cls_early["eq"]["switched"] if best_cls_early["eq"] else None), ws_readout=readout_eq["switched"], # тавтологический контроль ws_garbage=head_early["eq"]["garbage"], ws_orig=head_early["eq"]["orig"], input_score=head_full["src"]["switched"], all_score=head_full["all"]["switched"])# ==========================================================# T3. КОНФАБУЛЯЦИЯ [FIX-3]# ==========================================================def T3_confabulation(t2, model): """ Расхождение между «модель говорит, что применяет правило X» (голова op) и «модель действительно применяет X» (main). Три честных числа на режим: existence — разрыв на max(op_followed): СУЩЕСТВУЕТ интервенция, где отчёт следует ~100%, а вычисление нет; at_wsopt — разрыв на max(switched): даже там, где вычислению легче всего последовать, op впереди -> конфабуляция робастна; gap_mean — средний парный разрыв по всем строкам режима. """ if model.heads_mode != "multi": return None out = {} for mode in ["eq", "all"]: sub = [r for r in t2["rows"] if r["mode"] == mode and "op_followed" in r] if not sub: continue b_op = max(sub, key=lambda r: r["op_followed"]) # существование b_ws = max(sub, key=lambda r: r["switched"]) # ws-оптимум gap_exist = round(b_op["op_followed"] - b_op["switched"], 2) gap_wsopt = round(b_ws["op_followed"] - b_ws["switched"], 2) gap_mean = round(float(np.mean([r["op_followed"] - r["switched"] for r in sub])), 2) out[mode] = dict( existence=dict(layer=b_op["layer"], kind=b_op["kind"], alpha=b_op["alpha"], op_followed=b_op["op_followed"], main_switched=b_op["switched"], gap=gap_exist), at_wsopt=dict(layer=b_ws["layer"], kind=b_ws["kind"], alpha=b_ws["alpha"], op_followed=b_ws["op_followed"], main_switched=b_ws["switched"], gap=gap_wsopt), gap_mean=gap_mean) print(f" T3: конфабуляция [{mode}]: " f"существ. op {b_op['op_followed']:.1f}%/main {b_op['switched']:.1f}% " f"-> разрыв {gap_exist:.1f} | " f"ws-опт. op {b_ws['op_followed']:.1f}%/main {b_ws['switched']:.1f}% " f"-> разрыв {gap_wsopt:.1f} | средний разрыв {gap_mean:.1f}") return out# ==========================================================# T5. ИГНИШН (на режиме 'eq' — правим только рабочее пространство)# ==========================================================def T5_ignition(model, TR, TE, n_ops, meta, pair=(0, 1), mode="eq"): i, j = pair m01 = (TE["cls"] == i) | (TE["cls"] == j) X, a_, b_ = TE["X"][m01], TE["a"][m01], TE["b"][m01] ans_i, ans_j = op_answer(i, a_, b_), op_answer(j, a_, b_) pos = positions_for(mode, meta["seq_len"], meta["rule_pos"]) out = dict(pair=[i, j], mode=mode, layers={}) print(f" T5: игнишн (режим '{mode}') — доля цели среди ЧИСТЫХ ответов") for L in range(N_LAYERS): mu = class_means(model, TR["X"], TR["cls"], L, n_ops) u = pair_axis(mu, i, j) ci = float((mu[i][-1] * u[-1]).sum()) cj = float((mu[j][-1] * u[-1]).sum()) lo, hi = min(ci, cj), max(ci, cj) span = (hi - lo) if (hi - lo) > 1e-6 else 1.0 ts = [lo - 0.5 * span + k * (2.0 * span) / 24 for k in range(25)] curve = [] for t in ts: with torch.no_grad(): # свип по СКАЛЯРНОЙ цели на позиции '=' (ось u[-1]); setcoord с pos=[-1] pr = model(X, intervention=dict(layer=L, type="setcoord", positions=pos, axis=u, target=t))["main"].argmax(1) fi = float((pr == ans_i).float().mean()) * 100 fj = float((pr == ans_j).float().mean()) * 100 clean = fi + fj curve.append(dict(t=round(t, 4), cls_i=round(fi, 2), cls_j=round(fj, 2), clean=round(clean, 2), p=(round(fi / clean, 4) if clean > 1e-6 else None))) ps = [c["p"] for c in curve if c["p"] is not None] if len(ps) < 5: eff = sharp = 0.0 else: arr = np.array(ps) eff = float(abs(arr[-1] - arr[0])) sharp = float(np.abs(np.diff(arr)).max() / (eff / len(arr) + 1e-6)) if eff > 0.05 else 0.0 mc = float(np.mean([c["clean"] for c in curve[8:17]])) out["layers"][f"L{L}"] = dict(curve=curve, effect=round(eff, 3), sharpness=round(sharp, 2), mid_clean=round(mc, 1)) print(f" L{L}: эффект {eff:.2f} | резкость {sharp:5.1f} | чистых {mc:5.1f}%") bl, b = max(out["layers"].items(), key=lambda kv: kv[1]["effect"]) out["summary"] = dict(best_layer=bl, effect=b["effect"], sharpness=b["sharpness"], mid_clean=b["mid_clean"]) return out# ==========================================================# T8. АБЛЯЦИЯ (по режимам позиций)# ==========================================================def T8_ablation(model, TR, TE, n_ops, meta): keys = model.active_heads() + [CONTROL] model.eval() with torch.no_grad(): base = accs(model(TE["X"]), TE, keys) S, rp = meta["seq_len"], meta["rule_pos"] print(" T8: абляция оси правила по режимам позиций") rows = [] for mode in ["eq", "src", "all"]: pos = positions_for(mode, S, rp) for L in range(N_LAYERS): mu = class_means(model, TR["X"], TR["cls"], L, n_ops) M = torch.stack(mu); Mc = M - M.mean(0, keepdim=True) vec = torch.stack([torch.linalg.svd(Mc[:, s, :], full_matrices=False)[2][0] for s in range(S)]) rnd = torch.randn_like(vec); rnd = rnd / rnd.norm(dim=-1, keepdim=True) with torch.no_grad(): a = accs(model(TE["X"], intervention=dict(layer=L, type="ablate", positions=pos, vec=vec)), TE, keys) ar = accs(model(TE["X"], intervention=dict(layer=L, type="ablate", positions=pos, vec=rnd)), TE, keys) cons = model.active_heads() dc = float(np.mean([base[k] - a[k] for k in cons])) dr = float(np.mean([base[k] - ar[k] for k in cons])) dt = float(base[CONTROL] - a[CONTROL]) rows.append(dict(mode=mode, layer=L, consumer_drop=round(dc, 4), random_drop=round(dr, 4), control_drop=round(dt, 4))) for mode in ["eq", "src", "all"]: w = max([r for r in rows if r["mode"] == mode], key=lambda r: r["consumer_drop"]) print(f" {mode:<4} L{w['layer']}: потребители −{w['consumer_drop']*100:5.1f}% | " f"случайно −{w['random_drop']*100:5.1f}% | контроль −{w['control_drop']*100:5.1f}%") # headline по 'eq' — по РАННИМ слоям (0..N-2), симметрично T2 [FIX-1] eq_early = [r for r in rows if r["mode"] == "eq" and r["layer"] < LAST] or \ [r for r in rows if r["mode"] == "eq"] worst_eq = max(eq_early, key=lambda r: r["consumer_drop"]) return dict(baseline={k: round(v, 4) for k, v in base.items()}, rows=rows, summary=dict(eq_consumer_drop=worst_eq["consumer_drop"], eq_random_drop=worst_eq["random_drop"], eq_control_drop=worst_eq["control_drop"], eq_layer=worst_eq["layer"], eq_margin=round(worst_eq["consumer_drop"] - worst_eq["random_drop"], 4)))def T9_clusters(model, TE, n_ops): if not SKLEARN: return None model.eval() with torch.no_grad(): _, C = model(TE["X"], cache=True) true = TE["cls"].cpu().numpy() rows = [] for L in range(N_LAYERS): v = C["res"][L + 1][:, -1, :].cpu().numpy() km = KMeans(n_ops, random_state=0, n_init=10).fit_predict(v) s = float(silhouette_score(v, km)) acc = max(float((np.array([pm[k] for k in km]) == true).mean()) for pm in permutations(range(n_ops))) rows.append(dict(layer=L, silhouette=round(s, 4), match=round(acc, 4))) print(f" T9: sil {[r['silhouette'] for r in rows]} | match {[r['match'] for r in rows]}") best = max(rows, key=lambda r: r["match"]) return dict(rows=rows, best=best)# ==========================================================# ПРОГОН# ==========================================================def run(vid, seed, retrain, track=False): cfg = VARIANTS[vid] n_ops = cfg["n_ops"] print("\n" + "=" * 86) print(f" {vid} / seed {seed}: {cfg['desc']}") print("=" * 86) model, TR, TE, meta, tr = train_variant(vid, seed, retrain, track=track) keys = model.active_heads() + [CONTROL] with torch.no_grad(): final = {k: round(v, 4) for k, v in accs(model(TE["X"]), TE, keys).items()} grokked = tr.get("grokked", final["main"] >= 0.95) print(f" точность: {final} | ГРОККИНГ: {grokked}") log = dict(variant=vid, seed=seed, config=cfg, dataset=meta, hyper=dict(p=P, d_model=cfg["d"], n_layers=N_LAYERS), timestamp=datetime.now().isoformat(), training=tr, final_test_acc=final, grokked=bool(grokked)) log["T0_flows"] = T0_flows(model, TE, n_ops, meta) log["T2_positions"] = T2_positions(model, TR, TE, n_ops, meta) log["T3_confab"] = T3_confabulation(log["T2_positions"], model) log["T5_ignition_eq"] = T5_ignition(model, TR, TE, n_ops, meta, mode="eq") log["T8_ablation"] = T8_ablation(model, TR, TE, n_ops, meta) log["T9_clusters"] = T9_clusters(model, TE, n_ops) if track and tr.get("track"): log["T10_ontogeny"] = dict( trace=tr["track"], analysis=analyze_ontogeny(tr["track"], tr.get("epochs_to_grok"), vid, seed)) t2, s5, s8, s0 = (log["T2_positions"], log["T5_ignition_eq"]["summary"], log["T8_ablation"]["summary"], log["T0_flows"]["summary"]) cf = log["T3_confab"] or {} def _cf(mode, field): d = cf.get(mode) or {} if field == "exist": return (d.get("existence") or {}).get("gap") if field == "wsopt": return (d.get("at_wsopt") or {}).get("gap") if field == "mean": return d.get("gap_mean") return None log["metrics"] = dict( grokked=bool(grokked), emb_at_src=s0["emb_at_src"], peak_at_eq=s0["peak_at_eq"], ws_intervention=t2["intervention"], # 'pair' (бинарные) | 'setclass' (E) ws_switch=t2["workspace_score"], # <<< ГЛАВНАЯ (авто): правка только '=' на РАННИХ слоях ws_switch_pair=t2["ws_switch_pair"], # парная интервенция (для бинарных = ws_switch) ws_switch_class=t2["ws_switch_class"],# setclass (для E = ws_switch; для бинарных — сверка) ws_readout=t2["ws_readout"], # <<< тавтологический контроль (L=N-1) input_switch=t2["input_score"], # правка только входа all_switch=t2["all_score"], ws_garbage=t2["ws_garbage"], ws_orig=t2["ws_orig"], confab_gap_eq=_cf("eq", "exist"), confab_gap_all=_cf("all", "exist"), confab_gap_all_wsopt=_cf("all", "wsopt"), confab_gap_all_mean=_cf("all", "mean"), ign_effect_eq=s5["effect"], ign_sharp_eq=s5["sharpness"], ign_clean_eq=s5["mid_clean"], eq_consumer_drop=s8["eq_consumer_drop"], eq_control_drop=s8["eq_control_drop"], eq_margin=s8["eq_margin"], ) if log.get("T10_ontogeny") and log["T10_ontogeny"].get("analysis"): an = log["T10_ontogeny"]["analysis"] log["metrics"].update( onto_axis_epoch=an["axis_epoch"], onto_grok=an["grok"], onto_ws_at_grok=an["ws_at_grok"], onto_ws_final=an["ws_final"], onto_growth=an["growth"], onto_cos_at_grok=an["cos_at_grok"], onto_cos_final=an["cos_final"]) print(f"\n МЕТРИКИ: {json.dumps(log['metrics'], ensure_ascii=False)}") with open(os.path.join(LOG_DIR, f"{vid}_s{seed}.json"), "w", encoding="utf-8") as f: json.dump(log, f, ensure_ascii=False, indent=2) return logdef aggregate(logs): print("\n" + "=" * 122) print(" СВОДКА (mean ± std). Не загроккавшие прогоны исключены из агрегата.") print(" WS(ранн) = несущая правка только '=' на слоях 0..N-2 (авто: парная|setclass).") print(" WSкласс = setclass-версия (для E — она же несущая; для бинарных — сверка с парной).") print(" WS(ридаут)= та же правка на L=N-1: тавтологический контроль (высок почти всегда).") print("=" * 122) print(f"{'вар':<4}{'grok':<6}| {'WS(ранн)%':>10} {'WSкласс%':>10} {'WS(ридаут)%':>12} " f"{'input%':>8} {'all%':>7} | {'конфаб(all)':>12} {'сред':>6} | {'игн.эфф':>8}") print("-" * 122) agg = [] for vid in list(VARIANTS): ls = [l for l in logs if l["variant"] == vid] if not ls: continue ok = [l for l in ls if l["metrics"]["grokked"]] use = ok if ok else ls def ms(k): v = [l["metrics"][k] for l in use if l["metrics"].get(k) is not None] return (round(float(np.mean(v)), 2), round(float(np.std(v)), 2)) if v else (None, None) row = dict(variant=vid, n=len(ls), n_grokked=len(ok), desc=VARIANTS[vid]["desc"]) for k in ["emb_at_src", "peak_at_eq", "ws_switch", "ws_switch_pair", "ws_switch_class", "ws_readout", "input_switch", "all_switch", "ws_garbage", "ws_orig", "confab_gap_eq", "confab_gap_all", "confab_gap_all_wsopt", "confab_gap_all_mean", "ign_effect_eq", "ign_sharp_eq", "ign_clean_eq", "eq_consumer_drop", "eq_control_drop", "eq_margin"]: m, s = ms(k) row[k] = dict(mean=m, std=s) if m is not None else None agg.append(row) g = lambda k: (f"{row[k]['mean']:.1f}±{row[k]['std']:.1f}" if row[k] else "—") print(f"{vid:<4}{len(ok)}/{len(ls):<4}| {g('ws_switch'):>10} {g('ws_switch_class'):>10} " f"{g('ws_readout'):>12} {g('input_switch'):>8} {g('all_switch'):>7} | " f"{g('confab_gap_all'):>12} {g('confab_gap_all_mean'):>6} | {g('ign_effect_eq'):>8}") print("\n[РЕШАЮЩИЙ ТЕСТ — колонка «WS(ранн)»]") print(" Правим ТОЛЬКО токен '=' на слоях 0..N-2 (есть downstream-чтение), вход не трогаем.") print(" Высокий -> правило на '=' НЕСУЩЕЕ: настоящее рабочее пространство.") print(" Низкий при высоком «input» -> сеть читает правило из входа: маршрутизация.") print(" ВАЖНО: если WS(ридаут) высок, а WS(ранн) низок — это лишь правка входа ридаута,") print(" рабочего пространства НЕТ. Разрыв двух колонок — прямое доказательство.") print("\n[КЛЮЧЕВЫЕ СРАВНЕНИЯ]") print(" A,B (читается из a) -> ожидаем: WS(ранн) низкий, input высокий") print(" C,D (QR: строить + дорого) -> ожидаем: WS(ранн) высокий") print(" H (ключ: строить, но ДЁШЕВО):") print(" H ≈ D -> достаточно ПОСТРОЙКИ") print(" H ≈ A -> нужна ДОРОГОВИЗНА пересчёта (амортизация)") print(" F (вырожденный) -> ожидаем игнорирование") print(" E (4 класса, оба строить) -> несущая = setclass (WSкласс). Проверка, что картина") print(" рабочего пространства масштабируется за пределы бинарного правила.") print(" Сверка WS(ранн) vs WSкласс: для бинарных должны совпадать (парная≈setclass);") print(" расхождение -> сигнал, что одна из интервенций артефактна.") print("\n[КОНФАБУЛЯЦИЯ] колонка «конфаб(all)» = существование, «сред» = средний разрыв") print(" Разрыв = (голова 'op' следует за патчем) − (вычисление следует).") print(" «существование» велико -> ЕСТЬ интервенция, где отчёт следует ~100%, а вычисление нет.") print(" «средний» велик -> так ведёт себя не одна подобранная точка, а режим в целом:") print(" представление проходит тест на самоотчёт, не будучи несущим.") path = os.path.join(LOG_DIR, f"summary_{datetime.now():%Y%m%d_%H%M%S}.json") with open(path, "w", encoding="utf-8") as f: json.dump(agg, f, ensure_ascii=False, indent=2) print(f"\n[+] сводка: {path}")def ontogeny_summary(logs): rows = [l for l in logs if l.get("metrics", {}).get("onto_grok") is not None] if not rows: return print("\n" + "=" * 100) print(" T10. ОНТОГЕНЕЗ ЗНАКА: когда рождается ось относительно грокинга?") print("=" * 100) print(f"{'вар':<4}{'сид':<4}| {'грокинг':>8} {'ось@полумакс':>13} {'Δ':>7} | " f"{'ws@грок':>8} {'ws финал':>9} {'рост':>7} | {'cos@грок':>9} {'cos финал':>10}") print("-" * 100) for l in rows: m = l["metrics"] d = (m["onto_axis_epoch"] - m["onto_grok"]) if m["onto_axis_epoch"] is not None else None print(f"{l['variant']:<4}{l['seed']:<4}| {m['onto_grok']:8d} " f"{str(m['onto_axis_epoch']):>13} {str(d):>7} | " f"{m['onto_ws_at_grok']:8.1f} {m['onto_ws_final']:9.1f} {m['onto_growth']:+7.1f} | " f"{m['onto_cos_at_grok']:9.3f} {m['onto_cos_final']:10.3f}") print("\n[ТРИ СЦЕНАРИЯ]") print(" Δ < 0 -> ось ДО грокинга: знак есть ПРЕДПОСЫЛКА обобщения") print(" Δ ≈ 0 -> ось ВМЕСТЕ: представление и обобщение — одно событие") print(" Δ > 0 -> ось ПОСЛЕ: КРИСТАЛЛИЗАЦИЯ под weight decay") print("\n[КРИСТАЛЛИЗАЦИЯ]") print(" рост ws_switch после грокинга при НЕИЗМЕННОЙ точности -> ось ДОЗРЕВАЕТ,") print(" когда задача уже решена. Знак — продукт сжатия, а не вычислительной нужды.") print("\n[ОТРЫВ ОТ ВХОДА]") print(" cos→вход ~1 -> ось на '=' есть просто тень входного признака") print(" cos→вход →0 -> представление ПЕРЕСТРОИЛОСЬ во что-то своё") print(" падение cos при росте ws_switch = знак отрывается от входа и становится несущим") path = os.path.join(LOG_DIR, f"ontogeny_{datetime.now():%Y%m%d_%H%M%S}.json") with open(path, "w", encoding="utf-8") as f: json.dump([dict(variant=l["variant"], seed=l["seed"], analysis=l["T10_ontogeny"]["analysis"], trace=l["T10_ontogeny"]["trace"]) for l in rows], f, ensure_ascii=False, indent=2) print(f"\n[+] онтогенез: {path}")if __name__ == "__main__": ap = argparse.ArgumentParser() ap.add_argument("--variants", nargs="+", default=DEFAULT_ORDER, choices=list(VARIANTS)) ap.add_argument("--seeds", type=int, default=5) ap.add_argument("--retrain", action="store_true") ap.add_argument("--track", action="store_true", help="T10: онтогенез знака — трекинг каждые 250 эпох + " "продолжение обучения после грокинга (ДОЛГО)") args = ap.parse_args() print(f"[*] J-Space Level 9c (исправленный) | {device} | sklearn={SKLEARN}") print(f"[*] варианты {args.variants} × {args.seeds} сидов | самостоятельный (без весов L8)") logs = [run(v, s, args.retrain, track=args.track) for v in args.variants for s in range(args.seeds)] aggregate(logs) if args.track: ontogeny_summary(logs)
Логи и код на github
ссылка на оригинал статьи https://habr.com/ru/articles/1060402/