![Redes neurais de maneira fácil (Parte 85): previsão multidimensional de séries temporais](https://c.mql5.com/2/75/Neural_networks_are_easy_cPart_85k_600x314.jpg)
Redes neurais de maneira fácil (Parte 85): previsão multidimensional de séries temporais
Introdução
A previsão de séries temporais é, de uma forma ou de outra, um dos elementos mais importantes na construção de uma estratégia de trading eficiente. Afinal, ao realizar uma operação de trading em uma direção ou outra, partimos de nossa própria visão (previsão) do movimento futuro dos preços. Os recentes avanços em modelos de deep learning, especialmente nos modelos baseados na arquitetura Transformer, demonstraram um progresso significativo nessa área e abrem um enorme potencial para resolver os múltiplos problemas relacionados à previsão de séries temporais de longo prazo.
No entanto, surge a questão da eficácia do uso da arquitetura Transformer para fins de previsão de séries temporais. A maioria dos modelos baseados em Transformer que analisamos anteriormente usava o mecanismo de Self-Attention para capturar dependências de longo prazo em vários pontos temporais na sequência analisada. No entanto, alguns estudos sugerem que a maioria dos modelos Transformer, baseados na atenção entre tempos, não conseguem captar adequadamente as dependências temporais. E, às vezes, um simples modelo linear supera alguns deles.
Os autores do artigo "Client: Cross-variable Linear Integrated Enhanced Transformer for Multivariate Long-Term Time Series Forecasting" abordaram essa questão de forma bastante construtiva. Para avaliar a extensão do problema, realizaram um experimento abrangente com séries de mascaramento de parte do histórico, substituindo aleatoriamente alguns dados por "0". Os modelos mais sensíveis à dependência temporal mostraram uma grande queda no desempenho na ausência de dados históricos corretos. Assim, a queda no desempenho é um indicador da capacidade do modelo de capturar padrões temporais. Como resultado do experimento, observou-se que o desempenho dos modelos Transformer, baseados em atenção cruzada, não diminui significativamente à medida que aumenta o nível de mascaramento dos dados. Alguns desses modelos mantêm o desempenho de previsão praticamente inalterado, mesmo quando até 80% dos dados históricos são substituídos por "0" de forma aleatória. Isso pode indicar que os resultados de previsão desses modelos não são sensíveis às mudanças na série temporal analisada.
Minha opinião pessoal sobre os resultados da análise apresentada é ambígua. Claro, a falta de sensibilidade do modelo às mudanças na série temporal analisada é, para dizer o mínimo, preocupante. E considerando que o modelo é tratado como uma "caixa preta", é difícil entender quais partes dos dados o modelo leva em conta e quais ele ignora.
Por outro lado, nas condições de estocasticidade dos mercados financeiros, a série temporal analisada contém bastante ruído, que é desejável filtrar. E, nesse contexto, ignorar flutuações insignificantes ou outliers não característicos do ambiente em análise nos ajuda a destacar a parte mais relevante da série temporal analisada.
Além disso, os autores do artigo notaram que, em algumas séries temporais multivariadas, diferentes variáveis demonstram padrões relacionados ao longo do tempo. Isso indica a possibilidade de usar mecanismos de atenção para estudar as dependências entre variáveis, e não entre os passos temporais. Essa suposição permite mudar a direção de aplicação do mecanismo Self-Attention.
Embora o Transformer proposto pelos autores do artigo modele bem a não-linearidade e capture as dependências entre variáveis, ele pode não funcionar ao extrair tendências das séries analisadas. No entanto, modelos lineares realizam essa tarefa excelentemente. Para combinar o melhor dos dois mundos, os autores do artigo propõem o método Transformer Linearmente Integrado e Aprimorado com Análise Cruzada de Variáveis para Previsão de Séries Temporais Multivariadas de Longo Prazo (Cross-variable Linear Integrated Enhanced Transformer — Client). O algoritmo proposto combina a capacidade dos modelos lineares de extrair tendências com as poderosas habilidades do Transformer aprimorado.
1. Algoritmo Client
A principal ideia do Client consiste em passar da atenção ao longo do tempo para a análise das dependências entre variáveis e integrar um módulo linear no modelo, a fim de melhor aproveitar as dependências entre variáveis e a informação de tendências, respectivamente.
Os autores do método Client abordaram criativamente a solução do problema de previsão de séries temporais. Por um lado, encontramos no algoritmo proposto abordagens já familiares para nós. Por outro, ele rejeita métodos que, à primeira vista, já pareciam estabelecidos. Ao mesmo tempo, a inclusão ou exclusão de cada bloco individual no algoritmo é acompanhada por uma série de testes que demonstram a relevância da decisão adotada em termos da eficácia do modelo.
Para resolver o problema de deslocamento de distribuição, os autores do método utilizam a normalização reversível com uma estrutura simétrica (RevIN), que já abordamos no artigo anterior. Com sua ajuda, primeiro são removidas as informações estatísticas da série temporal original. Após o processamento pelo modelo e a obtenção dos valores previstos, as informações estatísticas da série temporal original são restauradas nos resultados, o que, em geral, aumenta a estabilidade do treinamento do modelo e a qualidade das previsões da série temporal.
Para possibilitar a análise adicional no nível das variáveis, e não dos passos temporais, os autores do método propõem transpor os dados originais.
Os dados preparados dessa forma são alimentados no codificador Transformer, que consiste em várias camadas de Self-Attention Multihead (MHA) e blocos FeedForward (FFN).
Aqui, é importante observar que os dados de entrada do codificador são fornecidos diretamente, sem passar pela já familiar camada de Embedding. Os testes realizados pelos autores do método demonstraram a ineficácia do seu uso, pois um nível adicional de transformação dos dados distorce as informações temporais e reduz o desempenho do modelo. Além disso, o bloco de codificação posicional é removido, já que não há sequência temporal entre as diferentes variáveis.
Após a extração de características no codificador, a sequência temporal é passada para a camada de projeção, que gera os valores previstos para cada variável.
A camada de projeção proposta substitui o decodificador do Transformer clássico. Em sua pesquisa, os autores do Client descobriram que a adição do decodificador reduz o desempenho geral do modelo.
Paralelamente ao bloco de atenção, um módulo linear é integrado ao modelo Client, sendo usado para analisar informações sobre tendências das séries temporais de canais independentes das variáveis individuais.
Os valores previstos do bloco de atenção e do módulo linear são somados, considerando coeficientes de ponderação treináveis, aplicados aos resultados do módulo linear.
Na saída do modelo, os resultados são transpostos novamente para alinhar com a sequência dos dados originais. E as informações estatísticas da série temporal são restauradas.
Assim, o método Client utiliza o módulo linear para coletar informações sobre tendências e o módulo Transformer expandido para captar informações não lineares e dependências entre variáveis. A visualização do método pelos autores é apresentada abaixo.
2. Implementação em MQL5
Após discutirmos os aspectos teóricos do método Client, passamos à parte prática de nosso artigo, na qual implementaremos nossa visão das abordagens propostas utilizando MQL5.
2.1 Criando uma nova camada neural
Primeiramente, criaremos uma nova classe CNeuronClientOCL, que incorporará a maioria das abordagens propostas. Essa classe, como a maioria das que criamos anteriormente, será uma herdeira de nossa classe base de camada neural CNeuronBaseOCL.
class CNeuronClientOCL : public CNeuronBaseOCL { protected: //--- Attention CNeuronMLMHAttentionOCL cTransformerEncoder; CNeuronConvOCL cProjection; //--- Linear model CNeuronConvOCL cLinearModel[]; //--- CNeuronBaseOCL cInput; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL); virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL); virtual bool calcInputGradients(CNeuronBaseOCL *prevLayer); public: CNeuronClientOCL(void) {}; ~CNeuronClientOCL(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_key, uint heads, uint at_layers, uint count, uint &mlp[], ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual int Type(void) const { return defNeuronClientOCL; } //--- virtual bool Save(int const file_handle); virtual bool Load(int const file_handle); //--- virtual void SetOpenCL(COpenCLMy *obj); virtual void TrainMode(bool flag); //--- virtual bool WeightsUpdate(CNeuronBaseOCL *source, float tau); };
O bloco de atenção será criado a partir de 2 objetos:
- cTransformerEncoder — um objeto da classe CNeuronMLMHAttentionOCL, que permite criar o bloco codificador do Transformer multihead com o número definido de camadas sequenciais;
- cProjection — camada de projeção. Aqui, usaremos uma camada convolucional para criar previsões independentes para variáveis individuais. E a profundidade da previsão determina o número de filtros na camada.
Para criar o módulo linear, criaremos um array dinâmico de camadas convolucionais cLinearModel[], que nos permitirá gerar previsões independentes para variáveis individuais.
Observe que, nesta implementação, decidimos mover para fora da classe as camadas de normalização reversível e transposição de dados. Isso se deve ao fato de que o bloco Client pode ser integrado a uma arquitetura mais complexa. Portanto, a remoção e restauração das informações estatísticas podem ocorrer fora deste bloco.
Ao mesmo tempo, a transposição dos dados também pode ser realizada separadamente do bloco Client. Além disso, em alguns casos, é possível criar a sequência necessária de dados originais na fase de preparação.
O conjunto de métodos da nossa nova classe é bastante padrão e não se distingue por sua originalidade.
Todos os objetos internos são declarados como estáticos, o que nos permite deixar o construtor e o destrutor da classe vazios. Essa abordagem nos permite prestar menos atenção às questões de gerenciamento de memória, delegando essa função ao sistema.
A inicialização de todos os objetos internos é realizada no método Init. Nos parâmetros desse método, passaremos para o objeto da classe todas as informações necessárias para organizar a arquitetura requerida.
Aqui, é importante observar que no corpo da classe criamos 2 fluxos paralelos:
- Bloco Transformer;
- Módulo linear.
Ambos os módulos possuem arquiteturas complexas e muito diferentes entre si, embora trabalhem com o mesmo conjunto de dados. Consequentemente, precisamos de um mecanismo para transmitir para o objeto da arquitetura ambos os módulos. Para o bloco Transformer, usaremos a abordagem já testada com 5 variáveis:
- window — tamanho do vetor de 1 elemento da sequência;
- window_key — tamanho do vetor da representação interna de 1 elemento da sequência;
- heads — número de cabeças de atenção;
- count — número de elementos na sequência;
- at_layers — número de camadas no bloco codificador.
Para descrever a arquitetura do módulo linear, utilizaremos um array numérico mlp[]. O número de elementos no array indicará a quantidade de camadas a serem criadas. E o valor de cada elemento indicará o tamanho do vetor de descrição de um elemento da sequência na saída da camada. O módulo linear trabalha com a mesma sequência de dados que o bloco de atenção. Portanto, o número de elementos na sequência será o mesmo.
É importante destacar que os autores do método Client propõem analisar as dependências entre variáveis. Portanto, nesse caso, o tamanho do vetor de descrição de um elemento da sequência será igual à profundidade do histórico analisado. E o número de elementos na sequência será igual ao número de variáveis analisadas. Os dados originais devem ser transpostos de maneira adequada antes de serem fornecidos como entrada para o objeto da nossa nova classe CNeuronClientOCL.
Com essa abordagem, a profundidade de previsão dos dados será indicada no último elemento do array mlp[].
Definida a lógica de passagem de dados, implementaremos a abordagem proposta no código. Nos parâmetros do método Init, incluiremos as variáveis mencionadas acima e as complementaremos com os elementos da classe base.
bool CNeuronClientOCL::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_key, uint heads, uint at_layers, uint count, uint &mlp[], ENUM_OPTIMIZATION optimization_type, uint batch) { uint mlp_layers = mlp.Size(); if(mlp_layers == 0) return false;
No corpo do método, primeiro verificamos o tamanho do array de descrição da arquitetura do módulo linear mlp[]. Ele deve conter pelo menos um elemento indicando a profundidade de previsão dos dados. Se o array estiver vazio, encerramos a execução do método com o resultado false.
O próximo estágio é a inicialização dos objetos da classe. Primeiro, alteramos o array dinâmico do módulo linear.
if(ArrayResize(cLinearModel, mlp_layers + 1) != (mlp_layers + 1)) return false;
Observe que o tamanho do array deve ser 1 elemento maior do que a arquitetura recebida da camada linear. Discutiremos as razões para essa etapa um pouco mais tarde.
Em seguida, chamamos o método homônimo da classe pai, onde todos os objetos herdados são inicializados.
if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, mlp[mlp_layers - 1] * count, optimization_type, batch)) return false;
Depois disso, chamamos o método de inicialização do codificador Transformer.
if(!cTransformerEncoder.Init(0, 0, OpenCL, window, window_key, heads, count, at_layers, optimization, iBatch)) return false;
E da camada auxiliar de armazenamento temporário dos dados originais.
if(!cInput.Init(0, 1, open_cl, window * count, optimization_type, batch)) return false;
O próximo passo é criar um loop, no qual inicializaremos as camadas do módulo linear.
uint w = window; for(uint i = 0; i < mlp_layers; i++) { if(!cLinearModel[i].Init(0, i + 2, OpenCL, w, w, mlp[i], count, optimization, iBatch)) return false; cLinearModel[i].SetActivationFunction(LReLU); w = mlp[i]; }
Vale lembrar que os autores do método Client sugerem aplicar coeficientes treináveis aos resultados do módulo linear. E devo dizer que foi encontrado um método bastante incomum para criar multiplicadores treináveis. Decidimos substituí-los por uma camada convolucional com a quantidade de filtros, tamanho da janela e passo de convolução igual a "1". Nós o adicionamos ao último elemento (adicionado por nós anteriormente) do array do módulo linear.
if(!cLinearModel[mlp_layers].Init(0, mlp_layers + 2, OpenCL, 1, 1, 1, w * count, optimization, iBatch)) return false;
E mais um ponto. No processo de normalização dos dados originais, os ajustamos para uma média igual a "0" e variância "1". Consequentemente, os valores previstos também devem seguir essa distribuição. Para limitar os valores previstos, usaremos a tangente hiperbólica (tanh) como função de ativação.
De maneira semelhante, também inicializamos a camada de projeção do bloco de atenção.
cLinearModel[mlp_layers].SetActivationFunction(TANH); if(!cProjection.Init(0, mlp_layers + 3, OpenCL, window, window, w, count, optimization, iBatch)) return false; cProjection.SetActivationFunction(TANH);
Como pode ser observado, ambos os blocos de previsão de dados na saída são ativados pela tangente hiperbólica. Para a correta propagação do gradiente de erro, especificaremos a mesma função de ativação para toda a camada.
SetActivationFunction(TANH);
Como planejamos simplesmente somar os valores dos 2 módulos, durante a propagação reversa, podemos distribuir o gradiente de erro integralmente entre ambos os módulos. Para evitar operações de cópia desnecessárias de dados, realizamos a substituição dos buffers de armazenamento de gradientes de erro nas camadas internas.
if(!SetGradient(cProjection.getGradient())) return false; if(!cLinearModel[mlp_layers].SetGradient(Gradient)) return false; //--- return true; }
Ao fazer isso, não nos esquecemos de controlar o processo de execução das operações em cada etapa. E após a inicialização bem-sucedida de todos os objetos aninhados, retornaremos o resultado lógico da execução das operações ao programa chamador.
Após a inicialização dos objetos aninhados da classe, passamos para a organização do algoritmo de propagação para frente no método CNeuronClientOCL::feedForward. Os principais princípios de transmissão de dados foram discutidos durante a inicialização dos objetos. Agora veremos a implementação das abordagens propostas.
Nos parâmetros, o método recebe um ponteiro para o objeto da camada de neurônio anterior. E no corpo do método, chamamos imediatamente o método de propagação para frente do nosso bloco de atenção multicamadas.
bool CNeuronClientOCL::feedForward(CNeuronBaseOCL *NeuronOCL) { if(!cTransformerEncoder.FeedForward(NeuronOCL)) return false;
Em seguida, projetamos os valores previstos na profundidade de planejamento necessária.
if(!cProjection.FeedForward(GetPointer(cTransformerEncoder))) return false;
Para não copiar todo o volume dos dados originais para a camada interna, copiaremos apenas o ponteiro para o buffer de dados correspondente.
if(cInput.getOutputIndex() != NeuronOCL.getOutputIndex())
cInput.getOutput().BufferSet(NeuronOCL.getOutputIndex());
E organizamos o loop de propagação para frente do módulo linear.
uint total = cLinearModel.Size(); CNeuronBaseOCL *neuron = NeuronOCL; for(uint i = 0; i < total; i++) { if(!cLinearModel[i].FeedForward(neuron)) return false; neuron = GetPointer(cLinearModel[i]); }
Nesta etapa, temos as projeções dos valores previstos de ambos os módulos. Ao mesmo tempo, as previsões do módulo linear já foram ajustadas pelos coeficientes de treinamento. Só nos resta somar os dados de ambos os fluxos.
if(!SumAndNormilize(neuron.getOutput(), cProjection.getOutput(), Output, 1, false, 0, 0, 0, 0.5 )) return false; //--- return true; }
De maneira semelhante, mas em ordem inversa, é definido o fluxo do gradiente de erro através dos objetos aninhados até a camada anterior, de acordo com sua influência no resultado. Isso pode ser observado no método CNeuronClientOCL::calcInputGradients.
Lembro que, graças à substituição dos buffers de dados, o gradiente de erro da camada subsequente é gravado diretamente nos buffers dos objetos de ambos os módulos. Consequentemente, omitimos a operação desnecessária de distribuir o gradiente de erro entre o Transformer e o módulo linear. E passamos imediatamente a distribuir o gradiente de erro através dos módulos especificados. Primeiro, propagamos o gradiente de erro através do bloco de atenção.
bool CNeuronClientOCL::calcInputGradients(CNeuronBaseOCL *prevLayer) { if(!cTransformerEncoder.calcHiddenGradients(cProjection.AsObject())) return false; if(!prevLayer.calcHiddenGradients(cTransformerEncoder.AsObject())) return false;
Depois, no ciclo inverso, através do módulo linear.
CNeuronBaseOCL *neuron = NULL; int total = (int)cLinearModel.Size() - 1; for(int i = total; i >= 0; i--) { neuron = (i > 0 ? cLinearModel[i - 1] : cInput).AsObject(); if(!neuron.calcHiddenGradients(cLinearModel[i].AsObject())) return false; }
Observe que o Transformer grava o gradiente de erro no buffer da camada anterior, enquanto o modelo linear grava no buffer da camada interna.
Antes de finalizar o método, somaremos os gradientes de erro de ambos os fluxos.
if(!SumAndNormilize(neuron.getGradient(), prevLayer.getGradient(), prevLayer.getGradient(), 1, false)) return false; //--- return true; }
De maneira semelhante, foram construídos outros métodos desta classe. Apenas chamamos os métodos homônimos dos objetos internos em sequência. No entanto, não entraremos em detalhes sobre os algoritmos deles neste artigo. Sugiro que você explore isso por conta própria. O código completo da classe e de todos os seus métodos está anexado. Lá também está disponível o código completo de todos os programas utilizados na preparação deste artigo.
2.2 Arquitetura dos modelos
Acima, criamos uma nova classe CNeuronClientOCL, na qual a maioria das abordagens propostas pelos autores do método Client foi implementada. No entanto, alguns requisitos do método foram deixados para implementação diretamente na arquitetura do modelo.
O método Client foi proposto para resolver problemas de previsão de séries temporais. E o utilizaremos no nosso codificador.
Aqui é importante entender que, na estrutura de nossos modelos, o codificador é usado para preparar uma representação comprimida do estado do ambiente. O modelo do Ator utiliza essa representação para gerar a ação ideal em um estado particular, com base na política de comportamento treinada. Naturalmente, para treinar a política de comportamento mais eficiente, precisamos de uma representação comprimida do estado do ambiente que seja correta e informativa.
O conceito de "representação comprimida correta e informativa do estado do ambiente" soa bastante abstrato e indefinido. É lógico supor que, como estamos treinando a política do Ator para realizar ações ideais que gerem o máximo possível de lucro nas condições do movimento de preço provável, a representação comprimida deve conter o máximo de informações possíveis sobre o movimento de preço futuro provável. Além disso, precisamos avaliar os riscos e a probabilidade de o preço se mover na direção oposta, bem como a possível magnitude desse movimento. Dentro dessa abordagem, parece sensato treinar o codificador para prever o movimento futuro dos preços. Assim, o estado oculto do codificador conterá o máximo de informações possíveis sobre o movimento futuro dos preços. Portanto, na arquitetura do nosso codificador, usamos as abordagens do método Client.
A arquitetura do codificador é apresentada no método CreateEncoderDescriptions. Nos parâmetros, o método recebe um ponteiro para um array dinâmico, onde será armazenada a arquitetura do modelo.
bool CreateEncoderDescriptions(CArrayObj *encoder) { //--- CLayerDescription *descr; //--- if(!encoder) { encoder = new CArrayObj(); if(!encoder) return false; }
No corpo do método, verificamos o ponteiro recebido e, se necessário, criamos uma nova instância do objeto array dinâmico.
Na entrada do modelo, como de costume, fornecemos a descrição "crua" do estado do ambiente. Para armazenar os dados de entrada, criamos uma camada básica de rede neural com tamanho suficiente para receber esses dados.
//--- Encoder encoder.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; int prev_count = descr.count = (HistoryBars * BarDescr); descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
Aqui, como anteriormente, o tamanho da camada é determinado pelo produto de 2 constantes:
- HistoryBars — a profundidade do histórico de estados (barras) analisada do ambiente;
- BarDescr — o tamanho do vetor que descreve uma barra do estado do ambiente.
Mas há um detalhe. Anteriormente, a cada iteração, fornecíamos ao modelo informações apenas sobre a última barra fechada do movimento de preços. E toda a profundidade do histórico analisado era acumulada na forma de embeddings no stack da camada interna de nosso modelo. Agora, os autores do método Client afirmam que uma camada adicional de embedding distorce as informações da série temporal e recomendam excluí-la. Consequentemente, expandimos a camada de dados de entrada do modelo para transmitir os dados de toda a profundidade da história analisada.
Vale mencionar que aumentamos o valor da constante HistoryBars para 120. Isso permite analisar os dados históricos da última semana no time frame H1.
#define HistoryBars 120 //Depth of history
A próxima camada, como antes, é a camada de normalização por lotes, onde os dados de entrada são ajustados removendo as informações estatísticas da série temporal.
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBatchNormOCL; descr.count = prev_count; descr.batch = 1000; descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
Vamos armazenar o identificador dessa camada. Afinal, na saída do modelo, precisaremos retornar as informações estatísticas da série temporal aos valores previstos.
Na preparação dos dados de entrada, podemos formá-los como uma sequência de dados de indicadores separados (variáveis no contexto do método Client), ou como uma sequência de descrições de passos temporais (barras), como feito anteriormente. Para este trabalho, decidi não alterar o bloco de preparação de dados de entrada. Isso permite usar os Expert Advisors (EAs) já criados para interagir com o ambiente com mínimas modificações.
No entanto, essa implementação requer a adição de uma camada de transposição de dados, que adicionaremos no próximo passo.
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronTransposeOCL; prev_count = descr.count = HistoryBars; int prev_wout = descr.window = BarDescr; if(!encoder.Add(descr)) { delete descr; return false; }
E após a camada de transposição, adicionamos a instância de nossa nova camada — o bloco Client.
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronClientOCL; descr.count = prev_wout; descr.window = prev_count; descr.step = 4; descr.window_out = EmbeddingSize; descr.layers = 5; { int temp[] = {1024, 1024, 1024, NForecast}; ArrayCopy(descr.windows, temp); } if(!encoder.Add(descr)) { delete descr; return false; }
Aqui, como o tamanho da sequência analisada, indicamos o número de variáveis analisadas (constante BarDescr). O tamanho do vetor que descreve um elemento da sequência é igual à profundidade do histórico analisado (constante HistoryBars). No bloco Transformer, usamos 4 cabeças de atenção e criamos 5 dessas camadas.
O módulo linear será composto por 4 camadas: 3 camadas ocultas de tamanho 1024 e a última camada correspondente ao horizonte de planejamento (constante NForecast).
Depois, realizamos a transposição reversa dos dados.
//--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronTransposeOCL; prev_count = descr.count = BarDescr; prev_wout = descr.window = NForecast; if(!encoder.Add(descr)) { delete descr; return false; }
E restauramos as informações estatísticas neles.
//--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronRevInDenormOCL; prev_count = descr.count = prev_count * prev_wout; descr.activation = None; descr.optimization = ADAM; descr.layers = 1; if(!encoder.Add(descr)) { delete descr; return false; } //--- return true; }
É necessário dizer algumas palavras sobre a arquitetura do Ator. Ela foi praticamente toda retirada do artigo anterior. No entanto, também há um detalhe aqui, sobre o qual falaremos mais tarde.
A arquitetura dos modelos Ator e Crítico é apresentada no método CreateDescriptions. Nos parâmetros do método, recebemos ponteiros para 2 arrays dinâmicos para registrar a arquitetura dos modelos.
bool CreateDescriptions(CArrayObj *actor, CArrayObj *critic) { //--- CLayerDescription *descr; //--- if(!actor) { actor = new CArrayObj(); if(!actor) return false; } if(!critic) { critic = new CArrayObj(); if(!critic) return false; }
No corpo do método, verificamos os ponteiros recebidos e, se necessário, criamos novas instâncias dos objetos dos arrays dinâmicos.
Na entrada do modelo Ator, como anteriormente, fornecemos a descrição do estado atual da conta e das posições abertas.
//--- Actor actor.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; int prev_count = descr.count = AccountDescr; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
Em seguida, formamos o embedding do estado da conta.
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = EmbeddingSize; descr.activation = SIGMOID; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
Adicionamos 3 camadas sequenciais de cross-attention, nas quais analisamos as dependências entre o estado atual da conta e a representação comprimida dos estados futuros do ambiente formada pelo codificador.
//--- layer 2-4 for(int i = 0; i < 3; i++) { if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronCrossAttenOCL; { int temp[] = {1, BarDescr}; ArrayCopy(descr.units, temp); } { int temp[] = {EmbeddingSize, NForecast}; ArrayCopy(descr.windows, temp); } descr.window_out = 16; descr.step = 4; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } }
Aqui é importante notar que, seguindo o espírito do método Client, para a análise cruzada utilizamos os dados do estado oculto do codificador antes da transposição repetida dos dados. Isso nos permite analisar as dependências do estado atual da conta com os valores previstos de variáveis individuais. Isso se refletiu na alteração dos valores dos arrays descr.units e descr.windows.
Em seguida, como anteriormente, há o bloco de tomada de decisões com a adição de estocasticidade à política do Ator.
//--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = SIGMOID; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 6 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 2 * NActions; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 7 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronVAEOCL; descr.count = NActions; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
Alterações semelhantes foram feitas no modelo Crítico. Como você lembra, os modelos Ator e Crítico possuem arquiteturas semelhantes. A única diferença é que na entrada do modelo Crítico, substituímos a descrição do estado da conta por um vetor de ações. E na saída do modelo, o vetor de ações é substituído por um vetor de recompensas. A descrição completa das soluções arquitetônicas de todos os modelos utilizados pode ser encontrada no anexo. Lá também está disponível o código completo de todos os programas utilizados na preparação deste artigo.
Além disso, alteramos o valor da constante ponteiro para a camada oculta do codificador para a extração de dados.
#define LatentLayer 3
O trabalho realizado para alinhar as soluções arquitetônicas dos modelos e alterar as constantes utilizadas permitiu que os Expert Advisors de interação com o ambiente previamente criados fossem utilizados praticamente sem mudanças. Foi necessário apenas recompilá-los, levando em conta as constantes e a arquitetura dos modelos modificadas. No entanto, o mesmo não pode ser dito sobre os Expert Advisors de treinamento dos modelos.
2.3 Expert Advisor de treinamento
O treinamento do modelo de previsão dos estados do ambiente é realizado no Expert Advisor “...\Experts\Client\StudyEncoder.mq5”. No geral, a estrutura do Expert Advisor foi herdada de trabalhos anteriores. E não iremos nos aprofundar em todos os métodos dele. Vamos apenas examinar o estágio de treinamento do modelo, realizado no método Train.
void Train(void) { //--- vector<float> probability = GetProbTrajectories(Buffer, 0.9); //--- vector<float> result, target; bool Stop = false; //--- uint ticks = GetTickCount();
No corpo do método, primeiro geramos um vetor de probabilidades de escolha de trajetórias do buffer de reprodução de experiência de acordo com sua rentabilidade real. As passagens lucrativas recebem maior probabilidade de serem usadas no processo de treinamento. Dessa forma, enfatizamos o treinamento nas trajetórias de maior rentabilidade.
Após o trabalho preparatório, organizamos o ciclo de treinamento do modelo. Diferente de alguns dos trabalhos mais recentes, aqui utilizamos um ciclo simples, em vez de um sistema de ciclos aninhados como anteriormente. Isso se tornou possível devido à remoção de elementos recorrentes (pilha de embeddings) na arquitetura dos modelos.
for(int iter = 0; (iter < Iterations && !IsStopped() && !Stop); iter ++) { int tr = SampleTrajectory(probability); int i = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (Buffer[tr].Total - 2 - NForecast)); if(i <= 0) { iter--; continue; }
No corpo do ciclo, fazemos o sampling da trajetória a partir do buffer de reprodução de experiência e do estado do ambiente nessa trajetória.
Extraímos a descrição do estado amostrado do ambiente a partir do buffer de reprodução de experiência e transferimos os valores obtidos para o buffer de dados.
bState.AssignArray(Buffer[tr].States[i].state);
Essa informação é suficiente para realizar a propagação para frente do codificador.
//--- State Encoder if(!Encoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
Em seguida, devemos preparar o vetor de valores-alvo. No escopo deste trabalho, o horizonte de planejamento é muito menor do que a profundidade da história analisada. Isso simplifica significativamente nossa tarefa de preparar os valores-alvo. Simplesmente extraímos a descrição do estado do ambiente do buffer de reprodução de experiência com um desvio correspondente ao horizonte de planejamento. E pegamos os primeiros elementos do tensor na quantidade necessária.
//--- Collect target data if(!bState.AssignArray(Buffer[tr].States[i + NForecast].state)) continue; if(!bState.Resize(BarDescr * NForecast)) continue;
Se você for usar um horizonte de planejamento maior do que a profundidade da história analisada, será necessário criar um ciclo para coletar os valores-alvo, iterando os estados no buffer de reprodução de experiência ao longo do horizonte de planejamento.
Após a preparação do tensor de valores-alvo, realizamos a propagação reversa do codificador para otimizar os parâmetros do modelo treinado para minimizar o erro de previsão dos dados.
if(!Encoder.backProp(GetPointer(bState), (CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
Depois, resta apenas informar o usuário sobre o progresso do processo de treinamento e passar para a próxima iteração do ciclo.
if(GetTickCount() - ticks > 500) { double percent = double(iter) * 100.0 / (Iterations); string str = StringFormat("%-14s %6.2f%% -> Error %15.8f\n", "Encoder", percent, Encoder.getRecentAverageError()); Comment(str); ticks = GetTickCount(); } }
É essencial controlar o processo de execução das operações em cada etapa. E após a conclusão bem-sucedida de todas as iterações de treinamento do modelo, limpamos o campo de comentários no gráfico.
Comment(""); //--- PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Encoder", Encoder.getRecentAverageError()); ExpertRemove(); //--- }
Registramos os resultados do treinamento do modelo no log e iniciamos o processo de finalização do Expert Advisor.
2.4 Expert Advisor de treinamento da política do Ator
No Expert Advisor de treinamento da política do Ator "...\Experts\Client\Study.mq5", também foram feitas algumas modificações. Assim como na análise do Expert Advisor anterior, vamos nos concentrar apenas no método de treinamento dos modelos.
void Train(void) { //--- vector<float> probability = GetProbTrajectories(Buffer, 0.9); //--- vector<float> result, target; bool Stop = false; //--- uint ticks = GetTickCount();
No corpo do método, primeiro geramos um vetor de probabilidades de escolha de trajetórias e realizamos outros trabalhos preparatórios. Nessa parte, podemos notar a exata repetição do algoritmo do Expert Advisor anterior.
Em seguida, também organizamos o ciclo de treinamento dos modelos, no qual fazemos o sampling da trajetória a partir do buffer de reprodução de experiência e do estado do ambiente nessa trajetória.
Carregamos a descrição selecionada do estado do ambiente e realizamos a propagação para frente do codificador.
bState.AssignArray(Buffer[tr].States[i].state); //--- State Encoder if(!Encoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
Aqui termina a "cópia" do algoritmo do Expert Advisor anterior. Após a geração da representação comprimida do ambiente, primeiro otimizamos os parâmetros do Crítico. Neste ponto, primeiro carregamos as ações do Ator, realizadas durante a interação com o ambiente nesse estado, e realizamos a propagação para frente do Crítico.
//--- Critic bActions.AssignArray(Buffer[tr].States[i].action); if(bActions.GetIndex() >= 0) bActions.BufferWrite(); if(!Critic.feedForward((CBufferFloat*)GetPointer(bActions), 1, false, GetPointer(Encoder), LatentLayer)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
Depois, extraímos do buffer de reprodução de experiência a recompensa real do ambiente obtida pelas ações do Ator.
result.Assign(Buffer[tr].States[i + 1].rewards); target.Assign(Buffer[tr].States[i + 2].rewards); result = result - target * DiscFactor; Result.AssignArray(result);
E otimizamos os parâmetros do Crítico para minimizar o erro de avaliação das ações do Ator.
Critic.TrainMode(true); if(!Critic.backProp(Result, (CNet *)GetPointer(Encoder), LatentLayer)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
A seguir, vem o bloco de treinamento em 2 etapas da política do Ator. Primeiro, extraímos a descrição do estado da conta correspondente ao estado do ambiente selecionado e transferimos para o buffer de dados.
//--- Policy float PrevBalance = Buffer[tr].States[MathMax(i - 1, 0)].account[0]; float PrevEquity = Buffer[tr].States[MathMax(i - 1, 0)].account[1]; bAccount.Clear(); bAccount.Add((Buffer[tr].States[i].account[0] - PrevBalance) / PrevBalance); bAccount.Add(Buffer[tr].States[i].account[1] / PrevBalance); bAccount.Add((Buffer[tr].States[i].account[1] - PrevEquity) / PrevEquity); bAccount.Add(Buffer[tr].States[i].account[2]); bAccount.Add(Buffer[tr].States[i].account[3]); bAccount.Add(Buffer[tr].States[i].account[4] / PrevBalance); bAccount.Add(Buffer[tr].States[i].account[5] / PrevBalance); bAccount.Add(Buffer[tr].States[i].account[6] / PrevBalance);
Em seguida, adicionamos ao buffer as harmônicas do carimbo de data e hora.
double time = (double)Buffer[tr].States[i].account[7]; double x = time / (double)(D'2024.01.01' - D'2023.01.01'); bAccount.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); x = time / (double)PeriodSeconds(PERIOD_MN1); bAccount.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0)); x = time / (double)PeriodSeconds(PERIOD_W1); bAccount.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); x = time / (double)PeriodSeconds(PERIOD_D1); bAccount.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); if(bAccount.GetIndex() >= 0) bAccount.BufferWrite();
E então realizamos a propagação para frente do Ator para gerar o vetor de ações.
//--- Actor if(!Actor.feedForward((CBufferFloat*)GetPointer(bAccount), 1, false, GetPointer(Encoder), LatentLayer)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
Como já mencionado, o treinamento da política do Ator é realizado em 2 etapas. Primeiro, ajustamos a política do Ator para manter suas ações dentro da distribuição do nosso conjunto de treinamento. Para isso, minimizamos o erro entre o vetor de ações gerado pelo Ator e as ações reais do buffer de reprodução de experiência.
if(!Actor.backProp(GetPointer(bActions), GetPointer(Encoder), LatentLayer)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
Na segunda etapa, ajustamos a política do Ator segundo a avaliação das ações geradas, realizada pelo Crítico. Aqui, primeiramente, realizamos a avaliação das ações.
if(!Critic.feedForward((CNet *)GetPointer(Actor), -1, (CNet*)GetPointer(Encoder), LatentLayer)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
Em seguida, desativamos o modo de treinamento do Crítico e passamos o gradiente da diferença entre a avaliação das ações e as ações realmente possíveis nesse estado através do Crítico.
Critic.TrainMode(false); if(!Critic.backProp(Result, (CNet *)GetPointer(Encoder), LatentLayer) || !Actor.backPropGradient((CNet *)GetPointer(Encoder), LatentLayer, -1, true)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
Partimos do pressuposto de que, no processo de treinamento, a política do Ator deve apenas se aprimorar. E a recompensa obtida deve ser no mínimo igual à obtida durante a interação real com o ambiente.
Após a atualização dos parâmetros dos modelos, informamos o usuário sobre o progresso do processo de treinamento e passamos para a próxima iteração do ciclo.
if(GetTickCount() - ticks > 500) { double percent = double(iter) * 100.0 / (Iterations); string str = StringFormat("%-14s %6.2f%% -> Error %15.8f\n", "Actor", percent, Actor.getRecentAverageError()); str += StringFormat("%-14s %6.2f%% -> Error %15.8f\n", "Critic", percent, Critic.getRecentAverageError()); Comment(str); ticks = GetTickCount(); } }
Durante todo o processo, garantimos o controle da execução das operações em cada etapa.
Após a conclusão bem-sucedida de todas as iterações do processo de treinamento dos modelos, limpamos o campo de comentários no gráfico.
Comment(""); //--- PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Actor", Actor.getRecentAverageError()); PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Critic", Critic.getRecentAverageError()); ExpertRemove(); //--- }
Registramos os resultados do treinamento dos modelos no log do terminal e iniciamos o encerramento do Expert Advisor.
O código completo de todos os programas utilizados na preparação deste artigo está disponível no anexo.
3. Teste
Neste artigo, apresentamos o método de previsão multidimensional de séries temporais Client e implementamos nossa visão dos métodos propostos usando MQL5. Agora passamos para a etapa final do nosso trabalho — a verificação dos resultados. Nesta etapa, treinaremos os modelos com dados históricos reais do ativo EURUSD no time frame H1 para o ano de 2023. Em seguida, verificaremos os resultados do modelo treinado no testador de estratégias do MetaTrader 5 com dados históricos de janeiro de 2024, mantendo o mesmo ativo e time frame usados no treinamento dos modelos.
É importante notar que a remoção da camada de Embedding e o aumento do número de barras na descrição de um estado do ambiente nos impedem de usar o conjunto de treinamento da publicação anterior. Portanto, será necessário reunir o conjunto novamente. Porém, como esse processo repete totalmente o algoritmo descrito na publicação anterior, não entraremos em detalhes aqui.
Após coletar o conjunto de treinamento inicial, começamos treinando o modelo de previsão da série temporal. Aqui, encontramos a primeira surpresa desagradável — a qualidade da previsão foi bastante baixa. Muito provavelmente, a abundância de ruído nos dados originais e o foco excessivo da modelo nos detalhes da série temporal jogaram contra nós.
Mas “não desistimos” e continuamos o experimento. Vamos ver se o modelo Ator consegue se adaptar a essas previsões. Realizamos algumas iterações de treinamento do Ator e atualizações do conjunto de treinamento. Mas, infelizmente, não conseguimos treinar o modelo para gerar lucro no conjunto de treinamento, muito menos no de teste. A linha de saldo continuava caindo. E o valor do profit factor oscilava em torno de 0,5.
É bem provável que esse resultado seja típico apenas para a nossa implementação. No entanto, o fato permanece: a modelo implementado não consegue fornecer a qualidade desejada na previsão de séries temporais em um ambiente altamente estocástico.
Considerações finais
Neste artigo, exploramos um algoritmo bastante interessante e complexo, o Client, que combina um modelo linear para capturar tendências lineares e um modelo Transformer para analisar as dependências entre variáveis individuais, permitindo o estudo de informações não lineares. Além disso, os autores do método excluem a atenção entre estados individuais do ambiente separados no tempo. O modelo Transformer aprimorado proposto também simplifica os níveis de embedding e de codificação posicional. Enquanto o módulo de decodificação é substituído por uma camada de projeção, o que, segundo os autores, aumenta significativamente a eficiência da previsão. Além disso, os resultados experimentais apresentados no artigo original mostram que, para tarefas de previsão de séries temporais, a análise das dependências entre variáveis no Transformer tem maior relevância do que a análise das dependências entre estados individuais do ambiente separados pelo tempo.
No entanto, os resultados de nosso trabalho mostram a ineficácia dos métodos propostos em condições de alta estocasticidade nos mercados financeiros.
Vale lembrar que este artigo apresenta os resultados dos testes da nossa implementação dos métodos propostos. Os resultados obtidos podem ser relevantes apenas para essa implementação específica. Em outras condições, é possível que resultados completamente opostos sejam alcançados.
O objetivo deste artigo é apenas familiarizar o leitor com o método Client e demonstrar uma das maneiras de implementar os métodos propostos. De forma alguma, estamos avaliando o algoritmo proposto pelos autores, apenas tentamos aplicar os métodos sugeridos para resolver nossos próprios problemas.
Referências
Programas utilizados no artigo
# | Nome | Tipo | Descrição |
---|---|---|---|
1 | Research.mq5 | Expert Advisor | EA de coleta de exemplos |
2 | ResearchRealORL.mq5 | Expert Advisor | EA de coleta de exemplos pelo método Real-ORL |
3 | Study.mq5 | Expert Advisor | EA de treinamento de Modelos |
4 | StudyEncoder.mq5 | Expert Advisor | EA de treinamento do codificador |
5 | Test.mq5 | Expert Advisor | EA para testar o modelo |
6 | Trajectory.mqh | Biblioteca de classe | Estrutura de descrição do estado do sistema |
7 | NeuroNet.mqh | Biblioteca de classe | Biblioteca de classes para criação de rede neural |
8 | NeuroNet.cl | Biblioteca | Biblioteca de código para programa OpenCL |
Traduzido do russo pela MetaQuotes Ltd.
Artigo original: https://www.mql5.com/ru/articles/14721
![Caminhe em novos trilhos: Personalize indicadores no MQL5](https://c.mql5.com/2/0/862_14.png)
![Redes neurais de maneira fácil (Parte 84): normalização reversível (RevIN)](https://c.mql5.com/2/74/Neural_networks_are_easy_5Part_84q_____LOGO.png)
![Está chegando o novo MetaTrader 5 e MQL5](https://c.mql5.com/2/0/851_25.jpg)
![Algoritmo de otimização baseado em brainstorming — Brain Storm Optimization (Parte I): Clusterização](https://c.mql5.com/2/75/Brain_Storm_Optimization_hPart_I4_____LOGO_2.png)
![MQL5 - Linguagem para estratégias de negociação inseridas no terminal do cliente MetaTrader 5](https://c.mql5.com/i/registerlandings/logo-2.png)
- 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