Алгоритм оптимизации кита-белухи — Beluga Whale Optimization (BWO)
Содержание
Введение
Продолжаем наш цикл, посвящённый популяционным алгоритмам оптимизации. Напомню, ради чего всё это затевалось. Мы последовательно перебираем алгоритмы природного и иного происхождения, реализуем каждый в едином фреймворке, прогоняем через один и тот же набор тестовых функций и заносим результат в общую рейтинговую таблицу. Таблица — не самоцель и не коллекция ради коллекции. Её смысл сугубо прикладной: трейдеру, который оптимизирует параметры торговой стратегии, нужен надёжный инструмент поиска, а не первый попавшийся. Чем больше алгоритмов прошло через одинаковую, честную и воспроизводимую проверку, тем увереннее можно сказать, какой из них стоит брать в работу под конкретную задачу. Поэтому мы и ищем лучших — методично, один за другим.
Сегодняшний кандидат — Beluga Whale Optimization (BWO), алгоритм оптимизации кита-белухи. Он был предложен в 2022 году в работе Zhong, Li и Meng (Knowledge-Based Systems) и, как часто бывает в этой области, опирается на поведенческую метафору. Авторы заметили в повадках белух три различных модели поведения и положили каждую в основу отдельной фазы алгоритма. Синхронное парное плавание, на котором строится разведка пространства поиска. Охотничье погружение с элементами полёта Леви — фаза эксплуатации. Падение кита — естественный механизм, когда погибшая особь опускается на дно, в алгоритме превращённый в способ обновления популяции. Идея, на мой взгляд, изящная: три качественно разных оператора, плавно сменяющих друг друга по ходу прогона, и при этом почти полное отсутствие свободных параметров — авторы подают это как одно из достоинств метода. Звучит многообещающе, и тем интереснее проверить, что из этого выйдет на нашем стенде.
Реализация алгоритма
В основе BWO лежит популяция из N китов-белух, и каждый кит — это одно candidate-решение, то есть точка в d-мерном пространстве поиска. На старте популяция расставляется случайно в пределах заданных границ, после чего начинается итеративный процесс. На каждой итерации кит выбирает одну из моделей поведения и сдвигается по соответствующему правилу; если новая позиция оказалась удачнее прежней, она принимается. Что именно будет делать кит, решает так называемый балансовый фактор.
Балансовый фактор. Это число, которое для каждого кита на каждой итерации вычисляется заново:
Bf = B0 · (1 − T / (2·Tmax))
Здесь B0 — случайное число из интервала (0, 1), T — номер текущей итерации, Tmax — их общее количество. Если Bf > 0.5, кит уходит в фазу разведки, иначе — в фазу эксплуатации. Простой пример того, как это работает во времени: в самом начале прогона множитель (1 − T/2Tmax) равен почти единице, и Bf может оказаться где угодно в (0, 1) — примерно половина китов разведывает. К концу прогона тот же множитель падает до 0.5, и Bf уже не может превысить 0.5 — все киты переходят к эксплуатации. То есть фактор работает как плавный регулятор: он не переключает фазы рывком, а постепенно смещает всю популяцию от исследования пространства к доводке найденного.
Фаза разведки — синхронное парное плавание. Белухи в природе плавают парами, зеркально повторяя движения друг друга, и авторы заложили это прямо в формулу. Кит i выбирает случайного напарника r, а его координаты обновляются попарно: в каждой паре одна координата считается через синус, вторая — через косинус, и обе используют один и тот же угол:
x[нечётная] = x_i + (x_r − x_i) · (1 + r1) · sin(2π·r2)
x[чётная] = x_i + (x_r − x_i) · (1 + r1) · cos(2π·r2)
где r1 и r2 — случайные числа из [0, 1]. Связка sin/cos и есть синхронное плавание: когда r2 пробегает свои значения, пара (sin, cos) обходит единичную окружность, то есть две координаты поворачиваются согласованно, как два пловца вокруг общей оси. Простой числовой пример. Пусть кит i стоит в точке (2.0, 5.0), напарник r — в (8.0, 1.0), опорным берётся значение 8.0, и выпало r1 = 0.3, r2 = 0.1 (тогда sin ≈ 0.588, cos ≈ 0.809, а множитель 1 + r1 = 1.3). Получаем:
новая x0 = 2.0 + (8.0 − 2.0) · 1.3 · 0.588 ≈ 6.59
новая x1 = 5.0 + (8.0 − 5.0) · 1.3 · 0.809 ≈ 8.16
Кит сместился в сторону напарника, но не по прямой к нему, а с «закруткой», заданной углом — отсюда и широкий охват пространства, нужный на стадии разведки.
Фаза эксплуатации — охота с полётом Леви. Здесь белухи действуют как стая на охоте, и движение к добыче моделируется полётом Леви. Позиция обновляется так:
X_i = r3·X_best − r4·X_i + C1 · LF · (X_r − X_i)
где r3, r4 — случайные числа из [0, 1], X_best — лучшее найденное решение, LF — вектор полёта Леви, а C1 = 2·r4·(1 − T/Tmax) задаёт силу рывка. В формуле сложены три тяги: к лучшему решению, масштабированный отскок от собственной позиции и леви-модулированная разность в сторону случайного кита. Полёт Леви — это случайное блуждание с тяжёлым хвостом: подавляющее большинство шагов мелкие, но изредка случается длинный прыжок. Простой образ: за сотню шагов гауссовский блуждатель сделает сотню примерно одинаковых мелких шажков, а леви-блуждатель — девяносто пять крошечных и несколько больших скачков. Эти редкие скачки и позволяют киту вырываться из локальной области. Множитель C1 убывает с ростом T, поэтому в начале прогона рывки крупные, а ближе к концу остаётся лишь тонкая доводка.
Падение кита. Третья модель поведения — редкая. С вероятностью
Wf = 0.1 − 0.05 · T / Tmax
кит погибает, и его место занимает новая особь. Координаты нового кита считаются таким образом:
X_i = r5·X_i − r6·X_r + r7·X_step, где X_step = (ub − lb) · exp(−C2·T/Tmax)
а C2 = 2·Wf·N. Смысл прост: погибший кит замещается смесью собственной позиции и позиции случайного сородича плюс шаг переселения. Величина этого шага задаётся экспонентой, которая быстро затухает: в начале прогона переселение размашистое и вбрасывает в популяцию разнообразие, к концу — почти нулевое. Сама вероятность Wf тоже невелика и падает с 0.1 до 0.05, так что падение кита остаётся фоновым механизмом обновления, а не основным движителем.
Одна итерация устроена так: для каждого кита вычисляется балансовый фактор, выбирается фаза (разведка/эксплуатация; иногда — падение кита), затем оценивается новая позиция и принимается, если она лучше прежней. Затем — следующая итерация, и так до исчерпания бюджета вычислений. Привлекательность BWO как раз в этой компактности: три качественно разных оператора, плавно сменяющих друг друга, и при этом почти нет параметров, которые пришлось бы вручную настраивать.

Рисунок 1. Фазы работы алгоритма BWO
Beluga Whale Optimization — три поведенческие фазы. Баланс‑фактор Bf смещает доминирующее поведение в ходе прогона; событие whale fall остаётся редким (Wf: 0.1 → 0.05).
На иллюстрации показано: три поведенческие фазы BWO — разведка (киты плавают зеркальными парами, пунктирная орбита со стрелкой передаёт синхронное движение и охват пространства), эксплуатация (кит идёт к лучшему решению — золотой звезде — траекторией полёта Леви: мелкие шаги и один редкий длинный прыжок), и падение кита (особь тонет и заменяется новой, зелёный плюс — обновление популяции). Внизу — градиентная полоса балансового фактора Bf: переход от разведки к эксплуатации по ходу прогона, с пометкой о редком и затухающем падении кита.
Напишем псевдокод алгоритма.
АЛГОРИТМ BWO (Beluga Whale Optimization) ВХОД: Npop — размер популяции Max_it — число итераций lb, ub — границы поиска nD — размерность задачи fobj — целевая функция (минимизация) ВЫХОД: xposbest, fvalbest — лучшее решение и его значение // ---- Инициализация ---- ДЛЯ каждого агента i = 1..Npop: pos[i] = rand(nD) * (ub - lb) + lb fit[i] = fobj(pos[i]) КОНЕЦ (fvalbest, index) = min(fit) xposbest = pos[index] // ---- Основной цикл ---- ДЛЯ T = 1..Max_it: WF = 0.1 - 0.05 * (T / Max_it) // вероятность падения кита ДЛЯ каждого агента i = 1..Npop: kk[i] = (1 - 0.5 * T / Max_it) * rand() // баланс разведка/эксплуатация КОНЕЦ // === Фаза 1: плавание / охота === ДЛЯ каждого агента i = 1..Npop: выбрать RJ — случайный индекс агента, RJ ≠ i ЕСЛИ kk[i] > 0.5: // --- РАЗВЕДКА (парное плавание) --- r1 = rand(); r2 = rand() ЕСЛИ nD ≤ Npop/5: // обновляем 2 случайных измерения (p1, p2) = два случайных номера измерений newpos[i][p1] = pos[i][p1] + (pos[RJ][p1] - pos[i][p2])*(r1+1)*sin(r2*360°) newpos[i][p2] = pos[i][p2] + (pos[RJ][p1] - pos[i][p2])*(r1+1)*cos(r2*360°) ИНАЧЕ: // обновляем измерения парами params = случайная перестановка измерений 1..nD ДЛЯ j = 1..floor(nD/2): newpos[i][2j-1] = pos[i][params[2j-1]] + (pos[RJ][params[1]] - pos[i][params[2j-1]])*(r1+1)*sin(r2*360°) newpos[i][2j] = pos[i][params[2j]] + (pos[RJ][params[1]] - pos[i][params[2j]])*(r1+1)*cos(r2*360°) КОНЕЦ КОНЕЦ ИНАЧЕ: // --- ЭКСПЛУАТАЦИЯ (охота, полёт Леви) --- r3 = rand(); r4 = rand() C1 = 2 * r4 * (1 - T / Max_it) // фактор интенсивности охоты LevyFlight = вектор полёта Леви (nD), множитель KD = 0.05 newpos[i] = r3*xposbest - r4*pos[i] + C1 * LevyFlight ⊙ (pos[RJ] - pos[i]) КОНЕЦ newpos[i] = ограничить границами [lb, ub] newfit = fobj(newpos[i]) ЕСЛИ newfit < fit[i]: // жадный отбор pos[i] = newpos[i]; fit[i] = newfit КОНЕЦ КОНЕЦ // === Фаза 2: падение кита (whale fall) === ДЛЯ каждого агента i = 1..Npop: ЕСЛИ kk[i] ≤ WF: выбрать RJ — случайный индекс агента r5, r6, r7 = rand() C2 = 2 * Npop * WF stepsize2 = r7 * (ub - lb) * exp(-C2 * T / Max_it) newpos[i] = (r5*pos[i] - r6*pos[RJ]) + stepsize2 newpos[i] = ограничить границами [lb, ub] newfit = fobj(newpos[i]) ЕСЛИ newfit < fit[i]: pos[i] = newpos[i]; fit[i] = newfit КОНЕЦ КОНЕЦ КОНЕЦ // === Обновление глобального лучшего === (fval, index) = min(fit) ЕСЛИ fval < fvalbest: fvalbest = fval; xposbest = pos[index] КОНЕЦ КОНЕЦ ВЕРНУТЬ xposbest, fvalbest
Приступим к реализации. Как обычно, алгоритм оформляется отдельным классом, наследующим базовый C_AO. Базовый класс даёт нам всё стандартное хозяйство — массив агентов, хранение лучшего решения, параметры; а от нас требуется реализовать четыре метода: SetParams, Init, Moving и Revision.
//+------------------------------------------------------------------+ //| Beluga Whale Optimization | //+------------------------------------------------------------------+ class C_AO_BWO : public C_AO { public: ~C_AO_BWO() {} C_AO_BWO() { ao_name = "BWO"; ao_desc = "Beluga Whale Optimization"; ao_link = "https://www.mql5.com/ru/users/joo"; popSize = 30; KD = 0.05; ArrayResize(params, 2); params [0].name = "popSize"; params [0].val = popSize; params [1].name = "KD"; params [1].val = KD; } void SetParams(); bool Init(const double &rangeMinP [], const double &rangeMaxP [], const double &rangeStepP [], const int epochsP = 0); void Moving(); void Revision(); double KD; // интенсивность полёта Леви (оригинал: 0.05) private: double snap_c []; // [popSize*coords] — снимок популяции double snap_f []; // [popSize] — снимок фитнесов int shuf []; // [coords] — буфер перестановки координат int epochs, epochsDen, epochNow; // счётчики итераций double levySigma; // константа масштаба полёта Леви double Gauss(); // стандартное нормальное число double LevyStep(); // один отсчёт полёта Леви };
Стоит обратить внимание на параметры. Их всего два — и это особенность BWO: алгоритм почти беспараметрический. popSize — размер популяции, общий для всех методов серии. KD — интенсивность полёта Леви; в оригинальной статье это зашитая константа 0.05, мы вынесли её в параметры лишь для удобства экспериментов. Всё остальное — балансовый фактор, вероятность падения кита, силовые коэффициенты — алгоритм вычисляет сам по ходу прогона.
Внутренние буферы: snap_c и snap_f хранят снимок популяции на начало итерации — он нужен для жадного отбора (greedy acceptance): если новая позиция оказалась хуже, мы откатываемся к снимку. shuf — рабочий массив для перестановки индексов координат в фазе разведки. Три счётчика epochs, epochsDen, epochNow нужны, чтобы внутри алгоритма знать отношение T/Tmax. И levySigma — предвычисленная константа для полёта Леви.
Инициализация. StandardInit поднимает базовое хозяйство, после чего мы расставляем стартовую популяцию случайно — пока в массив cP, на первом же Moving она перельётся в рабочие координаты. Дальше резервируем буферы и настраиваем счётчик итераций.
//+------------------------------------------------------------------+ //| Init | //+------------------------------------------------------------------+ bool C_AO_BWO::Init(const double &rangeMinP [], const double &rangeMaxP [], const double &rangeStepP [], const int epochsP = 0) { if(!StandardInit(rangeMinP, rangeMaxP, rangeStepP)) return false; for(int i = 0; i < popSize; i++) for(int c = 0; c < coords; c++) a [i].cP [c] = u.RNDfromCI(rangeMin [c], rangeMax [c]); ArrayResize(snap_c, popSize * coords); ArrayResize(snap_f, popSize); ArrayResize(shuf, coords); epochs = epochsP; epochsDen = (epochs > 1) ? epochs - 1 : 1; epochNow = 0; //--- sigma для полёта Леви (Mantegna), beta = 1.5 double beta = 1.5; double g1 = 1.3293403881791; // Г(2.5) double g2 = 0.9064024770554; // Г(1.25) levySigma = MathPow((g1 * MathSin(M_PI * beta / 2.0)) / (g2 * beta * MathPow(2.0, (beta - 1.0) / 2.0)), 1.0 / beta); return true; }
Последний блок требует пояснения. Полёт Леви по схеме Mantegna использует константу sigma, которая зависит от гамма-функции, sigma при фиксированном beta = 1.5 — это просто число, поэтому значения Г(2.5) и Г(1.25) посчитаны заранее, а сама sigma вычисляется один раз в Init (получается около 0.6966).
Полёт Леви разнесён на два вспомогательных метода:
//+------------------------------------------------------------------+ //| Gauss — стандартное нормальное (Box–Muller) | //+------------------------------------------------------------------+ double C_AO_BWOm::Gauss() { double u1 = u.RNDfromCI(0.0, 1.0); double u2 = u.RNDfromCI(0.0, 1.0); if(u1 < 1e-15) u1 = 1e-15; return MathSqrt(-2.0 * MathLog(u1)) * MathCos(2.0 * M_PI * u2); } //+------------------------------------------------------------------+
//+------------------------------------------------------------------+ //| LevyStep — один отсчёт полёта Леви (Mantegna) | //+------------------------------------------------------------------+ double C_AO_BWOm::LevyStep() { double uu = Gauss() * levySigma; double vv = Gauss(); double av = MathAbs(vv); if(av < 1e-300) av = 1e-300; return KD * (uu / MathPow(av, 1.0 / 1.5)); }
Moving — ядро алгоритма. Прежде чем смотреть код, одна важная оговорка о переносе. В оригинале одна итерация BWO — это два хода и две оценки целевой функции: сначала все киты делают ход плавания или охоты, затем у части особей дополнительно срабатывает падение кита. Наш фреймворк отводит ровно одну оценку функции на агента за эпоху. Поэтому три модели поведения сведены во взаимоисключающие ветви: за одну эпоху кит делает либо разведку, либо эксплуатацию, либо падение кита. Диапазон балансового фактора при этом разбивается без зазоров.
Начало метода — обработка самого первого прогона и снятие снимка:
//+------------------------------------------------------------------+ //| Moving | | //+------------------------------------------------------------------+ void C_AO_BWO::Moving() { if(!revision) // первый прогон: cP -> c { for(int i = 0; i < popSize; i++) for(int c = 0; c < coords; c++) a [i].c [c] = u.SeInDiSp(a [i].cP [c], rangeMin [c], rangeMax [c], rangeStep [c]); return; } epochNow++; double tRatio = (double)epochNow / (double)epochsDen; // отношение T/Tmax if(tRatio > 1.0) tRatio = 1.0; for(int i = 0; i < popSize; i++) // снимок популяции { for(int c = 0; c < coords; c++) snap_c [i * coords + c] = a [i].c [c]; snap_f [i] = a [i].f; } double WF = 0.1 - 0.05 * tRatio; // вероятность падения кита
На первой эпохе популяция ещё не оценена, поэтому мы просто переливаем стартовые координаты в рабочий массив и выходим — внешний цикл оценит их. На всех последующих эпохах увеличиваем счётчик, считаем отношение T/Tmax, снимаем снимок и вычисляем текущую вероятность падения кита.
Дальше — основной цикл по китам. Для каждого считается балансовый фактор и выбирается случайный сосед:
for(int i = 0; i < popSize; i++) { double Bf = (1.0 - 0.5 * tRatio) * u.RNDprobab(); // балансовый фактор int RJ = i; // случайный сосед RJ != i if(popSize > 1) while(RJ == i) RJ = u.RNDminusOne(popSize);
Первая ветвь — падение кита, срабатывает при Bf ≤ WF:
if(Bf <= WF) { double r5 = u.RNDprobab(), r6 = u.RNDprobab(), r7 = u.RNDprobab(); double C2 = 2.0 * (double)popSize * WF; double decay = MathExp(-C2 * tRatio); for(int c = 0; c < coords; c++) { double step = r7 * (rangeMax [c] - rangeMin [c]) * decay; double v = r5 * snap_c [i * coords + c] - r6 * snap_c [RJ * coords + c] + step; a [i].c [c] = u.SeInDiSp(v, rangeMin [c], rangeMax [c], rangeStep [c]); } }
Вторая ветвь — разведка, парное плавание, при Bf > 0.5. Координаты обрабатываются попарно: нечётная через синус, чётная через косинус. Опорой служит одна координата соседа, индексы координат предварительно перемешиваются:
else if(Bf > 0.5) { double r1 = u.RNDprobab(), r2 = u.RNDprobab(); double sinA = MathSin(2.0 * M_PI * r2); double cosA = MathCos(2.0 * M_PI * r2); for(int c = 0; c < coords; c++) // незатронутые координаты — прежние a [i].c [c] = snap_c [i * coords + c]; for(int c = 0; c < coords; c++) shuf [c] = c; // перестановка Фишера-Йетса for(int c = 0; c < coords - 1; c++) { int r = c + u.RNDminusOne(coords - c); int t = shuf [c]; shuf [c] = shuf [r]; shuf [r] = t; } int refDim = shuf [0]; double ref = snap_c [RJ * coords + refDim]; // ОДИН скаляр-опора int pairs = coords / 2; for(int j = 0; j < pairs; j++) { int dA = 2 * j, dB = 2 * j + 1; int pA = shuf [2 * j], pB = shuf [2 * j + 1]; double xA = snap_c [i * coords + pA]; double xB = snap_c [i * coords + pB]; double vA = xA + (ref - xA) * (r1 + 1.0) * sinA; double vB = xB + (ref - xB) * (r1 + 1.0) * cosA; a [i].c [dA] = u.SeInDiSp(vA, rangeMin [dA], rangeMax [dA], rangeStep [dA]); a [i].c [dB] = u.SeInDiSp(vB, rangeMin [dB], rangeMax [dB], rangeStep [dB]); } }
Третья ветвь — эксплуатация, охота с полётом Леви, для всех остальных значений Bf:
else { double r3 = u.RNDprobab(), r4 = u.RNDprobab(); double C1 = 2.0 * r4 * (1.0 - tRatio); for(int c = 0; c < coords; c++) { double xi = snap_c [i * coords + c]; double xr = snap_c [RJ * coords + c]; double Lv = LevyStep(); double v = r3 * cB [c] - r4 * xi + C1 * Lv * (xr - xi); a [i].c [c] = u.SeInDiSp(v, rangeMin [c], rangeMax [c], rangeStep [c]); } } } }
Все три ветви — прямой перенос формул, разобранных в предыдущем разделе. Граничные условия мы не выписываем отдельно: функция SeInDiSp, через которую проходит каждая новая координата, заодно и зажимает её в допустимый диапазон.
Revision делает две вещи: обновляет личные и глобальный рекорды и выполняет жадный отбор — если новая позиция кита оказалась хуже снимка, координаты и фитнес откатываются назад. Именно поэтому популяция в BWO не деградирует: ход может быть неудачным, но он не закрепляется.
//+------------------------------------------------------------------+ //| Revision | //+------------------------------------------------------------------+ void C_AO_BWO::Revision() { for(int i = 0; i < popSize; i++) // обновление лучших { if(a [i].f > a [i].fB) { a [i].fB = a [i].f; ArrayCopy(a [i].cB, a [i].c, 0, 0, coords); } if(a [i].f > fB) { fB = a [i].f; ArrayCopy(cB, a [i].c, 0, 0, coords); } } if(!revision) { revision = true; return; } for(int i = 0; i < popSize; i++) // greedy acceptance: откат если хуже if(a [i].f < snap_f [i]) { for(int c = 0; c < coords; c++) a [i].c [c] = snap_c [i * coords + c]; a [i].f = snap_f [i]; } }
Результаты тестов
Прогоняем стандартный стенд: размер популяции 30, бюджет 10000 вычислений функции, 10 повторов на каждую конфигурацию.
Итог — 47.51%. Казалось бы, можно записывать результат и переходить к следующему алгоритму. Но если вглядеться не в общий балл, а в то, как он распределён по размерности, обнаруживается странность. Посмотрите на строку Hilly: 0.6354 на 10 координатах, 0.6083 на 50 и вдруг 0.6898 на 1000. С ростом размерности счёт не падает, а растёт — и на тысяче координат оказывается выше, чем на десяти. Для популяционного алгоритма на фиксированном бюджете вычислений это противоестественно.
Монотонный спад счёта по размерности — это норма, физика процесса. Подъём — это сигнал, что часть результата получена не поиском. И сигнал тем громче, чем сильнее подъём: у BWO счёт на 1000 координат не просто не упал, он превысил результат на 10 координатах.
BWO|Beluga Whale Optimization|30.0|0.05|
=============================
5 Hilly's; Func runs: 10000; result: 0.6353865355283055
25 Hilly's; Func runs: 10000; result: 0.6082998984102445
500 Hilly's; Func runs: 10000; result: 0.6898049949555738
=============================
5 Forest's; Func runs: 10000; result: 0.42042059098839246
25 Forest's; Func runs: 10000; result: 0.34230526219017604
500 Forest's; Func runs: 10000; result: 0.34595127333449394
=============================
5 Megacity's; Func runs: 10000; result: 0.488
25 Megacity's; Func runs: 10000; result: 0.34586666666666666
500 Megacity's; Func runs: 10000; result: 0.39973333333333316
=============================
All score: 4.27577 (47.51%)
В композитном тесте десять координат раздаются пяти разным функциям: пара Hilly, пара Forest, пара Megacity, пара Peaks, пара Skin, а фитнес агента — среднее по всем пяти. Замысел простой: любой геометрический трюк, который алгоритм мог бы провернуть, опирается на повторяемость — на то, что все пары устроены одинаково. В композите пять функций имеют пять разных оптимумов с разной геометрией, поэтому такой трюк способен дать выигрыш максимум на одной паре из пяти и на общем счёте теряется. Композитный тест — это, по сути, проверка: держится ли результат, когда геометрическую опору выбивают из-под ног.
BWO на этом тесте набирает 38.57%. Теперь сравним. На обычном стенде те же первые функции на 10 координатах дали Hilly 0.6354, Forest 0.4204, Megacity 0.4880 — в среднем около 0.515. В композите общий результат — 0.386. То есть на первых функциях, которые поодиночке выглядели вполне достойно, в смешанной раскладке динамика заметно проседает. И это уже знак. Композитный тест не делает задачу принципиально другой. Он лишь лишает алгоритм возможности эксплуатировать одинаковость пар.
BWO|Beluga Whale Optimization |30.0|0.05|
=============================
Composite anti-cheat test: Hilly + Forest + Megacity + Peaks + Skin
Coordinates: 10; Epochs: 333; Repeats: 10
=============================
Run 1/10: 0.37161205365313077
Run 2/10: 0.40112339728582336
Run 3/10: 0.3706551427065614
Run 4/10: 0.4216333585559708
Run 5/10: 0.4101199993944876
Run 6/10: 0.3934049133990568
Run 7/10: 0.3593573524858875
Run 8/10: 0.3965450586725149
Run 9/10: 0.3584922015596293
Run 10/10: 0.3737824222225442
=============================
Average result: 0.3856725900 (38.57%)
Давайте разберём подробнее, что же всё-таки происходит с алгоритмом во время работы.

Рисунок 2. Коллапс координат в разведке
На этапе exploration в BWO один опорный скаляр приводит к коллапсу всего агента. Множитель M = (1 + r₁)·sin(2π·r₂) остаётся постоянным для всего агента. Когда M ≈ 1, каждая обновлённая координата становится равной опорной X[RJ][P1] — вектор схлопывается.
Вскрытие, часть первая: фаза разведки.
Симптомы у нас есть, теперь нужна причина. Открываем Moving и смотрим на операторы по очереди. Начнём с разведки — той ветви, где Bf > 0.5. Вот её сердцевина, уже знакомая по разделу о реализации:
double r1 = u.RNDprobab(), r2 = u.RNDprobab(); // разыграны ОДИН раз на агента double sinA = MathSin(2.0 * M_PI * r2); double cosA = MathCos(2.0 * M_PI * r2); int refDim = shuf [0]; double ref = snap_c [RJ * coords + refDim]; // ОДИН скаляр на все пары for(int j = 0; j < pairs; j++) { double xA = snap_c [i * coords + pA]; double vA = xA + (ref - xA) * (r1 + 1.0) * sinA; ... }
В этом фрагменте два факта, которые по отдельности безобидны, а вместе дают проблему. Первый: опорное значение ref — это один скаляр, одна координата случайного соседа, и она одна и та же для всех пар цикла. Второй: r1 и r2 разыгрываются до цикла, то есть один раз на агента, поэтому sinA, cosA и множитель (1 + r1) — постоянные величины для всех координат этого кита.
Распишем, что тогда получает каждая координата. Формула обновления v = x + (ref − x)·(1+r1)·sin раскрывается в смесь:
newpos[d] = M · ref + (1 − M) · x[d], где M = (1 + r1) · sin(2π·r2)
и M — константа по агенту (для нечётных координат; для чётных то же самое с косинусом). Это ключевой момент. Все нечётные координаты кита проходят через одно и то же аффинное преобразование, с одним и тем же M , в сторону одного и того же ref. И если M оказался близок к единице, то newpos[d] = ref — для всех нечётных координат сразу. Вектор кита, каким бы длинным он ни был, схлопывается в два значения: одно для нечётных координат, другое для чётных. Наглядно это показано на иллюстрации (рисунок 1) — слева разнообразный набор координат, справа от него после одного хода разведки не остаётся почти ничего.
Почему это чит именно на нашем стенде? Стандартный тест — это N копий одной и той же функции. Глобальный оптимум такой задачи осесимметричен по построению: все пары координат должны прийти в одну и ту же точку. А оператор разведки BWO как раз и тянет кита к осесимметричному виду — вектору из двух повторяющихся значений. То есть склонность алгоритма схлопывать агента бьёт прямо в структуру правильного ответа, и не потому, что алгоритм хорошо ищет, а потому, что форма его хода случайно совпала с формой оптимума.
Почему эффект усиливается с размерностью — то, что мы видели в строке Hilly? На 1000 координат каждый кит несёт тысячу координатных значений. По статистике порядка хотя бы одно из них почти наверняка окажется очень близким к оптимальной координате функции. Оператор разведки берёт удачное значение у соседа и за один ход размножает его на половину пространства, а жадный отбор закрепляет результат — ведь на стенде из одинаковых функций залить хорошее значение сразу в сотни пар означает резко поднять средний фитнес. Чем больше размерность, тем богаче запас координат у каждого кита и тем лучше посевной материал для широковещания. Отсюда и противоестественный рост счёта.
Напрашивается первая правка: убрать оба источника широковещания. Пусть каждая пара координат тянется к своей одноимённой паре соседа, а r1 и r2 разыгрываются заново на каждую пару:
for(int j = 0; j < pairs; j++) { int dA = 2 * j, dB = 2 * j + 1; double r1 = u.RNDprobab(), r2 = u.RNDprobab(); // на КАЖДУЮ пару double sinA = MathSin(2.0 * M_PI * r2); double cosA = MathCos(2.0 * M_PI * r2); double xA = snap_c [i * coords + dA]; double refA = snap_c [RJ * coords + dA]; // своя одноимённая координата соседа double vA = xA + (refA - xA) * (r1 + 1.0) * sinA; ... }
Геометрия парного плавания при этом сохраняется — внутри пары синус и косинус по-прежнему делят один угол и стоят на 90° друг от друга, — но общий скаляр-опора исчез, и единого по агенту множителя больше нет. Схлопывать вектор целиком теперь нечем.
Прогоняем стенд заново. И вот тут начинается самое интересное: общий балл изменился, но профиль по размерности всё ещё нездоровый — на больших размерностях алгоритм по-прежнему набирает подозрительно много. Фаза разведки была не единственным каналом. Где спрятался второй — в следующем разделе.
Вскрытие, часть вторая: фаза эксплуатации.
Эксплуатационная фаза BWO: член r₃·cB сканирует главную диагональ, далее на картинке.

Рисунок 3. Диагональное сканирование в эксплуатации
Красный луч — это r₃·cB: когда r₃ пробегает диапазон [0,1], кандидат скользит по отрезку от начала координат к cB. На наборе идентичных функций вектор cB осесимметричен, поэтому этот луч совпадает с главной диагональю — именно там расположены пики целевой функции. В высоких размерностях шум Леви усредняется.
После правки разведки прогон показал, что аномалия никуда не делась — на больших размерностях счёт по-прежнему завышен, и алгоритм всё так же «считывает диагональ». Значит, канал не один. Открываем вторую ветвь Moving — эксплуатацию, ту, где идёт охота с полётом Леви:
double r3 = u.RNDprobab(), r4 = u.RNDprobab(); // r3 — ОДИН скаляр на агента double C1 = 2.0 * r4 * (1.0 - tRatio); for(int c = 0; c < coords; c++) { double xi = snap_c [i * coords + c]; double xr = snap_c [RJ * coords + c]; double Lv = LevyStep(); double v = r3 * cB [c] - r4 * xi + C1 * Lv * (xr - xi); ... }
Болевая точка здесь — слагаемое r3·cB. Это скаляр, умноженный на вектор глобального лучшего решения. Вспомним, что на стенде из одинаковых функций cB осесимметричен — все его пары координат сидят в одной точке. И когда r3 пробегает диапазон [0, 1], точка r3·cB скользит по лучу из начала координат в cB. А луч из начала координат в осесимметричную точку — это и есть главная диагональ пространства поиска.
Дальше — хуже. Как только популяция стянулась на эту диагональ (а она стягивается, потому что жадный отбор закрепляет удачные ходы вдоль луча), позиция самого кита xi тоже оказывается на диагонали. Тогда весь скелет хода r3·cB − r4·xi — это линейная комбинация двух коллинеарных векторов, и она не выходит с диагональной прямой. Единственное слагаемое, которое уводит кандидата в сторону от диагонали, — это полёт Леви.
И вот тут вступает размерность. Жадный отбор у нас принимает кандидата по среднему фитнесу — среднему значению Core по всем парам координат. Скелет r3·cB − r4·xi сдвигает все пары когерентно, в одну сторону, поэтому он надёжно двигает это среднее. А слагаемое Леви сдвигает каждую пару в свою независимую случайную сторону — и на больших размерностях его вклад в среднее усредняется в ноль по закону больших чисел. Отбор попросту перестаёт видеть Леви. В итоге на тысячемерной задаче алгоритм ведёт себя как на одномерной: подбирает r3 для сканирования диагонали. Чем выше размерность — тем чище усредняется шум Леви и тем ближе поведение алгоритма к чистому сканированию диагонали. Этот механизм наглядно показан на иллюстрации (рисунок 2).
Теперь видно, что оба канала — это одна и та же болезнь. И в разведке, и в эксплуатации виноват один и тот же шаблон: случайный множитель, разыгранный раз на агента и домноженный на структурированный вектор. В разведке это был общий скаляр-опора X[RJ][P1], здесь — r3, домноженный на cB. Любой такой скаляр запирает кандидата в низкоразмерное подпространство, и на стенде из одинаковых функций это подпространство совпадает с диагональю, где лежит оптимум. Отсюда и формулируется правило честной правки: ни один случайный скаляр-на-агента не должен домножать осмысленный вектор.
Применяем это правило. В эксплуатации убираем масштаб-скаляр r3·cB и делаем тягу к лучшему решению покоординатной интерполяцией — каждая координата подтягивается к своему cB[c] на свою собственную случайную долю:
double r4 = u.RNDprobab(); double C1 = 2.0 * r4 * (1.0 - tRatio); for(int c = 0; c < coords; c++) { double r3c = u.RNDprobab(); // на КАЖДУЮ координату double xi = snap_c [i * coords + c]; double xr = snap_c [RJ * coords + c]; double Lv = LevyStep(); double v = xi + r3c * (cB [c] - xi) + C1 * Lv * (xr - xi); ...
Тем же приёмом лечится и фаза падения кита: множители r5 и r6 становятся покоординатными, чтобы скелет r5·xi − r6·xr не запирал кандидата в плоскость двух векторов:
for(int c = 0; c < coords; c++) { double r5c = u.RNDprobab(); double r6c = u.RNDprobab(); double step = r7 * (rangeMax [c] - rangeMin [c]) * decay; double v = r5c * snap_c [i * coords + c] - r6c * snap_c [RJ * coords + c] + step; ... }
Стоит оговорить, что мы оставили на агента и почему это не нарушает правило. Коэффициенты C1 (затухание Леви) и r7 (магнитуда шага падения кита) по-прежнему разыгрываются раз на агента. Но они домножают не структурированные векторы, а покоординатно-случайные — вектор Леви и шаг переселения. Равномерный масштаб случайного вектора не схлопывает его в подпространство, диагонали не создаёт. Запирает только скаляр, домножающий осмысленный вектор вроде cB или xi , — а такие убраны все три.
Получившуюся версию назовём BWOm. Трёхфазная структура, балансовый фактор, вероятность падения кита, полёт Леви и временное затухание — всё на месте; изменился лишь способ розыгрыша случайности, теперь он покоординатный во всех трёх фазах. Осталось прогнать стенд и посмотреть, что покажет честный профиль, — этим займёмся в следующем разделе.

Рисунок 4. График профиля по размерности
Результаты тестирования:
BWOm|Beluga Whale Optimization (modified)|30.0|0.05|
=============================
5 Hilly's; Func runs: 10000; result: 0.7130978249513108
25 Hilly's; Func runs: 10000; result: 0.5129375854531272
500 Hilly's; Func runs: 10000; result: 0.29394203648024203
=============================
5 Forest's; Func runs: 10000; result: 0.7714504280808662
25 Forest's; Func runs: 10000; result: 0.42740547099458537
500 Forest's; Func runs: 10000; result: 0.09803668589845292
=============================
5 Megacity's; Func runs: 10000; result: 0.656
25 Megacity's; Func runs: 10000; result: 0.35386666666666666
500 Megacity's; Func runs: 10000; result: 0.13465333333333432
=============================
All score: 3.96139 (44.02%)
Сведём оба варианта результатов работы алгоритма в одну таблицу:
| Функция, размерность | BWO (оригинал) | BWOm (исправленный) |
|---|---|---|
| Hilly, 10 | 0.635 | 0.713 |
| Hilly, 50 | 0.608 | 0.513 |
| Hilly, 1000 | 0.690 | 0.294 |
| Forest, 10 | 0.420 | 0.771 |
| Forest, 50 | 0.342 | 0.427 |
| Forest, 1000 | 0.346 | 0.098 |
| Megacity, 10 | 0.488 | 0.656 |
| Megacity, 50 | 0.346 | 0.354 |
| Megacity, 1000 | 0.400 | 0.135 |
Профиль по размерности — на иллюстрации выше — теперь читается однозначно. У BWOm счёт по всем трём функциям монотонно убывает с ростом размерности: никакого подъёма на 1000 координат, кривая идёт так, как и положено идти кривой честного популяционного алгоритма на фиксированном бюджете. Аномалия, с которой начиналось расследование, закрыта.
Дальше — два наблюдения, которые мне кажутся самыми важными во всей этой истории.
Первое. На 10 координатах BWOm обыгрывает оригинал по всем трём функциям, и с заметным отрывом: Hilly 0.713 против 0.635, Forest 0.771 против 0.420, Megacity 0.656 против 0.488. То есть геометрический трюк не просто надувал счёт на больших размерностях — он одновременно душил настоящий поиск на малых, запирая китов в осесимметричное подпространство вместо полноценного исследования пространства. Убрав чит, мы не отняли у алгоритма силу, а вернули её.
Второе. На 1000 координат картина обратная — честная BWOm проседает ниже оригинала, особенно резко на Forest: 0.098 против 0.346. Но эти 0.346 у BWO и были диагональным сканированием. Уберите механизм — и обнажится истинная сложность тысячемерного Forest на таком бюджете. 0.098 — это не провал реализации, это честная цена задачи.
Поэтому и общий балл, упавший с 47.51% до 44.02%, надо читать правильно. Это не регресс — это дефляция фиктивной части счёта. 44.02% BWOm заработаны поиском, а не формой стенда; 47.51% BWO — нет. Косвенное подтверждение мы получили ещё в начале: на композитном анти-чит тесте, где геометрический трюк не работает, оригинал давал 38.57% — и это куда ближе к честным низкоразмерным числам BWOm, чем к репозиторным 47.51%.
Что из этого следует для нашей рейтинговой таблицы. Таблица существует, чтобы трейдер мог выбрать инструмент для оптимизации торговой стратегии. А реальная задача оптимизации стратегии — это всегда разнородный ландшафт: параметры стопа, тейка, фильтров, размера позиции имеют разную природу и разные оптимумы. Алгоритм, набравший высокий балл диагональным трюком, на такой задаче просто не сработает — трюку не за что зацепиться. Поэтому в таблицу должна идти честная цифра. BWO в его исходном виде претендовать на честное место не может; BWOm с его 44.02% занимает законную позицию — скромную, но настоящую.
И два вывода более общего характера, которые стоит вынести из этой статьи читателю.
Диагностический. Немонотонность счёта по размерности — это красный флаг. Если алгоритм на фиксированном бюджете вычислений работает на больших размерностях не хуже, а лучше, он почти наверняка не оптимизирует, а считывает геометрию стенда. Проверять профиль по размерности стоит у каждого нового кандидата в таблицу, и композитный анти-чит тест здесь — надёжный второй индикатор.
Механический. Источник чита в BWO — повторяющийся шаблон: случайный скаляр, разыгранный раз на агента и домноженный на структурированный вектор. Такой множитель запирает кандидата в низкоразмерное подпространство. Это типовая ловушка проектирования метаэвристик, и теперь мы знаем, как её распознавать и лечить — заменой скалярной случайности на покоординатную.
Теперь можем посмотреть результаты прогона античит-теста, они соответствуют результатам по первым размерностям основного теста, однако заметен значительный разброс занчений.
BWOm|Beluga Whale Optimization (modified)|30.0|0.05|
=============================
Composite anti-cheat test: Hilly + Forest + Megacity + Peaks + Skin
Coordinates: 10; Epochs: 333; Repeats: 10
=============================
Run 1/10: 0.6768112016070393
Run 2/10: 0.8580079686168114
Run 3/10: 0.7773490639079661
Run 4/10: 0.6239346517249487
Run 5/10: 0.8031346429925448
Run 6/10: 0.7329426521976851
Run 7/10: 0.5579271937001347
Run 8/10: 0.7388632225403626
Run 9/10: 0.6460757525932408
Run 10/10: 0.7374171635629155
=============================
Average result: 0.7152463513 (71.52%)
=============================
Визуализация работы алгоритма BWOm на тестовых функциях и демонстрация работы на некоторых других.

BWOm на тестовой функции Hilly

BWOm на тестовой функции Forest

BWOm на тестовой функции Megacity

BWOm на тестовой функции Rosenbrock

BWOm на тестовой функции Ackley
Прежде чем выставлять алгоритму окончательную оценку, разумно потратить немного времени на параметры — мы их специально оставляли как в статье. Defaults были такими: popSize = 30, KD = 0.05. С ними BWOm давал честные, но скромные 44.02%. KD = 0.05 — это, по сути, не настраиваемый параметр, а вшитая константа полёта Леви из эталонной реализации, и она консервативна. После того как мы убрали геометрический чит, полёт Леви стал единственным дальнобойным механизмом в фазе эксплуатации, и его масштаб напрямую задаёт энергию поиска. На наших трёх многомодальных функциях значение 0.05 даёт слишком короткие прыжки.
Поднимем KD и одновременно уменьшим популяцию — при фиксированном бюджете в 10000 вычислений меньшая популяция означает больше итераций на агента, что даёт покоординатной интерполяции к лучшему решению отработать на ход глубже. С popSize = 20 и KD = 1.5 получаем:
=============================
5 Hilly's; Func runs: 10000; result: 0.784881775284155
25 Hilly's; Func runs: 10000; result: 0.5687271425947741
500 Hilly's; Func runs: 10000; result: 0.2955752923941583
=============================
5 Forest's; Func runs: 10000; result: 0.9137000614171915
25 Forest's; Func runs: 10000; result: 0.6176092379746694
500 Forest's; Func runs: 10000; result: 0.129880770631638
=============================
5 Megacity's; Func runs: 10000; result: 0.8133333333333332
25 Megacity's; Func runs: 10000; result: 0.4994666666666667
500 Megacity's; Func runs: 10000; result: 0.1500400000000007
=============================
All score: 4.77321 (53.04%)
Композит 83.96% — это не «хорошо», это очень хорошо: BWOm, лишившись чита, не просто остался конкурентоспособным, а на низкой размерности вышел в верхнюю часть таблицы. А теперь посмотрим на дисперсию результатов, она высока. Это типичная подпись алгоритма с агрессивным механизмом дальнего прыжка на многоэкстремальном ландшафте: либо случайный длинный шаг Леви попадает в правильный бассейн притяжения, либо нет. У оригинальной BWO такого выбора не было — её диагональный трюк работал стабильно и всегда давал одну и ту же скромную цифру около 0.39. Стабильность была обманчивой.
BWOm|Beluga Whale Optimization (modified)|20.0|1.5|
=============================
Composite anti-cheat test: Hilly + Forest + Megacity + Peaks + Skin
Coordinates: 10; Epochs: 500; Repeats: 10
=============================
Run 1/10: 0.8510959078175775
Run 2/10: 0.6776209662686439
Run 3/10: 0.6864531057459684
Run 4/10: 0.9823673728411345
Run 5/10: 0.9999793806023446
Run 6/10: 0.6755386763260092
Run 7/10: 0.9720418753408225
Run 8/10: 0.8871892660061993
Run 9/10: 0.7957332506268917
Run 10/10: 0.8675285275873976
=============================
Average result: 0.8395548329 (83.96%)
Рассмотрим ниже иллюстрацию, которая показывает прямой контраст двух распределений на одной шкале. Композитный античит‑тест: разброс 10 прогонов. BWO плотно кластеризуется вокруг значения 0.39 — его диагональное сканирование стабильно извлекает тот же скромный результат. BWOm лежит в диапазоне от 0.68 до 1.00 и показывает двумодальный характер: агрессивный шаг Леви либо попадает в глобальный бассейн (в одном прогоне значение достигло 0.9999).

Рисунок 5. Девиация распределений двух алгоритмов.
Верхняя строка — десять прогонов оригинальной BWO: точки плотно скучены в коридоре 0.36–0.42, светлая полоса — это ±1σ (ширина ~5% от шкалы), вертикальная риска — среднее 0.386. Подпись под кластером: диагональное сканирование — стабильный механизм, то есть алгоритм надёжно извлекал из стенда одну и ту же скромную цифру, прогон за прогоном на анти-чит тесте.
Нижняя строка — десять прогонов BWOm: те же точки и та же ±1σ полоса, но теперь полоса в шесть раз шире — 0.71–0.97, — и три точки слева и три точки справа выпадают даже за её пределы. Бимодальная структура наглядна: слева кластер неудачных (0.676–0.687), справа кластер удачных (0.972–1.000), причём самый правый прогон практически на границе шкалы — это та самая попытка, где алгоритм добрался до глобального оптимума с результатом 0.9999.
По результатам тестирования алгоритм BWOm занимает 38-ю строчку нашего рейтинга лучших популяционных методов оптимизации.
| cc | AO | Description | Hilly | Hilly Final | Forest | Forest Final | Megacity (discrete) | Megacity Final | Final Result | % of MAX | ||||||
| 10 p (5 F) | 50 p (25 F) | 1000 p (500 F) | 10 p (5 F) | 50 p (25 F) | 1000 p (500 F) | 10 p (5 F) | 50 p (25 F) | 1000 p (500 F) | ||||||||
| 1 | ANS | across neighbourhood search | 1,00000 | 0,88228 | 0,40138 | 2,28366 | 1,00000 | 0,95281 | 0,28092 | 2,23373 | 0,94667 | 0,85733 | 0,22389 | 2,02789 | 6,545 | 72,72 |
| 2 | AMOm | animal migration optimization M | 0,91624 | 0,83603 | 0,46790 | 2,22017 | 0,98482 | 0,92010 | 0,36391 | 2,26883 | 0,91733 | 0,81707 | 0,25177 | 1,98617 | 6,475 | 71,94 |
| 3 | CLA | code lock algorithm (joo) | 0,95139 | 0,86199 | 0,37879 | 2,19217 | 0,99349 | 0,93500 | 0,26497 | 2,19346 | 0,93600 | 0,84267 | 0,24060 | 2,01927 | 6,405 | 71,17 |
| 4 | (P+O)ES | (P+O) evolution strategies | 0,86571 | 0,89539 | 0,39740 | 2,15850 | 0,97761 | 0,89820 | 0,26878 | 2,14459 | 0,92133 | 0,80240 | 0,23952 | 1,96325 | 6,266 | 69,62 |
| 5 | SDSm | stochastic diffusion search M | 0,95195 | 0,84944 | 0,36249 | 2,16388 | 0,98061 | 0,88457 | 0,22112 | 2,08630 | 0,92267 | 0,79013 | 0,21380 | 1,92660 | 6,177 | 68,63 |
| 6 | AAm | archery algorithm M | 0,84685 | 0,73320 | 0,42590 | 2,00595 | 0,96709 | 0,77837 | 0,27789 | 2,02335 | 0,86133 | 0,77707 | 0,28712 | 1,92552 | 5,955 | 66,17 |
| 7 | SIA | simulated isotropic annealing (joo) | 0,93543 | 0,86504 | 0,38483 | 2,18530 | 0,94069 | 0,80609 | 0,23835 | 1,98513 | 0,86400 | 0,66160 | 0,19536 | 1,72096 | 5,891 | 65,46 |
| 8 | TETA | time evolution travel algorithm (joo) | 0,91452 | 0,86369 | 0,25579 | 2,03400 | 0,99654 | 0,91291 | 0,14394 | 2,05339 | 0,85467 | 0,82213 | 0,10443 | 1,78123 | 5,869 | 65,21 |
| 9 | ESG | evolution of social groups (joo) | 0,98111 | 0,79857 | 0,31167 | 2,09135 | 0,98954 | 0,82270 | 0,15032 | 1,96256 | 0,92133 | 0,73440 | 0,15315 | 1,80888 | 5,863 | 65,14 |
| 10 | CTA | comet tail algorithm (joo) | 0,92435 | 0,86786 | 0,27838 | 2,07059 | 0,99039 | 0,84571 | 0,19448 | 2,03058 | 0,95467 | 0,69680 | 0,11008 | 1,76155 | 5,863 | 65,14 |
| 11 | ECBO | enhanced colliding bodies optimization | 0,94024 | 0,72363 | 0,32356 | 1,98743 | 0,99477 | 0,80291 | 0,13056 | 1,92824 | 0,87600 | 0,70160 | 0,17433 | 1,75193 | 5,668 | 62,98 |
| 12 | DA | dialectical algorithm | 0,93117 | 0,75400 | 0,26205 | 1,94722 | 0,98925 | 0,81375 | 0,08662 | 1,88962 | 0,92667 | 0,68107 | 0,11315 | 1,72089 | 5,558 | 61,76 |
| 13 | BBO | biogeography based optimization | 0,95876 | 0,70609 | 0,35752 | 2,02237 | 0,92981 | 0,70660 | 0,16970 | 1,80611 | 0,87467 | 0,63013 | 0,20813 | 1,71293 | 5,541 | 61,57 |
| 14 | BHAm | black hole algorithm M | 0,79558 | 0,76207 | 0,34682 | 1,90447 | 0,99836 | 0,75798 | 0,13826 | 1,89460 | 0,85067 | 0,64427 | 0,17020 | 1,66514 | 5,464 | 60,71 |
| 15 | HS | harmony search | 0,91420 | 0,69049 | 0,29924 | 1,90393 | 0,97627 | 0,73373 | 0,14193 | 1,85193 | 0,91733 | 0,62720 | 0,15364 | 1,69817 | 5,454 | 60,60 |
| 16 | RFO | royal flush optimization (joo) | 0,80989 | 0,74481 | 0,34546 | 1,90016 | 0,95251 | 0,77926 | 0,15185 | 1,88362 | 0,80400 | 0,66427 | 0,19071 | 1,65898 | 5,443 | 60,48 |
| 17 | BOAm | billiards optimization algorithm M | 0,76177 | 0,72421 | 0,25275 | 1,73873 | 0,90890 | 0,81960 | 0,28853 | 2,01703 | 0,83733 | 0,74613 | 0,09763 | 1,68109 | 5,437 | 60,41 |
| 18 | ASO | anarchy society optimization | 0,73070 | 0,73713 | 0,31195 | 1,77978 | 0,99732 | 0,87700 | 0,17619 | 2,05051 | 0,72000 | 0,68773 | 0,18988 | 1,59761 | 5,428 | 60,31 |
| 19 | EOm | extremal optimization_M | 0,76527 | 0,75205 | 0,31908 | 1,83640 | 0,99999 | 0,76426 | 0,12437 | 1,88862 | 0,84133 | 0,64133 | 0,15247 | 1,63513 | 5,360 | 59,56 |
| 20 | ACS | artificial cooperative search | 0,75545 | 0,77162 | 0,31653 | 1,84360 | 1,00000 | 0,80488 | 0,10705 | 1,91193 | 0,76933 | 0,60800 | 0,14157 | 1,51890 | 5,274 | 58,60 |
| 21 | SSG | saplings sowing and growing | 0,75436 | 0,63206 | 0,35935 | 1,74577 | 0,91907 | 0,69694 | 0,19755 | 1,81356 | 0,81867 | 0,60533 | 0,21347 | 1,63747 | 5,197 | 57,74 |
| 22 | AOSm | atomic orbital search M | 0,76184 | 0,68435 | 0,31344 | 1,75963 | 0,90015 | 0,80044 | 0,11501 | 1,81560 | 0,82800 | 0,63280 | 0,15696 | 1,61776 | 5,193 | 57,70 |
| 23 | TSEA | turtle shell evolution algorithm (joo) | 0,95809 | 0,64852 | 0,29571 | 1,90232 | 0,99522 | 0,58104 | 0,10542 | 1,68168 | 0,92133 | 0,52160 | 0,14567 | 1,58860 | 5,173 | 57,48 |
| 24 | DE | differential evolution | 0,96398 | 0,62346 | 0,26089 | 1,84833 | 0,98482 | 0,77018 | 0,11459 | 1,86959 | 0,93067 | 0,36213 | 0,11000 | 1,40280 | 5,121 | 56,90 |
| 25 | BIO | blood inheritance optimization (joo) | 0,72580 | 0,66522 | 0,31228 | 1,70330 | 0,99995 | 0,68125 | 0,11540 | 1,79660 | 0,85467 | 0,59333 | 0,15364 | 1,60164 | 5,102 | 56,69 |
| 26 | (PO)ES | (PO) evolution strategies | 0,73972 | 0,58190 | 0,38896 | 1,71058 | 0,91199 | 0,59975 | 0,21262 | 1,72436 | 0,82400 | 0,56240 | 0,23432 | 1,62072 | 5,056 | 56,18 |
| 27 | BO | bonobo optimizer | 0,75555 | 0,64366 | 0,32657 | 1,72578 | 0,94332 | 0,70442 | 0,13999 | 1,78773 | 0,73467 | 0,61440 | 0,16728 | 1,51635 | 5,030 | 55,89 |
| 28 | SRA | successful restaurateur algorithm (joo) | 0,89010 | 0,63359 | 0,29115 | 1,81484 | 0,96634 | 0,55285 | 0,08914 | 1,60833 | 0,89333 | 0,52800 | 0,13911 | 1,56044 | 4,984 | 55,38 |
| 29 | CRO | chemical reaction optimisation | 0,91281 | 0,65681 | 0,29866 | 1,86828 | 0,90513 | 0,56020 | 0,10939 | 1,57472 | 0,82800 | 0,50133 | 0,14149 | 1,47082 | 4,914 | 54,60 |
| 30 | BCOm | bacterial chemotaxis optimization M | 0,82589 | 0,61733 | 0,31584 | 1,75906 | 0,95296 | 0,63718 | 0,11984 | 1,70998 | 0,76533 | 0,51653 | 0,15800 | 1,43986 | 4,909 | 54,54 |
| 31 | DOA | dream optimization algorithm | 0,78522 | 0,78121 | 0,36036 | 1,92679 | 0,61584 | 0,42117 | 0,12254 | 1,15955 | 0,86667 | 0,72587 | 0,21127 | 1,80381 | 4,890 | 54,33 |
| 32 | ABO | african buffalo optimization | 0,92295 | 0,62528 | 0,29885 | 1,84708 | 0,92992 | 0,57468 | 0,09372 | 1,59832 | 0,73333 | 0,51333 | 0,14324 | 1,38990 | 4,835 | 53,72 |
| 33 | BSA | bird swarm algorithm | 0,94432 | 0,67941 | 0,26401 | 1,88774 | 0,91649 | 0,65619 | 0,12054 | 1,69322 | 0,80933 | 0,33547 | 0,10652 | 1,25132 | 4,832 | 53,69 |
| 34 | TSm | tabu search M | 0,87806 | 0,61040 | 0,28993 | 1,77839 | 0,98116 | 0,52165 | 0,08544 | 1,58825 | 0,82667 | 0,49547 | 0,13552 | 1,45766 | 4,824 | 53,60 |
| 35 | BSA | backtracking search algorithm | 0,87128 | 0,53190 | 0,28675 | 1,68993 | 0,92408 | 0,51602 | 0,09153 | 1,53163 | 0,96000 | 0,47253 | 0,13760 | 1,57013 | 4,792 | 53,24 |
| 38 | BWOm | beluga_whale_optimization_M | 0,78488 | 0,56872 | 0,29557 | 1,64917 | 0,91370 | 0,61760 | 0,12988 | 1,66118 | 0,81333 | 0,49946 | 0,15004 | 1,46283 | 4,773 | 53,04 |
| 36 | WOAm | whale optimization algorithm M | 0,93893 | 0,59477 | 0,26695 | 1,80065 | 0,98036 | 0,53873 | 0,07112 | 1,59021 | 0,78667 | 0,47600 | 0,11892 | 1,38159 | 4,772 | 53,02 |
| 37 | ACA | andean_condor_algorithm | 0,78444 | 0,53260 | 0,33108 | 1,64812 | 0,79071 | 0,44960 | 0,10685 | 1,34716 | 0,92266 | 0,67733 | 0,17613 | 1,77612 | 4,771 | 53,02 |
| 39 | CSO | competitive swarm optimizer | 0,85151 | 0,60786 | 0,29896 | 1,75833 | 0,84085 | 0,58491 | 0,11974 | 1,54550 | 0,80000 | 0,48560 | 0,14184 | 1,42744 | 4,731 | 52,57 |
| 40 | FBA | fractal-based algorithm | 0,69419 | 0,64267 | 0,28955 | 1,62641 | 0,99812 | 0,54905 | 0,08705 | 1,63422 | 0,76133 | 0,51253 | 0,13689 | 1,41075 | 4,671 | 51,90 |
| 41 | ECOi | eco-inspired evolutionary algorithm | 0,78817 | 0,54402 | 0,29360 | 1,62579 | 0,88996 | 0,46592 | 0,09747 | 1,45335 | 0,78533 | 0,45173 | 0,14295 | 1,38001 | 4,459 | 49,54 |
| 42 | BSO | brain storm optimization | 0,92207 | 0,57625 | 0,29732 | 1,79564 | 0,80764 | 0,42508 | 0,09448 | 1,32720 | 0,77200 | 0,36533 | 0,13065 | 1,26798 | 4,391 | 48,79 |
| 43 | CAm | camel algorithm M | 0,71534 | 0,56917 | 0,35985 | 1,64436 | 0,84094 | 0,47174 | 0,10850 | 1,42118 | 0,70400 | 0,41947 | 0,19563 | 1,31910 | 4,385 | 48,72 |
| 44 | ACOm | ant colony optimization_M | 0,71885 | 0,48410 | 0,30990 | 1,51285 | 0,75792 | 0,48639 | 0,11871 | 1,36302 | 0,83600 | 0,48667 | 0,16148 | 1,48415 | 4,360 | 48,44 |
| 45 | BSO | brain storm optimization | 0,91331 | 0,55813 | 0,29705 | 1,76849 | 0,85329 | 0,44038 | 0,09447 | 1,38814 | 0,72267 | 0,32880 | 0,13325 | 1,18472 | 4,341 | 48,23 |
| RW | random walk | 0,49970 | 0,32333 | 0,25791 | 1,08094 | 0,30754 | 0,11470 | 0,04400 | 0,46624 | 0,36133 | 0,17013 | 0,10244 | 0,63390 | 2,181 | 24,23 | |
Выводы
Итак, общий балл вырос с 44.02% до 53.04% — это уверенная верхне-средняя часть рейтинговой таблицы. Профиль по размерности при этом остался монотонно убывающим по всем трём функциям — то есть прироста за счёт каких-то новых артефактов не возникло, мы получили просто лучше настроенный честный оптимизатор. И на низкой размерности результаты сильные: Hilly 0.78, Forest 0.91, Megacity 0.81 — это серьёзные цифры.
Композитный анти-чит тест в той же настройке дал 83.96%. На минуту вглядимся в эту цифру. Стандартный стенд на 10 координатах усреднил BWOm по трём функциям как (0.7849 + 0.9137 + 0.8133)/3 ≈ 0.8373. Композит на тех же 10 координатах, но из пяти разных функций с разными оптимумами, даёт 0.8396. Совпадение почти посимвольное. Это и есть визитная карточка честной оптимизации: на одном и том же бюджете и размерности алгоритм работает одинаково хорошо что на однородном, что на разнородном ландшафте — потому что не цепляется за форму стенда, а действительно ищет. У оригинального BWO здесь был провал с 47.51% до 38.57%, у BWOm никакого провала нет.
Сильные стороны. Главное — алгоритм после правки выдаёт честный результат и силён там, где это действительно важно для прикладной оптимизации торговых стратегий: на низкой и средней размерности. Лишь немногие методы из таблицы способны показать одновременно 0.83 на гомогенном бенче и 0.84 на композите. Профиль по размерности корректный, без подъёмов и аномалий. Параметров для настройки всего два — popSize и KD , — то есть пользоваться алгоритмом легко, а KD к тому же оказался полезным управляемым параметром, а не догмой 0.05 из статьи. Концептуально устройство тоже привлекательное: три качественно разных оператора, плавно переключающихся балансовым фактором, плюс полёт Леви, дающий редкие, но критичные дальние прыжки для выхода из локальных оптимумов.
Слабые стороны. Главная — резкое падение качества с ростом размерности. На 1000 координат Forest даёт лишь 0.13, Megacity 0.15 — это уже не уровень верхних строк рейтинга, а скромная середина. Покоординатная случайность во всех трёх фазах, которую мы внесли при правке, по построению не накачивает среднее усреднённой по парам приёмкой; на тысячемерных задачах с фиксированным бюджетом этого не хватает. Так что BWOm — не выбор для оптимизации задач с сотнями параметров. Второй минус — фаза падения кита. Она редкая (Wf не превышает 0.1 и затухает к 0.05), и её вклад в общую динамику невелик; на части тестов её можно было бы выключить почти без потерь, что слегка обесценивает её роль в концепции. Третий — наследие оригинала. Опубликованный алгоритм в чистом виде мухлюет, и без понимания, где и почему, можно занести в таблицу несправедливую цифру 47.51%. Этот разбор, собственно, для того и нужен.
Где использовать. Для типичной задачи трейдера — оптимизация набора параметров стратегии в диапазоне от единиц до нескольких десятков значений — BWOm в нашей таблице один из вариантов: низкая размерность, разнородный ландшафт параметров (стопы, тейки, фильтры, ATR-множители — всё с разной природой), необходимость уходить из локальных оптимумов. Полёт Леви и честный покоординатный поиск здесь работают на полную. Если же стратегия параметризована очень богато (сотни весов нейросети, тысячи переменных в портфельной задаче), BWOm уступит специализированным высокоразмерным методам — и здесь резонно смотреть на верхние строки таблицы для соответствующей категории.
Практический смысл для трейдера. Алгоритм, у которого 30% прогонов отстают от среднего на 16-18 процентных пунктов, нельзя запускать один раз и доверять результату — нужна стратегия мульти-рестарта. Прогон BWOm с тремя-пятью независимыми инициализациями и выбором лучшего результата практически гарантирует попадание в верхнюю часть распределения (вероятность не получить ни одного удачного прогона из пяти при ~70% доли удачных — около 0.3⁵ ≈ 0.2%). Для серьёзной задачи оптимизации стратегии 3-5 рестартов — копейки, в общем бюджете вычислений ничего не меняет. А вот без рестартов одинокая 0.68 в реальной задаче ставит трейдера в положение то ли стратегия слабая, то ли оптимизатор не дотянул, и это плохая позиция.
В следующей статье возьмём очередного кандидата в таблицу — и инструменты, которыми мы здесь обзавелись (проверка профиля по размерности, композитный анти-чит тест, правило никаких скаляров-на-агента, домножающих структурированный вектор), пойдут в работу с первой же строки.

Рисунок 6. Цветовая градация алгоритмов по соответствующим тестам

Рисунок 7. Гистограмма результатов тестирования алгоритмов (по шкале от 0 до 100, чем больше, тем лучше, где 100 — максимально возможный теоретический результат), в архиве скрипт для расчёта рейтинговой таблицы
Плюсы и минусы алгоритма BWOm:
Плюсы:
- Небольшое количество внешних параметров.
- Быстрый.
Минусы:
- Склонность к застреванию.
К статье прикреплён архив с актуальными версиями кодов алгоритмов. Автор статьи не несёт ответственности за абсолютную точность в описании канонических алгоритмов, во многие из них были внесены изменения для улучшения поисковых возможностей. Выводы и суждения, представленные в статьях, основываются на результатах проведённых экспериментов.
Программы, используемые в статье
| # | Имя | Тип | Описание |
|---|---|---|---|
| 1 | #C_AO.mqh | Включаемый файл | Родительский класс популяционных алгоритмов оптимизации |
| 2 | #C_AO_enum.mqh | Включаемый файл | Перечисление популяционных алгоритмов оптимизации |
| 3 | TestFunctions.mqh | Включаемый файл | Библиотека тестовых функций |
| 4 | TestStandFunctions.mqh | Включаемый файл | Библиотека функций тестового стенда |
| 5 | TestStand3D.mqh | Включаемый файл | 3D-панель визуализации для тестового стенда |
| 6 | Utilities.mqh | Включаемый файл | Библиотека вспомогательных функций |
| 7 | CalculationTestResults.mqh | Включаемый файл | Скрипт для расчёта результатов в сравнительную таблицу |
| 8 | Test_AO_All.mq5 | Скрипт | Единый испытательный стенд для всех популяционных алгоритмов оптимизации |
| 9 | Test_AO_AntiCheat | Скрипт | Тест на читерство алгоритмов оптимизации |
| 10 | Simple use of population optimization algorithms.mq5 | Скрипт | Простой пример использования популяционных алгоритмов оптимизации без визуализации |
| 11 | Test_AO_BWOm.mq5 | Скрипт | Испытательный стенд для BWOm |
Предупреждение: все права на данные материалы принадлежат MetaQuotes Ltd. Полная или частичная перепечатка запрещена.
Данная статья написана пользователем сайта и отражает его личную точку зрения. Компания MetaQuotes Ltd не несет ответственности за достоверность представленной информации, а также за возможные последствия использования описанных решений, стратегий или рекомендаций.
Разработка инструментария для анализа Price Action (Часть 58): Модуль анализа сжатия диапазона и классификации зрелости
Нейросети в трейдинге: Принятие торговых решений с учётом неопределённости (Модули прогнозирования и планирования)
Разработка инструментария для анализа Price Action (Часть 57): Создание модуля классификации состояния рынка на MQL5
- Бесплатные приложения для трейдинга
- 8 000+ сигналов для копирования
- Экономические новости для анализа финансовых рынков
Вы принимаете политику сайта и условия использования