22 эксперимента, 9 потолков, один champion и неприятная правда про дисциплину эксперимента
Месяц назад я прочитал на Хабре статью про нейронные клеточные автоматы. Маленькие нейросети управляют клетками на сетке, клетки сами собираются в букву T или крест, и всё это обучается без учителя через что-то вроде эволюции. Я подумал: круто, повторю за пару вечеров, посмотрю как себя ведёт.
Эта статья — про то, что было дальше. Спойлер: пара вечеров превратилась в месяц, я провёл 22 эксперимента, упёрся в потолок IoU 0.44 на простой букве T, и главное чему научился — это вообще не про нейросети.
Disclosure сразу: эксперименты я ставил в связке с Claude Code (это первый мой серьёзный опыт работы с AI-агентом, тут отдельная история).
Осторожно, иллюстрация мигает!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
Сравнение со старым baseline на одном 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-первоисточник всегда на месте).
ссылка на оригинал статьи https://habr.com/ru/articles/1039694/