Setembro de 2017
Volume 32 - Número 9
Execução de teste - Treinamento da rede neural profunda
Por James McCaffrey
Uma rede neural de encaminhamento do feed (FNN) regular possui um conjunto de nós de entrada, um conjunto de nós de processamento ocultos e um conjunto de nós de saída. Por exemplo, se quisesse prever a inclinação política de uma pessoa (conservadora, moderada, liberal) com base na sua idade e na renda, você poderia criar uma FNN com dois nós de entrada, oito nós ocultos e três nós de saída. O número de nós de entrada e de saída é determinado pela estrutura dos seus dados, mas o número de nós ocultos é um parâmetro livre determinado por meio de tentativa e erro.
Uma rede neural profunda (DNN) possui duas ou mais camadas ocultas. Neste artigo, explicarei como treinar uma DNN usando o algoritmo de propagação de retorno e descrever o problema do "gradiente de fuga" associado. Depois de ler este artigo, você terá um código para testar e entenderá melhor o que se passa nos bastidores ao usar uma biblioteca de rede neural, como o Microsoft Cognitive Toolkit (CNTK) ou o Google TensorFlow.
Examine a DNN da Figura 1. Ela tem três camadas ocultas com quatro, dois e dois nós. Os valores dos nós de entrada são 3,80 e 5,00, que você pode imaginar como a idade normalizada (38 anos) e a renda (US$ 50.000 anual) de uma pessoa. Cada um dos oito nós ocultos tem um valor entre -1,0 e +1,0 porque a DNN usa a ativação tanh.
Figura 1 Um exemplo de rede neural profunda 2-(4,2,2)-3
Os valores preliminares dos nós de saída são (-0,109, 3,015, 1,202). Esses valores são convertidos nos valores de saída final de (0,036, 0,829, 0,135) usando a função softmax. O objetivo do softmax é forçar os valores dos nós de saída a somar 1,0, para poderem ser interpretados como probabilidades. Supondo que os nós de saída representam as probabilidades de "conservador", "moderado" e "liberal", respectivamente, a pessoa nesse exemplo está prevista para ser um moderado político.
Na Figura 1, cada uma das setas que conectam os nós representa uma constante numérica denominada peso. Os valores de peso estão geralmente entre -10,0 e +10,0, mas podem ser qualquer valor em princípio. Cada uma das pequenas setas que apontam para os nós ocultos e os nós de saída é uma constante numérica denominada desvio. Os valores dos pesos e dos desvios determinam os valores dos nós de saída.
O processo de localização dos valores dos pesos e dos desvios é chamado de treinamento da rede. A ideia é usar um grande conjunto de dados de treinamento, com valores de entrada conhecidos e valores de saída corretos conhecidos e, em seguida, usar um algoritmo de otimização para encontrar valores de pesos e desvios para que a diferença entre os valores de saída calculados e os valores de saída corretos conhecidos seja minimizada. Há vários algoritmos que podem ser usados para treinar uma DNN, mas, sem dúvida, o mais comum é o algoritmo de propagação de retorno.
Este artigo supõe que você tenha uma compreensão básica do mecanismo de entrada-saída da rede neural e, no mínimo, habilidades de programação de nível intermediário. O programa de demonstração é codificado com C#, mas você não deverá ter muitos problemas para refatorar a demonstração para outro idioma, como Python ou Java, se desejar. O programa de demonstração é muito longo para ser apresentado por completo neste artigo, mas o programa completo está disponível no download do código que acompanha este artigo.
O programa de demonstração
A demonstração começa pela geração de 2.000 itens de dados de treinamento sintéticos. Cada item possui quatro valores de entrada entre -4,0 e +4,0, seguidos de três valores de saída, que podem ser (1, 0, 0) ou (0, 1, 0) ou (0, 0, 1), representando três valores categóricos possíveis. Os primeiros e últimos itens de treinamento são:
[ 0] 2.24 1.91 2.52 2.41 0.00 1.00 0.00
...
[1999] 1.30 -2.41 -3.18 0.11 1.00 0.00 0.00
Por trás das cenas, os dados fictícios são gerados criando uma DNN 4-(10,10,10)-3 com valores aleatórios de pesos e desvios e, em seguida, alimentando valores de entrada aleatórios para a rede. Após a geração dos dados de treinamento, a demonstração cria uma nova DNN 4-(10,10,10)-3 e treina-a usando o algoritmo de propagação de retorno. Durante o treinamento, o erro médio quadrático atual e a precisão da classificação são exibidos a cada 200 iterações.
O erro diminui lentamente e a precisão aumenta lentamente, conforme esperado. Após a conclusão do treinamento, a precisão final do modelo de DNN é 93,45 por cento, o que significa que 0,9345 * 2000 = 1869 itens foram corretamente classificados e, portanto, 131 itens foram incorretamente classificados. O código de demonstração que gera a saída começa com:
using System;
namespace DeepNetTrain
{
class DeepNetTrainProgram {
static void Main(string[] args) {
Console.WriteLine("Begin deep net demo");
int numInput = 4;
int[] numHidden = new int[] { 10, 10, 10 };
int numOutput = 3;
...
O programa de demonstração usa apenas C# simples sem namespaces, com exceção do sistema. Primeiro, a DNN para gerar os dados de treinamento simulados é preparada. O número de camadas ocultas (3) é transmitido implicitamente como o número de itens na matriz numHidden. Uma outra alternativa é transmitir o número de camadas ocultas explicitamente. Em seguida, os dados de treinamento são gerados usando o método auxiliar MakeData:
int numDataItems = 2000;
Console.WriteLine("Generating " + numDataItems +
" artificial training data items ");
double[][] trainData = MakeData(numDataItems,
numInput, numHidden, numOutput, 5);
Console.WriteLine("Done. Training data is: ");
ShowMatrix(trainData, 3, 2, true);
O 5 passado para MakeData é um valor de propagação para um objeto aleatório, de modo que as execuções de demonstração serão reproduzíveis. O valor 5 foi usado apenas porque gerou uma bela demonstração. A chamada para o ShowMatrix auxiliar mostra as 3 primeiras linhas e a última linha dos dados gerados, com 2 casas decimais, mostrando índices (verdadeiro). Em seguida, a DNN é criada e o treinamento é preparado:
Console.WriteLine("Creating a 4-(10,10,10)-3 DNN");
DeepNet dn = new DeepNet(numInput, numHidden, numOutput);
int maxEpochs = 2000;
double learnRate = 0.001;
double momentum = 0.01;
A demonstração usa uma classe DeepNet definida pelo programa. O algoritmo de propagação de retorno é iterativo, portanto, um número máximo de iterações, 2,000 neste caso, deve ser especificado. O parâmetro de taxa de aprendizado controla quanto os valores de peso e de desvio são ajustados sempre que um item de treinamento é processado. Uma pequena taxa de aprendizado pode resultar em treinamento lento demais (horas, dias ou mais), mas uma grande taxa de aprendizado pode levar a resultados extremamente oscilantes que nunca se estabilizam. Escolher uma boa taxa de aprendizado é uma questão de tentativa e erro e é um grande desafio ao trabalhar com DNNs. O fator de momentum é semelhante a uma taxa de aprendizado auxiliar e geralmente acelera o treinamento quando uma pequena taxa de aprendizado é usada.
O código de chamada do programa de demonstração conclui com:
...
double[] wts = dn.Train(trainData, maxEpochs,
learnRate, momentum, 10);
Console.WriteLine("Training complete");
double trainError = dn.Error(trainData, false);
double trainAcc = dn.Accuracy(trainData, false);
Console.WriteLine("Final model MS error = " +
trainError.ToString("F4"));
Console.WriteLine("Final model accuracy = " +
trainAcc.ToString("F4"));
Console.WriteLine("End demo ");
}
O método Train usa o algoritmo de propagação de retorno para encontrar valores para pesos e desvios, de modo que a diferença entre os valores de saída calculados e os valores de saída corretos seja minimizada. Os valores dos pesos e dos desvios são retornados pelo Train. O argumento 10 passado para o Train significa exibir mensagens de andamento a cada 2.000 / 10 = 200 iterações. É importante monitorar o andamento, pois situações ruins podem, e muitas vezes, acontecem ao se treinar uma rede neural.
Após a conclusão do treinamento, o erro final e a precisão do modelo são calculados e exibidos usando os pesos finais e os valores de desvio, que ainda estarão dentro da DNN. Os pesos e os desvios poderiam ter sido explicitamente recarregados executando a instrução dnn.SetWeights (wts), mas não é necessário neste caso. Os argumentos "false" passados para os métodos Error e Accuracy significam não exibir mensagens de diagnóstico.
Gradientes e pesos da rede neural profunda
Cada peso e desvio em uma DNN tem um valor de gradiente associado. Um gradiente é um derivado de cálculo da função de erro e é apenas um valor, por exemplo, -1,53, onde o sinal do gradiente informa se o peso ou o desvio associado deve ser aumentado ou diminuído para reduzir o erro, e a magnitude do gradiente é proporcional a quanto o peso ou o desvio deve mudar. Por exemplo, suponha que um dos pesos, w, em uma DNN tenha o valor +4,36 e depois de um item de treinamento ser processado, o cálculo do gradiente do peso, g, seja +2,50. Se a taxa de aprendizado, lr, estiver definida para 0,10, o novo valor de peso será:
w = w + (lr * g)= 4.36 + (0.10 * 2.50)= 4.36 + 0.25= 4.61
Portanto, treinar uma DNN realmente resume-se a encontrar os gradientes para cada valor de peso e de desvio. Como se verifica, o cálculo dos gradientes dos pesos que conectam os últimos nós da camada oculta aos nós da camada de saída e dos gradientes dos desvios do nó de saída é relativamente fácil mesmo que a matemática subjacente seja extraordinariamente profunda. Expressos em código, o primeiro passo consiste em calcular os assim chamados sinais de nó de saída de cada nó de saída:
for (int k = 0; k < nOutput; ++k) {
errorSignal = tValues[k] - oNodes[k];
derivative = (1 - oNodes[k]) * oNodes[k];
oSignals[k] = errorSignal * derivative;
}
A variável local errorSignal é a diferença entre o valor de destino (o valor correto do nó dos dados de treinamento) e o valor do nó de saída calculado. Os detalhes podem ser muito complicados. Por exemplo, o código de demonstração usa (destino - saída), mas algumas referências usam (saída - destion), que afeta se a instrução de atualização de peso associada deve ser adicionada ou subtraída ao modificar pesos.
O derivado da variável local é um derivado de cálculo (não o mesmo que o gradiente, que também é um derivad) da função de ativação de saída, que, neste caso, é a função softmax. Em outras palavras, se usar algo diferente de softmax, você deverá modificar o cálculo da variável local derivada.
Depois que os sinais de nó de saída forem calculados, eles poderão ser usados para calcular os gradientes dos pesos de oculto-para-saída:
for (int j = 0; j < nHidden[lastLayer]; ++j) {
for (int k = 0; k < nOutput; ++k) {
hoGrads[j][k] = hNodes[lastLayer][j] * oSignals[k];
}
}
Em palavras, o gradiente de um peso que conecta um nó oculto a um nó de saída é o valor do nó oculto vezes o sinal de saída do nó de saída. Depois que o gradiente associado a um peso de oculto-para-saída tiver sido calculado, o peso poderá ser atualizado:
for (int j = 0; j < nHidden[lastLayer]; ++j) {
for (int k = 0; k < nOutput; ++k) {
double delta = hoGrads[j][k] * learnRate;
hoWeights[j][k] += delta;
hoWeights[j][k] += hoPrevWeightsDelta[j][k] * momentum;
hoPrevWeightsDelta[j][k] = delta;
}
}
Primeiro, o peso é incrementado por delta, que é o valor do gradiente vezes a taxa de aprendizado. Em seguida, o peso é incrementado por um valor adicional — o produto do delta anterior vezes o fator de momentum. Observe que o uso de momentum é opcional, mas quase sempre ele é usado para aumentar a velocidade de treinamento.
Recapitulando, para atualizar um peso de oculto-para-saída, você calcula um sinal de nó de saída, que depende da diferença entre o valor de destino e o valor calculado, e o derivado da função de ativação do nó de saída (geralmente softmax). Em seguida, você usa o sinal do nó de saída e o valor do nó oculto para calcular o gradiente. Depois disso, você usa o gradiente e a taxa de aprendizado para calcular um delta para o peso e atualizar o peso usando o delta.
Infelizmente, calcular os gradientes dos pesos de entrada-para-oculto e os pesos de oculto-para-oculto é muito mais complicado. Uma explicação completa levaria páginas e páginas, mas você poderá ter uma boa ideia do processo examinando uma parte do código:
int lastLayer = nLayers - 1;
for (int j = 0; j < nHidden[lastLayer]; ++j) {
derivative = (1 + hNodes[lastLayer][j]) *
(1 - hNodes[lastLayer][j]); // For tanh
double sum = 0.0;
for (int k = 0; k < nOutput; ++k) {
sum += oSignals[k] * hoWeights[j][k];
}
hSignals[lastLayer][j] = derivative * sum;
}
Este código calcula os sinais dos últimos nós da camada oculta - aqueles imediatamente anteriores aos nós de saída. O derivado da variável local é o derivado de cálculo da função de ativação da camada oculta, tanh neste caso. Mas os sinais ocultos dependem de uma soma de produtos que envolve os sinais de nó de saída. Isso resulta no problema do "gradiente de fuga".
O problema do gradiente de fuga
Quando você usa o algoritmo de propagação de retorno para treinar uma DNN, durante o treinamento, os valores de gradiente associados aos pesos de oculto-para-oculto rapidamente tornam-se muito pequenos ou até mesmo zero. Se um valor de gradiente for zero, o gradiente vezes a taxa de aprendizado será zero e o delta do peso será zero, e o peso não será alterado. Mesmo que um gradiente não atinja zero, mas reduza muito, o delta será pequeno e o treinamento ficará extremamente lento.
O motivo pelo qual os gradientes rapidamente diminuem até zero deverá ser claro se você examinar cuidadosamente o código de demonstração. Como os valores dos nós de saída são forçados a probabilidades, eles estão todos entre 0 e 1. Isso resulta em sinais de nós de saída que estão entre 0 e 1. A parte da multiplicação do cálculo dos sinais de nó oculto envolve, portanto, a multiplicação repetida de valores entre 0 e 1, o que resultará em gradientes cada vez menores. Por exemplo, 0,5 * 0,5 * 0,5 * 0,5 = 0,0625. Além disso, a função tanh de ativação de camada oculta introduz outro termo de fração-vezes-fração.
O programa de demonstração ilustra o problema de gradiente de fuga espionando o gradiente associado ao peso do nó 0 na camada de entrada ao nó 0 na primeira camada oculta. O gradiente desse peso diminui rapidamente:
epoch = 200 gradient = -0.002536
epoch = 400 gradient = -0.000551
epoch = 600 gradient = -0.000141
epoch = 800 gradient = -0.159148
epoch = 1000 gradient = -0.000009
...
O gradiente salta temporariamente na epoch 800, pois a demonstração atualiza os pesos e os desvios depois que cada item de treinamento é processado (isso é chamado de treinamento "alheatório" ou "online", em oposição ao treinamento "em lote" ou "em minilote") e, por puro acaso, o item de treinamento processado no epoch 800 resultou em um gradiente maior do que o normal.
Nos primórdios das DNNs, talvez há 25 a 30 anos atrás, o problema do gradiente de fuga era algo impressionante. À medida que a capacidade de computação aumentou, o gradiente de fuga tornou-se um problema menor, pois o treinamento poderia diminuir essa velocidade um pouco. Mas, com o surgimento de redes muito profundas, com centenas ou até mesmo milhares de camadas ocultas, o problema ressurgiu.
Muitas técnicas foram desenvolvidas para enfrentar o problema do gradiente de fuga. Uma abordagem consiste em usar a função ReLU (unidade linear retificada) em vez da função tanh para ativação da camada oculta. Outra abordagem é usar diferentes taxas de aprendizado para camadas diferentes - taxas maiores para as camadas mais próximas da camada de entrada. E, agora, a norma é o uso de GPUs para aprendizagem profunda. Uma abordagem radical consiste em evitar completamente a propagação de retorno e, em vez disso, usar um algoritmo de otimização que não exija gradientes, como a otimização de inúmeras partículas.
Conclusão
O termo rede neural profunda geralmente se refere ao tipo de rede descrita neste artigo - uma rede totalmente conectada com várias camadas ocultas. Mas há muitos outros tipos de redes neurais profundas. As redes neurais convolucionais são muito boas na classificação de imagem. As redes longas de memória de curto prazo são muito boas no processamento de linguagem natural. A maioria das variações de redes neurais profundas usam alguma forma de propagação de retorno e estão sujeitas ao problema do gradiente de fuga.
Dr. James McCaffreytrabalha para a Microsoft Research em Redmond, Wash. Ele trabalhou em vários produtos da Microsoft, incluindo Internet Explorer e Bing. Entre em contato com o Dr. McCaffrey em jamccaff@microsoft.com.
Agradecemos aos seguintes especialistas técnicos da Microsoft pela revisão deste artigo: Chris Lee e Adith Swaminathan