English 中文 Español Deutsch 日本語 Português
preview
Нейросети — это просто (Часть 39): Go-Explore — иной подход к исследованию

Нейросети — это просто (Часть 39): Go-Explore — иной подход к исследованию

MetaTrader 5Эксперты | 28 апреля 2023, 16:46
1 374 21
Dmitriy Gizlyk
Dmitriy Gizlyk

Введение

Мы продолжаем тему исследования окружающей среды в обучении с подкреплением. В предыдущих статьях данной серии мы уже рассмотрели алгоритмы изучения окружающей среды через любопытство и несогласие в ансамбле моделей. Оба подхода эксплуатировали внутреннее вознаграждение для стимулирования агента совершать различные действия в похожих ситуациях, исследуя новые области. Но проблема в том, что внутреннее вознаграждение снижается по мере изучения окружающей среды. И в сложных ситуациях редкого вознаграждения, или когда на пути к вознаграждению агент может получить штрафы, такой подход может оказать не очень эффективным. В данной статье я предлагаю познакомиться с несколько иным подходом к изучению окружающей среды — алгоритмом Go-Explore.


1. Алгоритм Go-Explore

Go-Explore - это алгоритм обучения с подкреплением, предназначенный для нахождения оптимальных решений в сложных задачах, которые имеют большое пространство действий и состояний. Алгоритм был разработан Adrien Ecoffet и был описан в статье "Go-Explore: a New Approach for Hard-Exploration Problems".

Он использует методы эволюционных алгоритмов и машинного обучения, чтобы эффективно искать оптимальные решения в сложных и неразрешимых задачах.

Алгоритм начинается с исследования большого числа случайных путей, называемых "базовыми исследованиями". Затем, используя эволюционный алгоритм, он сохраняет лучшие найденные решения и комбинирует их для создания новых путей. Эти новые пути затем сравниваются с предыдущими лучшими решениями, и, если они лучше, они сохраняются. Этот процесс повторяется до тех пор, пока не будет найдено оптимальное решение.

Go-Explore также использует технику называемую "рекордер", чтобы сохранять лучшие найденные решения и переиспользовать их для создания новых путей. Это позволяет алгоритму находить более оптимальные решения, чем если бы он просто продолжал исследовать случайные пути.

Одной из главных преимуществ Go-Explore является его способность находить оптимальные решения в сложных и неразрешимых задачах, где другие алгоритмы обучения с подкреплением могут потерпеть неудачу. Он также способен эффективно обучаться в условиях разреженных наград, что может быть проблемой для других алгоритмов.

В целом, Go-Explore представляет собой мощный инструмент для решения задач обучения с подкреплением и может быть эффективно применен в различных областях, включая робототехнику, компьютерные игры и искусственный интеллект в целом.

Основная идея Go-Explore заключается в том, чтобы запоминать и возвращаться к перспективным состояниям. И является фундаментальной для эффективной эксплуатации в условиях небольшого количества наград. Эта идея настолько гибкая и обширная, что может быть реализована различными способами. 

В отличие от большинства алгоритмов обучения с подкреплением, Go-Explore сосредоточен не на прямом решении целевой задачи, а на нахождении релевантных состояний и действий в пространстве состояний, которые могут привести к достижению целевого состояния. Для этого алгоритм имеет две основные фазы: поиск и переиспользование.

Первая фаза состоит в том, чтобы пройти через все состояния в пространстве состояний, и записать каждое посещенное состояние в "карту" состояний. После этого алгоритм начинает изучать каждое посещенное состояние более подробно и собирать информацию о действиях, которые могут привести к другим интересным состояниям.

Вторая фаза заключается в переиспользовании ранее изученных состояний и действий для нахождения новых решений. Алгоритм сохраняет наиболее успешные траектории и использует их для генерации новых состояний, которые могут привести к еще более успешным решениям.

Алгоритм Go-Explore работает следующим образом:

  1. Сбор базы примеров (archive): агент начинает игру, записывает каждое достижение и сохраняет его в архив. Вместо хранения самих состояний, архив содержит описания действий, которые привели к достижению конкретного состояния.
  2. Итеративное исследование (iterative exploration): на каждой итерации агент выбирает случайное состояние из архива и производит переигрывание игры с этого состояния. Он сохраняет любые новые состояния, которые ему удается достичь, и добавляет их в архив вместе с описанием действий, которые привели к этим состояниям.
  3. Обучение на базе примеров: после итеративного исследования, алгоритм обучается на базе примеров, которые он собрал, используя какой-либо алгоритм обучения с подкреплением.
  4. Повторение: алгоритм повторяет итеративное исследование и обучение на базе примеров до тех пор, пока не достигнет желаемого уровня производительности.

Цель алгоритма Go-Explore - минимизировать количество переигрываний игры, необходимых для достижения высокого уровня производительности. Он позволяет агенту исследовать большое пространство состояний, используя базу примеров, что ускоряет процесс обучения и позволяет достичь более высокой производительности.

В целом, Go-Explore является достаточно мощным и эффективным алгоритмом, который показывает хорошие результаты в решении сложных задач обучения с подкреплением.


2. Реализация средствами MQL5

В своей реализации, в отличии от всех ранее рассмотренных, мы не будем объединять весь алгоритм в одной программе. Этапы алгоритма Go-Explore настолько различны, что эффективнее будет создать отдельную программу для каждого этапа.

2.1. Фаза 1 — исследование

Первой мы создадим программу для реализации первой фазы алгоритма — исследование окружающей среды и сбор базы примеров. Перед началом реализации нам необходимо определить основы выстраиваемого алгоритма.

Начиная изучение окружающей среды нам необходимо максимально полно исследовать все её состояния. На данном этапе мы не ставим цели поиска оптимальной стратегии. И здесь, как это не покажется странным, мы не будем использовать нейронную сеть. Ведь мы не ищем стратегию и не оптимизируем политику. Это задача второй фазы. На данном этапе мы просто будем совершать случайные действия несколькими агентами и записывать все состояния системы, которые посетит каждый агент.

Но таким образом мы получим кучу случайных не связанных состояний. А как же исследование окружающей среды. Ведь каждый агент совершит только одно действие из каждого состояния, не узнав положительные и отрицательные стороны других действий. Здесь нам поможет следующий шаг алгоритма. мы случайным образом или с использованием некой определенной нами политики выбираем из базы состояния. Повторяем все шаги до достижения этого состояния. И дальше вновь случайным способом определяем действия агента до достижения финала изучаемого эпизода. Новые состояния мы также добавляем в нашу базу примеров.

В этих двух шагах алгоритма заключается первая фаза — исследование.

Надо обратить внимание еще на один момент. Для эффективного исследования нам необходимо использовать насколько агентов. И здесь, для параллельного запуска нескольких независимых агентов мы воспользуемся многопоточным оптимизатором Тестера стратегий. По результатам каждого прохода агент будет передавать свою накопленную базу состояний в единый центр для обобщения.

Определив основные точки выстраиваемого алгоритма, мы можем приступить к его реализации. И начнем мы работу с создания структуры для записи состояния и пути к его достижению. Здесь мы сталкиваемся с первым ограничением: в тестере стратегий для передачи результатов каждого прохода Тестер стратегий позволяет передавать массив любого типа. Но в нём не должно быть сложных структур, использующих строковые значения и динамические массивы. Значит, для описания пути и состояния системы мы не можем использовать динамические массивы. Нам необходимо сразу определить их размерность. Для гибкости организации программы мы выведем основные значения в константы. В них мы определим глубину анализируемой истории в барах (HistoryBars) и размер буфера пути (Buffer_Size). Вы можете использовать свои значения, необходимые для решения конкретной задачи. 

#define                    HistoryBars  20            
#define                    Buffer_Size  600
#define                    FileName     "GoExploer"

Кроме того, мы сразу укажем имя файла для записи базы примеров.

Непосредственно записывать данные мы будем в формате структуры Cell. В ней мы создадим 2 массива. Один целочисленных значений для записи пути достижения состояния — actions. Второй массив вещественных значений для записи описания достигнутого состояния — state. Так как мы вынуждены использовать статические массивы данных, введем переменную total_actions для указания размера пройденного пути. Дополнительно добавим вещественную переменную value для записи значения веса состояния. Его мы будем использовать для приоритезации выбора состояний для последующего изучения.

//+------------------------------------------------------------------+
//| Cell                                                             |
//+------------------------------------------------------------------+
struct Cell
  {
   int               actions[Buffer_Size];
   float             state[HistoryBars * 12 + 9];
   int               total_actions;
   float             value;
//---
                     Cell(void);
//---
   bool              Save(int file_handle);
   bool              Load(int file_handle);
  };

Созданные переменные и массивы мы инициализируем в конструкторе структуры. При создании структуры мы заполняем массив пути значением "-1". А массив состояния и переменные нулевыми значениями.

Cell::Cell(void)
  {
   ArrayInitialize(actions, -1);
   ArrayInitialize(state, 0);
   value = 0;
   total_actions = 0;
  }

Надо помнить, что собранные состояния мы будем сохранять в файл базы примеров. Поэтому создадим методы для работы с файлами. Метод сохранения данных построен по уже знакомому нам алгоритму, который мы не раз использовали для записи данных создаваемых классов.

В параметрах метода мы получаем хэндл файла для записи данных. И сразу проверяем его значение. В случае получения некорректного хэндла мы завершаем работу метода с результатом false.

После успешного прохождения блока контроля мы запишем в файл "999" для идентификации нашей структуры. После этого мы сохраним значения переменных и массивов. Для корректного последующего прочтения массивов перед записью их данных необходимо указать размерность массива. С целью экономии дискового пространства мы будем сохранять только данные фактического пути, а не весь массив actions. А так как мы уже сохранили значение переменной total_actions, то мы пропустим указание размера данного массива. При сохранении массива состояния state мы сначала указываем размер массива. И только потом сохраняем его содержимое. Обязательно контролируем процесс выполнения каждой операции. И после успешного сохранения всех данных завершаем работу метода с результатом true.

bool Cell::Save(int file_handle)
  {
   if(file_handle <= 0)
      return false;
   if(FileWriteInteger(file_handle, 999) < INT_VALUE)
      return false;
   if(FileWriteFloat(file_handle, value) < sizeof(float))
      return false;
   if(FileWriteInteger(file_handle, total_actions) < INT_VALUE)
      return false;
   for(int i = 0; i < total_actions; i++)
      if(FileWriteInteger(file_handle, actions[i]) < INT_VALUE)
         return false;
   int size = ArraySize(state);
   if(FileWriteInteger(file_handle, size) < INT_VALUE)
      return false;
   for(int i = 0; i < size; i++)
      if(FileWriteFloat(file_handle, state[i]) < sizeof(float))
         return false;
//---
   return true;
  }

Метод чтения данных из файла Load построен аналогично. В нем выполняются операции чтения данных с четким сохранением последовательности их записи. С полным кодом метода можно познакомиться во вложении.

После создания структуры описания одного состояния системы и пути его достижения мы переходим к созданию советника для реализации первой фазы алгоритма Go-Explore. Назовем советник Faza1.mq5. Хоть мы и будем совершать случайные действия без анализа рыночной ситуации, все же, для описания состояния системы мы будем использовать индикаторы. Поэтому перенесем их параметры из предыдущих советников. Внешняя переменная Start будет использоваться для указания состояния из базы примеров. К ней мы вернемся немного позже.

input ENUM_TIMEFRAMES      TimeFrame   =  PERIOD_H1;
input int                  Start =  100;
//---
input group                "---- RSI ----"
input int                  RSIPeriod   =  14;            //Period
input ENUM_APPLIED_PRICE   RSIPrice    =  PRICE_CLOSE;   //Applied price
//---
input group                "---- CCI ----"
input int                  CCIPeriod   =  14;            //Period
input ENUM_APPLIED_PRICE   CCIPrice    =  PRICE_TYPICAL; //Applied price
//---
input group                "---- ATR ----"
input int                  ATRPeriod   =  14;            //Period
//---
input group                "---- MACD ----"
input int                  FastPeriod  =  12;            //Fast
input int                  SlowPeriod  =  26;            //Slow
input int                  SignalPeriod =  9;            //Signal
input ENUM_APPLIED_PRICE   MACDPrice   =  PRICE_CLOSE;   //Applied price
      bool                 TrainMode = true;

После указания внешних параметров создадим глобальные переменные. Здесь мы создаем 2 массива структур описания состояния системы. Первый (Base) используется для записи состояний текущего прохода. Второй (Total) — для записи полной базы примеров.

Тут же мы объявляем объекты для совершения торговых операций и загрузки исторических данных. Они полностью аналогичны используемым ранее.

Для целей создаваемого алгоритма создадим:

  • action_count — счетчик операций;
  • actions — массив для записи выполненных действий в течении сессии;
  • StartCell — структура описания состояния для начала исследования;
  • bar — счетчик шагов от запуска советника.
С функционалом каждой переменной и массива мы познакомимся в процессе реализации алгоритма.

Cell                 Base[Buffer_Size];
Cell                 Total[];
CSymbolInfo          Symb;
CTrade               Trade;
//---
MqlRates             Rates[];
CiRSI                RSI;
CiCCI                CCI;
CiATR                ATR;
CiMACD               MACD;
//---
int action_count = 0;
int actions[Buffer_Size];
Cell StartCell;
int bar = -1;

В функции OnInit мы сначала инициализируем объекты индикаторов и торговых операций. Данный функционал полностью идентичен ранее рассмотренным советникам.

int OnInit()
  {
//---
   if(!Symb.Name(_Symbol))
      return INIT_FAILED;
   Symb.Refresh();
//---
   if(!RSI.Create(Symb.Name(), TimeFrame, RSIPeriod, RSIPrice))
      return INIT_FAILED;
//---
   if(!CCI.Create(Symb.Name(), TimeFrame, CCIPeriod, CCIPrice))
      return INIT_FAILED;
//---
   if(!ATR.Create(Symb.Name(), TimeFrame, ATRPeriod))
      return INIT_FAILED;
//---
   if(!MACD.Create(Symb.Name(), TimeFrame, FastPeriod, SlowPeriod, SignalPeriod, MACDPrice))
      return INIT_FAILED;
   if(!RSI.BufferResize(HistoryBars) || !CCI.BufferResize(HistoryBars) ||
      !ATR.BufferResize(HistoryBars) || !MACD.BufferResize(HistoryBars))
     {
      PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
      return INIT_FAILED;
     }
//---
   if(!Trade.SetTypeFillingBySymbol(Symb.Name()))
      return INIT_FAILED;

Затем мы пробуем загрузить базу примеров, которая могла быть создана при предыдущих запусках советника. Здесь приемлемы оба варианта. Если нам удается загрузить базу примеров, то мы пробуем считать из нее элемент с индексом, указанным во внешней переменной Start. В случае отсутствия такого элемента берем один случайный элемент и копируем его в структуру StartCell. Это точка начала нашего исследования. Если база примеров не загружена, то мы начнем исследование с самого начала.

//---
   if(LoadTotalBase())
     {
      int total = ArraySize(Total);
      if(total > Start)
         StartCell = Total[Start];
      else
        {
         total = (int)(((double)MathRand() / 32768.0) * (total - 1));
         StartCell = Total[total];
        }
     }
//---
   return(INIT_SUCCEEDED);
  }

Я применил столь разветвленную систему создания начальной точки исследования для возможности организации различных сценариев без изменения кода советника.

После выполнения всех операций завершаем работу функции инициализации советника с результатом INIT_SUCCEEDED.

Для загрузки базы примеров мы воспользовались функцией LoadTotalBase. Что бы завершить с описанием процесса инициализации рассмотрим сразу и её алгоритм. Данная функция не имеет параметров. Вместо них мы воспользуемся ранее определенной константой имени файла FileName.

Здесь надо обратить внимание, что файл будет использоваться и на первой, и на второй фазе алгоритма. Поэтому мы константу FileName объявили в файле структуры описания состояния.

В теле функции мы сначала открываем файл для чтения данных и проверяем результат операции по значению хэндла.

При успешном открытии файла мы считываем количество элементов в базе примеров. Изменяем размер массива, для чтения данных. И организовываем цикл чтения данных из файла. Для чтения каждой отдельной структуры мы воспользуемся ранее созданным методом Load нашей структуры хранения состояния системы.

На каждой итерации мы контролируем процесс выполнения операций. И перед выходом из функции в любом из вариантов обязательно закрываем ранее открытый файл для чтения данных.

bool LoadTotalBase(void)
  {
   int handle = FileOpen(FileName + ".bd", FILE_READ | FILE_BIN | FILE_COMMON);
   if(handle < 0)
      return false;
   int total = FileReadInteger(handle);
   if(total <= 0)
     {
      FileClose(handle);
      return false;
     }
   if(ArrayResize(Total, total) < total)
     {
      FileClose(handle);
      return false;
     }
   for(int i = 0; i < total; i++)
      if(!Total[i].Load(handle))
        {
         FileClose(handle);
         return false;
        }
   FileClose(handle);
//---
   return true;
  }

После создания алгоритма инициализации советника мы переходим к методу обработки тиков OnTick. Данный метод вызывается терминалом при наступлении события нового тика на графике советника. Нам же нужно обрабатывать только событие открытия новой свечи. Для реализации такого контроля мы используем функцию IsNewBar. Она полностью скопирована из предыдущих советников, и мы не будем останавливаться на рассмотрении её алгоритма.

void OnTick()
  {
//---
   if(!IsNewBar())
      return;

Далее мы увеличиваем счетчик шагов от запуска советника и сравниваем его значение с количеством шагов до состояния начала исследования. Если мы ещё не достигли состояния начала исследования, то берем очередное действие из пути до целевого состояния и совершаем его. После чего ожидаем открытия новой свечи.

   bar++;
   if(bar < StartCell.total_actions)
     {
      switch(StartCell.actions[bar])
        {
         case 0:
            Trade.Buy(Symb.LotsMin(), Symb.Name());
            break;
         case 1:
            Trade.Sell(Symb.LotsMin(), Symb.Name());
            break;
         case 2:
            for(int i = PositionsTotal() - 1; i >= 0; i--)
               if(PositionGetSymbol(i) == Symb.Name())
                  Trade.PositionClose(PositionGetInteger(POSITION_IDENTIFIER));
            break;
        }
      return;
     }

После достижения состояния начала исследования мы копируем пройденный путь в массив действий текущего агента.

   if(bar == StartCell.total_actions)
      ArrayCopy(actions, StartCell.actions, 0, 0, StartCell.total_actions);

Затем мы обновляем исторические данные индикаторов.

   int bars = CopyRates(Symb.Name(), TimeFrame, iTime(Symb.Name(), TimeFrame, 1), HistoryBars, Rates);
   if(!ArraySetAsSeries(Rates, true))
      return;
//---
   RSI.Refresh();
   CCI.Refresh();
   ATR.Refresh();
   MACD.Refresh();

После чего мы создаем массив текущего описания состояния системы. В который мы запишем исторические данные индикаторов и ценовых значений. А также информацию о состоянии счета и открытых позициях.

   float state[249];
   MqlDateTime sTime;
   for(int b = 0; b < (int)HistoryBars; b++)
     {
      float open = (float)Rates[b].open;
      TimeToStruct(Rates[b].time, sTime);
      float rsi = (float)RSI.Main(b);
      float cci = (float)CCI.Main(b);
      float atr = (float)ATR.Main(b);
      float macd = (float)MACD.Main(b);
      float sign = (float)MACD.Signal(b);
      if(rsi == EMPTY_VALUE || cci == EMPTY_VALUE || atr == EMPTY_VALUE || macd == EMPTY_VALUE || sign == EMPTY_VALUE)
         continue;
      //---
      state[b * 12] = (float)Rates[b].close - open;
      state[b * 12 + 1] = (float)Rates[b].high - open;
      state[b * 12 + 2] = (float)Rates[b].low - open;
      state[b * 12 + 3] = (float)Rates[b].tick_volume / 1000.0f;
      state[b * 12 + 4] = (float)sTime.hour;
      state[b * 12 + 5] = (float)sTime.day_of_week;
      state[b * 12 + 6] = (float)sTime.mon;
      state[b * 12 + 7] = rsi;
      state[b * 12 + 8] = cci;
      state[b * 12 + 9] = atr;
      state[b * 12 + 10] = macd;
      state[b * 12 + 11] = sign;
     }
//---
   state[240] = (float)AccountInfoDouble(ACCOUNT_BALANCE);
   state[240 + 1] = (float)AccountInfoDouble(ACCOUNT_EQUITY);
   state[240 + 2] = (float)AccountInfoDouble(ACCOUNT_MARGIN_FREE);
   state[240 + 3] = (float)AccountInfoDouble(ACCOUNT_MARGIN_LEVEL);
   state[240 + 4] = (float)AccountInfoDouble(ACCOUNT_PROFIT);
//---
   double buy_value = 0, sell_value = 0, buy_profit = 0, sell_profit = 0;
   int total = PositionsTotal();
   for(int i = 0; i < total; i++)
     {
      if(PositionGetSymbol(i) != Symb.Name())
         continue;
      switch((int)PositionGetInteger(POSITION_TYPE))
        {
         case POSITION_TYPE_BUY:
            buy_value += PositionGetDouble(POSITION_VOLUME);
            buy_profit += PositionGetDouble(POSITION_PROFIT);
            break;
         case POSITION_TYPE_SELL:
            sell_value += PositionGetDouble(POSITION_VOLUME);
            sell_profit += PositionGetDouble(POSITION_PROFIT);
            break;
        }
     }
   state[240 + 5] = (float)buy_value;
   state[240 + 6] = (float)sell_value;
   state[240 + 7] = (float)buy_profit;
   state[240 + 8] = (float)sell_profit;

После этого мы совершаем случайное действие.

//---
   int act = SampleAction(4);
   switch(act)
     {
      case 0:
         Trade.Buy(Symb.LotsMin(), Symb.Name());
         break;
      case 1:
         Trade.Sell(Symb.LotsMin(), Symb.Name());
         break;
      case 2:
         for(int i = PositionsTotal() - 1; i >= 0; i--)
            if(PositionGetSymbol(i) == Symb.Name())
               Trade.PositionClose(PositionGetInteger(POSITION_IDENTIFIER));
         break;
     }

И сохраняем текущее состояния в массив посещенных стояний текущего агента.

Обратите внимание, что в качестве числа шагов до текущего стояния мы указываем сумму шагов до состояния начала исследования и случайных шагов исследования. Мы сохраняли состояния до начал исследования, так как они уже сохранены в нашей базе примеров. В то же время нам нужно сохранить полный путь до каждого состояния.

В качестве значения состояния мы укажем обратную величину изменения эквити счета. Её мы будем использовать в качестве ориентира приоритезации состояний для исследования. Цель такой приоритезации: найти шаги для минимизации потерь. Что в потенциально даст повышение обшей прибыли. Кроме того, мы можем позже использовать обратную величину данного значения в качестве вознаграждения при обучении политики на второй фазе алгоритма Go-Explore.

//--- copy cell
   actions[action_count] = act;
   Base[action_count].total_actions = action_count+StartCell.total_actions;
   if(action_count > 0)
     {
      ArrayCopy(Base[action_count].actions, actions, 0, 0, Base[action_count].total_actions+1);
      Base[action_count - 1].value = Base[action_count - 1].state[241] - state[241];
     }
   ArrayCopy(Base[action_count].state, state, 0, 0);
//---
   action_count++;
  }

После сохранения данных о текущем состоянии мы увеличиваем счетчик шагов и переходим к ожиданию следующей свечи.

Мы построили алгоритм агента для изучения окружающей среды. Теперь нам предстоит организовать процесс сбора данных от всех агентов в единую базу примеров. Для этого, после окончания тестирования каждый агент должен отправить собранные данные в центр обобщения. Этот функционал организовываем в методе OnTester. Он вызывается Тестером стратегий по завершении каждого прохода.

Здесь было решено сохранять только прибыльные проходы. Что позволит значительно сократить размер базы примеров и ускорить процесс обучения. Если Вы хотите максимально точно обучить политику и не ограничены в ресурсах, то можно сохранять все проходы. Это поможет вашей политики лучше изучить окружающую среду.

Мы сначала проверяем доходность прохода. И, при необходимости, отправляем данные средствами функции FrameAdd.

//+------------------------------------------------------------------+
//| Tester function                                                  |
//+------------------------------------------------------------------+
double OnTester()
  {
//---
   double ret = 0.0;
//---
   double profit = TesterStatistics(STAT_PROFIT);
   action_count--;
   if(profit > 0)
      FrameAdd(MQLInfoString(MQL_PROGRAM_NAME), action_count, profit, Base);
//---
   return(ret);
  }

Обратите внимание, что перед отправкой мы уменьшаем количестве шагов на 1, так как результаты последнего действия нам не известны.

Для организации процесса сбора данных в общую базу примеров мы будем использовать 3 функции. Вначале, при инициализации процесса оптимизации мы загружаем базу примеров, если таковая ранее была создана. Эта операция осуществляется в функции OnTesterInit.

//+------------------------------------------------------------------+
//| TesterInit function                                              |
//+------------------------------------------------------------------+
void OnTesterInit()
  {
//---
   LoadTotalBase();
  }

Затем, каждый проход мы обрабатываем в функции OnTesterPass. Здесь мы организовываем сбор данных со всех доступных фреймов и добавляем их в массив общей базы примеров. Функция FrameNext считывает очередной фрейм. И при успешной загрузке данных возвращает true. А в случае ошибки чтения данных фрейма вернет значение false. Благодаря этому свойству, мы можем организовать цикл чтения данных и добавления их в наш общий массив.

//+------------------------------------------------------------------+
//| TesterPass function                                              |
//+------------------------------------------------------------------+
void OnTesterPass()
  {
//---
   ulong pass;
   string name;
   long id;
   double value;
   Cell array[];
   while(FrameNext(pass, name, id, value, array))
     {
      int total = ArraySize(Total);
      if(name != MQLInfoString(MQL_PROGRAM_NAME))
         continue;
      if(id <= 0)
         continue;
      if(ArrayResize(Total, total + (int)id, 10000) < 0)
         return;
      ArrayCopy(Total, array, total, 0, (int)id);
     }
  }

В завершении процесса оптимизации вызывается функция OnTesterDeinit. Здесь мы сначала сортируем нашу бузу данных по убыванию значения value описания состояния. Это позволит нам перенести в начало массива элементы, дающие максимальный убыток.

//+------------------------------------------------------------------+
//| TesterDeinit function                                            |
//+------------------------------------------------------------------+
void OnTesterDeinit()
  {
//---
   bool flag = false;
   int total = ArraySize(Total);
   printf("total %d", total);
   Cell temp;
   Print("Start sorting...");
   do
     {
      flag = false;
      for(int i = 0; i < (total - 1); i++)
         if(Total[i].value < Total[i + 1].value)
           {
            temp = Total[i];
            Total[i] = Total[i + 1];
            Total[i + 1] = temp;
            flag = true;
           }
     }
   while(flag);
   Print("Saving...");
   SaveTotalBase();
   Print("Saved");
  }

После чего мы сохраняем базу примеров в файл с помощью метода SaveTotalBase. Его алгоритм построен аналогично выше рассмотренному методу LoadTotalBase. А с его полным кодом, как и всех указанных функций можно ознакомиться во вложении.

На этом мы завершаем работу над советником первой фазы. Компилируем его и переходим в Тестер стратегий. Выбираем созданный нами советник Faza1.ex5, инструмент, период тестирования (в нашем случае обучения), медленную оптимизацию с перебором всех вариантов.

Оптимизировать мы будем по одному параметру Start. Им мы определяем количество запущенных агентов. На начальном этапе я запускал с небольшим количеством агентов. Это даёт нам быстрый проход для создания начально базы примеров.

После завершения первого этапа оптимизации мы увеличиваем количество агентов тестирования. И здесь есть 2 подхода к следующему запуску. Если мы хотим попробовать найти лучшее действие в наиболее убыточных состояниях, то указывает интервал оптимизации параметра Start от "0". Для выбора случайных состояний в качестве точки начала исследования задаём заведомо большое начальное значение оптимизации параметра. Конечное значение оптимизации параметра зависит от количества запускаемых агентов. Значение в колонке Steps соответствует количеству запускаемых агентов в процессе оптимизации (обучения).

2.2. Фаза 2 — обучение политики на базе примеров

Пока наш первый советник работает над созданием базы примеров мы переходим к работе над советником второй фазы.

Здесь стоит сказать, что в своей реализации я немного отошел от процесса обучения политики на фазе 2, предложенного авторами статьи. В статье было предложено использовать имитационный метод обучения политики. Когда используется модифицированный подход к известным нам алгоритмам обучения с подкреплением. На отдельном участке агент обучается повторять действия успешной стратегии из базы примеров, а затем применяется стандартный подход обучения с подкреплением. На первом этапе демонстрационный отрезок "учителя" максимален. Агент должен получить результат не хуже "учителя". По мере обучения интервал "учителя" сокращается. А агент должен обучиться оптимизировать стратегию учителя.

В своей реализации я разбил данную фазу на 2 этапа. На первом этапе мы обучаем агента аналогично процессу обучения с учителем. Только мы не указываем правильное действие. А корректируем значение прогнозного вознаграждения. Для этого этапа мы создаем советник Faza2.mq5.

В код данного советника мы включаем элемент описания состояния системы и класс полностью параметризированной модели FQF.

//+------------------------------------------------------------------+
//| Includes                                                         |
//+------------------------------------------------------------------+
#include "Cell.mqh"
#include "..\RL\FQF.mqh"
//+------------------------------------------------------------------+
//| Input parameters                                                 |
//+------------------------------------------------------------------+
input int                  Iterations =  100000;

Внешних параметров в нем минимум. Мы указываем только количество итераций обучения модели.

Среди глобальных параметров мы объявляем класс модели, объект описания состояния и массив вознаграждений. А также массив для загрузки базы примеров.

CNet                 StudyNet;
//---
float                dError;
datetime             dtStudied;
bool                 bEventStudy;
//---
CBufferFloat         State1;
CBufferFloat         *Rewards;

Cell                 Base[];

В методе инициализации советника мы сначала загружаем базу примеров. В данном случае, это один из ключевых моментов. В случае ошибки загрузки базы примеров у нас не будет исходных данных для обучения модели. Поэтому, в случае ошибки загрузки мы завершаем работу функции с результатом INIT_FAILED.

//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit()
  {
//---
   if(!LoadTotalBase())
      return(INIT_FAILED);
//---
   if(!StudyNet.Load(FileName + ".nnw", dError, dError, dError, dtStudied, true))
     {
      CArrayObj *model = new CArrayObj();
      if(!CreateDescriptions(model))
        {
         delete model;
         return INIT_FAILED;
        }
      if(!StudyNet.Create(model))
        {
         delete model;
         return INIT_FAILED;
        }
      delete model;
     }
   if(!StudyNet.TrainMode(true))
      return INIT_FAILED;
//---
   bEventStudy = EventChartCustom(ChartID(), 1, 0, 0, "Init");
//---
   return(INIT_SUCCEEDED);
  }

После загрузки базы примеров мы инициализируем модель для обучения. Как обычно, мы сначала пытаемся загрузить предварительно обученную модель. И в случае неудачной загрузки модели по какой-либо причине, мы инициализируем создание новой модели случайными весовыми коэффициентами. Описание модели указано в функции CreateDescriptions.

После успешной инициализации модели мы создаем пользовательское события для запуска процесса обучения модели. Такой же подход мы использовали при обучении моделей с учителем.

И завершаем работу функции инициализации советника.

Обратите внимание, что в данном советнике мы не создавали объектов для загрузки исторических данных цены и индикаторов. Весь процесс обучения построен на базе примеров. А в ней мы сохраняли все описания состояния системы, включая информацию о счете и открытых позициях.

Созданное нами пользовательское событие обрабатывается в функции OnChartEvent. Здесь мы лишь проверяем наступление ожидаемого события и вызываем функцию обучения модели.

//+------------------------------------------------------------------+
//| ChartEvent function                                              |
//+------------------------------------------------------------------+
void OnChartEvent(const int id,
                  const long &lparam,
                  const double &dparam,
                  const string &sparam)
  {
//---
   if(id == 1001)
      Train();
  }

Непосредственно процесс обучения модели организован в функции Train. Данная функция не имеет параметров. В теле функции мы сначала определяем размер базы примеров и сохраняем в локальную переменную количество миллисекунд от старта системы. Данное значение будем использовать для создания периодического информирования пользователя о ходе процесса обучения модели.

//+------------------------------------------------------------------+
//| Train function                                                   |
//+------------------------------------------------------------------+
void Train(void)
  {
   int total = ArraySize(Base);
   uint ticks = GetTickCount();

После проведения небольшой подготовительной работы организовываем цикл обучения модели. Количество итераций цикла соответствует значению внешней переменной. При этом мы предусмотрим принудительное прерывание цикла и закрытие программы по требованию пользователя. Это позволяет сделать контроль значение функции IsStopped. В случае принудительного закрытие программы пользователем, указанная функция вернет значение true.

   for(int iter = 0; (iter < Iterations && !IsStopped()); iter ++)
     {
      int i = 0;
      int count = 0;
      int total_max = 0;
      i = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (total - 1));
      State1.AssignArray(Base[i].state);
      if(IsStopped())
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         ExpertRemove();
         return;
        }

В теле цикла мы случайным образом выбираем один пример из базы и копируем состояние в буфер данных. После чего выполняем прямой проход модели.

      if(!StudyNet.feedForward(GetPointer(State1), 12, true))
         return;

Затем мы извлекаем совершенное действие в текущем примере. Загружаем результаты прямого  прохода. И актуализируем вознаграждение за совершенное действие.

      int action = Base[i].total_actions;
      if(action < 0)
        {
         iter--;
         continue;
        }
      action = Base[i].actions[action];
      if(action < 0 || action > 3)
         action = 3;
      StudyNet.getResults(Rewards);
      if(!Rewards.Update(action, -Base[i].value))
         return;

Обратите внимание на 2 момента. Если действие в примере отсутствует (выбрано начальное состояние), то мы уменьшаем счетчик итераций и выбираем новый пример. А при актуализации вознаграждения мы берем значение value с обратным знаком. Помните? При сохранении состояния мы делали положительное значение для снижения эквити. А это отрицательный момент.

После актуализации вознаграждения мы совершаем обратный проход модели и обновляем весовые коэффициенты.

      if(!StudyNet.backProp(GetPointer(Rewards)))
         return;
      if(GetTickCount() - ticks > 500)
        {
         Comment(StringFormat("%.2f%% -> Error %.8f", iter * 100.0 / (double)(Iterations), StudyNet.getRecentAverageError()));
         ticks = GetTickCount();
        }
     }

В завершении итераций цикла мы проверяем необходимость обновления информации о процессе обучения для пользователя. В данном примере мы обновляем информацию в поле комментариев чата каждые 0.5 секунды.

На этом операции в теле цикла завершены и мы переходим к новому примеру из базы.

После завершения всех итераций цикла мы очищаем поле комментариев. Выводим информацию в журнал и инициализируем завершение работы советника.

   Comment("");
//---
   PrintFormat("%s -> %d -> %10.7f", __FUNCTION__, __LINE__, StudyNet.getRecentAverageError());
   ExpertRemove();
//---
  }

При завершении работы советника в его методе деинициализации мы удаляем используемые динамические объекты и сохраняем обученную модель на диске.

//+------------------------------------------------------------------+
//| Expert deinitialization function                                 |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
   if(!!Rewards)
      delete Rewards;
//---
   StudyNet.Save(FileName + ".nnw", 0, 0, 0, 0, true);
  }

После сбора базы примеров советником первой фазы нам достаточно прикрепить советник второй фазы к графику и начнется процесс обучения модели. Обратите внимани, в отличие от советника первой фазы, советник второй фазы мы не запускаем в тестере стратегий, а прикрепляем к реальному графику. В параметрах советника указываем количество итераций цикла процесса обучения и наблюдаем за процессом.

Для достижения оптимального результата допускается повторение итераций первой и второй фазы. При этом возможно сначала повторить первую фазу N раз, а затем вторую — M раз. А можно и несколько раз повторить цикл итераций первая фаза + вторая фаза.

Для более тонкой настройки политики мы применяем третий советник GE-lerning.mq5. В нем организован классический алгоритм обучения с подкреплением. Мы не будем сейчас подробно останавливаться на всех функциях советника. С их полным кодом можно познакомиться во вложении. Рассмотрим лишь функцию обработки тиков OnTick.

Как и в советнике первой фазы мы обрабатываем только событие открытие новой свечи. И при отсутствии такового просто завершаем работу функции в ожидании нужного момента.

При наступлении события открытия новой свечи мы сначала сохраняем последнее состояние, совершенное действие и изменение эквити в буфер воспроизведения опыта. И переписываем в глобальную переменную показатель эквити для отслеживания изменения на следующей свече.

void OnTick()
  {
   if(!IsNewBar())
      return;
//---
   float current = (float)AccountInfoDouble(ACCOUNT_EQUITY);
   if(Equity >= 0 && State1.Total() == (HistoryBars * 12 + 9))
      cReplay.AddState(GetPointer(State1), Action, (double)(current - Equity));
   Equity = current;

Затем, мы обновляем историю ценовых значений и индикаторов.

//---
   int bars = CopyRates(Symb.Name(), TimeFrame, iTime(Symb.Name(), TimeFrame, 1), HistoryBars, Rates);
   if(!ArraySetAsSeries(Rates, true))
     {
      PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
      return;
     }
//---
   RSI.Refresh();
   CCI.Refresh();
   ATR.Refresh();
   MACD.Refresh();

И формируем описание текущего состояния системы. Здесь надо быть внимательным, чтобы формируемое описание состояния системы полностью соответствовало аналогичному процессу в советнике первой фазы. Ведь эксплуатация и тонкая настройка должна проводиться на данных, сопоставимых с данными обучающей выборки.

   State1.Clear();
   for(int b = 0; b < (int)HistoryBars; b++)
     {
      float open = (float)Rates[b].open;
      TimeToStruct(Rates[b].time, sTime);
      float rsi = (float)RSI.Main(b);
      float cci = (float)CCI.Main(b);
      float atr = (float)ATR.Main(b);
      float macd = (float)MACD.Main(b);
      float sign = (float)MACD.Signal(b);
      if(rsi == EMPTY_VALUE || cci == EMPTY_VALUE || atr == EMPTY_VALUE || macd == EMPTY_VALUE || sign == EMPTY_VALUE)
         continue;
      //---
      if(!State1.Add((float)Rates[b].close - open) || !State1.Add((float)Rates[b].high - open) ||
         !State1.Add((float)Rates[b].low - open) || !State1.Add((float)Rates[b].tick_volume / 1000.0f) ||
         !State1.Add(sTime.hour) || !State1.Add(sTime.day_of_week) || !State1.Add(sTime.mon) ||
         !State1.Add(rsi) || !State1.Add(cci) || !State1.Add(atr) || !State1.Add(macd) || !State1.Add(sign))
        {
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }
     }
//---
   if(!State1.Add((float)AccountInfoDouble(ACCOUNT_BALANCE)) || !State1.Add((float)AccountInfoDouble(ACCOUNT_EQUITY)) ||
      !State1.Add((float)AccountInfoDouble(ACCOUNT_MARGIN_FREE)) || 
      !State1.Add((float)AccountInfoDouble(ACCOUNT_MARGIN_LEVEL)) ||
      !State1.Add((float)AccountInfoDouble(ACCOUNT_PROFIT)))
      return;
//---
   double buy_value = 0, sell_value = 0, buy_profit = 0, sell_profit = 0;
   int total = PositionsTotal();
   for(int i = 0; i < total; i++)
     {
      if(PositionGetSymbol(i) != Symb.Name())
         continue;
      switch((int)PositionGetInteger(POSITION_TYPE))
        {
         case POSITION_TYPE_BUY:
            buy_value += PositionGetDouble(POSITION_VOLUME);
            buy_profit += PositionGetDouble(POSITION_PROFIT);
            break;
         case POSITION_TYPE_SELL:
            sell_value += PositionGetDouble(POSITION_VOLUME);
            sell_profit += PositionGetDouble(POSITION_PROFIT);
            return;
        }
     }
   if(!State1.Add((float)buy_value) || !State1.Add((float)sell_value) || !State1.Add((float)buy_profit) || 
      !State1.Add((float)sell_profit))
      return;

После чего мы осуществляем прямой проход модели. По результатам прямого прохода мы определяем и совершаем действие.

   if(!StudyNet.feedForward(GetPointer(State1), 12, true))
      return;
   Action = StudyNet.getAction();
   switch(Action)
     {
      case 0:
         Trade.Buy(Symb.LotsMin(), Symb.Name());
         break;
      case 1:
         Trade.Sell(Symb.LotsMin(), Symb.Name());
         break;
      case 2:
         for(int i = PositionsTotal() - 1; i >= 0; i--)
            if(PositionGetSymbol(i) == Symb.Name())
               Trade.PositionClose(PositionGetInteger(POSITION_IDENTIFIER));
         break;
     }

Следует обратить внимание, что в данном случае мы не используем никакой политики исследования. Четко следуем выученной политике.

В завершении функции обработки тиков мы проверяем время. И один раз в сутки, в полночь, мы осуществляем актуализацию политики агента с использование буфера воспроизведения опыта.

   MqlDateTime time;
   TimeCurrent(time);
   if(time.hour == 0)
     {
      int repl_action;
      double repl_reward;
      for(int i = 0; i < 10; i++)
        {
         if(cReplay.GetRendomState(pstate1, repl_action, repl_reward, pstate2))
            return;
         if(!StudyNet.feedForward(pstate1, 12, true))
            return;
         StudyNet.getResults(Rewards);
         if(!Rewards.Update(repl_action, (float)repl_reward))
            return;
         if(!StudyNet.backProp(GetPointer(Rewards), DiscountFactor, pstate2, 12, true))
            return;
        }
     }
//---
  }

С полным кодом всех советников можно ознакомиться во вложении.


3. Тестирование

Тестирование работы всех трех созданных советников проводилось последовательно, в соответствии с алгоритмом Go-Explore:

  1. Несколько последовательных запусков советника первой фазы в режиме оптимизации тестера стратегий для создания базы примеров.
  2. Несколько итераций обучения политики советником второй фазы.
  3. Заключительная тонкая настройка в тестере стратегий с использование алгоритмов обучения с подкреплением.

Все тесты, как и во всей серии статей, проводились на исторических данных инструмента EURUSD, таймфрейм H1. Параметры индикаторов использовались по умолчанию без какой-либо корректировки.

В результате тестирования были получены довольно неплохие результаты, которые представлены на скриншотах ниже.

График тестирования Таблица результатов тестирования

На представленных изображениях мы видим довольно ровный график роста баланса. Практически, на тестовых данных был получен профит фактор равный 6.0 и фактор восстановления 3.34. Из 30 совершенных сделок 22 было прибыльными, что составило 73.3%. Средняя прибыль по сделке более чем в 2 раза превышает среднюю убыточную сделку. А максимальная прибыль на одну сделку в 3.5 раза превышает максимальную убыточную сделку.

Стоит отметить, что советник совершал только сделки на покупку и закрывал их без значительных просадок. Причина отсутствия коротких сделок является темой дополнительного исследования.

Результаты тестирования многообещающие, но получены на коротком временном интервале. Для подтверждения результатов работы алгоритма требуется дополнительные эксперименты на более длительном временном отрезке.


Заключение

В данной статье мы познакомились с алгоритмом Go-Explore, который представляет собой новый подход к решению сложных задач обучения с подкреплением. Он основывается на идее запоминания и повторного посещения перспективных состояний в пространстве состояний, что позволяет быстрее достигать желаемой производительности. Главным отличием Go-Explore от других алгоритмов является его фокус на поиске релевантных состояний и действий, а не на прямом решении целевой задачи.

Мы построили 3 советника, которые запускаются последовательно. И каждый из них выполняет свой функционал алгоритма для достижения общей цели обучения политике. Под политикой здесь имеется в виду торговая стратегия.

Алгоритм был протестирован на исторических данных и показал один из лучших результатов. Тем не менее, результаты были достигнуты в тестере стратегий на коротком временном отрезке. Поэтому, перед использование советника на реальных счетах требуется его всестороннее тестирование и обучение модели на более длительном, репрезентативном временном отрезке.


Ссылки

  1. Go-Explore: a New Approach for Hard-Exploration Problems
  2. Нейросети — это просто (Часть 35): Модуль внутреннего любопытства (Intrinsic Curiosity Module)
  3. Нейросети — это просто (Часть 36): Реляционные модели обучения с подкреплением (Relational Reinforcement Learning)
  4. Нейросети — это просто (Часть 37): Разреженное внимание (Sparse Attention)
  5. Нейросети — это просто (Часть 38): Исследование с самоконтролем через несогласие (Self-Supervised Exploration via Disagreement)

Программы, используемые в статье

# Имя Тип Описание
1 Faza1.mq5 Советник Советник первой фазы
2 Faza2.mql5 Советник Советник второй фазы
3 GE-lerning.mq5 Советник Советник тонкой настройки политики
4 Cell.mqh Библиотека класса Структура описания состояния системы
5 FQF.mqh Библиотека класса Библиотека класса организации работы полностью параметризированной модели
6 NeuroNet.mqh Библиотека класса Библиотека классов для создания нейронной сети
7 NeuroNet.cl Библиотека Библиотека кода программы OpenCL


Прикрепленные файлы |
MQL5.zip (806.88 KB)
Последние комментарии | Перейти к обсуждению на форуме трейдеров (21)
Viktor Kudriavtsev
Viktor Kudriavtsev | 6 мая 2023 в 20:44

А сколь вы его фазой 2 тренировали? Сколько раз запускали фазу 2?

И какая ошибка была при переходе к фазе 3?

И у меня с каждым запуском фазы 2 ошибка растёт. Это вообще нормально? Просто я как понимаю у нейронной сети в процессе обучения ошибка должна падать, а тут получается наоборот...

Zhongquan Jiang
Zhongquan Jiang | 7 мая 2023 в 14:07

I got this error.

2023.05.07 20:04:44.281 Core 01 pass 359 tested with error "critical runtime error 502 in OnTester function (array out of range, module Experts\GoExploer\Faza1.ex5, file Faza1.mq5, line 223, col 12)" in 0:00:00.202

//--- copy cell

   actions[action_count] = act;

   Base[action_count].total_actions = action_count+StartCell.total_actions;


how to solve it?


Dmitriy Gizlyk
Dmitriy Gizlyk | 8 мая 2023 в 08:45
Viktor Kudriavtsev #:

А сколь вы его фазой 2 тренировали? Сколько раз запускали фазу 2?

И какая ошибка была при переходе к фазе 3?

И у меня с каждым запуском фазы 2 ошибка растёт. Это вообще нормально? Просто я как понимаю у нейронной сети в процессе обучения ошибка должна падать, а тут получается наоборот...

Если ошибка постоянно растет, попробуйте уменьшить коэффициент обучения.

Dmitriy Gizlyk
Dmitriy Gizlyk | 8 мая 2023 в 08:46
Zhongquan Jiang #:

I got this error.

2023.05.07 20:04:44.281 Core 01 pass 359 tested with error "critical runtime error 502 in OnTester function (array out of range, module Experts\GoExploer\Faza1.ex5, file Faza1.mq5, line 223, col 12)" in 0:00:00.202

//--- copy cell

   actions[action_count] = act;

   Base[action_count].total_actions = action_count+StartCell.total_actions;


how to solve it?


What's period of study?

Zhongquan Jiang
Zhongquan Jiang | 8 мая 2023 в 10:26
Dmitry Gizlyk # :

What's the period of study?

H1 data, from 1 Apr 2023~ 30 Apr 2023

Алгоритм докупки: математическая модель увеличения эффективности Алгоритм докупки: математическая модель увеличения эффективности
В данной статье мы будем использовать алгоритм докупки, как путеводитель в мир более глубокого понимания эффективности торговых систем и начнем работать над общими принципами усиления эффективности торговли с помощью математики и логики а также применим самые нестандартные методы увеличения эффективности в контексте использования абсолютно любой торговой системы.
Как построить советник, работающий автоматически (Часть 12): Автоматизация (IV) Как построить советник, работающий автоматически (Часть 12): Автоматизация (IV)
Если вы думаете, что автоматизированные системы просты, то наверно вы еще не до конца поняли, что нужно для их создания. В данном материале мы поговорим о проблеме, с которой сталкиваются многие советники: неизбирательное исполнение ордеров, и возможное решение этой проблемы.
Управление капиталом в трейдинге Управление капиталом в трейдинге
В этой статье мы рассмотрим несколько новых способов построения систем управления капиталом и определим их основные особенности. На сегодняшний день существует довольно много стратегий управления капиталом на любой вкус. Мы попробуем рассмотреть несколько способов управления капиталом, опираясь на разные математические модели роста.
Как построить советник, работающий автоматически (Часть 11): Автоматизация (III) Как построить советник, работающий автоматически (Часть 11): Автоматизация (III)
Автоматизированная система без соответствующей безопасности не будет успешной. Однако безопасность не будет обеспечена без хорошего понимания некоторых вещей. В этой статье мы разберемся с тем, почему достижение максимальной безопасности в автоматизированных системах является такой сложной задачей.