Redes neurais em trading: Pipeline inteligente de previsões (Mistura esparsa de especialistas)
Introdução
Os mercados financeiros são um sistema complexo, caótico e de alta frequência. Aqui não funcionam aproximações grosseiras nem valores médios. Cada vela, cada movimento, é resultado de múltiplos fatores, desde notícias fundamentais até trading impulsivo. É justamente por isso que, para trabalhar com séries temporais de mercado, é necessária uma abordagem especial: sensível aos microdetalhes, resistente ao ruído e capaz de enxergar estrutura por trás do caos.
O framework Time-MoE propõe exatamente essa arquitetura. Não se trata apenas de um transformer adaptado para séries temporais. É um sistema integral no qual cada passo temporal do histórico analisado é considerado um token único. Esses tokens passam por uma sequência de transformações, preservando sua individualidade e contexto temporal. Essa abordagem permite ao modelo trabalhar com dados de alta frequência e identificar padrões inacessíveis na agregação tradicional.
A primeira etapa do processamento é a camada de incorporação, na qual os dados passam por transformações não lineares. Isso ajuda a capturar dependências complexas entre características, seja a relação entre preço e volume, a direção dos indicadores ou a força do último movimento. A representação oculta obtida torna-se a base para a análise subsequente.
Em seguida, os tokens são enviados para uma série de blocos Transformer. Aqui o modelo olha para trás, formando uma representação do momento atual com base na experiência acumulada. Para isso, é utilizado o mecanismo de atenção, no qual cada token é comparado com os anteriores, e a importância de determinados elementos é determinada não manualmente, mas pelo próprio modelo durante o treinamento. Isso permite considerar tanto impulsos de curto prazo quanto tendências de longo prazo.
Uma atenção especial no Time-MoE é dedicada à resistência ao ruído. Para isso, a arquitetura incorpora mecanismos de normalização que ajudam a suavizar valores atípicos ou valor aberrante aleatórios e reforçar sinais significativos. Como resultado, a atenção não se concentra em anomalias isoladas, mas se distribui de forma mais uniforme e significativa, o que é especialmente importante em condições de volatilidade e ruídos de mercado.
A principal diferença do Time-MoE em relação aos transformers clássicos é o uso da mistura esparsa de especialistas (Mixture-of-Experts, MoE). Em cada bloco, o roteador seleciona apenas parte dos especialistas disponíveis que realmente participam do processamento do token atual. Essa solução permite reduzir drasticamente a carga computacional e escalar o modelo sem crescimento exponencial de recursos. Além disso, no Time-MoE está previsto um especialista comum que permanece sempre ativo e garante a estabilidade do modelo diante de rotas incorretas.
A etapa final é a previsão. Aqui o modelo forma imediatamente várias previsões em diferentes horizontes. Essa abordagem permite considerar ao mesmo tempo sinais de curto prazo e tendências de longo prazo, oferecendo ao trader ou ao sistema analítico uma ampla faixa de cenários. Durante o treinamento, o modelo aprende em todos os horizontes ao mesmo tempo, o que o torna mais flexível e mais adaptável às condições de mercado em mudança.
Assim, Time-MoE é:
- um modelo sensível aos detalhes, trabalhando com tokens pontuais;
- resistente ao ruído e a valores atípicos ou valor aberrante graças à normalização da atenção;
- uma arquitetura escalável com uso esparso de especialistas;
- um mecanismo universal de previsão em vários horizontes.
A visualização autoral do framework Time-MoE é apresentada abaixo.

Hoje continuaremos o trabalho iniciado anteriormente e focaremos no elemento-chave do framework Time-MoE, mistura esparsa de especialistas (Sparse Mixture of Experts). Se na parte anterior nós, passo a passo, construímos a base do modelo, formando tokens e representações ocultas com a ajuda de incorporações SwiGLU, agora chegou a vez de passar para o destaque arquitetural, do qual em grande parte dependem a eficiência e a escalabilidade de todo o sistema.
Neste artigo, analisaremos detalhadamente como é organizado o funcionamento do grupo de especialistas e de que maneira os cálculos são distribuídos. Não vamos apenas descrever o esquema teórico, vamos passar para a implementação real do MoE esparso por meio do MQL5, com ênfase nos aspectos práticos.
Projeção da arquitetura
Antes de partir para a implementação direta do algoritmo de mistura esparsa de especialistas, vamos parar um pouco e refletir. Como antes, continuamos adeptos da ideia de trabalho paralelo de todos os especialistas, uma abordagem que se encaixa perfeitamente no paradigma de computação massiva. Por isso, a carga principal, sem hesitar, será levada para o contexto OpenCL. No entanto, aqui surge uma questão importante: de que maneira, exatamente, organizar a ativação esparsa dos especialistas, mantendo a eficiência e a capacidade de treinamento do modelo?
Primeiro, vamos relembrar o ponto-chave. No artigo original, os autores do Time-MoE propuseram uma construção de especialistas que difere de forma perceptível do bloco FeedForward clássico, conhecido por nós na arquitetura Transformer. Em particular, a primeira camada nos especialistas foi substituída por uma transformação SwiGLU. Essa mudança é totalmente justificável: SwiGLU, como já nos certificamos anteriormente, dá ao modelo mais flexibilidade no processamento das características e captura melhor as dependências não lineares. Felizmente, a implementação dessa camada já está em nossas mãos, no artigo anterior desenvolvemos o componente CNeuronSwiGLUOCL. E agora podemos usá-lo com segurança como a primeira etapa da nossa mistura de especialistas, escalando o número de filtros na saída pelo número de submodelos paralelos.
Cada filtro possui seus próprios parâmetros treináveis. Na prática, esse é o modelo especialista individual. Se agrupamos as saídas pelo número de especialistas, reproduziremos a estrutura de que precisamos: uma entrada, muitos especialistas independentes trabalhando em paralelo. Na segunda etapa do processamento, podemos aplicar convolução multi-janela, que já nos é conhecida de trabalhos anteriores. Essa camada ajudará a combinar informações dentro de cada especialista e prepará-las para a agregação.
Até este ponto, tudo parece bastante lógico. Mas há uma ressalva importante: no algoritmo descrito está ausente o elemento principal, a esparsidade. Sim, podemos multiplicar as saídas de todos os especialistas pela máscara de ativação e obter um resultado correto. No entanto, todos os especialistas continuam trabalhando, ainda que atenuada, o que praticamente elimina qualquer ganho potencial de desempenho. Esse esquema é aceitável em modelos pequenos, mas torna-se ineficiente ao escalar: o aumento do número de especialistas, o crescimento das dimensões, a ampliação da profundidade da rede, tudo isso eleva drasticamente a carga computacional.
É justamente aqui que chegamos à questão central: em que ponto do algoritmo e de que maneira devemos introduzir a esparsidade?
À primeira vista, pode parecer que a solução ideal seria desativar os especialistas não utilizados em todos os níveis, pois isso reduziria drasticamente o volume de cálculos. No entanto, aqui se esconde um problema muito mais sutil e importante. A própria ideia MoE consiste em que diferentes especialistas sejam treinados em diferentes subtarefas. Ao mesmo tempo, cada um desenvolve sua própria especialização. Mas quem determina quais especialistas estão ativos em uma situação específica? A resposta é o roteador, que, com base nos dados brutos, seleciona um subconjunto de modelos para ativação.
O problema surge se o roteador, cedo demais, passa a se concentrar apenas em um pequeno conjunto de especialistas e começa a utilizá-los de forma generalizada, independentemente do contexto. Esse modelo pode apresentar bons resultados nas fases iniciais, mas perderá a capacidade de adaptação, pois nunca experimenta rotas alternativas. Especialistas não utilizados, nesse caso, simplesmente não têm a oportunidade de provar sua eficácia em outros cenários. Surge o efeito conforto local: o roteador conhece apenas seus escolhidos e, sem testar outros, fecha-se no mesmo padrão. Isso leva à redução da diversidade do modelo e à degradação de sua capacidade de generalização.
Assim, nosso objetivo é treinar o roteador não apenas para selecionar especialistas, mas para adaptar a estratégia de escolha de acordo com as características dos dados brutos. Precisamos criar condições nas quais o modelo explorar o comportamento de outros especialistas, mesmo que inicialmente sejam menos eficientes. Somente assim ele poderá aprender uma distribuição de tarefas mais flexível, aumentando a precisão geral e a resistência às mudanças de mercado.
Na busca pelo equilíbrio entre eficiência computacional e completude do treinamento, foi tomada a decisão de implementar o uso esparso de especialistas apenas na segunda camada do bloco MoE, combinando essa camada com o processo de agregação dos resultados. Essa abordagem simplifica a arquitetura, reduz a carga computacional e, ao mesmo tempo, preserva a flexibilidade necessária para o treinamento do roteador.
O ponto-chave dessa construção torna-se o mecanismo de propagação do gradiente de erro. Nós conscientemente abandonamos a ideia de treinar diretamente especialistas não utilizados, pois a essência do MoE é justamente especializar diferentes especialistas para diferentes tarefas. No entanto, para que o modelo não fique preso ao mesmo conjunto de especialistas, nós direcionamos o gradiente ao roteador, fornecendo a ele um sinal sobre a possível ineficiência da escolha atual. Isso permite treinar o próprio algoritmo de roteamento: na próxima vez, ele poderá ativar um conjunto diferente de especialistas se o atual tiver produzido uma previsão malsucedida.
Assim, mesmo que em um caso específico apenas dois especialistas tenham sido ativados, o roteador fica sabendo que outros também poderiam ter sido escolhidos. Isso não é apenas um recurso técnico, mas um poderoso instrumento de adaptação, permitindo que o modelo aprenda gradualmente a relação entre o caráter dos dados brutos e a configuração ideal de especialistas ativos.
Convolução com janelas mascaradas
As abordagens principais estão definidas e agora, arregaçando as mangas, passamos da teoria à prática. Na primeira etapa da implementação, teremos que desenvolver o componente-chave, o objeto da segunda camada do MoE, responsável pela ativação esparsa dos especialistas e pela agregação de seus resultados. Ele se tornará o elo central do modelo: por ele passarão os dados de todos os especialistas, mas em cada passagem específica apenas alguns deles serão ativados.
A seleção dos especialistas ativos foi separada em um módulo independente, o roteador. Sua tarefa é analisar os dados brutos e gerar uma máscara de ativação que determina quais especialistas devem ser envolvidos para cada token. Essa máscara é transmitida para a entrada desse objeto junto com o tensor principal das características analisadas, definindo a configuração dos cálculos ativos na etapa atual.
Uma premissa importante: todos os especialistas possuem a mesma arquitetura e retornam resultados de dimensionalidade fixa. Ao mesmo tempo, em cada instante apenas um número limitado de modelos está ativo, geralmente significativamente menor que a dimensionalidade do espaço de saída. Os demais especialistas permanecem em modo inativo, não participam dos cálculos e não consomem recursos.
Essa abordagem garante imediatamente duas vantagens fundamentais:
- Redução significativa da carga computacional, algo criticamente importante ao escalar modelos e aumentar o número de especialistas.
- Aumento da especialização: cada especialista pode se concentrar no estudo de seu subespaço restrito de tarefas. Como resultado, o modelo forma verdadeiros conhecimentos especializados, mas distribuídos entre os participantes.
O principal volume de cálculos foi transferido para o contexto OpenCL, o que permite utilizar de forma máxima o paralelismo em GPU e outros dispositivos compatíveis. No centro da nossa atenção está o kernel de propagação para frente da segunda camada MoE, que implementa a ativação esparsa dos especialistas selecionados e a agregação de seus resultados.
Nos parâmetros do kernel, recebemos os pesos de todos os especialistas, os dados brutos, a máscara de ativação e vários parâmetros que definem a estrutura das janelas e as dimensionalidades.
__kernel void FeedForwardMaskMultWinConv(__global const float *matrix_w, __global const float *matrix_i, __global const float *masks, __global float *matrix_o, const int inputs, const int window_in, const int windows_total, const int activation ) { const size_t u = get_global_id(0); const size_t w = get_global_id(1); const size_t v = get_global_id(2); const size_t units = get_global_size(0); const size_t window_out = get_global_size(1); const size_t variables = get_global_size(2);
Cada fluxo computacional corresponde a um elemento específico do tensor de resultados, pela posição na sequência, elemento do token e variável. Esse endereçamento pontual garante um alto nível de paralelismo.
É importante destacar que fizemos previamente uma premissa: o número de especialistas ativos em cada passagem é significativamente menor que a dimensionalidade do token no tensor de resultados. Esse é o ponto-chave que determina a lógica de distribuição de tarefas dentro do kernel. Dividimos o espaço de tarefas pelos elementos do token, enquanto os especialistas ativos são percorridos ciclicamente dentro do próprio kernel. Essa abordagem permite utilizar de forma eficiente os recursos computacionais, ao mesmo tempo em que preserva a esparsidade da ativação dos especialistas.
No corpo do kernel, identificamos o fluxo de operações no espaço de tarefas e determinamos o deslocamento nos buffers de dados até os elementos analisados.
const int shift_in = u * window_in * windows_total; const int shift_in_var = v * units * window_in * windows_total; const int shift_out = (u + v * units) * window_out + w; const int shift_mask = (u + v * units) * windows_total; const int shift_weight = (v * window_out * windows_total + w) * (window_in + 1); const int step_weight = window_out * (window_in + 1);
Em seguida, organizamos o sistema de laços. O laço externo percorre todos os especialistas e verifica seu status por meio da máscara de ativação. Se o especialista não estiver ativo na janela atual, sua contribuição é ignorada, o que elimina cálculos desnecessários e economiza recursos. Para os especialistas ativos, é realizado o cálculo da soma ponderada sobre os dados brutos com a adição do viés. Os valores finais são acumulados e, em seguida, passam pela função de ativação.
float sum = 0; for(int w_in = 0; w_in < windows_total; w_in++) { float m = IsNaNOrInf(masks[shift_mask + w_in], 0); if(m < FLT_EPSILON) continue; const int shift_in_loc = shift_in + w_in * window_in; const int shift_weight_loc = shift_weight + w_in * step_weight; for(int i = 0; i < window_in; i++) if((shift_in_loc + i) < (inputs / variables)) sum += IsNaNOrInf(matrix_i[shift_in_var + shift_in_loc + i], 0) * matrix_w[shift_weight_loc + i] * m; sum += matrix_w[shift_weight_loc + window_in] * m; }
Observe que, no processo de agregação, não apenas somamos as saídas dos especialistas ativos, mas multiplicamos cada uma delas pelo valor correspondente da máscara. No caso de mascaramento binário, em que a máscara contém apenas zeros e uns, isso não agrega valor. No entanto, essa abordagem nos mantém uma opção importante: organizar uma agregação ponderada. Se a máscara contiver não apenas indicadores, mas coeficientes de peso reais, poderemos controlar a contribuição de cada especialista no resultado, inclusive permitindo uma seleção suave e um roteamento probabilístico.
Os valores obtidos passam pela função de ativação e são armazenados no buffer de resultados.
matrix_o[shift_out] = Activation(sum, activation);
}
Após a implementação da propagação para frente, na qual os especialistas ativos processam seus subespaços de características e os resultados são ponderados pela máscara, passamos para a segunda fase importante, a propagação reversa do gradiente de erro. Nessa etapa, precisamos transmitir cuidadosamente o erro não apenas aos dados brutos de cada modelo ativo, mas também à própria máscara de ativação, para que ela possa ser ajustada durante o treinamento.
Para resolver essa tarefa, desenvolvemos o kernel OpenCL CalcHiddenGradientMaskMultWinConv, que processa tudo isso dentro do conceito de computação paralela.
__kernel void CalcHiddenGradientMaskMultWinConv(__global const float *matrix_w, __global const float *matrix_i, __global float *matrix_ig, __global const float *matrix_og, __global const float *masks, __global float *masks_g, const int outputs, const int window_in, const int window_out, const int activation ) { const size_t u = get_global_id(0); const size_t w_in = get_global_id(1); const size_t v = get_global_id(2); const size_t units = get_global_size(0); const size_t windows_total = get_global_size(1); const size_t variables = get_global_size(2);
O kernel recebe os pesos, os dados brutos, os gradientes de erro no nível dos resultados, a máscara, bem como os buffers para registrar os gradientes dos dados brutos e da máscara. Cada fluxo de operações no lado OpenCL é responsável por uma combinação específica de token, especialista e variável. E, no corpo do kernel, imediatamente identificamos o fluxo de operações em todas as dimensões do espaço de tarefas. Em seguida, determinamos os deslocamentos nos buffers de dados.
const int shift_in = (u + v * units) * window_in * windows_total + w_in * window_in; const int shift_out = u * window_out; const int shift_out_var = v * units * window_out; const int shift_mask = (u + v * units) * windows_total + w_in; const int shift_weight = (v * window_out * windows_total + w_in * window_out) * (window_in + 1);
Na primeira etapa, distribuímos o gradiente de erro até o nível dos dados brutos. Aqui, o trabalho começa com a verificação da máscara: se o especialista correspondente naquele token não estava ativo, então valores nulos são simplesmente gravados no buffer de gradientes dos dados brutos e os recursos computacionais não são desperdiçados. Se, por outro lado, o especialista participou do cálculo, inicia-se a propagação do gradiente.
const float m = IsNaNOrInf(masks[shift_mask], 0); for(int i = 0; i < window_in; i++) { float sum = 0; if(m >= FLT_EPSILON) { for(int out = 0; out < window_out; out++) { if((shift_out + out) >= (outputs / variables)) continue; sum += IsNaNOrInf(matrix_og[shift_out_var + shift_out + out] * matrix_w[shift_weight + out * (window_in + 1) + i] * m, 0); sum += IsNaNOrInf(matrix_w[shift_weight + out * (window_in + 1) + window_in] * m, 0); } } matrix_ig[shift_in + i] = Deactivation(sum, matrix_i[shift_in + i], activation); }
Primeiro, o kernel itera por todos os canais de saída, calculando a contribuição de cada elemento dos dados brutos no erro final. Os valores obtidos são ajustados pela derivada da função de ativação da camada dos dados brutos e armazenados no elemento correspondente do buffer global de dados.
Em seguida, é executada a segunda etapa, distribuição do gradiente de erro pela máscara. Aqui agregamos a contribuição de todos os canais no nível dos resultados desse especialista, considerando a influência do especialista atual no resultado, e formamos um sinal de retroalimentação que indica o quão útil foi a escolha desse especialista.
float sum = 0; for(int out = 0; out < window_out; out++) { int shift_weight_loc = out * (window_in + 1) + shift_weight; float temp = matrix_w[shift_weight_loc + window_in]; for(int i = 0; i < window_in; i++) temp += IsNaNOrInf(matrix_i[shift_in + i], 0) * matrix_w[shift_weight_loc + i]; sum += IsNaNOrInf(temp * matrix_og[shift_out_var + shift_out + out], 0); } masks_g[shift_mask] = IsNaNOrInf(sum, 0); }
Mesmo que a máscara seja binária, esse valor ajuda o roteador a fazer escolhas mais fundamentadas no futuro e, se necessário, pode ser interpretado como uma estimativa de peso em uma ativação ponderada.
Graças a esse mecanismo, obtemos uma arquitetura verdadeiramente treinável, na qual cada decisão de roteamento pode ser ajustada por gradiente, e especialistas adormecidos podem despertar caso surja necessidade. Isso permite que a mistura esparsa de especialistas funcione de forma eficiente, flexível e realmente inteligente.
A próxima etapa criticamente importante é a atualização dos parâmetros do modelo. É aqui que o modelo aprende, ajustando seus pesos com base no sinal de erro recebido. Para implementar esse processo, utilizamos um kernel OpenCL separado UpdateWeightsMaskMultWinConvAdam, adaptado à especificidade da mistura esparsa de especialistas e com suporte à otimização pelo método Adam.
O principal volume de cálculos, como antes, é transferido para o contexto OpenCL, pois cada peso envolvido no treinamento pode ser processado de forma independente.
__kernel void UpdateWeightsMaskMultWinConvAdam(__global float *matrix_w, __global const float *matrix_og, __global const float *matrix_i, __global const float *masks, __global float *matrix_m, __global float *matrix_v, const int windows_total, const int inputs, const int outputs, const float l, const float b1, const float b2 ) { const size_t id_in = get_global_id(0); const size_t id_out = get_global_id(1); const size_t id_v = get_global_id(2); const size_t window_in = get_global_size(0) / windows_total - 1; const size_t window_out = get_global_size(1); const size_t variables = get_global_size(2);
O funcionamento do kernel é organizado em três coordenadas: id_in é responsável pela dimensionalidade de entrada e pela posição na janela, id_out pelo índice do filtro de saída, e id_v pela variável atual no lote. Essa organização tridimensional cobre completamente todos os parâmetros dos especialistas e permite processá-los em paralelo.
Aqui é importante observar que utilizamos conscientemente uma premissa estrutural: todos os especialistas trabalham com as mesmas janelas de análise, e os tensores de resultados têm a mesma forma. Isso significa que todo o sistema pode ser reduzido a uma matriz bidimensional: de um lado, o número de filtros, do outro, o número total de elementos nas janelas de análise, combinados de todos os especialistas. Essa simplificação permite endereçar os pesos na memória de forma eficiente e compacta, algo especialmente crítico durante a atualização massiva de parâmetros.
Dentro do kernel, primeiro identificamos cada fluxo de operações em todas as dimensões do espaço de tarefas. Em seguida, determinamos o deslocamento em todos os buffers de dados até os elementos analisados.
const int w_id = id_in / (window_in + 1); const int shift_in = id_in - w_id; const int step_in = window_in * windows_total; const int units = outputs / window_out; const int shift_in_var = id_v * inputs; const int shift_out_var = id_v * outputs; const int shift_mask_var = id_v * units * windows_total; const int shift_weight = ((id_v * windows_total + w_id) * window_out + id_out) * (window_in + 1) + id_in % (window_in + 1); const bool bias = (id_in % (window_in + 1) == window_in);
Depois disso, percorremos sequencialmente todos os tokens (units) com os quais os especialistas trabalharam. Para cada um desses fragmentos, verificamos, utilizando a máscara, se o especialista correspondente estava ativo.
float grad = 0; for(int u = 0; u < units; u++) { const int shift_in_loc = shift_in + u * step_in; if(shift_in < inputs) continue; float m = IsNaNOrInf(masks[shift_mask_var + u * windows_total + w_id], 0); if(m < FLT_EPSILON) continue; float inp = (bias ? 1 : IsNaNOrInf(matrix_i[shift_in_var + shift_in_loc], 0)); grad += IsNaNOrInf(inp * m * matrix_og[shift_out_var + u * window_out + id_out], 0); }
Se o especialista em determinada etapa estava em modo inativo, isto é, a máscara é próxima de zero, não gastamos recursos no cálculo do gradiente de erro e passamos para o próximo token. Essa é uma das propriedades-chave do treinamento esparso.
Para os especialistas ativos, calculamos o gradiente de erro: os dados brutos são multiplicados pelo valor do erro no nível do tensor de resultados (matrix_og) e pela máscara.
float mt = IsNaNOrInf(clamp(b1 * matrix_m[shift_weight] + (1 - b1) * grad, -1.0e5f, 1.0e5f), 0); float vt = IsNaNOrInf(clamp(b2 * matrix_v[shift_weight] + (1 - b2) * pow(grad, 2), 1.0e-6f, 1.0e6f), 1.0e-6f); float weight = clamp(matrix_w[shift_weight] + IsNaNOrInf(l * mt / sqrt(vt), 0), -MAX_WEIGHT, MAX_WEIGHT);
Em seguida, aplicamos a etapa de otimização pelo algoritmo Adam: atualizamos os momentos de primeira ordem (mt) e de segunda ordem (vt), normalizamos o gradiente e ajustamos o valor do peso. Todas as atualizações passam pela função clamp, que limita o valor dos pesos dentro do intervalo permitido.
Os valores obtidos são armazenados nos buffers globais.
matrix_w[shift_weight] = weight; matrix_m[shift_weight] = mt; matrix_v[shift_weight] = vt; }
Assim, cada peso é atualizado estritamente de acordo com sua contribuição no resultado final, e somente se tiver participado de cálculos reais. Essa organização garante alta eficiência computacional e também torna possível o treinamento de especialistas altamente especializados, que não apenas adormecidos em momentos inadequados, mas aprendem ativamente quando seu conhecimento é realmente necessário.
Agora que a lógica computacional no lado OpenCL está totalmente implementada, passamos para a próxima etapa, a organização de todo o processo no âmbito do programa principal. É aqui que é criado e configurado o objeto responsável por chamar os kernels correspondentes e trabalhar com as máscaras. Esse papel é assumido pela classe especializada CNeuronMaskMultiWinConv, herdada de CNeuronConvOCL. A estrutura do novo objeto é apresentada abaixo.
class CNeuronMaskMultiWinConv : public CNeuronConvOCL { protected: uint iWindowsTotal; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL) override { return false; } virtual bool feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput) override; virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) override { return false; } virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL, CBufferFloat *second)override; virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL) override { return false; } virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput, CBufferFloat *SecondGradient, ENUM_ACTIVATION SecondActivation = None) override; public: CNeuronMaskMultiWinConv(void) {}; ~CNeuronMaskMultiWinConv(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint windows_total, uint window_out, uint units_count, uint variables, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual int Type(void) override const { return defNeuronMaskMultiWinConv; } //--- methods for working with files virtual bool Save(int const file_handle) override; virtual bool Load(int const file_handle) override; };
Ele se torna o elo de ligação entre o modelo e o circuito computacional de baixo nível. Sua tarefa é preparar corretamente os dados, transferi-los para o contexto OpenCL, executar o kernel necessário e receber de volta os resultados.
Na estrutura do objeto CNeuronMaskMultiWinConv, evitamos deliberadamente declarar novos componentes internos. Tudo o que é necessário já é fornecido pela classe pai CNeuronConvOCL, e isso é totalmente suficiente para organizar os cálculos no lado OpenCL. Essa decisão permite manter a arquitetura limpa e livre de redundâncias, e o gerenciamento de todos os recursos, centralizado.
A inicialização da camada é realizada no método sobrescrito Init, onde ajustamos apenas os parâmetros-chave, sem afetar a lógica geral.
bool CNeuronMaskMultiWinConv::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint windows_total, uint window_out, uint units_count, uint variables, ENUM_OPTIMIZATION optimization_type, uint batch) { uint win = window * windows_total + MathMax(windows_total, 1) - 1; if(!CNeuronConvOCL::Init(numOutputs, myIndex, open_cl, win, win, window_out, units_count, variables, optimization_type, batch)) return false; iWindowsTotal = windows_total; iWindow = window; //--- return true; }
Em particular, definimos o valor de iWindowsTotal, que determina o número de especialistas paralelos. Esse valor será utilizado na formação de deslocamentos e no cálculo de endereços dentro dos kernels.
O foco principal no método de inicialização está no cálculo correto da largura da janela processada. Como cada especialista opera com sua própria janela de comprimento fixo, nós os combinamos em um único espaço de entrada. Como resultado, é formado o tamanho da janela agregada analisada dos dados brutos. Esse cálculo garante que, mesmo em casos limítrofes, o tamanho da janela não caia para zero.
Em seguida, delegamos a execução ao método com o mesmo nome da classe pai, passando os parâmetros atualizados, e concluímos o método.
Nos métodos de propagação para frente e propagação reversa dessa camada, não é implementada uma lógica de cálculo própria, mas apenas o gerenciamento dos OpenCL-kernels descritos acima. Cada um desses métodos é responsável por preparar os argumentos, transmitir os parâmetros e configurar o enfileiramento do kernel correspondente para execução.
O algoritmo de funcionamento neste caso é padrão: passamos ponteiros para os buffers de dados, incluindo as máscaras, e outros parâmetros, após o que chamamos a função OpenCL com a dimensionalidade adequada do espaço de trabalho. Acredito que essa sequência de ações já seja bem conhecida por você. Por isso, não é necessário aqui um detalhamento aprofundado de cada método e propomos deixar os métodos indicados para estudo independente. O código completo da classe CNeuronMaskMultiWinConv e de todos os seus métodos é apresentado no anexo.
Mistura esparsa de especialistas
A próxima etapa importante do nosso trabalho é a construção do módulo da mistura esparsa de especialistas. No âmbito da nossa implementação, sua arquitetura é organizada na forma da classe CNeuronTimeMoESparseExperts, herdada da classe base CNeuronBaseOCL. Dentro do objeto estão concentrados todos os componentes principais necessários para iniciar e coordenar o funcionamento dos especialistas especializados e do especialista geral.
class CNeuronTimeMoESparseExperts : public CNeuronBaseOCL { protected: CNeuronSwiGLUOCL cExpertsIn; CNeuronSwiGLUOCL cSharedIn; CNeuronMaskMultiWinConv cExpertsOut; CNeuronConvOCL cSharedOut; CNeuronTopKGates cMasks; CNeuronConvOCL cSharedGates; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL) override; virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) override; virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL) override; public: CNeuronTimeMoESparseExperts(void) {}; ~CNeuronTimeMoESparseExperts(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_out, uint units_count, uint variables, uint experts, uint topK, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual int Type(void) override const { return defNeuronTimeMoESparseExperts; } //--- 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; };
Na estrutura da classe CNeuronTimeMoESparseExperts, é possível observar vários objetos internos, cada um desempenhando uma função estritamente definida dentro do mecanismo da mistura esparsa de especialistas. Com a lógica interna deles iremos nos familiarizar gradualmente à medida que construirmos os algoritmos de funcionamento dos métodos. Neste estágio, é importante destacar a seguinte decisão arquitetural: todos os objetos internos são declarados estaticamente, sem alocação dinâmica de memória. Essa abordagem não apenas simplifica o gerenciamento de recursos, mas também permite manter o construtor e o destrutor da classe vazios. Todos os processos de inicialização são implementados no método Init.
bool CNeuronTimeMoESparseExperts::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_out, uint units_count, uint variables, uint experts, uint topK, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, window * units_count * variables, optimization_type, batch)) return false; SetActivationFunction(None);
Nos parâmetros do método, recebemos um conjunto de constantes-chave que permite definir de forma inequívoca a arquitetura do objeto criado. Aqui está previsto tudo: os tamanhos dos tokens de entrada e saída, o número total de especialistas e a quantidade selecionada pela máscara Top-K, o tipo de otimização utilizada, o número de variáveis e de blocos computacionais.
Parte desses parâmetros é então transmitida ao método homônimo da classe pai. É ali que já está organizado o controle básico de correção das configurações arquiteturais e a inicialização das interfaces globais, incluindo o contexto OpenCL. Dessa forma, construímos uma estrutura hierárquica transparente, em que cada nível é responsável por seu próprio escopo de responsabilidade, e todos os elementos da arquitetura se conectam em um todo coerente e integrado.
Após a conclusão bem-sucedida da inicialização no nível da classe pai, passamos à configuração dos componentes internos, é aqui que começa a formação da estrutura da própria mistura de especialistas.
O primeiro nessa cadeia é o objeto cExpertsIn, que implementa a funcionalidade SwiGLU. Ele desempenha o papel do primeiro nível de processamento dos dados brutos, um filtro preliminar que reforça sinais úteis e forma a incorporação direcionada a cada especialista. O número de filtros nessa camada é escalado proporcionalmente ao número de especialistas, permitindo que cada um deles receba uma representação suficientemente expressiva da tarefa.
int index = 0; if(!cExpertsIn.Init(0, index, OpenCL, window, window, window_out * experts, units_count, variables, optimization, iBatch)) return false; index++; if(!cSharedIn.Init(0, index, OpenCL, window, window, window_out, units_count, variables, optimization, iBatch)) return false;
De maneira análoga, inicializamos o componente cSharedIn, que representa a primeira camada de processamento para o especialista geral. Diferentemente dos especialistas especializados, aqui não é necessário escalar a dimensionalidade dos filtros, ao contrário, ela é fixada em um nível equivalente a um único especialista individual. Isso se deve ao fato de que o especialista geral cobre todo o espaço de tarefas como um todo, e sua função não é competir com os demais, mas oferecer uma perspectiva universal, uma espécie de linha de base para a tomada de decisão.
O uso de SwiGLU nesse papel garante seletividade não linear, reforçando a distinção entre tokens potencialmente relevantes para um ou outro especialista.
Em seguida, passamos à formação da segunda camada de processamento tanto para os especialistas especializados quanto para o geral. Aqui, a arquitetura segue rigorosamente a lógica previamente estabelecida: cada bloco desempenha sua função específica dentro do mecanismo geral da mistura esparsa.
Para a mistura de especialistas especializados, utilizamos o módulo CNeuronMaskMultiWinConv criado anteriormente, que implementa a convolução com janelas mascaradas. Esse componente garante a filtragem das características analisadas levando em conta a máscara individual de cada especialista, permitindo definir uma ativação pontual e uma delimitação rigorosa de responsabilidades entre os especialistas.
index++; if(!cExpertsOut.Init(0, index, OpenCL, window_out, experts, window, units_count, variables, optimization, iBatch)) return false; cExpertsOut.SetActivationFunction(None); index++; if(!cSharedOut.Init(0, index, OpenCL, window_out, window_out, window, units_count, variables, optimization, iBatch)) return false; cSharedOut.SetActivationFunction(None);
Para o especialista geral, por sua vez, aplicamos a camada convolucional padrão CNeuronConvOCL.
Como objeto responsável pela criação da máscara dos especialistas ativos, utilizamos o módulo CNeuronTopKGates, já conhecido por nós a partir do framework DUET. Esse componente desempenha um papel central no mecanismo de esparsificação: ele analisa os tokens brutos e seleciona um número estritamente limitado dos especialistas mais relevantes.
Em outras palavras, CNeuronTopKGates forma a máscara de ativação segundo o princípio Top-K, zerando rigidamente os canais não relevantes. Essa solução permite reduzir significativamente a carga computacional, mantendo ao mesmo tempo a alta capacidade expressiva do modelo. O número de especialistas ativos (topK) é definido por parâmetro e pode ser facilmente adaptado à especificidade de cada tarefa.
index++; if(!cMasks.Init(0, index, OpenCL, window, units_count, experts, topK, optimization, iBatch)) return false;
É importante entender que o objeto CNeuronTopKGates não retorna apenas uma máscara binária. Ele também forma uma distribuição probabilística normalizada sobre os canais selecionados. Isso permite regular de maneira flexível a contribuição de cada especialista no resultado final. Os especialistas ativos participam dos cálculos com diferentes níveis de confiança, e essa probabilidade é explicitamente refletida nos pesos da máscara final.
Graças a essa abordagem, a arquitetura obtém não apenas compacidade e eficiência computacional, mas também resistência ao overfitting. Cada especialista é acionado estritamente quando necessário, e não aleatoriamente, o que é especialmente importante ao trabalhar com séries temporais ruidosas ou multimodais.
E a estrutura do objeto é concluída com o módulo de gates do especialista geral, cSharedGates. Sua tarefa é determinar o grau de participação do especialista geral na formação da resposta final do modelo. Para isso, utilizamos uma camada convolucional clássica, configurada para extrair padrões espaço-temporais. Diferentemente da máscara dos especialistas individuais, aqui é aplicada a função de ativação sigmoide, o que permite obter valores suaves no intervalo de 0 a 1.
index++; if(!cSharedGates.Init(0, index, OpenCL, window, window, window, units_count, variables, optimization, iBatch)) return false; cSharedGates.SetActivationFunction(SIGMOID); //--- return true; }
Essa abordagem garante não uma decisão ligar/desligar, mas um escalonamento flexível da contribuição do especialista geral, dependendo do contexto atual. A sigmoide desempenha o papel de amortecedor flexível: se a confiança no padrão geral é alta, o valor tende a um, se o sinal é fraco, a máscara correspondente suprime a saída. Tudo isso permite regular com precisão a interação entre localizados e generalizadores componentes da arquitetura.
Após a execução bem-sucedida de todas as iterações, retornamos seu resultado lógico ao programa chamador e concluímos o método.
Em seguida, passamos à próxima etapa-chave, a organização da propagação para frente no método feedForward. É aqui que a lógica de funcionamento do módulo da mistura esparsa de especialistas se manifesta plenamente.
bool CNeuronTimeMoESparseExperts::feedForward(CNeuronBaseOCL *NeuronOCL) { if(!cExpertsIn.FeedForward(NeuronOCL)) return false;
Primeiro, executamos a propagação para frente através da camada cExpertsIn, que transforma o sinal analisado no espaço dos especialistas, escalando-o de acordo com o número de caminhos especializados de processamento. Em paralelo, é iniciado o funcionamento da camada cSharedIn, que representa o primeiro nível de processamento do especialista geral mais universal.
if(!cSharedIn.FeedForward(NeuronOCL)) return false;
Depois ocorre a ativação dos dois ramos de roteamento. A camada cSharedGates calcula a máscara de participação do especialista geral: aqui atua a função de ativação sigmoide, criando uma máscara gradiente de relevância. Por sua vez, o objeto cMasks, módulo especializado, seleciona os especialistas mais relevantes, formando uma máscara discreta Top-K. Ele não apenas exclui os canais inativos, mas também retorna uma distribuição de pesos normalizada sobre os caminhos selecionados.
if(!cSharedGates.FeedForward(NeuronOCL)) return false; if(!cMasks.FeedForward(NeuronOCL)) return false;
Nesse estágio começa a formação dos resultados. Por meio do módulo cExpertsOut é executada a convolução do bloco especializado mascarado: a cada filtro ativo corresponde seu próprio peso, e todo o processamento é organizado levando em conta a máscara previamente preparada.
if(!cExpertsOut.FeedForward(cExpertsIn.AsObject(), cMasks.getOutput())) return false;
Para o especialista geral é utilizada a convolução comum por meio de cSharedOut, após o que sua saída é adicionalmente escalada pela máscara de relevância calculada anteriormente por cSharedGates. O escalonamento é realizado por meio de multiplicação elemento a elemento.
if(!cSharedOut.FeedForward(cSharedIn.AsObject())) return false; if(!ElementMult(cSharedOut.getOutput(), cSharedGates.getOutput(), cSharedOut.getPrevOutput())) return false;
Após a obtenção dos resultados da mistura esparsa de especialistas e do geral, escalado pela máscara de relevância, ocorre a combinação dos dois fluxos de informação. Nesse estágio, os dados são somados e gravados em um buffer intermediário de dados.
const int window = (int)cSharedGates.GetWindow(); if(!SumAndNormilize(cExpertsOut.getOutput(), cSharedOut.getPrevOutput(), PrevOutput, window, false, 0, 0, 0, 1)) return false; if(!SumAndNormilize(NeuronOCL.getOutput(), PrevOutput, Output, window, true, 0, 0, 0, 1)) return false; //--- return true; }
No entanto, essa é apenas a primeira parte da etapa final da propagação para frente. Em seguida, aos valores obtidos são adicionadas as conexões residual, as saídas preservadas do nível anterior da arquitetura de redes neurais. Essa técnica permite preservar informações importantes do sinal original e melhorar a convergência do modelo por meio da estabilização dos gradientes.
Após a combinação de todos os componentes, é realizada a normalização dos resultados dentro de cada token, ao longo da janela de análise. Essa operação leva os dados a uma escala consistente, garantindo a interpretação correta do tensor de saída. O resultado é armazenado no buffer da interface global de resultados.
Como se pode observar, o algoritmo de propagação para frente no nosso módulo possui uma estrutura bastante ramificada. Os dados brutos entram simultaneamente em cinco diferentes fluxos de informação, o que aumenta significativamente a flexibilidade e a adaptabilidade do modelo. No entanto, essa arquitetura multicanal impõe certas complexidades na etapa de propagação reversa do erro.
No método calcInputGradients ocorre uma distribuição cuidadosa dos gradientes de erro entre todos os componentes internos, de acordo com sua contribuição no resultado final. Isso se assemelha a um trabalho refinado de regência, em que cada instrumento soa em perfeita harmonia com a orquestra.
bool CNeuronTimeMoESparseExperts::calcInputGradients(CNeuronBaseOCL *NeuronOCL) { if(!NeuronOCL) return false;
Primeiro são ativados os procedimentos de distribuição do gradiente de erro para os fluxos de informação individuais:
- para a saída da camada da mistura esparsa de especialistas (cExpertsOut), onde é realizada a desativação dos gradientes levando em conta a função de ativação;
- para o especialista geral e os gates (cSharedOut e cSharedGates), onde é considerada a interação entre os valores de saída e as máscaras, aplicando-se produtos de gradientes com consideração das funções de ativação.
if(!DeActivation(cExpertsOut.getOutput(), cExpertsOut.getGradient(), Gradient, cExpertsOut.Activation())) return false; if(!ElementMultGrad(cSharedOut.getOutput(), cSharedOut.getGradient(), cSharedGates.getOutput(), cSharedGates.getGradient(), Gradient, cSharedOut.Activation(), cSharedGates.Activation())) return false;
Em seguida, ocorre a chamada recursiva do cálculo dos gradientes ocultos nos objetos internos, isso permite passo a passo desdobrar a contribuição de cada componente, começando pelas primeiras camadas dos especialistas (cExpertsIn, cSharedIn) até o nível dos dados brutos, garantindo um cálculo profundo e correto.
if(!cExpertsIn.calcHiddenGradients(cExpertsOut.AsObject(), cMasks.getOutput(), cMasks.getGradient(), (ENUM_ACTIVATION)cMasks.Activation())) return false; if(!cSharedIn.calcHiddenGradients(cSharedOut.AsObject())) return false; //--- if(!NeuronOCL.calcHiddenGradients(cExpertsIn.AsObject())) return false;
Atenção especial é dada à acumulação dos gradientes nos buffers intermediários. Aqui ocorre a soma dos gradientes pelos tokens da janela, assegurando a correta harmonização dos sinais provenientes dos diferentes participantes do processo.
if(!DeActivation(NeuronOCL.getOutput(), PrevOutput, Gradient, NeuronOCL.Activation())) return false; const int window = (int)cSharedGates.GetWindow(); if(!SumAndNormilize(PrevOutput, NeuronOCL.getGradient(), PrevOutput, window, false, 0, 0, 0, 1)) return false; if(!NeuronOCL.calcHiddenGradients(cSharedIn.AsObject())) return false; if(!SumAndNormilize(PrevOutput, NeuronOCL.getGradient(), PrevOutput, window, false, 0, 0, 0, 1)) return false; if(!NeuronOCL.calcHiddenGradients(cMasks.AsObject())) return false; if(!SumAndNormilize(PrevOutput, NeuronOCL.getGradient(), PrevOutput, window, false, 0, 0, 0, 1)) return false; if(!NeuronOCL.calcHiddenGradients(cSharedGates.AsObject())) return false; if(!SumAndNormilize(PrevOutput, NeuronOCL.getGradient(), NeuronOCL.getGradient(), window, false, 0, 0, 0, 1)) return false; //--- return true; }
Como resultado, o método calcInputGradients equilibra cuidadosamente o fluxo complexo de informações e garante o treinamento eficiente de todo o módulo da mistura esparsa de especialistas, o que torna essa arquitetura não apenas poderosa, mas também resistente a erros e ao overfitting.
O método de atualização dos parâmetros do modelo é organizado segundo o esquema clássico: o controle é delegado aos componentes internos. Cada um deles implementa sua própria estratégia de adaptação de pesos com base nos gradientes recebidos. No corpo do método updateInputWeights ocorre apenas a chamada sequencial dos procedimentos correspondentes. Por isso, para evitar repetições, propomos que este trecho seja estudado de forma independente. O código-fonte completo da classe CNeuronTimeMoESparseExperts com a implementação de todos os métodos está disponível no anexo.
Considerações finais
Neste artigo, analisamos detalhadamente a implementação do módulo de convolução mascarada e a arquitetura do bloco da mistura esparsa de especialistas, adaptados para tarefas de processamento de séries temporais em ambiente OpenCL. Foram examinados os principais aspectos da configuração dos cálculos no lado do dispositivo e da organização da interação com o programa principal. Atenção especial foi dedicada à correta distribuição do gradiente em condições de fluxo de informação ramificado.
Na próxima parte, continuaremos o desenvolvimento da arquitetura e nos concentraremos na construção e no treinamento de modelos que utilizam essa estrutura como parte de sistemas de redes neurais mais complexos.
Links
- Time-MoE: Billion-Scale Time Series Foundation Models with Mixture of Experts
- Outros artigos da série
Programas utilizados no artigo
| # | Nome | Tipo | Descrição |
|---|---|---|---|
| 1 | Study.mq5 | Expert Advisor | EA de treinamento offline dos modelos |
| 2 | StudyOnline.mq5 | Expert Advisor | EA de treinamento online dos modelos |
| 3 | Test.mq5 | Expert Advisor | EA para testar o modelo |
| 4 | Trajectory.mqh | Biblioteca de classe | Estrutura de descrição do estado do sistema e da arquitetura dos modelos |
| 5 | NeuroNet.mqh | Biblioteca de classe | Biblioteca de classes para criação de redes neurais |
| 6 | NeuroNet.cl | Biblioteca | Biblioteca de código de programa OpenCL |
Traduzido do russo pela MetaQuotes Ltd.
Artigo original: https://www.mql5.com/ru/articles/18527
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.
Estratégia evolutiva de adaptação da matriz de covariância, Covariance Matrix Adaptation Evolution Strategy (CMA-ES)
Introdução à diversificação (en. diversification) de estruturas fractais de mercado com o auxílio de machine learning
Construindo um Indicador Keltner Channel com Gráficos Canvas Personalizados em MQL5
Avaliação da qualidade da negociação de spreads por fatores de sazonalidade no mercado Forex no terminal MetaTrader 5
- 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