В последние несколько месяцев наблюдаются значительные успехи в разработке языковых моделей, особенно — в сфере частного бизнеса. В прошлом году вышло несколько подобных проектов, основанных на Minecraft. В частности — речь идёт о фреймворке MineDojo, который представляет собой огромный симулятор, а так же — о системах, позволяющих ИИ‑агентам обучаться игре в Minecraft с помощью VPT (Video PreTraining). Похожие проекты появились и в этом году. Среди них — системы, в которых используется SOTA LLM, модель STEVE-1, Minecraft‑агент Voyager, фреймворк GITM.

Базой всех этих разработок является игра Minecraft. В них, кроме того, обычно используется окружение MineRL. Мы, для удовлетворения наших исследовательских потребностей, занимаемся работой над другим стеком инструментов, который основан на воксельном движке Minetest.

Что представляет собой движок Minetest и зачем создавать на его основе окружение для обучения с подкреплением?

Основной целью этого проекта было выполнение исследований с использованием ИИ‑агентов, которые выходят за пределы существующей парадигмы языкового моделирования. Хотя и наблюдается впечатляющий прогресс в деле создания агентов, работающих в среде Minecraft и поддающихся обучению с подкреплением, мы выяснили, что существующие фреймворки отличаются определёнными ограничениями, которые усложняют изучение интересующих нас задач.

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

MineRL -> Malmo -> Minecraft -> Зависимости Minecraft -> (???)

Сам проект Minecraft — это игра с закрытым исходным кодом, которую, для целей моддинга, нужно декомпилировать. Malmo — это старый фреймворк от Microsoft, который не обновлялся уже лет пять. С точки зрения разработки и поддержки ПО это — ситуация, далёкая от идеальной. Добавление нового функционала требует реверс‑инжиниринга и модификации немалых объёмов старого кода.

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

Ещё одна проблема существующих проектов крутится вокруг возможности подстройки их под свои нужды. А именно, мы, в долгосрочной перспективе, намерены поддерживать режимы работы в Minetest, которые просто невозможно реализовать на базе существующих фреймворков. Сюда, например, входит взаимодействие в игре агентов‑людей с синхронными и асинхронными мультиагентами.

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

Как устроен Minetester?

Фреймворк Minetester расширяет стандартную среду Minetest несколькими способами, которые ориентированы на обеспечение возможности проведения исследований обучения с подкреплением.

How the Minetester framework integrates with the Minetest game.
Интеграция фреймворка Minetester с движком Minetest. Оранжевым выделены части схемы, относящиеся к расширениям Minetester, чёрным — к воксельному дижку Minetest.

Вспомогательные данные и коммуникация (вознаграждение, обратная связь и т. п.)

Самая важная возможность Minetester — расширения API моддинга. Это позволяет задействовать программную передачу клиенту информации о вознаграждении.

Клиент-серверная синхронизация

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

Работа без графического интерфейса

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

Клиентская Python-обёртка

И наконец — вся система инкапсулирована в Python‑обёртку, которая служит интерфейсом для ИИ‑агентов, построенных в современных фреймворках машинного обучения.

Простой пример использования Minetester: PPO

Для начала приведём пример элементарного использования Minetester с применением простой политики, агент, реализующий которую, просто валит деревья. Решать эту задачу мы начали с использованием алгоритма PPO (Proximal Policy Optimization, Проксимальная оптимизация политики), так как это — один из простейших алгоритмов обучения с подкреплением.

Первое, что мы отметили, заключается в том, что без какой‑либо помощи подобные алгоритмы вообще не работают — они не позволяют решить даже «простую» задачу вроде валки деревьев. Обычно агенты, управляемые такими алгоритмами, просто «топчутся на месте», совершая случайные действия. Когда им удаётся разрушить блок — это редко бывает что‑то такое, за что можно получить вознаграждение. В результате получается, что алгоритмы, в их базовом состоянии, попросту неработоспособны.

С этой проблемой можно справиться множеством различных способов. В идеале речь может идти о методах, реализующих более жёсткие установки, работа которых основана на создании благоприятных условий для исследования окружающей среды и на формирование навыков. Подобные механизмы естественным образом столкнутся с тем, что за валку дерева даётся вознаграждение, и быстро это усвоят. Но, на самом деле, успешно решить эту задачу довольно сложно. Нам неизвестны успешные примеры её решения с использованием такого подхода. Вместо этого системы полагаются на другие популярные подходы, которые задействуют ранее полученные знания и механизмы клонирования поведения. Именно такой подход был применён в проекте OpenAI, где используется VPT, он же применяется и в модели STEVE-1. Ещё одна тактика заключается в упрощении задачи. Именно так в DeepMind поступили при работе над DreamerV3. То же самое, возможно, было сделано при работе над агентами, основанными на GPT-4, когда использовались особые API для очень сильного упрощения пространства действий.

В нашем случае было решено сделать что‑то подобное путём комбинирования механизма формирования вознаграждения с блокировкой определённых действий, что позволило ещё сильнее упростить задачу. Реализация обеих этих модификаций в фреймворке Minetester затруднений не вызывает, они значительно упрощают среду, в которой работает агент.

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

Вот — пример кастомной обёртки, которая упрощает задачу, блокируя для агента определённые действия.

class AlwaysDig(MinetestWrapper):
  def step(self, action):
    action["DIG"] = True
    obs, rew, done, info = self.env.step(action)
    return obs, rew, done, info

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

-- вознаграждение за разрушение узлов дерева
minetest.register_on_dignode(function(pos, node)
  if string.find(node["name"], "tree") then
    REWARD = 1.0
  end
end)

Все эти модификации позволяют простому PPO-агенту легко научиться решать поставленную перед ним задачу.

Обученный агент валит дерево (GIF, 14Мб)

Интерпретация базовой политики PPO

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

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

Интерпретация методом «белого ящика» без глубокого исследования модели

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

Зондирование активаций/Deep Dream

Так как это — визуальная модель — мы, для её исследования, можем воспользоваться некоторыми техниками, приведёнными в публикации OpenAI «Zoom In: An Introduction to Circuits». Так, мы пользуемся сетью ConvNet. Это значит, что мы можем применить градиентный спуск для того чтобы прозондировать сеть и узнать о том, какие именно фрагменты изображений отвечают за активацию тех или иных нейронов сети. В нашем случае некоторые показатели играют особую роль в интерпретации реакции системы на изображения. В частности, речь идёт о выходах блоков Actor и Critic, а так же — об активации низкоуровневых нейронов.

Simplified NN diagram, 3 convolutional layers feed into value and critic heads. There are ReLU non-linearities between each layer. See the notebook for full implementation details.
Упрощённая диаграмма нейронной сети. 3 свёрточных слоя поставляют данные в следующие блоки. Между слоями используются нелинейные функции активации ReLU. Подробности о реализации этой системы смотрите в вышеупомянутом Jupyter-блокноте.

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

Делать такое для состояний с высокими показателями, выдаваемыми блоком Critic, не особенно информативно, но мы видим появление определённых повторяющихся низкоуровневых паттернов.

Image inputs with a high value according to the critic. We do see some repeating patterns but nothing very clear. Each image represents a different time delayed frame that gets fed into the NN.
Входные изображение с высокими значениями, показанными блоком Critic. Тут видны некие повторяющиеся паттерны, но ничего достаточно чёткого здесь нет. Каждое изображение представляет собой различные кадры, передаваемые нейронной сети.

Ещё мы можем, применяя метод обратного распространения ошибки, исследовать вероятность предпочтения агентом выполнения поворота вправо или влево, что, в дальнейшем, мы назвали «вероятностью отклонения от основного направления» (yaw probability). Полученные результаты отличаются гораздо меньшей чёткостью, чем те, что мы видели, исследуя методом «Deep Dream» классификаторы, прошедшие серьёзное обучение. Но и тут мы смогли разглядеть некоторые паттерны. В частности, мы обнаружили, что сеть уделяет больше внимания чему‑то, находящемуся ближе к левой центральной части экрана, когда ей хочется повернуть влево, и что, когда ей хочется повернуть вправо, она больше ориентируется на то, что находится ближе к краям экрана.

“Saliency” of the most recent frame fed into the network. Left image represents turning left, the right image represents turning right. See the notebook for how these images were created from deep dream outputs.
«Значимость» самого свежего кадра, переданного сети. Левое изображение инициирует поворот влево, правое — поворот вправо. В вышеупомянутом Jupyter-блокноте вы можете найти подробности о том, как были созданы эти изображения на основе Deep Dream-вывода.

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

Другие наблюдения

Мы сделали ещё несколько менее значительных наблюдений.

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

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

Анализ реальных изображений и оценка значений, символизирующих действия

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

What players see.
То, что видит игрок
What the network sees.
То, что видит сеть

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

Учитывая вышесказанное, мы, всё равно, можем взглянуть на то, как работает сеть. Нас, в основном, интересует значение показателя вероятности отклонения от основного направления (yaw probability).

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

The yaw probability flips sign when we mirror the image.
Показатель вероятности отклонения от основного направления (Yaw) меняет знак при отражении изображения.

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

This persists even at night, when trees are brighter than the environment.
Показатель Yaw демонстрирует изменение и тогда, когда деревья светлее фона.

И при таких входных изображениях сеть продолжает работать. Это исключает возможность того, что она, чтобы ориентироваться, использует нечто вроде тёмных или светлых фрагментов изображения, находящихся справа или слева.

Исследование глубинных механизмов сети

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

Слой 1

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

An example of an edge detector in the first layer.
Пример детектора краёв в первом слое. На первом изображении сверху показаны входные данные сети, на втором — данные до обработки их с помощью функций ReLU, на третьем — то, что получается после использования ReLU.

Слой 2

С использованием одного нелинейного слоя признаки имеют тенденцию к усложнению. В целом признаки, как мы выяснили, распределяются по трём категориям:

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

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

  • И наконец, мы обнаружили 1 нейрон, который, как кажется, достаточно стабильно ведёт себя как детектор деревьев.

The “tree detector” neuron in the second layer.
Нейрон, обнаруживающий деревья, находящийся во втором слое.

Слой 3

Нейроны в третьем слое ведут себя примерно так же, в том числе — и нейрон, который действовал как детектор деревьев. Для того чтобы подтвердить то, что этот признак влияет на решения, которые принимает сеть, мы вычислили градиент w.r.t показателя вероятности отклонения от основного направления признака и выяснили, что активация нейрона соответствовала сильному градиенту в ожидаемом нами направлении.

Конкретные выводы

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

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

Первая проблема — это отсутствие чёткого соответствия между тем, что видит и делает пользователь, и тем, что видит и делает сеть. Конвейер обработки данных, используемый нами в Jupyter‑блокноте был реконструирован с использование кодовых баз OpenAI Gym и Minetester, но, в идеале, это должно делаться автоматически.

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

Третья проблема — это постоянная нехватка инструментов для исследований, направленных на интерпретацию того, что происходит в экосистеме JAX. В случае с PyTorch — имеются инструменты, вроде TransformerLens, которые упрощают объяснение работы трансформеров, но, на момент написания этого текста, нам неизвестны какие‑либо JAX‑эквиваленты этих инструментов.

Более общие/гипотетические выводы

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

Существует большой разрыв между работающей нейронной сетью и той, поведение которой легко поддаётся интерпретации

В отличие от использования сетей ConvNet для решения задач классификации, эти сети, похоже, отличаются гораздо меньшим уровнем структурированности. Стандартные подходы, позволяющие понять то, как они работают, часто приводят к обнаружению гораздо менее структурированной информации, чем та, которую можно обнаружить в чём‑то вроде VGG.

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

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

Возникает такое ощущение, что, возможно, агенты обучения с подкреплением, основанные на неких моделях, лучше поддаются интерпретации, так как они лучше «трансформируют во внутреннее представление» то, что их окружает. Но, похоже, такое никогда не приводит к построению полной внутренней «картины» внешнего мира (так как нельзя вместить в модель весь тот мир, в котором она функционирует). Поэтому для того, чтобы понять то, как агент ведёт себя в среде, устройство которой не вполне понятно и нам самим, нужны другие подходы.

Структура сети и алгоритм обучения оказывают решающее влияние на возможность интерпретации систем

Это, похоже, тема, которая постоянно всплывает в разговорах об обученных моделях. Знание внутренней структуры сети и того, как она приобрела такую структуру, даёт ключ к возможности интерпретации поведения этой сети. Так, в случае с трансформерами, из‑за того, что в их состав входит механизм внутреннего внимания, и из‑за их архитектуры, особый интерес представляют индуктивные блоки. Аналогично, при анализе сетей ConvNet визуализация в духе Deep Dream возможна отчасти благодаря ограниченным рецепторным полям и непрерывной природе их входных данных. В нашем случае мы воспользовались тем, что сеть имеет свёрточную структуру, и тем, что соотношения действий и значений организовано сравнительно просто и без особых сложностей поддаётся интерпретации. Это позволило подвергнуть механизмы модели реверс‑инжинирингу, по крайней мере — частичному.

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

Возможность интерпретации работы моделей и их соответствие решаемым задачам должны серьёзно учитываться на стадии проектирования моделей

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

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

Что дальше?

Проект Gymnasium: коллаборация с Farama Foundation и поддержка нескольких агентов

Существующая среда Minetester построена на основе старого API OpenAI Gym, который уже устарел и не поддерживается. К нам обратились представители Farama Foundation, после чего у нас появился план перейти на их API Gymnasium. Этот API современнее, им можно заменить старый API без внесения серьёзных изменений в проект, он обладает дополнительными возможностями и нормально поддерживается. Мы, кроме того, собираемся более тесно сотрудничать с Farama Foundation в направлении дальнейшего расширения их пакета программ и добавления в него нового функционала, такого, как поддержка нескольких агентов.

Запись действий и генеративные Minetest-модели

Хотя использование политик управления агентами позволяет достаточно просто и понятно достигать желаемого, этот подход, очевидно, отличается ограничениями. Следующий шаг, который мы планируем предпринять, заключается в реализации инфраструктуры для записи действий пользователя. Это позволит клонировать поведение пользователя и создавать генеративные Minetest‑модели, похожие на DreamerV3 от DeepMind.

Обучение с подкреплением, основанное на модели

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

Присоединяйтесь к нам!

Minetester — это большой проект. Постоянно растёт количество задач, которые связаны с ним и нуждаются в решении. Если хотите над ним поработать — загляните в раздел #alignment‑minetest в нашем Discord. У нас найдётся место для множества волонтёров, готовых поработать над разными аспектами Minetester.

О, а приходите к нам работать? ???? ????

Мы в wunderfund.io занимаемся высокочастотной алготорговлей с 2014 года. Высокочастотная торговля — это непрерывное соревнование лучших программистов и математиков всего мира. Присоединившись к нам, вы станете частью этой увлекательной схватки.

Мы предлагаем интересные и сложные задачи по анализу данных и low latency разработке для увлеченных исследователей и программистов. Гибкий график и никакой бюрократии, решения быстро принимаются и воплощаются в жизнь.

Сейчас мы ищем плюсовиков, питонистов, дата-инженеров и мл-рисерчеров.

Присоединяйтесь к нашей команде

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