Execução de teste

Retropropagação de rede neural para programadores

James McCaffrey

Baixar o código de exemplo

Uma rede neural artificial pode ser considerada como uma metafunção que aceita um número fixo de entradas numéricas e produz um número fixo de saídas numéricas. Na maioria das situações, uma rede neural tem uma camada de neurônios ocultos, onde cada neurônio oculto é totalmente conectado aos neurônios de entrada e aos neurônios de saída. Associados a cada neurônio oculto individual e a cada neurônio de saída individual estão um conjunto de valores de peso e o chamado valor de tendência. Os pesos e as tendências determinam os valores de saída para determinado conjunto de valores de entrada.

Quando redes neurais são usadas para modelar um conjunto de dados existentes para que previsões possam ser feitas sobre novos dados, o principal desafio é encontrar o conjunto de valores de peso e tendência que gerem as saídas que melhor correspondam aos dados existentes. A técnica mais comum para estimar os pesos e as tendências da rede neural ideal é chamada de retropropagação. Embora existam muitas referências excelentes que descrevem a matemática complicada que sustenta a retropropagação, há pouquíssimos guias disponíveis para programadores que explicam claramente como programar o algoritmo de retropropagação. Este artigo explica como implementar a retropropagação. Eu uso a linguagem C#, mas você não deve ter problemas para refatorar o código apresentado aqui em outras linguagens.

A melhor maneira de ver para onde estou indo é dar uma olhada na captura de tela de um programa de demonstração na Figura 1. O programa de demonstração cria uma rede neural que tem três neurônios de entrada, uma camada oculta com quatro neurônios e dois neurônios de saída. Redes neurais com uma única camada oculta precisam de duas funções de ativação. Em muitas situações, porém, as duas funções de ativação são iguais, normalmente a função sigmoide. Mas nesta demonstração, para ilustrar a relação entre as funções de ativação e de retropropagação, usarei funções de ativação diferentes: a função sigmoide para as computações de entrada-para-oculto e a função tanh (tangente hiperbólica) para computações de oculto-para-saída.


Figura 1 Algoritmo de retropropagação em ação

Uma rede neural totalmente conectada 3-4-2 requer 3*4 + 4*2 = 20 valores de peso e 4+2 = 6 valores de tendência para um total de 26 pesos e tendências. Esses pesos e tendências são inicializados para valores mais ou menos arbitrários. Os três valores de entrada fictícios são definidos como 1.0, 2.0 e 3.0. Com os valores iniciais de peso, tendência e entrada, os valores iniciais de saída são computados pela rede neural para ser {0.7225, -0.8779}. O programa de demonstração arbitrariamente supõe que os dois valores de saída corretos são {-0.8500, 0.7500}. O objetivo do algoritmo de retropropagação é encontrar um novo conjunto de pesos e tendências que gerem saídas muito próximas dos valores corretos para entradas {1.0, 2.0, 3.0}.

A retropropagação requer dois parâmetros livres. A taxa de aprendizagem, geralmente dada a letra grega eta na literatura de retropropagação, controla o quão rápido o algoritmo converge para uma estimativa final. A dinâmica, geralmente dada a letra grega alfa, ajuda o algoritmo de retropropagação a evitar situações em que o algoritmo oscila e nunca converge para uma estimativa final. O programa de demonstração define a taxa de aprendizagem como 0.90 e a dinâmica como 0.04. Normalmente, esses valores são encontrados por tentativa e erro.

Encontrar o melhor conjunto de pesos e tendências para uma rede neural é às vezes chamado de treinamento da rede. Treinar com retropropagação é um processo iterativo. Em cada iteração, a retropropagação computa um novo conjunto de valores de peso e tendência de rede neural que, em tese, geram valores de saída mais próximos dos valores de destino. Após a primeira iteração de treinamento do programa de demonstração, o algoritmo de retropropagação encontrou novos valores de peso e tendência que gerou novas saídas de {-0.8932, -0.8006}. O primeiro novo valor de saída de -0.8932 estava muito mais próximo do primeiro valor de saída de destino de -0.8500. O segundo novo valor de saída de -0.8006 estava ainda bem longe de seu valor de destino de 0.7500.

O processo de treinamento pode ser encerrado de diversas maneiras. O programa de demonstração itera treinamento até que a soma das diferenças absolutas entre os valores de saída e de destino seja < = 0.01 ou que o treinamento atinja 1.000 iterações. Na demonstração, após seis iterações de treinamento, a retropropagação encontrou um conjunto de valores de peso e tendência de rede neural gerou saídas de {-0.8423, 0.7481}, que estavam muito próximas dos valores de destino {-0.8500, 0.7500} desejados.

Este artigo pressupõe que você tenha habilidades de especialista em programação e um entendimento bem básico de redes neurais. (Para obter informações básicas sobre redes neurais, consulte meu artigo de maio de 2012, “Mergulhe nas redes neurais”, em msdn.microsoft.com/magazine/hh975375.) O código do programa de demonstração mostrado na Figura 1 é um pouco longo demais para apresentar neste artigo. Então, irei me concentrar nas principais partes do algoritmo. O código-fonte completo do programa de demonstração está disponível em archive.msdn.microsoft.com/mag201210TestRun.

Definindo uma classe de rede neural

Codificar uma rede neural que usa retropropagação presta-se bem a uma abordagem orientada a objeto. A definição de classe usada para o programa de demonstração está listada na Figura 2.

Figura 2 Classe de rede neural

class NeuralNetwork
{
  private int numInput;
  private int numHidden;
  private int numOutput;
  // 15 input, output, weight, bias, and other arrays here
  public NeuralNetwork(int numInput, 
    int numHidden, int numOutput) {...}
  public void UpdateWeights(double[] tValues, 
    double eta, double alpha) {...}
  public void SetWeights(double[] weights) {...}
  public double[] GetWeights() {...}
  public double[] ComputeOutputs(double[] xValues) {...}
  private static double SigmoidFunction(double x)
  {
    if (x < -45.0) return 0.0;
    else if (x > 45.0) return 1.0;
    else return 1.0 / (1.0 + Math.Exp(-x));
  }
  private static double HyperTanFunction(double x)
  {
    if (x < -10.0) return -1.0;
    else if (x > 10.0) return 1.0;
    else return Math.Tanh(x);
  }
}

Os campos de membro, numInput, numHidden e NumOutput estão definindo as características da arquitetura de rede neural. Além de um simples construtor, a classe tem quatro métodos acessíveis ao público e dois métodos auxiliares. O método UpdateWeights contém toda a lógica do algoritmo de retropropagação. O método SetWeights aceita uma matriz de pesos e tendências e copia esses valores sequencialmente nas matrizes membro. O método GetWeights executa a operação inversa copiando os pesos e as tendências em uma única matriz e retornando essa matriz. O método ComputeOutputs determina os valores de saída da rede neural usando os valores de entrada, peso e tendência atuais.

O método SigmoidFunction é usado como a função de ativação de entrada-para-oculto. Ele aceita um valor real (tipo double em C#) e retorna um valor entre 0.0 e 1.0. O método HyperTanFunction também aceita um valor real, mas retorna um valor entre -1.0 e +1.0. A linguagem C# tem uma função de tangente hiperbólica intergada, Math.Tanh, mas se você estiver usando um idioma que não tem uma função tanh nativa, terá de codificar uma do zero.

Configurando as matrizes

Um dos segredos para programar com sucesso um algoritmo de retropropagação da rede neural é compreender plenamente as matrizes que estão sendo usadas para armazenar valores de peso e tendência, armazenar diferentes tipos de valores de entrada e saída, armazenar os valores de uma iteração anterior do algoritmo e armazenar cálculos transitórios. O diagrama grande na Figura 3 contém todas as informações que você precisa saber para compreender como programar a retropropagação. Sua reação inicial à Figura 3 provavelmente será algo do tipo "Esquece — isso é muito complicado." Aguente firme. A retropropagação não é trivial, mas depois de entender o diagrama, você será capaz de implementar a retropropagação usando qualquer linguagem de programação.


Figura 3 Algoritmo de retropropagação

A Figura 3 tem as principais entradas e saídas nas bordas da figura, mas também vários valores de entrada e saída locais que ocorrem no interior do diagrama. Não se deve subestimar a dificuldade da codificação de uma rede neural e a necessidade de manter os nomes e os significados de todas essas entradas e saídas claras. Baseado na minha experiência, um diagrama como o da Figura 3 é absolutamente essencial.

 As primeiras cinco das 15 matrizes usadas na definição de rede neural descritas na Figura 2 lidam com as camadas de entrada-para-oculto e são:

public class NeuralNetwork
{
  // Declare numInput, numHidden, numOutput
  private double[] inputs;
  private double[][] ihWeights;
  private double[] ihSums;
  private double[] ihBiases;
  private double[] ihOutputs;
...

A primeira matriz, chamada "inputs", tem os valores de entrada numéricos. Esses valores geralmente vêm diretamente de alguma fonte de dados normalizada, como um arquivo de texto. O construtor NeuralNetwork instancia entradas como:

this.inputs = new double[numInput];

A matriz ihWeights (pesos de entrada-para-oculto) é uma matriz bidimensional virtual implementada como uma matriz de matrizes. O primeiro índice indica o neurônio de entrada e o segundo índice indica o neurônio oculto. A matriz é instanciada pelo construtor como:

this.ihWeights = Helpers.MakeMatrix(numInput, numHidden);

Aqui, Helpers é uma classe de utilitário de métodos estáticos que ajudam a simplificar a classe de rede neural:

public static double[][] MakeMatrix(int rows, int cols)
{
  double[][] result = new double[rows][];
  for (int i = 0; i < rows; ++i)
    result[i] = new double[cols];
  return result;
}

A matriz IhSums é uma matriz transitória que é usada para conter um cálculo intermediário no método ComputeOutputs. A matriz contém os valores que se tornarão as entradas locais para os neurônios ocultos e é instanciada como:

this.ihSums = new double[numHidden];

A matriz ihBiases contém os valores de tendência para os neurônios ocultos. Os valores de peso da rede neural são constantes que são aplicadas multiplicando-as por um valor de entrada local. Os valores de tendência são adicionados a uma soma intermediária para gerar um valor de saída local, que se torna a entrada local para a próxima camada. A matriz ihBiases é instanciada como:

this.ihBiases = new double[numHidden];

A matriz IhOutputs contém os valores que são emitidos pelos neurônios da camada oculta (que se tornam as entradas para a camada de saída).

As próximas quatro matrizes na classe NeuralNetwork contêm valores relacionados à camada oculta-para-saída:

private double[][] hoWeights;
private double[] hoSums;
private double[] hoBiases;
private double[] outputs;

Essas quatro matrizes são instanciadas no construtor como:

this.hoWeights = Helpers.MakeMatrix(numHidden, numOutput);
this.hoSums = new double[numOutput];
this.hoBiases = new double[numOutput];
this.outputs = new double[numOutput];

A classe da rede neural tem seis matrizes que estão diretamente relacionadas ao algoritmo de retropropagação. As duas primeiras matrizes contêm valores chamados os gradientes para os neurônios das camadas de saída e oculta. Gradiente é um valor que descreve indiretamente o quão longe, e em que direção (positiva ou negativa), as saídas locais estão em relação às saídas de destino. Os valores de gradiente são usados para computar os valores de delta, que são adicionados ao valores atuais de peso e tendência para gerar novos e melhores pesos e tendências. Há um valor de gradiente para cada neurônio da camada oculta e cada neurônio da camada de saída. As matrizes são declaradas como:

private double[] oGrads; // Output gradients
private double[] hGrads; // Hidden gradients

AS matrizes são instanciadas no construtor como:

this.oGrads = new double[numOutput];
this.hGrads = new double[numHidden];

As quatro matrizes finais na classe NeuralNetwork contêm os deltas (não gradientes) da iteração anterior do loop de treinamento. Esses deltas anteriores são necessários se você usar o mecanismo da dinâmica para evitar a não-convergência de retropropagação. Considero a dinâmica essencial, mas se você decidir não implementar a dinâmica, poderá omitir essas matrizes. Elas são declaradas como:

private double[][] ihPrevWeightsDelta;  // For momentum
private double[] ihPrevBiasesDelta;
private double[][] hoPrevWeightsDelta;
private double[] hoPrevBiasesDelta;

Essas matrizes são instanciadas como:

ihPrevWeightsDelta = Helpers.MakeMatrix(numInput, numHidden);
ihPrevBiasesDelta = new double[numHidden];
hoPrevWeightsDelta = Helpers.MakeMatrix(numHidden, numOutput);
hoPrevBiasesDelta = new double[numOutput];

Computando saídas

Cada iteração no loop de treinamento mostrado na Figura 1 tem duas partes. Na primeira parte, as saídas são calculadas usando as atuais entradas principais, pesos e tendências. Na segunda parte, a retropropagação é usada para modificar os pesos e as tendências. O diagrama da Figura 3 ilustra ambas as partes do processo de treinamento.

Trabalhando da esquerda para a direita, as entradas x0, x1 e x2 são atribuídas aos valores de 1.0, 2.0 e 3.0. Estes valores de entrada principais vão para os neurônios da camada de entrada e são emitidos sem modificação. Embora os neurônios da camada de entrada possam modificar sua entrada, como normalizar os valores para estar dentro de determinado intervalo, na maioria dos casos esse processamento é feito externamente. Por isso, os diagramas de rede neural geralmente usam retângulos ou caixas quadradas para os neurônios de entrada para indicar que não estão processando os neurônios no mesmo sentido que os neurônios da camada oculta e da camada de saída estão. E isso também afeta a terminologia utilizada. Em alguns casos, a rede neural mostrada na Figura3 seria chamada de rede de três camadas, mas como a camada de entrada não executa o processamento, a rede neural mostrada é às vezes chamada de rede de duas camadas.

Em seguida, cada um dos neurônios do camada oculta computa uma entrada local e uma saída local. Por exemplo, o neurônio oculto na extremidade inferior, com índice [3], computa sua soma transitória como (1.0)(0.4)+(2.0)(0.8)+(3.0)(1.2) = 5.6. A soma transitória é o produto da soma das três entradas vezes o peso de entrada-para-oculto associado. Os valores acima de cada seta são os pesos. Em seguida, o valor de tendência,-7.0, é adicionado à soma transitória para gerar um valor de entrada local de 5.6 + (-7.0) = -1.40. Então, a função de ativação de entrada-para-oculto é aplicada a este valor de entrada intermediário para gerar o valor de saída local do neurônio. Neste caso, a função de ativação é a função sigmoide, logo, a saída local é 1 / (1 + exp(-(-1.40))) = 0.20.

Os neurônios da camada de saída computam sua entrada e saída da mesma forma. Por exemplo, na Figura 3, o neurônio da camada oculta na extremidade inferior, com índice [1], computa sua soma transitória como (0.86)(1.4)+(0.17)(1.6)+(0.98)(1.8)+(0.20)(2.0) = 3.73. A tendência associada é adicionada para gerar a entrada local: 3.73 + (-5.0) = -1.37. E a função de ativação é aplicada para gerar a saída primária: tanh(-1.37) = -0.88. Se você examinar o código de ComputeOutputs, verá que o método computa as saídas exatamente como acabei de descrever.

Retropropagação

Embora a matemática por trás da teoria da retropropagação seja bastante complicada, uma vez que você sabe quais são os resultados matemáticos, implementar a retropropagação não é muito difícil. A retropropagação começa trabalhando da direita para a esquerda no diagrama mostrado na Figura 3. O primeiro passo é computar os valores de gradiente para cada neurônio da camada de saída. Lembre-se de que o gradiente é um valor que tem informações sobre a magnitude e a direção de um erro. Os gradientes dos neurônios da camada de saída são computados diferentemente dos gradientes dos neurônios da camada oculta.

O gradiente de um neurônio da camada de saída é igual ao valor de destino (desejado) menos o valor de saída computado, vezes a derivada do cálculo da função de ativação da camada de saída avaliada com o valor calculado de saída. Por exemplo, o valor de gradiente do neurônio da camada de saída na extremidade inferior da Figura 3, com índice [1], é computado como:

(0.75 – (-0.88)) * (1 – (-0.88)) * (1 + (-0.88)) = 0.37   

0.75 é o valor desejado. -0.88 é o valor de saída computado a partir da computação da passagem para frente. Lembre-se que, neste exemplo, a função de ativação da camada de saída é a função tanh. A derivada de cálculo de tanh(x) é (1 - tanh(x)) * (1 + tanh(x)). A análise matemática é um pouco complicada, mas, em última análise, a computação do gradiente de um neurônio da camada de saída é dada pela fórmula descrita aqui.

O gradiente de um neurônio da camada oculta é igual a derivada do cálculo da função de ativação de camada oculta avaliada na saída local do neurônio vezes a soma do produto das saídas principais vezes seus pesos de oculto-para-saída associados. Por exemplo, na Figura 3, o gradiente do neurônio da camada oculta na extremidade inferior com índice [3] é:

(0.20)(1 – 0.20) * [ (-0.76)(1.9) + (0.37)(2.0) ] = -0.03

Se nós chamarmos a função sigmoide g(x), verifica-se que a derivada do cálculo da função sigmoide é g(x) * (1 - g(x)). Lembre-se de que este exemplo usa a função sigmoide para a função de ativação de entrada-para-oculto. Aqui, 0.20 é a saída local do neurônio. -0.76 e 0.37 são os gradientes dos neurônios da camada de saída, e 1.9 e 2.0 são os pesos de oculto-para-saída associados aos dois gradientes da camada de saída.

Computando os deltas de peso e tendência

Depois de todos os gradientes da camada de saída e da camada oculta terem sido computados, o próximo passo do algoritmo de retropropagação é usar os valores de gradiente para calcular os valores delta para cada valor de peso e tendência. Diferente dos gradientes, que devem ser computados da direita para a esquerda, os valores delta podem ser computados em qualquer ordem. O valor delta de qualquer peso ou tendência é igual a eta vezes o gradiente associado ao peso ou tendência, vezes o valor de entrada associado ao peso ou tendência. Por exemplo, o valor delta do peso de entrada-para-oculto de neurônio de entrada [2] para o neurônio oculto [3] é:

    delta i-h peso[2][3] = eta * gradiente oculto[3] * entrada[2]
    = 0.90 * (-0.11) * 3.0
    = -0.297

0.90 é o eta, que controla a velocidade de aprendizagem da retropropagação. Valores de eta maiores geram maiores mudanças no delta, com o risco de errar o alvo de uma boa resposta. O valor -0.11 é o gradiente do neurônio oculto [3]. O valor 3.0 é o valor de entrada do neurônio de entrada [2]. Em termos do diagrama na Figura 3, se um peso é representado como uma seta de um neurônio para outro, para computar o delta de um peso específico, use o valor de gradiente do neurônio apontado para a direita e o valor de entrada do neurônio apontado da esquerda.

Ao calcular os deltas dos valores de tendência, observe que como os valores de tendência são simplesmente adicionados a uma soma intermediária, eles não têm valor de entrada associado. Assim, para calcular o delta de um valor de tendência, você pode omitir o termo valor de entrada completamente, ou usar um valor fictício de 1.0 como uma forma de documentação. Por exemplo, na Figura 3, a tendência da camada oculta na extremidade inferior tem o valor -7.0. O delta desse valor de tendência é:

    0.90 * gradiente do neurônio apontado para * 1.0
    = 0.90 * (-0.11) * 1.0
    = 0.099

Adicionando o termo "dinâmica"

Depois de todos os valores delta de peso e tendência terem sido calculados, é possível atualizar cada peso e tendência simplesmente adicionando o valor delta associado. No entanto, a experiência com redes neurais mostrou que com determinados conjuntos de dados, o algoritmo de retropropagação pode oscilar, repetidamente, excedendo e errando o valor de destino e nunca convergindo para um conjunto final de estimativas de peso e tendência. Uma técnica para reduzir essa tendência é adicionar a cada novo peso e tendência um termo adicional chamado dinâmica. A dinâmica de um peso (ou tendência) é apenas um valor pequeno (como 0.4 no programa de demonstração) vezes o valor do delta do anterior do peso. Usar a dinâmica agrega certa complexidade ao algoritmo de retropropagação porque os valores dos deltas anteriores devem ser armazenados. A matemática por trás do por quê essa técnica evita a oscilação é sutil, mas o resultado é simples.

Resumindo, para atualizar um peso (ou tendência) usando a retropropagação, o primeiro passo é computar gradientes para todos os neurônios da camada de saída. O segundo passo é computar gradientes para todos os neurônios da camada oculta. O terceiro passo é computar deltas para todos os pesos usando a eta, a taxa de aprendizagem. O quarto passo é adicionar os deltas para cada peso. O quinto passo é adicionar o termo "dinâmica" para cada peso.  

Codificação com o Visual Studio 2012

A explicação de retropropagação apresentada neste artigo, juntamente com o código de exemplo, deve dar a você informações suficientes para compreender e codificar o algoritmo de retropropagação. Retropropagação é apenas uma das várias técnicas que podem ser utilizadas para fazer uma estimativa dos melhores valores de peso e tendência para um conjunto de dados. Comparado a alternativas como otimização por nuvem de partículas e algoritmos de otimização evolutiva, a retropropagação tende a ser mais rápida. Mas a retropropagação tem desvantagens. Ela não pode ser usada com redes neurais que utilizam funções de ativação não-diferenciáveis. Determinar bons valores para os parâmetros de taxa de aprendizagem e dinâmica é mais arte do que ciência e pode ser demorado.

Existem vários tópicos importantes que este artigo não aborda, em particular como lidar com vários itens de dados de destino. Explicarei este conceito e outras técnicas de redes neurais no futuro artigos.

Quando eu codifiquei o programa de demonstração para este artigo, usei a versão beta do Visual Studio de 2012. Embora muitos dos novos recursos do Visual Studio 2012 estejam relacionados aos aplicativos do Windows 8, eu queria ver como o Visual Studio 2012 lidava com os bons aplicativos de console antigos. Fiquei agradavelmente surpreso por não ter sido desagradavelmente supreendido por nenhum dos novos recursos do Visual Studio de 2012. Minha transição para o Visual Studio 2012 foi sem esforço. Apesar de não ter feito uso do novo recurso Assíncrono do Visual Studio 2012, poderia ter sido útil para computar os valores delta para cada peso e tendência. Eu testei o novo recurso Hierarquia de Chamadas e achei útil e intuitivo. Minhas impressões iniciais do Visual Studio 2012 foram favoráveis, e pretendo migrar para ele assim que puder.

Dr. James McCaffrey trabalha para a Volt Information Sciences Inc., onde gerencia o treinamento técnico de engenheiros de software que trabalham no campus de Washington da Microsoft Redmond. Ele trabalhou em vários produtos da Microsoft, como o Internet Explorer e o MSN Busca. Ele é autor de “.NET Test Automation Recipes” (Apress, 2006) e pode ser contatado pelo email jammc@microsoft.com.

Agradecemos ao seguinte especialista técnico pela revisão deste artigo: Dan Liebling