На этой неделе я стёр собственный логотип до тонкой полоски и стал ждать, пока он заживёт.
Он какое-то время думал. А потом превратился в палку. Не в G и не в R, а в длинную горизонтальную палку, и так палкой и остался.
Неловко вышло: весь смысл этого логотипа в том, что он заживает.
Что это вообще такое
На rossev.dev есть монограмма GR, которую никто не рисовал. Она вырастает из одного пикселя. Проведите по ней курсором, и она отрастёт обратно.
Под капотом нейронный клеточный автомат по статье Mordvintsev et al., Distill 2020. Я написал его с нуля на PyTorch. Если коротко:
- Сетка 56×56, в каждой клетке 16 чисел: RGBA и 12 скрытых каналов, которым сеть сама придумывает смысл.
- На каждом шаге клетка смотрит на соседей 3×3 через identity и фильтры Собеля, и крошечная сеть (48 → 128 → 16) решает, как ей измениться.
- Обновляется только случайная половина клеток, а клетка без живых соседей умирает.
Это 8 320 параметров. Каждый пиксель запускает одну и ту же сеть и видит только соседей. Картинки «GR» в рантайме нет нигде.
Именно из-за последнего предложения я и получил палку.
Сначала он научился писать «RR»
В статье заживление учат на круглых укусах. Первая модель освоила укусы идеально.
Потом я посмотрел, как с ней обращаются живые люди. Логотип никто не кусает. Его перечёркивают курсором, то есть разрезают.
Один вертикальный разрез между буквами, и модель отрастила на месте G вторую R. Моё портфолио гордо сообщало: «RR».
Дообучение на разрезах не помогло: на двух learning rate модель упрямо растила лаймовый блок из-за R прямо на место G. Помогло только обучение с нуля на повреждениях, похожих на то, что делают пользователи: вертикальные, горизонтальные и диагональные разрезы, каракули и немного укусов. 12 000 итераций, 48 минут на M3 Pro.
Урок первый: учите модель на тех повреждениях, которые делают ваши пользователи, а не на тех, которые удобно сгенерировать. Модель, которая не видела такого сбоя, начнёт импровизировать. Моя сымпровизировала вторую R.
А потом я сделал палку
Возвращаемся к полоске.
Это не баг обучения, это физика. Каждая клетка знает только соседей. В полоске сверху нет ни бита информации о том, где были G и R. Локальное правило нашло другую устойчивую форму, палку, и его всё устроило.
Я замерил это на весах из продакшена:
- если оставить верхние 30–40%, монограмма никогда не восстанавливается;
- если потерять 55%, она заживает примерно за 2 000 шагов, сильно позже, чем раньше останавливался цикл (700).
Никакое обучение это не исправит: информации просто больше нет.
Поэтому исправление не в более умной нейросети, а в ~280 строках очень скучного TypeScript.
Глупый супервизор для умной локальной системы
Recovery guard смотрит на автомат снаружи и сравнивает каждый кадр с грубым отпечатком «GR»: сколько клеток живо и, на сетке 8×8, где они и насколько они зелёные. Несколько десятков чисел, а не картинка.
Правила простые:
- Осталось меньше 40%? Не притворяться. Вырастить заново из одной клетки и честно сказать посетителю: «Не зажило — отрастает заново из одной клетки».
- К шагу 380 не похоже на GR? Сдаться и вырастить заново. Обычные разрезы и укусы выглядят выросшими к ~200 шагу, а безнадёжные случаи и к 400 далеки от цели.
- Похоже, но ещё меняется? Дать ещё 400 шагов, а не замораживать на полпути.
- Первый рост вышел кривым? Такое бывает примерно на 6% случайных сидов. Он идёт за статичным SVG, поэтому его молча перевыращивают, и никто этого не видит.
Пороги не взяты с потолка. Они откалиброваны на ~400 ростах и заживлениях и обязаны ловить старые веса «RR», которые лежат в репозитории как регрессионная фикстура. Гард, который не ловит заведомо плохую модель, не выкатывается.
Урок второй, и его я как раз применяю в работе: локальная модель не может восстановить информацию, которой нет. Дайте ей глупый глобальный супервизор, который понимает, когда она потеряла нить, и честно её перезапускает.
Это pod и liveness probe. Это AI-агент и детерминированная проверка вокруг него. Та же форма, логотип поменьше.
Сколько это стоит странице
Это портфолио, и никто не должен платить за фокус до того, как до него доскроллит.
- Единственный код, который грузится сразу, это загрузчик на 0,47 KB. Он ждёт, пока блок вот-вот появится на экране.
- Веса во float16 в крошечном бинарном формате: 15,5 KB в gzip. Скрипт обучения перезаписывает веса из продакшена, только если новые лучше после округления до float16, так что переобучение не может тихо выкатить что-то хуже.
- Инференс это ~240 строк обычного TypeScript в Web Worker. Без WebGL и без ML-рантайма, и работает он, только пока блок виден.
- Пока модель не загрузилась, место держит статичный SVG на 0,4 KB. С Save-Data, на 2G или без JavaScript вы получите только SVG.
На запуске было Lighthouse 1.0 на мобильных и десктопе, CLS 0.
Написали агенты, проверяли агенты
Я собрал это так же, как сейчас собираю фичи в продакшене: агент-писатель, потом независимые агенты-судьи, которые пытаются всё сломать, и каждая находка сначала становится красным тестом и только потом фиксом.
Судьи нашли «RR», мёртвый пустой квадрат после полного стирания, заблокированный тач-скролл на телефонах, второй цикл таймера, который мог стартовать внутри первого, и обучение, которое могло перезаписать веса получше. Тесты гоняются на тех же float16-весах, что отдаёт страница, а не на чекпойнте обучения.
А вот палку не поймал никто. Для неё понадобился живой человек с мышкой, который пытался сломать собственный логотип.
Идеально до сих пор не стало: на 90 сидах × 8 видах повреждений 11 из 720 заживлений не дотягивают до планки. Кнопка Reset их чинит.
Четыре дня спустя
Я писал это в день запуска сайта. С тех пор было около пятидесяти PR. Гард не менялся, а работы у GR прибавилось.
- Он читает вместе с вами. На каждой странице поста, включая эту, в углу сидит маленький GR и растёт из одной клетки по мере чтения: зародыш на четверти, половина GR на середине, весь логотип в конце. Выросший, он заживает так же, как большой, вместе с гардом. С 23:00 до 06:00 по вашим часам он спит.
- Он есть на 404. На неверном адресе GR перечёркнут через обе буквы и заживает, пока страница подсказывает, куда вы, скорее всего, хотели попасть.
- Существом поста он стать не смог. Я хотел, чтобы у каждого поста вырастал свой вариант логотипа. Потом проверил модель из продакшена: из любого сида она выращивает тот же GR, это единственная форма, к которой её учили возвращаться. Поэтому существ на карточках постов растит обычный клеточный автомат 11×11 с сидом из адреса поста. Без нейросети, около 140 строк.
- У симуляций свой CI. Рост, укус, разрез и отрастание на весах из продакшена занимают 30–45 с одного ядра на тестовый файл, и рядом с остальными тестами они начали падать по таймауту. Теперь у каждого файла свой раннер, а ещё они гоняются по ночам на main.
Код открыт: github.com/Grumbash/rossev.dev. Обучение в scripts/nca/train.py, модель и гард в src/scripts/nca/.
Попробуйте сломать её: rossev.dev. Если у вас получится, чтобы она застыла в чём-то, что не GR, мне правда интересно как.