![Redes neurais de maneira fácil (Parte 21): Autocodificadores variacionais (VAE)](https://c.mql5.com/2/49/Neural_Networks_Easy_013_600x314.jpg)
Redes neurais de maneira fácil (Parte 21): Autocodificadores variacionais (VAE)
Conteúdo
- Introdução
- 1. Arquitetura do autocodificador variacional
- 2. Implementação
- 3. Teste
- Considerações finais
- Referências
- Programas utilizados no artigo
Introdução
Continuando a introdução aos algoritmos para métodos de aprendizado não supervisionado, vale a pena dizer que, no último artigo, nos encontramos com autocodificadores. O tópico de autocodificadores é bastante amplo e está além do escopo de um artigo. Eu gostaria de continuar este tópico e apresentar a você uma das modificações dos autocodificadores - autocodificadores variacionais.
1. Arquitetura do autocodificador variacional
Antes de começar a estudar a arquitetura do autocodificador variacional, vamos relembrar um pouco o material apresentado no último artigo.
- Um autocodificador é uma rede neural treinada usando retropropagação de erro.
- Qualquer autocodificador consiste em dois blocos - codificador e decodificador.
- O tamanho da camada de dados de entrada do codificador é igual ao tamanho da camada de resultado do decodificador.
- O codificador e o decodificador são juntos por um estado latente de "gargalo", no qual as informações sobre o estado inicial são compactadas.
No processo de treinamento, conseguimos a máxima semelhança entre os resultados da decodificação do estado latente pelo decodificador e os dados iniciais. Nesse caso, podemos afirmar que o máximo de informações sobre os dados iniciais é criptografado no estado latente. E eles são suficientes para restaurar os dados iniciais com alguma probabilidade. Mas, como dissemos no último artigo, o uso de autocodificadores é muito mais amplo do que apenas tarefas de compactação de dados.
E aqui chegamos a um problema que foi identificado ao usar autocodificadores para gerar imagens. Vamos representar nossos dados iniciais por uma nuvem. No processo de treinamento, nosso modelo aprendeu a restaurar perfeitamente 2 objetos A e B selecionados aleatoriamente. Exagerando um pouco, o codificador e o decodificador concordaram entre si para o objeto A em estado latente indicar 1 e 5 para o objeto B. Não há nada de errado com isto para a tarefa de compressão de dados. Pelo contrário, os objetos são bem separáveis e o modelo pode restaurá-los.
Porém quando os pesquisadores tentaram usar autocodificadores para gerar imagens, a diferença nos valores de latência entre 2 objetos provou ser um problema. Os experimentos mostraram que quando os valores do estado latente mudaram do objeto A para o objeto B em áreas próximas aos objetos, o decodificador restaurou os objetos indicados com algumas distorções. Mas no meio do intervalo, o decodificador gerou algo que não era característico dos dados iniciais.
Em outras palavras, o estado latente dos autocodificadores no qual os dados iniciais são codificados e compactados pode não ser contínuo ou pode permitir alguma interpolação. Este é o problema fundamental dos autocodificadores para gerar quaisquer dados.
Certamente não vamos gerar nenhum dado. Mas não nos esqueçamos da inconstância do nosso mundo. De fato, no processo de estudo da situação do mercado, a probabilidade de obter um padrão do amostra de treinamento no futuro com precisão matemática é insignificante. E gostaríamos que nosso modelo processasse corretamente a situação do mercado e desse resultados adequados no futuro. Portanto, a solução deste problema também é necessária para nós, assim como para modelos generativos.
A solução para o problema não é tão trivial quanto pode parecer à primeira vista. Um aumento na amostra de treinamento e o uso de vários métodos de regularização de estado latente só levam a um dimensionamento do problema. Por exemplo, ao aplicar a regularização, reduzimos a distância entre os vetores do estado latente dos objetos. Digamos que para o exemplo exagerado acima, esses serão os números 1 e 2. Mas sempre pode haver um objeto que o codificador codifica como 1.5. E novamente deixamos nosso decodificador em um "beco sem saída". Uma enorme aproximação com sobreposição pode dificultar a separação de objetos.
Aumentar a amostra de treinamento tem um resultado semelhante, pois cada estado permanece discreto. Ao mesmo tempo, um aumento na amostra de treinamento leva a um aumento no tempo e nos recursos gastos no treinamento. Além disso, o autocodificador, em seu desejo de destacar cada padrão individual dos dados iniciais, se esforçará para maximizar a distância até o estado vizinho mais próximo.
Ao contrário do nosso modelo, sabemos que cada um dos nossos estados discretos é um representante de uma determinada classe de objetos. Objetos esses que em nossa nuvem de dados iniciais estão próximos e distribuídos de acordo com a lei de uma determinada distribuição. Vamos adicionar nosso conhecimento prévio ao modelo.
Mas como podemos fazer o modelo retornar um intervalo inteiro de valores ao invés de um único valor. Entretanto, este intervalo de valores pode diferir em termos do número de valores discretos e sua variação. Isso pode lembrá-lo de problemas de agrupamento. Mas não temos entendimento sobre o número de classes. E seu número pode variar dependendo da amostra de dados iniciais usada. Precisamos de um modelo de representação de dados mais genérico.
Já mencionamos que o posicionamento dos objetos de cada classe em nossa nuvem de dados iniciais está sujeito a determinada distribuição. Provavelmente a mais comumente usada é a distribuição normal. Então vamos supor que cada característica em nosso estado latente no resultado obtido pelo codificador corresponde a uma distribuição normal. Como você sabe, a distribuição normal é determinada por 2 parâmetros: a expectativa matemática e o desvio padrão. Então, vamos pedir ao nosso codificador que retorne não um valor discreto para cada característica, mas dois - a expectativa matemática (valor médio) e o desvio padrão da distribuição à qual o padrão analisado de dados iniciais pertence.
Mas não importa como chamamos os valores no resultado gerado pelo codificador, para o decodificador eles permanecem os mesmos números. E aqui chegamos à arquitetura do autocodificador variacional. Não há transmissão direta de valores entre codificador e decodificador em sua arquitetura. Pelo contrário, pegamos os parâmetros de distribuição do codificador, amostramos um valor aleatório da distribuição especificada e o passamos para alimentar o decodificador. Assim, como resultado do processamento do mesmo padrão de dados iniciais pelo codificador, a alimentação do decodificador pode ter um vetor de valores diferente, mas sempre sujeito à mesma distribuição normal.
Como você pode ver, como resultado de tal operação, a entrada do decodificador sempre terá 2 vezes menos valores que a saída do codificador.
Mas aqui enfrentamos o problema de treinar nosso modelo. Como você lembra, o método de retropropagação de erro é usado para treinar o modelo. Um dos principais requisitos para a aplicação deste método é a diferenciabilidade de todas as funções ao longo do caminho do gradiente de erro. E o gerador de números aleatórios, infelizmente, não é desse tipo.
Mas esse problema também foi resolvido. Vamos examinar mais de perto as propriedades da distribuição normal e o significado dos parâmetros que a descrevem. A distribuição normal é uma distribuição de probabilidade matemática centrada no ponto de expectativa matemática, sendo que 68% estão a uma distância não superior ao desvio padrão em relação ao centro da distribuição. Portanto, uma mudança na expectativa matemática desloca o centro da distribuição. E alterar o desvio padrão dimensiona a dispersão dos valores ao redor do centro.
Assim, para obter um único valor de uma distribuição normal com determinados parâmetros, podemos gerar um valor para uma distribuição normal padrão com média "0" e desvio padrão "1". E então o valor resultante é multiplicado pelo desvio padrão dado e adicionado à expectativa matemática dada. Essa abordagem é chamada de truque de reparametrização.
Como resultado, quando geramos um valor aleatório da distribuição normal padrão na passagem direta, nós o salvamos. E alimentamos com o vetor corrigido com os parâmetros fornecidos o decodificador. Na retropropagação, passamos facilmente o gradiente de erro para o codificador através de operações de adição e multiplicação, que são facilmente diferenciadas. E um gerador de valor aleatório não diferenciável é deixado para nosso modelo.
Parece que montamos o quebra-cabeça e contornamos todas as "armadilhas". Mas experimentos práticos mostraram que o modelo não quer jogar de acordo com nossas novas regras. Em vez de aprender regras mais complexas com novas entradas, nosso autocodificador reduziu as caraterísticas de desvio padrão para "0" durante o processo de aprendizado. Como resultado da multiplicação por "0", nossa variável aleatória não tem efeito e o decodificador recebe um valor discreto da expectativa matemática na entrada. Ou seja, ao reduzir as caraterísticas de desvio padrão para "0", o modelo anula todo o nosso trabalho feito acima e retorna à troca de valores discretos entre o codificador e o decodificador.
Para fazer o modelo funcionar de acordo com nossas regras, precisamos introduzir regras e restrições adicionais. Em particular, devemos indicar ao nosso modelo que os sinais de expectativa matemática e desvio padrão devem corresponder o máximo possível aos parâmetros da distribuição normal padrão. Podemos implementar isso adicionando uma penalidade de desvio adicional. A divergência de Kullback–Leibler foi escolhida como medida de tal desvio pelos autores de tal abordagem. Não vamos mergulhar em cálculos matemáticos agora, apenas daremos o resultado do erro para o caso de desvio de valores empíricos em relação aos parâmetros da distribuição normal. É essa função que usaremos para regularizar os valores do estado latente. Na prática, adicionaremos seu valor ao erro de latência.
Assim, cada vez que penalizarmos o modelo pelo desvio dos parâmetros de características em relação à referência, neste caso, distribuição padrão, forçaremos o modelo a aproximar os parâmetros de distribuição de cada característica dos parâmetros da distribuição padrão (a expectativa matemática é "0", e o desvio padrão é "1").
Aqui deve ser dito que tal atração de características no resultado gerado pelo codificador irá contra a tarefa principal de extrair características de objetos individuais. Afinal, a regularização adicionada irá “puxar” todas as caraterísticas para os valores de referência com a mesma força. Em outras palavras, ela se esforçará para torná-los iguais. Ao acontecer isso, o gradiente de erro do decodificador tenderá a separar o máximo possível as caraterísticas de diferentes objetos. Existe claramente um conflito de interesses entre as 2 tarefas executadas. E o modelo deve encontrar um equilíbrio na resolução das tarefas. E esse equilíbrio nem sempre atenderá às nossas expectativas. Para controlar este ponto de equilíbrio, um hiperparâmetro adicional é introduzido no modelo, hiperparâmetro esse que controla a influência da divergência de Kullback–Leibler no resultado geral.
2. Implementação
Depois de revisar os aspectos teóricos do algoritmo do autocodificador variacional, podemos passar para a parte prática do nosso artigo. Devo dizer que para implementar o codificador e o decodificador de nosso autocodificador variacional, nós, como antes, usaremos camadas neurais totalmente conectadas da biblioteca criada anteriormente. Para uma implementação completa do autocodificador variacional, não temos um bloco para trabalhar com um estado latente. É neste bloco que planejamos implementar todas as inovações do autocodificador variacional mencionadas acima.
Para preservar a abordagem geral de elaboração de redes neurais em nossa biblioteca, agruparemos todo o algoritmo para processar o estado latente de autocodificador variacional em uma camada neural CVAE separada. Mas antes de prosseguir com a implementação da classe em si, vamos criar kernels para implementar a funcionalidade no lado OpenCL do dispositivo.
E começaremos com o kernel de passagem direta. Deixe-me lembrá-lo que os parâmetros da descrição da distribuição normal para as características do estado latente chegam à entrada da camada criada. Mas há uma ressalva: a expectativa matemática pode assumir qualquer valor, enquanto o desvio padrão é apenas valores não negativos. Se usássemos diferentes camadas neurais para gerar parâmetros, poderíamos usar diferentes funções de ativação de neurônios. Mas a arquitetura de construção de nossa biblioteca permite criar apenas modelos lineares. Ao mesmo tempo, apenas uma função de ativação pode ser usada dentro de uma camada neural.
E novamente chegamos à conclusão de que para o modelo é absolutamente tudo igual como chamamos este ou aquele indicador. Ele simplesmente executa as fórmulas matemáticas estabelecidas. Isso é importante apenas para nós, para que possamos construir corretamente nosso modelo. Vamos prestar atenção à fórmula de divergência de Kullback-Leibler acima. Ela usa a variância e seu logaritmo. Como você sabe, a variância de uma distribuição é igual ao quadrado do desvio padrão e pode não ser estritamente negativa. Sendo que seu logaritmo pode assumir valores positivos e negativos. E se você observar o gráfico do logaritmo natural do quadrado do argumento, o ponto de interseção entre a linha de abcissa e o gráfico da função está estritamente no valor "1". É este valor que é o alvo para o desvio padrão. Além disso, para o intervalo de valores da função de -1 a 1, o argumento da função assume valores de 0,6 a 1,6, o que satisfaz bastante nossas expectativas para o desvio padrão.
Assim, na saída pediremos ao codificador do modelo que forneça a expectativa matemática e o logaritmo natural da variância da distribuição. Nesse caso, podemos usar a tangente hiperbólica como função de ativação da camada neural, pois a faixa de seus valores satisfaz bastante nossas expectativas tanto para a expectativa matemática da distribuição quanto para o logaritmo de sua variância.
Tendo decidido por uma abordagem conceitual, passamos para a programação de nossas funções. Como mencionado acima, começaremos com o kernel de passagem direta VAE_FeedForward. Em parâmetros, este kernel recebe ponteiros para 3 buffers de dados. Dois buffers contêm os dados iniciais e o outro buffer é de resultados. Aqui deve ser dito que não há gerador de números pseudo-aleatórios no lado OpenCL. Portanto, vamos amostrar os elementos da distribuição padrão ao lado do programa principal. E, em seguida, vamos passá-los com um buffer aleatório para o kernel de passagem direta que está sendo criado.
O segundo buffer de dados iniciais conterá os resultados do codificador. Como você deve ter adivinhado, o vetor de expectativa e o vetor de variância logarítmica estarão contidos no mesmo buffer de alimentação inputs.
No corpo do kernel, basta implementar o truque de reparametrização. Mas não esqueçamos que ao invés do desvio padrão, o codificador nos deu o logaritmo da variância. Portanto, antes de realizar o truque de alterar os parâmetros de distribuição, precisamos obter o valor do desvio padrão.
O inverso do logaritmo natural é a função exponencial. Com sua ajuda, podemos obter a variância. E depois de extrair a raiz quadrada da variância, obtemos o desvio padrão. Ou, usando a propriedade das potências, podemos simplesmente pegar o expoente da metade do logaritmo da variância, que também nos dará o desvio padrão.
No corpo do kernel de passagem direta, primeiro determinamos o identificador da thread atual e o número total de threads em execução, que servirão como ponteiros nos buffers de dados de entrada e de resultado para as células necessárias. E logo a seguir realizaremos o truque de reparametrização, levando em consideração a extração do desvio padrão do logaritmo da variância. Gravamos o resultado no elemento correspondente do buffer de resultados e saímos do kernel.
__kernel void VAE_FeedForward(__global float* inputs, __global float* random, __global float* outputs ) { uint i = (uint)get_global_id(0); uint total = (uint)get_global_size(0); outputs[i] = inputs[i] + exp(0.5f * inputs[i + total]) * random[i]; }
Como você pode ver, o algoritmo do kernel de passagem direta é bastante simples. E após sua implementação, procedemos à criação da passagem para atrás do lado do contexto OpenCL. Devo dizer que nossa camada de estado latente do autocodificador variacional não conterá parâmetros treináveis. Portanto, todo o processo de retropropagação consistirá em elaborar a transmissão do gradiente de erro - do decodificador para o codificador. Criamos essa funcionalidade no kernel VAE_CalcHiddenGradient.
Ao criar este kernel, devemos lembrar que durante a passagem direta pegamos 2 elementos a partir do vetor de resultado do codificador e após o truque de reparametrização alimentamos a entrada do decodificador com uma caraterística. Portanto, devemos pegar um gradiente de erro a partir do decodificador e distribuí-lo para dois elementos correspondentes do codificador.
E se para a expectativa matemática tudo é simples, porque, durante a adição, o gradiente de erro é totalmente transferido para ambos os termos, então para o logaritmo da variância devemos lidar com a derivada de uma função complexa.
Mas há também o outro lado da moeda. Como você lembra, para fazer o modelo funcionar de acordo com nossas regras, introduzimos a divergência de Kullback-Leibler. E agora devemos acrescentar ao gradiente de erro proveniente do decodificador o gradiente de erro do desvio dos parâmetros de distribuição em relação aos valores de referência da distribuição padrão.
Vejamos a implementação do kernel VAE_CalcHiddenGradient descrito acima no código. Nos parâmetros, o kernel recebe ponteiros para 4 buffers de dados e uma constante. Três dos buffers recebidos carregam as informações iniciais e um buffer para registrar os resultados dos gradientes e transferi-los para o nível do codificador.
- Os inputs são os resultados da passagem direta do codificador. O buffer contém os valores das expectativas matemáticas e logaritmos da variância das caraterísticas;
- O random é os valores dos elementos da distribuição padrão usados na passagem direta;
- O gradient é os gradientes de erro provenientes do decodificador;
- O inp_grad é o buffer de resultado para gravação de gradientes de erro passados para o codificador;
- O kld_mult é o valor discreto do coeficiente de influência da divergência de Kullback–Leibler no resultado geral.
No corpo do kernel, primeiro determinamos o número de série do thread atual e o número total de threads do kernel em execução. Usamos esses valores como ponteiros para os elementos necessários dos buffers de dados iniciais e de resultados.
Em seguida, determinamos o valor da divergência de Kullback-Leibler. E aqui é preciso prestar atenção, nos esforçamos para minimizar a distância entre a distribuição empírica e a de referência, ou seja, tentamos reduzir para "0". Isso significa que o erro será igual ao valor do desvio com o sinal oposto. Para eliminar operações desnecessárias, simplesmente removemos o sinal de menos na frente da fórmula para determinar o desvio. E então corrigiremos o valor pelo coeficiente de influência da divergência no resultado geral.
Em seguida, temos que transferir o gradiente de erro para o nível do codificador. Aqui vamos passar a soma de 2 gradientes para cada parâmetro de distribuição, de acordo com as derivadas das funções acima.
__kernel void VAE_CalcHiddenGradient(__global float* inputs, __global float* inp_grad, __global float* random, __global float* gradient, const float kld_mult ) { uint i = (uint)get_global_id(0); uint total = (uint)get_global_size(0); float kld = kld_mult * 0.5f * (inputs[i + total] - exp(inputs[i + total]) - pow(inputs[i], 2.0f) + 1); inp_grad[i] = gradient[i] + kld * inputs[i]; inp_grad[i + total] = 0.5f * (gradient[i] * random[i] * exp(0.5f * inputs[i + total]) - kld * (1 - exp(inputs[i + total]))) ; }
Isso conclui o trabalho com o programa OpenCL e passamos para a implementação da funcionalidade no lado do programa principal. Aqui, primeiro criaremos uma nova classe de camada neural CVAEque herda da classe base das camadas neurais CNeuronBaseOCL.
Nesta classe, adicionamos uma variável m_fKLD_Mult para armazenar o coeficiente de influência da divergência de Kullback-Leibler no resultado geral e o método SetKLDMult para especificá-lo. Também criaremos um buffer m_cRandom adicional para armazenar valores aleatórios da distribuição padrão. Amostraremos diretamente os valores usando os métodos da biblioteca de estatísticas padrão e as operações matemáticas "Math\Stat\Normal.mqh".
Além disso, para implementar nossa funcionalidade, precisamos substituir os métodos de passagem direta e retropropagação, assim como métodos para trabalhar com arquivos.
class CVAE : public CNeuronBaseOCL { protected: float m_fKLD_Mult; CBufferDouble* m_cRandom; virtual bool feedForward(CNeuronBaseOCL *NeuronOCL); virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) { return true; } public: CVAE(); ~CVAE(); virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint numNeurons, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual void SetKLDMult(float value) { m_fKLD_Mult = value;} virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL); //--- virtual bool Save(int const file_handle); virtual bool Load(int const file_handle); //--- virtual int Type(void) const { return defNeuronVAEOCL; } };
O construtor e o destruidor de uma classe são bastante simples. No primeiro, apenas definimos o valor inicial da nossa nova variável e inicializamos a instância do buffer de dados para trabalhar com uma sequência de variáveis aleatórias.
CVAE::CVAE() : m_fKLD_Mult(0.01f) { m_cRandom = new CBufferDouble(); }
No destruidor de classe, excluímos apenas o objeto do buffer criado no construtor.
CVAE::~CVAE() { if(!!m_cRandom) delete m_cRandom; }
O método de inicialização da instância de classe também não é muito complicado. Na verdade, quase todas as funcionalidades de inicialização de objetos são executadas pelo método da classe pai. Nessa classe são implementados todos os controles e funcionalidades necessários para inicializar objetos herdados. No método de nossa classe de autocodificador variacional, chamamos apenas o método da classe pai. E após sua execução bem-sucedida, inicializamos o buffer para trabalhar com uma sequência aleatória. E então criamos um buffer para ele na memória do contexto OpenCL.
bool CVAE::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint numNeurons, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, numNeurons, optimization_type, batch)) return false; //--- if(!m_cRandom) { m_cRandom = new CBufferDouble(); if(!m_cRandom) return false; } if(!m_cRandom.BufferInit(numNeurons, 0.0)) return false; if(!m_cRandom.BufferCreate(OpenCL)) return false; //--- return true; }
Vamos iniciar a implementação da funcionalidade principal da classe com o método de passagem direta CVAE::feedForward. Assim como métodos similares de outras camadas neurais, nos parâmetros o método recebe um ponteiro para o objeto da camada neural anterior, e segue imediatamente o bloco de controles. Na etapa seguinte, verificamos se os ponteiros para os objetos utilizados são válidos. Depois disso, verificamos as dimensões dos dados iniciais recebidos. O número de elementos no buffer de resultado da camada anterior deve ser um múltiplo de 2 e ser 2 vezes o buffer de resultado da camada neural que estamos criando. Tal correspondência estrita é ditada pela arquitetura do autocodificador variacional. Como você lembra, o codificador deve retornar 2 valores para cada caraterística, valores esse que descrevem a expectativa matemática e o desvio padrão da distribuição de cada caraterística.
bool CVAE::feedForward(CNeuronBaseOCL *NeuronOCL) { if(!OpenCL || !NeuronOCL || !m_cRandom) return false; if(NeuronOCL.Neurons() % 2 != 0 || NeuronOCL.Neurons() / 2 != Neurons()) return false;
Depois de passar com sucesso o bloco de controles, fazemos a amostragem de valores aleatórios do desvio padrão e transferimos seus valores para o buffer apropriado.
double random[]; if(!MathRandomNormal(0, 1, m_cRandom.Total(), random)) return false; if(!m_cRandom.AssignArray(random)) return false; if(!m_cRandom.BufferWrite()) return false;
Os valores gerados são imediatamente transferidos para a memória de contexto OpenCL para posterior processamento.
Em seguida, temos que fazer a chamada do kernel correspondente. Aqui devemos primeiro passar ponteiros para os buffers de dados usados pelo kernel. Observe que apenas transferimos o buffer de caso de valores gerado para a memória de contexto. Esperamos que todos os outros buffers de dados usados já estejam na memória do contexto. Se você não criou um buffer na memória de contexto antes ou fez qualquer alteração nos dados do buffer no lado do programa principal, antes de passar os ponteiros de buffer para os parâmetros do kernel, você deve transferir os dados para a memória de contexto OpenCL. É muito importante lembrar que um programa OpenCL opera apenas na memória de seu contexto, sem acessar a memória global do computador. Mesmo se você estiver usando uma placa gráfica integrada ou a biblioteca OpenCL no processador.
if(!OpenCL.SetArgumentBuffer(def_k_VAEFeedForward, def_k_vaeff_inputs, NeuronOCL.getOutput().GetIndex())) return false; if(!OpenCL.SetArgumentBuffer(def_k_VAEFeedForward, def_k_vaeff_random, m_cRandom.GetIndex())) return false; if(!OpenCL.SetArgumentBuffer(def_k_VAEFeedForward, def_k_vaeff_outputd, Output.GetIndex())) return false;
No final do método, especificamos a dimensão das tarefas, o deslocamento para cada dimensão e chamamos o método para colocar o kernel na fila de execução.
uint off_set[] = {0}; uint NDrange[] = {Neurons()}; if(!OpenCL.Execute(def_k_VAEFeedForward, 1, off_set, NDrange)) return false; //--- return true; }
Certifique-se de não esquecer de verificar o resultado das operações em cada etapa.
Após a conclusão bem-sucedida de todas as operações do método de passagem direta, saímos dele com o resultado true.
A passagem direta é seguida pela retropropagação. Como você sabe, geralmente implementamos a passagem para tás de várias maneiras. Primeiro, usando os métodos calcOutputGradients, calcHiddenGradients e calcInputGradients, fazemos o cálculo e a transmissão do gradiente de erro sequencialmente por todo o nosso modelo desde a camada neural de saída até a camada de dados de entrada. E então, usando o método updateInputWeights, alteramos os parâmetros de treinamento na direção do anti-gradiente.
Nossa camada neural para trabalhar com a camada latente do autocodificador variacional não contém parâmetros treináveis. Portanto, substituiremos o último método de otimização dos parâmetros treinados com um stub que sempre retornará true em cada chamada de método.
De fato, para a elaboração normal do processo de retropropagação na classe, basta sobrescrever apenas o método calcInputGradients. Apesar do fato de que funcionalmente os métodos de passagem direta e retropropagação têm uma direção reversa do fluxo de dados, o conteúdo dos métodos é bastante semelhante. Isso ocorre porque a funcionalidade dos algoritmos é realizada no contexto OpenCL. Do lado do programa principal, apenas fazemos um trabalho preparatório para chamar os kernels. E sua chamada é realizada de acordo com um único modelo.
Como no método de passagem direta, primeiro verificamos a validade dos ponteiros para os objetos que estão sendo usados. Eu não repasso dados para o contexto OpenCL. Mas se você não tiver certeza de que todas as informações necessárias estão na memória de contexto, é melhor transferi-las novamente para a memória de contexto OpenCL agora. E só depois disso passamos os parâmetros para o kernel.
Após a transferência bem-sucedida dos parâmetros, há um bloco de operações para iniciar diretamente a execução do kernel. Aqui, primeiro definimos a dimensão dos problemas e o deslocamento ao longo de cada dimensão. Em seguida, chamamos o método de configuração do kernel para a fila de execução.
bool CVAE::calcInputGradients(CNeuronBaseOCL *NeuronOCL) { if(!OpenCL || !NeuronOCL) return false; //--- if(!OpenCL.SetArgumentBuffer(def_k_VAECalcHiddenGradient, def_k_vaehg_input, NeuronOCL.getOutput().GetIndex())) return false; if(!OpenCL.SetArgumentBuffer(def_k_VAECalcHiddenGradient, def_k_vaehg_inp_grad, NeuronOCL.getGradient().GetIndex())) return false; if(!OpenCL.SetArgumentBuffer(def_k_VAECalcHiddenGradient, def_k_vaehg_random, Weights.GetIndex())) return false; if(!OpenCL.SetArgumentBuffer(def_k_VAECalcHiddenGradient, def_k_vaehg_gradient, Gradient.GetIndex())) return false; if(!OpenCL.SetArgument(def_k_VAECalcHiddenGradient, def_k_vaehg_kld_mult, m_fKLD_Mult)) return false; int off_set[] = {0}; int NDrange[] = {Neurons()}; if(!OpenCL.Execute(def_k_VAECalcHiddenGradient, 1, off_set, NDrange)) return false; //--- return true; }
Verificamos os resultados de todas as operações e saímos do método.
Isso conclui a implementação da funcionalidade principal da classe. Mas lembramos das funções igualmente importantes para trabalhar com arquivos. Dito isso, complementaremos a funcionalidade da classe com esses métodos. Mas antes de começarmos a escrever métodos de classe, vamos pensar em quais informações precisamos salvar para restaurar com sucesso o desempenho do modelo. Nesta classe, criamos apenas uma variável e um buffer de dados. O conteúdo do buffer é preenchido com valores aleatórios em cada passagem direta. Portanto, não há necessidade de salvarmos esses dados. Mas o valor da variável é um hiperparâmetro e precisamos salvá-lo.
Assim, nosso método de salvamento de objetos conterá apenas 2 operações:
- chamada de um método semelhante da classe pai, no qual são realizados todos os controles necessários e salvamento de objetos herdados;
- preservação do hiperparâmetro de influência da divergência de Kullback–Leibler no resultado global.
bool CVAE::Save(const int file_handle) { //--- if(!CNeuronBaseOCL::Save(file_handle)) return false; if(FileWriteFloat(file_handle, m_fKLD_Mult) < sizeof(m_fKLD_Mult)) return false; //--- return true; }
Não se esqueça de verificar o resultado das operações em cada etapa. Após a conclusão bem-sucedida de todas as operações, saímos do método com o resultado true.
Para restaurar a operabilidade do modelo, a leitura dos dados salvos do arquivo é realizada em estrita conformidade com a ordem de gravação dos dados. Primeiro chamamos um método semelhante na classe pai. Ele faz todos os controles necessários e carregamento de objetos herdados.
bool CVAE::Load(const int file_handle) { if(!CNeuronBaseOCL::Load(file_handle)) return false; m_fKLD_Mult=FileReadFloat(file_handle);
Após a execução bem-sucedida do método da classe pai, lemos os valores dos hiperparâmetros do arquivo e os gravamos na variável correspondente. Mas, diferentemente do método de salvar dados, o método de carregar dados não termina aqui. Sim, não há mais informações no arquivo para carregar nesta classe. Mas para lograr seu correto funcionamento, precisamos inicializar o buffer para trabalhar com variáveis aleatórias de tamanho correto. Criamos um buffer com tamanho igual ao buffer de resultados da camada neural atual carregado (ele foi carregado pelo método da classe pai). E imediatamente criamos o buffer correspondente na memória do contexto OpenCL.
if(!m_cRandom) { m_cRandom = new CBufferDouble(); if(!m_cRandom) return false; } if(!m_cRandom.BufferInit(Neurons(), 0.0)) return false; if(!m_cRandom.BufferCreate(OpenCL)) return false; //--- return true; }
Após a conclusão bem-sucedida de todas as operações, saímos do método com o resultado true.
Isso conclui nosso trabalho com nossa classe de processamento de latência de autocodificador variacional. E o código completo de todos os métodos e classes pode ser encontrado no anexo.
Nossa nova classe está pronta. Mas nossa classe gerenciadora para realizar a operação de uma rede neural ainda não sabe nada sobre ela. Portanto, vamos ao arquivo NeuroNet.mqh e encontramos a classe CNet.
Primeiro, vamos ao construtor da classe e descrevemos o procedimento para criar uma nova camada neural. Aumentamos imediatamente o número de kernels OpenCL usados e declaramos dois novos kernels.
CNet::CNet(CArrayObj *Description) : recentAverageError(0), backPropCount(0) { ................. ................. //--- for(int i = 0; i < total; i++) { ................. ................. if(CheckPointer(opencl) != POINTER_INVALID) { CNeuronBaseOCL *neuron_ocl = NULL; CNeuronConvOCL *neuron_conv_ocl = NULL; CNeuronProofOCL *neuron_proof_ocl = NULL; CNeuronAttentionOCL *neuron_attention_ocl = NULL; CNeuronMLMHAttentionOCL *neuron_mlattention_ocl = NULL; CNeuronDropoutOCL *dropout = NULL; CNeuronBatchNormOCL *batch = NULL; CVAE *vae = NULL; switch(desc.type) { ................. ................. //--- case defNeuronVAEOCL: vae = new CVAE(); if(!vae) { delete temp; return; } if(!vae.Init(outputs, 0, opencl, desc.count, desc.optimization, desc.batch)) { delete vae; delete temp; return; } if(!temp.Add(vae)) { delete vae; delete temp; return; } vae = NULL; break; default: return; break; } } else for(int n = 0; n < neurons; n++) { ................. ................. } if(!layers.Add(temp)) { delete temp; delete layers; return; } } //--- if(CheckPointer(opencl) == POINTER_INVALID) return; //--- create kernels opencl.SetKernelsCount(32); ................. ................. opencl.KernelCreate(def_k_VAEFeedForward, "VAE_FeedForward"); opencl.KernelCreate(def_k_VAECalcHiddenGradient, "VAE_CalcHiddenGradient"); //--- return; }
Fazemos alterações semelhantes no método de carregamento do modelo CNet::Load. Não vou duplicar o código neste artigo. Você pode ver o código do método no anexo.
Em seguida, adicionamos ponteiros à nova classe nos métodos CLayer::CreateElement e CLayer::Load.
Por fim, adicionamos novos ponteiros de classe aos métodos gerenciadores da camada neural baseCNeuronBaseOCL FeedForward, calcHiddenGradients e UpdateInputWeights.
Depois de fazer todas as adições necessárias, podemos começar a implementar e testar o modelo. O código completo de todos os métodos e funções pode ser encontrado no anexo.
3. Teste
Para testar o funcionamento do nosso autocodificador variacional, peguei o modelo do artigo anterior e salvei-o novamente em um novo arquivo "vae.mq5". Nesse modelo, o codificador retornou 2 valores na 5ª camada neural. Para elaborar adequadamente a operação do autocodificador variacional, aumentei o tamanho da camada no resultado gerado pelo codificador para 4 neurônios. E inserimos a nossa 6ª nova camada neural de trabalho com o estado latente do autocodificador variacional. O modelo foi treinado com o instrumento EURUSD e com o timeframe H1 sem alterar os parâmetros. O período de tempo para o treinamento do modelo foi escolhido como os últimos 15 anos. O gráfico comparativo da dinâmica de aprendizado de autocodificadores multicamadas e variacionais é mostrado na figura abaixo.
Como você pode ver, de acordo com os resultados do treinamento do modelo, o autocodificador variacional apresentou um erro de recuperação de dados significativamente menor durante todo o período de treinamento. Além disso, a dinâmica de redução de erro no autocodificador variacional é maior.
Com base nos resultados dos testes, podemos concluir que para resolver os problemas de extração de características de séries temporais usando o exemplo da dinâmica de preços EURUSD, os autocodificadores variacionais têm grande potencial na extração de características individuais de descrições de padrões.
Considerações finais
Neste artigo, aprendemos o algoritmo do autocodificador variacional; construímos uma classe para implementar o algoritmo de autocodificador variacional e realizamos treinamento de teste do modelo de autocodificador variacional com dados históricos reais. Os resultados dos testes demonstraram a viabilidade do modelo de autocodificador variacional para uso como treinamento preliminar de modelos para extração de características individuais que descrevem a situação de mercado. Os resultados desse treinamento podem ser usados para criar padrões de negociação e acompanhá-los até o treinamento usando métodos de aprendizado supervisionado.
Referências
- Redes neurais de maneira fácil (Parte 14): agrupamento de dados
- Redes neurais de maneira fácil (Parte 15): agrupamento de dados via MQL5
- Redes neurais de maneira fácil (Parte 16): uso prático do agrupamento
- Redes neurais de maneira fácil (Parte 17): redução de dimensionalidade
- Redes neurais de maneira fácil (Parte 18): regras de associação
- Redes neurais de maneira fácil (Parte 19): regras de associação usando MQL5
- Redes neurais de maneira fácil (Parte 20): autocodificadores
- Tutorial sobre autocodificadores variacionais
- Entendendo intuitivamente os autocodificadores variacionais
- Tutorial - O que é um autocodificador variacional?
Programas utilizados no artigo
# | Nome | Tipo | Descrição |
---|---|---|---|
1 | vae.mq5 | EA | EA de treinamento de autocodificador variacional |
2 | vae2.mq5 | EA | Consultor de preparação de dados para visualização |
3 | VAE.mqh | Biblioteca de classe | Biblioteca da classe de camada latente de autocodificador variacional |
4 | NeuroNet.mqh | Biblioteca de classe | Biblioteca de classes para criar uma rede neural |
5 | NeuroNet.cl | Biblioteca | Biblioteca de códigos do programa OpenCL |
Traduzido do russo pela MetaQuotes Ltd.
Artigo original: https://www.mql5.com/ru/articles/11206
![Funcionalidades do assistente MQL5 que você precisa conhecer (Parte 02): Mapas de Kohonen](https://c.mql5.com/2/47/logo_r1__1.png)
![Redes neurais de maneira fácil (Parte 20): autocodificadores](https://c.mql5.com/2/48/Neural_networks_made_easy_020.png)
![Aprendendo a construindo um EA que opera de forma automática (Parte 04): Gatilhos manuais (I)](https://c.mql5.com/2/50/aprendendo_construindo_004_avatar.png)
![Indicador CCI. Atualizações e novos recursos](https://c.mql5.com/2/47/new_oscillator.png)
![MQL5 - Linguagem para estratégias de negociação inseridas no terminal do cliente MetaTrader 5](https://c.mql5.com/i/registerlandings/logo-2.png)
- Aplicativos de negociação gratuitos
- 8 000+ sinais para cópia
- Notícias econômicas para análise dos mercados financeiros
Você concorda com a política do site e com os termos de uso