Целью проекта является создание модели, способной приблизиться или превзойти человеческий уровень игры Stack.
Для этого определены два ключевых критерия:
-
При классическом старте — набрать не менее 100 очков.
-
При случайном старте — набрать не менее 30 очков.
Второй пункт необходим для проверки обобщающей способности модели: она должна действовать не как детерминированный автокликер с фиксированными таймингами, а как игрок, способный адаптироваться к разным сценариям. Оценивать модель я собираюсь именно по внутриигровым очкам.
Вместо классического обучения с подкреплением (Reinforcement Learning) я применил обучение с учителем (Supervised
Learning).
Такой выбор был сделан по следующим причинам:
- Мне удалось эмпирически вывести формулу для автоматической разметки данных.
- Автокликер способен решать задачу идеально. Следовательно, если соединить автокликер с системой разметки данных, можно создать почти идеальную обучающую выборку.
- Метод обучения с учителем в этом контексте оказывается быстрее и точнее, чем RL, особенно на ранних этапах.
Также я решил собирать не только бинарные метки (для классификации), но и регрессионные метки, чтобы реализовать двуголовую архитектуру нейросети. Это, в теории, улучшает обобщающую способность, если базовая модель недостаточно стабильна.
Скин Platinum выбран за счет его нейтральности и неизменности цвета, что улучшает стабильность при обучении и инференсе.
- Сбор базовых данных при помощи небольших скриптов, их аугментация и первичное обучение моделей для онлайн-режима.
- Онлайн-сбор расширенных данных с использованием обученных моделей и формулы разметки.
- Финальное обучение основной сети на обновлённой и более детализированной выборке.
Дополнительно для корректного сбора данных в реальном времени были разработаны две вспомогательные модели:
- Сеть для классификации конца игры.
- Сеть для классификации идеального попадания.
Обе они играют важную роль при онлайн-разметке и обеспечивают точность обучения.
| Раздел | Назначение |
|---|---|
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/ |
Содержит данные для обучения сетей |
Сначала необходимо было собрать достаточно разнообразные данные для обучения.
Ручной сбор и последующая разметка оказались бы крайне долгими и трудозатратными, поэтому я разработал *
автоматизированную систему*.
Общий алгоритм выглядел так:
- Сначала собрать базовые случаи при помощи скриптов.
- Провести аугментацию данных и обучить простые сети для онлайн-сбора.
- Используя онлайн-модели и формулу разметки, собрать расширенные данные.
- Обучить на них финальную модель.
Скрипт data_prepare.py выполняет основную работу:
-
Настройка окна игры: изменение разрешения, позиции, управление кликами и запись кадров.
-
Определение таймингов:
- Идеальный первый тайминг — около 0.85 сек, затем сразу снижается до 0.82 сек.
- Важно учесть задержку между кликом и реакцией игры, которая составила ≈ 32 мс (2 кадра при 60 FPS).
- Также добавлена поправка на задержку модели и возможные input lag — ещё ≈ 13 мс.
- В итоге идеальные тайминги вычисляются с поправкой в ≈45 мс.
- Для дискретных кадров (60 FPS) это соответствует смещению на 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 очков 

-
data_union_gameover()— объединение и разметка изображений конца/неконца игры.
-
Скрипт data_augmentation.py аугментирует данные. Там не будет аугментации success_collect, так как там не сложно
набрать много данных, а аугментация при моих наблюдениях для этой задачи не очень хорошо подходит.
Запускаем nets_train.py.
Внутри проходит обучение трёх моделей: online_agent, game_over_net и success_net.
Важно: во время обучения в сетях нет финальной сигмоиды для классификации.
Для стабильности использовалась функция потерь BCEWithLogitsLoss() (в кодеBCELogits()).
После обучения внутренняя функция автоматически добавляет сигмоиду и сохраняет модели.
При тестировании выяснилось, что при использовании 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 мс.
Архитектура:
{"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хорошо выражена — при конце игры появляются светлые элементы интерфейса. - Модель фактически вычисляет «среднюю яркость», поэтому задача простая и риск переобучения минимален.
Архитектура:
{"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.
Назначение:
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-блок по сравнению с основным агентом из-за присутствия большего количества каналов.
Архитектура 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 вынесен крупный полносвязный слой.
Архитектура:
{"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() — реализует основной цикл взаимодействия с игрой.
При запуске training выполняется:
-
Загрузка в память базового набора данных,
на котором обучаласьonline_agent, но без аугментаций.
Если ранее уже выполнялся сбор данных, загружается последняя сохранённая версия. -
Запускается цикл сбора новых данных и дообучения онлайн-агента.
-
После сбора формируются массивы:
X,y_cls,y_reg.
Они конкатенируются с предыдущими сохранёнными данными для обновления. -
Затем применяется функция:
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, чтобы усилить стохастичность обучения.
- Обновления параметров происходят постепенно, без резких скачков.
Это самая сложная часть системы.
- Идеальный базовый тайминг выбран равным 0.775 с. Это значение учитывает небольшие задержки и не нарушает точность.
- С учётом задержки эмулятора и игры, реальный тайминг ≈ 0.788 с.
Для точного ожидания реализована функция precise_sleep(),
которая большую часть времени ждёт через time.sleep(),
а последние 5 мс дожидается в активном цикле —
чтобы избежать неточностей из-за планировщика ОС.
-
Цикл начинается с предварительной задержки 34 мс — это задержка между нажатием и фактическим срабатыванием клика в игре.
-
После этого запускается отсчёт времени, но захват кадров начинается только через 6 мс, чтобы исключить некорректные изображения.
-
Вводится переменная
passive_time— период после начала итерации, в течение которого сеть не кликает, а просто собирает данные.- Если кликнуть слишком рано, результат будет либо промах, либо неидеальное попадание.
- После промаха экран конца игры появляется через 75 кадров, если итерация < 11. За это время может накопиться шум в данных.
- Поэтому при таких условиях последние 75 кадров обрезаются, чтобы избежать неправильных меток.
-
Скриншоты получаются через:
sct.grab(monitor) -
Далее кадр обрабатывается функцией
img_prepare_online_:- создаются две копии: одна для CPU (сохранение), вторая для GPU (инференс);
- производится обрезка и нормализация.
Перед подачей данных в другие модели проверяется, не закончена ли игра.
Условия входа в блок game_over_net:
- сеть предсказала класс
1(игра окончена); - текущее время цикла превысило 20 итераций (чтобы не копить ошибки);
- тайминг текущей итерации больше 1.45 (чтобы не зависнуть в ожидании).
Если одно из условий выполнено — данные сохраняются и начинается новая итерация.
- Если текущее время меньше
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.
-
После каждого цикла меняется флаг стороны (
right↔left). -
Рассчитывается задержка
sleep:np.abs((ideal_timing_right - 0.775) / 1.35) + np.abs((ideal_timing_left - 0.775) / 1.35)Это компенсирует ситуации, когда плитки появляются за экраном и нужно подождать перед записью кадров.
-
Параметр
sleepдобавляется также кpassive_time.
Для запуска онлайн-сбора данных достаточно запустить:
python online.pyпри включённом эмуляторе. Процесс полностью автоматизирован.
После онлайн-сбора данных часто наблюдается разбалансировка классов.
Поскольку используется бинарная классификация, pos_weight в BCELogits применять не обязательно.
Эксперименты показали, что это не влияет существенно на результат.
- Рекомендуется запустить скрипт
del_duplicates.pyдля удаления возможных дубликатов. - После этого запускается
nets_train.pyс флагомfinal,
чтобы обучился final_agentpython nets_train.py final. - Модель сохраняется в ту же папку, что и предыдущие сети,
а также обновляется файлnets_thresholds.jsonс новыми порогами.
- Файл для теста —
nn_test.py. - Цель: проверить работу модели после финального обучения.
- В ходе теста сеть достигала 500+ очков без проблем.
- Для оценки способности модели импровизировать добавлялись ошибки в начале цикла.
- Результат: первые шесть плиток намеренно поставлены с ошибкой,
но сеть смогла восстановить идеальный размер плитки, по внутриигровой механике и продолжить игру корректно.
Вывод:
- Даже после ошибок сеть удерживает высокий уровень точности.
- Задача решается не просто автокликером, а полноценным агентом с обучением.
- Простая базовая архитектура уже показывает отличные метрики.
- Нет необходимости обучать запасные архитектуры, кроме как для экспериментов с F1 или loss.
- Все цели исследования достигнуты, вопросы по работе сетей рассмотрены.
Итог:
- Сети
online_agent,game_over_netиsuccess_netнастроены и оптимизированы для online сбора данных. - Цикл
online.pyобеспечивает автоматизированное обучение и сбор данных с минимальной погрешностью. - Финальное обучение
final_agentстабильно показывает высокие результаты, даже при добавлении ошибок во время игры. - Архитектуры сетей и методы обучения продуманы с учётом ограничений по времени, дисбаланса классов и особенностей игры.


