
Нейросети в трейдинге: Прогнозирование временных рядов при помощи адаптивного модального разложения (ACEFormer)
Введение
Финансовый рынок — это сложная и динамичная система, в которой каждое ценовое движение представляет собой результат сложного взаимодействия многих факторов. В нем можно найти отражения практически всего: от потоков макроэкономической информации и внутренних корпоративных новостей до эмоциональных всплесков инвесторов и холодных расчётов алгоритмических торговых стратегий. В этом многообразии сигналов, шумов и искажений, задача извлечь полезную информацию и распознать настоящий тренд становится не просто интересной, а стратегически важной.
Способность точно прогнозировать направление рынка может обеспечить устойчивое преимущество. Особую сложность представляет так называемый информационный шум — частые и, зачастую, бессмысленные микроколебания цен, вызванные короткими сделками, новостными заголовками или случайными алгоритмическими действиями. Именно они часто мешают аналитическим моделям ухватить суть происходящего.
Попытки построить модели прогнозирования предпринимались ещё в конце XX века. Простейшие нейросетевые архитектуры показали, что в принципе, можно учить модели прогнозировать рыночные движения. Однако эти подходы страдали от неспособности удерживать информацию на длинных временных интервалах и быстро забывали то, что происходило немного ранее.
С появлением архитектуры LSTM ситуация улучшилась. Эти модели, обладая механизмами памяти, могли удерживать важные закономерности на протяжении более длительных временных промежутков. Они получили широкое распространение в задачах прогнозирования временных рядов. Но и здесь не всё оказалось просто. Финансовые ряды — это не обычные временные последовательности. Они нерегулярны. В них часто отсутствует равномерность между тиками. И в них крайне много краткосрочных всплесков, не несущих значимой информации о направлении тренда.
Особенно серьёзную проблему представляет собой высокочастотная торговля. Она создает так называемый рыночный шум — многократные колебания котировок в пределах очень коротких интервалов времени. Эти колебания маскируют настоящие тренды, делают данные нестабильными, перегружают модель несущественными событиями. В результате, даже сложные архитектуры начинают фокусироваться не на том, что важно, а на том, что лишь отвлекает.
Для решения указанных задач, в работе "An End-to-End Structure with Novel Position Mechanism and Improved EMD for Stock Forecasting", был предложен фреймворк ACEFormer, который представляет собой интегрированный алгоритм для анализа биржевых временных рядов, специально адаптированный к условиям высокочастотной торговли. Это не просто модель, а система взаимодополняющих компонентов, каждый из которых решает конкретную задачу: фильтрацию шума, учёт временных интервалов и акцентирование внимания на ключевых изменениях.
Первым этапом в архитектуре ACEFormer является очистка данных от шумов. Здесь используется модифицированный алгоритм ACEEMD (Alias Complete Ensemble Empirical Mode Decomposition with Adaptive Noise). Метод основан на подходах эмпирического модального разложения (EMD), однако, реализован в усовершенствованной форме. Это позволяет устранить два основных недостатка EMD: эффект концов и смешивания компонент. Благодаря удалению первой внутренней модальной функции (IMF), содержащей наибольшее количество высокочастотных колебаний, ACEEMD эффективно убирает шум и сохраняет важные поворотные точки тренда.
После предварительной фильтрации, очищенные данные направляются в модуль временной осведомлённости. Биржевые события происходят в нерегулярные интервалы времени, и это накладывает ограничения на работу классических механизмов внимания. Чтобы учитывать такие различия, авторы фреймворка интегрировали механизм Time-Aware — модуль, который анализирует значения признаков с учетом временных интервалов между ними. Это позволяет модели лучше понимать последовательность событий и выявлять причинно-следственные связи.
Затем, данные обрабатываются улучшенным блоком внимания. В отличие от стандартного Attention, предложенный модуль адаптирован к особенностям рыночных данных, в которых важно уметь выделять ключевые точки изменения, игнорируя незначительные флуктуации. Благодаря усиленному фокусу на значимых участках временного ряда, модель не распыляется на шумовые элементы и концентрируется на потенциально важной информации.
На последнем этапе используется полносвязная нейросеть. Она агрегирует извлечённые признаки и формирует финальный прогноз по направлению движения цены. Таким образом, архитектура ACEFormer охватывает полный цикл обработки данных: от шумоподавления и учёта временной структуры, до фокусировки внимания и прогнозирования.
Алгоритм ACEFormer
Алгоритм ACEFormer представляет собой многоэтапную систему обработки временных рядов с целью точного прогноза направлений движения цены на финансовых рынках. Суть его работы заключается в последовательном и адаптивном устранении рыночных шумов, выделении важных признаков, а затем, в построении прогноза с учетом долгосрочных трендов. Такой подход особенно полезен в условиях высокочастотной торговли, когда полезный сигнал скрывается среди множества случайных колебаний и шумов.
Процесс начинается с подачи исходных данных в виде временного ряда 𝑆={𝑠1,𝑠2,…,𝑠𝑛}, где каждый вектор 𝑠𝑖 включает в себя цену, объем и другие индикаторы на момент времени 𝑖. Для подготовки данных к обучению модели, в последовательность добавляется дополнительная нулевая подушка длиной 𝑝, создавая структуру для прогнозирования будущих шагов. Это позволяет модели строить прогнозы на следующие 𝑝 шагов, несмотря на отсутствие явной информации о будущем в исходных данных 𝐷=[𝑠1,𝑠2,…,𝑠𝑛,0,0,…,0] ∈ 𝑅(𝑛+𝑝)×𝑑, где 𝑑 — количество признаков. Эти нули помогают модели воспринимать структуру и прогнозировать будущие значения, даже если исторические данные не содержат полной картины.
Следующий этап включает в себя сглаживание сигнала с помощью свёрточных фильтров. Два свёрточных фильтра 𝑓 и 𝑔 накладываются последовательно, уменьшая количество случайных флуктуаций в данных и стабилизируя ряд. Это сглаживание помогает модели избавиться от случайных всплесков и улучшить качество исходных данных для последующих этапов обработки.
После предварительного сглаживания, на сцену выходит адаптированный метод эмпирического модального разложения (ACEEMD), который помогает эффективно устранять высокочастотный шум. Процесс начинается с того, что к каждому элементу временного ряда добавляется и вычитается гауссовский шум 𝑛𝑖(𝑡), что создаёт два новых ряда 𝑝𝑒𝑖(𝑡)=𝑥(𝑡)+𝑛𝑖(𝑡) и 𝑝𝑚𝑖(𝑡)=𝑥(𝑡)−𝑛𝑖(𝑡).
Затем, каждый из этих рядов обрабатывается методом эмпирического модального разложения (EMD), выделяя первую внутреннюю модальную функцию (IMF).
После выделения IMF, усреднённые компоненты из обоих рядов суммируются. Полученная компонента затем вычитается из исходного ряда, что даёт очищенную временную последовательность 𝑟1(𝑡)=𝑥(𝑡)−IMF1(𝑡). Очищенный временной ряд передается на следующие этапы обработки.
Чтобы модель могла распознавать порядок событий во времени, добавляется позиционное кодирование, которое сохраняет информацию о временном расположении данных в ряду. И осуществляется линейная проекция данных.
Особенностью архитектуры ACEFormer является использование модуля вероятностного внимания, который играет важную роль в повышении обобщающей способности модели. Вероятностное внимание представляет собой модификацию классического Self-Attention, направленную на повышение вычислительной эффективности и устранение нерелевантных связей. Основная идея заключается в том, чтобы не рассчитывать внимание ко всем позициям в последовательности, а фокусироваться лишь на наиболее значимых временных шагах. Для этого, предварительно оценивается мера важности каждой позиции. В ACEFormer эта мера определяется, как максимальное значение проекции Querys на случайную выборку из Keys. Значения нормализуются, после чего выбираются наиболее информативные позиции. Именно для них и производится расчёт Self-Attention. Таким образом, внимание работает не над всей последовательностью, а над её сжатым подмножеством, которое с высокой вероятностью содержит ключевые точки.
Использование модуля вероятностного внимания в ACEFormer — это не просто технический приём, а стратегический ход, позволяющий модели более гибко адаптироваться к динамичным рыночным условиям, где важность каждой отдельной зависимости может изменяться со временем. Такой подход способствует созданию более надёжных и обоснованных прогнозов в условиях нестабильных данных.
В результате, вероятностное внимание помогает модели ACEFormer фокусироваться на действительно значимых паттернах в данных, исключая несущественные связи и шумовые вариации. Этот модуль усиливает способность модели извлекать важные закономерности и строить точные прогнозы, что особенно важно в задачах прогнозирования направления предстоящего ценового движения на финансовых рынках.
После применения вероятностного внимания, результат проходит через слой свёртки и процедуру субдискретизации (max-pooling), что дополнительно усиливает локальные признаки и улучшает представление модели о важных паттернах в данных. Операция свёртки улучшает выделение локальных признаков, усиливая те части временного ряда, которые содержат наиболее важную информацию для дальнейшего прогноза.
Завершающий этап обработки данных — классический механизм Self-Attention. Он позволяет каждому элементу временного ряда учитывать глобальный контекст, а также выявлять зависимости между событиями, разделёнными значительными временными интервалами.
Для получения прогнозных значений на заданный горизонт планирования, используется полносвязная сеть.
Таким образом, алгоритм ACEFormer действует в несколько этапов, начиная от удаления шума и заканчивая построением точного прогноза. Каждый из этих этапов помогает модели более эффективно работать с нестабильными рыночными данными, выявляя ключевые долгосрочные тренды и прогнозируя движения цен с высокой точностью.
Авторская визуализация фреймворка ACEFormer представлена ниже.
Реализация средствами MQL5
После подробного изучения теоретических аспектов фреймворка ACEFormer, переходим к его практической реализации средствами MQL5. Начнем работу с модуля вероятностного внимания. Это один из ключевых блоков архитектуры, обеспечивающий высокую вычислительную эффективность при сохранении качества представления данных.
Прежде чем углубиться в детали реализации, стоит ещё раз подчеркнуть концептуальное преимущество вероятностного внимания. Этот механизм представляет собой компромисс между точностью и вычислительной экономичностью. В отличие от классического внимания, которое анализирует всю последовательность целиком, здесь применяется отбор наиболее информативных элементов. Такой подход позволяет снизить нагрузку на память и вычислительные ресурсы без потери качества, особенно на длинных последовательностях.
Представленная в данной статье реализация разбита на три последовательно исполняемых кернела. Каждый из них решает свою задачу: от вычисления значимости, через отбор лучших запросов, до финального расчёта контекстного представления. Рассмотрим весь процесс пошагово.
Сначала определяется значимость каждого запроса. Это происходит в кернеле ProbAttentionQeuryImp. В параметрах кернела получаем:
- матрицу Запросов (querys),
- объединённую матрицу Ключей и Значений (keys_values),
- индексный массив index_keys, в котором указаны сэмплированные порядковые номера Ключей, ассоциированные с каждым Запросом.
В данном контексте речь идёт о случайно выбранных Ключах, на основании которых осуществляется оценка важности Запросов. Сэмплирование используется не для построения финального внимания, а исключительно для статистической оценки — вычисления, насколько сильно каждый Запрос откликается на предоставленную подвыборку Ключей.
__kernel void ProbAttentionQeuryImp(__global const float* querys, __global const float2* __attribute__((aligned(8))) keys_values, __global const float* index_keys, __global float* querys_imp, const int dimension ) { const size_t id_q = get_global_id(0); const size_t total_q = get_global_size(0); const size_t ind_k = get_local_id(1); const size_t total_ind = get_local_size(1); const size_t id_h = get_global_id(2); const size_t total_h = get_global_size(2);
Мы планируем выполнение данного кернела в трёхмерном пространстве задач, где каждое измерение играет специфическую роль в организации параллельных вычислений. Первое измерение охватывает последовательность Запросов. Второе измерение соответствует количеству сэмплированных Ключей, привязанных к каждому конкретному Запросу. Это число может варьироваться, в зависимости от параметров модели и глубины анализа. Третье измерение задаёт количество голов внимания — независимых подмодулей, которые одновременно и параллельно анализируют различные аспекты исходных данных.
Особое внимание следует уделить механизму работы с головами внимания. Каждая из них оперирует собственным, индивидуальным набором сэмплированных Ключей. Этот подход обеспечивает разнообразие взглядов на одну и ту же последовательность: каждая голова концентрируется на своей подвыборке, выявляя уникальные закономерности и связи. Такое распределение позволяет повысить устойчивость всей архитектуры: даже если одна из голов недооценила важный фрагмент, другая может это компенсировать. В совокупности, все головы формируют более богатое и репрезентативное представление исходного сигнала, что существенно улучшает качество внимания и повышает информативность итогового контекста.
Потоки операций объединяются в рабочие группы по второму измерению пространства задач. А для обмена информацией между параллельными потоками операций рабочей группы, создадим массив данных в локальной памяти OpenCL-устройства.
__local float temp[LOCAL_ARRAY_SIZE][2]; const int ls = min((int)total_ind, (int)LOCAL_ARRAY_SIZE);
Следующим шагом определяем смещения в массивах данных, соответствующие текущему потоку операций. При этом, для определения смещения в буфере Запросов, используем идентификатор потока по первому измерению. А для определения смещения в буфере Ключей, сначала получаем из соответствующего буфера индексации порядковый номер сэмплированного Ключа, а затем, определяем смешение в буфере.
const int shift_q = dimension * (id_q * total_h + id_h); const int id_k = index_keys[total_ind * id_q * total_h + ind_k * total_h + id_h]; const int shift_k = dimension * (id_k * total_h + id_h);
Для каждой пары Запрос-Ключ вычисляется скалярное произведение, отражающее степень их соответствия. В цикле осуществляется поэлементное умножение и накопление результата.
float sum = 0; #pragma unroll for(int d = 0; d < dimension; d++) sum += IsNaNOrInf(querys[shift_q + d] * keys_values[shift_k + d].s0, 0);
Затем, используя массив в локальной памяти, параллельно вычисляются сумма и максимум этих произведений в пределах рабочей группы. Это позволяет с высокой производительностью получить агрегированную характеристику для каждой подвыборки.
int id_t = ind_k % ls; #pragma unroll for(int i = 0; i < total_ind; i += ls) { if(i <= ind_k || (i + ls) > ind_k) { temp[id_t][0] = IsNaNOrInf((i == 0 ? 0 : temp[id_t][0]) + sum, 0); temp[id_t][1] = (i == 0 ? IsNaNOrInf(sum, MIN_VALUE) : fmax(temp[id_t][1], IsNaNOrInf(sum, MIN_VALUE))); barrier(CLK_LOCAL_MEM_FENCE); } } int count = ls; #pragma unroll do { count = (count + 1) / 2; if(ind_k < count && (ind_k + count) < ls) { temp[ind_k][0] += temp[ind_k + count][0]; temp[ind_k + count][0] = 0; temp[ind_k][1] = fmax(temp[ind_k + count][1], temp[ind_k][1]); } barrier(CLK_LOCAL_MEM_FENCE); } while(count > 1);
Далее, определяется разность между максимумом и средним значением скалярных произведений — это и есть мера важности текущего запроса. Чем больше это значение, тем более информативным считается данный запрос. Итоговая значимость сохраняется в буфере результатов querys_imp.
if(ind_k == 0) querys_imp[id_q * total_h + id_h] = IsNaNOrInf(temp[0][1] - temp[0][0] / total_ind, MIN_VALUE); }
Следующий этап — отбор наиболее значимых запросов. Эту задачу выполняет кернел TopKImportanceToIndex. Вместо использования сложных алгоритмов сортировки, здесь реализован простой и надёжный способ ранжирования.
Для каждого Запроса организован параллельный подсчет количества более значимых. Если оно меньше заданного порога top_k, текущий Запрос включается в итоговый список индексов. Такой способ, несмотря на свою прямолинейность, отлично подходит для выполнения на GPU, так как требует минимума синхронизаций и не нуждается в дополнительных структурах данных.
__kernel void TopKImportanceToIndex(__global const float* importance, __global float* indexes, const int top_k ) { const size_t id_q = get_global_id(0); const size_t total_q = get_global_size(0); const size_t id_h = get_global_id(1); const size_t total_h = get_global_size(1); //--- float imp = importance[id_q * total_h + id_h]; int pos = 0; #pragma unroll for(int i = 0; i < total_q; i++) { if(i == id_q) continue; float val = importance[i * total_h + id_h]; if(val > imp || (i < id_q && val >= imp)) pos++; if(pos >= top_k) break; } //--- if(pos < top_k) indexes[pos * total_h + id_h] = (float)id_q; }
И наконец, третий ключевой этап — расчёт непосредственно внимания. Здесь в игру вступает кернел QIndexAttention. Его задача — сформировать финальное контекстное представление для каждого отобранного Запроса.
На вход данного кернела подаётся полный набор сущностей Запросов (Query), Ключей (Key) и Значений (Value). Как и обсуждалось ранее, принципиальным решением является отказ от создания дополнительных копий данных для отобранного подмножества, что критически важно для экономии памяти и ускорения вычислений. Вместо этого, используется индексный буфер, содержащий указатели на наиболее значимые Запросы, отобранные на предыдущих этапах.
При этом стоит обратить внимание, что токены Ключей и Значений объединены в единый буфер данных. Это упрощает организацию доступа к данным и повышает эффективность кэширования. В данном случае, применяется векторный тип float2, где первый элемент соответствует Ключу, а второй — Значению. Такая структура позволяет обрабатывать пары "Ключ-Значение" как единое логическое целое, снижая накладные расходы при обращении к памяти и способствуя более компактной и понятной реализации вычислительных операций.
__kernel void QIndexAttention(__global const float *q, __global const float2* kv, __global float *scores, __global const float *indexes, __global float *out, const int dimension, const int heads_kv ) { //--- init const int ind_q = get_global_id(0); const int k = get_local_id(1); const int h = get_global_id(2); const int total_q = get_global_size(0); const int total_k = get_local_size(1); const int heads = get_global_size(2);
В данном кернеле мы снова используем трехмерное пространство задач. Только первое измерение Запросов оперирует с ограниченной выборкой наиболее значимых токенов. А второе измерение Ключей, наоборот, соответствует полной последовательности. И мы так же объединяем потоки операций в рабочие группы по второму измерению.
В теле кернела идентифицируем текущий поток операций во всех измерения пространства задач. А затем, на основании полученных значений определяем смещение в буферах данных.
const int h_kv = h % heads_kv; const int q_id = (int)(indexes[ind_q * heads + h] + 0.001f); const int shift_q = dimension * (q_id * heads + h); const int shift_kv = dimension * (heads_kv * k + h_kv); const int shift_s = total_k * (ind_q * heads + h) + k;
Обратите внимание, что перед определением смещения в буфере данных Запросов, мы сначала извлекаем указатель на нужный токен из буфера индексов наиболее важных элементов.
Тут же создаем массив данных в локальной памяти, для передачи информации между потоками рабочей группы.
__local float temp[LOCAL_ARRAY_SIZE]; const uint ls = min((uint)total_k, (uint)LOCAL_ARRAY_SIZE);
Первым этапом производится вычисление скалярных произведений токенов Запросов и Ключей, что формирует массив промежуточных значений — так называемых Raw Score. Эти оценки отражают степень взаимной релевантности пары "Запрос-Ключ" и становятся основой для дальнейшей обработки.
//--- Score float score = 0; if(q_id >= 0) { #pragma unroll for(int d = 0; d < dimension; d++) score += IsNaNOrInf(q[shift_q + d] * kv[shift_kv + d].s0, 0); }
С целью стабилизации вычислений и повышения числовой устойчивости, нормализация проводится с использованием механизма SoftMax в модифицированной форме. В рамках каждой рабочей группы (work group) изначально определяется максимум среди всех Score.
//--- max of score #pragma unroll for(int i = 0; i < total_k; i += ls) { if(k >= i && k < (i + ls)) temp[k % ls] = (i == 0 ? score : fmax(temp[k % ls], score)); barrier(CLK_LOCAL_MEM_FENCE); } //--- uint count = ls; #pragma unroll do { count = (count + 1) / 2; if(k < count && (k + count) < ls) temp[k] = fmax(temp[k + count], temp[k]); barrier(CLK_LOCAL_MEM_FENCE); } while(count > 1);
Далее, каждый Score корректируется путём вычитания найденного максимума. Это позволяет избежать переполнения экспоненты и гарантирует, что значения под экспонентой будут меньше либо равны нулю. Следовательно, результат экспоненциальной функции — в пределах от 0 до 1.
score = IsNaNOrInf(exp(score - temp[0]), 0);
Затем, производится суммирование экспонент и деление каждой из них на эту сумму, превращая скор в итоговый вес.
//--- sum of exp #pragma unroll for(int i = 0; i < total_k; i += ls) { if(k >= i && k < (i + ls)) temp[k % ls] = (i == 0 ? 0 : temp[k % ls]) + score; barrier(CLK_LOCAL_MEM_FENCE); } //--- count = ls; #pragma unroll do { count = (count + 1) / 2; if(k < count && (k + count) < ls) { temp[k] += temp[k + count]; temp[k + count] = 0; } barrier(CLK_LOCAL_MEM_FENCE); } while(count > 1); //--- score if(temp[0] > 0) score /= temp[0]; scores[shift_s] = score;
Затем, полученные веса применяются к тензору Значений. Все такие взвешенные векторы аккумулируются и объединяются в единый контекстный вектор, описывающий семантическую суть исходной информации с точки зрения данного Запроса.
//--- out #pragma unroll for(int d = 0; d < dimension; d++) { float val = kv[shift_kv + d].s1 * score; #pragma unroll for(int i = 0; i < total_k; i += ls) { if(k >= i && k < (i + ls)) temp[k % ls] = (i == 0 ? 0 : temp[k % ls]) + val; barrier(CLK_LOCAL_MEM_FENCE); } //--- uint count = ls; #pragma unroll do { count = (count + 1) / 2; if(k < count && (k + count) < ls) { temp[k] += temp[k + count]; temp[k + count] = 0; } barrier(CLK_LOCAL_MEM_FENCE); } while(count > 1); //--- if(k == 0) out[dimension * (ind_q * heads + h) + d] = temp[0]; barrier(CLK_LOCAL_MEM_FENCE); } }
Весь описанный механизм реализует согласованную и высокоэффективную схему вероятностного внимания. Сначала мы быстро и приближённо оцениваем важность Запросов. Затем, отбираем наиболее перспективные, и, в завершение, производим полные точные вычисления на ограниченном и информативном подмножестве. Такой подход не только ускоряет обработку длинных последовательностей, но и сохраняет высокий уровень точности модели. При этом достигается значительное сокращение объёма промежуточных данных и обращений к глобальной памяти.
Однако, описанный выше процесс охватывает лишь прямой проход — фазу, в которой модель формирует свои прогнозы на основе исходных данных. Для того, чтобы модель могла обучаться, необходимо организовать обратное распространение ошибки — процесс корректировки обучаемых параметров всех компонентов, с учётом их влияния на итоговый результат работы модели.
В данном случае, мы приняли осознанное и обоснованное архитектурное решение: распространять градиент ошибки исключительно через механизм внимания, исключая из цепочки обратного прохода этап выбора наиболее значимых Запросов. На первый взгляд, это может показаться упрощением, однако, за этим стоит точный расчёт и понимание внутренней структуры вычислений.
Оба вышеупомянутых этапа — как выбор значимых Запросов, так и собственно внимание — используют одну и ту же базовую операцию: сопоставление токенов Запросов и Ключей. В первом случае, мы имеем дело с анализом подмножества сэмплированных Ключей в контексте всей последовательности Запросов, оценивая значимость каждого элемента на основе его отклика. Во втором случае — наоборот: мы фокусируем внимание на уже отобранных наиболее значимых Запросах и оцениваем их в контексте полной последовательности Ключей. Иными словами, в обоих случаях осуществляется сопоставление одних и тех же сущностей, но с разных проекций. Это даёт нам возможность избежать дублирования вычислений и организовать эффективную обратную связь, корректируя параметры модели только через один информационный поток.
Такой подход позволяет достичь сразу нескольких целей. Во-первых, он снижает вычислительную нагрузку, поскольку градиент распространяется только по одному информационному пути. Во-вторых, повышается числовая устойчивость модели, так как исключается возможность конфликтов между двумя параллельными источниками градиентов. В-третьих, архитектура становится проще и элегантнее: уменьшается количество зависимостей, упрощается реализация и тестирование. И самое главное — вся необходимая информация о значимости уже содержится в градиентных сигналах. Этим обеспечивается эффект переиспользования знаний, при котором одна корректировка параметров даёт двойной выигрыш: улучшает как механизм внимания, так и процедуру отбора.
Кернел QIndexAttentionGradients реализует обратное распространение ошибки через механизм внимания, отвечая за точное распределение градиентов по трём ключевым составляющим: Запросам (Query), Ключам (Key) и Значениям (Value). Рабочее пространство организовано в трёх измерениях:
- наиболее значимые Запросы;
- размерность токенов;
- головы внимания.
__kernel void QIndexAttentionGradients(__global const float* q, __global float* q_g, __global const float2* kv, __global float2* kv_g, __global const float* indexes, __global const float* scores, __global const float* gradient, const int kunits, const int heads_kv ) { //--- init const int ind_q = get_global_id(0); const int d = get_global_id(1); const int h = get_global_id(2); const int qunits = get_global_size(0); const int dimension = get_global_size(1); const int heads = get_global_size(2);
На начальном этапе каждый поток идентифицирует свои координаты в пространстве задач. Из массива индексов indexes извлекается действительный идентификатор Запроса, необходимый для корректного сопоставления с элементами в глобальных массивах. Также рассчитываются все нужные сдвиги для доступа к соответствующим участкам памяти.
const int h_kv = h % heads_kv; const int q_id = (int)(indexes[ind_q * heads + h] + 0.001f); const int shift_q = dimension * (q_id * heads + h) + d; const int shift_s = (ind_q * heads + h) * kunits; const int shift_g = h * dimension + d;
Далее, начинается первый этап вычислений — распространение градиента по Значениям (Value). В данной реализации предусмотрена возможность использования меньшего количества голов внимания для Ключей и Значений (heads_kv), по сравнению с общим числом голов (heads), обрабатывающих Запросы. Это позволяет сократить объём памяти и ускорить вычисления, без потери структурной гибкости модели. Однако, такое решение требует особого подхода к обратному распространению градиента ошибки.
Поскольку Значения (Value) могут совместно использоваться несколькими головами внимания, необходимо агрегировать вклад от всех голов, на выход которых эти значения повлияли. Это обеспечивает корректное распространение информации об ошибке обратно к значениям, от которых зависят сразу несколько информационных путей вычисления внимания.
Для каждой позиции Значений осуществляется проход по всем головам, которым потенциально доступны анализируемые элементы. Внутри этого прохода вычисляется взвешенный вклад от каждой головы — произведение градиента ошибки на уровне результатов и нормализованного коэффициента внимания (score), полученного в прямом проходе. Результаты операций аккумулируются и, в конечном счёте, сохраняются во второй компонент структуры float2 массива градиентов ошибки kv_g.
Такой механизм обеспечивает согласованность и точность обратного распространения в условиях, когда голов внимания для Ключей и Значений меньше, чем для Запросов. В результате, модель корректно обучается, несмотря на структурную асимметрию между компонентами внимания.
//--- Calculating Value's gradients int step_score = kunits * heads; if(h < heads_kv) { #pragma unroll for(int v = ind_q; v < kunits; v += qunits) { float grad = 0; for(int hq = h; hq < heads; hq += heads_kv) { int shift_score = hq * kunits + v; for(int g = 0; g < qunits; g++) grad += IsNaNOrInf(gradient[shift_g + dimension * (hq - h + g * heads)], 0) * scores[shift_score + g * step_score]; } int shift_v = dimension * (heads_kv * v + h) + d; kv_g[shift_v].s1 = IsNaNOrInf(grad, 0); } }
На следующем этапе переходим к вычислению градиентов по Запросам. Здесь ситуация более интересная, так как участвует производная функции SoftMax, которая требует дополнительных вычислений. Для каждого Запроса берётся градиент ошибки на уровне результатов, соответствующий текущей позиции, после чего происходит двойной цикл по Ключам: сначала — чтобы вычислить вклад каждого веса внимания, а затем — чтобы учесть влияние каждого Ключа, с точки зрения его нормализованного значения. Таким образом, получается аккуратное распространение сигнала ошибки через веса SoftMax, с учётом всей вероятностной структуры внимания. В финале, накопленный градиент по конкретному элементу запроса записывается в массив q_g по заранее рассчитанному сдвигу.
//--- Calculating Query's gradients float grad = 0; float out_g = IsNaNOrInf(gradient[shift_g + ind_q * dimension], 0); int shift_kv = h_kv * dimension + d; #pragma unroll for(int k = 0; (k < kunits && out_g != 0); k++) { float sc_g = 0; float sc = scores[shift_s + k]; if(sc == 0) continue; for(int v = 0; v < kunits; v++) sc_g += scores[shift_s + v] * out_g * kv[shift_kv + v * heads_kv * dimension].s1 * ((float)(k == v) - sc); grad += sc_g * kv[shift_kv + k * heads_kv * dimension].s0; } q_g[shift_q] = grad;
Далее переходим к расчёту градиентов по Ключам — одному из наиболее тонких этапов обратного прохода. Здесь важно точно определить, как каждый ключ повлиял на итоговый результат модели через механизм внимания.
Прежде всего, необходимо учитывать, что в данной реализации может использоваться различное количество голов внимания для Запросов и Ключей. Поэтому, градиент по ключу формируется с учётом вклада всех голов внимания, для которых данный Ключ использовался в расчётах внимания.
Во время прямого прохода каждая пара Запрос–Ключ порождает скалярное значение, нормализуемое функцией SoftMax. Результат сохраняется в буфере scores. Однако, для корректного расчёта градиента этого недостаточно. SoftMax является нелинейной функцией, и потому, при обратном распространении ошибки, необходимо учитывать её производную. Даже несмотря на то, что значения SoftMax уже были сохранены, для каждого входного логита необходимо вычислить чувствительность всей функции к его изменению. Это реализуется по формуле производной SoftMax, включающей как диагональный, так и внешние элементы. Таким образом, при расчёте градиента ключа, алгоритм должен пройти по всем Запросам, с которыми сопоставлялся данный Ключ, и аккумулировать их вклад.
Ключевой момент здесь — использование индексов наиболее значимых Запросов для восстановления правильной цепочки зависимостей. Без этого, распределение градиента будет некорректным.
Алгоритм итерирует по всем релевантным парам, вычисляет необходимые элементы производной SoftMax и перемножает их с градиентом ошибки на уровне результатов. Полученные значения затем аккумулируются в градиент для данного ключа и записываются в первый буфер kv_g, используемый для накопления градиентов ошибки Ключей и Значений.
//--- Calculating Key's gradients if(h < heads_kv) { #pragma unroll for(int k = ind_q; k < kunits; k += qunits) { int shift_k = dimension * (heads_kv * k + h_kv) + d; grad = 0; for(int hq = h; hq < heads; hq++) { int shift_score = hq * kunits + k; float val = kv[shift_k + heads_kv * dimension].s1; for(int scr = 0; scr < qunits; scr++) { float sc_g = 0; int shift_sc = scr * kunits * heads; float sc = scores[shift_sc + k]; if(sc == 0) continue; for(int v = 0; v < kunits; v++) sc_g += scores[shift_sc + v] * gradient[shift_g + scr * dimension] * val * ((float)(k == v) - sc); grad += IsNaNOrInf(sc_g * q[(hq + (int)(indexes[scr * heads + hq] + 0.001f) * heads) * dimension + d], 0); } } kv_g[shift_k].s0 = IsNaNOrInf(grad, 0); } } }
На этом мы завершаем рассмотрение алгоритмов построения процессов вероятностного внимания на стороне OpenCL-программы. Мы последовательно разобрали все ключевые этапы — от оценки значимости Запросов и выбора наиболее информативных элементов, до расчёта внимания и организации обратного распространения ошибки. Каждый кернел был тщательно адаптирован под особенности архитектуры ACEFormer и оптимизирован для эффективного исполнения на GPU-устройствах.
Полный исходный код реализации, включая все описанные кернелы, доступен во вложении к данной статье.
Следующим этапом нашей работы станет имплементация алгоритмов вероятностного внимания уже на стороне основной программы. Именно здесь осуществляется интеграция OpenCL-программы с логикой модели, управление буферами и синхронизация вычислений. Однако, объём текущей статьи уже достиг разумных пределов, поэтому мы предлагаем сделать небольшой перерыв и продолжить работу в следующей статье.
Заключение
В данной статье мы познакомились с концепцией фреймворка ACEFormer — архитектуры, ориентированной на высокоэффективную работу с последовательными данными в условиях ограниченных вычислительных ресурсов. Её сильные стороны — модульность, адаптивность и вычислительная экономичность — стали фундаментом всей последующей реализации.
ACEFormer предлагает элегантное решение проблемы масштабирования внимания при работе с длинными последовательностями. Вместо полной обработки всего потока исходных данных, применяется вероятностный механизм отбора наиболее значимых элементов, что позволяет существенно снизить вычислительную нагрузку без существенной потери качества. Это особенно актуально в средах, где каждая микросекунда и каждый мегабайт памяти на счету — например, в торговых платформах.
В практической части данной работы мы подробно рассмотрели реализацию всех ключевых компонентов вероятностного внимания на стороне OpenCL-программы. Следующим шагом станет реализация алгоритмов вероятностного внимания на уровне основной программы. Однако, чтобы не перегружать текущий материал, мы сделаем небольшую паузу и продолжим работу в следующей статье. Впереди нас ждёт не менее интересный и насыщенный этап интеграции.
Ссылки
- An End-to-End Structure with Novel Position Mechanism and Improved EMD for Stock Forecasting
- Другие статьи серии
Программы, используемые в статье
# | Имя | Тип | Описание |
---|---|---|---|
1 | Research.mq5 | Советник | Советник сбора примеров |
2 | ResearchRealORL.mq5 | Советник | Советник сбора примеров методом Real-ORL |
3 | Study.mq5 | Советник | Советник офлайн обучения моделей |
4 | StudyOnline.mq5 | Советник | Советник онлайн обучения моделей |
4 | Test.mq5 | Советник | Советник для тестирования модели |
5 | Trajectory.mqh | Библиотека класса | Структура описания состояния системы и архитектуры моделей |
6 | NeuroNet.mqh | Библиотека класса | Библиотека классов для создания нейронной сети |
7 | NeuroNet.cl | Библиотека | Библиотека кода OpenCL-программы |





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