Нейросети в трейдинге: Управление риском через распределение результатов (Вычислительная основа)
Введение
При оценке торговой стратегии нас интересует не только величина прибыли. Важно понимать, какие потери могут сопровождать ее получение. Стратегия способна регулярно закрывать сделки с небольшим положительным результатом, а затем отдать значительную часть заработанного за один неудачный период. Средняя доходность при этом может оставаться привлекательной. Но для практической торговли такой результат уже требует другого отношения к риску. Поэтому важно научить модель различать не только прибыльные и убыточные решения, но и характер возможных последствий.
В классическом Actor–Critic эта информация представлена лишь частично. Критик оценивает ожидаемый результат действия, а Актер стремится увеличить полученную оценку. Однако за одним средним значением могут стоять совершенно разные сочетания прибылей и убытков. Умеренный результат с небольшим разбросом и редкая крупная прибыль на фоне частых потерь способны получить близкие оценки. Для трейдера различие очевидно.
Такой подход предложен Mehrdad Moghimi и Hyejin Ku в работе "Risk-sensitive actor-critic with static spectral risk measures for online and offline reinforcement learning". Авторы фреймворка AC-SRM переходят от максимизации среднего возврата к оптимизации статической спектральной меры риска. Она позволяет придать разный вес отдельным областям распределения результатов. Например, усилить влияние неблагоприятных исходов. В таком случае высокая ожидаемая прибыль уже не будет единственным ориентиром при обучении политики. Значение получит и то, насколько тяжелыми могут оказаться неудачные продолжения торговой траектории.
При этом выбранное отношение к риску должно сохраняться на протяжении всей траектории. Нельзя на каждом шаге оценивать только оставшийся результат, игнорируя уже накопленное изменение счета. Поэтому в AC-SRM используется двухуровневая оптимизация. Внешний контур строит вспомогательную функцию риска h по распределению полного возврата. Внутренний контур обучает политику при фиксированной функции. Накопленный результат и коэффициент дисконтирования входят в расширенное состояние. Они связывают очередное решение с итогом всей траектории. После изменения политики функция h обновляется, а заданный спектр риска остается прежним.
В предыдущей статье мы разобрали эту постановку и начали ее практическую адаптацию. Прежде всего нам понадобилось распределительное представление результата. Основой для него служит уже реализованная в нашей библиотеке полностью параметризованная квантильная функция FQF. Для обучения по одному наблюдению мы подготовили кернел FQF_DistributionTargetGradient. Он находит представителя распределения, ближайшего к целевому результату, и формирует градиенты по его значению и вероятности. Так очередной наблюдаемый исход становится основой для уточнения соответствующей области распределения.
Этой теме посвятим текущую статью. Сначала подготовим выборку из распределения, чтобы получать отдельный возможный результат без переноса всего массива на CPU. Затем перейдем к спектральным весам, прямой оценке и ее градиентам. Все новые операции разместим на стороне OpenCL рядом с существующими примитивами библиотеки.

От распределения результатов к вычислениям
Распределительный Критик описывает несколько возможных продолжений одного торгового решения. Для этого в нашей реализации используются два связанных массива. На выходе cQuantile2 находятся значения возможного возврата, а на выходе cSoftMax — соответствующие вероятностные массы. Значение показывает, какой результат представляет данный элемент. Вероятность определяет его долю в общей картине исходов. Поэтому при дальнейших вычислениях важно сохранять соответствие между элементами этих массивов.
Для спектральной оценки задача другая. Нужно рассмотреть распределение целиком и определить вклад каждой его области с учетом выбранного отношения к риску. Неблагоприятный исход может получить больший спектральный вес, но его прогнозируемая вероятность от этого не меняется. Поэтому вероятности будут описывать форму распределения, а спектр — наше отношение к разным его областям. Эти два представления используют одни данные, но решают разные задачи, что и определяет дальнейшую структуру вычислений.
Построение OpenCL-программы
Выборка из распределения
Начнем с условного примера. Пусть для некоторого состояния рынка и выбранного действия Критик представляет три возможных результата: −40, 10 и 30 USD. Их вероятности равны 0.2, 0.3 и 0.5. После последовательного сложения получим границы 0.2, 0.5 и 1.0. Теперь достаточно взять равномерно распределенное случайное число и определить, в какой интервал оно попало. Значение 0.1 укажет на убыток −40 USD, 0.35 — на результат 10 USD, а 0.8 — на прибыль 30 USD.
Эту операцию выполняет кернел FQF_Sample. Он получает значения представителей, их вероятности и уже подготовленные случайные числа. Генерация случайных значений остается за его пределами. Результатом работы становится одно выбранное значение для каждого обрабатываемого распределения.
__kernel void FQF_Sample(__global float *quantiles, __global float *probabilities, __global float *random, __global float *result) { const size_t i = get_local_id(0); const size_t total = get_local_size(0); const size_t task = get_group_id(1); const size_t tasks = get_num_groups(1); //--- __local float Temp[LOCAL_ARRAY_SIZE]; //--- row = task, column = quantile, row width = total (single variable) const size_t index = RCtoFlat(task, i, tasks, total, 0); //--- every task has its own uniform random value u_t in [0, 1] const float rnd = fmin(fmax(IsNaNOrInf(random[task], 0.5f), 0.0f), 1.0f); //--- one probability read per element; NaN/Inf/negative maps to 0 const float pi = fmax(IsNaNOrInf(probabilities[index], 0.0f), 0.0f);
Одно распределение целиком размещается в одной рабочей группе. Для N представителей и T независимых задач используем пространство размером [N, T] и рабочую группу [N, 1]. Локальный индекс i выбирает представителя, а task — распределение. Функция RCtoFlat переводит эти координаты в адрес элемента общего буфера. Число представителей должно помещаться в локальный массив Temp и соответствовать допустимому размеру рабочей группы.
//--- local inclusive prefix scan: Temp[i] = CDF_i = sum_{j<=i} p_j Temp[i] = pi; BarrierLoc; for(size_t offset = 1; offset < total; offset <<= 1) { const float add = (i >= offset ? Temp[i - offset] : 0.0f); BarrierLoc; if(i >= offset) Temp[i] += add; BarrierLoc; }
Первоначально каждый элемент Temp содержит только собственную вероятность. На первом шаге потоки прибавляют значение ближайшего предшествующего элемента. Затем смещение увеличивается вдвое. Так каждый следующий шаг объединяет все более длинные участки массива. В результате Temp[i] содержит сумму вероятностей от начала распределения до текущего представителя включительно.
Перед изменением массива нужное слагаемое сохраняется в локальной переменной add. Затем BarrierLoc дожидается завершения чтения всеми потоками. Второй барьер завершает текущий шаг сложения. Благодаря этому следующий шаг использует уже обновленные значения, а чтение и запись соседних потоков не смешивают разные этапы расчета. Все промежуточные суммы остаются внутри рабочей группы.
//--- destroyed or non-finite distribution (mass ~ 0, or the float32 sum //--- overflowed to +Inf/NaN): neutral sample 0 written by i == 0 const float mass = IsNaNOrInf(Temp[total - 1], 0.0f); if(mass <= 1.0e-6f) { if(i == 0) result[task] = 0.0f; return; }
Последний элемент накопленной последовательности содержит общую массу распределения. Если она некорректна или не превышает 0.000001, первый поток записывает ноль. После этого вся группа завершает работу. Такой ноль является защитным результатом вычисления, а не прогнозом безубыточной торговли. При допустимой массе продолжаем выбор.
//--- first interval containing u_t: CDF_i >= u_t and CDF_{i-1} < u_t const float threshold = rnd * mass; const float cumulative = Temp[i]; const float previous = (i == 0 ? 0.0f : Temp[i - 1]); bool selected = cumulative >= threshold && (i == 0 || previous < threshold); //--- float rounding fallback: force the last element if(i == total - 1 && cumulative < threshold) selected = true;
Умножение случайного числа на mass переносит порог выбора на фактическую шкалу накопленных вероятностей. Поэтому нам не требуется отдельно делить каждый элемент на общую массу. Каждый поток проверяет, достигла ли его накопленная сумма порога и оставалась ли предыдущая сумма ниже него. Для первого элемента предыдущая граница равна нулю. В частности, нулевое случайное значение код относит к первому элементу.
//--- the parallel prefix scan is float32; the CDF can locally decrease by //--- one ulp, so several items may satisfy the crossing test. Keep the //--- candidate index in a private register, then LocalMin selects one //--- deterministic winner (the smallest index). The barrier after the //--- candidate guarantees every work-item already consumed its CDF value //--- (Temp) before the reduction reuses the scratch buffer. const float candidate = (selected ? (float)i : (float)total); BarrierLoc; const float winner = LocalMin(candidate, 0, Temp); BarrierLoc; //--- only the single winning item reads its centroid through RCtoFlat if(winner == (float)i) { const float value = quantiles[index]; result[task] = IsNaNOrInf(value, 0.0f); } }
Потоки-кандидаты передают в редукцию свои индексы. Остальные используют значение total, которое находится за пределами допустимого диапазона. Функция LocalMin оставляет наименьший индекс среди кандидатов. Перед ее вызовом нужен барьер. Массив Temp повторно используется как рабочая память редукции, поэтому все потоки должны завершить чтение накопленных вероятностей.
Только победивший поток считывает значение из quantiles и записывает его в result. Некорректное значение заменяется нулём. Таким образом, для каждой задачи получаем один возможный исход без выгрузки полного распределения на CPU. Кернел не выбирает специально лучший или худший результат и не рассчитывает среднее. Его задача — получить отдельную реализацию по заданным вероятностям.
Вычисление спектральных весов
При получении выборки мы опирались на вероятности возможных исходов. Теперь необходимо учесть наше отношение к этим результатам. Например, при выборе торгового решения мы можем уделять больше внимания неблагоприятным исходам. Их вероятность от этого не изменится. Изменится вклад соответствующей области распределения в итоговую оценку. Именно эту задачу решает спектр риска.
Каждому представителю квантильной функции соответствует определенный вероятностный интервал. Его ширина может меняться, поэтому недостаточно оценить спектр только в одной точке. Нужно получить вес всего интервала. Для этого введем накопленную спектральную функцию:
Φ(u) = ∫0u φ(v) dv.
Она показывает, какая доля общего спектрального веса приходится на участок от начала вероятностной шкалы до выбранной границы. Тогда вес отдельного интервала получаем простым вычитанием:
wi = Φ(τi) − Φ(τi−1).
Такую операцию удобно вынести во вспомогательную функцию SRMCumulative. Параметр u задает границу интервала, а risk_type выбирает профиль риска. Смысл risk_parameter зависит от этого профиля. Дополнительный параметр mean_weight понадобится для смешивания среднего результата с оценкой неблагоприятного хвоста.
float SRMCumulative(const float u, const int risk_type, const float risk_parameter, const float mean_weight) { const float tau = fmin(fmax(u, 0.0f), 1.0f); //--- switch(risk_type) { case SRM_MEAN: return tau;
Сначала ограничиваем переданную границу диапазоном [0, 1]. В режиме SRM_MEAN накопленный спектральный вес совпадает с самой границей. Поэтому разность двух границ даст обычную вероятностную массу интервала. Все области распределения оцениваются без дополнительного предпочтения. После суммирования взвешенных результатов получим привычное математическое ожидание.
case SRM_CVAR: { const float alpha = fmin(fmax(risk_parameter, SRM_PARAMETER_EPS), 1.0f); return fmin(fmax(fmin(tau, alpha) / alpha, 0.0f), 1.0f); } case SRM_MEAN_CVAR: { const float alpha = fmin(fmax(risk_parameter, SRM_PARAMETER_EPS), 1.0f); const float omega = fmin(fmax(mean_weight, 0.0f), 1.0f); return fmin(fmax(omega * tau + (1.0f - omega) * fmin(tau, alpha) / alpha, 0.0f), 1.0f); }
В режиме SRM_CVAR весь вес сосредоточен на нижней доле распределения, заданной параметром alpha. Например, значение 0.1 направляет оценку на худшие 10% результатов. До этой границы накопленный вес растет как отношение tau к alpha. На границе он достигает единицы и дальше не меняется. Если вероятностный интервал пересекает эту границу, разность накопленных весов учтет только попавшую в хвост часть.
Режим SRM_MEAN_CVAR позволяет сохранить внимание и к среднему результату. Его долю задает omega, а оставшаяся часть относится к CVaR. При omega, равном единице, возвращаемся к обычному ожиданию. При нулевом значении остается только хвостовая оценка. Для трейдера это способ совместить два ориентира: общий результат стратегии и последствия ее наиболее неудачных продолжений.
case SRM_EXPONENTIAL: { //--- expm1 is supported by the target OpenCL compiler const float lambda = fmax(risk_parameter, 0.0f); if(lambda <= SRM_PARAMETER_EPS) return tau; const float denominator = -expm1(-lambda); const float numerator = -expm1(-lambda * tau); return fmin(fmax(numerator / denominator, 0.0f), 1.0f); }
Экспоненциальный профиль не отсекает распределение на одной границе. Он плавно уменьшает внимание к более благоприятной области. Выраженность этого предпочтения задает lambda. Для расчета числителя и знаменателя используем expm1. При значении lambda не выше SRM_PARAMETER_EPS сразу возвращаем tau. Так почти равномерный профиль переходит в уже рассмотренный режим среднего результата.
case SRM_DUAL_POWER: { const float nu = fmax(risk_parameter, 1.0f); return fmin(fmax(1.0f - pow(1.0f - tau, nu), 0.0f), 1.0f); } default: return 0.0f; } }
Профиль SRM_DUAL_POWER использует степенную зависимость с параметром nu не меньше единицы. При единичном значении получаем tau. Увеличение параметра усиливает вес нижней области распределения. Для неизвестного режима функция возвращает ноль. Это защитная ветвь, а не еще один профиль риска. Проверка допустимости выбранного режима и его параметров остается на стороне MQL5.
Накопленная функция позволяет рассчитать вес интервала в прямом проходе. Однако при обучении его границы будут смещаться вместе с вероятностями. Чтобы определить влияние такого смещения на оценку, потребуется спектральная плотность φ. Ее вычисление вынесем во вторую вспомогательную функцию — SRMDensity. Она получает тот же набор параметров и использует те же профили.
float SRMDensity(const float u, const int risk_type, const float risk_parameter, const float mean_weight) { const float tau = fmin(fmax(u, 0.0f), 1.0f); //--- switch(risk_type) { case SRM_MEAN: return 1.0f; case SRM_CVAR: { const float alpha = fmin(fmax(risk_parameter, SRM_PARAMETER_EPS), 1.0f); return (tau < alpha ? 1.0f / alpha : 0.0f); } case SRM_MEAN_CVAR: { const float alpha = fmin(fmax(risk_parameter, SRM_PARAMETER_EPS), 1.0f); const float omega = fmin(fmax(mean_weight, 0.0f), 1.0f); return (tau < alpha ? omega + (1.0f - omega) / alpha : omega); }
Для среднего результата плотность равна единице на всей шкале. В режиме CVaR внутри выбранного хвоста она составляет 1/alpha, а выше границы равна нулю. Например, при alpha = 0.1 получаем коэффициент 10. Он не является вероятностью. Это плотность спектрального веса, которая усиливает вклад соответствующего участка. В смешанном профиле к хвостовой составляющей добавляется постоянный вклад среднего результата.
На границе tau = alpha накопленная функция имеет излом. В коде используем правостороннюю производную. Поэтому для CVaR на самой границе возвращается ноль, а для Mean-CVaR — omega. Такой выбор нужно сохранить и при последующем разборе градиентов.
case SRM_EXPONENTIAL: { //--- expm1 is supported by the target OpenCL compiler const float lambda = fmax(risk_parameter, 0.0f); if(lambda <= SRM_PARAMETER_EPS) return 1.0f; const float denominator = -expm1(-lambda); return fmax(lambda * exp(-lambda * tau) / denominator, 0.0f); } case SRM_DUAL_POWER: { const float nu = fmax(risk_parameter, 1.0f); if(nu == 1.0f) return 1.0f; return fmax(nu * pow(1.0f - tau, nu - 1.0f), 0.0f); } default: return 0.0f; } }
Для экспоненциального и степенного профилей рассчитываем производные соответствующих накопленных функций. Сохраняем и условия перехода к среднему результату. При малом lambda или единичном nu плотность равна единице. Таким образом, прямой и обратный проходы используют согласованные выражения.
Спектральная оценка распределения
Подготовленные функции позволяют перейти от отдельных вероятностных интервалов к оценке торгового решения. Теперь каждый возможный результат получит вес, который учитывает выбранное отношение к риску. В режиме среднего возврата этот вес совпадет с вероятностью исхода. При использовании CVaR внимание сместится к неблагоприятной части распределения. При этом сами прогнозируемые результаты и их вероятности останутся прежними. Изменится только способ их оценки.
Эти вычисления объединим в кернеле FQF_SRMForward. Он получает два массива распределения и параметры спектра. В отличие от выборки, случайное число здесь не требуется. Каждая рабочая группа должна рассчитать одну оценку по всем представителям своего распределения.
__kernel void FQF_SRMForward(__global float *quantiles, __global float *probabilities, __global float *result, const int risk_type, const float risk_parameter, const float mean_weight) { const size_t i = get_local_id(0); const size_t total = get_local_size(0); const size_t task = get_group_id(1); const size_t tasks = get_num_groups(1); //--- __local float Temp[LOCAL_ARRAY_SIZE]; //--- row = task, column = quantile, row width = total (single variable) const size_t index = RCtoFlat(task, i, tasks, total, 0); //--- one probability read per element; NaN/Inf/negative maps to 0 float pi = probabilities[index]; pi = fmax(IsNaNOrInf(pi, 0.0f), 0.0f);
//--- local inclusive prefix scan: Temp[i] = CDF_i = sum_{j<=i} p_j Temp[i] = pi; BarrierLoc; for(size_t offset = 1; offset < total; offset <<= 1) { const float add = (i >= offset ? Temp[i - offset] : 0.0f); BarrierLoc; if(i >= offset) Temp[i] += add; BarrierLoc; }
Этот алгоритм мы уже разобрали при реализации выборки. После завершения цикла каждый поток располагает накопленной массой до своего элемента включительно. Однако дальше вычисления расходятся. Для выборки мы переносили случайный порог на шкалу общей массы. Теперь нужно выполнить обратное преобразование: привести границы всех интервалов к диапазону [0, 1], в котором определены спектральные функции.
//--- destroyed distribution (total mass ~ 0): neutral risk 0 const float mass = Temp[total - 1]; if(IsNaNOrInf(mass, 0.0f) <= 1.0e-37f) { if(i == 0) result[task] = 0.0f; return; } //--- normalize the probability axis by the actual total mass const float inv_mass = 1.0f / mass; const float tau_high = fmin(fmax(Temp[i] * inv_mass, 0.0f), 1.0f); const float tau_low = (i == 0 ? 0.0f : fmin(fmax(Temp[i - 1] * inv_mass, 0.0f), 1.0f));
Общую массу берем из последнего элемента Temp. Если она некорректна или не превышает 10−37, первый поток записывает ноль и вся группа завершает работу. Это защитный результат, а не оценка отсутствия торгового риска. Для допустимого распределения рассчитываем обратную массу inv_mass. Умножение накопленных значений на нее дает нормированные границы tau_low и tau_high. У первого интервала нижняя граница равна нулю.
Такая нормировка сохраняет относительные доли исходов. Например, массы 0.2, 0.3, 0.5 и массы 2, 3, 5 зададут одинаковые границы. Поэтому оценка не зависит от общего масштаба допустимых входных масс. Полученные границы дополнительно ограничиваем диапазоном [0, 1]. Теперь к ним можно применить выбранный спектр.
//--- exact spectrum mass of the adaptive interval const float spectral_high = SRMCumulative(tau_high, risk_type, risk_parameter, mean_weight); const float spectral_low = SRMCumulative(tau_low, risk_type, risk_parameter, mean_weight); const float weight = fmax(spectral_high - spectral_low, 0.0f); //--- distribution value of the same FQF grid index const float zi = IsNaNOrInf(quantiles[index], 0.0f); const float contribution = weight * zi;
Два вызова SRMCumulative дают накопленный спектральный вес на границах интервала. Их разность определяет долю, которая приходится на текущего представителя. Отрицательную разность ограничиваем нулём. Затем считываем его значение из quantiles и рассчитываем вклад contribution. Индекс остается тем же, что и при чтении вероятности. Кернел не сортирует значения и не меняет соответствие между элементами массивов. Интерпретация нижних интервалов как неблагоприятных исходов предполагает упорядоченное квантильное представление.
Вернемся к нашему примеру с результатами −40, 10 и 30 USD и вероятностями 0.2, 0.3 и 0.5. В режиме SRM_MEAN спектральные веса совпадут с этими вероятностями. Итоговая оценка составит 10 USD. Теперь выберем CVaR с границей 0.25. В нижние 25% войдут все 20% исходов с результатом −40 USD и еще 5% из области с результатом 10 USD.
После нормировки на размер хвоста получим веса 0.8, 0.2 и 0. Итог составит −30 USD. Это средний результат выбранной неблагоприятной части распределения, а не прогноз убытка по каждой сделке. Пример также показывает, зачем мы вычисляем вес всего интервала. Граница CVaR проходит внутри второго интервала, поэтому его вклад учитывается частично. Отдельно разбивать этот интервал в памяти не требуется.
//--- CDF is no longer needed BarrierLoc; //--- reduce the weighted returns over the work-group const float risk = LocalSum(contribution, 0, Temp); if(i == 0) result[task] = IsNaNOrInf(risk, 0.0f); }
Остается сложить вклады представителей. Накопленные вероятности больше не нужны, поэтому массив Temp можно повторно использовать для редукции. Перед этим ставим барьер, чтобы все потоки завершили чтение границ. Функция LocalSum рассчитывает общую оценку, а первый поток записывает ее в result. Некорректный итог заменяем нулём.
Мы получили оценку представленного распределения при заданном профиле риска. Построение вспомогательной функции h и организация внешнего контура AC-SRM в этот примитив не входят.
Градиенты спектральной оценки
При расчете спектральной оценки мы учитывали и возможные результаты, и их вероятности. Теперь нужно проследить обратную зависимость. Например, оценка торгового решения может улучшиться при уменьшении глубины возможного убытка. Она также может вырасти, если сократится вероятность этого убытка. Это два разных изменения распределения. Поэтому обучающий сигнал должен пройти как через значения представителей, так и через их вероятностные массы.
Соответствующие вычисления выполним в кернеле FQF_SRMGradient. Он получает распределение, параметры спектра и входящий градиент рассчитанной оценки. Результат записывается в два отдельных буфера.
__kernel void FQF_SRMGradient(__global float *quantiles, __global float *probabilities, __global float *result_gradient, __global float *quantiles_gradient, __global float *probabilities_gradient, const int risk_type, const float risk_parameter, const float mean_weight) { const size_t i = get_local_id(0); const size_t total = get_local_size(0); const size_t task = get_group_id(1); const size_t tasks = get_num_groups(1); //--- __local float Temp[LOCAL_ARRAY_SIZE]; //--- row = task, column = quantile, row width = total (single variable) const size_t index = RCtoFlat(task, i, tasks, total, 0);
Организация работы остается прежней: одна группа обрабатывает одно распределение. Буфер result_gradient содержит по одному входящему сигналу для каждой задачи. Это не наблюдаемый торговый результат и не целевое значение для обучения распределения. Здесь передается градиент по уже рассчитанной спектральной оценке. Его влияние нужно распределить между входами прямого прохода.
//--- sanitize inputs; validity flags repeat the forward masking const float raw_pi = probabilities[index]; const bool p_valid = !(isnan(raw_pi) || isinf(raw_pi)) && raw_pi >= 0.0f; const float pi = fmax(IsNaNOrInf(raw_pi, 0.0f), 0.0f); const float raw_zi = quantiles[index]; const bool z_valid = !(isnan(raw_zi) || isinf(raw_zi)); const float zi = IsNaNOrInf(raw_zi, 0.0f); const float upstream = IsNaNOrInf(result_gradient[task], 0.0f); //--- zero upstream: gradients vanish identically (avoids 0*Inf NaN paths) if(upstream == 0.0f) { quantiles_gradient[index] = 0.0f; probabilities_gradient[index] = 0.0f; return; }
//--- local inclusive prefix scan: Temp[i] = CDF_i = sum_{j<=i} p_j Temp[i] = pi; BarrierLoc; for(size_t offset = 1; offset < total; offset <<= 1) { const float add = (i >= offset ? Temp[i - offset] : 0.0f); BarrierLoc; if(i >= offset) Temp[i] += add; BarrierLoc; } //--- destroyed distribution (total mass ~ 0): neutral gradients only const float mass = IsNaNOrInf(Temp[total - 1], 0.0f); if(mass <= 1.0e-37f) { quantiles_gradient[index] = 0.0f; probabilities_gradient[index] = 0.0f; return; } //--- normalize the probability axis by the actual total mass const float inv_mass = 1.0f / mass; const float tau_high = fmin(fmax(Temp[i] * inv_mass, 0.0f), 1.0f); const float tau_low = (i == 0 ? 0.0f : fmin(fmax(Temp[i - 1] * inv_mass, 0.0f), 1.0f));
Начнем с градиента по значению результата. При фиксированных границах вклад представителя равен его значению, умноженному на спектральный вес. Поэтому производная по самому значению совпадает с этим весом. Обозначив входящий градиент через g, получим:
giz = g · [Φ(τi) − Φ(τi−1)].
//--- exact spectrum mass of the adaptive interval (same as forward) const float spectral_high = SRMCumulative(tau_high, risk_type, risk_parameter, mean_weight); const float spectral_low = SRMCumulative(tau_low, risk_type, risk_parameter, mean_weight); const float weight = fmax(spectral_high - spectral_low, 0.0f); //--- direct centroid gradient: g_t * w_i (invalid centroid -> 0) quantiles_gradient[index] = (z_valid ? IsNaNOrInf(upstream * weight, 0.0f) : 0.0f);
Снова обращаемся к SRMCumulative и вычисляем разность накопленных весов. Затем умножаем ее на upstream. Для нашего примера с CVaR уровня 0.25 веса составляли 0.8, 0.2 и 0. Следовательно, изменение результата −40 USD влияет на оценку сильнее, чем такое же изменение результата 10 USD. Значение 30 USD находится за пределами выбранного хвоста и при фиксированных вероятностях прямого влияния на эту оценку не оказывает.
С вероятностями ситуация сложнее. Их изменение сдвигает границы интервалов. Рассмотрим сначала одну внутреннюю границу между соседними представителями. Ее смещение вправо расширяет область текущего значения и сокращает область следующего. Поэтому влияние определяется разностью соседних результатов и плотностью спектра на этой границе:
ai = g · (zi − zi+1) · φ(τi).
Так, расширение области убытка −40 USD за счет соседней области с результатом 10 USD ухудшает оценку. Насколько сильно — зависит от спектральной плотности в точке разделения. Для расчета каждому потоку потребуется значение следующего представителя.
//--- stage sanitized centroids into local memory for neighbor reads BarrierLoc; Temp[i] = zi; BarrierLoc; const float next_z = (i + 1 < total ? Temp[i + 1] : zi); //--- derivative of rho w.r.t. the adaptive CDF boundary tau_i const float boundary_gradient = (i + 1 < total ? IsNaNOrInf(upstream * (zi - next_z) * SRMDensity(tau_high, risk_type, risk_parameter, mean_weight), 0.0f) : 0.0f);
Границы уже сохранены в отдельных переменных, поэтому локальный массив Temp используем повторно. Записываем в него значения представителей и после синхронизации читаем соседний элемент. Вызов SRMDensity дает плотность спектра на текущей верхней границе. Для последнего элемента устанавливаем нулевой boundary_gradient: верхняя граница нормированного распределения закреплена на единице и не является внутренней границей между двумя исходами.
Теперь нужно перейти от границ к вероятностям. Масса текущего представителя входит в накопленные суммы его собственного и всех последующих интервалов. Поэтому собираем влияние этих границ обратным префиксным суммированием. В отличие от прямого прохода, накопление идет от конца массива к началу.
//--- reverse inclusive scan: Temp[i] = A_i = sum_{j>=i} a_j BarrierLoc; Temp[i] = boundary_gradient; BarrierLoc; for(size_t offset = 1; offset < total; offset <<= 1) { const float add = (i + offset < total ? Temp[i + offset] : 0.0f); BarrierLoc; if(i + offset < total) Temp[i] += add; BarrierLoc; } const float suffix = Temp[i];
После завершения цикла suffix содержит сумму сигналов от текущей границы до конца распределения. Обозначим ее Ai. Но это еще не окончательный градиент. В прямом проходе мы делили накопленные массы на их общую сумму M. Изменение отдельной вероятности меняет не только накопленные суммы, но и этот знаменатель.
Для учета нормировки вычисляем общую поправку B. Она представляет собой среднее значений Ai с весами pi/M. Затем вычитаем ее из каждого накопленного сигнала и делим результат на общую массу:
B = Σj (pj/M) · Aj, gip = (Ai − B)/M.
//--- normalization correction B = sum_q q_i A_i (one local reduction) const float normalized_probability = pi * inv_mass; const float correction = LocalSum(normalized_probability * suffix, 0, Temp); //--- direct probability gradient: (A_i - B) / M (invalid p -> 0) probabilities_gradient[index] = (p_valid ? IsNaNOrInf((suffix - correction) * inv_mass, 0.0f) : 0.0f); }
Общую поправку рассчитываем одной редукцией LocalSum. После этого каждый поток записывает свой градиент вероятности. Для недопустимых исходных масс оставляем ноль. Поправка необходима для согласованности с прямым проходом: пропорциональное увеличение всех масс не меняет распределение и не должно создавать направление изменения оценки.
Проверим смысл полученного выражения на режиме среднего результата. При корректных входах и единичном upstream оно упрощается до (zi − ρ)/M. В нашем примере среднее равно 10 USD, а общая масса — единице. Поэтому градиенты масс составят −50, 0 и 20. Увеличение массы результата ниже среднего снижает оценку. Увеличение массы результата выше среднего повышает ее. Если результат совпадает со средним, изменение его массы не влияет на оценку.
Градиент квантильного кодирования
При расчете спектральной оценки мы рассматривали значения результатов и их вероятности как отдельные входы. Однако внутри FQF они связаны. Вероятностные массы определяют границы интервалов, а их середины используются для квантильного кодирования. Через это кодирование модель получает информацию об уровне, для которого нужно оценить возврат. Поэтому изменение вероятности неблагоприятного исхода влияет не только на его вес в итоговой оценке. Оно также меняет входные данные той части сети, которая рассчитывает значения результатов.
Этот путь тоже должен участвовать в обратном проходе. Иначе мы учтем перераспределение спектральных весов, но потеряем влияние изменения квантильной сетки на сами прогнозируемые результаты. Для расчета дополнительного сигнала подготовим кернел FQF_CosineGradientSRM. Существующий FQF_CosineGradient оставим без изменений. Новый примитив будет использоваться в специализированном обратном проходе, не затрагивая поведение ранее рассмотренных объектов библиотеки.
Напомним вычисление, производную которого нам предстоит получить. В используемой реализации индекс начинается с нуля. Середина интервала определяется суммой предшествующих вероятностей и половиной собственной массы. Затем она преобразуется в косинусный признак:
τ̂i = Σj=0i−1 pj + pi/2, ei = cos(π · i · τ̂i).
Отсюда видны две зависимости. Собственная вероятность входит в середину интервала с коэффициентом 1/2. В середины всех последующих интервалов она входит целиком. На предшествующие уровни этот элемент не влияет. Поэтому градиент по одной вероятности складывается из собственного вклада и влияния на последующие косинусные признаки.
__kernel void FQF_CosineGradientSRM(__global float *softmax, __global float *output_gr, __global float *softmax_gr) { size_t i = get_global_id(0); size_t total = get_global_size(0); size_t action = get_global_id(1); int shift = action * total; //--- float cumul = 0;
Буфер softmax содержит вероятностные массы, использованные в прямом проходе. Через output_gr поступает градиент по выходам косинусного преобразования. Это уже сигнал, прошедший обратно через последующие вычисления квантильной ветви. Полученный вклад нужно добавить в softmax_gr, где ранее был сформирован непосредственный градиент вероятностей.
for(int it = 0; it < i; it++) cumul += softmax[shift + it]; float result = -0.5f * M_PI_F * i * sin(M_PI_F * i * (cumul + softmax[shift + i] / 2)) * output_gr[shift + i];
Сначала в cumul собираем вероятностную массу всех предшествующих элементов. Добавление половины текущей вероятности восстанавливает середину собственного интервала. Затем рассчитываем производную косинуса. Отсюда появляются отрицательный знак, синус и множитель M_PI_F · i. Коэффициент 0.5 учитывает, что собственная вероятность входит в середину интервала только наполовину.
Полученную производную умножаем на соответствующий элемент output_gr. Первая часть выражения показывает, как небольшое изменение вероятности смещает косинусный признак. Вторая показывает, насколько этот признак влияет на последующие вычисления квантильной ветви. Их произведение и дает собственный вклад текущей вероятности в градиент. После этого останется добавить влияние на все следующие уровни, границы которых также зависят от той же вероятностной массы.
for(int it = i + 1; it < total; it++) { cumul += softmax[shift + it - 1]; float temp = cumul + softmax[shift + it] / 2; result += -M_PI_F * it * sin(M_PI_F * it * temp) * output_gr[shift + it]; } softmax_gr[shift + i] += result; }
Переходим к следующему интервалу и дополняем cumul вероятностью предыдущего элемента. На каждой итерации эта переменная содержит массу слева от рассматриваемого интервала. Добавив половину его собственной вероятности, получаем середину temp. Здесь текущая вероятность pi уже входит в накопленную сумму целиком. Поэтому множителя 0.5 в производной больше нет.
Каждый последующий признак имеет свой частотный множитель it и свой входящий градиент. Их вклад последовательно добавляется в result. В частности, у элемента с индексом ноль собственный косинусный признак постоянен. Но его вероятность влияет на все последующие уровни, поэтому полный градиент этой вероятности не обязан быть нулевым.
Обратим внимание на последнюю операцию. Мы не заменяем softmax_gr, а прибавляем рассчитанный вклад. Непосредственный сигнал от спектральной свертки сохраняется. К нему добавляется влияние вероятности на значения результатов через квантильное кодирование. Теперь обе зависимости представлены в одном буфере.
Градиент вероятностной ветви по входу
После предыдущей операции в градиенте вероятностей объединены два сигнала. Первый поступил непосредственно от спектральной оценки. Второй отражает влияние квантильного кодирования на значения результатов. Теперь этот общий сигнал нужно провести через SoftMax и линейную ветвь cFraction. Так мы получим вклад вероятностного разбиения в градиент входного представления.
Для обучения торговых решений этот путь не менее важен, чем изменение самих значений возврата. Действие может влиять и на величину возможных потерь, и на вероятность неблагоприятного исхода. Если передавать назад только сигнал от значений результатов, часть этой зависимости останется неучтенной. Поэтому подготовим отдельный кернел FQF_FractionGradient, который рассчитает вклад линейной вероятностной ветви.
Здесь важно различать точки вычислительного графа. На вход нового кернела должен поступить градиент по выходу cFraction, уже прошедший через SoftMax. Непосредственно подставлять градиент по вероятностям нельзя. Линейная ветвь формирует параметры разбиения, а в вероятностные массы их превращает последующая нормировка.
Обозначим входное представление через x, а выход линейной ветви — через f. Тогда для преобразования f = Wx + b градиент по входу определяется транспонированной матрицей весов:
gxfraction = WTgf.
Каждый входной компонент связан со всеми выходами линейной ветви. Поэтому для него нужно сложить выходные градиенты, умноженные на соответствующие веса. Именно эту операцию и выполняет новый примитив.
__kernel void FQF_FractionGradient(__global float *matrix_w, __global float *gradient, __global float *input_gr, const int outputs) { const uint i = get_global_id(0); const uint inputs = get_global_size(0); const uint loc = get_local_id(1); const uint total_loc = get_local_size(1); //--- __local float Temp[LOCAL_ARRAY_SIZE];
Через matrix_w передаем веса линейной ветви, а через gradient — градиенты ее выходов. Результат записывается в input_gr. Число выходов задается параметром outputs. Размер входного представления определяется первым измерением пространства задач и сохраняется в inputs. Сам входной вектор кернелу не требуется: производная линейного преобразования по входу определяется весами.
float sum = 0; for(int k = (int)loc; k < outputs; k += (int)total_loc) sum += matrix_w[k * (inputs + 1) + i] * gradient[k];
Цикл начинается с номера текущего потока. После обработки одного выхода индекс увеличивается на total_loc. Так потоки делят между собой выходы линейной ветви и накапливают независимые частичные суммы. Если выходов меньше, чем потоков, часть участников сохранит нулевую сумму.
В адресе веса используется шаг inputs + 1. Дополнительный элемент каждой строки отведен под смещение нейрона. При расчете градиента входа он не считывается, поскольку индекс i перебирает только входные компоненты. Это соответствует производной линейного преобразования: смещение не зависит от входного значения и отдельного вклада в его градиент не дает.
if(total_loc > 1) sum = LocalSum(sum, 1, Temp); if(loc == 0) input_gr[i] = IsNaNOrInf(sum, 0.0f); }
Если в расчете участвует несколько потоков, объединяем их частичные суммы с помощью LocalSum. Второй аргумент равен единице: редукция выполняется по второму измерению рабочей группы. При единственном участнике этот шаг не нужен. Запись результата поручаем только потоку с loc = 0.
В конце важно корректно интерпретировать записываемый результат. В отличие от FQF_CosineGradientSRM, здесь используется присваивание, а не добавление. Буфер input_gr получает только вклад вероятностной ветви, поэтому его нельзя считать полным градиентом входа FQF. Вклад ветви значений будет рассчитан отдельно, а объединение обоих сигналов организуем уже в новом объекте.
Заключение
В этой статье мы продолжили адаптацию AC-SRM и подготовили вычислительные операции для работы с распределением торговых результатов. Реализованная выборка позволяет получить отдельный возможный исход с учетом его вероятности. Спектральная свертка решает другую задачу: оценивает распределение целиком с заданным отношением к риску. Теперь один и тот же набор возможных прибылей и убытков можно рассматривать как с позиции среднего результата, так и с повышенным вниманием к неблагоприятным исходам.
Для последующего обучения мы подготовили градиенты спектральной оценки. Они учитывают изменение значений результатов и перераспределение вероятностной массы. Дополнительные примитивы позволяют провести сигнал через квантильное кодирование и рассчитать вклад линейной вероятностной ветви в градиент по входу. Так мы подготовили вычисления для обоих путей влияния на оценку. В торговой задаче это позволит учитывать не только изменение величины возможного убытка, но и изменение его вероятности.
Полученные операции еще предстоит объединить в работающий объект библиотеки. Этому посвятим следующую статью. Новый класс унаследует существующий CNeuronFQF и добавит только специфичные для нашей задачи режимы работы. В нем организуем вызовы подготовленных кернелов и согласуем передачу градиентов между внутренними компонентами.
Ссылки
- Risk-sensitive actor-critic with static spectral risk measures for online and offline reinforcement learning
- Другие статьи серии
Программы, используемые в статье
| # | Имя | Тип | Описание |
|---|---|---|---|
| 1 | Trajectory.mqh | Общий модуль | Построение моделей и вспомогательные функции обучения |
| 2 | StudyForecast.mq5 | Советник | Обучение прогнозной модели |
| 3 | Study.mq5 | Советник | Обучение базовой модели |
| 4 | StudyOnline.mq5 | Советник | Онлайн-обучение модели |
| 5 | Test.mq5 | Советник | Тестирование модели на отложенном периоде |
| 6 | NeuroNet.mqh | Библиотека классов | Библиотека классов нейронных сетей |
| 7 | NeuroNet.cl | OpenCL-программа | OpenCL-ядра библиотеки нейронных сетей |
Проект представлен на forge.mql5.io/dng.
Предупреждение: все права на данные материалы принадлежат MetaQuotes Ltd. Полная или частичная перепечатка запрещена.
Данная статья написана пользователем сайта и отражает его личную точку зрения. Компания MetaQuotes Ltd не несет ответственности за достоверность представленной информации, а также за возможные последствия использования описанных решений, стратегий или рекомендаций.
Как реализовать прогнозирование с помощью AutoARIMA в MQL5
Использование Экономического календаря MQL5 для фильтрации новостей (Часть 4): Точное тестирование на истории со статическими данными
Три фильтра MACD на US_TECH100: Тестирование на данных брокера за пять лет
Автоматизация индикатора рыночной энтропии: Торговая система на основе теории информации
- Бесплатные приложения для трейдинга
- 8 000+ сигналов для копирования
- Экономические новости для анализа финансовых рынков
Вы принимаете политику сайта и условия использования