Universidade do Algarve Faculdade de Ciências e Tecnologia Inteligência Artificial (2006-2007– 2º Semestre) Trabalho prático 2: Uma rede neuronal para o quebra-cabeças da AMI Discentes: Filipe Silva Pereira nº 17144 Renato Miguel Santos nº 24143 1. Introdução Este trabalho tem o objectivo de implementar um perceptrão multi-camada, com aprendizagem por retropropagação do erro, para dotar um resolutor do quebra-cabeças da AMI com aprendizagem. O programa recebe da linha de comandos a topologia pretendida para a rede neuronal, definindo-se assim o número de entradas, saídas, camadas e de neurónios por camada Um exemplo da linha da topologia introduzida na linha de comandos seria: >java redeNeuronal 2 2 1 Assim, a rede teria 3 camadas (uma de entrada, uma escondida e uma de saída) 2 entradas, 1 saída e um total de 3 neurónios (2+1, na camada de entrada não existem neurónios). Para facilitar a leitura dos dados obtidos e a partir deles criar os gráficos pedidos no enunciado do trabalho, o programa guarda num ficheiro "dados.csv" os dados relativos aos pesos iniciais e finais, a margem de erro utilizada, o valor do momentum e o racio utilizado para actualizar o eta, num ficheiro "etas.csv" guarda todos os etas de cada época e num ficheiro “emq.csv” guarda a média do erro médio quadrado em que cada linha nestes dois últimos ficheiros corresponde a uma época. As referências para as páginas web pesquisadas encontram-se na bibliografia. 2. Modelo da rede neuronal A rede neuronal implementada é composta por várias camadas, a primeira contém as entradas da rede e as seguintes contêm neurónios. Cada neurónio pode ter várias entradas e a cada entrada é associado um peso. Para se calcular a saída do neurónio fazse o somatório da multiplicação de cada entrada com o seu peso correspondente e aplica-se uma função sigmoide. Neste trabalho utilizámos a fórmula 1/( 1+e-x ) para o cálculo da sigmoide. A saída então calculada será uma entrada de cada neurónio da camada seguinte. Na camada de saída temos apenas um neurónio cuja saída corresponde à saída da rede. Figura 1. Modelo de uma rede neuronal 3. Algoritmo utilizado para aprendizagem Como foi referido na introdução, a rede tem aprendizagem através do algoritmo de retropropagação do erro que, resumidamente, é usado para aprender os pesos ideais para a rede utilizando a descida do gradiente para minimizar o erro quadrado entre os valores de saída e os valores esperados. A rede é inicializada com pesos aleatórios e dado um determinado conjunto de entrada calcula-se a saída. Com o valor da saída calcula-se o quadrado da diferença em relação ao valor esperado, achando-se assim o erro médio quadrado. Depois percorre-se a rede desde a camada de saída até à camada de entrada, actualizando-se os pesos de cada neurónio. Isto é feito até que seja encontrada uma condição de paragem. A condição de paragem de paragem por nós implementada consiste em arredondar os valores de saída e os valores esperados do conjunto de treino para depois compará-los. Quando todos forem iguais a rede pára. Neste algoritmo são utilizados alguns termos que é importante ter a noção do que são: Gradiente do erro – o gradiente do erro é determinado através da derivada da sigmoide multiplicado pelo erro da saída do neurónio. Erro da saída – diferença entre o valor obtido e o valor esperado Eta – corresponde ao ritmo de aprendizagem Rácio do eta – valor entre 1 e 1.99 utilizado para acelerar o ritmo de aprendizagem. Momentum – É uma constante utilizada para acelerar o processo de aprendizagem e reduzir os saltos que a curva de aprendizagem dá ao longo do processo. Delta dos pesos – A variação dos pesos é obtida através da expressão: Momentum vezes Delta pesos anterior mais eta vezes valor de saída do neurónio vezes gradiente do neurónio. [4] página 185 fórmula (6.17). 4. Desenvolvimento Tal como no primeiro trabalho, a linguagem de programação utilizada foi o Java. A rede neuronal é usada como uma função de avaliação heurística para o primeiro trabalho mas neste relatório explicamos apenas as novas classes implementadas, já que as outras classes utilizadas já estão explicadas no primeiro relatório. Este trabalho tem 3 novas classes: redeNeuronal: Implementa a rede neuronal com aprendizagem por retropropagação do erro. Tem as seguintes variáveis globais: public double[] entradasNaRede; public double[] valEsperadoRede; public Neuronio[][] rede; private double[][][] cuboDePesos; private double momentum; private double emq; //erro médio quadrado private double racioEta; private double eta; static int div; // o valor desta variável é utilizado para dividir os valores dos conjuntos de teste e de treino de modo a que estes estejam entre 0 e 1. Usa vários métodos para actualizações de valores usados no algoritmo de aprendizagem: private void actualizaDeltasR() - Percorre a rede no fim para o ínicio e actualiza cada um dos delta através da fórmula: momentum*deltaPeso + (eta*entrada*gradiente) private void actualizaPesosR() - Adiciona a cada peso o seu delta public void actualizaEta(int epoca, int aumenta, int diminui, double [] buffer, double mediaEMQ, double racio) O método de actualização dos etas utilizado foi retirados de [4] paginas 186 e 187, e é feita de 6 em 6 épocas, onde ao fim das quais existe uma actualização de eta*rácioEta ou de eta/racioEta. Aumenta quando a maioria dos elementos do buffer só aumenta ou só diminui. Caso contrario reduz-se o ritmo de aprendizagem. private void setEmq() - O erro médio quadrado é calculado através da potência de 2 do erro da rede e divide-se esse valor por 2. Depois vêm os métodos de inicialização: public redeNeuronal (int[] topologia) - Inicializa a rede com a topologia pretendida, alocando espaço para os neurónios a inicializar e guardando os valores dos pesos na variável global cuboDePesos. É aqui também que se define os valores do rácio, do momentum e do eta. Para a inicialização dos pesos temos dois métodos: private void atribuiPesosR(double [][][]pesos)- Aqui são inicializados com valores pré-definidos. private void atribuiPesosR() - Aqui são inicializados aleatoriamente. public void resetDeltaPesos() - faz reset ao deltaPesos public void correRedeFrente (double[] entradas, double[] valEsperados) - Corre a rede para a frente, fazendo todos os cálculos para se obter os valores de saída Usa uma função da classe neuronio para efectuar os cálculos a cada neurónio da rede. private double decTrunc(double valor, int decimal) - Criámos esta função para "truncar" alguns valores demasiados extensos a uma determinada casa decimal private static double[] heur( String str ) - Esta função recebe um estado do quebra-cabeças e retorna um array com o valor de várias heurísticas aplicadas ao estado recebido. public static void main(String[] args) - Recebe como argumento a topologia pretendida, define-se aqui os conjuntos de treino e de teste e chama-se as respectivas funções para treinar e testar a rede. public static char[][] strToMat (String str) - Criámos esta função para transformar uma string numa matriz com as dimensões correspondentes public void treinoDaRede(double[][][] conjTreino, FileWriter ficheiro, FileWriter ficheiroEtas) - Treina a rede com o conjunto de treino definido. Percorre várias épocas até atingir a condição de paragem. Em cada época actualiza os pesos e o eta de forma a diminuir o erro. Guarda ainda os dados relativos ao treino em ficheiros private boolean terminaAprendizagem(double[][][] conjTreino, int epoca) - Implementa a condição de paragem do treino da rede que consiste na comparação entre os valores obtidos (doubles) arredondados para o inteiro mais próximo e o valor esperado. Caso todos os elementos do conjunto de treino menos um sejam iguais, o treino pára. Optou-se por retirar um elemento desta forma porque tipicamente a rede demora muito mais tempo a aprender o último elemento, assim de acordo com a inicialização dos pesos o elemento mais difícil de aprender é ignorado. public void testeDaRede(double[][][] conjTeste) - Testa a rede a partir de um dado conjunto de teste. No fim da classe existem algumas funções para guardar os dados em ficheiros consoante o tipo de dados: private static FileWriter criaCSV(String ficheiro) private static void guardaDados(int dados, FileWriter outFile, String nomeArray) private static void guardaDados(double dados, FileWriter outFile, String nomeArray) private static void guardaDados(int[] array, FileWriter outFile, String nomeArray) private static void guardaDados(double[] array, FileWriter outFile, String nomeArray) private void guardaDadosPesos(FileWriter outFile, String nomeArray) private void guardaDadosEtas(FileWriter outFile) Neuronio: Esta classe serve para implementar cada um dos neurónios com as suas características individuais. Características essas que são usadas como variáveis globais da classe: private private private private private private private double[] entrada; double[] peso; double[] deltaPeso; double valorSaida = 0; int camada; int numero; double somatorio=0; Tem um construtor para um neurónio, recebendo a camada a que pertence, a sua posição na camada e a topologia de rede utilizada public Neuronio (int camad, int numer, int[] topologia) public void atribuiPesosN() - Atribui pesos ao neurónio a partir de um array contendo esses mesmos pesos. private void actualizaPesosN() - Actualiza os pesos do neurónio através do deltaPeso private void actualizaDeltasN() - Actualiza os deltaPesos do neurónio public void resetDeltaPesosN() - Coloca os deltaPesos do neurónio a zero Para os cálculos a usar em cada neurónio utiliza-se os seguintes métodos: private private private private void funciona(double[] entrds) void setSomatorio() double sigmoid() double derivada() HeurísticaRedeNeuronal: Esta classe implementa uma heurística utilizando a rede neuronal criada. Cria-se uma instância da rede com topologia introduzida explicitamente. Os pesos introduzidos nesta instância são os que foram obtidos na aprendizagem e também são introduzidos explicitamente. As três entradas são heurísticas implementadas no primeiro trabalho (AnéisDe4e2, Manhattan e Quinas2). Contém apenas uma função: public int valor(char[][] mat, char[][] solucao) - Corre a rede para a frente com os valores dos pesos introduzidos e o valor de saída da rede corresponde ao valor desta heurística. 5. Testes No fim testámos três topologias com cinco inicializações cada e apresentamos os resultados pedidos no enunciado que são: - O número total de pesos da rede, ou seja, a soma do tamanho dos arrays de pesos de cada neurónio. - O erro médio produzido que interpretámos como sendo o erro médio quadrado obtido em cada saída na última época. No nosso caso como só temos uma saída só apresentamos um valor. - O número de iterações necessárias para atingir a condição de paragem, ou seja, o número de épocas. 1ª Inicialização 2ª Inicialização 3ª Inicialização 4ª Inicialização 5ª Inicialização Topologia: 3 4 2 1 Nº total de pesos Erro médio 29 3,87E+12 29 5,88E+11 29 3,86E+12 29 0.0012675792931943041 29 1,50E+12 Nº de Iterações 317874 167148 381978 44719 958207 1ª Inicialização 2ª Inicialização 3ª Inicialização 4ª Inicialização 5ª Inicialização Topologia: 3 5 2 1 Nº total de pesos Erro médio 35 0.001227750035785427 Divergiu Divergiu 35 8,90E+11 35 0.0010200570846973598 35 6,82E+11 Nº de Iterações 26158 Divergiu 55528 57682 12355 1ª Inicialização 2ª Inicialização 3ª Inicialização 4ª Inicialização 5ª Inicialização Topologia: 3 5 3 1 Nº total de pesos Erro médio 42 6,09E+11 Divergiu Divergiu 42 7,30E+11 42 6,04E+10 42 7,02E+10 Nº de Iterações 31298 Divergiu 71896 200613 3876 Com base nisto escolhemos a 4ª inicialização da topologia 3-4-2-1 como a melhor inicialização. Assim foram os pesos finais obtidos nesta inicialização que introduzimos na heuristicaRedeNeuronal. De seguida apresentamos os pesos iniciais e finais desta inicialização e os gráficos correspondentes à evolução do eta e do erro médio quadrado. Não nos foi possível apresentar a curva de evolução dos erros com o eta fixo pois à hora da realização deste relatório ainda tínhamos a rede a correr nestas condições e tal iria demorar ainda algumas horas a terminar. Pesos Iniciais: 1º neurónio 0.079 2º neurónio -0.131 3º neurónio 0.305 4º neurónio 0.321 5º neurónio 0.14 6º neurónio 0.034 7º neurónio 0.333 -0.303 -0.023 0.447 0.214 0.384 -0.437 0.454 0.117 -0.492 -0.38 -0.397 0.169 -0.312 -0.004 0.373 -0.259 -0.008 -0.131 -0.416 -0.385 0.18 0.252 Pesos Finais: 1º neurónio 2º neurónio 3º neurónio 4º neurónio 5º neurónio 6º neurónio 7º neurónio 0.087 -0.083 0.834 -0.064 2.07 -2.311 2.828 0.047 -0.519 -0.186 -0.939 0.161 -0.3 -3.113 0.007 -0.299 -0.854 0.669 -0.914 0.552 -0.891 1.794 2.798 -0.126 -0.078 -0.363 0.162 -0.504 0.04 Gráfico do eta: 0,25 0,2 etas 0,15 Série1 0,1 0,05 0 1 36 71 106 141 176 211 246 281 316 351 386 421 epocas/100 Gráfico do erro médio quadrado: 0,06 0,05 EMQ 0,04 0,03 Série1 0,02 0,01 0 1 32 63 94 125 156 187 218 249 280 311 342 373 404 435 Epocas/100 6. Notas finais sobre o código feito Este trabalho consiste de 10 ficheiros de código java: 5 deles são do primeiro trabalho e não têm qualquer alteração. Tabuleiro.java IdaAst.java Heuristica.java Quinas2.java Manhattan.java O ficheiro QC.java tem uma linha alterada: a linha da declaração da heuristica, que agora é a nova heurística baseada na rede neuronal criada. O ficheiro AneisDe4e2.java tem a heuristica corrigida. Este segundo trabalho foi feito em 2 ficheiros: redeNeuronal.java e HeuristicaRedeNeuronal.java . O primeiro tem 2 classes, a classe neuronio dentro da classe redeNeuronal. O segundo é a nova heurística, que consiste na criação de um instância de uma rede neuronal, escrevendo explicitamente no código a topologia e os pesos aleatórios que consideramos mais adequados. O critério escolhido foi a aprendizagem mais curta da topologia mais pequena que obtemos. O objectivo final é puder correr o primeiro trabalho com uma heurística bastante mais eficiente. O trabalho que entregamos, a rede está sobre treinada, ou seja, resolve bem, os elementos aprendidos, mas comete erros para outros valores. A forma de correr o primeiro trabalho está explicada nesse relatório. Existem 2 ficheiros com a função main. Ou seja, o QC.java, que é o ficheiro relativo ao primeiro trabalho, e o ficheiro redeNeuronal.java. Para correr o ficheiro redeNeuronal tem que se depois de compilar, escrever como argumentos a topologia da rede: ~/redeneuronal $ javac *.java ~/redeneuronal $ java redeNeuronal 3 4 2 1 Quando se corre desta forma está-se a treinar a rede com pesos gerados aleatoriamente. O output que se apresenta quando se corre o programa é primeiro, o nome dos ficheiro para os quais se está a enviar dados, e depois, de 50 em 50 épocas de treino mostra-se quantos valores já se aprendeu, o erro médio quadrado do valor devolvido pela rede e o valor do eta (o ritmo de aprendizagem). 7. Conclusão Após a realização deste trabalho e dos testes realizados verificámos que a nossa rede demora muito tempo a aprender. Isto dá-se devido às heurísticas que utilizámos, pois com heurísticas mais adequadas teria-se convergências mais rápidas. De notar ainda que não se convém usar uma topologia que contenha camadas com muitos neurónios porque assim a rede fica demasiado especializada. 8. Bibliografia [1] Ernesto Costa e Anabela Simões, Inteligência Artificial: Fundamentos e Aplicações, FCA, Fev. 2004 [2] Stuart Russel and Peter Norvig, Artificial Intelligence: A modern approach, 2nd edition, Pearson Education, 2003. [3] Inteligência Artificial 2006/2007, “http://w3.ualg.pt/~jvo/ia/” [4] Michael Negnevitsky, Artificial Intelligence: A Guide to Intelligent Systems, Addison-Wesley, 2005