Я хотел повторить Growing Neural CA за вечер. Ушёл месяц. claude code.. claude code. genetic algorithms.. claude code. genetic algorithms. ml engineering.. claude code. genetic algorithms. ml engineering. neural cellular automata.. claude code. genetic algorithms. ml engineering. neural cellular automata. neural networks.. claude code. genetic algorithms. ml engineering. neural cellular automata. neural networks. neuroevolution.. claude code. genetic algorithms. ml engineering. neural cellular automata. neural networks. neuroevolution. optuna.. claude code. genetic algorithms. ml engineering. neural cellular automata. neural networks. neuroevolution. optuna. PyTorch.. claude code. genetic algorithms. ml engineering. neural cellular automata. neural networks. neuroevolution. optuna. PyTorch. reproducibility.. claude code. genetic algorithms. ml engineering. neural cellular automata. neural networks. neuroevolution. optuna. PyTorch. reproducibility. Research.

22 эксперимента, 9 потолков, один champion и неприятная правда про дисциплину эксперимента

Месяц назад я прочитал на Хабре статью про нейронные клеточные автоматы. Маленькие нейросети управляют клетками на сетке, клетки сами собираются в букву T или крест, и всё это обучается без учителя через что-то вроде эволюции. Я подумал: круто, повторю за пару вечеров, посмотрю как себя ведёт.

Эта статья — про то, что было дальше. Спойлер: пара вечеров превратилась в месяц, я провёл 22 эксперимента, упёрся в потолок IoU 0.44 на простой букве T, и главное чему научился — это вообще не про нейросети.

Disclosure сразу: эксперименты я ставил в связке с Claude Code (это первый мой серьёзный опыт работы с AI-агентом, тут отдельная история).

Осторожно, иллюстрация мигает!

Champion на seed 4: 5 065 параметров, 250 шагов симуляции, рост из горстки случайных стволовых клеток

Champion на seed 4: 5 065 параметров, 250 шагов симуляции, рост из горстки случайных стволовых клеток

Champion на seed 4: 5 065 параметров, 250 шагов симуляции, рост из горстки случайных стволовых клеток

Это финальная модель. 5 065 параметров, 250 шагов симуляции, на старте — горстка случайных «стволовых» клеток в центре поля. Никаких градиентов, никакого обучения с учителем. Только локальные правила и эволюционный отбор.

С чего начинал

Базовая идея NCA в моей версии: 15×15 сетка, на ней клетки. Клетка может быть пустой, стволовой (S), типа A или типа B. Каждый шаг каждая живая клетка смотрит на 8 соседей по Муру, на свои координаты (y, x) и принимает решение: остаться, дифференцироваться в A, дифференцироваться в B, поделиться, умереть. Решение принимает крошечная нейросеть — у меня она называется LittleLM.

Архитектура у LittleLM смешная по меркам современного ML. Один блок multi-head attention, один линейный head, плюс embedding для типа клетки и для номера соседа. Итого 5 065 параметров. Это в 25 000 раз меньше GPT-2 small. Но именно эти 5 тысяч чисел и обучаются генетическим алгоритмом, чтобы вся сетка из одной случайной точки выросла в букву T.

Цель — IoU (пересечение/объединение) построенной формы с целевой буквой. 0.0 — ничего не совпало, 1.0 — идеально.

Чтобы не учить модель буквам с нуля, я добавил curriculum: сначала 50 поколений учится строить простой крест, потом 16 поколений мягкий переход с креста на T, потом 150 поколений на T, потом 30 поколений на «полировку» (phase_T_refine). Идея: сначала легче, потом сложнее, общее знание переносится между фазами через эволюционный отбор.

Это всё работало плохо. Первый рабочий baseline после 58 ручных экспериментов в марте дал MS-10 IoU = 0.256 — то есть 25%. Я смотрел на это число и думал: ну, теперь надо просто покрутить веса в фитнес-функции, и всё взлетит.

Первое открытие: клетка не знала, где она

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

Это как если бы вы стояли в толпе с закрытыми глазами и знали только, что у вас рядом 8 человек, но не знали — вы в центре зала или у стены. С такой информацией невозможно понять «я должен быть в горизонтальной планке T» или «я должен быть в вертикальном стволе». Решения принимаются только по локальному соседству, а целевая форма — глобальная.

Я добавил блок на 96 параметров — линейную проекцию (y, x) → 32, которая даёт клетке вектор «где я нахожусь», и прибавил его к выходу attention’а. На старте этот блок весит 2% от размера модели.

Это даёт +124% среднего IoU по всем 73 чекпоинтам в истории проекта. Не +5%, не +20% — в 2.2 раза.

Я сидел и смотрел на это число и понимал, что один маленький архитектурный фикс перекрыл результат всех моих 58 ручных тюнингов вместе взятых. С этого момента в проекте появилась первая запись в файле gotchas: «Adaptive mutation alone is useless without positional encoding». Все мои предыдущие попытки крутить мутации без позиционирования были мёртвыми.

Урок: архитектурный фикс может в принципе перекрыть всё пространство, в котором ты тюнил. Сначала проверь архитектурное допущение, потом тюни.

Ложный прорыв через HPO

С позиционкой baseline вырос с 0.256 до 0.339. Это уже что-то. Но потолок на букве T я ощущал — модели начинали строить горизонтальную планку, но почему-то не могли построить вертикальный ствол. И вообще ландшафт фитнеса я не понимал — у меня было 15 весов в фитнес-функции, и я не знал какую из 15 ручек крутить.

Я подключил Optuna и запустил HPO sweep на 25 trial’ов overnight. Утром получил winner: trial №17 показал MS-10 IoU = 0.4254. Это +25.5% к моему лучшему ручному результату, и впервые в истории проекта — выше 0.40.

Я сидел утром перед монитором и думал, что пробил «архитектурный потолок» одной ночью настройки весов. Это было приятное чувство. Я уже планировал, какие применения у этой штуки могут быть в реальном мире — лизинг, дроны, городские светофоры, биотех.

Через два дня я понял что 0.4254 — артефакт бага.

Дело было в run_trial. Optuna при каждом trial’е перезаписывала RNG-состояние, и сидинг получался другой между фазой обучения и фазой бенчмарка. Trial 17 случайно попал на конкретный seed, на котором модель работала хорошо. После починки RNG-стейтинга в коммите 1a10ac9 я перепрогнал — и получил MS-10 = 0.329. То есть HPO не дал никакого прорыва. Вообще никакого. На 0.0 — фактически в шуме.

Урок номер один из этого: training peak IoU врёт. И HPO best тоже врёт. Любой результат, который не воспроизведён на 10 разных world-seed’ах через multiseed_bench, — гипотеза, не факт.

Урок номер два, более болезненный: я был готов поверить в 0.4254 потому что хотел поверить. Я не запустил multi-seed bench с самого начала, потому что цифра уже выглядела как победа. Это и есть hope-driven verification — когда тебе результат нравится, ты перестаёшь искать в нём ошибки.

После этого случая я начал строить вокруг проекта дисциплину, которую назову позже. Первое появление: файл docs/DECISION_2026-04-19.md с правилом «никакого нового эксперимента без записанной success criterion до запуска».

Настоящий прорыв

Реальный потолок 0.40 был не фикс HPO, а структурный. Модели физически не могли построить вертикальный ствол T, потому что в моей системе действие «поделиться» выбирало случайного соседа. Если ты хочешь построить вертикаль на буквой T в столбце 7, тебе надо чтобы клетки делились строго вверх или вниз, не случайно. А они так не умели.

Я добавил в src/world.py направленные действия: ACTION_DIVIDE_N, ACTION_DIVIDE_S, ACTION_DIVIDE_E, ACTION_DIVIDE_W. Action space расширился с 5 до 8.

Сначала это не дало прироста. Я неделю крутил веса и отказывался верить.

Что в итоге сработало — это комбинация направленных действий с одним странным фитнес-правилом: stem_trunk_presence: bool. Бинарный флаг «в столбце 7 есть хоть одна стволовая клетка → даёшь бонус». Не градиентный (credit: 0.0..1.0), а именно бинарный. До этого я несколько недель крутил stem_trunk_credit как float от 0.2 до 0.7, и каждое значение давало MS-10 в районе 0.17–0.25. Когда я заменил это на bool — прыжок до 0.44.

Логика такая: GA получает или не получает бонус. Половинчатого «частично есть ствол» не бывает. С градиентом эволюция теряется в локальных оптимумах «почти есть ствол, но не до конца», с бинарным сигналом — либо строит ствол, либо нет, и быстро находит «либо».

Champion: MS-10 IoU = 0.440 на runs/exp_directional_presence_v1_*/best_t.pt. +33.7% к старому baseline 0.329. 9 из 10 seed’ов достигают IoU ≥ 0.40. Визуально — горизонтальная планка из A-клеток в строке 2, вертикальный ствол из B-клеток в столбце 7, во всех seed’ах одинаково.

Вот как это выглядит на 10 seed’ах одновременно:

Осторожно, иллюстрация мигает!

Champion на 10 разных world-seed’ах одновременно. 9 из 10 достигают IoU ≥ 0.40 — форма устойчива между сидами, это и есть MS-10 = 0.440

Champion на 10 разных world-seed’ах одновременно. 9 из 10 достигают IoU ≥ 0.40 — форма устойчива между сидами, это и есть MS-10=0.440

Champion на 10 разных world-seed’ах одновременно. 9 из 10 достигают IoU ≥ 0.40 — форма устойчива между сидами, это и есть MS-10 = 0.440

Сравнение со старым baseline на одном seed’е:

Осторожно, иллюстрация мигает!

Слева — legacy baseline (MS-10 = 0.329, без направленных действий). Справа — champion (MS-10 = 0.440, +33.7%). Один и тот же world-seed

Слева — legacy baseline (MS-10=0.329, без направленных действий). Справа — champion (MS-10=0.440, +33.7%). Один и тот же world-seed

Слева — legacy baseline (MS-10 = 0.329, без направленных действий). Справа — champion (MS-10 = 0.440, +33.7%). Один и тот же world-seed

В этот момент я сидел и думал: всё, проект готов, остаётся продакшен. Я даже написал executive overview для воображаемых стейкхолдеров про то, как этот проект применим в роях дронов, городских светофорах и портфельной оптимизации. Лизинг — у меня же знакомые в финансах, я смогу продать.

Это была вторая версия hope-driven thinking, через которую мне пришлось пройти.

Пять попыток пробить 0.50

Champion стоит на 0.44. Идеальный T — это 1.0. Между 0.44 и 1.0 — много места. Я начал думать, как выжать ещё.

Дамп грида показал почему модель не идёт выше: горизонталь и вертикаль построены правильно, но вокруг них в сетке плавает шум. Стволовые клетки S, которые модель не убрала, сидят везде. Живых клеток в среднем 95-100 при цели 25. Лишние клетки = false positive penalty = низкий IoU.

Решение «очевидно»: усилить штраф за клетки вне цели. Я начал серию.

Эксперимент #17 — die_cap cap. Я повысил «сколько клеток за шаг могут умереть» с 35% до 60% в поздней фазе. Логика: GA сможет быстрее убивать стволы. Результат: alive выросло с 95 до 145–158. То есть GA в ответ на «больше можно умирать» начал рожать ещё больше клеток. Жёсткий cap не даёт фитнес-градиента, поэтому эволюция не учится «не делиться так часто», она учится «делиться больше, чтобы компенсировать смерти». Регулятор проиграл арм-рейс производителю. MS-10 = 0.30, регресс −31%.

Это записалось как gotcha #12: hard simulation-side caps paradoxically grow what they cap. Жёсткие правила без градиента не работают; нужны costs, не constraints.

Эксперимент #18 — stem_cleanup_multiplier. Если жёсткий cap не работает, дам штраф через фитнес. Поднял stem_cleanup_multiplier с 1.5 до 2.5 (стволы в поздней фазе становятся в 2.5 раза дороже). Cleanup сработал: alive упал с 95 до 57! Я обрадовался.

Но MS-10 упал до 0.38. Я смотрел на дампы и видел странное: ствол убран, но в строке 3 (прямо под планкой T) появилась фантомная вторая планка из 12 клеток типа a (тип A вне цели). Откуда?

Посчитал стоимости. Стволовая клетка вне цели: alpha_fp + stem_penalty stem_multiplier = 2.88 + 0.127 × 2.5 = 3.20. Клетка типа A вне цели: только alpha_fp = 2.88. Дифференцироваться *дешевле**, чем оставаться стволом.

GA нашёл escape-hatch: вместо того чтобы убивать стволы, он их дифференцирует в тип A. Снаружи это выглядит как «штраф работает, alive падает», изнутри — это substitution. Gotcha #13: stem-specific penalties trigger type-A differentiation escape-hatch.

Эксперимент #19 — late_t.fp_multiplier. Хорошо, тогда буду штрафовать любую клетку вне цели, не только стволы. Поднял умножитель FP в поздней фазе с 1.2 до 1.8. Это бьёт одинаково по S, a, x. Должно закрыть escape-hatch.

Результат: MS-10 = 0.41 (−6.8%), ближайший промах из всей серии. Дампы показали что фантомная планка a уменьшилась с 12 клеток до 5. Направление верное. Но стволы вернулись (alive 97 — почти как champion). Потому что я сбросил stem_cleanup_multiplier обратно к 1.5, чтобы менять одну переменную за раз.

Каждый отдельный рычаг закрывает одну дырку и открывает другую. Gotcha #15: single-axis fitness attacks close one escape-hatch and open another.

Эксперимент #20 — комбинация #18 + #19. Если каждый по отдельности — недостаточно, объединю. stem_cleanup_multiplier=2.5 + fp_multiplier=1.8. Должно убить и стволы, и a-substitution.

Запустил. Результат: MS-10 = 0.38. Бит-в-бит идентичный #18. Все 10 seed’ов до четвёртого знака. Это значит fp_multiplier=1.8 при stem=2.5 вообще не повлиял на эволюцию.

Я полез в код проверить, не сломан ли параметр. Не сломан, читается, применяется. Но при stem_cleanup_multiplier=2.5 фитнес-ландшафт настолько сильно доминируется штрафом за стволы, что любое второстепенное возмущение (fp_multiplier бьющий по a) не меняет порядок индивидов в популяции. Те же elite, те же дети, тот же финальный champion.

Gotcha #16: dominant-parameter basins mask weaker levers. Нельзя стэкать слабый рычаг поверх доминирующего — он просто маскируется. Если хочешь использовать слабый рычаг, тестируй его от baseline, не на стэке.

Эксперимент #21 — late_t.clean_fp_weight. Последний нетестированный fitness-axis. Линейный аддитивный компонент (не мультипликатор), отдельный механизм. От baseline, не от провалов.

Запустил. Training peak пробил 0.56 на gen 161. И на gen 182. И на gen 227. И на gen 243. Четыре раза модель достигала IoU 0.56 при fp = 0.0000 (нулевые false positives) — это лучшее число training-time во всём проекте. Я думал «вот оно».

Multi-seed bench: MS-10 = 0.43. Разрыв 0.13. Модель может достичь 0.56, на конкретных world-seed’ах. Но не переносит это умение на другие seed’ы.

Это самое болезненное findings из всей серии. Capability существует, robustness — нет. Дальнейший fitness-tuning не поможет, потому что модель уже умеет — просто умеет не везде.

После #21 я объявил OQ#4 (peripheral clutter cleanup) структурно закрытым. Пять рычагов в фитнесе, ноль рычагов работают.

Последний эксперимент: curriculum redesign

Был ещё один фронт. Все 6 экспериментов после champion’а показывали один странный паттерн: best_t.pt (пик из фазы phase_T) систематически лучше чем best_t_refine.pt (пик из последней refine-фазы). Refine-фаза 30 поколений на «полировку» каждый раз делала модель хуже на 0.02–0.10 MS-10.

Очевидный фикс: убрать refine. И добавить эти 30 поколений к phase_T (150→200), чтобы у GA было больше времени поиска в самой продуктивной фазе.

Эксперимент #22, exp_directional_noRefine_v1. Запустил, training прошёл все 266 поколений, peak в phase_T достигал 0.56 несколько раз.

Multi-seed bench: MS-10 = 0.376. Регресс −14.6% от champion. Только 2 из 10 seed’ов достигают IoU 0.40 (у champion’а — 9 из 10).

Это falsified мою gotcha #19 («refine регрессирует champion → убрать refine»). Refine на training fitness действительно регрессирует best_t.pt → best_t_refine.pt. Но на multi-seed bench ровно тот же best_t.pt, обученный с активной refine-фазой, оказывается более робастным, чем без неё.

Refine — это не «полировка champion’а». Это диверсифицирующее давление на популяцию, которое влияет на отбор в phase_T-у. Без refine GA сваливается в seed-overfitting, и продуктовая модель оказывается хрупкой.

После #22 я переписал gotcha #19 в обратную сторону, добавил предупреждение «не убирай refine, даже если кажется что он мешает», и закрыл проект на сегодняшнем потолке 0.440.

Что я узнал на самом деле

Если вернуть себя в начало, на момент чтения той Хабр-статьи, и спросить «чего ты на самом деле узнаешь за этот месяц», то это будет вообще не про нейросети. Я узнал три вещи.

Первая — про training peak. Любая цифра с одной тренировки врёт. Cross-validation (multi-seed bench) — это не сложная academic вещь, это базовая гигиена. Я знал про это раньше абстрактно, но в первый раз проняло меня именно через RNG-баг с HPO 0.4254. Когда смотришь на красивое число и потом видишь, что оно артефакт — только тогда ты начинаешь верифицировать всё.

Вторая — про дисциплину эксперимента. К концу серии у меня в проекте появилось:

docs/DECISION_2026-04-19.md — единый журнал всех экспериментов с гипотезой, success criterion, результатом и вердиктом по каждому. Без этой записи эксперимент не считается проведённым

.claude/rules/experimental-findings.md — gotcha-каталог, 19 правил «как не повторить ошибку»

.claude/skills/experiment-loop/SKILL.md — обязательный 8-step gate до запуска тренировки. Без записанной hypothesis и success criterion не запустится

– Stop-hook, который ругается если я закрываю сессию, изменив configs/exp_* или runs/, но забыв обновить journal

Это результат работы над проектом не меньший чем champion 0.440. Он переносится. Я применяю эту же methodology к параллельному проекту в финансах сейчас, и там уже видно ROI.

Без этой системы я бы дошёл до champion’а 0.440 примерно так же. Но я бы провёл не 22 эксперимента, а 60+, потому что повторял бы dead end’ы и не запоминал что уже пробовал. Я знаю это потому что между 12 и 19 апреля у меня был 8-дневный цикл без governance, и я тогда повторил 5 раз тестирование float stem_trunk_credit с разными значениями — после того как первый же эксперимент уже показал что параметр мёртвый.

Третья — про потолок. 0.440 — это потолок этой архитектуры. 5K параметров, action space 8, grid 15×15. Не fitness-tuning потолок. Это видно по training peak 0.56 — модель умеет, но не переносит. Чтобы пробить 0.50 надо что-то одно из:

– Увеличить модель: embed_dim 32 → 64, heads 4 → 8 — даст ~4× capacity

– Расширить action space: pair-differentiate, hold, die-if-isolated

– Поднять grid: 15×15 → 20×20 со scaled T

– Или всё вместе

Это уже не fitness-tuning, это архитектурный pivot. И вот тут уже честно подумаю стоит ли он того.

Про коллаборацию с агентом

Это был мой первый серьёзный опыт работы с Claude Code как с парт-агентом, а не как «ИИ помощник для одного запроса». Сначала я работал по обычной схеме: «напиши такой-то скрипт», «исправь такую-то ошибку», «прогони тесты». Это работает но это не парт.

Перелом случился в момент когда агент в одном из ответов сказал что-то вроде «прежде чем мы это запустим — ты записал гипотезу? У тебя есть success criterion до запуска тренировки?». Я подумал «не отвлекай, я уже знаю что делаю». Потом сложилась картинка с RNG-багом 0.4254, и я понял что это не я знаю что делаю — это я хочу думать что знаю.

После этого у меня с агентом сложился такой workflow: я говорю задачу, агент пишет план в Decision Doc, спрашивает про success criterion, и пока я её не сформулировал — он не запускает тренировку. Decision Doc стал не моим, а нашим документом. Каждый эксперимент в нём — это договорённость.

Из этого получилось то что я выше назвал «дисциплиной эксперимента». Я бы сам не построил такую систему — слишком много работы для bookkeeping одному человеку. Но когда у тебя есть агент который автоматически апдейтит этот journal, проверяет gotcha-каталог, ругается через хук если ты что-то забыл — это становится дешевле, чем без него.

Не идеально. Я не раз нарушал собственные же правила, агент несколько раз мне напоминал. Я сорвался в hope-driven verification на 0.4254 и должен был писать постмортем. Это всё было.

Но между «я повторил статью с Хабра за месяц» и «я повторил статью с Хабра за месяц + у меня теперь система governance которой я могу пользоваться 5 лет» — большая разница. Второе мне дал агент.

Дальше

Код открыт в Гите по лицензии MIT. Там есть:

  • Тренировочный pipeline (run_train.py + agents/train_agent/)

  • 9 v1 + 33 v2 golden-anchor regression тестов на reproducibility

  • Полный benchmark CLI (scripts/multiseed_bench.py)

  • HPO infrastructure (Optuna)

  • Decision Doc и все 22 эксперимента в docs/DECISION_2026-04-19.md

  • Gotcha-каталог в .claude/rules/experimental-findings.md

  • experiment-loop skill с 8-step Pre-Experiment Gate

Если кто-то возьмёт этот repo и захочет пробить 0.50 — велкам. Открытые направления (OQ#5 в Decision Doc): размер модели, action space, grid size. Я в эту сторону пока не пошёл потому что для меня проект свою задачу выполнил — дал структурированный навык эксперимента, который теперь применяю на параллельных задачах. Но ничего не мешает кому-то другому продолжить.

Если интересна сама эта methodology (Decision Doc, Pre-Experiment Gate, gotcha catalog) — она в репо отдельным слоем под .claude/. Переносится на любой ML-проект где есть baseline и multi-seed eval.

И последнее. Если бы я сейчас писал советы себе-который-открывал-Хабр-статью-месяц-назад, их было бы три:

Первый — записывай success criterion до запуска тренировки. Не после. До. Иначе ты будешь подгонять «успех» под тот результат, который случайно получился.

Второй — multi-seed bench с самого начала, не с момента, когда training peak выглядит подозрительным. Чужой подозрительности у тебя ещё нет, своей будет недостаточно.

Третий — когда хочется убрать какую-то часть системы, потому что она «явно мешает» — она почти никогда не «явно мешает». Refine-фаза в моём curriculum’е выглядела как тормоз 6 экспериментов подряд. Я её убрал в #22, и она оказалась стабилизатором. Системы работают через эффекты, которые ты не видишь напрямую.

Удачи всем, кто решит повторить за пару вечеров.


Об авторе. Анонимно, под ником Nasfermax. Параллельно работаю над финтех-проектом, в свободное время — над экспериментами вроде вот этого. Связь через GitHub.

Благодарности. Александру Мордвинцеву и команде Distill за оригинальную работу «Growing Neural Cellular Automata», которая много лет вдохновляет всех кто туда заходит. Anthropic за Claude Code, без которого governance-слой проекта не сложился бы. И Хабру, в котором я когда-то прочитал ту самую статью что начала весь этот месяц (она уже снята с публикации, но Distill-первоисточник всегда на месте).

Автор: Nasfermax

Источник