Все началось с любопытства к нейросетям и личным ассистентам. Однажды я даже объединил проект Django и телеграм бота в мини социальную сеть. Телеграм боту я прикрутил обвязку от Google Dialogflow. Это было моё первое общение с цифровым “разумом”. Второй прилив желаний что-то сделать пришел, когда стали появляться разные нейронные сети, которые не просто отвечали по списку ответов, а могли генерировать эти ответы. Это кажется таким нереальным, невозможным, и в тот же момент хочется постичь эту невозможность. Понять от корки до корки всю кухню нового цифрового разума. Это поражает и восхищает! Так что, после того как я закончил работать над CORMless и Mail Pigeon, мне захотелось чего-то, что можно не только запустить, но и увидеть, как нейронная сеть делает выбор. Чтобы нейросеть на моих глазах училась управлять луноходом или балансировать шест на тележке. И чтобы это выглядело не как утилита для гиков из командной строки (и такое у меня тоже есть), а как законченное приложение с кнопками, графиками и прогресс-барами.

Так родился Neuro Evolution — микрофреймворк для параллельного обучения AI-агентов в средах Gymnasium с графическим интерфейсом на wxPython.

Проблема, знакомая каждому, кто обучал нейросети локально

Кто хоть раз запускал обучение на своём компьютере, знает эти неудобства:

  • Вы написали скрипт на Python, использующий PyTorch и Gymnasium.

  • Запустили обучение. Модель учится 10 минут, 20, час…

  • В это время вы смотрите на окно. С matplotlib ещё можно работать в интерактивном режиме — plt.ion(), plt.draw(), plt.gcf().canvas.flush_events() позволяют обновлять график в реальном времени. Но с ростом сложности GUI (а у меня были и прогресс-бары, и консольный вывод, и кнопки управления) одного графика становится мало.

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

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

Откуда взялась идея: индуктивный путь архитектора

Есть два способа прийти к архитектурному решению. Первый — дедуктивный: ты видишь систему сверху, знаешь, что в ней есть две «коробки», и проектируешь их взаимодействие. Это классический путь архитектора.

Но мой путь был другим.

Я начал с одной «коробки» — консольное-приложение с полносвязанной нейросетью. Я знал её изнутри: как она запускает обучение, как обновляет график, как реагирует на кнопки. И я видел внутри неё разницу — принципиальную разницу между задачами, которые грузят CPU, и задачами, которые управляют интерфейсом. Я рассуждал: если внутри одной коробки есть такой разрыв, то должна существовать и вторая коробка — та, которая этот разрыв устранит.

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

Этот индуктивный путь — от внутреннего знания одной коробки к обнаружению второй — и определил всю архитектуру Neuro Evolution.

Архитектурная идея: десктопное приложение как микросервисная система

Главная инженерная идея проекта: построить GUI-приложение не как монолит, а как кластер из нескольких процессов-сервисов.

Обычно GUI-приложения пишут так: есть главный поток (Event Loop) для интерфейса, и, возможно, один-два воркера для тяжёлых задач. Но когда тяжёлых задач несколько, они разных типов (одна грузит одно ядро CPU, другая ждёт ввода-вывода), классическая модель начинает трещать по швам.

Я выделил три типа сервисов:

  • Frontend — процесс, в котором крутится GUI (wxPython). Он только отображает данные и принимает ввод от пользователя. Никакой вычислительной нагрузки.

  • Backend — процесс, который может содержать бизнес-логику.

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

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

  • Собственный диспетчер процессов (RootService). Я написал менеджер, который управляет жизненным циклом подпроцессов. Это не просто multiprocessing.Process, а фреймворк, который запускает, останавливает и координирует работу нескольких сервисов.

  • Разделение на сервисы (Frontend, Backend, Tasks). UI, бизнес-логика и вычислительная нагрузка физически разнесены по разным процессам. Это классический паттерн Принцип разделения ответственности (Separation of Concerns, SoC) на уровне операционной системы.

  • Асинхронная очередь задач. Пользователь может поставить в очередь несколько сред, и они будут обрабатываться на свободных CPU-ядрах параллельно, не блокируя UI. В это время можно спокойно переключаться между вкладками, смотреть статистику уже обученных моделей или ставить новые задачи.

  • Межпроцессное взаимодействие (IPC) через систему команд. Я не просто передаю данные через Queue. Я реализовал протокол обмена командами (Command, TypeCommand.REQUEST, TypeCommand.REPLY). Это подход из мира распределённых систем, применённый для локального приложения.

Вкратце, для управления этими процессами микрофреймворк отвечает за:

  • Запуск и остановку сервисов-процессов.

  • Маршрутизацию сообщений между ними (через очереди multiprocessing).

  • Логирование из всех процессов в отдельные файлы (через QueueHandler).

# Упрощённая структура запуска
if __name__ == '__main__':
    mp.set_start_method('spawn', force=True)
    root = RootService()
    root.start_services()   # запускает Frontend, Backend, Tasks
    root.join()             # ждёт завершения
    root.close()            # корректно останавливает все процессы

Как это решает проблему зависающего GUI

Теперь, когда пользователь нажимает кнопку «Обучить», происходит следующее:

  1. Frontend создает команду и отправляет её через очередь в CPU Tasks.

  2. Родительский процесс создает подпроцессы для обучения.

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

  4. Frontend обновляет прогресс-бар и текст в консоли.

Вопрос, зачем нужен Backend, если он не работает с обучением? На данный момент времени он не используется. Но я показал, каким образом его можно использовать. Скажем, в будущем, Backend может обрабатывать запросы, которые блокируются на ввод-вывод. Можно работать как с синхронным кодом, так и с асинхронным. С Frontend может отправляться команда, а представление которое реализует эту команду на Backend будет выполняться.

Протокол команд:

# Отправка асинхронной команды
async_request = AsyncTestCommand().set_is_request()
service.send_to(service.backend_id, async_request)

# Отправка синхронной команды
thread_request = ThreadTestCommand().set_is_request()
service.send_to(service.backend_id, thread_request)

Протокол обработчиков:

@handler(cls_service=Backend, cls_command=AsyncTestCommand, type_command=TypeCommand.REQUEST)
async def async_test_metod(service: Backend, message: AsyncTestCommand):
    await asyncio.sleep(1)
    logger.debug('AsyncTestCommand(id={}): Отработала тестовая асинхронная команда'.format(message.id))


@handler(cls_service=Backend, cls_command=ThreadTestCommand, type_command=TypeCommand.REQUEST)
def thread_test_metod(service: Backend, message: ThreadTestCommand):
    time.sleep(1)
    logger.debug('ThreadTestCommand(id={}): Отработала тестовая потоковая функция'.format(message.id))

Отправление команды на CPU ядро.

# Отправка команды на CPU-ядро
request = RunTrainAgent().set_is_request()
service.run_cpu_command(request)

CPU команды должны добавляться через роутер. Это связка между командой и функцией.

from neuro_gym import RouterTasks
from neuro_gym.commands import RunTrainAgent
from services.tasks.cpu_tasks import run_train


RouterTasks.add(RunTrainAgent, run_train)

Обработчик CPU команды может располагаться в любом файле. Принимает данные по команде.

def run_train(message: RunTrainAgent, mails: Dict[str, Queue], *args, **kwargs):
    ...

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

Главный результат: GUI остаётся полностью отзывчивым во время обучения. Пользователь может в это время:

  • Переключаться между разными средами.

  • Смотреть статистику уже обученных моделей.

  • Ставить в очередь на обучение новые среды.

  • Запускать тестовый прогон обученного агента, чтобы увидеть его в деле.

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

Самой хитрой проблемой оказалась ошибка со слабой ссылкой (weakref) при передаче объектов синхронизации между подпроцессами на этапе инициализации. Я перебрал несколько вариантов и в итоге пришёл к компромиссу: менеджер процессов создаёт все необходимые объекты синхронизации и передаёт их дочернему подпроцессу при запуске. Это позволило подпроцессам общаться друг с другом, не теряя ссылки на лету. Если сейчас обучение агента происходит на отдельном выделенном ядре CPU, то в начале моих попыток обучение отправлялось на Backend (а не напрямую в CPU Tasks), и этот отдельный сервис-подпроцесс останавливался пока обучал одну модель. Отправь нескольких агентов на обучение, и тогда один будет ждать другого. Меня это тоже не устраивало. Еще была проблема создания подпроцессов внутри дочерних подпроцессов. Подпроцесс не может создаваться из дочернего, если этот подпроцесс демонический. Еще одной сложностью было понимание как работают нейронные сети, я до сих пор смотрю курсы. Один просмотрел аж уже три раза. И знаете, каждый раз нахожу для себя что-то новое. В этом есть плюс, с появлением нейросетей я стал вспоминать курс математики по производным (основа для обучения с учителем).

Почему CPU, а не GPU?

Вы могли заметить, что обучение в Neuro Evolution заточено под CPU. Это осознанный выбор, продиктованный архитектурой фреймворка.

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

С GPU всё сложнее. Видеокарта — это разделяемый ресурс. Несколько процессов, одновременно обращающихся к одному GPU, будут конкурировать за память и вычислительные блоки. Можно настроить CUDA_VISIBLE_DEVICES и распределить процессы по разным картам, но это усложняет и развёртывание, и код. Плюс к этому, для полносвязных сетей, которые используются в нейроэволюции, накладные расходы на перенос данных между CPU и GPU часто съедают весь прирост в скорости. CPU справляется с такими моделями достаточно быстро.

Тем не менее, архитектура фреймворка не запрещает использование GPU. При желании можно адаптировать воркеры под CUDA — это вопрос будущих экспериментов.

Плагинная система: добавь своё окружение

Вторая важная архитектурная идея — возможность легко добавлять новые среды Gymnasium без изменения кода фреймворка.

Я сделал систему на основе динамической загрузки модулей через pkgutil.walk_packages. Пользователю достаточно создать Python-файл в папке environs, унаследовав класс от Environ:

from neuro_gym.environ import Environ, Complexity
import torch

class LunarLander(Environ):
    
    @property
    def id(self) -> str:
        return 'LunarLander-v3'
    
    @property
    def name(self) -> str:
        return 'Лунный посадочный модуль'
    
    @property
    def complexity(self) -> int:
        return Complexity.MEDIUM
    
    @property
    def number_input_neurons(self) -> int: 
        return 8
    
    @property
    def number_output_neurons(self) -> int: 
        return 4
    
    def update_vector(self, output_vector):
        return torch.argmax(output_vector, dim=-1).item()

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

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

Нейроэволюция как метод обучения

В отличие от классического Deep Reinforcement Learning (DQN, PPO), я выбрал нейроэволюцию — генетический алгоритм, оптимизирующий веса нейронной сети.

Почему не градиентные методы? Причины две:

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

  • Параллелизм. Генетический алгоритм естественно ложится на многопроцессорную архитектуру. Каждую особь (набор весов) можно протестировать независимо.

Сама реализация генетического алгоритма использует библиотеку DEAP:

  • Популяция — 50 особей, каждая особь — это плоский вектор всех весов нейросети.

  • Скрещивание — Simulated Binary Crossover (оператор рекомбинации) с вероятностью 0.9.

  • Мутация — Polynomial Bounded Mutation (оператор мутации) с вероятностью 0.1 на ген.

  • Селекция — турнирная, размер турнира 3.

  • Элитизм — 3 лучшие особи сохраняются в «зал славы» (Hall of Fame).

Каждые 10 поколений (одно десятилетие) лучшая особь сохраняется в файл. Пользователь видит на графике, как растёт награда от десятилетия к десятилетию. Конечно, многие настройки можно скорректировать settings.py, он вынесен из кода и должен лежать рядом с exe приложением.

Визуализация: чтобы видеть прогресс

Отдельно хочется сказать про визуализацию. Я встроил в приложение:

  • График эволюции: максимальная, средняя и минимальная награда по поколениям и по десятилетиям. Строится через matplotlib.

  • Столбчатая диаграмма: лучшие результаты по десятилетиям.

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

Появляется уведомление о завершение процесса обучения.

уведомление о завершение
уведомление о завершение

Рис. 1: Уведомление о завершении обучения

Сохранение модели. На диаграмме можно выбрать модель для сохранения в файл.

сохранение модели
сохранение модели

Рис. 2: Окно сохранения модели с диаграммой лучших результатов по десятилетиям

Лунный посадочный модуль. У модуля есть три двигателя и направление движения. Нейронная сеть должна уметь управлять этими двигателями и корректировать посадку модуля.

neuro evolution graphic
neuro evolution graphic

Рис. 3: Тестовый прогон обученного агента в среде LunarLander-v3

Сборка в EXE: чтобы работало у всех

Отдельный челлендж — упаковка AI-приложения в .exe через PyInstaller.

Если вы когда-нибудь пробовали собрать проект с PyTorch, Gymnasium, wxPython и Matplotlib в один исполняемый файл, вы знаете, что это отдельный вид спорта. Вот с чем я столкнулся:

  • Скрытые импорты. PyInstaller не всегда видит динамические импорты Gymnasium (среды подгружаются через importlib). Пришлось вручную прописать все hiddenimports для сред classic_control и box2d.

  • Matplotlib и wxPython. Для визуализации я использовал бэкенд TkAgg — Matplotlib открывает графики в собственном окне. Это работает независимо от основного GUI и не конфликтует с многопроцессорной архитектурой.

  • Многопроцессорность. PyInstaller плохо дружит с multiprocessing на Windows. У меня появилось 50 приложений в диспетчере задач, пока я думал почему не работает, мою систему заполонили копии. Спас вызов multiprocessing.freeze_support() и метод spawn вместо fork.

Весь процесс сборки автоматизирован через GitHub Actions: при каждом пуше в main собирается новая версия .exe и публикуется в релизах. Пользователю нужно только скачать архив и запустить.

Заключение

Neuro Evolution — это не продукт и не коммерческий проект. Это полигон для отработки архитектурных идей:

  • Построение многопроцессорных приложений с разделением на фронтенд, бэкенд и воркеры.

  • Проектирование плагинных систем на основе динамической загрузки модулей.

  • Реализация генетических алгоритмов на практике.

  • Production-сборка Python-десктопа в единый исполняемый файл.

Для меня этот проект стал проверкой идеи, что даже десктопное AI-приложение может (и должно) строиться по принципам распределённых систем. Как раз это вкупе с желанием заставляют меня дальше двигаться в этом направлении. Любому пытливому уму наверняка захочется заглянуть за край горизонта. К тому же это сейчас очень перспективное и очень интересное направление.


Ссылки