
Redes neurais em trading: Transformer parâmetro-eficiente com atenção segmentada (PSformer)
Introdução
A previsão de séries temporais multivariadas é uma tarefa importante no aprendizado profundo de modelos, com aplicação prática em diversas áreas, como meteorologia, setor de energia, detecção de anomalias e análise financeira. Com o avanço dos métodos de inteligência artificial, esforços significativos têm sido feitos para criar modelos inovadores que melhoram a qualidade da previsão. Os modelos baseados na arquitetura Transformer, em especial, têm chamado a atenção por sua eficácia no processamento de linguagem natural e na visão computacional. Além disso, modelos grandes pré-treinados baseados nessa arquitetura demonstraram vantagens na previsão de séries temporais, confirmando que o aumento do número de parâmetros e do volume de dados de treinamento pode melhorar significativamente a capacidade do modelo.
Por outro lado, diversos modelos lineares simples também apresentam resultados competitivos em comparação com modelos mais complexos baseados na arquitetura Transformer. Provavelmente, o segredo do sucesso desses modelos na previsão de séries temporais está em sua baixa complexidade, o que reduz a chance de overfitting com dados ruidosos ou irrelevantes. Assim, mesmo com um volume limitado de dados, esses modelos são capazes de capturar representações confiáveis da informação.
Para superar limitações relacionadas à modelagem de dependências de longo prazo e à captura de conexões temporais complexas PatchTST processa os dados usando métodos de comutação para extrair a semântica local, o que garante desempenho excepcional. No entanto, ele utiliza estruturas independentes dos canais e tem potencial significativo para aprimorar ainda mais a eficiência da modelagem. Além disso, as tarefas únicas de modelagem de séries temporais multivariadas, nas quais as dimensões temporais e espaciais diferem substancialmente de outros tipos de dados, oferecem diversas oportunidades ainda não exploradas.
Uma das formas de reduzir a complexidade dos modelos de aprendizado profundo é utilizar a abordagem de compartilhamento de parâmetros (PS), que diminui significativamente a quantidade de parâmetros do modelo e aumenta a eficiência computacional. Em redes convolucionais, os filtros compartilham pesos entre posições espaciais, identificando características locais com um número reduzido de parâmetros. Da mesma forma, os modelos LSTM utilizam matrizes de pesos compartilhadas para diferentes etapas no tempo, controlando a memória e regulando o fluxo de informação. No processamento de linguagem natural, as capacidades de compartilhamento de parâmetros se expandem por meio de pesos comuns entre camadas do Transformer, reduzindo a redundância de parâmetros sem comprometer o desempenho.
No aprendizado multitarefa, é utilizado o método Task Adaptive Parameter Sharing (TAPS) — ajuste fino seletivo de camadas específicas para cada tarefa, maximizando o compartilhamento de parâmetros entre tarefas e alcançando um treinamento eficiente com alterações mínimas adaptadas a cada tarefa. Estudos apontam o potencial do compartilhamento de parâmetros para reduzir o tamanho dos modelos, melhorar a capacidade de generalização e diminuir o risco de overfitting ao resolver tarefas diversas.
Os autores do trabalho "PSformer: Parameter-efficient Transformer with Segment Attention for Time Series Forecasting" investigam desenvolvimentos inovadores de modelo baseados no Transformer para resolver tarefas de previsão de séries temporais multivariadas, considerando a ideia de compartilhamento de parâmetros.
Eles propõem um codificador baseado na arquitetura Transformer com uma estrutura de atenção segmentada em dois níveis, sendo que cada nível do modelo inclui um bloco com parâmetros compartilhados. Esse bloco contém três camadas totalmente conectadas com conexão residual, o que permite manter o número total de parâmetros em um nível baixo, garantindo uma troca eficiente de informações entre as partes do modelo. Para focar a atenção nos segmentos, é aplicado o método de patching, que divide as séries das variáveis em patches separados. Em seguida, os patches que ocupam a mesma posição em variáveis diferentes são agrupados em segmentos. Assim, cada segmento representa uma extensão espacial de um patch de uma variável, o que permite dividir uma série temporal multivariada em vários segmentos.
A atenção dedicada a cada segmento visa aprimorar a identificação de conexões espaço-temporais locais, enquanto a integração das informações entre segmentos contribui para aumentar a precisão geral das previsões. A aplicação do método de otimização SAM permite que os autores do framework reduzam ainda mais o overfitting sem comprometer a eficácia do treinamento. Extensos experimentos realizados pelos criadores do PSformer com dados para previsão de séries temporais de longo prazo demonstram a alta eficácia da arquitetura proposta. O PSformer apresenta resultados competitivos em comparação com modelos de ponta, alcançando o melhor desempenho em 6 de 8 tarefas-chave de previsão de séries temporais.
Algoritmo PSformer
A série temporal multivariada X ∈ RM×L contém M variáveis e a janela de análise retrospectiva é igual a L. O comprimento da sequência L é uniformemente dividido em N patches não sobrepostos de tamanho P. Então, P(i) das M variáveis formam o i-ésimo segmento, representando uma seção transversal de comprimento C (C=M×P).
Os componentes-chave do framework proposto PSformer são a atenção aos segmentos (SegAtt) e o bloco de compartilhamento de parâmetros (PS). O codificador do PSformer é a base do modelo e contém tanto o módulo de atenção aos segmentos quanto o bloco PS. O bloco PS fornece os parâmetros para todas as camadas do codificador, utilizando a técnica de compartilhamento de parâmetros.
De forma semelhante a outras arquiteturas de previsão de séries temporais, os autores do PSformer utilizam o método RevIN, que elimina com eficácia o problema de deslocamento na distribuição.
A atenção espaço-temporal aos segmentos (SegAtt) agrupa patches de diferentes canais na mesma posição em um segmento e estabelece relações espaço-temporais entre os diversos segmentos. Em particular, as séries temporais originais X ∈ RM×L são primeiramente divididas em patches, onde L=P×N, e depois transformadas em X ∈ R(M×P)×N, por meio da fusão das dimensões M e P. Dessa forma, os dados originais assumem a forma X ∈ RC×N (C=M×P), facilitando a subsequente fusão de informações entre canais.
Neste espaço transformado, os dados originais são processados por dois módulos sequenciais, com a mesma arquitetura e separados por ReLU. Cada módulo contém um bloco de compartilhamento de parâmetros e o já conhecido mecanismo de Self-Attention. Enquanto o cálculo das matrizes 𝑸uery ∈ RC×N, 𝑲ey ∈ RC×N e 𝑽alue ∈ RC×N envolve transformações não lineares dos dados originais Xin por segmento na dimensão N, a atenção escalável sob forma de produto escalar distribui, em primeiro lugar, o foco da atenção sobre toda a dimensão C, permitindo ao modelo capturar dependências entre segmentos espaço-temporais ao longo dos canais e do tempo.
Esse mecanismo garante a integração de informações entre diferentes segmentos por meio do cálculo de Q, K e V. Ele também identifica dependências espaço-temporais locais dentro de cada segmento, dando atenção à estrutura interna de cada um. Além disso, capta dependências de longo prazo entre os segmentos ao longo de passos temporais mais extensos. O resultado final é Xout ∈ RC×N, encerrando o processo de atenção.
No PSformer é proposto o uso de um novo bloco denominado Bloco com Compartilhamento de Parâmetros (PS Block), que consiste em 3 camadas totalmente conectadas com conexão residual. Em particular, são utilizadas 3 transformações lineares treináveis Wj ∈ RN×N com j ∈ {1, 2, 3}. Os resultados das duas primeiras camadas são calculados da seguinte forma:
Aqui pode-se notar uma estrutura semelhante ao bloco FeedForward com conexões residuais. Esse resultado intermediário 𝑿out é então utilizado como dado de entrada para a terceira transformação:
Assim, o bloco PS como um todo pode ser expresso da seguinte forma:
A estrutura do bloco PS permite realizar transformações não lineares mantendo a trajetória da transformação linear. Embora as 3 camadas do bloco PS tenham parâmetros distintos, todo o bloco PS é reutilizado em diferentes posições no codificador do PSformer, garantindo que os mesmos parâmetros do bloco 𝑾S sejam compartilhados entre todas essas posições. Em especial, o bloco PS utiliza parâmetros comuns em 3 partes de cada codificador do PSformer, incluindo os 2 módulos de atenção espaço-temporal aos segmentos e o último bloco PS. Essa estratégia de compartilhamento de parâmetros reduz a quantidade total de parâmetros, mantendo a expressividade do modelo.
O mecanismo SegAtt de dois estágios pode ser comparado ao bloco FeedForward no Transformer puro, onde o MLP é substituído por operações de atenção. Além disso, entre os dados originais e o resultado são adicionadas conexões residuais, e o resultado é então encaminhado para o bloco PS final.
Em seguida, é aplicado um redimensionamento para obter 𝑿out ∈ RM×L, onde C=M×P e L=P×N.
Após a passagem por n camadas do codificador PSformer, é realizada a transformação final dos dados para o horizonte de previsão F.
onde 𝑿pred ∈ RM×F e 𝑾F ∈ RL×F são transformações lineares.
A visualização do framework PSformer pelos autores é apresentada abaixo.
Implementação com MQL5
Após analisarmos os aspectos teóricos do framework PSformer, passamos à implementação prática da nossa interpretação das abordagens propostas usando os recursos do MQL5. E, para nós, o que mais desperta interesse é o algoritmo de implementação do bloco de compartilhamento de parâmetros (PS).
Parameter Shared Block
Como mencionado anteriormente, o bloco de compartilhamento de parâmetros na implementação dos autores consiste em três camadas totalmente conectadas, cujos parâmetros são aplicados a todos os segmentos analisados. Isso não representa nenhuma dificuldade para nós. Já utilizamos, em várias ocasiões semelhantes, camadas convolucionais com janelas de análise de dados não sobrepostas. A complexidade está em outro ponto: no mecanismo de compartilhamento de parâmetros em vários blocos.
Por um lado, é claro que podemos reutilizar o mesmo bloco várias vezes dentro de uma única camada. No entanto, nesse caso, enfrentamos o problema de preservar os dados necessários para realizar as operações de propagação reversa. Isso ocorre porque, ao reutilizar o objeto para executar operações de propagação para frente, os novos dados são armazenados no buffer de resultados, substituindo os resultados do chamado anterior do método de propagação para frente. Em um algoritmo convencional de uso de camada neural, isso não representa problema algum, já que alternamos constantemente entre propagação para frente e propagação reversa. Após executar as operações da próxima propagação reversa, podemos substituir tranquilamente os resultados da propagação anterior, pois eles já não são mais necessários. No entanto, se essa alternância entre as passagens direta e reversa for interrompida, surge a necessidade de preservar todos os dados essenciais para a correta execução do próximo ciclo de propagação reversa.
Vale acrescentar que, numa implementação como essa, precisamos armazenar não apenas os resultados na saída do bloco, mas também todos os valores intermediários. Caso contrário, seria necessário recalcular os valores, o que aumentaria a complexidade computacional do modelo. Além disso, é necessário um mecanismo de sincronização dos buffers em regiões específicas para garantir o cálculo correto do gradiente de erro.
Fica evidente que a implementação desses requisitos exige alterações nas interfaces de troca de dados entre as camadas neurais. E isso resultaria em mudanças mais amplas no funcionamento da nossa biblioteca.
A segunda alternativa é encontrar um mecanismo que permita o compartilhamento completo de um único buffer de parâmetros entre várias camadas neurais idênticas. E é preciso dizer que essa alternativa também não está livre de “armadilhas”.
Vale lembrar que, ao analisar o framework Deep Deterministic Policy Gradient, nós já implementamos o algoritmo de atualização suave dos parâmetros do modelo-alvo. No entanto, copiar os parâmetros após cada atualização é bastante custoso. Por isso, o ideal seria substituir os buffers pelas matrizes de parâmetros nos objetos que fazem uso compartilhado deles.
Aqui, além da própria matriz de parâmetros, precisamos organizar o compartilhamento dos buffers de momentos utilizados durante a atualização dos parâmetros. Afinal, o uso de buffers de momentos separados em diferentes estágios pode desviar o vetor de atualização dos parâmetros em direção a uma das camadas internas.
Há ainda outro ponto que precisamos mencionar. Em uma implementação como essa, deparamo-nos com a situação em que os parâmetros da camada durante as operações de propagação reversa são diferentes dos utilizados na propagação para frente. Embora isso possa parecer estranho, vamos analisar um exemplo simples com duas camadas sequenciais que compartilham os mesmos parâmetros. Durante a propagação para frente, ambas as camadas utilizam os parâmetros W e geram, na saída, os resultados O1 e O2, respectivamente. Na distribuição dos gradientes de erro no nível dos resultados de cada camada, obtemos as variações G1 e G2, respectivamente. Quanto ao processo de distribuição dos gradientes de erro, não há problemas. Nesse estágio, os parâmetros do modelo permanecem inalterados e todos os gradientes de erro estão em conformidade com os parâmetros W usados na propagação. No entanto, assim que ajustamos os parâmetros de uma das camadas, por exemplo, a segunda, passamos a ter os parâmetros corrigidos W'. Aqui nos deparamos com o problema de incompatibilidade entre os gradientes de erro e os novos parâmetros. É evidente que o uso direto de um gradiente de erro que não corresponde aos parâmetros atuais pode distorcer o processo de aprendizado.
Uma das soluções para esse problema é determinar os valores-alvo para uma camada específica com base nos resultados da última propagação e no gradiente de erro, e então realizar uma nova propagação para a frente com os parâmetros atuais e calcular o gradiente de erro correto. Essa estratégia não lembra o algoritmo de otimização SAM, com o qual já nos deparamos em artigos anteriores? Adicionando uma etapa de ajuste dos parâmetros antes da nova propagação ao algoritmo descrito acima, obtemos exatamente o algoritmo completo de otimização SAM.
É justamente a otimização SAM que os autores do framework PSformer propõem utilizar. Isso nos permite aceitar os riscos de trabalhar com gradientes de erro inconsistentes, já que eles são recalculados antes da atualização dos parâmetros. Contudo, em outras situações, isso poderia representar um problema sério.
Diante de tudo o que foi discutido, foi tomada a decisão de adotar a segunda opção de implementação do compartilhamento de parâmetros.
Como já foi mencionado, o bloco PS utiliza 3 camadas totalmente conectadas, que substituiremos por camadas convolucionais. Por isso, iniciaremos a implementação do algoritmo de compartilhamento de parâmetros pelo objeto da camada convolucional CNeuronConvSAMOCL.
Aqui, vale destacar que, na camada convolucional com compartilhamento de parâmetros, substituímos apenas os ponteiros para os buffers de parâmetros e de momentos. Ao mesmo tempo, os demais buffers e os valores das variáveis internas devem ser compatíveis com a dimensionalidade da matriz de parâmetros. É evidente que, neste caso, será necessário alterar o método de inicialização do objeto. Antes disso, porém, criaremos dois métodos auxiliares: InitBufferLike e ReplaceBuffer.
O primeiro método cria um novo buffer preenchido com zeros, seguindo o modelo fornecido. Seu algoritmo é bastante simples. Nos parâmetros, recebemos dois ponteiros para objetos de buffers de dados. Primeiramente, verificamos a validade do ponteiro para o buffer modelo (master). A presença de um ponteiro válido para esse buffer é crítico para a execução das operações seguintes. Por isso, se a verificação falhar, encerramos a execução do método com o resultado false.
bool CNeuronConvSAMOCL::InitBufferLike(CBufferFloat *&buffer, CBufferFloat *master) { if(!master) return false;
Após passar com sucesso pelo primeiro ponto de controle, verificamos a validade do ponteiro para o buffer que está sendo criado. Neste caso, se a verificação falhar, simplesmente criamos uma nova instância do objeto.
if(!buffer) { buffer = new CBufferFloat(); if(!buffer) return false; }
E não nos esquecemos de verificar se o novo buffer foi criado corretamente.
Em seguida, inicializamos o buffer com o tamanho necessário e preenchido com valores zero.
if(!buffer.BufferInit(master.Total(), 0)) return false;
E criamos sua cópia no contexto OpenCL.
if(!buffer.BufferCreate(master.GetOpenCL())) return false; //--- return true; }
Depois disso, encerramos o método, retornando o resultado lógico da execução das operações para o programa chamador.
O segundo método, ReplaceBuffer, substitui o ponteiro por um buffer especificado. À primeira vista, pode parecer desnecessário criar um método inteiro apenas para atribuir um ponteiro de objeto a uma variável interna. No entanto, dentro do corpo do método, fazemos verificações e, se necessário, excluímos buffers de dados redundantes. Isso nos permite utilizar a memória de forma mais eficiente, tanto a RAM quanto a do contexto OpenCL.
void CNeuronConvSAMOCL::ReplaceBuffer(CBufferFloat *&buffer, CBufferFloat *master) { if(buffer==master) return; if(!!buffer) { buffer.BufferFree(); delete buffer; } //--- buffer = master; }
Após criarmos os métodos auxiliares, partimos para a construção do novo algoritmo de inicialização do objeto da camada convolucional baseado em um modelo, com cópia dos ponteiros para os buffers de parâmetros do objeto modelo InitPS. Nos parâmetros do método, em vez de uma série de constantes que definem a arquitetura do objeto, recebemos apenas um ponteiro para o objeto modelo, segundo o qual será criado o novo objeto.
bool CNeuronConvSAMOCL::InitPS(CNeuronConvSAMOCL *master) { if(!master || master.Type() != Type() ) return false;
No corpo do método, verificamos a validade do ponteiro recebido e a compatibilidade dos tipos de objetos.
Depois disso, optamos por não construir uma série de métodos do classe-mãe, e simplesmente transferimos os valores de todos os parâmetros herdados a partir do objeto modelo.
alpha = master.alpha; iBatch = master.iBatch; t = master.t; m_myIndex = master.m_myIndex; activation = master.activation; optimization = master.optimization; iWindow = master.iWindow; iStep = master.iStep; iWindowOut = master.iWindowOut; iVariables = master.iVariables; bTrain = master.bTrain; fRho = master.fRho;
Em seguida, criamos os buffers de resultados e os de gradientes de erro com base em buffers semelhantes do objeto modelo.
if(!InitBufferLike(Output, master.Output)) return false; if(!!master.getPrevOutput()) if(!InitBufferLike(PrevOutput, master.getPrevOutput())) return false; if(!InitBufferLike(Gradient, master.Gradient)) return false;
Logo após, transferimos os ponteiros, primeiro para os buffers dos parâmetros de pesos e seus momentos, herdados da camada totalmente conectada base.
ReplaceBuffer(Weights, master.Weights); ReplaceBuffer(DeltaWeights, master.DeltaWeights); ReplaceBuffer(FirstMomentum, master.FirstMomentum); ReplaceBuffer(SecondMomentum, master.SecondMomentum);
Repetimos a mesma operação para os buffers dos parâmetros da camada convolucional e seus momentos.
ReplaceBuffer(WeightsConv, master.WeightsConv); ReplaceBuffer(DeltaWeightsConv, master.DeltaWeightsConv); ReplaceBuffer(FirstMomentumConv, master.FirstMomentumConv); ReplaceBuffer(SecondMomentumConv, master.SecondMomentumConv);
Em seguida, basta criarmos os buffers dos parâmetros ajustados. No entanto, é importante lembrar que a criação de ambos os buffers de parâmetros ajustados pode não ocorrer em determinadas condições. O buffer de parâmetros ajustados da camada totalmente conectada é criado apenas quando há conexões de saída. Por isso, verificamos o tamanho desse buffer no objeto-modelo. Criamos um buffer equivalente apenas se for necessário.
if(master.cWeightsSAM.Total() > 0) { CBufferFloat *buf = GetPointer(cWeightsSAM); if(!InitBufferLike(buf, GetPointer(master.cWeightsSAM))) return false; }
Caso contrário, limpamos esse buffer, reduzindo o consumo de memória.
else
{
cWeightsSAM.BufferFree();
cWeightsSAM.Clear();
}
O buffer de parâmetros ajustados das conexões de entrada é criado quando o coeficiente da área de desfoque é maior que "0".
if(fRho > 0) { CBufferFloat *buf = GetPointer(cWeightsSAMConv); if(!InitBufferLike(buf, GetPointer(master.cWeightsSAMConv))) return false; }
Do contrário, simplesmente limpamos esse buffer.
else
{
cWeightsSAMConv.BufferFree();
cWeightsSAMConv.Clear();
}
Claro que, tecnicamente, em vez de verificar o coeficiente de desfoque, poderíamos verificar o tamanho do buffer de parâmetros ajustados das conexões de entrada do objeto-modelo. Como foi feito com o buffer das conexões de saída. Mas sabemos que, se o coeficiente de desfoque for maior que "0", esse buffer precisa existir. E, dessa forma, adicionamos uma verificação extra. Afinal, se tentarmos criar um buffer com comprimento zero, ocorrerá um erro e o processo de inicialização será interrompido. Isso ajuda a prevenir erros mais graves no futuro.
No final do método de inicialização, transferimos todos os objetos para um único contexto OpenCL e encerramos o método, retornando à aplicação chamadora o resultado lógico das operações realizadas.
SetOpenCL(master.OpenCL); //--- return true; }
Após realizar as modificações no objeto da camada convolucional, avançamos para a próxima etapa do trabalho. Neste ponto, criaremos diretamente o bloco de compartilhamento de parâmetros (PS). Para isso, criaremos um novo objeto chamado CNeuronPSBlock. Conforme mencionado na parte teórica, o bloco de compartilhamento de parâmetros é composto por três camadas sequenciais de transformação linear de dados. Cada uma dessas camadas possui uma matriz de parâmetros quadrada, o que garante a preservação das dimensões dos tensores tanto na entrada quanto na saída do bloco como um todo, bem como nas camadas internas. Entre as duas primeiras camadas, introduzimos uma não linearidade usando a função de ativação GELU. Após a segunda camada, adicionamos uma conexão residual com os dados originais.
Para implementar o algoritmo proposto, criaremos duas camadas convolucionais internas dentro do novo objeto e utilizaremos a estrutura da nossa classe como terceira camada convolucional, cujo funcional básico será herdado da camada convolucional. Como utilizaremos a otimização SAM durante o treinamento, também utilizaremos camadas convolucionais compatíveis com essa técnica ao construir a arquitetura do objeto. A estrutura da nova classe está apresentada abaixo.
class CNeuronPSBlock : public CNeuronConvSAMOCL { protected: CNeuronConvSAMOCL acConvolution[2]; CNeuronBaseOCL cResidual; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL); virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL); virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL); public: CNeuronPSBlock(void) {}; ~CNeuronPSBlock(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_out, uint units_count, uint variables, float rho, ENUM_OPTIMIZATION optimization_type, uint batch); virtual bool InitPS(CNeuronPSBlock *master); //--- virtual int Type(void) const { return defNeuronPSBlock; } //--- methods for working with files virtual bool Save(int const file_handle); virtual bool Load(int const file_handle); //--- virtual CLayerDescription* GetLayerInfo(void); virtual void SetOpenCL(COpenCLMy *obj); };
Como se pode observar, na estrutura apresentada do novo objeto são declarados dois métodos de inicialização. E isso não é por acaso. O primeiro método de inicialização Init é o mecanismo básico de inicialização do objeto, cuja arquitetura é definida de forma clara pelos parâmetros do método. O segundo método InitPS, de forma semelhante ao método homônimo da camada convolucional, cria um novo objeto com base no modelo recebido nos parâmetros. E, durante a inicialização do novo objeto, são copiados os ponteiros para os buffers de parâmetros e seus momentos. Vamos analisar em mais detalhes o algoritmo de construção desses métodos.
Como já foi mencionado, nos parâmetros do método Init recebemos uma série de constantes que nos permitem definir de forma precisa a arquitetura do objeto a ser criado.
bool CNeuronPSBlock::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_out, uint units_count, uint variables, float rho, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronConvSAMOCL::Init(numOutputs, myIndex, open_cl, window, window, window_out, units_count, variables, rho, optimization_type, batch)) return false;
E, no corpo do método, repassamos imediatamente todos os parâmetros recebidos para o método homônimo da classe-mãe. Como se sabe, o método da classe-mãe já possui os pontos de controle necessários para os parâmetros recebidos, bem como os algoritmos de inicialização dos objetos herdados.
Como também já foi dito, todas as camadas convolucionais dentro do bloco de compartilhamento de parâmetros possuem as mesmas dimensões. Portanto, ao inicializar a primeira camada convolucional interna, usamos os mesmos parâmetros.
if(!acConvolution[0].Init(0, 0, OpenCL, iWindow, iWindow, iWindowOut, units_count, iVariables, fRho, optimization, iBatch)) return false; acConvolution[0].SetActivationFunction(GELU);
E adicionamos a função de ativação sugerida pelos autores do framework.
No entanto, deixamos ao usuário a possibilidade de alterar as dimensões do tensor de saída do bloco. Por isso, ao inicializar a segunda camada convolucional interna — que pressupõe a adição de conexões residuais —, com o objetivo de fazer a saída ter as mesmas dimensões dos dados originais, invertemos os parâmetros da janela de análise dos dados e da quantidade de filtros.
if(!acConvolution[1].Init(0, 1, OpenCL, iWindowOut, iWindowOut, iWindow, units_count, iVariables, fRho, optimization, iBatch)) return false; acConvolution[1].SetActivationFunction(None);
Aqui, não utilizamos função de ativação.
Em seguida, adicionamos uma camada neural base para armazenar os dados das conexões residuais. Seu tamanho corresponde ao buffer de resultados da segunda camada convolucional interna.
if(!cResidual.Init(0, 2, OpenCL, acConvolution[1].Neurons(), optimization, iBatch)) return false; if(!cResidual.SetGradient(acConvolution[1].getGradient(), true)) return false; cResidual.SetActivationFunction(None);
E já realizamos a substituição direta do buffer de gradientes de erro, o que nos permite reduzir as operações de cópia de dados durante a propagação reversa.
Agora só nos resta desabilitar explicitamente a função de ativação do nosso bloco de compartilhamento de parâmetros e encerrar a execução do método, retornando à aplicação chamadora o resultado lógico da operação.
SetActivationFunction(None); //--- return true; }
O algoritmo do segundo método de inicialização é um pouco mais simples. Nos parâmetros do método, recebemos um ponteiro para o objeto-modelo e o repassamos diretamente ao método homônimo da classe-mãe.
Aqui, vale prestar atenção à diferença entre os tipos de parâmetros recebidos neste método e os esperados pela classe-mãe. Por isso, indicamos explicitamente o tipo do objeto que está sendo passado.
bool CNeuronPSBlock::InitPS(CNeuronPSBlock *master) { if(!CNeuronConvSAMOCL::InitPS((CNeuronConvSAMOCL*)master)) return false;
No corpo da classe-mãe já estão organizados os pontos de controle necessários. A bem como a cópia das constantes, criação de novos buffers e salvamento dos ponteiros para os buffers de parâmetros e seus momentos.
Em seguida, organizamos um laço no qual chamamos os métodos homônimos para as camadas convolucionais internas, copiando os dados a partir dos respectivos objetos-modelo.
for(int i = 0; i < 2; i++) if(!acConvolution[i].InitPS(master.acConvolution[i].AsObject())) return false;
A camada responsável por armazenar os resultados das conexões residuais não contém parâmetros treináveis, e seu tamanho é igual ao buffer de resultados da segunda camada convolucional interna. Por isso, o algoritmo de inicialização desse objeto foi completamente reaproveitado do método de inicialização principal.
if(!cResidual.Init(0, 2, OpenCL, acConvolution[1].Neurons(), optimization, iBatch)) return false; if(!cResidual.SetGradient(acConvolution[1].getGradient(), true)) return false; cResidual.SetActivationFunction(None); //--- return true; }
E não devemos esquecer de substituir os ponteiros para o buffer de gradientes de erro.
Depois de analisarmos os métodos de inicialização do objeto — que, neste caso, são dois — passamos à construção dos algoritmos de propagação para frente. Aqui, tudo é bastante direto. Nos parâmetros do método, recebemos um ponteiro para o objeto com os dados de entrada, que imediatamente repassamos ao método homônimo da primeira camada convolucional interna.
bool CNeuronPSBlock::feedForward(CNeuronBaseOCL *NeuronOCL) { if(!acConvolution[0].FeedForward(NeuronOCL)) return false;
Encadeamos os resultados para a próxima camada convolucional. Em seguida, somamos os valores obtidos com os dados de entrada. A soma é armazenada no buffer da camada de conexões residuais.
if(!acConvolution[1].FeedForward(acConvolution[0].AsObject())) return false; if(!SumAndNormilize(NeuronOCL.getOutput(), acConvolution[1].getOutput(), cResidual.getOutput(), iWindow, true, 0, 0, 0, 1)) return false;
Aqui, é importante destacar que, ao contrário do algoritmo original dos autores, o tensor das conexões residuais é normalizado. E só então o repassamos para a última camada convolucional, cujo funcionamento é executado por meio do funcional herdado da classe-mãe.
if(!CNeuronConvSAMOCL::feedForward(cResidual.AsObject())) return false; //--- return true; }
Concluímos o método retornando à aplicação chamadora o resultado lógico da execução das operações.
O método de distribuição dos gradientes de erro, calcInputGradients, também é simples, mas não isento de particularidades. Nos parâmetros do método, recebemos um ponteiro para o objeto da camada neural com os dados de entrada, ao qual devemos repassar o gradiente de erro.
bool CNeuronPSBlock::calcInputGradients(CNeuronBaseOCL *NeuronOCL) { if(!NeuronOCL) return false;
No corpo do método, verificamos imediatamente a validade do ponteiro recebido, pois, caso contrário, as operações seguintes não fariam sentido.
A seguir, propagamos os gradientes por todas as camadas convolucionais em ordem reversa.
if(!CNeuronConvSAMOCL::calcInputGradients(cResidual.AsObject())) return false; if(!acConvolution[0].calcHiddenGradients(acConvolution[1].AsObject())) return false; if(!NeuronOCL.calcHiddenGradients(acConvolution[0].AsObject())) return false;
Note que no código apresentado não aparece a transferência explícita do gradiente de erro do objeto de conexões residuais para a segunda camada convolucional interna. No entanto, graças à substituição dos ponteiros dos buffers de dados que organizamos anteriormente, a transferência da informação ocorre por completo.
Depois de transmitir o gradiente de erro ao nível dos dados de entrada através da cadeia das camadas convolucionais, precisamos adicionar o gradiente de erro referente à cadeia das conexões residuais. E aqui, existem dois caminhos possíveis, dependendo da função de ativação do objeto com os dados de entrada.
Gostaria de lembrar que no objeto das conexões residuais passamos o gradiente de erro sem ajustá-lo pela derivada da função de ativação. Indicamos explicitamente a ausência dessa função para esse objeto.
Portanto, se o objeto dos dados de entrada também não possui função de ativação, basta somar os valores correspondentes dos dois buffers.
if(NeuronOCL.Activation() == None) { if(!SumAndNormilize(NeuronOCL.getGradient(), cResidual.getGradient(), NeuronOCL.getGradient(), iWindow, false, 0, 0, 0, 1)) return false; }
Caso contrário, primeiro ajustamos o gradiente de erro recebido aplicando a derivada da função de ativação dos dados de entrada em um buffer livre. Em seguida, somamos os resultados obtidos com os valores previamente acumulados no buffer do objeto dos dados de entrada.
else { if(!DeActivation(NeuronOCL.getOutput(), cResidual.getGradient(), cResidual.getPrevOutput(), NeuronOCL.Activation()) || !SumAndNormilize(NeuronOCL.getGradient(), cResidual.getPrevOutput(), NeuronOCL.getGradient(), iWindow, false, 0, 0, 0, 1)) return false; } //--- return true; }
Após isso, encerramos a execução do método.
É importante mencionar também o método de atualização dos parâmetros do bloco, updateInputWeights. Aqui, não há dificuldade na construção do algoritmo — basta chamar os métodos homônimos da classe-mãe e dos objetos internos que contêm parâmetros treináveis.
bool CNeuronPSBlock::updateInputWeights(CNeuronBaseOCL *NeuronOCL) { if(!CNeuronConvSAMOCL::updateInputWeights(cResidual.AsObject())) return false; if(!acConvolution[1].UpdateInputWeights(acConvolution[0].AsObject())) return false; if(!acConvolution[0].UpdateInputWeights(NeuronOCL)) return false; //--- return true; }
No entanto, o uso da abordagem de otimização SAM impõe restrições rigorosas à ordem de execução das operações. O motivo é que, durante a execução da otimização SAM, realizamos uma nova propagação para frente com os parâmetros ajustados. Como resultado, modificamos os dados no buffer de resultados. Isso não compromete a otimização dos parâmetros da camada atual, mas afeta a atualização dos parâmetros da camada seguinte. Afinal, ela utiliza os resultados da propagação do nível anterior para ajustar seus próprios parâmetros. Por isso, neste caso, é essencial realizar os ajustes dos parâmetros na ordem inversa dos objetos internos. Isso permite corrigir os parâmetros de uma camada antes que os valores no buffer de resultados do objeto anterior sejam modificados.
Com isso, concluímos a análise dos algoritmos do bloco de compartilhamento de parâmetros CNeuronPSBlock. O código completo dessa classe e de todos os seus métodos pode ser consultado separadamente no anexo.
Nosso trabalho ainda não terminou, mas o espaço deste artigo está praticamente esgotado. Por isso, faremos uma breve pausa e continuaremos o que foi iniciado no próximo artigo.
Considerações finais
Neste artigo, conhecemos o framework PSformer, cujos autores destacam a alta precisão na previsão de séries temporais e a eficiência no uso de recursos computacionais. As principais características arquitetônicas do PSformer são o bloco de compartilhamento de parâmetros (PS) e a atenção aos segmentos espaço-temporais (SegAtt). O uso dessas abordagens permite modelar de forma eficiente dependências locais e globais das séries temporais, além de reduzir a quantidade de parâmetros sem perder qualidade nas previsões.
Na parte prática do artigo, iniciamos a implementação da nossa própria visão das abordagens propostas usando os recursos do MQL5. No entanto, nosso trabalho ainda não está concluído. E, no próximo artigo, daremos continuidade ao que foi iniciado, além de avaliar a eficácia das abordagens propostas na resolução de nossas tarefas com dados históricos reais.
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 teste do modelo |
6 | Trajectory.mqh | Biblioteca de classe | Estrutura para 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 em OpenCL |
Traduzido do russo pela MetaQuotes Ltd.
Artigo original: https://www.mql5.com/ru/articles/16439





- 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