Redes neurais em trading: Desvendando os componentes estruturais (Encoder)
Introdução
Continuamos trabalhando na implementação da nossa própria visão das abordagens apresentadas pelos autores do framework SCNN. Vale lembrar que a SCNN (Structured Component Neural Network) propõe uma abordagem conceitualmente diferente: em vez de tentar abranger toda a série temporal com um único mecanismo universal, ela a divide em cinco componentes principais: de longo prazo, sazonal, de curto prazo, coevolutivo e residual. Cada um desses componentes é modelado e extrapolado separadamente, o que nos oferece não apenas flexibilidade, mas também a possibilidade de obter um resultado interpretável em cada etapa.
A principal vantagem dessa arquitetura é a transparência. Diferentemente das caixas pretas tradicionais, a SCNN permite que analistas e traders vejam exatamente qual parte do modelo responde por determinado trecho da previsão e com que grau de confiança o modelo opera nas condições atuais. Isso é especialmente importante ao trabalhar com dados financeiros, em que a confiança no modelo precisa ser sustentada por explicabilidade e capacidade de controle. Além disso, a abordagem individual para extrapolar cada componente permite usar tanto heurísticas estatísticas (para padrões de longo prazo e sazonais) quanto módulos de redes neurais treináveis (para modelar anomalias de curto prazo ou interdependências entre ativos).
O framework também considera dependências temporais complexas, incluindo relações de autocorrelação que muitas vezes são ignoradas em modelos simplificados. Isso é especialmente relevante para tarefas ligadas ao trading intraday ou à previsão nas transições entre sessões de negociação. A SCNN permite ajustar-se dinamicamente ao deslocamento das estatísticas em tempo real, além de identificar pontos anômalos nos quais a previsão pode ser pouco confiável.
A visualização autoral do framework SCNN é apresentada abaixo.

Na parte prática do artigo anterior, desenvolvemos o objeto CNeuronPeriodNorm, destinado a isolar componentes periódicos da série temporal. Esse componente tornou-se o primeiro passo para implementar o framework SCNN no ambiente MQL5 e será usado para extrair componentes de longo prazo e de curto prazo. O uso de kernels OpenCL garante o processamento paralelo eficiente dos dados e o suporte ao mecanismo de retropropagação do erro, o que torna esse módulo adequado para uso dentro de arquiteturas neurais treináveis.
Mais adiante, mostraremos que, por meio de algumas transformações simples dos dados, o CNeuronPeriodNorm também pode ser adaptado para extrair o componente sazonal, o que lhe confere versatilidade adicional. Hoje daremos o próximo passo: iniciaremos a construção do objeto responsável por extrair o componente conjugado, que reflete mudanças inter-relacionadas entre várias variáveis da série temporal. Esse módulo terá um papel essencial na modelagem de oscilações sincronizadas e movimentos conjuntos anômalos, algo especialmente relevante em condições de análise de mercado multivariada.
Extração do componente conjugado
A experiência prática que obtivemos mostra que, para implementar corretamente o modelo, só a análise temporal não é suficiente. Também é importante considerar as dependências espaciais, isto é, as relações entre diferentes variáveis em cada instante específico. É exatamente disso que trata o mecanismo de extração do componente conjugado, que ajuda a captar movimentos na mesma direção ou em direções opostas entre os sinais das sequências unitárias individuais que compõem a série temporal multimodal analisada. Essa coevolução pode ser tanto estável quanto dinamicamente variável e, por isso, exige uma abordagem adaptativa para a normalização.
No âmbito do framework SCNN, essa tarefa é resolvida pela incorporação da normalização espacialmente ponderada com o uso de um mecanismo de atenção. Passamos para a próxima etapa da implementação, na qual apresentaremos nossa própria visão do algoritmo proposto com base nas necessidades práticas da modelagem financeira. Como de costume, delegamos ao contexto OpenCL a parte principal dos cálculos, incluindo a média ponderada e a padronização adaptativa. Isso permite não apenas acelerar o processamento, mas também preservar a flexibilidade na configuração da arquitetura.
Para isso, criaremos um novo kernel AdaptSpatialNorm. O algoritmo do kernel implementa a normalização adaptativa dos dados de entrada levando em conta a atenção entre as variáveis, proporcionando um processamento mais preciso e sensível das inter-relações espaciais. A ideia consiste em calcular, para cada ponto temporal, o valor médio e o desvio padrão ponderados pela máscara de atenção. Isso permite não apenas calcular a média entre todas as variáveis, mas também considerar sua importância relativa, isto é, a influência de cada variável sobre um ponto específico no contexto espaço-temporal.
__kernel void AdaptSpatialNorm(__global const float* inputs, __global const float* attention, __global float2* mean_stdevs, __global float* outputs ) { const size_t i = get_global_id(0); const size_t a = get_local_id(1); const size_t v = get_global_id(2); const size_t total_inputs = get_global_size(0); const size_t total_local = get_local_size(1); const size_t variables = get_global_size(2);
No kernel computacional, os cálculos são distribuídos em três dimensões: por tempo, por variáveis e por processos locais. Cada thread é responsável pelo processamento de um valor específico, ou seja, uma variável em determinado momento do tempo. Primeiro, determina-se o deslocamento dos índices, o que permite que cada thread acesse corretamente as partes necessárias da memória.
__local float Temp[LOCAL_ARRAY_SIZE]; const int shift_v = v * total_inputs; const int shift_out = shift_v + i;
Em seguida, começa a parte principal da execução, na qual, para todas as variáveis, são extraídos os valores do array de entrada e os respectivos coeficientes de atenção. Cada valor é multiplicado por seu peso e, depois disso, ocorre a soma local, primeiro para calcular a média e, em seguida, para determinar a variância.
float mean = 0, stdev = 0; for(uint l = 0; l < variables; l += total_local) { const int shift_at = v * variables + (a + l); float val = IsNaNOrInf(inputs[(a + l) * total_inputs + i], 0); float att = IsNaNOrInf(attention[shift_at], 0); mean += LocalSum(val * att, 1, Temp); BarrierLoc; stdev += LocalSum(val * val * att, 1, Temp); BarrierLoc; }
Para garantir a consistência dos cálculos paralelos entre os processos, é usada uma barreira de sincronização, o que permite evitar conflitos de acesso ao trabalhar com a memória compartilhada.
Aqui vale prestar atenção especial ao aspecto técnico da distribuição dos cálculos. Para executar corretamente as operações de soma dos valores vindos de diferentes processos, no OpenCL, são criados os chamados grupos de trabalho. Os processos dentro desses grupos podem trocar dados por meio da memória local, que, diferentemente da global, opera de forma significativamente mais rápida e permite implementar com eficiência operações coletivas, como redução ou soma.
No entanto, essa arquitetura tem uma limitação natural: o tamanho do grupo de trabalho não pode exceder determinado limite de hardware definido pela placa de vídeo ou por outro dispositivo de computação. Na prática, isso significa que o número de processos que processam variáveis simultaneamente em um mesmo grupo é limitado e nem sempre corresponde à dimensionalidade do array de entrada processado.
Para contornar essa limitação, no corpo do kernel foi implementado um laço especial que permite percorrer todas as variáveis em etapas, mesmo quando seu número supera significativamente o tamanho do grupo de trabalho. O espaço total de atributos é dividido em blocos, processado por partes, e os valores são agregados sequencialmente. Essa abordagem preserva a eficiência dos cálculos e garante a execução correta de todas as operações matemáticas, independentemente do número de variáveis envolvidas no modelo.
Esse recurso técnico torna a implementação estável em cenários de escalabilidade e adequada para execução em diferentes dispositivos com características distintas, além de tornar o próprio modelo mais flexível e portável.
Depois que todos os valores foram processados, apenas um fluxo em cada grupo local assume a tarefa da normalização final. Ele calcula a variância subtraindo da soma dos quadrados dos valores o quadrado da média e, em seguida, extrai a raiz quadrada, obtendo o desvio padrão. Para evitar divisão por zero, foi implementada uma proteção: quando a variância é pequena demais, ela é substituída por um.
if(a == 0) { stdev -= mean * mean; stdev = IsNaNOrInf(sqrt(stdev), 1); if(stdev <= 0) stdev = 1; mean_stdevs[shift_out] = (float2)(mean, stdev); outputs[shift_out] = IsNaNOrInf((inputs[shift_out] - mean) / stdev, 0); } }
Depois disso, calcula-se o valor de entrada normalizado, que é então gravado no array de saída. Em paralelo, são preservados a média e o desvio padrão calculados, pois eles serão úteis posteriormente.
A lógica implementada combina a agregação das informações sobre a distribuição espacial dos dados com a normalização baseada nela. Isso permite que o modelo reaja de forma sensível às dependências ocultas entre as variáveis e, portanto, capte com mais precisão a dinâmicas temporais complexas, algo especialmente relevante na previsão financeira.
Para o treinamento completo do modelo, é necessária não apenas a propagação para frente com o cálculo dos valores normalizados, mas também uma propagação reversa correta, garantindo a transmissão dos gradientes do erro de volta aos dados originais e aos parâmetros. No contexto do nosso kernel AdaptSpatialNorm, para a normalização espacialmente ponderada, o próximo passo lógico é implementar o kernel de propagação reversa AdaptSpatialNormGrad, responsável por distribuir os gradientes do erro pelos dados originais e pelos coeficientes de atenção.
__kernel void AdaptSpatialNormGrad(__global const float* inputs, __global float* inputs_gr, __global const float* attention, __global float* attention_gr, __global const float2* mean_stdevs, __global const float2* mean_stdevs_gr, __global const float* outputs_gr, const uint total_inputs ) { const size_t i = get_global_id(0); // main const size_t loc = get_local_id(1); // local to sum const size_t v = get_global_id(2); // variable const size_t total_main = get_global_size(0); // total const size_t total_loc = get_local_size(1); // local dimension const size_t variables = get_global_size(2); // total variables //--- __local float Temp[LOCAL_ARRAY_SIZE];
O algoritmo se baseia na distribuição da carga computacional entre os processos, em que cada fluxo processa uma combinação específica de índices responsáveis pela fatia temporal e pela variável. Para armazenar valores intermediários, usa-se a memória local, o que acelera as operações de soma e agregação de dados dentro dos grupos de trabalho.
Primeiro, são calculados os gradientes em relação aos dados originais. Para cada elemento do array de entrada, são obtidos os parâmetros de atenção correspondentes e os gradientes de saída. Em seguida, calcula-se o gradiente levando em conta as derivadas parciais em relação aos parâmetros de normalização (média e desvio padrão), que também participam da retropropagação do erro. Todas as somas parciais são acumuladas e, em seguida, o resultado é gravado no array de gradientes dos dados de entrada.
//--- Inputs gradient { if(i < total_inputs) { float grad = 0; int shift_in = v * total_inputs + i; float x = IsNaNOrInf(inputs[shift_in], 0); for(int l = 0; l < variables; l += total_loc) { if((l + loc) >= variables) break; int shift_out = i + (l + loc) * total_inputs; float att = IsNaNOrInf(attention[(l + loc) * variables + v], 0); float out_gr = IsNaNOrInf(outputs_gr[shift_out], 0); float2 ms = mean_stdevs[shift_out]; float2 ms_gr = mean_stdevs_gr[shift_out]; float dy = (1 - att) * (1 / ms.y - (x - ms.x) * att * x / pow(ms.y, 3.0f)); float dmean = IsNaNOrInf(ms_gr.x * att, 0); float dstd = IsNaNOrInf(ms_gr.y * x * (att - att * att) / ms.y, 0); grad += IsNaNOrInf(dy * out_gr + dmean + dstd, 0); } grad = LocalSum(grad, 1, Temp); if(loc == 0) inputs_gr[shift_in] = grad; } BarrierLoc; }
Antecipando um pouco, vale destacar um detalhe importante: os parâmetros de normalização que armazenamos (valor médio e desvio padrão) não são um subproduto dos cálculos. Pelo contrário, eles participam ativamente das operações posteriores do framework SCNN, formando uma espécie de ramo auxiliar de processamento de dados. Por isso, o gradiente do erro acumulado nesses parâmetros durante estágios posteriores da propagação para frente também precisa ser retropropagado até o nível dos dados originais.
Isso significa que, durante a propagação reversa, não nos limitamos apenas ao fluxo principal de informação: também consideramos a contribuição associada às derivadas em relação às estatísticas de normalização salvas e a adicionamos ao gradiente final da entrada. Essa abordagem garante a integridade do grafo computacional e permite que o modelo ajuste com eficiência todos os parâmetros que influenciam o resultado, incluindo aqueles que estão indiretamente ligados à entrada por meio dos mecanismos de normalização.
Em seguida, são calculados os gradientes em relação aos parâmetros de atenção. Para cada elemento dos pesos de atenção, o algoritmo percorre todos os pontos temporais correspondentes, usando os valores dos dados originais e dos gradientes de saída já calculados. Os cálculos também levam em conta a influência da atenção sobre a normalização por meio da média e do desvio padrão. Os valores finais são acumulados com o uso da memória local e salvos no array correspondente de gradientes dos parâmetros de atenção.
//--- Attention gradient { if(i < variables) { float grad = 0; int shift_att = v * variables + i; float att = IsNaNOrInf(attention[shift_att], 0); for(int l = 0; l < total_inputs; l += total_loc) { if((l + loc) >= total_inputs) break; int shift_out = (l + loc) + v * total_inputs; int shift_in = (l + loc) + i * total_inputs; float x = IsNaNOrInf(inputs[shift_in], 0); float out_gr = IsNaNOrInf(outputs_gr[shift_out], 0); float2 ms = mean_stdevs[shift_out]; float2 ms_gr = mean_stdevs_gr[shift_out]; float dy = -x / ms.y - (x - ms.x) * x * x * (1 - 2 * att) / (2 * pow(ms.y, 3.0f)); float dmean = IsNaNOrInf(ms_gr.x * x, 0); float dstd = IsNaNOrInf(ms_gr.y * x * x * (1 - 2 * att) / (2 * ms.y), 0); grad += IsNaNOrInf(dy * out_gr + dmean + dstd, 0); } grad = LocalSum(grad, 1, Temp); if(loc == 0) attention_gr[shift_att] = grad; } } }
É importante observar que a implementação considera a possibilidade de valores numéricos inválidos, como NaN ou infinitos, e impede sua propagação, aumentando a robustez do algoritmo.
A abordagem geral com processamento em loop dos dados e uso da memória local garante escalabilidade e eficiência, permitindo processar grandes conjuntos de variáveis e etapas temporais, mesmo quando seu tamanho excede as dimensões dos grupos de trabalho.
Assim, o kernel AdaptSpatialNormGrad realiza o cálculo preciso e eficiente dos gradientes para os principais parâmetros de normalização, levando em conta os pesos espaciais de atenção, o que permite integrar esse mecanismo a modelos complexos com treinamento por meio da retropropagação do erro.
Para integrar o algoritmo de normalização espacialmente ponderada descrito acima à arquitetura geral, no programa principal, é criado um objeto especializado CNeuronAdaptSpatialNorm. Essa classe herda as interfaces básicas de CNeuronBaseOCL, o que permite integrá-la de forma natural à hierarquia dos componentes neurais. A principal tarefa do objeto é garantir a execução correta dos cálculos tanto na propagação para frente quanto na propagação reversa, além de processar corretamente todos os componentes auxiliares associados ao mecanismo de atenção.
A estrutura da nova classe é apresentada abaixo.
class CNeuronAdaptSpatialNorm : public CNeuronBaseOCL { protected: uint iVariables; uint iCount; //--- CParams cEn; CNeuronTransposeOCL cEnT; CNeuronBaseOCL cEnEnT; CNeuronSoftMaxOCL cAttan; CNeuronBaseOCL cMeanSTDevs; //--- virtual bool AdaptSpatialNorm(CNeuronBaseOCL *NeuronOCL); virtual bool AdaptSpatialNormGrad(CNeuronBaseOCL *NeuronOCL); //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL) override; virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) override; virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL) override; public: CNeuronAdaptSpatialNorm(void) : iCount(0), iVariables(1) {}; ~CNeuronAdaptSpatialNorm(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint units_count, uint variables, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual bool Save(const int file_handle) override; virtual bool Load(const int file_handle) override; //--- virtual int Type(void) override const { return defNeuronAdaptSpatialNorm; } virtual void SetOpenCL(COpenCLMy *obj) override; //--- CNeuronBaseOCL* GetMeanSTDevs(void) { return cMeanSTDevs.AsObject(); } virtual uint GetVariables(void) const { return iVariables; } virtual uint GetUnits(void) const { return iCount; } //--- virtual bool WeightsUpdate(CNeuronBaseOCL *source, float tau); };
Dentro da classe, vemos uma série de membros protegidos, e cada um deles desempenha um papel importante no funcionamento do componente. As variáveis iVariables e iCount definem as dimensões do tensor dos dados originais e estabelecem os limites espaciais do processamento. Os objetos internos cEn, cEnT, cEnEnT e cAttan formam e treinam sequencialmente a matriz de atenção. O componente cMeanSTDevs fecha essa cadeia, sendo responsável por armazenar e atualizar os parâmetros de normalização usados para escalar os dados originais levando em conta os pesos de atenção calculados.
Todos os objetos internos são declarados de forma estática, o que permite simplificar significativamente a gestão da memória e da inicialização. Com isso, a solução se torna mais confiável e previsível: não há necessidade de alocar nem liberar recursos manualmente. O construtor e o destrutor da classe permanecem vazios, pois os objetos já existem no momento da criação da instância da classe e são destruídos automaticamente ao fim de seu ciclo de vida.
A inicialização de todos os componentes internos da classe é realizada de forma centralizada no método Init. Esse método recebe como entrada os principais parâmetros que permitem definir de maneira inequívoca a arquitetura do objeto criado, incluindo a dimensionalidade dos dados de entrada. Toda a sequência se desenrola de forma lógica e ordenada, com uma vinculação clara à estrutura interna do modelo.
bool CNeuronAdaptSpatialNorm::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint units_count, uint variables, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, units_count * variables, optimization_type, batch)) return false;
Na primeira etapa, é chamado o método homônimo da classe base CNeuronBaseOCL, no qual ocorre a inicialização primária do nó neural. O tamanho do buffer de resultados é calculado como o produto do número de variáveis pelo comprimento da sequência. Se a inicialização do nível base for concluída com sucesso, os parâmetros do tensor dos dados originais são definidos e salvos nas variáveis locais iVariables e iCount.
iVariables = variables; iCount = units_count; //--- uint dimension = (iVariables + 1) / 2; uint index = 0; if(!cEn.Init(0, index, OpenCL, iVariables * dimension, optimization, iBatch)) return false; cEn.SetActivationFunction(None);
Em seguida, inicia-se a configuração sequencial dos componentes internos responsáveis pela formação da matriz de atenção. O objeto cEn é inicializado primeiro. Ele representa um tensor de parâmetros tr Sua dimensionalidade é definida como o produto do número de variáveis pela metade desse mesmo número.eináveis. Essa redução permite destacar os atributos mais relevantes para o processamento posterior. Aqui, a função de ativação é explicitamente desativada, pois nessa etapa é necessária uma transformação linear pura, sem distorcer o sinal de saída.
Na sequência, é inicializado o objeto de transposição cEnT, que permite obter uma cópia transposta do tensor de parâmetros treináveis.
index++; if(!cEnT.Init(0, index, OpenCL, iVariables, dimension, optimization, iBatch)) return false; cEnT.SetActivationFunction(None); index++; if(!cEnEnT.Init(0, index, OpenCL, iVariables * iVariables, optimization, iBatch)) return false; cEnEnT.SetActivationFunction(None);
O objeto cEnEnT desempenha um papel central na construção do mecanismo de atenção dentro do framework SCNN. Ele armazena os resultados da multiplicação matricial do tensor de parâmetros treináveis por sua cópia transposta. Essa operação forma uma matriz simétrica que reflete as dependências mútuas e a força da correlação entre as variáveis em uma única etapa temporal. A estrutura resultante permite identificar explicitamente quais dos atributos analisados exercem maior influência uns sobre os outros.
O objeto cAttan conclui a construção do mecanismo de atenção. Ele recebe como entrada a matriz de correlação e aplica a normalização SoftMax a ela, distribuindo a atenção entre as variáveis. O número de cabeças de atenção corresponde ao número de linhas na matriz de correlação, o que permite uma adaptação flexível à estrutura dos dados.
index++; if(!cAttan.Init(0, index, OpenCL, iVariables * iVariables, optimization, iBatch)) return false; cAttan.SetHeads(iVariables);
Por fim, é configurado o objeto cMeanSTDevs, destinado a armazenar pares de valores: média e desvio padrão calculados pela normalização espacialmente ponderada. A dimensionalidade desse objeto corresponde ao dobro do número de neurônios na saída, pois para cada elemento dos resultados é necessário armazenar dois parâmetros.
index++; if(!cMeanSTDevs.Init(0, index, OpenCL, 2 * Neurons(), optimization, iBatch)) return false; cMeanSTDevs.SetActivationFunction(None); //--- return true; }
Assim, o método Init cria e configura todos os componentes necessários para o funcionamento correto do mecanismo de normalização espacialmente ponderada no âmbito da SCNN. A estrutura do código reflete o rigor e a modularidade da arquitetura, em que cada bloco tem uma finalidade bem definida e interage com os demais em uma sequência determinada.
Após a inicialização bem-sucedida de todos os componentes internos, passamos à construção do mecanismo de propagação para frente, implementado no método feedForward. Aqui começa a etapa central de execução da camada, na qual a matriz de atenção é formada e a normalização espacialmente ponderada dos dados de entrada é realizada.
bool CNeuronAdaptSpatialNorm::feedForward(CNeuronBaseOCL *NeuronOCL) { if(bTrain) { if(!cEn.FeedForward()) return false; if(!cEnT.FeedForward(cEn.AsObject())) return false; if(!MatMul(cEn.getOutput(), cEnT.getOutput(), cEnEnT.getOutput(), iVariables, cEnT.GetWindow(), iVariables, 1, false)) return false; if(!cAttan.FeedForward(cEnEnT.AsObject())) return false; } //--- return AdaptSpatialNorm(NeuronOCL); }
Aqui vale destacar um ponto arquitetural importante: a formação dos parâmetros de atenção é executada exclusivamente em modo de treinamento. Isso foi feito de propósito, pois a própria matriz de atenção é um componente estático do modelo, ou seja, ela não se adapta a dados de entrada específicos durante a operação. Em outras palavras, treinamos coeficientes de ponderação universais que refletem inter-relações estáveis entre as variáveis dentro da série temporal. Essa abordagem garante estabilidade no comportamento do modelo em novos dados, e o cálculo da atenção durante a inferência pode ser omitido, acelerando significativamente o funcionamento sem perda de qualidade na normalização.
Em seguida, na cadeia da propagação para frente, chama-se o método AdaptSpatialNorm, que atua como wrapper do kernel homônimo. É exatamente essa etapa que transfere o controle para o contexto OpenCL, onde ocorrem os principais cálculos: a normalização dos dados originais levando em conta os pesos de atenção.
Vale dizer algumas palavras sobre o funcionamento do método wrapper para o kernel OpenCL. Embora a lógica geral do algoritmo tenha permanecido a mesma, foram introduzidas melhorias estruturais na implementação voltadas a aumentar a legibilidade e a confiabilidade do código. Isso se aplica, antes de tudo, à forma de escrever as chamadas das funções OpenCL.
Foram introduzidas macros auxiliares que permitem simplificar e padronizar a configuração dos argumentos do kernel e sua execução. Por exemplo, a macro setBuffer encapsula a chamada OpenCL.SetArgumentBuffer com tratamento automático de erro e exibição de informações de depuração, incluindo o nome do kernel, o código do erro e a linha em que a falha ocorreu. De modo semelhante, setArgument funciona para definir valores escalares.
#define setBuffer(kernel, id, buffer) if(!OpenCL.SetArgumentBuffer(kernel, id, buffer)) { \ printf("Error of set parameter kernel %s: %d; line %d", OpenCL.GetKernelName(kernel), GetLastError(), __LINE__); \ return false; } #define setArgument(kernel, id, value) if(!OpenCL.SetArgument(kernel, id, value)) { \ printf("Error of set parameter kernel %s: %d; line %d", OpenCL.GetKernelName(kernel), GetLastError(), __LINE__); \ return false; }
A execução do kernel é realizada por meio das macros kernelExecute e kernelExecuteLoc, que ocultam toda a camada técnica auxiliar associada à inicialização da grade de processos e, em caso de falha, exibem automaticamente uma descrição textual detalhada do erro vinculada ao nome da função chamada
#define kernelExecute(kernel,offset,global) if(!OpenCL.Execute(kernel, global.Size(), offset, global)) { \ string error; \ CLGetInfoString(OpenCL.GetContext(), CL_ERROR_DESCRIPTION, error); \ printf("Error of execution kernel %s %s: %s", __FUNCSIG__, OpenCL.GetKernelName(kernel), error); \ return false; } #define kernelExecuteLoc(kernel,offset,global,local) if(!OpenCL.Execute(kernel, global.Size(), offset, global, local)) { string error; \ CLGetInfoString(OpenCL.GetContext(), CL_ERROR_DESCRIPTION, error); \ printf("Error of execution kernel %s %s: %s", __FUNCSIG__, OpenCL.GetKernelName(kernel), error); \ return false; }
Com essa estrutura, o código do método se torna mais compacto, logicamente mais claro e mais fácil de escalar ao adicionar novos kernels ou parâmetros.
Além disso, como já mencionado na descrição dos kernels, antes de executá-los é necessário considerar as limitações técnicas do dispositivo computacional, em particular, o tamanho máximo do grupo de trabalho compatível com a plataforma OpenCL. Esses parâmetros variam conforme o modelo do adaptador gráfico e podem influenciar significativamente a execução correta do código.
No corpo do método AdaptSpatialNorm, isso é implementado por meio da chamada CLGetDeviceInfo, com a qual é solicitado o tamanho permitido do grupo de trabalho para cada dimensão. Os valores retornados são salvos na estrutura union sizes, o que permite acessá-los de forma conveniente como um array de inteiros, sem se preocupar com as particularidades da representação interna dos dados.
bool CNeuronAdaptSpatialNorm::AdaptSpatialNorm(CNeuronBaseOCL *NeuronOCL) { if(!OpenCL || !NeuronOCL) return false; uint global_work_offset[3] = { 0 }; union sizes { long data[3]; uchar cdata[24]; } max_workgroup_size; uint size = 0; if(!CLGetDeviceInfo(OpenCL.GetContext(), CL_DEVICE_MAX_WORK_ITEM_SIZES, max_workgroup_size.cdata, size)) return false;
Depois de obter esses parâmetros, formamos o array global_work_size, que define a grade global de execução. Além disso, na segunda dimensão (que define a largura do grupo local), limitamos explicitamente esse valor ao menor entre o número de variáveis iVariables e o tamanho máximo permitido pelo dispositivo. Isso garante que o loop implementado no kernel funcione corretamente mesmo com um grande volume de dados.
uint global_work_size[] = { iCount, MathMin(iVariables, uint(max_workgroup_size.data[1])), iVariables}; uint local_work_size[] = { 1, global_work_size[1], 1}; //--- uint kernel = def_k_AdaptSpatialNorm; setBuffer(kernel, def_k_asn_inputs, NeuronOCL.getOutputIndex()) setBuffer(kernel, def_k_asn_mean_stdevs, cMeanSTDevs.getOutputIndex()) setBuffer(kernel, def_k_asn_attention, cAttan.getOutputIndex()) setBuffer(kernel, def_k_asn_outputs, getOutputIndex()) kernelExecuteLoc(kernel, global_work_offset, global_work_size, local_work_size) //--- return true; }
Paralelamente, é formado o array local_work_size, no qual é definido explicitamente o número de processos na segunda dimensionalidade. Essa abordagem permite usar a memória local de forma eficiente durante o processamento dos vetores de atributos.
Em seguida, configuramos sequencialmente os buffers dos argumentos do kernel usando as macros setBuffer definidas anteriormente, o que garante uma inicialização uniforme e segura dos parâmetros. Essa etapa é concluída pela macro kernelExecuteLoc, que executa o kernel AdaptSpatialNorm levando em conta todas as limitações definidas anteriormente e a estrutura da grade computacional.
Assim, implementamos não apenas um wrapper para chamar o kernel, mas também um mecanismo flexível de adaptação do algoritmo às capacidades técnicas específicas do hardware-alvo, garantindo compatibilidade e funcionamento robusto em uma ampla variedade de dispositivos.
A próxima etapa importante é a distribuição do gradiente do erro, implementada no método calcInputGradients. É exatamente aqui que ocorre a retropropagação do sinal pelos objetos internos. Os erros acumulados são transformados sequencialmente, retornando aos parâmetros treináveis do nível original. Essa parte do algoritmo é especialmente importante, pois permite atualizar os pesos com base nas informações atuais sobre a divergência entre os valores previstos e os valores verdadeiros.
bool CNeuronAdaptSpatialNorm::calcInputGradients(CNeuronBaseOCL *NeuronOCL) { if(!AdaptSpatialNormGrad(NeuronOCL)) return false;
A rotina começa com a chamada do método AdaptSpatialNormGrad, que, assim como sua contraparte da propagação para frente, representa um wrapper do kernel OpenCL correspondente. Ele é responsável por propagar o gradiente das saídas do bloco de normalização para baixo, em direção aos dados originais e aos parâmetros treináveis da matriz de atenção.
O algoritmo de construção do método AdaptSpatialNormGrad praticamente repete a estrutura usada anteriormente ao enfileirar o kernel de propagação para frente para execução. Também formamos os dimensões globais e locais da grade de execução, configuramos os buffers usando macros e passamos os parâmetros ao kernel na ordem necessária. No entanto, aqui existe uma diferença importante, decorrente da própria natureza da operação de retropropagação do erro.
Se, na propagação para frente, cada elemento era processado de forma independente, no cálculo dos gradientes a situação muda: para cada elemento no nível dos dados de entrada, é necessário agregar a contribuição do gradiente de todas as sequências unitárias usando os pesos de atenção. Isso exige acesso simultâneo a múltiplos componentes distintos do tensor de saída e, portanto, um esquema de soma mais complexo.
Uma abordagem especial também é necessária para os coeficientes de atenção. Aqui, é preciso integrar cuidadosamente o gradiente ao longo da dimensão temporal, ou seja, reunir as informações de todas as posições da sequência em que o coeficiente correspondente foi aplicado. Essa é uma operação bastante custosa computacionalmente, especialmente no caso de tensores de entrada grandes, por isso se dá atenção especial ao tamanho do grupo de trabalho.
Como resultado, para configurar a tarefa de forma correta e eficiente, o tamanho do grupo de trabalho é escolhido pelo valor máximo entre os eixos ao longo dos quais os gradientes precisam ser agregados, mas sempre levando em conta o limite máximo permitido pelo dispositivo OpenCL específico. Essa abordagem permite não apenas cumprir os requisitos técnicos, mas também otimizar o desempenho, evitando estouros desnecessários e conflitos de acesso à memória.
Depois que os cálculos são concluídos no contexto OpenCL, o método segue com a execução em um estilo mais familiar. Propagamos sequencialmente o gradiente do erro pelos objetos de formação da matriz de atenção.
if(!cEnEnT.CalcHiddenGradients(cAttan.AsObject())) return false; if(!MatMulGrad(cEn.getOutput(), cEn.getPrevOutput(), cEnT.getOutput(), cEnT.getGradient(), cEnEnT.getGradient(), iVariables, cEnT.GetWindow(), iVariables, 1, false)) return false; if(!cEn.CalcHiddenGradients(cEnT.AsObject()) || !SumAndNormilize(cEn.getGradient(), cEn.getPrevOutput(), cEn.getGradient(), cEnT.GetWindow(), false, 0, 0, 0, 1)) return false; if(cEn.Activation() != None) if(!DeActivation(cEn.getOutput(), cEn.getGradient(), cEn.getGradient(), cEn.Activation())) return false; //--- return true; }
Assim, todo o método calcInputGradients representa uma cadeia de etapas claramente estruturada, que garante a propagação sequencial e correta do erro para trás pela rede.
O método de otimização dos parâmetros é implementado aqui de forma extremamente concisa, mas, ao mesmo tempo, objetiva. Toda a tarefa se resume a transferir o controle para um único objeto interno, cEn, que contém os parâmetros de atenção treináveis. Esse objeto representa o núcleo do bloco paramétrico com base no qual a matriz de correlação é construída. Ele é o único responsável por toda a parte treinável do módulo.
bool CNeuronAdaptSpatialNorm::updateInputWeights(CNeuronBaseOCL *NeuronOCL) { return cEn.UpdateInputWeights(); }
O código completo da classe CNeuronAdaptSpatialNorm e de todos os seus métodos é apresentado no anexo para estudo independente.
SCNN Encoder
Depois de construirmos os objetos responsáveis pela extração dos componentes individuais da série temporal, o próximo passo lógico é integrá-los em uma única estrutura unificada e coerente, denominada SCNN Encoder. Nessa etapa, os blocos previamente formados são encadeados em uma sequência claramente definida, garantindo um processamento confiável, robusto e, ao mesmo tempo, flexível dos dados analisados.
A característica central do SCNN Encoder está em sua arquitetura modular. Arquitetura essa que se baseia em quatro ramos paralelos, cada um responsável por sua própria escala de análise. Um capta tendências de longo prazo, outro identifica padrões sazonais, o terceiro captura flutuações de curto prazo, e o quarto normaliza os dados de forma adaptativa, reforçando as inter-relações entre atributos individuais.
Todos esses algoritmos são implementados de forma precisa dentro do objeto especializado CNeuronSCNNEncoder. Sua estrutura concretiza a lógica descrita e estabelece a base para a formação de uma representação generalizada da sequência, compacta, significativa e adequada para a previsão posterior.
class CNeuronSCNNEncoder : public CNeuronTransposeOCL { protected: CNeuronPeriodNorm cLongNorm; CNeuronTransposeVRCOCL cSeasonTransp; CNeuronPeriodNorm cSeasonNorm; CNeuronTransposeVRCOCL cUnSeasonTransp; CNeuronPeriodNorm cShortNorm; CNeuronAdaptSpatialNorm cAdaptSpatNorm; CNeuronBaseOCL cConcatenated; CNeuronSwiGLUOCL cProjection; CNeuronTransposeOCL cTranspose; CNeuronConvOCL caFusion[2]; CNeuronBaseOCL cFusionOut; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL) override; virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) override; virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL) override; public: CNeuronSCNNEncoder(void) {}; ~CNeuronSCNNEncoder(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint units_count, uint variables, uint forecast, uint season_period, uint short_period, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual bool Save(const int file_handle) override; virtual bool Load(const int file_handle) override; //--- virtual int Type(void) override const { return defNeuronGinAR; } virtual void TrainMode(bool flag) override; virtual void SetOpenCL(COpenCLMy *obj); //--- virtual bool WeightsUpdate(CNeuronBaseOCL *source, float tau); };
Na estrutura da nova classe, observamos diversos objetos internos, cada um desempenhando uma função estritamente definida na composição do Encoder. Conheceremos sua finalidade um pouco mais adiante, à medida que a lógica do algoritmo for sendo apresentada. Por ora, vale destacar um ponto arquitetural importante: todos esses componentes são declarados de forma estática. Isso significa que a memória para eles é alocada antecipadamente, e seu ciclo de vida corresponde estritamente ao ciclo de vida do próprio objeto CNeuronSCNNEncoder. Essa solução permite dispensar tratamento adicional: o construtor e o destrutor da classe permanecem vazios.
A inicialização de todos os objetos internos declarados e herdados é implementada no método Init, cujos parâmetros recebem todas as constantes que definem a arquitetura do objeto.
bool CNeuronSCNNEncoder::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint units_count, uint variables, uint forecast, uint season_period, uint short_period, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronTransposeOCL::Init(numOutputs, myIndex, open_cl, units_count + forecast, variables, optimization_type, batch)) return false; SetActivationFunction(None);
Tudo começa com a chamada do método homônimo da classe base, no qual são definidos os principais parâmetros da arquitetura. Após a inicialização bem-sucedida, é ativada a função de ativação neutra, que impede a introdução de distorções não lineares nessa etapa.
Em seguida, são criados sequencialmente os módulos responsáveis pelo pré-processamento da série temporal. Primeiro, é iniciado o bloco de normalização do componente de longo prazo.
uint index = 0; if(!cLongNorm.Init(0, index, OpenCL, 1, units_count, variables, optimization, iBatch)) return false; cLongNorm.SetActivationFunction(None);
A próxima etapa importante é a extração do componente sazonal. Na concepção original dos autores do framework SCNN, essa etapa é interpretada como a normalização dos dados com um passo correspondente ao período de sazonalidade. À primeira vista, pode parecer que isso exige algum módulo especializado. No entanto, abordamos esse problema de forma mais racional, seguindo os princípios clássicos da engenharia de sistemas.
Desenvolvemos um objeto universal de normalização, destinado ao processamento dos dados em períodos fixos. Por si só, ele não contém informações sobre a estrutura sazonal; estrutura essa que surge assim que mudamos a representação do array analisado. Basta transpor a sequência de etapas temporais com passo igual ao período de sazonalidade para que os dados sejam agrupados automaticamente em sequências com o intervalo necessário. Essas sequências recém-formadas se encaixam perfeitamente com o formato esperado pelo nosso módulo de normalização por períodos.
Assim, com um esforço mínimo, obtemos um mecanismo poderoso para extrair o componente sazonal, sem introduzir alterações adicionais na estrutura do próprio objeto normalizador. Tudo o que precisamos fazer é reestruturar corretamente os dados de entrada; depois disso, entra em ação o algoritmo já testado e depurado.
index++; if(!cSeasonTransp.Init(0, index, OpenCL, variables, units_count / season_period, season_period, optimization, iBatch)) return false; cSeasonTransp.SetActivationFunction(None); index++; if(!cSeasonNorm.Init(0, index, OpenCL, cSeasonTransp.GetCount(), season_period, variables, optimization, iBatch)) return false; cSeasonNorm.SetActivationFunction(None); index++; if(!cUnSeasonTransp.Init(0, index, OpenCL, variables, season_period, cSeasonTransp.GetCount(), optimization, iBatch)) return false; index++;
Após extrair o componente sazonal, retornamos os dados à representação original por meio da transposição inversa e ativamos o bloco de normalização de curto prazo.
if(!cShortNorm.Init(0, index, OpenCL, units_count / short_period, short_period, variables, optimization, iBatch)) return false; cSeasonNorm.SetActivationFunction(None); index++; if(!cAdaptSpatNorm.Init(0, index, OpenCL, units_count, variables, optimization, iBatch)) return false; cAdaptSpatNorm.SetActivationFunction(None);
Em seguida, vem a normalização espacial adaptativa, que leva em conta as inter-relações entre as variáveis na sequência de entrada.
Após a conclusão da preparação dos dados provenientes de diferentes fontes, é criado o módulo de unificação, que acumula as saídas de todos os blocos normalizadores em uma única matriz de atributos. O tamanho desse módulo é calculado dinamicamente, levando em conta tanto as saídas principais quanto os estatísticas que acompanham cada bloco de normalização.
index++; uint concatSize = units_count * variables; //inputs concatSize += cLongNorm.Neurons() + cLongNorm.GetMeanSTDevs().Neurons(); // long term concatSize += cSeasonNorm.Neurons() + cSeasonNorm.GetMeanSTDevs().Neurons(); // seasons concatSize += cShortNorm.Neurons() + cShortNorm.GetMeanSTDevs().Neurons(); // short term concatSize += cAdaptSpatNorm.Neurons() + cAdaptSpatNorm.GetMeanSTDevs().Neurons(); // spatial if(!cConcatenated.Init(0, index, OpenCL, concatSize, optimization, iBatch)) return false; cConcatenated.SetActivationFunction(None);
O próximo elemento lógico é o bloco de projeção, que permite compactar e reestruturar de forma eficiente o espaço multidimensional de atributos.
index++; if(!cProjection.Init(0, index, OpenCL, concatSize / variables, concatSize / variables, units_count + forecast, 1, variables, optimization, iBatch)) return false;
Sua saída é encaminhada ao módulo de transposição, que realiza a conversão dos dados entre as representações de tempo e de atributos, conforme os requisitos do processamento posterior.
index++; if(!cTranspose.Init(0, index, OpenCL, variables, units_count + forecast, optimization, iBatch)) return false; index++; if(!caFusion[0].Init(0, index, OpenCL, variables, variables, variables, units_count + forecast, optimization, iBatch)) return false; caFusion[0].SetActivationFunction(TANH); index++; if(!caFusion[1].Init(0, index, OpenCL, variables, variables, variables, units_count + forecast, optimization, iBatch)) return false; caFusion[1].SetActivationFunction(SIGMOID); index++; if(!cFusionOut.Init(0, index, OpenCL, caFusion[0].Neurons(), optimization, iBatch)) return false; //--- return true; }
A etapa final inclui duas camadas convolucionais paralelas que implementam diferentes mecanismos de filtragem do sinal. Uma delas usa a tangente hiperbólica, conferindo à saída uma saturação suave; a outra usa a sigmoide, impondo uma restrição logística aos valores. Seus resultados convergem no módulo de convolução final, que conclui a construção da arquitetura.
Avançamos bastante, e o volume do artigo cresceu visivelmente. À frente, temos a análise de um algoritmo complexo de propagação para frente e propagação reversa do nosso Encoder, uma etapa que exige atenção e concentração especiais. Por isso, faz sentido fazer uma breve pausa, para que haja tempo de assimilar e compreender o material já apresentado. No próximo artigo, continuaremos a implementação, levaremos essa etapa à sua conclusão lógica e avaliaremos a eficiência das soluções implementadas em dados históricos reais.
Conclusão
Neste artigo, analisamos em detalhes a próxima etapa da implementação do framework SCNN: a construção e a integração do objeto de normalização espacial adaptativa, bem como a combinação dos principais componentes em um único Encoder. Mostramos como uma arquitetura bem estruturada e o uso de tecnologias computacionais modernas, como OpenCL, permitem extrair com eficiência os componentes estruturais das séries temporais e garantir uma preparação qualificada dos dados para a previsão posterior.
No próximo artigo, continuaremos a investigação, concentrando-nos nos testes e na avaliação prática do desempenho do framework SCNN em dados reais, o que permitirá avaliar a relevância aplicada dos métodos implementados e sua eficiência em tarefas de previsão financeira.
Referências
- Disentangling Structured Components: Towards Adaptive, Interpretable and Scalable Time Series Forecasting
- Outros artigos da série
Programas utilizados no artigo
| # | Nome | Tipo | Descrição |
|---|---|---|---|
| 1 | Study.mq5 | Expert Advisor | EA de treinamento offline de modelos |
| 2 | StudyOnline.mq5 | Expert Advisor | EA de treinamento online de modelos |
| 3 | Test.mq5 | Expert Advisor | EA para teste do modelo |
| 4 | Trajectory.mqh | Biblioteca de classes | Estrutura para descrever o estado do sistema e a arquitetura dos modelos |
| 5 | NeuroNet.mqh | Biblioteca de classes | Biblioteca de classes para criação de redes neurais |
| 6 | NeuroNet.cl | Biblioteca | Biblioteca de código para o programa OpenCL |
Traduzido do russo pela MetaQuotes Ltd.
Artigo original: https://www.mql5.com/ru/articles/18982
Aviso: Todos os direitos sobre esses materiais pertencem à MetaQuotes Ltd. É proibida a reimpressão total ou parcial.
Esse artigo foi escrito por um usuário do site e reflete seu ponto de vista pessoal. A MetaQuotes Ltd. não se responsabiliza pela precisão das informações apresentadas nem pelas possíveis consequências decorrentes do uso das soluções, estratégias ou recomendações descritas.
Rede neural quântica em MQL5 (Parte III): Processador quântico virtual com qubits
Construindo Expert Advisors Autootimizáveis em MQL5 (Parte 6): Regras de Trading Autoajustáveis (II)
Está chegando o novo MetaTrader 5 e MQL5
Superando as limitações do aprendizado de máquina (Parte 2): falta de reprodutibilidade
- 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