Просто о сложном: нейросети
Введение
В этой статье я хочу простыми словами объяснить практическое применение нейронных сетей для решения конкретных задач. Важно отметить, что мы не будем подробно разбирать, как устроены нейросети изнутри – об этом уже написано множество материалов. Вместо этого сосредоточимся на том, как применить нейросеть к конкретной задаче, как подобрать под неё данные и параметры. Мы не будем использовать готовые библиотеки машинного обучения – весь функционал реализован самостоятельно, чтобы наглядно разобраться, как можно написать нейросеть под свою задачу. Первое, с чего начнём: нейросеть имеет смысл применять только там, где действительно существуют закономерности в данных. Простой пример – домашний питомец, услышав будильник утром, с большой вероятностью понимает, что скоро получит свежую еду. Это примитивная закономерность (звук будильника → завтрак). Но бывают и очень сложные закономерности, которые не лежат на поверхности. То, что мы называем интуицией, по сути является распознаванием подобных скрытых закономерностей нашим мозгом. Итак, если в вашей задаче нет никаких паттернов или повторяющихся зависимостей, нейросеть не поможет – она просто будет гадать наугад. Если же вы предполагаете наличие закономерностей, можно попытаться их выявить с помощью обучения сети. Правда, будьте готовы к ситуации: если результат плохой, непонятно, то ли закономерностей нет, то ли вы неправильно обучили модель. В этой статье на конкретном примере мы рассмотрим весь путь: от зарождения идеи до реализации и обучения нейросети, а также разберём сложности, с которыми можно столкнуться. Примером послужит задача прогнозирования исхода спортивного события – будем пытаться угадать, выиграет ли первая команда первую четверть баскетбольного матча по ходу игры, используя нейросеть. Это своего рода модель для ставок на спорт, но сразу подчеркну: цель исключительно научная, а не научиться обыгрывать букмекеров (позже станет ясно почему).
Постановка задачи: нейросеть для ставок на спорт

https://github.com/vladimirsolovyovjob/neurobet-analyzer/tree/main

Идея создать такую нейросеть появилась после знакомства с одним профессиональным игроком, который успешно предсказывал результаты матчей. На YouTube есть канал «Игровые», где автор демонстрирует умение угадывать исходы футбольных матчей небольших лиг, опираясь на свои наблюдения. Я лично знаком с человеком, который на основе многолетнего просмотра матчей научился замечать определённые паттерны в ходе игры и благодаря этому делал точные прогнозы. Появилась мысль: а можно ли доверить поиск таких скрытых закономерностей нейросети? Я решил сосредоточиться на баскетболе (конкретно – прогнозировать исход первой четверти матча). В баскетболе за короткий период происходит много событий, и мне встретился аналогичный успешный прогнозист по баскетболу: он объяснял часть своей логики и даже вёл закрытое сообщество по ставкам. Из его пояснений я вынес, что закономерности действительно есть, но они крайне сложные. Для человека, который сам не играл профессионально, уловить эти нюансы практически нереально. Однако именно для этого и существует нейросеть – выявлять сложные зависимости. Если обучить её на большом количестве игр, она может подметить те тонкие сигналы, которые мы пропускаем. Что именно будем прогнозировать? Одно конкретное событие: победит ли первая команда в первой четверти (что эквивалентно поражению второй, то есть двоичный исход). Таким образом, задача – в процессе течения 1-й четверти в какой-то момент времени решить: «стоит ставить на победу первой команды или нет?». После завершения четверти мы точно узнаем, выиграла первая команда или нет – это и будет правильный ответ для обучения. Теперь главное – какие данные (факторы) использовать в качестве входных для нейросети, чтобы она могла сделать вывод.
Выбор входных факторов
Посмотрев упомянутого прогнозиста, я сделал вывод, что ключевыми факторами при решении, ставить или не ставить, являются:
Текущее время четверти (какая идёт минута, поскольку четверть длится 10 минут). Очевидно, поведение команд в начале и в конце четверти отличается, и оставшееся время влияет на шансы отыграть отставание.
Счёт первой команды на данный момент (сколько очков набрала команда №1).
Счёт второй команды на данный момент (очки команды №2).
Текущий тотал четверти, предлагаемый букмекером – то есть прогнозируемое суммарное количество очков в четверти по версии букмекера. Тотал отражает ожидания относительно результативности: высокое значение тотала обычно означает быстрый темп игры и много очков. Я добавил этот параметр, предполагая, что он косвенно указывает на риски для ставки: по тоталу можно судить, чего ждёт букмекер, и использовать это как ещё одну подсказку для нейросети.
Таким образом, на каждый момент времени в четверти у нас есть набор этих четырёх признаков. Но нейросети работают с числами (чаще нормированными или бинарными), поэтому нужно закодировать эти факторы численно. Я решил представить каждый фактор в бинарном виде – фактически превратить каждое значение в набор бит (0 или 1) в большом входном векторе фиксированной длины.
Кодирование данных для нейросети

Самый сложный этап – правильно представить данные для подачи в нейросеть. Я выбрал бинарное представление, где каждый входной нейрон соответствует определённому признаку или значению. Всего у меня получилось 299 входных нейронов (то есть размер входного вектора – 299 бит). Давайте разберём, откуда взялось такое число и что оно значит:
Время четверти: я дискретизировал время с шагом 0,5 минуты (30 секунд). Четверть 10 минут даёт 20 интервалов по полминуты. Для каждого такого интервала завёл отдельный нейрон, значение которого = 1, если текущая минута попадает в этот интервал, и 0 для остальных. Таким образом, текущее время представлено «скользящим» единичным битом среди, условно, 20 возможных положений. Например, если сейчас 7 минут 30 секунд от начала четверти, то нейрон, отвечающий за интервал 7,0–7,5 минуты, получит значение 1, а все прочие нейроны времени – 0. Это похоже на one-hot кодирование времени. (Почему полминуты? Так сеть точнее учитывает, начало это седьмой минуты или её середина – прогнозист упоминал, что каждые секунды могут играть роль.)
Очки первой команды: количество очков команды №1 к текущему моменту я закодировал тоже набором бинарных признаков. В теории счёт может достигать довольно большого числа, но в пределах одной четверти практические значения ограничены (обычно не более 30–40 очков). Для надёжности я зарезервировал под счёт 100 возможных значений, от 0 до 99. По аналогии с временем, можно использовать либо позиционное кодирование (100 нейронов, у одного из них значение 1, соответствующее текущему числу очков, остальные 0), либо двоичное представление числа. Я выбрал позиционное (one-hot) кодирование для простоты: например, если у команды 1 сейчас 18 очков, то 18-й нейрон из этой группы будет активирован (=1), остальные 99 нейронов счета будут 0.
Очки второй команды: аналогично, ещё 100 нейронов отведено под счёт команды №2, кодирование такое же.
Тотал четверти от букмекера: тотал в лайве тоже меняется с течением игры. Я заметил, что обычно диапазон тоталов для четверти ограничен (скажем, от ~20 до ~60 очков). Я отвёл под тотал оставшиеся нейроны. Например, если нейросеть учитывает 80 возможных значений тотала, то из 80 нейронов тотала будет активен один (соответствующий текущему тоталу), остальные — нули. Предположим, сейчас тотал на четверть равен 45.5 очкам; я округлял или приводил к целому числу и затем выставлял 1 на соответствующем нейроне тотала.
Совокупно эти блоки признаков и составляют бинарный вход длиной 299. Каждый входной нейрон – это фактически ответ на вопрос типа «Равно ли текущее значение X определённому значению?» (где X – время, счёт или тотал). На один такой момент времени (шаг) формируется строка из 299 нулей и единиц, оканчивающаяся ещё одним значением – целевым признаком. Целевой признак – это правильный ответ: 1, если первая команда в итоге выиграла 1-ю четверть, или 0, если не выиграла. Именно на этих примерах сеть и обучается.
Для обучения мне потребовалось собрать много таких строк.
Я разработал парсер для букмекерского сайта (в частности, 1xСтавка) – задача оказалась нетривиальной, потому что прямого открытого API не было. Пришлось через инструменты разработчика в браузере изучить, как формируются запросы и ответы при обновлении коэффициентов. Выяснилось, что данные можно получать по специальным URL с идентификаторами матчей и рынков. В итоге я настроил программу, которая автоматически перебирала нужные ссылки и собирала данные по всем матчам (первым четвертям) за определённый период. За месяц набрался солидный объем: для каждой игры – несколько временных срезов с указанием счета, тотала и финального исхода четверти. Некоторые записи приходилось отсеивать (например, если данные были неполными или ошибочными), но в итоге у меня сформировался большой обучающий набор.
Структура нейросети
resArr[tot1] = 1; // индексы 0–49
resArr[tot2 + 50] = 1; // индексы 50–99
resArr[(int)(rTot * 2 + 100)] = 1; // индексы 100–288 → это 189 ячеек!
resArr[minut + 289] = 1; // индексы 290–298
resArr[299] = (byte)(res < rTot ? 1 : 0); // финальный результат
Продолжение в моем соседнем посте....

Упрощённая диаграмма искусственной нейросети с входным слоем (зелёные нейроны), одним скрытым слоем (синие нейроны) и выходным нейроном (жёлтый). Стрелки обозначают взвешенные связи между нейронами. С данными разобрались – перейдём к самой нейросети. Мною реализована двухслойная нейронная сеть прямого распространения: есть входной слой нейронов, один скрытый слой (также называемый промежуточным или hidden layer) и выходной нейрон. Все нейроны соседних слоёв полностью соединены между собой (каждая входная переменная связана с каждым нейроном скрытого слоя, а каждый нейрон скрытого слоя – с выходом). Такая архитектура – минимально достаточная для решения многих задач. Известно, что даже сеть с одним скрытым слоем способна аппроксимировать любую достаточную сложную функцию при правильных весах (теорема Цыбенко–Хехт-Нильсена).В моём случае выбор был продиктован и практическими соображениями – добавить ещё один скрытый слой означало бы значительно усложнить подбор параметров и увеличить время обучения, поэтому я остановился на одном. Размеры слоёв. Входной слой имеет столько нейронов, сколько признаков мы ему подаём – в нашем случае 299 входных нейронов. Скрытый слой я, для простоты, сделал того же размера – 299 нейронов (то есть столько же, сколько входов, хотя это не обязательное требование). Почему именно столько? Признаться, точного метода расчёта оптимального числа скрытых нейронов у меня не было – в литературе нет однозначной формулы для этого, обычно советуют экспериментировать. Я решил взять число порядка количества входных параметров (если паттерны сложные, скрытых нейронов нужно не меньше, чем входных, чтобы они могли эти паттерны перекодировать). В итоге скрытый слой = 299. Выходной нейрон один, так как мы решаем бинарную классификацию: выдаём либо 1 (ставим на команду), либо 0 (отказываемся от ставки). На выходе сети используется порог 0.5 для принятия решения: если выходное значение > 0.5, трактуем как «ставим», иначе – «не ставим». Стоит подчеркнуть: скрытый слой обязателен.Если бы его не было (то есть выход напрямую считался бы на основе линейной комбинации входов), наша модель была бы линейным классификатором, который не смог бы уловить сложные нелинейные зависимости.

Скрытые нейроны применяют нелинейную функцию активации, благодаря чему сеть способна строить сложные решающие поверхности. В нашем случае в качестве активации использована сигмоидальная функция: f(x) = 1 / (1 + e^(-x)). Сигмоида получает на вход взвешенную сумму сигналов с предыдущего слоя и выдаёт значение от 0 до 1 – это и есть «активность» нейрона. Инициализация весов. Перед обучением все веса связей в сети нужно задать случайными небольшими значениями. Весов очень много (каждый из 299 входов соединяется с 299 скрытыми нейронами, и каждый скрытый – с выходом). В моём коде веса генерируются равномерно в диапазоне −?;+?−W;+W, где W – некоторый начальный предел. Предел этот я вводил как параметр: для связей вход→скрытый задал переменную initialInputWeight (она определяет разброс начальных весов первых связей), а для связей скрытый→выход – параметр initialHiddenWeight. Оба этих параметра мы будем подбирать экспериментально. Для воспроизводимости результатов я зафиксировал зерно генератора случайных чисел (Random(123)), чтобы при одинаковых параметрах и данных сеть всегда стартовала с одинаковых случайных весов – так удобнее сравнивать эксперименты. Обучение нейросети. Я реализовал классический алгоритм обратного распространения ошибки (backpropagation). Он устроен так: мы подаём сети строку обучающих данных (299 входов) и считаем выход. Затем сравниваем выход с правильным ответом (целевым значением 0 или 1). Разница – это ошибка. Дальше итеративно корректируем веса: немного изменяем каждый вес так, чтобы уменьшить ошибку на этом примере. Формулы основаны на производной сигмоиды (в коде видно вычисление дельт для выходного и скрытого слоёв). Процесс повторяется для множества примеров много раз – за счёт итерационного процесса сеть постепенно настраивает веса так, чтобы правильно среагировать на паттерны в данных.Подбор гиперпараметров и длительное обучениеУ алгоритма обучения есть свои настройки – их называют гиперпараметры. В нашем случае ключевыми были:Начальный разброс весов вход→скрытый (W_in) – чем выше, тем более разношёрстные значения стартовых весов между входными и скрытыми нейронами.Начальный разброс весов скрытый→выход (W_out) – аналогично, диапазон начальных весов второго слоя.Скорость обучения (learning rate) – обозначим η, это коэффициент, определяющий, насколько сильно менять веса на каждом шаге обучения.

Слишком большой η — сеть будет «скакать» мимо оптимума, слишком маленький — обучение пойдёт очень медленно.Признаюсь, точного теоретического понимания оптимальных значений этих параметров под мою задачу у меня не было. Поэтому я пошёл по пути перебора комбинаций. Я написал обёртку над нейросетью, которая прогоняла обучение сети на различных сочетаниях параметров и измеряла точность на отложенной выборке данных.То есть сеть для каждой комбинации W_in, W_out, η обучалась, затем проверялась, насколько процент правильно предсказанных исходов она выдаёт, и этот процент фиксировался. Были выбраны следующие наборы значений для перебора:W_in (веса вход→скрытый): {0.001, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9} – 10 вариантов от совсем крошечных весов до умеренно крупных.W_out (веса скрытый→выход): аналогично, {0.001, 0.1, 0.2, ..., 0.9} – тоже 10 вариантов.η (скорость обучения): {0.1, 0.09, 0.08, 0.07, 0.06, 0.05, 0.04, 0.03, 0.02, 0.01} – 10 вариантов, постепенно уменьшающиеся.В итоге возможных комбинаций порядка 10 × 10 × 10 = 1000 вариантов (даже чуть больше, поскольку на самом деле я добавлял еще одно значение η = 0.1, но суть в том, что счёт шёл на тысячи). Каждую такую комбинацию приходилось обучать на всём датасете. Я установил число эпох (итераций обучения) равным 100 для первичного перебора. Даже на современном компьютере одна такая тренировка занимала ощутимое время, а ведь нужно тысячу раз это повторить! В сумме перебор занял больше месяца непрерывной работы – я запускал процесс на сервере и ждал результатов. Чтобы не пропустить удачные случаи, программа следила за прогрессом: после каждой комбинации я вычислял точность (accuracy) – процент правильных прогнозов на контрольной выборке. Если точность превышала определённый порог (скажем, 62%), программа логировала эту комбинацию как потенциально успешную.Также я настроил уведомления, чтобы узнать, если внезапно какая-то комбинация даст очень высокий результат (например, >70%). Таким образом, не дожидаясь конца всего перебора, я собирал список лучших найденных вариантов параметров. Когда перебор грубым шагом завершился, я проанализировал результаты. Лучшая комбинация дала точность ~62,8%. Казалось бы, улучшение против случайных 50% не так уж велико (~12-13% сверху), но на фоне общего уровня (большинство параметров давали ~50-55%) это значимый успех. Я сохранил комбинацию параметров, давшую этот результат, и затем провёл тонкую настройку вокруг неё. Например, если лучшая η оказалась 0.05, имело смысл попробовать значения 0.045, 0.055 и т.д. (более мелкий шаг).

Результаты и ограничения
Итак, экспериментальная нейросеть научилась с ~62-63% точности предсказывать победителя четверти по ходу игры. Возникает резонный вопрос: можно ли на основе этого стабильно зарабатывать на ставках? Ведь 62,8% успешных прогнозов – это лучше, чем бросать монетку, а если букмекер даёт на равные исходы коэффициент около 1.8, такая проходимость должна давать прибыль. Действительно, простой расчет ожидаемой прибыли показывает плюс: при 62,8% выигрышных ставок с коэф. ~1.8 игрок получает положительный математический итог (ожидание > 1). Я на практике проверял эту систему – делал ставки по сигналам сети, и результаты были прибыльными.
Однако столкнулся с реальностью букмекерского бизнеса: как только аккаунт начинает выигрывать значимые суммы, букмекер вводит ограничения. Сначала снижают максимальную ставку на отдельный рынок (например, на ставки именно на четверти в баскетболе) или на аккаунт в целом. В моём случае несколько счетов в разных конторах были быстро порезаны – максимальная сумма ставки стала символической (на уровне 10 рублей), что делает продолжение игры бессмысленным
bookmaker-ratings.ru
. Некоторые букмекеры и вовсе могут отказать в выплате, если заподозрят нечестную игру. Таким образом, практического заработка из такой нейросети не получить – букмекеры попросту не дадут. Но наш интерес был научным. Мы подтвердили, что нейросеть смогла уловить реальные паттерны в динамике баскетбольной четверти, раз отличает победителей с достоверностью выше случайной. В статье мы рассмотрели, какие признаки для этого понадобились и как сеть строилась.
Код проекта (нейросеть и парсер данных) выложен на GitHub в открытом доступе – можно ознакомиться, хотя прошу учесть, что проект делался «для себя» и не претендует на образец чистоты кода. Тем не менее, он рабочий и отражает описанные идеи.
Сложные паттерны: опыт с финансовыми данными
После спортивного кейса я попробовал применить нейросети к ещё более сложной задаче – анализу биржевых графиков цен. Здесь речь про поиск закономерностей в движениях цен (например, акций или криптовалют) с помощью нейросети. Задача сложная: финансовые временные ряды очень шумные (много случайных колебаний), и паттерны там более скрытые. Тем не менее, я решил проверить одну гипотезу, связанную с техническим индикатором Bollinger Bands (полосы Боллинджера). Обычно Боллинджера строят по периоду ~20 – то есть используют последние 20 свечей (баров) цены

Я взял немножко больше – 22 свечи, думая, что примерно на таком окне может проявляться определённый паттерн (скажем, уход цены за границы полос и возврат). Что из этого вышло? Во-первых, резко выросла размерность задачи. Пришлось кодировать гораздо больше данных: каждая свеча содержит 4 параметра (цена открытия, максимальная и минимальная цены, цена закрытия). Я решил тоже перевести их в дискретные признаки. Для этого определил некоторый шаг процентного изменения (допустим, 1%) и для каждой из 4 цен свечи завёл множество бинарных нейронов, покрывающих диапазон возможных изменений с такой точностью. В итоге на одну свечу получалось порядка 400 нейронов ввода (грубо, по 100 на каждый из четырёх параметров свечи при шаге ~1%). Этого разрешения в 1% оказалось недостаточно – хотелось бы 0.1% для более точного описания формы свечи, но тогда число нейронов выросло бы в 10 раз (≈4000 на свечу). При 22 свечах на входе это совершенно нереально: даже с 1% шагом суммарно вышло 8800 входных нейронов (22 × 400). Плюс я добавлял ещё некоторые признаки, например индикаторы или производные, чтобы помочь сети. Архитектура сети аналогично имела один скрытый слой (тоже ~8800 нейронов) и выход. Мы пытались обучать эту громоздкую модель на исторических данных рынка, и столкнулись с тем, что обучение идёт предельно медленно, а результат не впечатляет. Максимальная точность, которую удалось выжать, была около 54%.
По сути чуть лучше случайного угадывания, но недостаточно, чтобы говорить о каком-то надёжном предсказании. Здесь всплыла проблема шума: финансы содержат много случайных движений, которые маскируют даже существующие паттерны. Наша модель, видимо, не смогла выделить сигналы из шумовой завесы, по крайней мере с тем объёмом данных и теми вычислительными ресурсами, что были. Даже перебор параметров здесь мало помог – пространство параметров слишком велико. Для ускорения я пробовал подключать вычисления на GPU. Существуют библиотеки (например, NVIDIA CUDA) для задействования графических карт в нейросетевых вычислениях. Использование GPU действительно дало прирост скорости обучения – по оценкам, в несколько раз. Однако и этого оказалось недостаточно, чтобы перебрать сколько-нибудь значимое число вариантов или эпох обучения для такой огромной сети. По сути, мой эксперимент показал границу: не все паттерны, которые теоретически есть, practically извлекаемы на малых мощностях.
Возможно, с помощью современных глубоких сетей, обученных на гигантских объемах биржевых данных, можно вытянуть и 60%, и 70% точности по таким задачам. Но это совсем другой уровень сложности, требующий отдельных исследований. Если у меня будет время и ресурсы, в будущем опишу подробнее этот опыт с финансовой нейросетью – там было много нюансов с нормировкой данных, выбором обучающей выборки (например, исключать ли периоды высокой волатильности как отдельные режимы рынка) и так далее.
Заключение
Подведём итог. Мы рассмотрели практический пример, как самодельная нейросеть может быть использована для поиска закономерностей в реальных данных. На примере ставок мы выяснили, как выбрать информативные признаки, как их закодировать для подачи в сеть, как выбрать архитектуру и настроить обучение. Важные выводы:
Нейросеть эффективна там, где есть скрытые закономерности. Если проблема сводится к перебору огромного числа комбинаций или учёту множества факторов, нейросеть может выявить зависимость, которая не поддаётся простым алгоритмам. Прямой перебор вариантов очень быстро становится неосуществимым – комбинаций слишком много. Например, выбор даже 5 оптимальных факторов из 50 даёт свыше 2,1 миллиона комбинаций, полный перебор которых невозможен за разумное время. Нейросети обходят эту проблему за счёт обучения – они адаптируют свои веса, фактически приближая сложную функцию зависимостей.
Правильная подготовка данных – половина успеха. В нашем случае пришлось тщательно продумать, как подать время матча, счёт, тотал в численном виде. От представления данных зависит, сумеет ли сеть что-то извлечь. Мы использовали бинарное one-hot кодирование для категориальных признаков (временные интервалы, возможные счета), что позволило нейросети получать чёткие сигналы, не размытые дополнительными масштабированиями.
Эксперименты и подбор параметров неизбежны. Не существует универсальной формулы, сколько делать нейронов в скрытом слое или какой брать коэффициент обучения. Мы перебирали порядка тысячи вариантов и затратили много часов вычислений, прежде чем нашли удачную комбинацию. Это нормальная практика в машинном обучении – подбор гиперпараметров часто делается перебором или более умными методами поиска.

Результат должен проверяться на практике, но учитывайте ограничения. Наша сеть показала положительный результат на исторических данных и даже давала прибыльные сигналы в реальных ставках, однако внешние факторы (политика букмекеров) сделали применение невозможным. В других задачах могут быть свои ограничения – например, на бирже модель может обгонять рынок не сильно дольше, чем длится ее обучение, потому что сам рынок меняется. Всегда оценивайте, как будет использоваться ваш предиктор и нет ли внештатных ситуаций (бизнес-правил, смены условий и т.д.), которые нивелируют успех модели.
В завершение отмечу: хотя наш пример был довольно узконаправленным, принципы подходят очень многим сферам. Нейросеть – универсальный инструмент, и её можно приспособить практически под любую задачу, где имеются данные и цель, которую можно сформулировать как функцию от этих данных. Будь то распознавание образов, управление устройствами или прогнозирование – важно правильно спроектировать входы и методично обучить сеть. Иногда препятствием могут стать вычислительные ресурсы, но современные технологии (например, GPU-ускорение, распределённые вычисления) позволяют обучать даже огромные модели – вопрос лишь в ценности результата. В нашем случае мы убедились, что маленькая самодельная нейросеть способна решить нетривиальную задачу лучше человека. Возможно, в следующий раз мы поговорим о другом эксперименте – например, о нейросетях в торговле, где предстоит побороться уже не только с шумом, но и с постоянно адаптирующимся рынком. Наука на месте не стоит, и инструменты вроде нейросетей открывают увлекательные возможности поиска скрытых знаний в массивах данных.
Спасибо за внимание! P.S.
Код проекта (нейросеть для четверти и скрипт парсера данных) доступен в репозитории на GitHub – желающие могут ознакомиться, покритиковать и, возможно, улучшить. Проект был написан два года назад, поэтому прошу снисхождения к стилю кода, но основные идеи там реализованы. Удачных экспериментов!

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