
Нейросети в трейдинге: Интеллектуальный конвейер прогнозов (Разреженная смесь экспертов)
Введение
Финансовые рынки — это сложная, хаотичная и высокочастотная система. Здесь не работают грубые приближения и средние значения. Каждая свеча, каждое движение — это результат множества факторов, от фундаментальных новостей до импульсной торговли. Именно поэтому для работы с рыночными временными рядами необходим особый подход: чувствительный к микродеталям, устойчивый к шуму и способный видеть структуру за хаосом.
Фреймворк Time-MoE предлагает именно такую архитектуру. Это не просто трансформер, адаптированный к временным рядам. Это целостная система, в которой каждый временной шаг анализируемой истории рассматривается как уникальный токен. Эти токены проходят через последовательность преобразований, сохраняя свою индивидуальность и временной контекст. Такой подход позволяет модели работать с данными высокой частоты и выявлять закономерности, недоступные при традиционной агрегации.
Первым этапом обработки становится слой эмбеддинга, в котором данные проходят через нелинейные преобразования. Это помогает уловить сложные зависимости между признаками, будь то взаимосвязь цены и объёма, направление индикаторов или сила последнего движения. Полученное скрытое представление становится базой для дальнейшего анализа.
Затем токены отправляются в серию блоков Transformer. Здесь модель оглядывается назад, формируя представление о текущем моменте на основе накопленного опыта. Для этого используется механизм внимания, при котором каждый токен сопоставляется с предыдущими, а значимость тех или иных элементов определяется не вручную, а самой моделью в ходе обучения. Это позволяет учитывать как краткосрочные импульсы, так и долгосрочные тренды.
Особое внимание в Time-MoE уделяется устойчивости к шуму. Для этого в архитектуру встроены механизмы нормализации, которые помогают сгладить случайные выбросы и усилить значимые сигналы. В результате, внимание не концентрируется на отдельных аномалиях, а распределяется более равномерно и осмысленно, что особенно важно в условиях волатильности и рыночных шумов.
Ключевым отличием Time-MoE от классических трансформеров является использование разреженной смеси экспертов (Mixture-of-Experts — MoE). В каждом блоке роутер выбирает лишь часть доступных экспертов, которые действительно участвуют в обработке текущего токена. Это решение позволяет резко снизить нагрузку на вычисления и масштабировать модель без экспоненциального роста ресурсов. Кроме того, в Time-MoE предусмотрен один общий эксперт, который всегда активен и обеспечивает устойчивость модели к ошибочным маршрутам.
Финальный этап — прогнозирование. Здесь модель формирует сразу несколько прогнозов на разные горизонты. Такой подход позволяет одновременно учитывать краткосрочные сигналы и долгосрочные тенденции, предоставляя трейдеру или аналитической системе широкий диапазон сценариев. При обучении модель учится на всех горизонтах сразу, что делает её более гибкой и приспособленной к меняющимся рыночным условиям.
Таким образом, Time-MoE — это:
- чувствительная к деталям модель, работающая с точечными токенами;
- устойчивая к шуму и выбросам за счёт нормализации внимания;
- масштабируемая архитектура с разреженным использованием экспертов;
- универсальный механизм прогнозирования на несколько горизонтов.
Авторская визуализация фреймворка Time-MoE представлена ниже.
Сегодня мы продолжим начатую ранее работу и сосредоточим внимание на ключевом элементе фреймворка Time-MoE — разреженной смеси экспертов (Sparse Mixture of Experts). Если в предыдущей части мы шаг за шагом строили основу модели, формируя токены и скрытые представления с помощью SwiGLU-эмбеддингов, то теперь настала очередь перейти к архитектурной изюминке, от которой во многом зависит эффективность и масштабируемость всей системы.
В данной статье мы подробно разберём, как организована работа группы экспертов, и каким образом распределяются вычисления. Мы не просто опишем теоретическую схему, а перейдём к реальной реализации разреженного MoE средствами MQL5, делая акцент на практические аспекты.
Проработка архитектуры
Прежде чем приступить к непосредственной реализации алгоритма разреженной смеси экспертов, давайте немного остановимся и порассуждаем. Как и прежде, мы остаёмся приверженцами идеи параллельной работы всех экспертов — подхода, который прекрасно ложится в парадигму массовых вычислений. Поэтому основную нагрузку, без лишних сомнений, перенесём в OpenCL-контекст. Однако, здесь возникает важный вопрос: каким именно образом организовать разреженную активацию экспертов, сохранив эффективность и обучаемость модели?
Сначала напомним ключевой момент. В оригинальной статье авторы Time-MoE предложили конструкцию экспертов, которая заметно отличается от классического блока FeedForward, знакомого нам по архитектуре Transformer. В частности, первый слой в экспертах заменён на SwiGLU-преобразование. Это изменение вполне обосновано: SwiGLU, как мы уже убедились ранее, даёт модели больше гибкости при обработке признаков и лучше захватывает нелинейные зависимости. К счастью, реализация этого слоя уже у нас на руках — в предыдущей статье мы разработали компонент CNeuronSwiGLUOCL. И теперь мы можем смело использовать его в качестве первой стадии нашей смеси экспертов, масштабировав количество фильтров на выходе по числу параллельных подмоделей.
Каждый фильтр обладает собственными обучаемыми параметрами. По сути, это и есть отдельная экспертная модель. Если сгруппировать выходы по количеству экспертов, мы воспроизведём нужную нам структуру: один вход — множество независимых экспертов, работающих параллельно. На втором этапе обработки мы можем применить мультиоконную свёртку, которая уже знакома нам по предыдущим работам. Такой слой поможет объединить информацию внутри каждого эксперта и подготовить её к агрегации.
До этого момента всё выглядит весьма логично. Но есть одна важная оговорка: в описанном алгоритме отсутствует главный элемент — разреженность. Да, мы можем перемножить выходы всех экспертов с маской активации и получить корректный результат. Однако, при этом все эксперты продолжают работать, пусть и приглушённо, что полностью обнуляет потенциальный выигрыш в производительности. Такая схема допустима в небольших моделях, но становится неэффективной при масштабировании: рост количества экспертов, увеличение размерностей, расширение глубины сети — всё это резко повышает нагрузку.
Именно здесь мы подходим к ключевому вопросу: в какой точке алгоритма и каким образом внедрять разреженность?
На первый взгляд может показаться, что оптимальным решением будет отключать неиспользуемых экспертов на всех уровнях — ведь это резко сокращает объём вычислений. Однако здесь кроется куда более тонкая и важная проблема. Дело в том, что сама идея MoE заключается в том, чтобы различные эксперты обучались на разных подзадачах. При этом, каждый развивает свою специализацию. Но кто определяет, какие эксперты активны в конкретной ситуации? Ответ — роутер, который на основе исходных данных выбирает подмножество моделей для активации.
Проблема возникает, если роутер слишком рано сосредотачивается только на небольшом наборе экспертов и начинает использовать их повсеместно — вне зависимости от контекста. Такая модель может показать хорошие результаты на первых этапах, но потеряет способность к адаптации, поскольку никогда не пробует альтернативные маршруты. Неиспользуемые эксперты в таком случае просто не получают шанс доказать свою эффективность в других сценариях. Возникает эффект локального комфорта: роутер знает только своих избранных и, не пробуя других, замыкается в одном и том же паттерне. Это ведёт к снижению разнообразия модели и к деградации её обобщающей способности.
Таким образом, наша цель — обучить роутер не просто выбирать экспертов, а адаптировать стратегию выбора в зависимости от характеристик исходных данных. Мы должны создать условия, в которых модель будет исследовать поведение других экспертов, даже если они изначально менее эффективны. Только так она сможет научиться более гибкому распределению задач, повышающему общую точность и устойчивость к рыночным изменениям.
В поисках баланса между вычислительной эффективностью и полнотой обучения было принято решение внедрить разреженное использование экспертов только на втором слое блока MoE, объединив этот слой с процессом агрегации результатов. Такой подход упрощает архитектуру, снижает вычислительную нагрузку и в то же время сохраняет необходимую гибкость для обучения маршрутизатора.
Ключевым моментом в этой конструкции становится механизм распространения градиента ошибки. Мы сознательно отказываемся от идеи прямого обучения неиспользуемых экспертов — ведь суть MoE как раз в том, чтобы специализировать разные эксперты под разные задачи. Однако, чтобы модель не зацикливалась на одном и том же наборе экспертов, мы направляем градиент на роутер, давая ему сигнал о возможной неэффективности текущего выбора. Это позволяет обучать сам алгоритм маршрутизации: в следующий раз он может активировать другой набор экспертов, если текущий дал неудачный прогноз.
Таким образом, даже если в одном конкретном случае были активированы всего два эксперта, роутер узнаёт о том, что могли быть выбраны и другие. Это не просто технический приём, а мощный инструмент адаптации, позволяющий модели постепенно изучать взаимосвязь между характером исходных данных и оптимальной конфигурацией активных экспертов.
Свертка с маскированными окнами
Основные подходы определены, и теперь, засучив рукава, мы переходим от теории к практике. На первом этапе реализации нам предстоит разработать ключевой компонент — объект второго слоя MoE, который отвечает за разреженную активацию экспертов и агрегацию их результатов. Именно он станет центральным звеном модели: через него будут проходить данные от всех экспертов, но в каждом конкретном проходе — активируются лишь немногие из них.
Выбор активных экспертов мы вынесли в отдельный модуль — роутер. Его задача — проанализировать исходные данные и сгенерировать маску активации, которая определяет, какие эксперты должны быть задействованы для каждого токена. Эта маска передаётся на вход данного объекта вместе с основным тензором анализируемых признаков, определяя конфигурацию активных вычислений на текущем шаге.
Важное допущение: все эксперты имеют одинаковую архитектуру и возвращают результаты фиксированной размерности. При этом, в каждый момент времени активно лишь ограниченное число моделей — обычно значительно меньше размерности выходного пространства. Остальные эксперты остаются в спящем режиме, не участвуют в вычислениях и не расходуют ресурсы.
Такой подход обеспечивает сразу два принципиальных преимущества:
- Существенное снижение вычислительной нагрузки — критично важно при масштабировании моделей и росте числа экспертов.
- Повышение специализации: каждый эксперт может сосредоточиться на изучении своего узкого подпространства задач. В результате модель формирует настоящие экспертные знания, но распределённые между участниками.
Основной объём вычислений мы вынесли в OpenCL-контекст, что позволяет максимально эффективно использовать параллельные вычисления на GPU и других совместимых устройствах. В центре нашего внимания — кернел прямого прохода второго слоя MoE, которое реализует разреженную активацию выбранных экспертов и агрегацию их результатов.
В параметрах кернела получаем веса всех экспертов, исходные данные, маску активации и несколько параметров, задающих структуру окон и размерности.
__kernel void FeedForwardMaskMultWinConv(__global const float *matrix_w, __global const float *matrix_i, __global const float *masks, __global float *matrix_o, const int inputs, const int window_in, const int windows_total, const int activation ) { const size_t u = get_global_id(0); const size_t w = get_global_id(1); const size_t v = get_global_id(2); const size_t units = get_global_size(0); const size_t window_out = get_global_size(1); const size_t variables = get_global_size(2);
Каждому вычислительному потоку соответствует конкретный элемент тензора результатов — по позиции в последовательности, элементу токена и переменной. Такая точечная адресация обеспечивает высокий уровень параллелизма.
Важно подчеркнуть, что мы заранее сделали допущение: число активных экспертов в каждом проходе значительно меньше размерности токена в тензоре результатов. Это ключевой момент, который определяет логику распределения задач внутри ядра. Мы разбиваем пространство задач по элементам токена, а активных экспертов перебираем циклично внутри самого кернела. Такой подход позволяет эффективно задействовать вычислительные ресурсы, при этом сохраняя разреженность активации экспертов.
В теле кернела мы идентифицируем поток операций в пространстве задач и определяем смещение в буферах данных до анализируемых элементов.
const int shift_in = u * window_in * windows_total; const int shift_in_var = v * units * window_in * windows_total; const int shift_out = (u + v * units) * window_out + w; const int shift_mask = (u + v * units) * windows_total; const int shift_weight = (v * window_out * windows_total + w) * (window_in + 1); const int step_weight = window_out * (window_in + 1);
Далее организуем систему циклов. Внешний проходит по всем экспертам и проверяет их статус через маску активации. Если эксперт не активен в данном окне, его вклад пропускается, что исключает ненужные вычисления и экономит ресурсы. Для активных экспертов осуществляется вычисление взвешенной суммы по исходным данным с добавлением смещения. Итоговые значения аккумулируются, после чего проходят через функцию активации.
float sum = 0; for(int w_in = 0; w_in < windows_total; w_in++) { float m = IsNaNOrInf(masks[shift_mask + w_in], 0); if(m < FLT_EPSILON) continue; const int shift_in_loc = shift_in + w_in * window_in; const int shift_weight_loc = shift_weight + w_in * step_weight; for(int i = 0; i < window_in; i++) if((shift_in_loc + i) < (inputs / variables)) sum += IsNaNOrInf(matrix_i[shift_in_var + shift_in_loc + i], 0) * matrix_w[shift_weight_loc + i] * m; sum += matrix_w[shift_weight_loc + window_in] * m; }
Обратите внимание, что в процессе агрегации мы не просто складываем выходы активных экспертов, а умножаем каждый из них на соответствующее значение маски. В случае бинарного маскирования, где маска содержит только нули и единицы, это не добавляет ценности. Однако такой подход оставляет за нами важную опцию: организовать взвешенную агрегацию. Если маска будет содержать не просто флаги, а реальные весовые коэффициенты, мы сможем контролировать вклад каждого эксперта в результат — вплоть до мягкого выбора и вероятностного роутинга.
Полученные значения проходят через функцию активации и сохраняется в буфере результатов.
matrix_o[shift_out] = Activation(sum, activation);
}
После реализации прямого прохода, где активные эксперты обрабатывают свои подпространства признаков и результаты взвешиваются маской, мы переходим ко второй важной фазе — обратному распространению градиента ошибки. На этом этапе нам необходимо аккуратно донести ошибку не только до исходных данных каждой активной модели, но и до самой маски активации, чтобы она могла корректироваться в процессе обучения.
Для решения этой задачи мы разработали OpenCL-кернел CalcHiddenGradientMaskMultWinConv, который обрабатывает всё это в рамках концепции параллельных вычислений.
__kernel void CalcHiddenGradientMaskMultWinConv(__global const float *matrix_w, __global const float *matrix_i, __global float *matrix_ig, __global const float *matrix_og, __global const float *masks, __global float *masks_g, const int outputs, const int window_in, const int window_out, const int activation ) { const size_t u = get_global_id(0); const size_t w_in = get_global_id(1); const size_t v = get_global_id(2); const size_t units = get_global_size(0); const size_t windows_total = get_global_size(1); const size_t variables = get_global_size(2);
Кернел принимает веса, исходные данные, градиенты ошибки на уровне результатов, маску, а также буферы для записи градиентов исходных данных и маски. Каждый поток операций на стороне OpenCL-контекста отвечает за отдельное сочетание токена, эксперта и переменной. И в теле кернела сразу идентифицируем поток операций по всем измерениям пространства задач. После чего определяем смещения в буферах данных.
const int shift_in = (u + v * units) * window_in * windows_total + w_in * window_in; const int shift_out = u * window_out; const int shift_out_var = v * units * window_out; const int shift_mask = (u + v * units) * windows_total + w_in; const int shift_weight = (v * window_out * windows_total + w_in * window_out) * (window_in + 1);
На первом этапе распределяем градиент ошибки до уровня исходных данных. Здесь работа начинается с проверки маски: если соответствующий эксперт на данном токене был неактивен, то в буфер градиентов исходных данных просто записываются нулевые значения и вычислительные ресурсы не расходуются впустую. Если же эксперт участвовал в вычислении — начинается распространение градиента.
const float m = IsNaNOrInf(masks[shift_mask], 0); for(int i = 0; i < window_in; i++) { float sum = 0; if(m >= FLT_EPSILON) { for(int out = 0; out < window_out; out++) { if((shift_out + out) >= (outputs / variables)) continue; sum += IsNaNOrInf(matrix_og[shift_out_var + shift_out + out] * matrix_w[shift_weight + out * (window_in + 1) + i] * m, 0); sum += IsNaNOrInf(matrix_w[shift_weight + out * (window_in + 1) + window_in] * m, 0); } } matrix_ig[shift_in + i] = Deactivation(sum, matrix_i[shift_in + i], activation); }
Сначала кернел итерирует по всем выходным каналам, вычисляя вклад каждого элемента исходных данных в итоговую ошибку. Полученные значения корректируются на производную функции активации слоя исходных данных и сохраняются в соответсвующий элемент глобального буфера данных.
Затем выполняется второй этап — распределение градиента ошибки по маске. Здесь мы агрегируем вклад всех каналов на уровне результатов данного эксперта, учитывая влияние текущего эксперта на результат, и формируем сигнал обратной связи, указывающий, насколько полезным оказался выбор этого эксперта.
float sum = 0; for(int out = 0; out < window_out; out++) { int shift_weight_loc = out * (window_in + 1) + shift_weight; float temp = matrix_w[shift_weight_loc + window_in]; for(int i = 0; i < window_in; i++) temp += IsNaNOrInf(matrix_i[shift_in + i], 0) * matrix_w[shift_weight_loc + i]; sum += IsNaNOrInf(temp * matrix_og[shift_out_var + shift_out + out], 0); } masks_g[shift_mask] = IsNaNOrInf(sum, 0); }
Даже если маска бинарная, это значение помогает роутеру в будущем делать более осмысленный выбор, а при необходимости может быть интерпретировано как весовая оценка при взвешенной активации.
Благодаря этому механизму, мы получаем по-настоящему обучаемую архитектуру, в которой каждое решение маршрутизации поддаётся градиентной корректировке, а спящие эксперты могут пробудиться, если в них появится необходимость. Это позволяет разреженной смеси экспертов работать эффективно, гибко и по-настоящему умно.
Следующий критически важный этап — обновление параметров модели. Именно здесь модель учится, корректируя свои веса на основе полученного сигнала ошибки. Для реализации этого процесса мы задействуем отдельный OpenCL-кернел UpdateWeightsMaskMultWinConvAdam, адаптированный под специфику разреженной смеси экспертов и поддерживающий оптимизацию по методу Adam.
Основной объём вычислений мы, как и прежде, переносим в OpenCL-контекст, поскольку каждый вес, участвующий в обучении, может обрабатываться независимо.
__kernel void UpdateWeightsMaskMultWinConvAdam(__global float *matrix_w, __global const float *matrix_og, __global const float *matrix_i, __global const float *masks, __global float *matrix_m, __global float *matrix_v, const int windows_total, const int inputs, const int outputs, const float l, const float b1, const float b2 ) { const size_t id_in = get_global_id(0); const size_t id_out = get_global_id(1); const size_t id_v = get_global_id(2); const size_t window_in = get_global_size(0) / windows_total - 1; const size_t window_out = get_global_size(1); const size_t variables = get_global_size(2);
Работа кернела организована по трём координатам: id_in отвечает за входную размерность и позицию в окне, id_out — за индекс выходного фильтра, а id_v — за текущую переменную в пакете. Такая трёхмерная организация полностью покрывает все параметры экспертов и позволяет обрабатывать их параллельно.
Здесь важно отметить, что мы сознательно используем структурное допущение: все эксперты работают на одинаковых окнах анализа, и тензоры результатов имеют одинаковую форму. Это означает, что вся система может быть сведена к двумерной матрице: по одной стороне — количество фильтров, по другой — общее число элементов в окнах анализа, объединённое от всех экспертов. Такое упрощение позволяет эффективно и компактно адресовать веса в памяти, что особенно критично при массовом обновлении параметров.
Внутри кернела мы сначала идентифицируем каждый поток операций по всем измерениям пространства задач. Затем определяем смещение во всех буферах данных до анализируемых элементов.
const int w_id = id_in / (window_in + 1); const int shift_in = id_in - w_id; const int step_in = window_in * windows_total; const int units = outputs / window_out; const int shift_in_var = id_v * inputs; const int shift_out_var = id_v * outputs; const int shift_mask_var = id_v * units * windows_total; const int shift_weight = ((id_v * windows_total + w_id) * window_out + id_out) * (window_in + 1) + id_in % (window_in + 1); const bool bias = (id_in % (window_in + 1) == window_in);
Далее мы последовательно перебираем все токены (units), с которыми работали эксперты. Для каждого такого фрагмента мы проверяем, используя маску, был ли активен соответствующий эксперт.
float grad = 0; for(int u = 0; u < units; u++) { const int shift_in_loc = shift_in + u * step_in; if(shift_in < inputs) continue; float m = IsNaNOrInf(masks[shift_mask_var + u * windows_total + w_id], 0); if(m < FLT_EPSILON) continue; float inp = (bias ? 1 : IsNaNOrInf(matrix_i[shift_in_var + shift_in_loc], 0)); grad += IsNaNOrInf(inp * m * matrix_og[shift_out_var + u * window_out + id_out], 0); }
Если эксперт на определенном шаге находился в спящем режиме (маска близка к нулю), то мы не затрачиваем ресурсы на вычисления градиента ошибки и переходим к следующему токену. Это одно из ключевых свойств разреженного обучения.
Для активных экспертов мы вычисляем градиент ошибки: исходные данные умножаются на значение ошибки на уровне тензора результатов (matrix_og) и маску.
float mt = IsNaNOrInf(clamp(b1 * matrix_m[shift_weight] + (1 - b1) * grad, -1.0e5f, 1.0e5f), 0); float vt = IsNaNOrInf(clamp(b2 * matrix_v[shift_weight] + (1 - b2) * pow(grad, 2), 1.0e-6f, 1.0e6f), 1.0e-6f); float weight = clamp(matrix_w[shift_weight] + IsNaNOrInf(l * mt / sqrt(vt), 0), -MAX_WEIGHT, MAX_WEIGHT);
Далее применяем шаг оптимизации по алгоритму Adam: обновляем моменты первого (mt) и второго (vt) порядка, нормализуем градиент и корректируем значение веса. Все обновления проходят через функцию clamp, ограничивающую значение весов в пределах допустимого диапазона.
Полученные значения сохраняем в глобальные буферы.
matrix_w[shift_weight] = weight; matrix_m[shift_weight] = mt; matrix_v[shift_weight] = vt; }
Таким образом, каждый вес обновляется строго в соответствии с его вкладом в итоговый результат — и только если он участвовал в реальных вычислениях. Подобная организация обеспечивает высокую вычислительную эффективность, а также делает возможным обучение узкоспециализированных экспертов, которые не только спят в неподходящие моменты, но и активно учатся тогда, когда их знания действительно востребованы.
Теперь, когда вычислительная логика на стороне OpenCL полностью реализована, мы переходим к следующему этапу — организации всего процесса в рамках основной программы. Именно здесь создаётся и настраивается объект, отвечающий за вызов соответствующих кернелов и работу с масками. Эту роль берёт на себя специализированный класс CNeuronMaskMultiWinConv, унаследованный от CNeuronConvOCL. Структура нового объекта представлена ниже.
class CNeuronMaskMultiWinConv : public CNeuronConvOCL { protected: uint iWindowsTotal; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL) override { return false; } virtual bool feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput) override; virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) override { return false; } virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL, CBufferFloat *second)override; virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL) override { return false; } virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput, CBufferFloat *SecondGradient, ENUM_ACTIVATION SecondActivation = None) override; public: CNeuronMaskMultiWinConv(void) {}; ~CNeuronMaskMultiWinConv(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint windows_total, uint window_out, uint units_count, uint variables, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual int Type(void) override const { return defNeuronMaskMultiWinConv; } //--- methods for working with files virtual bool Save(int const file_handle) override; virtual bool Load(int const file_handle) override; };
Он становится тем самым связующим звеном между моделью и низкоуровневым вычислительным контуром. Его задача — корректно подготовить данные, передать их в OpenCL-контекст, запустить нужный кернел и получить обратно результаты.
В структуре объекта CNeuronMaskMultiWinConv мы сознательно избегаем объявления новых внутренних компонентов. Всё необходимое уже предоставлено родительским классом CNeuronConvOCL, и этого вполне достаточно для организации вычислений на стороне OpenCL. Такое решение позволяет сохранить архитектуру чистой и лишённой избыточности, а управление всеми ресурсами — централизованным.
Инициализация слоя осуществляется в переопределённом методе Init, где мы корректируем лишь ключевые параметры, не затрагивая общую логику.
bool CNeuronMaskMultiWinConv::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint windows_total, uint window_out, uint units_count, uint variables, ENUM_OPTIMIZATION optimization_type, uint batch) { uint win = window * windows_total + MathMax(windows_total, 1) - 1; if(!CNeuronConvOCL::Init(numOutputs, myIndex, open_cl, win, win, window_out, units_count, variables, optimization_type, batch)) return false; iWindowsTotal = windows_total; iWindow = window; //--- return true; }
В частности, задаём значение iWindowsTotal, определяющее количество параллельных экспертов. Это значение будет использоваться при формировании сдвигов и вычислении адресов внутри кернелов.
Основной акцент в методе инициализации делается на корректном расчёте ширины обрабатываемого окна. Поскольку каждый эксперт оперирует собственным окном фиксированной длины, мы объединяем их в единое входное пространство. В результате формируется размер совокупного анализируемого окна исходных данных. Этот расчёт гарантирует, что даже в граничных случаях размер окна не опустится до нуля.
Далее мы делегируем выполнение одноименному методу родительского класса, передавая обновлённые параметры, и завершаем работу метода.
В методах прямого и обратного проходов этого слоя реализуется не самостоятельная логика вычислений, а лишь обслуживание описанных выше OpenCL-кернелов. Каждый из этих методов отвечает за подготовку аргументов, передачу параметров и постановку соответствующего кернела в очередь выполнения.
Алгоритм работы в данном случае типовой: передаём указатели на буферы данных, включая маски, и другие параметры, после чего вызываем OpenCL-функцию с нужной размерностью рабочего пространства. Думаю, подобный порядок действий уже хорошо вам знаком. Поэтому углублённый разбор каждого метода здесь не требуется и предлагаем оставить указанные методы для самостоятельного изучения. А полный код класса CNeuronMaskMultiWinConv и всех его методов представлен во вложении.
Разреженная семь экспертов
Следующим важным этапом нашей работы становится построение модуля разреженной смеси экспертов. В рамках нашей реализации его архитектура оформляется в виде класса CNeuronTimeMoESparseExperts, унаследованного от базового класса CNeuronBaseOCL. Внутри объекта сосредоточены все основные компоненты, необходимые для запуска и координации работы специализированных и общего экспертов.
class CNeuronTimeMoESparseExperts : public CNeuronBaseOCL { protected: CNeuronSwiGLUOCL cExpertsIn; CNeuronSwiGLUOCL cSharedIn; CNeuronMaskMultiWinConv cExpertsOut; CNeuronConvOCL cSharedOut; CNeuronTopKGates cMasks; CNeuronConvOCL cSharedGates; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL) override; virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) override; virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL) override; public: CNeuronTimeMoESparseExperts(void) {}; ~CNeuronTimeMoESparseExperts(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_out, uint units_count, uint variables, uint experts, uint topK, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual int Type(void) override const { return defNeuronTimeMoESparseExperts; } //--- methods for working with files virtual bool Save(int const file_handle) override; virtual bool Load(int const file_handle) override; //--- virtual void SetOpenCL(COpenCLMy *obj) override; //--- virtual bool WeightsUpdate(CNeuronBaseOCL *source, float tau) override; virtual void TrainMode(bool flag) override; };
В структуре класса CNeuronTimeMoESparseExperts можно заметить несколько внутренних объектов, каждый из которых выполняет строго определённую функцию в рамках механизма разреженной смеси экспертов. С их внутренней логикой мы будем постепенно знакомиться по мере построения алгоритмов работы методов. На данном этапе важно отметить следующее архитектурное решение: все внутренние объекты объявлены статически, без динамического выделения памяти. Такой подход не только упрощает управление ресурсами, но и позволяет оставить конструктор и деструктор класса пустыми. Все процессы инициализации реализованы в методе Init.
bool CNeuronTimeMoESparseExperts::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_out, uint units_count, uint variables, uint experts, uint topK, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, window * units_count * variables, optimization_type, batch)) return false; SetActivationFunction(None);
В параметрах метода мы получаем набор ключевых констант, который позволяет однозначно задать архитектуру создаваемого объекта. Здесь предусмотрено всё: размеры входных и выходных токенов, общее количество экспертов и число выбираемых маской Top-K, тип используемой оптимизации, количество переменных и вычислительных блоков.
Часть этих параметров передаётся далее, в одноимённый метод родительского класса. Именно там уже организован базовый контроль корректности архитектурных настроек и инициализация глобальных интерфейсов, включая OpenCL-контекст. Таким образом, мы строим иерархически прозрачную структуру, где каждый уровень отвечает за свой объём ответственности, а все элементы архитектуры соединяются в единое согласованное целое.
После успешного завершения инициализации на уровне родительского класса, мы переходим к настройке внутренних компонентов — именно здесь начинается формирование структуры самой смеси экспертов.
Первым в этой цепочке выступает объект cExpertsIn, реализующий функциональность SwiGLU. Он играет роль первого уровня обработки исходных данных — своеобразного предварительного фильтра, усиливающего полезные сигналы и формирующего эмбеддинг, направляемый к каждому эксперту. Количество фильтров в этом слое масштабируется пропорционально числу экспертов, что позволяет каждому из них получить достаточно выразительное представление задачи.
int index = 0; if(!cExpertsIn.Init(0, index, OpenCL, window, window, window_out * experts, units_count, variables, optimization, iBatch)) return false; index++; if(!cSharedIn.Init(0, index, OpenCL, window, window, window_out, units_count, variables, optimization, iBatch)) return false;
Аналогичным образом мы инициализируем компонент cSharedIn, который представляет собой первый слой обработки для общего эксперта. В отличие от специализированных экспертов, здесь не требуется масштабировать размерность фильтров — наоборот, она фиксируется на уровне, эквивалентном одному индивидуальному эксперту. Это обусловлено тем, что общий эксперт охватывает всё пространство задач целиком, и его назначение — не соревноваться с остальными, а предложить универсальную точку зрения, своего рода базовую линию для принятия решения.
Использование SwiGLU в данной роли обеспечивает нелинейную селективность, усиливая различие между токенами, потенциально релевантными тому или иному эксперту.
Далее мы переходим к формированию второго слоя обработки как для специализированных экспертов, так и для общего. Здесь архитектура строго следует ранее заложенной логике: каждый блок выполняет свою конкретную функцию в общем механизме разреженной смеси.
Для специализированной смеси экспертов мы используем ранее созданный модуль CNeuronMaskMultiWinConv, реализующий свёртку с маскированными окнами. Этот компонент обеспечивает фильтрацию анализируемых признаков с учётом индивидуальной маски каждого эксперта, что позволяет задать точечную активацию и строгое разграничение ответственности между экспертами.
index++; if(!cExpertsOut.Init(0, index, OpenCL, window_out, experts, window, units_count, variables, optimization, iBatch)) return false; cExpertsOut.SetActivationFunction(None); index++; if(!cSharedOut.Init(0, index, OpenCL, window_out, window_out, window, units_count, variables, optimization, iBatch)) return false; cSharedOut.SetActivationFunction(None);
Для общего эксперта, в свою очередь, мы применяем стандартный свёрточный слой CNeuronConvOCL.
В качестве объекта создания маски активных экспертов мы задействуем модуль CNeuronTopKGates, знакомый нам ещё по фреймворку DUET. Этот компонент выполняет ключевую роль в механизме разреживания: он анализирует исходные токены и выбирает строго ограниченное число наиболее релевантных экспертов.
Иначе говоря, CNeuronTopKGates формирует маску активации по принципу Top-K, жёстко обнуляя не актуальные каналы. Такое решение позволяет значительно сократить вычислительную нагрузку, сохранив при этом высокую выразительность модели. Количество активных экспертов (topK) задаётся параметром и легко адаптируется под специфику конкретной задачи.
index++; if(!cMasks.Init(0, index, OpenCL, window, units_count, experts, topK, optimization, iBatch)) return false;
Важно понимать, что объект CNeuronTopKGates не просто возвращает бинарную маску. Он дополнительно формирует нормализованное вероятностное распределение по отобранным каналам. Это позволяет гибко регулировать вклад каждого эксперта в итоговый результат. Активные эксперты участвуют в расчётах с разной степенью уверенности — и эта вероятность явно отражается в весах итоговой маски.
Благодаря такому подходу, архитектура получает не только компактность и вычислительную эффективность, но и устойчивость к переобучению. Каждый эксперт задействуется строго по делу, а не на авось, что особенно важно при работе с шумными или мультимодальными временными рядами.
И завершает структуру объекта модуль гейтов общего эксперта — cSharedGates. Его задача — определить степень участия общего эксперта в формировании финального ответа модели. Для этого мы используем классический свёрточный слой, настроенный на извлечение пространственно-временных закономерностей. В отличие от маски для отдельных экспертов, здесь применяется сигмовидная функция активации, что позволяет получать плавные значения в диапазоне от 0 до 1.
index++; if(!cSharedGates.Init(0, index, OpenCL, window, window, window, units_count, variables, optimization, iBatch)) return false; cSharedGates.SetActivationFunction(SIGMOID); //--- return true; }
Такой подход обеспечивает не бинарное решение включить/выключить, а гибкое масштабирование вклада общего эксперта, в зависимости от текущего контекста. Сигмоида выполняет роль гибкого демпфера: если уверенность в общем паттерне высока — значение стремится к единице, если сигнал слаб — соответствующая маска подавляет выход. Всё это позволяет точно регулировать взаимодействие между локализованными и обобщающими компонентами архитектуры.
После успешного выполнения всех итераций, мы возвращаем их логический результат вызывающей программе и завершаем работу метода.
Далее переходим к следующему ключевому этапу — организации прямого прохода в методе feedForward. Именно здесь во всей красе проявляется логика работы модуля разреженной смеси экспертов.
bool CNeuronTimeMoESparseExperts::feedForward(CNeuronBaseOCL *NeuronOCL) { if(!cExpertsIn.FeedForward(NeuronOCL)) return false;
Сначала мы запускаем прямой проход через слой cExpertsIn, который преобразует анализируемый сигнал в пространстве экспертов, масштабируя его в соответствии с количеством специализированных путей обработки. Параллельно с ним инициируется работа слоя cSharedIn, представляющего собой первый уровень обработки более универсального общего эксперта.
if(!cSharedIn.FeedForward(NeuronOCL)) return false;
Далее идёт активация двух ветвей маршрутизации. Слой cSharedGates рассчитывает маску участия общего эксперта: здесь работает сигмовидная функция активации, создающая градиентную маску значимости. В свою очередь, объект cMasks — это специализированный модуль, который отбирает наиболее релевантных экспертов, формируя дискретную маску Top-K. Он не просто исключает неактивные каналы, но и возвращает нормализованное распределение весов по выбранным путям.
if(!cSharedGates.FeedForward(NeuronOCL)) return false; if(!cMasks.FeedForward(NeuronOCL)) return false;
На этом этапе начинается формирование результатов. Через модуль cExpertsOut запускается свёртка маскированного экспертного блока: каждому активному фильтру соответствует свой вес, а вся обработка организована с учётом ранее подготовленной маски.
if(!cExpertsOut.FeedForward(cExpertsIn.AsObject(), cMasks.getOutput())) return false;
Для общего эксперта используется обычная свёртка через cSharedOut, после чего, её выход дополнительно масштабируется по маске значимости, рассчитанной ранее cSharedGates. Масштабирование осуществляется через поэлементное умножение.
if(!cSharedOut.FeedForward(cSharedIn.AsObject())) return false; if(!ElementMult(cSharedOut.getOutput(), cSharedGates.getOutput(), cSharedOut.getPrevOutput())) return false;
После получения результатов разреженной смеси экспертов и общего, масштабированного маской значимости, происходит объединение двух информационных потоков. На этом этапе данные суммируются и записываются в промежуточный буфер данных.
const int window = (int)cSharedGates.GetWindow(); if(!SumAndNormilize(cExpertsOut.getOutput(), cSharedOut.getPrevOutput(), PrevOutput, window, false, 0, 0, 0, 1)) return false; if(!SumAndNormilize(NeuronOCL.getOutput(), PrevOutput, Output, window, true, 0, 0, 0, 1)) return false; //--- return true; }
Однако это лишь первая часть финального этапа прямого прохода. Далее к полученным значениям добавляются остаточные (residual) связи — сохранённые выходы предыдущего уровня нейронной архитектуры. Этот приём позволяет сохранить важную информацию от исходного сигнала и улучшить сходимость модели за счёт стабилизации градиентов.
После объединения всех компонентов, выполняется нормализация результатов в рамках каждого токена (вдоль окна анализа). Эта операция приводит данные к согласованному масштабу, обеспечивая корректную интерпретацию выходного тензора. Результат сохраняется в буфере глобального интерфейса результатов.
Как можно заметить, алгоритм прямого прохода в нашем модуле имеет весьма разветвлённую структуру. Исходные данные поступают одновременно в пять различных информационных потоков, что значительно повышает гибкость и адаптивность модели. Однако, такая многоканальная архитектура накладывает определённые сложности на этап обратного распространения ошибки.
В методе calcInputGradients происходит тщательное распределение градиентов ошибки между всеми внутренними компонентами в соответствии с их вкладом в итоговый результат. Это похоже на искусную дирижёрскую работу, где каждый инструмент звучит в точной гармонии с оркестром.
bool CNeuronTimeMoESparseExperts::calcInputGradients(CNeuronBaseOCL *NeuronOCL) { if(!NeuronOCL) return false;
Сначала активируются процедуры распределения градиента ошибки для отдельных информационных потоков:
- для выхода слоя разреженной смеси экспертов (cExpertsOut), где производится деактивация градиентов с учётом функции активации;
- для общего эксперта и гейтов (cSharedOut и cSharedGates), где учитывается взаимодействие выходных значений и масок, применяются произведения градиентов с учётом функций активации.
if(!DeActivation(cExpertsOut.getOutput(), cExpertsOut.getGradient(), Gradient, cExpertsOut.Activation())) return false; if(!ElementMultGrad(cSharedOut.getOutput(), cSharedOut.getGradient(), cSharedGates.getOutput(), cSharedGates.getGradient(), Gradient, cSharedOut.Activation(), cSharedGates.Activation())) return false;
Затем происходит рекурсивный вызов расчёта скрытых градиентов во внутренних объектах — это позволяет пошагово развернуть вклад каждого компонента, начиная с первых слоёв экспертов (cExpertsIn, cSharedIn) и вплоть до уровня исходных данных, обеспечивая глубокий и корректный расчёт.
if(!cExpertsIn.calcHiddenGradients(cExpertsOut.AsObject(), cMasks.getOutput(), cMasks.getGradient(), (ENUM_ACTIVATION)cMasks.Activation())) return false; if(!cSharedIn.calcHiddenGradients(cSharedOut.AsObject())) return false; //--- if(!NeuronOCL.calcHiddenGradients(cExpertsIn.AsObject())) return false;
Особое внимание уделяется аккумулированию градиентов в промежуточных буферах. Здесь происходит суммирование градиентов по токенам окна, что обеспечивая корректное согласование сигналов от разных участников процесса.
if(!DeActivation(NeuronOCL.getOutput(), PrevOutput, Gradient, NeuronOCL.Activation())) return false; const int window = (int)cSharedGates.GetWindow(); if(!SumAndNormilize(PrevOutput, NeuronOCL.getGradient(), PrevOutput, window, false, 0, 0, 0, 1)) return false; if(!NeuronOCL.calcHiddenGradients(cSharedIn.AsObject())) return false; if(!SumAndNormilize(PrevOutput, NeuronOCL.getGradient(), PrevOutput, window, false, 0, 0, 0, 1)) return false; if(!NeuronOCL.calcHiddenGradients(cMasks.AsObject())) return false; if(!SumAndNormilize(PrevOutput, NeuronOCL.getGradient(), PrevOutput, window, false, 0, 0, 0, 1)) return false; if(!NeuronOCL.calcHiddenGradients(cSharedGates.AsObject())) return false; if(!SumAndNormilize(PrevOutput, NeuronOCL.getGradient(), NeuronOCL.getGradient(), window, false, 0, 0, 0, 1)) return false; //--- return true; }
В итоге, метод calcInputGradients аккуратно балансирует сложный поток информации и обеспечивает эффективное обучение всего модуля разряженной смеси экспертов, что делает эту архитектуру не только мощной, но и устойчивой к ошибкам и переобучению.
Метод обновления параметров модели организован по классической схеме: управление делегируется внутренним компонентам. Каждый из них реализует собственную стратегию адаптации весов на основе полученных градиентов. В теле метода updateInputWeights происходит лишь последовательный вызов соответствующих процедур. Поэтому, чтобы не повторяться, мы предлагаем изучить данный фрагмент самостоятельно. Полный исходный код класса CNeuronTimeMoESparseExperts с реализацией всех методов доступен во вложении.
Заключение
В данной статье мы подробно разобрали реализацию модуля маскированной свёртки и архитектуру блока разреженной смеси экспертов, адаптированных под задачи обработки временных рядов в OpenCL-среде. Были рассмотрены ключевые аспекты настройки вычислений на стороне устройства и организации взаимодействия с основной программой. Отдельное внимание уделено корректному распределению градиента в условиях разветвлённого информационного потока.
В следующей части мы продолжим развитие архитектуры и сосредоточимся на построении и обучении моделей, использующих данную структуру в составе более сложных нейронных систем.
Ссылки
Программы, используемые в статье
# | Имя | Тип | Описание |
---|---|---|---|
1 | Study.mq5 | Советник | Советник офлайн обучения моделей |
2 | StudyOnline.mq5 | Советник | Советник онлайн обучения моделей |
3 | Test.mq5 | Советник | Советник для тестирования модели |
4 | Trajectory.mqh | Библиотека класса | Структура описания состояния системы и архитектуры моделей |
5 | NeuroNet.mqh | Библиотека класса | Библиотека классов для создания нейронной сети |
6 | NeuroNet.cl | Библиотека | Библиотека кода OpenCL-программы |
Предупреждение: все права на данные материалы принадлежат MetaQuotes Ltd. Полная или частичная перепечатка запрещена.
Данная статья написана пользователем сайта и отражает его личную точку зрения. Компания MetaQuotes Ltd не несет ответственности за достоверность представленной информации, а также за возможные последствия использования описанных решений, стратегий или рекомендаций.





- Бесплатные приложения для трейдинга
- 8 000+ сигналов для копирования
- Экономические новости для анализа финансовых рынков
Вы принимаете политику сайта и условия использования