Redes neurais de maneira fácil (Parte 39): Go-Explore - uma abordagem diferente para exploração
Introdução
Continuamos com o tema da exploração do ambiente no aprendizado por reforço. Nas últimas edições desta série, já discutimos algoritmos para explorar o ambiente por curiosidade e desacordo em um conjunto de modelos. Ambos os enfoques aproveitaram recompensas internas para incentivar o agente a tomar ações diversas em situações similares, explorando novas áreas. No entanto, o dilema reside no fato de que essas recompensas internas diminuem à medida que o ambiente é explorado. Em situações complexas com recompensas raras, ou quando penalidades podem surgir no caminho das recompensas, esse método pode não ser tão eficaz. Neste artigo, apresento uma abordagem diferente para a exploração do ambiente: o algoritmo Go-Explore.
1. Algoritmo Go-Explore
Go-Explore é um algoritmo de aprendizado por reforço projetado para descobrir soluções ótimas em desafios complexos que possuem um grande espaço de ações e estados. O algoritmo foi desenvolvido por Adrien Ecoffet e foi detalhado no artigo "Go-Explore: a New Approach for Hard-Exploration Problems".
Ele utiliza métodos de algoritmos evolutivos e aprendizado de máquina para buscar eficazmente soluções ótimas em tarefas complexas e insolúveis.
O algoritmo começa explorando um grande número de caminhos aleatórios, chamados de "explorações base". Em seguida, utilizando um algoritmo evolutivo, ele mantém as melhores soluções encontradas e as combina para criar novos caminhos. Esses novos caminhos são então comparados com as melhores soluções anteriores e, se forem melhores, são mantidos. Esse processo continua até que uma solução ótima seja encontrada.
O Go-Explore também utiliza uma técnica chamada "gravador" para armazenar as melhores soluções encontradas e reutilizá-las na criação de novos caminhos. Isso permite que o algoritmo descubra soluções mais ótimas do que se ele apenas continuasse a explorar caminhos aleatórios.
Uma das principais vantagens do Go-Explore é sua capacidade de encontrar soluções ótimas em desafios complexos e insolúveis, nos quais outros algoritmos de aprendizado por reforço podem falhar. Ele também é capaz de aprender eficazmente em situações de recompensas esparsas, o que pode ser um problema para outros algoritmos.
Em resumo, o Go-Explore é uma ferramenta poderosa para resolver desafios de aprendizado por reforço e pode ser aplicado de maneira eficaz em diversas áreas, incluindo robótica, jogos de computador e inteligência artificial em geral.
A ideia central do Go-Explore é memorizar e retornar a estados promissores. Isso se torna fundamental para a exploração eficaz em cenários com poucas recompensas. Essa ideia é tão flexível e ampla que pode ser implementada de várias maneiras.
Diferentemente da maioria dos algoritmos de aprendizado por reforço, o Go-Explore não se concentra em encontrar diretamente a solução para a tarefa final, mas sim em descobrir estados e ações relevantes dentro do espaço de estados que podem levar ao estado final desejado. Para isso, o algoritmo possui duas fases principais: busca e reutilização.
A primeira fase envolve percorrer todos os estados no espaço de estados e registrar cada estado visitado em um "mapa" de estados. Em seguida, o algoritmo começa a examinar cada estado visitado com mais detalhes e coleta informações sobre as ações que podem levar a outros estados interessantes.
A segunda fase consiste em reutilizar os estados e ações previamente estudados para encontrar novas soluções. O algoritmo mantém as trajetórias mais bem-sucedidas e as utiliza para gerar novos estados que podem levar a soluções ainda mais bem-sucedidas.
O algoritmo Go-Explore funciona da seguinte maneira:
- Coleta de base de exemplos (archive): o agente inicia o jogo, registra cada conquista e a armazena no arquivo. Em vez de armazenar os próprios estados, o arquivo contém descrições das ações que levaram a alcançar um estado específico.
- Exploração iterativa (iterative exploration): em cada iteração, o agente escolhe um estado aleatório do arquivo e replaya o jogo a partir desse estado. Ele armazena quaisquer novos estados que consiga alcançar e os adiciona ao arquivo, juntamente com a descrição das ações que levaram a esses estados.
- Aprendizado com base em exemplos: após a exploração iterativa, o algoritmo aprende com base nos exemplos que coletou, usando algum algoritmo de aprendizado por reforço.
- Repetição: o algoritmo repete a exploração iterativa e o aprendizado com base em exemplos até atingir o desempenho desejado.
O objetivo do algoritmo Go-Explore é minimizar a quantidade de repetições do jogo necessárias para alcançar um alto desempenho. Ele permite que o agente explore um amplo espaço de estados, utilizando exemplos prévios, acelerando o processo de aprendizado e atingindo um desempenho mais elevado.
No geral, o Go-Explore é um algoritmo bastante poderoso e eficaz, demonstrando bons resultados na resolução de desafios complexos de aprendizado por reforço.
2. Implementação em MQL5
Na sua implementação, ao contrário de todas as abordagens anteriores, não vamos consolidar todo o algoritmo em um único programa. As etapas do algoritmo Go-Explore são tão distintas que seria mais eficaz criar um programa separado para cada etapa.
2.1. Fase 1 — Exploração
Primeiramente, vamos criar um programa para implementar a primeira fase do algoritmo — a exploração do ambiente e a criação de uma base de exemplos. Antes de começar a implementação, é importante definir os fundamentos do algoritmo que estamos construindo.
Ao iniciar a exploração do ambiente, nosso objetivo é explorar o máximo possível de estados do ambiente. Nesta fase, não estamos buscando uma estratégia ótima. Curiosamente, aqui não usaremos redes neurais. Afinal, não estamos procurando estratégias ou otimizando políticas. Isso é tarefa da segunda fase. Nesta etapa, simplesmente realizamos ações aleatórias com vários agentes e registramos todos os estados do sistema que cada agente visita.
No entanto, dessa forma, obteremos um monte de estados aleatórios não relacionados. E o que dizer sobre a exploração do ambiente? Afinal, cada agente realizará apenas uma ação a partir de cada estado, sem conhecer os aspectos positivos e negativos das outras ações. Aqui entra o próximo passo do algoritmo. Selecionamos aleatoriamente um estado da base de dados ou usando uma política definida por nós. Repetimos todos os passos até atingir esse estado. Em seguida, mais uma vez, determinamos aleatoriamente as ações do agente até alcançar o fim do episódio em estudo. Os novos estados também são adicionados à nossa base de exemplos.
Esses dois passos do algoritmo compõem a primeira fase: exploração.
Vale a pena atentar para mais um ponto. Para uma exploração eficaz, é necessário usar vários agentes. E aqui, para executar paralelamente vários agentes independentes, usaremos o otimizador multithread do testador de estratégias. Após cada execução, o agente transmitirá sua base de estados acumulados para um centro unificado para consolidação.
Tendo definido os principais pontos do algoritmo em construção, podemos começar a implementá-lo. Iniciaremos criando uma estrutura para armazenar os estados e o caminho para alcançá-los. Aqui nos deparamos com a primeira limitação: no testador de estratégias, para transmitir os resultados de cada execução, é permitido usar um array de qualquer tipo. No entanto, não devem existir estruturas complexas com valores de string e arrays dinâmicos. Isso significa que não podemos usar arrays dinâmicos para descrever o caminho e o estado do sistema. Precisamos determinar sua dimensão imediatamente. Para flexibilidade na organização do programa, definiremos os valores principais como constantes. Neles, estabeleceremos a profundidade do histórico analisado em barras (HistoryBars) e o tamanho do buffer do caminho (Buffer_Size). Você pode usar seus próprios valores, adequados para a resolução de tarefas específicas.
#define HistoryBars 20 #define Buffer_Size 600 #define FileName "GoExploer"
Além disso, especificaremos imediatamente o nome do arquivo para armazenar a base de exemplos.
A gravação dos dados propriamente dita será no formato da estrutura "Cell". Nela, criaremos 2 arrays. Um de valores inteiros para registrar o caminho para atingir o estado — "actions". O segundo array de valores de ponto flutuante para registrar a descrição do estado alcançado — "state". Como estamos obrigados a usar arrays de dados estáticos, introduziremos a variável "total_actions" para indicar o tamanho do caminho percorrido. Adicionalmente, adicionaremos a variável de ponto flutuante "value" para registrar o peso do estado. Isso será usado para priorizar a seleção de estados para estudo posterior.
//+------------------------------------------------------------------+ //| 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); };
As variáveis e arrays criados são inicializados no construtor da estrutura. Ao criar a estrutura, preenchemos o array de caminho com o valor "-1". E o array de estado e as variáveis são inicializados com valores zero.
Cell::Cell(void) { ArrayInitialize(actions, -1); ArrayInitialize(state, 0); value = 0; total_actions = 0; }
É importante lembrar que os estados coletados serão salvos em um arquivo de base de exemplos. Portanto, criaremos métodos para lidar com arquivos. O método de salvamento de dados é construído utilizando o algoritmo que já nos é familiar. O mesmo algoritmo que já usamos para gravar dados em classes criadas.
Nos parâmetros do método, recebemos o handle do arquivo para gravar os dados. E imediatamente verificamos seu valor. Se recebermos um handle inválido, o método é encerrado com o resultado false.
Após passar pelo bloco de controle com sucesso, gravamos o valor "999" no arquivo para identificar nossa estrutura. Em seguida, salvamos os valores das variáveis e do array. Para garantir a leitura subsequente correta dos arrays, é necessário especificar as dimensões dos arrays antes de gravar os dados. Com o objetivo de economizar espaço em disco, armazenaremos apenas os dados do caminho real, não todo o array "actions". E como já salvamos o valor da variável "total_actions", deixaremos de especificar o tamanho desse array. Ao salvar o array de estado "state", primeiro especificamos o tamanho do array e, em seguida, salvamos seu conteúdo. Controlamos rigorosamente o processo de execução de cada operação. Após salvar com sucesso todos os dados, encerramos o método com o resultado 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; }
O método de leitura de dados do arquivo "Load" é construído de maneira similar. Nele, as operações de leitura são executadas com uma clara preservação da sequência de gravação. O código completo do método pode ser encontrado no anexo.
Após a criação da estrutura que descreve um estado do sistema e o caminho para alcançá-lo, passamos à criação de um Expert Advisor (EA) para implementar a primeira fase do algoritmo Go-Explore. Nomearemos o EA de "Faza1.mq5". Embora estejamos realizando ações aleatórias sem analisar a situação de mercado, ainda assim usaremos indicadores para descrever o estado do sistema. Portanto, transferiremos seus parâmetros dos EAs anteriores. A variável externa "Start" será usada para indicar o estado da base de exemplos.Voltaremos a ela um pouco mais adiante. Voltaremos a isso um pouco mais tarde.
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;
Após definir os parâmetros externos, criaremos variáveis globais. Aqui, criamos 2 arrays de estruturas que descrevem o estado do sistema. O primeiro (Base) é usado para gravar os estados da passagem atual. O segundo (Total) é usado para gravar a base completa de exemplos.
Aqui mesmo, declaramos objetos para realizar operações de negociação e carregar dados históricos. Eles são totalmente idênticos aos usados anteriormente.
Para os fins do algoritmo que estamos criando:
- action_count: um contador de ações;
- actions: um array para registrar as ações executadas durante a sessão;
- StartCell: uma estrutura de descrição de estado para o início da exploração;
- bar: um contador de etapas desde o início do EA.
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;
Na função OnInit, primeiro inicializamos os objetos dos indicadores e das operações de negociação. Esse funcionalidade é completamente igual à dos EAs discutidos anteriormente.
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;
Então, tentamos carregar a base de exemplos que poderia ter sido criada em execuções anteriores do EA. Aqui, ambos os cenários são aceitáveis. Se conseguirmos carregar a base de exemplos, tentamos ler dela o elemento com o índice indicado na variável externa Start. Se esse elemento não estiver presente, selecionamos um elemento aleatório e o copiamos para a estrutura StartCell. Esse é o ponto de partida para nossa exploração. Se a base de exemplos não estiver carregada, começaremos a exploração desde o início.
//--- 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); }
Eu apliquei esse sistema ramificado de criação de ponto de partida para permitir a organização de vários cenários sem alterar o código do EA.
Após executar todas as operações, encerramos a função de inicialização do EA com o resultado INIT_SUCCEEDED.
Para carregar a base de exemplos, usamos a função LoadTotalBase. Para concluir a descrição do processo de inicialização, vamos analisar imediatamente o algoritmo dessa função. Esta função não possui parâmetros. Em vez disso, usamos a constante FileName, previamente definida, como o nome do arquivo.
Aqui, é importante notar que o arquivo será usado tanto na primeira quanto na segunda fase do algoritmo. Portanto, declaramos a constante FileName no arquivo da estrutura que descreve o estado.
Dentro da função, primeiro abrimos o arquivo para leitura e verificamos o resultado da operação com base no valor do handle.
Com a abertura bem-sucedida do arquivo, lemos o número de elementos na base de exemplos. Ajustamos o tamanho do array para leitura dos dados e preparamos um laço para ler os dados do arquivo. Para ler cada estrutura individual, usamos o método Load da nossa estrutura de armazenamento do estado do sistema, criado anteriormente.
A cada iteração, controlamos o processo de execução das operações. E antes de sair da função, em qualquer um dos cenários, fechamos o arquivo que estava aberto para leitura.
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; }
Após criar o algoritmo de inicialização do EA, passamos para o método de processamento de ticks, OnTick. Esse método é chamado pelo terminal quando ocorre um novo tick no gráfico do EA. No entanto, precisamos lidar apenas com o evento de abertura de uma nova vela. Para implementar esse controle, usamos a função IsNewBar. Ela é uma cópia completa dos EAs anteriores e não entraremos nos detalhes do algoritmo dela.
void OnTick() { //--- if(!IsNewBar()) return;
Em seguida, incrementamos o contador de etapas desde o início do EA e comparamos seu valor com o número de etapas até o estado inicial da exploração. Se ainda não chegamos ao estado inicial da exploração, pegamos a próxima ação do caminho até o estado alvo e a executamos. Em seguida, aguardamos a abertura de uma nova vela.
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; }
Após atingir o estado inicial da exploração, copiamos o caminho percorrido para o array de ações do agente atual.
if(bar == StartCell.total_actions) ArrayCopy(actions, StartCell.actions, 0, 0, StartCell.total_actions);
Em seguida, atualizamos os dados históricos dos indicadores.
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();
Depois, criamos um array com a descrição atual do estado do sistema. Nele, gravamos os dados históricos dos indicadores e dos valores dos preços, bem como informações sobre o estado da conta e das posições abertas.
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;
Depois disso, realizamos uma ação aleatória.
//--- 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; }
E salvamos o estado atual no array de estados visitados do agente atual.
Note que, para o número de etapas até o estado atual, usamos a soma das etapas até o estado inicial da exploração e as etapas aleatórias da exploração. Salvamos os estados antes do início da exploração, pois eles já estão em nossa base de exemplos. Ao mesmo tempo, precisamos salvar o caminho completo para cada estado.
Como valor do estado, usaremos o inverso da mudança no saldo da conta. Isso será usado como um guia para priorizar os estados a serem explorados. O objetivo dessa priorização é encontrar etapas para minimizar as perdas, o que potencialmente levará a um aumento nos lucros totais. Além disso, podemos usar o inverso desse valor como recompensa ao treinar a política na segunda fase do algoritmo 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++; }
Após salvar os dados do estado atual, incrementamos o contador de etapas e aguardamos a próxima vela.
Construímos o algoritmo do agente para explorar o ambiente. Agora, temos que preparar o processo de coleta de dados de todos os agentes em uma única base de exemplos. Para isso, após a conclusão do teste, cada agente deve enviar os dados coletados para o centro de agregação. Essa funcionalidade é implementada no método OnTester. Ele é chamado pelo testador de estratégias após cada passagem.
Aqui, foi decidido salvar apenas as passagens lucrativas. Isso permitirá reduzir significativamente o tamanho da base de exemplos e acelerar o processo de aprendizado. Se você deseja treinar a política com máxima precisão e não tem restrições de recursos, pode salvar todas as passagens. Isso ajudará sua política a entender melhor o ambiente.
Primeiro, verificamos a lucratividade da passagem e, se necessário, enviamos os dados usando a função 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); }
Observe que, antes do envio, decrementamos o número de etapas em 1, pois os resultados da última ação são desconhecidos.
Para realizar o processo de coleta de dados em uma base de exemplos comum, usaremos 3 funções. No início, durante a inicialização do processo de otimização, carregamos a base de exemplos, se ela já tiver sido criada. Essa operação é realizada na função OnTesterInit.
//+------------------------------------------------------------------+ //| TesterInit function | //+------------------------------------------------------------------+ void OnTesterInit() { //--- LoadTotalBase(); }
Em seguida, processamos cada passagem na função OnTesterPass. Aqui, realizamos a coleta de dados de todos os quadros disponíveis e os adicionamos ao array da base de exemplos comum. A função FrameNext lê o próximo quadro (frame). E se a leitura dos dados for bem-sucedida, ela retorna verdadeiro. Caso ocorra um erro na leitura dos dados do quadro, ela retorna false. Graças a essa propriedade, podemos gerar um laço para ler os dados e adicioná-los ao nosso array comum.
//+------------------------------------------------------------------+ //| 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); } }
Ao finalizar o processo de otimização, é chamada a função OnTesterDeinit. Aqui, primeiro ordenamos nossa base de dados em ordem decrescente com base no valor do "value" da descrição do estado. Isso nos permitirá mover para o início do array os elementos que causam a maior perda.
//+------------------------------------------------------------------+ //| 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"); }
Em seguida, salvamos a base de exemplos em um arquivo usando o método SaveTotalBase. Seu algoritmo é construído de forma semelhante ao método LoadTotalBase mencionado anteriormente. E com o código completo dele, assim como de todas as funções mencionadas, você pode se familiarizar no anexo.
Com isso, concluímos o trabalho no EA da primeira fase. Compilamos ele e seguimos para o testador de estratégias. Selecionamos o EA que criamos, o Faza1.ex5, o instrumento, o período de teste (no nosso caso, treinamento) e a otimização lenta com a varredura de todas as opções.
Vamos otimizar por um parâmetro, o Start. Usamos ele para determinar a quantidade de agentes em execução. Inicialmente, eu iniciei com um pequeno número de agentes. Isso nos proporciona uma rápida passagem para a criação da base de exemplos inicial.
Após a conclusão da primeira fase de otimização, aumentamos o número de agentes de teste. Aqui, temos 2 abordagens para a próxima execução. Se quisermos tentar encontrar a melhor ação em estados mais deficitários, definimos o intervalo de otimização do parâmetro Start a partir de "0". Para escolher estados aleatórios como ponto de partida da exploração, atribuímos um valor inicial de otimização do parâmetro muito grande. O valor final de otimização do parâmetro depende do número de agentes a serem executados. O valor na coluna "Steps" corresponde ao número de agentes a serem executados durante o processo de otimização (treinamento).
2.2. Fase 2 - Treinamento da Política com Base em Exemplos
Enquanto nosso primeiro EA está ocupado criando uma base de exemplos, vamos agora trabalhar no EA da segunda fase.
É importante notar que na minha implementação eu desviei um pouco do processo de treinamento da política na fase 2 conforme proposto pelos autores do artigo. O artigo sugeria o uso de um método de aprendizado por imitação na fase 2, onde uma abordagem modificada dos algoritmos de aprendizado por reforço conhecidos é utilizada. Nesse método, o agente aprende a replicar as ações de uma estratégia bem-sucedida da base de exemplos e depois emprega a abordagem padrão de aprendizado por reforço. Na primeira etapa, o segmento "professor" é maximizado. O agente deve obter resultados não piores do que o "professor". À medida que o agente aprende, o intervalo do "professor" é reduzido. E o agente deve aprender a otimizar a estratégia do "professor".
Na minha implementação, dividi essa fase em 2 etapas. Na primeira etapa, treinamos o agente de maneira semelhante ao processo de aprendizado supervisionado. No entanto, em vez de fornecer a ação correta, ajustamos o valor da recompensa prevista. Para essa etapa, criamos o EA Faza2.mq5.
No código desse EA, incorporamos um elemento de descrição do estado do sistema e uma classe de modelo totalmente parametrizada FQF.
//+------------------------------------------------------------------+ //| Includes | //+------------------------------------------------------------------+ #include "Cell.mqh" #include "..\RL\FQF.mqh" //+------------------------------------------------------------------+ //| Input parameters | //+------------------------------------------------------------------+ input int Iterations = 100000;
Ele possui poucos parâmetros externos. Apenas especificamos o número de iterações para treinar o modelo.
Entre os parâmetros globais, declaramos a classe do modelo, o objeto de descrição do estado e o array de recompensas. Também um array para carregar a base de exemplos.
CNet StudyNet; //--- float dError; datetime dtStudied; bool bEventStudy; //--- CBufferFloat State1; CBufferFloat *Rewards; Cell Base[];
No método de inicialização do EA, primeiro carregamos a base de exemplos. Este é um ponto-chave. Se houver um erro ao carregar a base de exemplos, não teremos dados iniciais para treinar o modelo. Portanto, em caso de erro, encerramos a função com o resultado 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); }
Após o carregamento da base de exemplos, inicializamos o modelo para treinamento. Como de costume, tentamos carregar um modelo pré-treinado primeiro. Se a tentativa de carregar o modelo falhar por algum motivo, inicializamos a criação de um novo modelo com pesos aleatórios. A descrição do modelo é fornecida na função CreateDescriptions.
Após a inicialização bem-sucedida do modelo, criamos um evento personalizado para iniciar o processo de treinamento do modelo. Usamos uma abordagem semelhante ao treinamento supervisionado para isso.
Em seguida, concluímos a função de inicialização do EA.
É importante notar que neste EA não criamos objetos para carregar dados históricos de preços e indicadores. Todo o processo de treinamento é baseado em exemplos. E em nossa base de exemplos, salvamos todas as descrições do estado do sistema, incluindo informações sobre o saldo e as posições abertas.
O evento personalizado que criamos é processado na função OnChartEvent. Aqui, apenas verificamos se o evento esperado ocorreu e chamamos a função de treinamento do modelo.
//+------------------------------------------------------------------+ //| ChartEvent function | //+------------------------------------------------------------------+ void OnChartEvent(const int id, const long &lparam, const double &dparam, const string &sparam) { //--- if(id == 1001) Train(); }
O processo real de treinamento do modelo ocorre na função Train. Esta função não possui parâmetros. No corpo da função, primeiro determinamos o tamanho da base de exemplos e salvamos o número de milissegundos desde o início do sistema em uma variável local. Esse valor será usado para informar periodicamente o usuário sobre o progresso do processo de treinamento do modelo.
//+------------------------------------------------------------------+ //| Train function | //+------------------------------------------------------------------+ void Train(void) { int total = ArraySize(Base); uint ticks = GetTickCount();
Após alguma preparação, iniciamos um laço para o treinamento do modelo. O número de iterações do laço é determinado pelo valor da variável externa. Além disso, consideramos a possibilidade de interromper o laço e fechar o programa conforme solicitado pelo usuário. Isso é realizado por meio da função IsStopped. Se o programa for fechado pelo usuário, essa função retornará 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; }
Dentro do laço, escolhemos aleatoriamente um exemplo da base de exemplos e copiamos o estado para um buffer de dados. Em seguida, realizamos uma propagação pelo modelo.
if(!StudyNet.feedForward(GetPointer(State1), 12, true)) return;
Em seguida, extraímos a ação tomada no exemplo atual, carregamos os resultados da propagação e atualizamos a recompensa pela ação tomada.
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;
Destaco dois pontos. Se não houver ação no exemplo (caso do estado inicial), diminuímos o contador de iterações e escolhemos um novo exemplo. Além disso, ao atualizarmos a recompensa, usamos o valor "value" com sinal invertido. Lembra? Ao salvar o estado, atribuímos um valor positivo para diminuir o patrimônio. Esse é um aspecto negativo.
Após atualizar a recompensa, realizamos a retropropagação do modelo e atualizamos os pesos.
if(!StudyNet.backProp(GetPointer(Rewards))) return; if(GetTickCount() - ticks > 500) { Comment(StringFormat("%.2f%% -> Error %.8f", iter * 100.0 / (double)(Iterations), StudyNet.getRecentAverageError())); ticks = GetTickCount(); } }
Ao final de cada iteração do laço, verificamos se é necessário atualizar as informações sobre o processo de treinamento para o usuário. No exemplo dado, atualizamos as informações no campo de comentários do chat a cada 0,5 segundos.
As operações no corpo do laço estão concluídas e passamos para um novo exemplo na base.
Após a conclusão de todas as iterações do laço, limpamos o campo de comentários. Exibimos informações no log e iniciamos o processo de encerramento do EA.
Comment(""); //--- PrintFormat("%s -> %d -> %10.7f", __FUNCTION__, __LINE__, StudyNet.getRecentAverageError()); ExpertRemove(); //--- }
Ao encerrar o EA em seu método de desinicialização, excluímos os objetos dinâmicos utilizados e salvamos o modelo treinado no disco.
//+------------------------------------------------------------------+ //| Expert deinitialization function | //+------------------------------------------------------------------+ void OnDeinit(const int reason) { if(!!Rewards) delete Rewards; //--- StudyNet.Save(FileName + ".nnw", 0, 0, 0, 0, true); }
Após a coleta da base de exemplos pelo EA da primeira fase, basta anexar o EA da segunda fase ao gráfico para iniciar o processo de treinamento do modelo. Diferentemente do EA da primeira fase, o EA da segunda fase não é executado no testador de estratégias, mas é anexado a um gráfico real. Nos parâmetros do EA, indicamos o número de iterações do ciclo do processo de treinamento e observamos o processo.
Para alcançar resultados ótimos, é permitido repetir as iterações das primeiras e segundas fases. Você pode, por exemplo, repetir a primeira fase N vezes e, em seguida, a segunda fase M vezes. Ou até mesmo repetir o laço de iterações primeira fase + segunda fase várias vezes.
Para ajustes mais refinados na política, aplicamos um terceiro EA, GE-lerning.mq5. Ele implementa um algoritmo clássico de aprendizado por reforço. Não vamos entrar em detalhes sobre todas as funções do EA agora. O código completo delas está disponível no anexo. Vamos focar apenas na função de processamento de ticks, OnTick.
Assim como no EA da primeira fase, processamos apenas o evento de abertura de uma nova vela. Caso esse evento não ocorra, simplesmente encerramos a função, aguardando o momento necessário.
Quando ocorre o evento de abertura de uma nova vela, primeiro salvamos o último estado, a ação realizada e a variação do patrimônio em um buffer de reprodução de experiência. E sobrescrevemos a variável global do patrimônio para monitorar a mudança na próxima vela.
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;
Em seguida, atualizamos o histórico de preços e indicadores.
//--- 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();
E criamos a descrição do estado atual do sistema. É importante prestar atenção para garantir que a descrição do estado seja completamente compatível com o processo análogo no EA da primeira fase. Afinal, a operação e o ajuste refinado devem ser realizados em dados comparáveis aos da amostra de treinamento.
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;
Após isso, realizamos uma propagação pelo modelo. Com base nos resultados dessa propagação, determinamos e executamos a ação.
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; }
É importante destacar que, neste caso, não usamos nenhuma política de exploração. Estamos seguindo rigidamente a política aprendida.
Ao final da função de processamento de ticks, verificamos a hora. Uma vez por dia, à meia-noite, atualizamos a política do agente usando o buffer de reprodução de experiência.
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; } } //--- }
O código completo de todos os EAs está disponível no anexo.
3. Teste
O teste de funcionamento dos três EAs criados foi realizado sequencialmente, de acordo com o algoritmo Go-Explore:
- Múltiplas execuções sequenciais do EA da primeira fase no modo de otimização do testador de estratégias para criar a base de exemplos.
- Várias iterações de formação política pelo EA da segunda fase.
- Ajuste final no testador de estratégias usando algoritmos de aprendizado por reforço.
Todos os testes, assim como em toda a série de artigos, foram realizados com base em dados históricos do instrumento EURUSD, no intervalo de tempo H1. Os parâmetros dos indicadores foram utilizados com as configurações padrão, sem nenhuma alteração.
Como resultado do teste, foram obtidos resultados bastante promissores, como mostrado nas capturas de tela abaixo.
de testes
Nas imagens apresentadas, podemos observar um gráfico de crescimento de saldo bastante estável. Nos dados de teste, foi alcançado um fator de lucro de 6.0 e um fator de recuperação de 3.34. Das 30 negociações realizadas, 22 foram lucrativas, o que representou 73,3%. O lucro médio por negociação é mais de duas vezes maior do que a perda média por negociação. Além disso, o lucro máximo por negociação é 3,5 vezes maior do que a perda máxima por negociação.
É importante notar que o EA realizou apenas negociações de compra e as fechou sem grandes quedas. A ausência de negociações curtas é um tópico de pesquisa adicional.
Os resultados dos testes são promissores, mas foram obtidos em um intervalo de tempo curto. Para confirmar os resultados do algoritmo, são necessários experimentos adicionais em um intervalo de tempo mais longo.
Considerações finais
Neste artigo, fomos apresentados ao algoritmo Go-Explore, que representa uma nova abordagem para resolver problemas complexos de aprendizado por reforço. Ele se baseia na ideia de lembrar e revisitar estados promissores no espaço de estados, o que permite alcançar um desempenho desejado mais rapidamente. A principal diferença do Go-Explore em relação a outros algoritmos é o foco na busca por estados e ações relevantes, em vez de uma solução direta para a tarefa-alvo.
Construímos três EAs que são executados sequencialmente. Cada um deles realiza uma parte funcional do algoritmo para alcançar o objetivo geral de treinar uma política. Neste contexto, política se refere a uma estratégia de negociação.
O algoritmo foi testado em dados históricos e apresentou um dos melhores desempenhos. No entanto, esses resultados foram obtidos no testador de estratégias em um intervalo de tempo curto. Portanto, antes de usar o EA em contas reais, é necessário testá-lo amplamente e treinar o modelo em um intervalo de tempo mais longo e representativo.
Referências
- Go-Explore: a New Approach for Hard-Exploration Problems
- Redes neurais de maneira fácil (Parte 35): módulo de curiosidade intrínseca
- Redes neurais de maneira fácil (Parte 36): modelos relacionais de aprendizado por reforço
- Redes neurais de maneira fácil (Parte 37): atenção esparsa
- Redes neurais de maneira fácil (Parte 38): exploração auto-supervisionada via desacordo
Programas utilizados no artigo
# | Nome | Tipo | Descrição |
---|---|---|---|
1 | Faza1.mq5 | EA | EA da primeira fase |
2 | Faza2.mql5 | EA | EA da segunda fase |
3 | GE-lerning.mq5 | EA | EA de ajuste fino de políticas |
4 | Cell.mqh | Biblioteca de classe | Estrutura de descrição do estado do sistema |
5 | FQF.mqh | Biblioteca de classe | Biblioteca de classes de preparação de modelos totalmente parametrizada |
6 | NeuroNet.mqh | Biblioteca de classe | Biblioteca de classes para a criação de uma rede neural |
7 | NeuroNet.cl | Biblioteca | Biblioteca do código do programa OpenCL |
Traduzido do russo pela MetaQuotes Ltd.
Artigo original: https://www.mql5.com/ru/articles/12558
- Aplicativos de negociação gratuitos
- 8 000+ sinais para cópia
- Notícias econômicas para análise dos mercados financeiros
Você concorda com a política do site e com os termos de uso