Skip to content

Latest commit

 

History

History
678 lines (450 loc) · 35.7 KB

File metadata and controls

678 lines (450 loc) · 35.7 KB

Проект: Обучение модели для игры Stack

Цель проекта

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

  1. При классическом старте — набрать не менее 100 очков.

  2. При случайном старте — набрать не менее 30 очков.

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


Общая идея

Вместо классического обучения с подкреплением (Reinforcement Learning) я применил обучение с учителем (Supervised Learning).
Такой выбор был сделан по следующим причинам:

  • Мне удалось эмпирически вывести формулу для автоматической разметки данных.
  • Автокликер способен решать задачу идеально. Следовательно, если соединить автокликер с системой разметки данных, можно создать почти идеальную обучающую выборку.
  • Метод обучения с учителем в этом контексте оказывается быстрее и точнее, чем RL, особенно на ранних этапах.

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

Скин Platinum выбран за счет его нейтральности и неизменности цвета, что улучшает стабильность при обучении и инференсе.


Структура пайплайна

  1. Сбор базовых данных при помощи небольших скриптов, их аугментация и первичное обучение моделей для онлайн-режима.
  2. Онлайн-сбор расширенных данных с использованием обученных моделей и формулы разметки.
  3. Финальное обучение основной сети на обновлённой и более детализированной выборке.

Дополнительно для корректного сбора данных в реальном времени были разработаны две вспомогательные модели:

  • Сеть для классификации конца игры.
  • Сеть для классификации идеального попадания.

Обе они играют важную роль при онлайн-разметке и обеспечивают точность обучения.


Структура проекта

Раздел Назначение
data_prepare.py Сбор, разметка исходных данных и Вспомогательные функции (захват экрана, тайминги, клики и т.д.)
data_augmentation.py Аугментация изображений
nets_train.py Обучение базовых моделей
del_duplicates.py Удаление дубликатов изображений из данных после онлайн сбора.
online.py Онлайн-сбор данных и дообучение
models/ Сохраненные обученные сети и их пороги для классификации
test_sub_models Проверка работы success_net и game_over_net после обучения, перед запуском онлайн сбора
nn_test Проверка работы final_agent после обучения
Data/ Содержит данные для обучения сетей

Сбор данных

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

Общий алгоритм выглядел так:

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

Подробности реализации

Скрипт data_prepare.py выполняет основную работу:

  1. Настройка окна игры: изменение разрешения, позиции, управление кликами и запись кадров.

  2. Определение таймингов:

    • Идеальный первый тайминг — около 0.85 сек, затем сразу снижается до 0.82 сек.
    • Важно учесть задержку между кликом и реакцией игры, которая составила ≈ 32 мс (2 кадра при 60 FPS).
    • Также добавлена поправка на задержку модели и возможные input lag — ещё ≈ 13 мс.
    • В итоге идеальные тайминги вычисляются с поправкой в ≈45 мс.
    • Для дискретных кадров (60 FPS) это соответствует смещению на 3 кадра.
  3. Сбор и разметка данных:
    Используются функции:

    • collect_base_data(monitor, sct) — сбор базовых игровых сценариев.

    • data_markup(...) и data_markup_reg(...) — автоматическая разметка кадров на основе best_frames.

    • data_union(...) — объединение всех выборок (X, y_cls, y_reg) в единый набор.

    • success_collect(...) — сбор кадров для классификации идеального попадания (необходимы сбалансированные данные 50/50).

    • success_markup_and_save(...) — ручная маркировка собранных кадров (промах/успех).

    • save(record(...), folder_name="game_over") — сбор изображений с концом игры (важно учитывать разные визуальные варианты при <11 и >11 очках).

      Менее 11 очков Более 11 очков
      1 2
    • data_union_gameover() — объединение и разметка изображений конца/неконца игры.

Скрипт data_augmentation.py аугментирует данные. Там не будет аугментации success_collect, так как там не сложно набрать много данных, а аугментация при моих наблюдениях для этой задачи не очень хорошо подходит.


Обучение и архитектуры нейросетей

Первое обучение

Запускаем nets_train.py.
Внутри проходит обучение трёх моделей: online_agent, game_over_net и success_net.

Важно: во время обучения в сетях нет финальной сигмоиды для классификации.
Для стабильности использовалась функция потерь BCEWithLogitsLoss() (в коде BCELogits()).
После обучения внутренняя функция автоматически добавляет сигмоиду и сохраняет модели.


Ограничения по времени и FPS

При тестировании выяснилось, что при использовании mss, если логика между сбором кадров превышает 0.016 ms, FPS резко падает.
Пример:

import time
import mss

sct = mss.mss()
monitor = {
    "top": 100,
    "left": 100,
    "width": 100,
    "height": 100
}

start = time.perf_counter()
sleep = 0.016
for _ in range(2):
    for _ in range(100):
        sct.grab(monitor)
        time.sleep(sleep)
    print((time.perf_counter() - start) / 100)
    sleep = 0.013
    start = time.perf_counter()

При разнице всего в 3 мс FPS падает в 2 раза, то есть более чем на 16 мс на кадр. Поэтому весь цикл предобработка → online_agent → game_over_net должен укладываться в ≤ 10 мс. Предобработка данных занимает примерно 1.2 мс.


Архитектуры сетей

1. game_over_net

Архитектура:

{"layer": Dense, "input_dim": 300, 'neurons': 128, "learn_params": {"lr": 0.0001}},
{"layer": Relu},
{"layer": Dense, 'neurons': 64, "learn_params": {"lr": 0.0001}},
{"layer": Relu},
{"layer": Dense, 'neurons': 1, "learn_params": {"lr": 0.0001}, "init_dict": {"init_cls": XavierUniform}}

Аргументация:

  • Классификация конца игры сосредоточена на небольшой области (примерно 6×50px).
  • Вариативность данных низкая, присутствуют лишь незначительные шумы от прозрачного фона.
  • Разница между классами 1 и 0 хорошо выражена — при конце игры появляются светлые элементы интерфейса.
  • Модель фактически вычисляет «среднюю яркость», поэтому задача простая и риск переобучения минимален.

2. online_agent

Архитектура:

{"input_dim": (100, 100, 1), "out_channels": 8, "layer": Conv2D, "learn_params": {"lr": 0.0001}, "bias": False},
{"layer": BatchNorm, "learn_params": {"lr": 0.0001}},
{"layer": Relu},
{"layer": Pooling, "pooling_shape": (2, 2), "pooling_func": "max"},

{"out_channels": 16, "layer": Conv2D, "learn_params": {"lr": 0.0001}, "bias": False},
{"layer": BatchNorm, "learn_params": {"lr": 0.0001}},
{"layer": Relu},

{"out_channels": 1, "layer": Conv2D, "learn_params": {"lr": 0.0005}, "kernel_size": (1, 1), "bias": False},
{"layer": BatchNorm, "learn_params": {"lr": 0.0005}},
{"layer": Relu},

{"layer": Dense, 'neurons': 512, "learn_params": {"lr": 0.0005}, "bias": False},
{"layer": BatchNorm, "learn_params": {"lr": 0.0005}},
{"layer": Relu},

{"layer": Dense, 'neurons': 64, "bias": False},
{"layer": BatchNorm, "learn_params": {"lr": 0.0005}},
{"layer": Relu},

{"layer": Dense, 'neurons': 8, "learn_params": {"lr": 0.0005}},
{"layer": Relu},

{"layer": Dense, 'neurons': 1, "learn_params": {"lr": 0.0005}, "init_dict": {"init_cls": XavierUniform}}

Аргументация:

  • Задача относительно простая — требуется распознавание прямоугольной фигуры.
  • Вместо увеличения числа фильтров акцент сделан на сохранении размерности карты признаков, чтобы повысить точность классификации и регрессии.
  • На вход MLP подаётся всего один фильтр — чтобы не раздувать количество параметров.
  • Сжатие каналов выполняет обучаемая свёртка 1×1×1, идея заимствованная из GoogLeNet.

3. success_net

Назначение:

success_net используется для компенсации накопленных ошибок при онлайн-сборе данных. К 20-й итерации метки становятся шумными, и сеть начинает путаться.

Использование success_net позволяет:

  • точнее классифицировать идеальные попадания (подсветку плитки);
  • устранить эффект накопления погрешностей.

Архитектура:

{"input_dim": (75, 90, 1), "out_channels": 4, "layer": Conv2D, "learn_params": {"lr": 0.0001}, "bias": False},
{"layer": BatchNorm, "learn_params": {"lr": 0.0001}},
{"layer": Relu},
{"layer": Pooling, "pooling_shape": (2, 2), "pooling_func": "max"},

{"out_channels": 8, "layer": Conv2D, "learn_params": {"lr": 0.0001}, "bias": False},
{"layer": BatchNorm, "learn_params": {"lr": 0.0001}},
{"layer": Relu},
{"layer": Pooling, "pooling_shape": (2, 2), "pooling_func": "max"},

{"layer": Dense, 'neurons': 512, "learn_params": {"lr": 0.0001}, "bias": False},
{"layer": BatchNorm, "learn_params": {"lr": 0.0001}},
{"layer": Relu},

{"layer": Dense, 'neurons': 64, "learn_params": {"lr": 0.0001}, "bias": False},
{"layer": BatchNorm, "learn_params": {"lr": 0.0001}},
{"layer": Relu},

{"layer": Dense, 'neurons': 8, "learn_params": {"lr": 0.0001}},
{"layer": Relu},

{"layer": Dense, 'neurons': 1, "learn_params": {"lr": 0.0001}, "init_dict": {"init_cls": XavierUniform}}

Комментарий: Здесь нет особенностей — стандартная CNN+MLP-архитектура без особых решений. Немного раздут MLP-блок по сравнению с основным агентом из-за присутствия большего количества каналов.

Альтернативные архитектуры для online_agent

Вариант 1: Multi-Head (classification + regression)

Архитектура backbone:

{"input_dim": (100, 100, 1), "out_channels": 8, "layer": Conv2D, "learn_params": {"lr": 0.0001}, "bias": False},
{"layer": BatchNorm, "learn_params": {"lr": 0.0001}},
{"layer": Relu}, 
{"layer": Pooling, "pooling_shape": (2, 2), "pooling_func": "max"},

{"out_channels": 16, "layer": Conv2D, "learn_params": {"lr": 0.0001}, "bias": False},
{"layer": BatchNorm, "learn_params": {"lr": 0.0001}},
{"layer": Relu},

{"out_channels": 1, "layer": Conv2D, "learn_params": {"lr": 0.0005}, "kernel_size": (1, 1), "bias": False},
{"layer": BatchNorm, "learn_params": {"lr": 0.0005}},
{"layer": Relu},

{"layer": Dense, 'neurons': 512, "learn_params": {"lr": 0.0005}, "bias": False},
{"layer": BatchNorm, "learn_params": {"lr": 0.0005}},
{"layer": Relu},

{"layer": MultiHead, "heads": [cls_head, reg_head]}

Классификационная голова (cls_head):

{"layer": Dense, 'neurons': 64, "learn_params": {"lr": 0.0005}, "bias": False},
{"layer": BatchNorm, "learn_params": {"lr": 0.0005}}, {"layer": Relu},
{"layer": Dense, 'neurons': 8, "learn_params": {"lr": 0.0005}}, {"layer": Relu},
{"layer": Dense, 'neurons': 1, "learn_params": {"lr": 0.0005}, "init_dict": {"init_cls": XavierUniform}}

Регрессионная голова (reg_head):

{"layer": Dense, 'neurons': 64, "learn_params": {"lr": 0.0005}, "bias": False},
{"layer": BatchNorm, "learn_params": {"lr": 0.0005}}, {"layer": Relu},
{"layer": Dense, 'neurons': 8, "learn_params": {"lr": 0.0005}}, {"layer": Relu},
{"layer": Dense, 'neurons': 2, "learn_params": {"lr": 0.0005}, "init_dict": {"init_cls": XavierUniform}}

Аргументация:

  • При добавлении регрессионной головы сеть лучше понимает пространство, аналогично подходу R-CNN.
  • Метки регрессии показывают смещение фигуры относительно идеальной позиции.
  • Это помогает улучшить качество распознавания положения объекта.
  • Для этого в backbone вынесен крупный полносвязный слой.

Вариант 2: С вниманием (ConvAttention / CBAM-модификация)

Архитектура:

{"input_dim": (100, 100, 1), "out_channels": 8, "layer": Conv2D, "learn_params": {"lr": 0.0001}, "bias": False},
{"layer": BatchNorm, "learn_params": {"lr": 0.0001}},
{"layer": Relu},
{"layer": Pooling, "pooling_shape": (2, 2), "pooling_func": "max"},

{"out_channels": 16, "layer": Conv2D, "lr": 0.0001, "bias": False},
{"layer": BatchNorm, "learn_params": {"lr": 0.0001}},
{"layer": Relu},

{"layer": ConvAttention, "learn_params": {"lr": 0.0005}, "mode": "Channel", "agg_mode": "GAP+GMP"},
{"out_channels": 1, "layer": Conv2D, "learn_params": {"lr": 0.0005}, "kernel_size": (1, 1), "bias": False},
{"layer": BatchNorm, "learn_params": {"lr": 0.0005}},
{"layer": Relu},

{"layer": ConvAttention, "learn_params": {"lr": 0.0005}, "mode": "Spatial", "agg_mode": "GAP"},

{"layer": Dense, 'neurons': 512, "learn_params": {"lr": 0.0005}, "bias": False},
{"layer": BatchNorm, "learn_params": {"lr": 0.0005}},
{"layer": Relu},

{"layer": Dense, 'neurons': 64, "learn_params": {"lr": 0.0005}, "bias": False},
{"layer": BatchNorm, "learn_params": {"lr": 0.0005}},
{"layer": Relu},

{"layer": Dense, 'neurons': 8, "learn_params": {"lr": 0.0005}},
{"layer": Relu},

{"layer": Dense, 'neurons': 1, "learn_params": {"lr": 0.0005}, "init_dict": {"init_cls": XavierUniform}}

Аргументация:

  • Используется модификация CBAM (Channel + Spatial Attention).
  • ChannelAttention ставится перед свёрткой 1×1×1, чтобы сделать сжатие каналов "умным".
  • SpatialAttention применяется после, так как внутри использует агрегации по каналам (B, H, W, 2) через GAP+GMP.
  • В нашем случае достаточно "agg_mode": "GAP", так как канал всего один — быстрее и проще.
  • Такое расположение модулей повышает точность при минимальной потере скорости.

Замечания по реализациям

  • DropOut и другие методы регуляризации не использовались, поскольку не давали улучшения метрик на тестовых выборках.
  • Переобучения не наблюдалось.
  • BatchNorm используется вместо LayerNorm, поскольку:
    • он быстрее при inference (из-за использования скользящих статистик);
    • bias отключён, так как BatchNorm уже выполняет его роль.

Производительность сетей

После измерения времени forward всех моделей были получены следующие результаты:

Сеть Время, мс
online_agent 3.5
game_over_net 1.5
success_net 4.18

⚙️ Поскольку success_net используется только после нажатия кнопки, её время не включается в расчёт основного цикла.

Общий пайплайн:

4.18 (online_agent) + 1.5 (game_over_net) + 1.2 (предобработка данных) = 6.88 мс

Это меньше установленного порога в 10 мс, что удовлетворяет требованиям к производительности.


Онлайн обучение

Файл online.py состоит из двух главных функций:

  • training() — отвечает за подготовку данных и обучение;
  • cycle() — реализует основной цикл взаимодействия с игрой.

1. Функция training

При запуске training выполняется:

  1. Загрузка в память базового набора данных,
    на котором обучалась online_agent, но без аугментаций.
    Если ранее уже выполнялся сбор данных, загружается последняя сохранённая версия.

  2. Запускается цикл сбора новых данных и дообучения онлайн-агента.

  3. После сбора формируются массивы:
    X, y_cls, y_reg.
    Они конкатенируются с предыдущими сохранёнными данными для обновления.

  4. Затем применяется функция: mix_new_old(x_, y_, X, y_cls)

где (x_, y_) — ранее сохранённые данные. Это нужно, чтобы избежать катастрофического забывания старого опыта. В моём случае данные перемешиваются 50/50.


Контроль дисбаланса классов

После каждого цикла проверяется, достигла ли длина собранных данных 95% от целевого объёма. Если нет, применяется:

  • online_thr = 0.98 — повышенный порог уверенности;
  • использование мягких меток (soft targets): (0.99, 0.01).

Это сделано, чтобы:

  • модель дольше ошибалась, тем самым собирая более информативные данные;
  • уменьшить дисбаланс классов, так как класс 1 (идеальное попадание) встречается реже.

Почему это важно

Каждый игровой цикл содержит около 90 кадров:

  • при нормальной игре классы 0 и 1 примерно делятся 50/50;
  • если агент начинает «слишком хорошо» играть, цикл завершается раньше, и класс 1 встречается всего 1–2 раза, а 0 — около 45;
  • иногда модель кликает слишком рано, из-за чего цикл вообще лишён кадров класса 1.

Поэтому первые 95% данных собираются с повышенным порогом thr и мягкими метками. После достижения 95%:

  • возвращается исходный порог (тот, что использовался в исходной модели);
  • soft_targets больше не применяются.

Настройки дообучения

  • Дообучение проводится в 2 эпохи.
  • Размер батча — 32, чтобы усилить стохастичность обучения.
  • Обновления параметров происходят постепенно, без резких скачков.

2. Функция cycle

Это самая сложная часть системы.

Инициализация и тайминг

  • Идеальный базовый тайминг выбран равным 0.775 с. Это значение учитывает небольшие задержки и не нарушает точность.
  • С учётом задержки эмулятора и игры, реальный тайминг ≈ 0.788 с.

Для точного ожидания реализована функция precise_sleep(), которая большую часть времени ждёт через time.sleep(), а последние 5 мс дожидается в активном цикле — чтобы избежать неточностей из-за планировщика ОС.

Основной цикл

  1. Цикл начинается с предварительной задержки 34 мс — это задержка между нажатием и фактическим срабатыванием клика в игре.

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

  3. Вводится переменная passive_time — период после начала итерации, в течение которого сеть не кликает, а просто собирает данные.

    Зачем нужен passive_time

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

Захват и обработка кадров

  • Скриншоты получаются через:

    sct.grab(monitor)

  • Далее кадр обрабатывается функцией img_prepare_online_:

    • создаются две копии: одна для CPU (сохранение), вторая для GPU (инференс);
    • производится обрезка и нормализация.

Логика game_over_net

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

Условия входа в блок game_over_net:

  • сеть предсказала класс 1 (игра окончена);
  • текущее время цикла превысило 20 итераций (чтобы не копить ошибки);
  • тайминг текущей итерации больше 1.45 (чтобы не зависнуть в ожидании).

Если одно из условий выполнено — данные сохраняются и начинается новая итерация.


Логика online_agent

  • Если текущее время меньше passive_time — сеть пропускает шаг.
  • Если больше — выполняется клик и начинается новая итерация.

После клика:

  • ожидается 0.034 с, чтобы эмулятор успел обработать событие;
  • затем запускается функция is_ideal(), которая анализирует 5 последовательных кадров и определяет, было ли идеальное попадание (класс 1).

Это сделано, потому что иногда подсветка слишком слабая, а буфер из 5 кадров гарантирует, что идеальное попадание не будет пропущено.


Пересчёт идеального тайминга

Если идеального попадания не было, выполняется пересчёт нового тайминга по формуле:

new_ideal_timing = ideal_time + (current_time - ideal_time - 0.013) / 2

Где:

  • 0.013 = 0.788 - 0.775 — разница между идеальным таймингом и реальным;
  • эта поправка нужна, чтобы сохранить запас в 13 мс на возможные лаги и неточности.

Пример сравнения

ideal_time = 0.775
current_time = 0.9

classic_1 = ideal_time + (current_time - ideal_time) / 2
corrected_1 = ideal_time + (current_time - ideal_time - 0.013) / 2
print(f"{classic_1=}")
print(f"{corrected_1=}", "\n")

ideal_time = 0.788
current_time = 0.9
classic_2 = ideal_time + (current_time - ideal_time) / 2
print(f"{classic_2=}")
print(f"{classic_2 - classic_1=}, {classic_2- corrected_1=}")

Таким образом создаётся «подушка безопасности» в 13 мс для компенсации погрешностей и input lag.


Дополнительная логика цикла

  • После каждого цикла меняется флаг стороны (rightleft).

  • Рассчитывается задержка sleep:

    np.abs((ideal_timing_right - 0.775) / 1.35) + np.abs((ideal_timing_left - 0.775) / 1.35)

    Это компенсирует ситуации, когда плитки появляются за экраном и нужно подождать перед записью кадров.

    • Вот тут наглядно:

      Без корректировки С корректировкой
      1 2
  • Параметр sleep добавляется также к passive_time.


Результат

Для запуска онлайн-сбора данных достаточно запустить:

python online.py

при включённом эмуляторе. Процесс полностью автоматизирован.


Финальное обучение и тестирование

После онлайн-сбора данных часто наблюдается разбалансировка классов.
Поскольку используется бинарная классификация, pos_weight в BCELogits применять не обязательно.
Эксперименты показали, что это не влияет существенно на результат.


Подготовка данных

  1. Рекомендуется запустить скрипт del_duplicates.py для удаления возможных дубликатов.
  2. После этого запускается nets_train.py с флагом final,
    чтобы обучился final_agent python nets_train.py final .
  3. Модель сохраняется в ту же папку, что и предыдущие сети,
    а также обновляется файл nets_thresholds.json с новыми порогами.

Тестирование

  • Файл для теста — nn_test.py.
  • Цель: проверить работу модели после финального обучения.
  • В ходе теста сеть достигала 500+ очков без проблем.

Проверка «человечности» сети

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

Тест стабильности final_agent

Вывод:

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

Заключение по архитектурам

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

Итог:

  1. Сети online_agent, game_over_net и success_net настроены и оптимизированы для online сбора данных.
  2. Цикл online.py обеспечивает автоматизированное обучение и сбор данных с минимальной погрешностью.
  3. Финальное обучение final_agent стабильно показывает высокие результаты, даже при добавлении ошибок во время игры.
  4. Архитектуры сетей и методы обучения продуманы с учётом ограничений по времени, дисбаланса классов и особенностей игры.