Начнем с простого. Вы скачали нейросеть, и теперь у вас на диске лежит файл на несколько гигабайт, внутри которого миллиарды чисел. Этот файл можно запустить и он будет делать всякие осмысленные штуки: писать текст, отличать котов от собак на фотографиях, переводить с китайского. То есть перед нами работающая программа. Только исходного кода к ней нет, и никогда не было.

Дальше в статье мы разберем идею, которая из этого же наблюдения и выросла. Она звучит так: а давайте напишем компилятор, который берет понятную человеку программу и превращает ее прямо в веса нейросети.

Звучит странно. Но чтобы понять, зачем это нужно и почему это красиво, придется сначала заглянуть в обычные компиляторы и немного в азы ИИ. Без них дальше будет непонятно.

Что вообще делает компилятор

Если вы это знаете, тогда смело листайте дальше. Если нет, то постарался объяснить доходчиво.

Процессор понимает очень мало

Человек пишет примерно так: если баланс больше нуля, спиши 100 рублей.

Процессор не понимает ни слова из этой фразы. Он вообще не знает, что такое «баланс», «если» и «рубли». Он умеет только очень примитивные вещи, и список этих вещей заранее фиксирован:

  • положи число в ячейку;

  • сложи содержимое двух ячеек;

  • сравни два числа;

  • если результат нулевой, то перейди к другой команде;

  • прочитай число из памяти, запиши число в память.

Вот примерно и все. Этот список называется набором команд, и он у каждого типа процессора свой. У процессора в вашем ноутбуке один, у процессора в телефоне — второй, у видеокарты — третий.

Компилятор — это программа, которая переводит человеческий текст в список примитивных команд.

Как он это делает

Компилятор работает конвейером. Стадии примерно такие.

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

Промежуточное представление. Дальше дерево превращают в универсальный полуфабрикат — что-то среднее между исходным текстом и машинными командами. Еще не привязано к конкретному процессору, но уже и не похоже на человеческий язык.

Это ключевая деталь, из-за которой компиляторы вообще устроены именно так. Полуфабрикат позволяет разделить работу на две независимые половины. Фронтенд знает язык, на котором вы пишете, и не знает ничего про железо. Бэкенд знает железо и не знает ничего про язык. Хотите поддержать новый язык — тогда пишите новый фронтенд. Хотите поддержать новый процессор — будьте добры, пишите новый бэкенд. Заново переделывать все не приходится.

Дальше над полуфабрикатом работают оптимизаторы: выкидывают вычисления, результат которых никому не нужен, разворачивают циклы, подставляют маленькие функции прямо в место вызова. Здесь программа становится быстрее.

Распределение регистров. А вот эта стадия для нашей истории очень важна.

Внутри процессора есть регистры, несколько сверхбыстрых ячеек прямо в самом чипе. Их мало, обычно десятки. А переменных в программе могут быть тысячи. Значит, надо решить, какая переменная в какой момент сидит в регистре, а какая ждет своей очереди в обычной памяти, которая в разы медленнее.

Генерация кода. Наконец, полуфабрикат превращают в конкретные команды конкретного процессора. Ну и готово!

Главное, что надо унести из этой части

Компилятор всегда пишется под целевую архитектуру. Под то, что железо физически умеет делать.

Нельзя написать «универсальный компилятор». Сначала вы смотрите, какие команды есть у железа, а потом придумываете, как через них выразить все остальное. Нет команды умножения — умножение собирается из сложений. Нет ветвлений — вообще беда.

Нейросеть как странный процессор

Что такое веса

Нейросеть — это набор простых операций (в основном умножения матриц), у которых есть настроенные числа. Эти числа и называются весами.

Сама по себе структура сети ничего не решает. Она одинаковая у сети, которая различает котов, и у сети, которая различает собак. Разница только в числах. Значит, веса — это буквально содержание программы. Все, что сеть умеет, лежит в них.

Как их обычно получают

Обучением. Показываем сети миллионы примеров, она предсказывает, ошибается, мы чуть-чуть подкручиваем все веса в сторону меньшей ошибки, повторяем миллиард раз. В конце получается файл с числами, который делает что-то полезное.

И вот тут стоит посмотреть на это глазами человека, который только что прочитал введение про компиляторы. 

Обучение — это тоже компиляция

На входе у нас датасет и описание того, что мы хотим (функция потерь). На выходе получаем исполняемый код для довольно странной машины.

Только компилятор попался очень специфический по нескольким причинам:

  • он случайный — запустили дважды, получили два разных результата;

  • он не дает гарантий — никто не обещает, что получившееся действительно решает задачу;

  • он не выдает ошибок — если что-то пошло не так, вы узнаете об этом сильно потом и косвенно;

  • и главное, у него нет читаемого исходника (датасет — это, скорее, набор пожеланий, чем исходник в привычном смысле).

А то, чем занимается наука под названием интерпретируемость (попытки понять, что происходит внутри модели), — это реверс-инжиниринг. То же самое, чем занимается человек, которому дали чужую программу без исходного кода и попросили объяснить, как она устроена, только сложнее (в обычной программе хотя бы видно, где кончается одна функция и начинается другая).

И тут возникает вопрос

Компиляторы пишут под целевую архитектуру. А что, если объявить целевой архитектурой не процессор, а нейросеть?

То есть взять понятную человеку программу и скомпилировать ее не в команды процессора, а в веса, детерминированно и без обучения. На выходе мы получим файл с весами, который загружается и работает, как обычная нейросеть. Только мы точно знаем, что внутри, потому что сами это написали.

ИИ‑роутер — доступ к 300+ моделям из одной панели

Подключите OpenAI‑совместимый шлюз по единому API‑ключу. Настраивайте сквозную аналитику, отслеживайте лимиты и квотируйте ресурсы.

Запустить ИИ‑роутер →

Проблема: на каком языке писать

Идея красивая, но сразу упирается в стенку.

Помните ведь, что компилятор надо писать под то, что железо физически умеет? Так вот, нейросеть-трансформер (тот тип, на базе которого сделаны все современные языковые модели) умеет совсем не то, к чему мы привыкли. У нее нет цикла, нет счетчика команд, нет переходов, нет памяти, куда можно положить что угодно и потом достать по адресу.

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

Что умеет трансформер

Если убрать все лишнее, у трансформера ровно два механизма:

  • Внимание. Каждая позиция в тексте (грубо говоря, каждое слово) решает, на какие другие позиции ей посмотреть, и забирает оттуда информацию;

  • Полносвязный слой. Обработка каждой позиции по отдельности, без оглядки на соседей. Взяли то, что в этой позиции лежит, применили функцию и положили обратно.

Неожиданно мало, но именно из этих двух кирпичей собрано вообще все, что вы видели в языковых моделях. 

Язык RASP

В 2021 году трое исследователей — Гейл Вайсс, Йоав Голдберг и Эран Яхав — сделали ровно то, что напрашивается. Придумали язык, в котором эти два механизма стали конструкциями языка. Назвали RASP.

Конструкций там, соответственно, тоже примерно две.

select — «выбери, на что смотреть». Задает правило: какая позиция на какие позиции обращает внимание. Например: «пусть каждая позиция смотрит на все позиции с точно таким же словом».

aggregate — «забери то, что выбрал». Собирает информацию из выбранных позиций.

Выглядит игрушечно, но на этом реально программируют. Вот, например, разворот строки задом наперед:

# правило: позиция №1 смотрит на последнюю,
#          позиция №2 — на предпоследнюю, и так далее
flip    = select(indices, length - indices - 1, ==)

# а теперь просто забрать оттуда слова
reverse = aggregate(flip, tokens)

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

Похожим образом на RASP пишется подсчет того, сколько раз встретилось каждое слово, сортировка, проверка правильности расстановки скобок.

И уже тогда обнаружилась интересная вещь: по RASP-программе можно предсказать нужный размер сети. Берете сеть предсказанного размера, обучаете на задаче — справляется. Урезаете размер — перестает справляться. То есть язык оказался рабочей моделью того, что в сеть влезает.

Tracr — собственно компилятор

Язык есть, теперь нам нужен бэкенд. Его написали в DeepMind в 2023 году, а компилятор называется Tracr.

На вход — программа на RASP. На выход — файл с весами обычного трансформера. Загружаете, запускаете, работает.

Где живут переменные

В трансформере есть штука, которая называется остаточным потоком, но давайте назовем ее общей доской. Через все слои сети проходит один и тот же набор чисел, и каждый слой к нему что-то дописывает. Вход в начале, ответ в конце, а посередине — рабочие записи.

Tracr использует эту доску как память программы. Каждой переменной он выделяет свой участок доски, не пересекающийся с чужими. Одна переменная — свой кусок, вторая — свой. Это ровно та же задача, что распределение регистров: решить, что где лежит и на каких слоях кому нужно.  

Как компилируются команды

select превращается в веса внимания. Правило «смотри на позиции с таким же словом» становится конкретными числами в матрицах, которые управляют вниманием. Поэлементные операции превращаются в веса полносвязного слоя. Функцию, которую вы написали, кодируют в числах напрямую.

Слои раскладываются по программе. Каждый шаг вычисления занимает свой слой. Чем длиннее цепочка зависимостей в вашей программе, тем глубже получится сеть. На выходе — нейросеть, про которую известно все. Какая переменная в каком куске доски лежит и какой слой за что отвечает.

А зачем это вообще нужно

Резонный вопрос: если у нас уже есть программа, которая сортирует список, зачем нам сортирующая нейросеть? Обычный код быстрее, надежнее и понятнее.

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

Исследователь берет модель, применяет свой метод анализа и заявляет: «вот здесь у нее механизм, который копирует имя из предыдущего предложения».

Как проверить, что он прав? А никак, ответа не существует. Никто не знает, что модель делает на самом деле — она сама себя написала, спросить не у кого. Все, что есть, так это объяснение. И проверять объяснение приходится, кстати, тем же объяснением.

Авторы Tracr описывают ситуацию через хорошую аналогию: это как изобретать микроскоп, не имея ни одного препарата, про который заранее известно, как он должен выглядеть. Вы что-то видите в окуляре. Это структура клетки — или царапина на линзе? Непонятно.

Скомпилированная модель и есть тот самый эталонный препарат. Мы своими руками положили внутрь известный алгоритм. Наводим микроскоп: если инструмент не находит то, что там гарантированно есть, то проблема в инструменте.

Микроскоп проверяют на препарате, о котором многое известно заранее.
Микроскоп проверяют на препарате, о котором многое известно заранее.

Идея, честно говоря, старая как инженерия. Прежде чем доверять новым весам, на них взвешивают гирю с известной массой.

Неожиданная находка

Тут выяснилось, что скомпилированная модель не только эталон, но и лаборатория, где можно ставить опыты.

Вот пример. Вернемся к тому, как Tracr раскладывает переменные — каждой свой непересекающийся участок доски. Чисто, аккуратно и очень расточительно: место под переменную зарезервировано во всех слоях, даже там, где она не нужна.

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

Это называется суперпозицией и является одной из главных бед интерпретируемости. Нельзя посмотреть на отдельный нейрон и сказать, за что он отвечает, потому что он отвечает за шесть разных вещей одновременно.

Так вот. Если взять чистенькую скомпилированную модель и сжать доску — заставить те же переменные уместиться на меньшем месте — суперпозиция возникает сама собой. Модель вынуждена начать писать поверх.

Суперпозиция возникает сама собой.
Суперпозиция возникает сама собой.

Теперь есть уникальная вещь: сеть с суперпозицией, про которую точно известно, какие именно записи в нее уложены. Явление можно изучать.

Про то, где это не работает

Теперь то, без чего статья была бы неполной.

Язык слишком беден. RASP умеет описывать «на входе последовательность — на выходе последовательность», но настоящая языковая модель выдает не совсем ответ, а вероятности всех возможных следующих слов. И многие механизмы внутри нее устроены именно так: компонент не выбирает слово, а слегка повышает или понижает его шансы. Такое RASP выразить не может в принципе.

Получается расточительно. Те самые непересекающиеся участки доски для каждой переменной на каждом слое. Реальная сеть так не делает никогда.

И главное — слишком чисто. Веса в Tracr заданы вручную, аккуратно, по линеечке. Каждая переменная лежит в своих собственных нейронах, ровно и отдельно.

Именно из-за этой аккуратности скомпилированные модели неправдоподобно легко разобрать. Допустим, что ваш метод отлично находит алгоритм в модели Tracr — а вот в настоящей сети не найдет ничего, потому что настоящая сеть кривая, забита суперпозицией и шумом.

То есть эталон получился проще реального объекта, и потому проверяет инструмент недостаточно строго.  

Из этой критики выросло следующее поколение — полусинтетические модели. Идея: не задавать веса руками, а обучать сеть, но с ограничениями, которые гарантируют, что внутри получится заранее известная схема. Тогда объект и обучен по-настоящему, со всем сопутствующим беспорядком, и при этом ответ известен. Самый заметный проект здесь называется InterpBench.

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

Компилятор как способ что-то доказать

Дальше на сцене появляется язык ALTA. Техническое отличие в том, что появились циклы. RASP их не умеет: программа разворачивается в фиксированную цепочку слоев, и все. ALTA умеет, потому что целится в сети, где один и тот же слой применяется многократно. Вот вам и повторение.

Но интереснее то, для чего это стали использовать. В теории нейросетей есть бесконечный спор: может ли архитектура в принципе выразить такой-то алгоритм? Обычно на подобные вопросы отвечают тяжелой математикой, и ответ звучит как «в некотором классе схем — да, при некоторых условиях».

Компилятор позволяет ответить совсем иначе: предъявить веса. Через ALTA конструктивно показали, что трансформер способен представить алгоритмы, которые не ломаются от длины входа: вычисление четности, сложение столбиком с переносом. Причем без промежуточных рассуждений вслух.

«Может» и «выучит» — это разные вещи

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

Раньше в такой ситуации спор был неразрешимым. Одни говорили: трансформеры фундаментально не годятся для арифметики, нужна другая архитектура. Другие: годятся, просто вы плохо учите.

Теперь спор разрешим. Веса существуют и лежат в открытом доступе. Значит, дело не в архитектуре. Значит, нужное решение в принципе находится где-то в пространстве возможных весов, просто обучение до него не доходит — не хватает или данных, или подсказок, или оно сваливается в решение попроще.

Разница между «нельзя» и «не получилось» огромна с практической точки зрения. Первое означает, что надо менять конструкцию. Второе — что надо менять способ обучения.

Авторы ALTA пробуют использовать компилятор как учителя: обучать сеть не на парах «вопрос — ответ», а на пошаговом выполнении правильной программы. То есть показывать не только результат, но и все промежуточные шаги.

Когда ассемблер начал предсказывать будущее

Давняя загадка: почему сеть, обученная на коротких примерах, иногда прекрасно работает на длинных, а иногда полностью разваливается? Обучили складывать трехзначные числа — а пятизначные она либо складывает, либо нет, и заранее непонятно.

Группа исследователей предложила ответ, сформулированный прямо в терминах нашего ассемблера. Они взяли слегка урезанную версию RASP и выдвинули гипотезу:

Сеть справится с длинами, которых не видела, если для задачи существует короткая RASP-программа, работающая при любой длине входа.

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

Гипотеза объясняет большинство известных случаев успеха и провала. И, что важнее, опираясь на нее, авторы заметно улучшили результаты на задачах, которые традиционно считались для трансформеров тяжелыми.

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

И в обратную сторону

Раз есть компилятор, положено быть декомпилятору. Он тоже есть. Проект Transformer Programs, вместо того чтобы разбирать готовую сеть, обучает сеть, устроенную с такими ограничениями, что ее автоматически можно превратить в читаемую программу на Python.

Логика здесь та же, что у безопасных языков программирования. Вместо того чтобы ловить ошибки после того, как они случились, сделайте так, чтобы неправильное просто невозможно было выразить. То есть вместо того чтобы разбирать сеть постфактум, ограничьте ее так, чтобы результат обучения был читаем по построению. Плата, разумеется, та же: ограничения снижают возможности.

Есть и совсем экзотика: работы, где показано, что трансформер с циклом можно превратить в буквально программируемый компьютер — реализовать в весах интерпретатор простейшей вычислительной машины, а дальше подавать ему программы как обычные входные данные.

От «нейросеть — черный ящик» до «нейросеть — платформа исполнения, для которой у нас есть тулчейн» путь, конечно, приличный.

Насколько это применимо на практике

Масштаб пока крошечный. Речь про программы уровня «отсортируй список» и «проверь скобки». И это все про сети в несколько слоев. Естественно, что ни о какой компиляции чего-то размером с реальную языковую модель речи не идет и в ближайшие годы не пойдет. 

Практическая польза сегодня — исследовательская, и сводится к трем вещам:

  • Эталон для проверки инструментов. Тестировать методы анализа на моделях с известным ответом. С поправкой на то, что эталон подозрительно чистый.

  • Доказательства предъявлением. Вместо тяжелых рассуждений о том, что теоретически выразимо, мы получаем файл с весами.

  • Отличать «не может» от «не научилось». Каждый раз, когда модель проваливает задачу, есть развилка: чинить архитектуру или чинить обучение.

Зачем я вообще это рассказал

Мы привыкли считать, что нейросеть и программа — вещи принципиально разной природы. Программу пишут, модель обучают. Программу читают, модель зондируют. Программу отлаживают, модель дообучают и надеются на лучшее. Теперь эта граница стала чуть менее четкой.

Веса — это машинный код. Обучение — компилятор. Попытки понять модель — реверс-инжиниринг программы без исходников. И в тот момент, когда для какого-то класса задач появляется настоящий компилятор, объект перестает быть черным ящиком и становится обычным скомпилированным артефактом, к которому есть исходник.

Разница между «выучено» и «написано» сводится к одному вопросу: есть ли у вас компилятор?

Для маленьких алгоритмов он уже есть. Для настоящих больших моделей — нет. И не факт, что появится: потому что непонятно, на каком языке вообще писать исходник. Каким текстом описать то, что делает большая языковая модель? Никто не знает. Единственный доступный способ такую модель получить — запустить обучение и посмотреть, что выйдет.

Комментарии (0)