Coletânea Python do ZERO às Redes Neurais Artificiais
Perceptron Multicamada – Tabela XOR
Entendida a lógica do processo de aprendizado de máquina na prática, treinando um perceptron para que aprenda um operador lógico AND, hora de aumentar levemente a complexidade. Como dito anteriormente, gradualmente vamos construindo essa bagagem de conhecimento. O principal diferencial deste capítulo em relação ao anterior é que, em uma tabela AND temos um problema linearmente separável, onde a resposta (saída) era basicamente 1 ou 0, pegando o exemplo de uma tabela XOR temos o diferencial de que este operador lógico realiza a operação lógica entre dois operandos, que resulta em um valor lógico verdadeiro se e somente se o número de operandos com valor verdadeiro for ímpar, dessa forma temos um problema computacional onde, a nível de perceptron, teremos de treinar o mesmo para descobrir a probabilidade desta operação ser verdadeira.
Em outras palavras, teremos de identificar e treinar nosso perceptron para que realize mais de uma camada de correção de pesos, afim de identificar a probabilidade correta da relação entre as entradas e as saídas da tabela XOR que por sua vez possui mais de um parâmetro de ativação. O fato de trabalharmos a partir daqui com uma camada a mais de processamento para rede, camada essa normalmente chamada de camada oculta, permite que sejam aplicados “filtros” que nos ajudam a identificar, treinar e aprender sob nuances de valores muito menores do que os valores brutos do exemplo anterior.
Tabela XOR:
X1 X2
0 0 0 0 1 1 1 0 1 1 1 0
Estrutura Lógica:
Diretamente ao código:
Como de costume, todo processo se inicia com a importação das bibliotecas e módulos que utilizaremos ao longo de nossa rede. Agora trabalhando diretamente com o Spyder, podemos em tempo real executar linhas ou blocos de código simplesmente os selecionando com o mouse e aplicando o comando Ctrl + ENTER. Para importação da biblioteca Numpy basta executar o comando import como exemplificado acima.
Em seguida criamos as variáveis entradas e saidas que como seus nomes já sugerem, recebem como atributos os respectivos dados da tabela XOR, em forma de array numpy, assim, uma vez vetorizados, poderemos posteriormente aplicar funções internas desta biblioteca.
Executando esse bloco de código (via Ctrl + ENTER) podemos ver que ao lado direito da interface, no Explorador de variáveis são criadas as respectivas variáveis.
Clicando duas vezes sobre as mesmas podemos as explorar de forma visual, em forma de matrizes como previsto, lado a lado compondo a tabela XOR.
Em seguida criamos as variáveis dedicadas a guardar os valores dos pesos, aqui, novamente apenas para fins de exemplo, estamos iniciando essas variáveis com valores aleatórios.
Se a sintaxe estiver correta, ao selecionar e executar este bloco de código podemos ver no explorador de variáveis que tais variáveis foram corretamente criadas.
Em seguida criamos estas três variáveis auxiliares que nos serão úteis posteriormente. A variável ntreinos, que aqui recebe como atributo o valor 100, diz respeito ao parâmetro de quantas vezes a rede será executada para que haja o devido ajuste dos pesos. Da mesma forma a variável taxaAprendizado aqui com valor 0.3 atribuído é o parâmetro para de quantos em quantos números os pesos serão modificados durante o processo de aprendizado, esse parâmetro, entendido em outras literaturas como a velocidade em que o algoritmo irá aprender, conforme o valor pode inclusive fazer com que o algoritmo piore sua eficiência ou entre em um loop onde fica estagnado em um ponto, logo, é um parâmetro a ser testado com outros valores e comparar a eficiência dos resultados dos mesmos. Por fim momentum é um parâmetro padrão, uma constante na fórmula de correção da margem de erro, nesse processo de atualização de pesos, aqui, para este exemplo, segue com o valor padrão 1 atribuído, mas pode ser testado com outros valores para verificar se as últimas alterações surtiram melhoria da eficiência do algoritmo.
Logo após criamos nossa Função Sigmoide, que neste modelo, substitui a Função Degrau que utilizamos anteriormente. Existem diferentes funções de ativação, as mais comuns, Degrau e Sigmoide utilizaremos com frequência ao longo dos demais exemplos. Em suma, a grande diferença entre elas é que a Função Degrau retorna valores absolutos 0 ou 1 (ou valores definidos pelo usuário, mas sempre neste padrão binário, um ou outro), enquanto a Função Sigmoide leva em consideração todos valores intermediários entre 0 e 1, dessa forma, conseguimos verificar a probabilidade de um dado valor se aproximar de 0 ou se aproximar de 1, de acordo com suas características.
Para criar a função Sigmoide, simplesmente definimos sigmoid( ) que recebe como parâmetro a variável temporária soma, internamente ela simplesmente retorna o valor 1 dividido pela soma de 1 pela exponenciação de soma, o que na prática nos retornará um valor float entre 0 e 1 (ou seja, um número com casas decimais).
Prosseguindo criamos o primeiro bloco de código onde de fato ocorre a interação entre as variáveis. Criamos um laço de repetição que de acordo com o valor setado em ntreinos executa as seguintes linhas de código. Inicialmente é criada uma variável temporária camadaEntrada que recebe como atributo o conteúdo de entradas, em seguida é criada uma variável somaSinapse0, que recebe como atributo o produto escalar (soma das multiplicações) de camadaEntrada por pesos0. Apenas relembrando, uma sinapse é a termologia da conexão entre neurônios, aqui, trata-se de uma variável que faz a interligação entre os nós da camada de entrada e da camada oculta. Em seguida é criada uma variável camadaOculta que recebe como atributo a função sigmoid( ) que por sua vez tem como parâmetro o valor resultante dessa operação sobre somaSinapse0.
Da mesma forma é necessário criar a estrutura de interação entre a camada oculta e a camada de saída. Para isso criamos a variável somaSinapse1 que recebe como atributo o produto escalar entre camadaOculta e pesos1, assim como anteriormente fizemos, é criada uma variável camadaSaida que por sua vez recebe como atributo o valor gerado pela função sigmoid( ) sobre somaSinapse1.
Por fim são criados por convenção duas variáveis que nos mostrarão parâmetros para avaliar a eficiência do processamento de nossa rede. erroCamadaSaida aplica a fórmula de erro, fazendo a simples diferença entre os valores de saidas (valores que já conhecemos) e camadaSaida (valores encontrados pela rede). Posteriormente criamos a variável mediaAbsoluta que simplesmente, por meio da função .mean( ) transforma os dados de erroCamadaSaida em um valor percentual (Quantos % de erro existe sobre nosso processamento).
Selecionando e executando esse bloco de código são criadas as referentes variáveis, das camadas de processamento assim como as variáveis auxiliares. Podemos clicando duas vezes sobre as mesmas visualizar seu conteúdo. Por hora, repare que neste primeiro processamento é criada a variável mediaAbsoluta que possui valor 0.49, ou seja 49% de erro em nosso processamento, o que é uma margem muito grande. Pode ficar tranquilo que valores altos nesta etapa de execução são perfeitamente normais, o que faremos na sequência é justamente trabalhar a realizar o aprendizado de máquina, fazer com que nossa rede identifique os padrões corretos e reduza esta margem de erro ao menor valor possível.
Dando sequência criamos nossa função sigmoideDerivada( ) que como próprio nome sugere, faz o cálculo da derivada, que também nos será útil na fase de aprendizado de máquina. Basicamente o que fazemos é definir uma função sigmoideDerivada( ) que tem como parâmetro a variavel temporária sig. Internamente é chamada a função que retorna o valor obtido pela multiplicação de sig (do valor que estiver atribuído a esta variável) pela multiplicação de 1 menos o próprio valor de sig.
Em seguida, aproveitando o mesmo bloco de código dedicado a esta etapa, criamos duas variáveis sigDerivada, uma basicamente aplica a função sigmoid( ) com o valor de 0.5, a outra, aplica a própria função sigmoideDerivada( ) sobre sigDerivada.
Da mesma forma, seguimos criando mais variáveis que nos auxiliarão a acompanhar o processo de aprendizado de máquina, dessa vez criamos uma variável derivadaSaida que aplica a função sigmoideDerivada( ) para a camadaSaida, por fim criamos a variável deltaSaida que recebe como atributo o valor da multiplicação entre erroCamadaSaida e derivadaSaida.
Vale lembrar que o que nomeamos de delta em uma rede neura normalmente se refere a um parâmetro usado como referência para correto ajuste da descida do gradiente. Em outras palavras, para se realizar um ajuste fino dos pesos e consequentemente conseguir minimizar a margem de erro dos mesmos, são feitos internamente uma série de cálculos para que esses reajustes sejam feitos da maneira correta, parâmetros errados podem prejudicar o reconhecimento dos padrões corretos por parte da rede neural.
Podemos sempre que quisermos, fazer a visualização das variáveis por meio do explorador, aqui, os dados encontrados executando a fórmula do cálculo da derivada sobre os valores de saída assim como os valores encontrados para o delta, em outras palavras, pelo sinal do delta podemos entender como (em que direção no grafo) serão feitos os reajustes dos pesos, uma vez que individualmente eles poder ter seus valores aumentados ou diminuídos de acordo com o processo.
Dando sequência, teremos de fazer um reajuste de formado de nossos dados em suas devidas matrizes para que as operações aritméticas sobre as mesmas possam ser realizadas corretamente. Raciocine que quando estamos trabalhando com vetores e matrizes temos um padrão de linhas e colunas que pode ou não ser multiplicável. Aqui nesta fase do processo teremos inconsistência de formatos de nossas matrizes de pesos, sendo necessário realizar um processo chamado Matriz Transposta, onde transformaremos linhas em colunas e vice versa de forma que possamos realizar as operações e obter valores corretos.
Inicialmente criamos uma variável pesos1Transposta que recebe como atributo pesos1 com aplicação da função .T( ), função interna da biblioteca Numpy que realizará essa conversão. Em seguida criamos uma variável de nome deltaSaidaXpesos que recebe como atributo o produto escalar de deltaSaida por pesos1Transposta. Por fim aplicamos novamente a fórmula do delta, dessa vez para camada oculta, multiplicando deltaSaidaXpesos pelo resultado da função sigmoide sob o valor de camadaOculta.
Apenas visualizando a diferença entre pesos1 e pesos1Transposta (colunas transformadas em linhas).
Se você se lembra do algoritmo explicado em capítulos anteriores verá
que até este momento estamos trabalhando na construção da estrutura desse perceptron, assim como sua alimentação com valores para seus nós e pesos. A esta altura, obtidos todos valores iniciais, inclusive identificando que tais valores resultam em erro se comparado ao esperado na tabela XOR, é hora de darmos início ao que para alguns autores é chamado de backpropagation, ou seja, realizar de fato os reajustes dos pesos e o reprocessamento do perceptron, repetindo esse processo até o treinar de fato para a resposta correta.
Todo processo realizado até o momento é normalmente tratado na literatura como feed forward, ou seja, partindo dos nós de entrada fomos alimentando e processando em sentido a saída, a ativação desse perceptron, a etapa de backpropagation como o nome já sugere, retroalimenta o perceptron, faz o caminho inverso ao feed forward, ou em certos casos, retorna ao ponto inicial e refaz o processamento a partir deste.
O processo de backpropagation inclusive tem uma fórmula a ser aplicada
para que possamos entender a lógica desse processo como operações sobre nossas variáveis.
peso(n + 1) = (peso(n) + momento) + (entrada * delta * taxa de aprendizagem)
Da mesma forma como fizemos anteriormente, criamos uma variável camadaOcultaTransposta que simplesmente recebe a camadaOculta de forma transposta por meio da função .T( ). Logo após usamos a variável pesos3 que recebe como atributo o produto escalar de camadaOcultaTransposta pelo deltaSaida. Finalmente, fazemos o processo de atualização dos valores de pesos1, atribuindo ao mesmo os valores atualizados de pesos1 multiplicado pelo momentum somado com os valores de pesos3 multiplicados pelo parâmetro de taxaAprendizado.
Executando esse bloco de código você pode ver que de fato houve a atualização dos valores de pesos1. Se nesse processo não houver nenhuma inconsistência ou erro de sintaxe, após a execução desses blocos de código temos a devida atualização dos pesos da camada oculta para a camada de saída.
O mesmo processo é realizado para atualização dos valores de pesos0. Ou seja, agora pela lógica backpropagation voltamos mais um passo ao início do perceptron, para que possamos fazer as devidas atualizações a partir da camada de entrada.
Lembrando que Python é uma linguagem interpretada e de forte indentação, o que em outras palavras significa que o interpretador lê e executa linha após linha em sua sequência normal, e executa blocos de código quando estão uns dentro dos outros de acordo com sua indentação. Sendo assim, todo código praticamente pronto, apenas reorganizamos o mesmo para que não haja problema de interpretação. Dessa forma, todos blocos dedicados aos ajustes dos pesos ficam dentro daquele nosso laço de repetição, para que possamos executá-los de acordo com os parâmetros que iremos definir para as épocas e taxa de aprendizado.
Finalmente criamos uma função print( ) dedicada a nos mostrar via console a margem de erro deste processamento. Agora selecionando e executando todo o código, podemos acompanhar via console o aprendizado de máquina acontecendo. Lembrando que inicialmente havíamos definido o número de vezes a serem executado o código inicialmente como 100. Esse parâmetro pode ser livremente modificado na variável ntreinos.
Executando 100 vezes a margem de erro cai para 0.497.
Executando 1000 vezes, a margem de erro cai para 0.367.
Executando 100000 vezes, a margem de erro cai para 0.133.
Por fim, rede executada 1000000 de vezes, a margem de erro cai para 0.006, em outras palavras, agora a rede está treinada para identificar os padrões de entrada e saída de uma tabela XOR, com 0,006% de margem de erro (ou 99,994% de precisão). Lembrando que em nossa amostragem inicial tínhamos 51% de acerto, agora quase 100% de acerto é um resultado excelente.
Código Completo: