Redes neurais em trading: Treinamento de modelos spiking profundos (Integração de spikes)
Introdução
Ao longo da história, os mercados financeiros sempre foram palco do confronto entre forças opostas: ordem e caos, cálculo racional e surtos irracionais de emoção. Eles lembram um mar revolto, em cuja superfície vemos o movimento das ondas, representado pelas oscilações de preços, enquanto sob a água se escondem correntes complexas, como fatores fundamentais, mudanças macroeconômicas e o sentimento dos participantes. Nesse ambiente, todo trader, investidor ou pesquisador busca uma chave para compreender os padrões do mercado. Alguns confiam na intuição, outros em fórmulas rigorosas, mas, nas últimas décadas, a inteligência artificial e as arquiteturas de redes neurais vêm recebendo cada vez mais atenção.
As redes neurais clássicas, desenvolvidas para o processamento de imagens e áudio, pareciam ferramentas universais. No entanto, quando as aplicamos a séries temporais financeiras, especialmente a dados de alta frequência, surgem contradições fundamentais. O mercado não é uma curva suave de uma função, mas um fluxo de eventos: negociações, ordens, picos de volatilidade e mudanças bruscas de liquidez. Trata-se de uma sequência de batidas discretas de um metrônomo, em que cada tick de preço pode ser visto como um impulso. É justamente por isso que, nos últimos anos, as redes neurais spiking (SNN, Spiking Neural Networks) passaram a atrair a atenção dos pesquisadores. Elas estão mais próximas da própria natureza do mercado, em que tudo se manifesta como uma sequência de eventos ao longo do tempo.
Mas a transição dos modelos clássicos para os modelos spiking não é apenas um detalhe técnico, e sim uma mudança qualitativa de paradigma. Aquilo que funciona no universo das ativações suaves pode se mostrar inútil no contexto spiking. Um exemplo marcante é a conhecida arquitetura ResNet. Em sua versão clássica, a ResNet revolucionou o machine learning ao permitir a construção de redes com dezenas e até centenas de camadas sem comprometer a capacidade de ser treinada. O segredo estava nas conexões shortcut, que permitiam que o sinal e o gradiente atravessassem a rede praticamente sem distorções.
E aqui é importante destacar um ponto. Em uma ResNet convencional, o shortcut não representa um simples bypass da camada. Ele também passa pela função de ativação ReLU. No entanto, como a ReLU é positiva e contínua, o sinal do shortcut é transmitido praticamente sem alteração, permitindo que os gradientes circulem livremente. Foi justamente essa propriedade que transformou a ResNet em um símbolo de profundidade e estabilidade.
Nos modelos spiking, porém, a situação é completamente diferente. Aqui, a ativação é um impulso binário. Quando o shortcut passa pela função de ativação spiking, ele perde sua identidade. A binarização destrói a própria ideia do shortcut como um canal direto. Como resultado, o aumento da profundidade deixa de funcionar como esperado: os gradientes ou desaparecem ou se tornam instáveis, e a rede deixa de ser treinável. Foi nesse ponto que os autores do SEW-ResNet propuseram uma solução que pode ser considerada, ao mesmo tempo, simples e engenhosa.
Eles retiraram o shortcut da função de ativação. Em vez de fazê-lo passar pela ativação binária, mantiveram o shortcut separado e o combinaram com o resultado da transformação residual após a ativação, definindo essa combinação por meio de uma operação elemento a elemento específica. Dessa forma, o shortcut recupera sua verdadeira função. Ele volta a ser efetivamente uma conexão identidade, e o sinal não se perde na dinâmica discreta.
Essa alteração parece mínima, mas suas consequências são enormes. Com isso, os modelos spiking profundos voltam a ser treináveis. Isso significa que é possível construir modelos com dezenas de camadas e esperar que funcionem com a mesma estabilidade de seus predecessores no universo das redes clássicas.
Além disso, os autores do SEW-ResNet ampliaram o conceito de shortcut. Na ResNet clássica, a operação de combinação era definida de forma rígida: uma simples soma. No SEW-ResNet, surge a possibilidade de escolher entre soma, AND lógico, sua inversão e outras funções g(•,•). Isso abre uma nova etapa no projeto de arquiteturas. O shortcut pode ser adaptado à tarefa específica.
No contexto financeiro, essa flexibilidade é especialmente valiosa. Pensemos no mercado de câmbio. Impulsos de curta duração provocados por notícias se sobrepõem a tendências de longo prazo determinadas pelas políticas dos bancos centrais. As ações apresentam oscilações sazonais e são influenciadas pelos resultados trimestrais, enquanto as criptomoedas estão sujeitas a picos repentinos de atividade das comunidades e a atualizações tecnológicas. Uma única arquitetura não é suficiente para todos esses processos: é preciso flexibilidade. O SEW-ResNet oferece justamente isso. É possível escolher um shortcut que capte melhor as particularidades de cada ativo.
Para o trader e o pesquisador, isso representa um novo nível de análise. Se os métodos clássicos permitem captar apenas uma espécie de "temperatura média" do mercado, o SEW-ResNet permite acompanhar simultaneamente os ticks rápidos do mercado e os movimentos de longo prazo. O shortcut passa a funcionar como um canal de memória confiável, impedindo que o modelo esqueça o que aconteceu anteriormente e, ao mesmo tempo, permitindo que ele assimile novos eventos.
O SEW-ResNet pode ser considerado um passo importante na evolução dos modelos spiking. Trata-se da recuperação de um mecanismo fundamental para a construção de redes profundas sob dinâmica binária.
A visualização do framework elaborada pelos autores é apresentada abaixo.


Na parte prática do artigo anterior, concentramos nossa atenção nos elementos fundamentais do neurônio spiking adaptado às séries temporais financeiras. Dedicamos atenção especial a dois mecanismos principais:
- limiares de ativação treináveis;
- gates de acumulação do potencial de membrana.
O limiar de ativação treinável permitirá que o neurônio ajuste de forma flexível sua sensibilidade aos impulsos de entrada. Em termos de mercado, isso se assemelha a uma adaptação à volatilidade. O neurônio aprende a distinguir oscilações de preço causadas por ruído de eventos realmente relevantes. Graças a isso, mesmo diante de movimentos intensos do mercado, o modelo mantém a capacidade de reagir apenas a sinais significativos, evitando a geração excessiva de spikes falsos.
Os gates de acumulação do potencial de membrana controlam a integração dos eventos analisados. Em vez de simplesmente somar os spikes recebidos, o neurônio controla o acúmulo do potencial e determina quando esse acúmulo deve gerar um spike. Esse mecanismo permitirá que o modelo desenvolva uma memória temporal, registrando sequências de eventos relevantes e gerando um spike somente quando o sinal acumulado ultrapassar o limiar adaptado ao contexto atual do mercado.
Esses mecanismos foram implementados no nível do neurônio spiking básico, estabelecendo uma base para a construção de arquiteturas mais complexas.
Otimização do programa OpenCL
Hoje, damos continuidade a esse trabalho. Mas, antes de avançarmos na implementação das abordagens propostas pelos autores do framework SEW-ResNet, faremos uma breve digressão para observar outra característica importante dos modelos spiking: o neurônio permanece silencioso até que ocorra um evento. Essa propriedade permite reduzir significativamente o custo computacional, pois o neurônio aguarda um sinal e não desperdiça recursos processando entradas vazias. Nos algoritmos que implementamos anteriormente, uma entrada igual a zero não proporcionava nenhuma economia de recursos. A multiplicação por zero era executada todas as vezes, embora o resultado já fosse conhecido de antemão.
Nos modelos clássicos de redes neurais, esse comportamento é perfeitamente justificável. Uma entrada igual a zero é rara, uma exceção à regra, e introduzir uma verificação adicional muitas vezes apenas complica e torna os cálculos mais lentos. Os modelos spiking, porém, funcionam de outra maneira. Neles, a maioria das entradas em cada instante é igual a zero, pois apenas eventos isolados são relevantes: impulsos curtos em meio ao silêncio. Por isso, adicionar uma verificação que permita ignorar operações com valores nulos torna-se uma solução natural e justificável.
Fazendo uma analogia com os mercados financeiros, o silêncio do neurônio lembra os períodos de "calma" no gráfico entre surtos de atividade no mercado. Quando o mercado está tranquilo, cada tick de preço traz pouca informação, e analisar todos eles seria um desperdício de recursos. O neurônio spiking se comporta de forma semelhante. Ele permanece inativo até receber um sinal com informação relevante. Assim que surge um evento, como um aumento abrupto de volume, o rompimento de um nível ou um impulso provocado por notícias, o neurônio reage imediatamente. Essa abordagem economiza recursos computacionais e aumenta a robustez da rede ao ruído, filtrando alterações pouco significativas para a análise.
No entanto, para preservar a versatilidade dos componentes da nossa biblioteca, optamos por não criar novos objetos e, em vez disso, acrescentar verificações aos algoritmos existentes. Começaremos pela propagação para frente da camada totalmente conectada, na qual foram feitas as alterações mais significativas no kernel do programa OpenCL.
Primeiro, foi adicionada uma verificação para identificar valores nulos nos dados analisados. Segundo, abandonamos o laço tradicional que percorria todos os dados de entrada dentro de uma única thread. Em seu lugar, os cálculos são distribuídos entre as threads do grupo de trabalho, e os resultados são somados depois que todas as threads concluem sua execução. Essa abordagem aumenta significativamente a eficiência e permite aproveitar melhor o paralelismo da GPU.
__kernel void FeedForward(__global const float *matrix_w, __global const float *matrix_i, __global float *matrix_o, const int inputs, const int activation ) { const int i = get_global_id(0); const int total_out = get_global_size(0); const int loc = get_local_id(1); const int total_loc = get_local_size(1);
O algoritmo do kernel começa pela obtenção do identificador global da thread atual. Cada neurônio da camada corresponde a um grupo de trabalho específico, cujas threads são agrupadas ao longo da primeira dimensão do espaço de tarefas. Isso permite calcular em paralelo as saídas de todos os neurônios.
Em seguida, determinamos o identificador local da thread dentro do grupo de trabalho e o número total de threads do grupo. Isso é importante para distribuir os cálculos entre várias threads e acelerar o processamento de grandes volumes de dados analisados.
O array local Temp é usado para armazenar as somas parciais dentro do grupo de trabalho. Ele permite coordenar as threads enquanto cada uma delas calcula sua parcela dos produtos entre entradas e pesos.
__local float Temp[LOCAL_ARRAY_SIZE];
Depois, criamos variáveis locais para acumular a soma (sum) e armazenar temporariamente o valor de entrada (inp). A variável shift determina o deslocamento na matriz de pesos correspondente ao neurônio atual. Como os pesos são armazenados em um array linear, esse deslocamento permite acessar corretamente os elementos associados a cada neurônio.
float sum = 0; float inp; int shift = RCtoFlat(i, 0, total_out, (inputs + 1), 1);
Concluída essa preparação, criamos o laço principal para calcular a soma dos produtos entre entradas e pesos. Mantemos esse laço como uma alternativa de segurança para processar tensores de entrada muito grandes, cujo tamanho exceda o limite permitido para o grupo de trabalho. Cada thread recebe seu próprio conjunto de entradas, permitindo distribuir os cálculos.
for(int k = loc; k < inputs; k += total_loc) { inp = IsNaNOrInf(matrix_i[k], 0.0f); if(inp == 0.0f) continue;
No corpo do laço, primeiro obtemos um valor do buffer de dados de entrada. A cada iteração, é lido o próximo sinal de entrada. Se o valor for zero, passamos imediatamente para a iteração seguinte, sem executar as demais operações. Dessa forma, evitamos tanto o acesso custoso à memória global para leitura dos pesos quanto a própria multiplicação. A verificação de valores nulos transforma o laço em um filtro inteligente, concentrando os cálculos apenas nos eventos relevantes, economizando recursos computacionais e acelerando a análise dos dados de mercado.
Quando recebemos um sinal significativo, acumulamos os produtos entre as entradas e os pesos.
sum += IsNaNOrInf(inp * matrix_w[shift + k], 0.0f); } if(loc == 0) sum += IsNaNOrInf(matrix_w[shift + inputs], 0.0f);
Após a conclusão de todas as iterações do laço, apenas uma thread do grupo de trabalho adiciona o bias. Em seguida, somamos todos os resultados parciais das threads dentro do grupo de trabalho usando o array local Temp. Isso garante a combinação correta dos resultados obtidos pelos cálculos paralelos.
sum = LocalSum(sum, 1, Temp); //--- if(loc == 0) matrix_o[i] = fActivation(sum, activation); }
Por fim, a função de ativação selecionada é aplicada à soma resultante.
Essa abordagem preserva a versatilidade da camada e permite integrá-la posteriormente a modelos clássicos e spiking mais complexos sem alterar a arquitetura da rede. Ao mesmo tempo, reduzimos o consumo de recursos e aceleramos o processamento de spikes, em que muitas entradas podem assumir valor zero a cada etapa, refletindo períodos de silêncio no mercado.
É importante observar que a introdução dos grupos de trabalho altera o espaço de tarefas usado na execução do kernel. Por isso, para que o objeto funcione corretamente, é necessário fazer alguns ajustes pontuais no método responsável por colocar o kernel na fila de execução.
bool CNeuronBaseOCL::feedForward(CNeuronBaseOCL *NeuronOCL) { if(CheckPointer(OpenCL) == POINTER_INVALID || CheckPointer(NeuronOCL) == POINTER_INVALID) return false; uint global_work_offset[2] = { 0, 0 }; uint global_work_size[2] = { Output.Total(), (uint)MathMin((NeuronOCL.Neurons() + 3) / 4, OpenCL.GetMaxLocalSize(1)) }; uint local_work_size[2] = { 1, global_work_size[1] }; //--- const int kernel = def_k_FeedForward; setBuffer(kernel, def_k_ff_matrix_w, NeuronOCL.getWeightsIndex()) setBuffer(kernel, def_k_ff_matrix_i, NeuronOCL.getOutputIndex()) setBuffer(kernel, def_k_ff_matrix_o, Output.GetIndex()) setArgument(kernel, def_k_ff_inputs, NeuronOCL.Neurons()) setArgument(kernel, def_k_ff_activation, (int)activation) //--- kernelExecuteLoc(kernel, global_work_offset, global_work_size, local_work_size) //--- return true; }
Adicionamos uma verificação semelhante de valores nulos ao kernel de propagação para frente da camada convolucional. Neste caso, porém, não utilizamos grupos de trabalho. Isso ocorre porque os kernels de convolução tendem a ser relativamente pequenos e, nessas condições, distribuir os cálculos entre várias threads não proporciona um ganho significativo de desempenho.
Ainda assim, a verificação de entradas nulas continua sendo extremamente importante. Ela permite evitar multiplicações desnecessárias e acessos à memória global mesmo nas camadas convolucionais, economizando recursos durante o processamento de grandes volumes de séries temporais financeiras.
__kernel void FeedForwardConv(__global const float *matrix_w, __global const float *matrix_i, __global float *matrix_o, const int inputs, const int step, const int window_in, const int window_out, const int activation ) { const size_t i = get_global_id(0); const int out = get_global_id(1); const size_t v = get_global_id(2); const size_t outputs = get_global_size(0); //--- const int shift_out = window_out * i; const int shift_in = step * i; //--- const int shift_var_in = v * inputs; const int shift_var_out = v * window_out * outputs; const int shift_var_w = v * window_out * (window_in + 1); //--- float sum = 0; float inp; //--- int shift = (window_in + 1) * out; int stop = (window_in <= (inputs - shift_in) ? window_in : (inputs - shift_in)); for(int k = 0; k < stop; k ++) { inp = IsNaNOrInf(matrix_i[shift_var_in + shift_in + k], 0.0f); if(inp == 0.0f) continue; sum += IsNaNOrInf(inp * matrix_w[shift_var_w + shift + k], 0.0f); } sum += IsNaNOrInf(matrix_w[shift_var_w + shift + window_in], 0.0f); //--- matrix_o[shift_var_out + out + shift_out] = fActivation(sum, activation);; }
Dessa forma, a verificação de entradas nulas torna-se um mecanismo de otimização versátil, aplicável tanto às camadas totalmente conectadas quanto às convolucionais, preservando a eficiência e a precisão do modelo.
Módulo Spike ResNeXt Bottleneck
Depois de otimizar os algoritmos existentes, retomamos a implementação das abordagens propostas pelos autores do framework SEW-ResNet. No entanto, faremos uma pequena adaptação em relação ao trabalho original e incorporaremos as soluções propostas à arquitetura ResNeXt.
A escolha da ResNeXt se deve às suas características. Ela combina as vantagens da ResNet, como blocos residuais e conexões shortcut, com o conceito de blocos ramificados, que permitem processar vários caminhos independentes em paralelo. Essa arquitetura oferece maior flexibilidade e melhora a capacidade da rede de extrair atributos.
A incorporação dos blocos SEW à ResNeXt abre novas possibilidades para o processamento de séries temporais financeiras. Os vários caminhos paralelos do modelo podem agora analisar diferentes aspectos dos dados. Alguns captam impulsos de curto prazo e picos de volatilidade, enquanto outros identificam tendências de longo prazo. O shortcut, mantido fora da ativação spiking, preserva a integridade do sinal, enquanto os limiares treináveis e os gates de acumulação do potencial de membrana permitem adaptar o modelo às condições específicas do mercado.
Assim, obtemos uma arquitetura híbrida que combina os conceitos do SEW-ResNet e da ResNeXt e é capaz de analisar séries temporais financeiras com profundidade e eficiência, reduzindo cálculos redundantes e aumentando a robustez ao ruído.
Começamos pela criação do módulo Bottleneck na classe CNeuronSpikeResNeXtBottleneck, que herda a funcionalidade do neurônio spiking básico com ativação CNeuronSpikeActivation. Esse objeto passa a constituir o bloco fundamental para a integração dos blocos SEW à arquitetura ResNeXt.
class CNeuronSpikeResNeXtBottleneck : public CNeuronSpikeActivation { protected: CLayer cBottleneck; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL) override; virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) override; virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL) override; public: CNeuronSpikeResNeXtBottleneck(void) {}; ~CNeuronSpikeResNeXtBottleneck(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint chanels_in, uint chanels_out, uint units_in, uint units_out, uint group_size, uint groups, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual int Type(void) override const { return defNeuronSpikeResNeXtBottleneck; } //--- methods for working with files virtual bool Save(int const file_handle) override; virtual bool Load(int const file_handle) override; //--- virtual void SetOpenCL(COpenCLMy *obj) override; //--- virtual bool WeightsUpdate(CNeuronBaseOCL *source, float tau) override; virtual void TrainMode(bool flag) override; virtual bool Clear(void) override; };
Dentro da classe, é definido o objeto cBottleneck, um array dinâmico preenchido com ponteiros para os componentes da arquitetura que formam o caminho principal de processamento dos dados.
A natureza dinâmica da arquitetura merece atenção especial. Sua estrutura não é fixa e depende inteiramente do conjunto de parâmetros especificados pelo usuário durante a inicialização. Por isso, abandonamos uma lista rígida de componentes internos e utilizamos um array dinâmico, formado dinamicamente durante a inicialização.
A inicialização propriamente dita é realizada no método Init. É nele que o array cBottleneck é criado e preenchido, formando bloco a bloco o caminho principal de processamento dos sinais e garantindo a flexibilidade da arquitetura do objeto.
bool CNeuronSpikeResNeXtBottleneck::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint chanels_in, uint chanels_out, uint units_in, uint units_out, uint group_size, uint groups, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronSpikeActivation::Init(numOutputs, myIndex, open_cl, chanels_out * units_out, optimization_type, batch)) return false;
A inicialização começa com a chamada do método homônimo da classe pai, que configura o neurônio spiking básico, definindo os parâmetros gerais da camada, o número de saídas e o modo de otimização. Em seguida, são criados ponteiros locais para os diferentes tipos de componentes.
CNeuronConvOCL *conv = NULL; CNeuronMultiWindowsConvOCL *mwconv = NULL; CNeuronBatchNormOCL *norm = NULL; CNeuronSpikeActivation *spike = NULL; CNeuronTransposeOCL* transp = NULL;
Concluída essa preparação, passamos à inicialização dos componentes internos. Primeiro, é montado o bloco de projeção dos dados de entrada, no qual o sinal é projetado para a representação interna do neurônio. Para isso, é criada e inicializada uma camada convolucional com os parâmetros definidos de canais, grupos e tamanho da sequência analisada.
cBottleneck.Clear(); cBottleneck.SetOpenCL(OpenCL); //--- Projection in uint index = 0; conv = new CNeuronConvOCL(); if(!conv || !conv.Init(0, index, OpenCL, chanels_in, chanels_in, group_size * groups, units_in, 1, optimization, iBatch) || !cBottleneck.Add(conv)) { DeleteObj(conv) return false; } conv.SetActivationFunction(SoftPlus);
Em seguida, adicionamos BatchNorm para normalizar os resultados e uma ativação spiking, que permite ao neurônio responder apenas a eventos relevantes, ignorando valores nulos associados ao ruído.
index++; norm = new CNeuronBatchNormOCL(); if(!norm || !norm.Init(0, index, OpenCL, conv.Neurons(), iBatch, optimization) || !cBottleneck.Add(norm)) { DeleteObj(norm) return false; } norm.SetActivationFunction(None); index++; spike = new CNeuronSpikeActivation(); if(!spike || !spike.Init(0, index, OpenCL, norm.Neurons(), optimization, iBatch) || !cBottleneck.Add(spike)) { DeleteObj(spike) return false; }
Depois, é montado o bloco Feature Extraction. Nele, são criados neurônios convolucionais multicanais destinados à execução de convoluções em grupo, conforme proposto pelos autores da arquitetura ResNeXt. Aos resultados da convolução são aplicadas, em sequência, a função de ativação SoftPlus, a normalização por lote e a ativação spiking.
//--- Feature Extraction index++; uint windows[]; if(ArrayResize(windows, groups) < (int)groups) return false; ArrayFill(windows, 0, groups, group_size); mwconv = CNeuronMultiWindowsConvOCL(); if(!mwconv || !mwconv.Init(0, index, OpenCL, windows, group_size, groups, units_in, optimization, iBatch) || !cBottleneck.Add(mwconv)) { DeleteObj(mwconv) return false; } mwconv.SetActivationFunction(SoftPlus); index++; norm = new CNeuronBatchNormOCL(); if(!norm || !norm.Init(0, index, OpenCL, mwconv.Neurons(), iBatch, optimization) || !cBottleneck.Add(norm)) { DeleteObj(norm) return false; } norm.SetActivationFunction(None); index++; spike = new CNeuronSpikeActivation(); if(!spike || !spike.Init(0, index, OpenCL, norm.Neurons(), optimization, iBatch) || !cBottleneck.Add(spike)) { DeleteObj(spike) return false; }
Esse bloco desempenha uma função fundamental. Ele extrai atributos do sinal analisado, formando padrões robustos e informativos que são então encaminhados pelos diferentes caminhos ramificados da ResNeXt. Dessa forma, o Feature Extraction permite ao modelo se concentrar em eventos relevantes e se adaptar aos diferentes padrões encontrados nas séries temporais financeiras.
A etapa seguinte é a projeção dos resultados. O sinal é agregado ao passar por uma camada convolucional e pelo BatchNorm.
//--- Projection out index++; conv = new CNeuronConvOCL(); if(!conv || !conv.Init(0, index, OpenCL, group_size * groups, group_size * groups, chanels_out, units_in, 1, optimization, iBatch) || !cBottleneck.Add(conv)) { DeleteObj(conv) return false; } conv.SetActivationFunction(SoftPlus); index++; norm = new CNeuronBatchNormOCL(); if(!norm || !norm.Init(0, index, OpenCL, conv.Neurons(), iBatch, optimization) || !cBottleneck.Add(norm)) { DeleteObj(norm) return false; } norm.SetActivationFunction(None);
Aqui, vale destacar a flexibilidade da arquitetura do objeto. É possível alterar na saída tanto a dimensionalidade dos atributos quanto o comprimento da sequência.
Se o comprimento da sequência permanecer inalterado, a inicialização do objeto termina nesse ponto. Nesse caso, a ativação final dos dados será realizada pela classe pai, garantindo consistência com os demais componentes da rede.
if(units_in == units_out) return true;
No entanto, se for necessário alterar o comprimento da sequência, precisamos adicionar mais alguns objetos para ajustar corretamente a forma dos tensores. Primeiro, adicionamos uma ativação spiking para preparar os valores obtidos na etapa anterior.
index++; spike = new CNeuronSpikeActivation(); if(!spike || !spike.Init(0, index, OpenCL, norm.Neurons(), optimization, iBatch) || !cBottleneck.Add(spike)) { DeleteObj(spike) return false; }
Em seguida, vem o bloco de projeção do comprimento da sequência. Inicialmente, os dados são transpostos de uma série temporal para uma sequência de atributos.
//--- Projection units index++; transp = new CNeuronTransposeOCL(); if(!transp || !transp.Init(0, index, OpenCL, units_in, chanels_out, optimization, iBatch) || !cBottleneck.Add(transp)) { DeleteObj(transp) return false; }
O bloco convolucional ajusta a dimensionalidade das sequências individuais de atributos ao tamanho especificado.
index++; conv = new CNeuronConvOCL(); if(!conv || !conv.Init(0, index, OpenCL, units_in, units_in, units_out, chanels_out, 1, optimization, iBatch) || !cBottleneck.Add(conv)) { DeleteObj(conv) return false; } conv.SetActivationFunction(SoftPlus);
Os valores obtidos são normalizados e, em seguida, é realizada a transposição inversa, devolvendo o tensor à representação de sequência temporal multimodal, agora com o comprimento ajustado.
index++; norm = new CNeuronBatchNormOCL(); if(!norm || !norm.Init(0, index, OpenCL, conv.Neurons(), iBatch, optimization) || !cBottleneck.Add(norm)) { DeleteObj(norm) return false; } norm.SetActivationFunction(None); index++; transp = new CNeuronTransposeOCL(); if(!transp || !transp.Init(0, index, OpenCL, chanels_out, units_out, optimization, iBatch) || !cBottleneck.Add(transp)) { DeleteObj(transp) return false; } //--- return true; }
Cada componente é adicionado ao array dinâmico cBottleneck e, caso ocorra qualquer erro durante a inicialização, o objeto é removido com segurança por meio de DeleteObj, evitando vazamentos de memória.
Como resultado, obtemos um módulo versátil e flexível, capaz de processar séries temporais financeiras, filtrar ruído, considerar eventos raros e operar com diferentes configurações do modelo. A arquitetura dinâmica também permite adaptar o neurônio a tarefas específicas, preservando a eficiência e a precisão da análise.
Durante a inicialização do objeto, construímos uma arquitetura complexa e estruturada, composta por diversos componentes: neurônios convolucionais, convoluções multicanais, normalização e ativação spiking. No entanto, o uso de um array dinâmico de ponteiros para os componentes internos permite ocultar toda essa complexidade ao implementar os algoritmos de propagação para frente e propagação reversa.
Apesar da diversidade das operações realizadas, o fluxo de dados permanece linear. O sinal percorre sequencialmente todos os blocos internos. Isso significa que, na propagação para frente, basta percorrer em um laço todos os componentes do array cBottleneck, passando o sinal sucessivamente de um bloco para o seguinte.
O método feedForward implementa o algoritmo de propagação para frente. No início de sua execução, são criadas duas variáveis:
- prev, que aponta para a origem do sinal de entrada,
- current, que quereceberáreceberá sucessivamente as referências de cada bloco do array.
Inicialmente, prev aponta para o objeto de dados de entrada recebido nos parâmetros do método.
bool CNeuronSpikeResNeXtBottleneck::feedForward(CNeuronBaseOCL *NeuronOCL) { CNeuronBaseOCL *prev = NeuronOCL; CNeuronBaseOCL *current = NULL;
Em seguida, todos os componentes do array dinâmico são percorridos em um laço. A cada iteração, current recebe a referência ao bloco correspondente, seja ele um neurônio convolucional, BatchNorm, uma convolução multicanal ou uma ativação spiking.
for(int i = 0; i < cBottleneck.Total(); i++) { current = cBottleneck[i]; if(!current || !current. Feedforward(prev)) return false; prev = current; }
Cada componente processa sequencialmente o sinal recebido do bloco anterior, executa seus cálculos internos e retorna o resultado. Se algum bloco estiver ausente ou ocorrer um erro durante o processamento, o método encerra imediatamente a execução e retorna false. Após o processamento bem-sucedido do bloco atual, o ponteiro prev é atualizado para apontar para esse bloco, permitindo que o sinal seja encaminhado ao próximo componente do array.
Ao final do laço, é chamado o método homônimo da classe pai, responsável pela ativação final dos dados. É nesse ponto que os spikes são gerados levando em conta os limiares treináveis e o acúmulo do potencial de membrana, permitindo que o neurônio responda apenas a eventos relevantes.
return CNeuronSpikeActivation::feedforward(prev);
}
Assim, mesmo com uma arquitetura complexa e estruturada, o fluxo de dados permanece linear e controlável. O uso do array dinâmico oculta a complexidade interna, simplifica a implementação do algoritmo e permite adicionar novos componentes ou modificar a arquitetura sem reescrever a propagação para frente.
Depois de examinarmos em detalhes o método feedForward, que garante a passagem sequencial e linear do sinal por todos os componentes internos do objeto, o passo seguinte é implementar a propagação reversa do erro. Nos modelos spiking, assim como nas redes neurais clássicas, a distribuição correta dos gradientes é fundamental. Ela permite que o modelo se adapte a eventos raros e filtre o ruído, algo particularmente importante para séries temporais financeiras. Nesse contexto, o método calcInputGradients desempenha um papel análogo ao da propagação para frente, mas com o sinal percorrendo a arquitetura no sentido inverso.
bool CNeuronSpikeResNeXtBottleneck::calcInputGradients(CNeuronBaseOCL *NeuronOCL) { if(!NeuronOCL) return false;
O método começa verificando se o ponteiro para o objeto de dados de entrada é válido, evitando o processamento de dados ausentes, o que poderia provocar erros ou atualizações incorretas dos pesos.
Em seguida, é determinado o bloco a partir do qual será iniciada a propagação reversa do sinal de erro. Se o array dinâmico cBottleneck contiver elementos, o processo começa pelo último bloco, pois ele corresponde ao ponto final da propagação para frente. Caso contrário, o sinal de erro é encaminhado diretamente ao neurônio de origem.
CNeuronBaseOCL *current = (cBottleneck.Total() > 0 ? cBottleneck[-1] : NeuronOCL); if(!CNeuronSpikeActivation::calcInputGradients(current)) return false;
Essa etapa é fundamental, pois garante que a propagação reversa comece no ponto correto, algo especialmente importante em arquiteturas complexas com várias transformações internas.
Em seguida, é chamado o método homônimo da classe pai, responsável por gerar os gradientes iniciais no nível da saída. Essa chamada leva em conta o potencial de membrana acumulado e os limiares de ativação treináveis, permitindo que o neurônio spiking responda corretamente aos eventos relevantes e ignore impulsos nulos ou associados ao ruído.
Depois, inicia-se um laço que percorre todos os elementos do array em ordem inversa. A cada iteração, o sinal de erro é passado do bloco atual para o anterior por meio da chamada ao método CalcHiddenGradients, que calcula os gradientes dos componentes ocultos.
for(int i = cBottleneck.Total() - 1; i >= 0; i--) { current = (i > 0 ? cBottleneck[i - 1] : NeuronOCL); if(!current || !current.CalcHiddenGradients(cBottleneck[i])) return false; } //--- result return true; }
Dessa forma, cada componente interno da arquitetura recebe corretamente o sinal de erro e pode atualizar seus parâmetros. Se algum bloco estiver ausente ou ocorrer uma falha no cálculo, o método encerra imediatamente a execução e retorna false.
É importante observar que, apesar da complexidade e da profundidade da arquitetura, o fluxo do erro permanece linear e controlável. O uso do array dinâmico oculta a complexidade interna e garante flexibilidade à implementação. Assim, é possível modificar ou expandir facilmente o modelo, adicionando novos componentes sem alterar o algoritmo de propagação reversa.
Isso é particularmente importante na análise de séries temporais financeiras, pois os sinais podem conter ruído, eventos raros e padrões complexos. A distribuição correta dos gradientes garante que o modelo seja treinado com eficiência, represente com precisão a dinâmica dos mercados e produza previsões confiáveis sobre movimentos futuros.
Com isso, o método calcInputGradients funciona como o reflexo lógico da propagação para frente, fornecendo o caminho de retorno do erro e permitindo controlar com precisão os parâmetros de todos os componentes do objeto. Ele mostra como uma arquitetura dinâmica e a passagem sequencial do sinal podem atuar em conjunto, ocultando a complexidade interna e permitindo que o modelo se adapte às informações mais diversas e imprevisíveis presentes nas séries temporais financeiras.
Módulo de conexões residuais
A etapa seguinte consiste na construção do módulo de conexões residuais. Durante a análise dos dados, a dimensionalidade do tensor pode mudar e, para que as operações subsequentes sejam executadas corretamente, é fundamental compatibilizar as dimensões dos tensores de saída dos dois fluxos paralelos de informação. Sem essa sincronização, não seria possível somar ou combinar corretamente os resultados do caminho principal e do caminho adicional do modelo, o que comprometeria a integridade das informações e afetaria a precisão das previsões.
O módulo de conexões residuais funciona como uma ponte, compatibilizando as dimensões e permitindo a passagem correta do sinal por toda a arquitetura. Para implementar o novo objeto, apesar de isso parecer pouco convencional, utilizamos como classe pai o CNeuronSpikeResNeXtBottleneck criado anteriormente. Sua capacidade de trabalhar com uma arquitetura dinâmica mostra-se especialmente adequada neste caso. Toda a funcionalidade é herdada pelo novo objeto, enquanto sua estrutura interna é definida simplesmente pelo preenchimento do array dinâmico com os componentes da nova configuração.
class CNeuronSpikeResNeXtResidual : public CNeuronSpikeResNeXtBottleneck { public: CNeuronSpikeResNeXtResidual(void) {}; ~CNeuronSpikeResNeXtResidual(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint chanels_in, uint chanels_out, uint units_in, uint units_out, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual int Type(void) override const { return defNeuronSpikeResNeXtResidual; } };
Assim, para criar um objeto de conexões residuais totalmente funcional, basta sobrescrever o método de inicialização.
bool CNeuronSpikeResNeXtResidual::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint chanels_in, uint chanels_out, uint units_in, uint units_out, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronSpikeActivation::Init(numOutputs, myIndex, open_cl, chanels_out * units_out, optimization_type, batch)) return false;
O método Init constrói a arquitetura interna exclusivamente por meio do array dinâmico, preenchendo-o com componentes que compatibilizam as dimensões dos tensores na saída dos fluxos paralelos. Primeiro, é chamado o método de inicialização da classe pai comum CNeuronSpikeActivation, preparando o objeto para funcionar.
Em seguida, o array dinâmico cBottleneck, herdado da classe pai, é limpo e recebe uma referência ao objeto OpenCL responsável pelos cálculos. Todos os componentes posteriores serão adicionados a esse array, formando um caminho de processamento linear, mas configurável dinamicamente.
Se o número de canais de entrada e saída for diferente, é criada uma camada convolucional para a projeção dos canais, à qual é imediatamente atribuída a função de ativação SoftPlus.
CNeuronConvOCL *conv = NULL; CNeuronBatchNormOCL *norm = NULL; CNeuronSpikeActivation *spike = NULL; CNeuronTransposeOCL* transp = NULL; //--- cBottleneck.Clear(); cBottleneck.SetOpenCL(OpenCL); //--- Projection Chanels uint index = 0; if(chanels_in != chanels_out) { conv = new CNeuronConvOCL(); if(!conv || !conv.Init(0, index, OpenCL, chanels_in, chanels_in, chanels_out, units_in, 1, optimization, iBatch) || !cBottleneck.Add(conv)) { DeleteObj(conv) return false; } conv.SetActivationFunction(SoftPlus); index++; }
Depois, caso seja necessário alterar o comprimento da sequência, são adicionados os componentes responsáveis pela projeção dessa dimensão. Antes disso, quando necessário, acrescentamos a normalização e a ativação spiking para a projeção da dimensionalidade dos atributos.
//--- Projection Units if(units_in != units_out) { if(chanels_in != chanels_out) { norm = new CNeuronBatchNormOCL(); if(!norm || !norm.Init(0, index, OpenCL, conv.Neurons(), iBatch, optimization) || !cBottleneck.Add(norm)) { DeleteObj(norm) return false; } norm.SetActivationFunction(None); index++; spike = new CNeuronSpikeActivation(); if(!spike || !spike.Init(0, index, OpenCL, norm.Neurons(), optimization, iBatch) || !cBottleneck.Add(spike)) { DeleteObj(spike) return false; } index++; }
Em seguida, transpomos a representação dos dados e adicionamos uma camada convolucional com ativação SoftPlus, cuja função é ajustar a dimensionalidade da sequência temporal.
transp = new CNeuronTransposeOCL(); if(!transp || !transp.Init(0, index, OpenCL, units_in, chanels_out, optimization, iBatch) || !cBottleneck.Add(transp)) { DeleteObj(transp) return false; } index++; conv = new CNeuronConvOCL(); if(!conv || !conv.Init(0, index, OpenCL, units_in, units_in, units_out, chanels_out, 1, optimization, iBatch) || !cBottleneck.Add(conv)) { DeleteObj(conv) return false; } conv.SetActivationFunction(SoftPlus); index++; norm.SetActivationFunction(None); index++; transp = new CNeuronTransposeOCL(); if(!transp || !transp.Init(0, index, OpenCL, chanels_out, units_out, optimization, iBatch) || !cBottleneck.Add(transp)) { DeleteObj(transp) return false; } index++; }
Depois disso, é realizada a transposição inversa dos dados.
Essa abordagem permite compatibilizar as dimensões dos tensores e garantir a passagem correta do sinal por toda a estrutura.
Ao final da inicialização, é adicionada uma camada BatchNorm, que estabiliza os sinais de saída e os prepara para a ativação spiking subsequente realizada pela classe pai comum.
norm = new CNeuronBatchNormOCL(); if(!norm || !norm.Init(0, index, OpenCL, Neurons(), iBatch, optimization) || !cBottleneck.Add(norm)) { DeleteObj(norm) return false; } //--- return true; }
Dessa forma, o método Init transforma um conjunto complexo de operações de compatibilização de dimensões e integração das conexões residuais em um processo claro e controlável. A estrutura dinâmica permite criar modelos com diferentes parâmetros, preservando o fluxo linear do sinal e garantindo a formação correta dos atributos mesmo nas séries temporais financeiras mais complexas.
Hoje realizamos uma etapa importante e avançamos significativamente na implementação do framework. Agora é um bom momento para fazer uma breve pausa e consolidar os conceitos apresentados. O código completo de todos os objetos descritos está disponível no anexo. Retomaremos o trabalho no próximo artigo para continuar o estudo aprofundado das possibilidades do SEW-ResNet e a incorporação de suas abordagens aos modelos financeiros.
Conclusão
Ao longo do artigo, examinamos em detalhes a implementação, em MQL5, dos principais componentes do framework SEW-ResNet. Analisamos a propagação para frente e a propagação reversa do Bottleneck, adicionamos verificações de valores nulos, otimizamos os algoritmos computacionais e construímos um módulo de conexões residuais capaz de compatibilizar as dimensões dos tensores em uma arquitetura dinâmica complexa. O uso de arrays dinâmicos permitiu ocultar a complexidade interna e garantir flexibilidade no processamento de fluxos de dados com diferentes estruturas.
As soluções implementadas demonstram como abordagens spiking modernas podem ser integradas a modelos financeiros, proporcionando extração precisa e eficiente de atributos, filtragem de eventos raros e treinamento consistente com dados históricos.
O código completo de todos os objetos descritos está disponível no anexo, permitindo reproduzir e ampliar os métodos propostos. Demos um passo importante rumo à aplicação prática do SEW-ResNet na análise de séries temporais financeiras e estabelecemos uma base para novos experimentos. No próximo artigo, continuaremos o estudo, incorporando novos recursos do framework e aprofundando nossa compreensão de seu potencial para trading.
Referências
Programas usados 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 de descrição do estado do sistema e da arquitetura dos modelos |
| 5 | NeuroNet.mqh | Biblioteca de classes | Biblioteca de classes para criação de rede neural |
| 6 | NeuroNet.cl | Biblioteca | Biblioteca de código do programa OpenCL |
Traduzido do russo pela MetaQuotes Ltd.
Artigo original: https://www.mql5.com/ru/articles/19870
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.
Caminhe em novos trilhos: Personalize indicadores no MQL5
Algoritmo dos Macacos-Azuis — Blue Monkey (BM) Algorithm
Está chegando o novo MetaTrader 5 e MQL5
Desenvolvendo um EA Dinâmico para Múltiplos Pares (Parte 2): Diversificação e Otimização de Portfólio
- 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