Binary Coffee

Programando un Perceptrón Multicapa en Java.

java AI
En el post [Un acercamiento a las Redes Neuronales](https://binary-coffee.dev/post/un-acercamiento-a-las-redes-neuronales) estuve explicando todo el modelo matemático y los algoritmos que se necesitan para diseñar un **Perceptrón Multicapa**. En este post les mostraré una implementación utilizando el lenguaje de programación *Java*. Recordando la arquitectura de este modelo, todo *Perceptrón multicapa* está compuesto por una capa de entrada, una capa de salida y una o más capas ocultas; aunque se ha demostrado que para la mayoría de problemas bastará con una sola capa oculta. Las conexiones entre neuronas son siempre hacia delante: las conexiones van desde las neuronas de una determinada capa hasta las neuronas de la capa siguiente; no existen conexiones laterales ni conexiones hacia atrás. Por tanto la información siempre es transmitida desde la capa de entrada a la capa de salida. Dicho esto pongámonos a trabajar... Para hacer el código lo más estructurado posible crearemos tres clases. * Antes que todo sería bueno aclarar lo siguiente, cuando se explicó el modelo matemático se trataron los índices comenzando desde 1 pero a la hora de programarlo los índices comenzarán en 0. ## Clase neurona Esta primera clase contiene la definición de lo que es una Neurona y todas sus funcionalidades: ``` public class Neurona { public double umbral; public double[] pesos; double sumaPonderada; Neurona(int numEntradas, Random r){ umbral = r.nextDouble(); pesos = new double[numEntradas]; for(int i = 0; i < pesos.length; i++){ pesos[i] = r.nextDouble(); } } public double Activacion(double[] entradas){ sumaPonderada = umbral; for(int i = 0; i < entradas.length; i++){ sumaPonderada += entradas[i] * pesos[i]; } return Sigmoide(sumaPonderada); } public double Sigmoide(double x){ return 1 / (1 + Math.exp(-x)); } } ``` Cuenta con los siguientes atributos de clase: 1. ```umbral```: Variable de tipo ```double``` donde se almacena el valor del umbral de la neurona. 2. ```pesos```: Arreglo de ```double``` donde se almacenan los pesos de la neurona. 3. ```sumaPonderada```: Variable de tipo ```double``` donde se almacena $u_i^k + \sum_{j=1}^{n_{k-1}}a_j^{k-1}w_{j,i}^{k-1}$. Y con los siguientes métodos: 1. ```Constructor```: Receive dos parámetros, el primero, un entero que representa el número de conexiones de entrada de la neurona y el segundo un objeto de tipo ```Random``` que se emplea para inicializar los pesos y el umbral. Este objeto se le pasa como parámetro ya que a la hora de crear todas las neuronas de nuestro Perceptrón se necesita que el ```Random``` tenga la misma semilla para todas. 2. ```Sigmoide```: Este método recibe un ```double``` ```x``` y retorna la función *Sigmoidea* evaluada en ese valor. 3. ```Activacion```: Este método retorna $F(u_i^k + \sum_{j=1}^{n_{k-1}}a_j^{k-1}w_{j,i}^{k-1})$ donde ```F``` es la *Sigmoidea* y $a_j^{k-1}$ son los valores que se pasan en ```double[] entradas```. ## Clase Capa ``` public class Capa { public ArrayList<Neurona> neuronas; public double[] salidas; Capa(int numEntradas, int numNeuronas, Random r){ neuronas = new ArrayList<Neurona>(); for(int i = 0; i < numNeuronas; i++){ neuronas.add(new Neurona(numEntradas, r)); } } public double[] Activacion(double[] entradas){ salidas = new double[neuronas.size()]; for(int i = 0; i < neuronas.size(); i++){ salidas[i] = neuronas.get(i).Activacion(entradas); } return salidas; } } ``` Esta clase define a una capa con su conjunto de neuronas y sus salidas. Cuenta con los siguientes atributos de clase: 1. ```neuronas```: Objeto de tipo ```ArrayList<Neurona>``` para almacenar las neuronas de la capa. 2. ```salidas```: Arreglo de ```doubles``` que almacena en la posición ```i``` la salida correspondiente a la neurona ```i```. Y con los siguientes métodos: 1. ```Constructor```: Recibe tres parámetros, número de entradas de la capa, número de neuronas de la capa y un objeto ```Random```. 2. ```Activacion```: Este método recibe un arreglo de ```double``` que contiene los valores de las entradas a cada una de las neuronas de la capa y retorna un arreglo de ```double``` con las salidas de dichas neuronas. ## Clase Perceptrón En esta clase implementaremos toda las funcionalidades necesarias para que nuestra red sea entrenada. ``` public class Perceptron { public ArrayList<Capa> capas; ArrayList<double[]> sigmas; ArrayList<double[][]> deltas; public Perceptron(int[] numNeuronasPorCapa){ capas = new ArrayList<Capa>(); Random r = new Random(); for(int i = 0; i < numNeuronasPorCapa.length; i++){ if(i == 0){ capas.add(new Capa(numNeuronasPorCapa[i], numNeuronasPorCapa[i], r)); }else{ capas.add(new Capa(numNeuronasPorCapa[i - 1], numNeuronasPorCapa[i], r)); } } } } ``` Esta clase cuenta con los siguientes atributos: 1. ```capas```: Objeto de tipo ```ArrayList<Capa>``` para almacenar las capas que forman al perceptrón. 2. ```deltas```: Objeto de tipo ```ArrayList<double[][]>``` para almacenar las derivadas $\frac{\delta error}{\delta w_{i,j}^{k}}$. 3. ```sigmas```: objeto de tipo ```ArrayList<double[]>``` que permite ir calculando las derivadas parciales de forma dinámica capa por capa y así cuando se este calculando las derivadas de las neuronas de la capa i ya previamente se han calculado las derivadas de la capa i+1, esto es así porque el proceso de *Back propagation* se hace desde la capa de salida hasta la capa de entrada. El constructor de esta clase recibe un arreglo de enteros ```numNeuronasPorCapa``` donde se almacenan en la posición ```i``` el número de neuronas de la capa ```i```. A la hora de crear las capas hay que tener en cuenta que en la capa 0 el número de entradas es el número de entradas de la red, para las demás capas el número de entradas es la cantidad de neuronas de la capa anterior. Ahora veremos todos los métodos con los que cuenta esta clase. El primero de los siguientes métodos retorna la función *Sigmoidea* evaluada en ```x```. El segundo retorna la derivada de la ```Sigmoidea``` evaluada en ```x```. ``` public double Sigmoide(double x){ return 1 / (1 + Math.exp(-x)); } public double SigmoideDerivada(double x){ double y = Sigmoide(x); return y*(1 - y); } ``` El método ```Activacion``` retorna la salida de nuestra red. ``` public double[] Activacion(double[] entradas){ double[] salidas = new double[0]; for(int i = 0; i < capas.size(); i++){ salidas = capas.get(i).Activacion(entradas); entradas = salidas; } return salidas; } ``` Con los siguientes métodos calculamos el error de nuestra red. El primero retorna el error para un solo conjunto de entrenamiento y el segundo retorna el error para todos nuestros datos de entrenamiento. ``` public double Error(double[] salidaReal, double[] salidaEsperada){ double err = 0; for(int i = 0; i < salidaReal.length; i++){ err += 0.5 * Math.pow(salidaReal[i] - salidaEsperada[i], 2); } return err; } public double ErrorTotal(ArrayList<double[]> entradas, ArrayList<double[]> salidaEsperada){ double err = 0; for(int i = 0; i < entradas.size(); i++){ err += Error(Activacion(entradas.get(i)), salidaEsperada.get(i)); } return err; } ``` La siguiente función inicializa todos los deltas a 0. ``` public void initDeltas(){ deltas = new ArrayList<double[][]>(); for(int i = 0; i < capas.size(); i++){ deltas.add(new double[capas.get(i).neuronas.size()][capas.get(i).neuronas.get(0).pesos.length]); for(int j = 0; j < capas.get(i).neuronas.size(); j++){ for(int k = 0; k < capas.get(i).neuronas.get(0).pesos.length; k++){ deltas.get(i)[j][k] = 0; } } } } ``` Para el cálculo de los ```sigmas``` utilizamos la siguiente función: ``` public void calcSigmas(double[] salidaEsperada){ sigmas = new ArrayList<double[]>(); for(int i = 0; i < capas.size(); i++){ sigmas.add(new double[capas.get(i).neuronas.size()]); } for(int i = capas.size() - 1; i >= 0; i--){ for(int j = 0; j < capas.get(i).neuronas.size(); j++){ if(i == capas.size() - 1){ double y = capas.get(i).salidas[j]; sigmas.get(i)[j] = (y - salidaEsperada[j]) * SigmoideDerivada(y); }else{ double sum = 0; for(int k = 0; k < capas.get(i + 1).neuronas.size(); k++){ sum += capas.get(i + 1).neuronas.get(k).pesos[j] * sigmas.get(i + 1)[k]; } sigmas.get(i)[j] = SigmoideDerivada(capas.get(i).neuronas.get(j).sumaPonderada) * sum; } } } } ``` * Este procedimiento sale del método para el cálculo de las derivadas parciales descrito en el post anterior. Para el cálculo de los ```deltas``` empleamos los ```sigmas``` previamente calculados: ``` public void calcDeltas(){ for(int i = 1; i < capas.size(); i++){ for(int j = 0; j < capas.get(i).neuronas.size(); j++){ for(int k = 0; k < capas.get(i).neuronas.get(j).pesos.length; k++){ deltas.get(i)[j][k] += sigmas.get(i)[j] * capas.get(i - 1).salidas[k]; } } } } ``` Ya con estas dos funciones procedemos a implementar los métodos que permiten actualizar los pesos y los umbrales: ``` public void actPesos(double alfa){ for(int i = 0; i < capas.size(); i++){ for(int j = 0; j < capas.get(i).neuronas.size(); j++){ for(int k = 0; k < capas.get(i).neuronas.get(j).pesos.length; k++){ capas.get(i).neuronas.get(j).pesos[k] -= alfa * deltas.get(i)[j][k]; } } } } public void actUmbrales(double alfa){ for(int i = 0; i < capas.size(); i++){ for(int j = 0; j < capas.get(i).neuronas.size(); j++){ capas.get(i).neuronas.get(j).umbral -= alfa * sigmas.get(i)[j]; } } } ``` Note que estos métodos hacen uso de la fórmula del *Descenso del Gradiente*. Ambos métodos reciben la *razón de aprendizaje* como parámetro. Ahora ya podemos definir el algoritmo de **Back Propagation**: ``` public void BackPropagation(ArrayList<double[]> entradas, ArrayList<double[]> salidaEsperada, double alfa){ initDeltas(); for(int i = 0; i < entradas.size(); i++){ Activacion(entradas.get(i)); calcSigmas(salidaEsperada.get(i)); calcDeltas(); actUmbrales(alfa); } actPesos(alfa); } ``` Para cada conjunto de entrada posible calculamos la salida de la red y propagamos el error hacia atrás, o lo que es lo mismo, calculamos las derivadas de todos los pesos y los umbrales con respecto al error. Luego actualizamos los pesos y los umbrales según la fórmula del *Descenso del gradiente*. Note que para actualizar los pesos hay que esperar haber calculado todas las derivadas asociadas a una entrada. Por último implementamos el método que entrena a la red: ``` public void Entrenar(ArrayList<double[]> entradasPruebas, ArrayList<double[]> salidasPruebas, double alfa, double maxError){ double err = 99999999; while(err > maxError){ BackPropagation(entradasPruebas, salidasPruebas, alfa); err = ErrorTotal(entradasPruebas, salidasPruebas); System.out.println(err); } } ``` Este método recibe: 1. ```entradasPruebas```: Lista de arreglos de ```double``` donde se almacenan todos los datos de entrada. 2. ```salidasPruebas```: Lista de arreglos de ```double``` donde se almacenan las respectivas salidas para cada entrada. 3. ```alfa```: Valor que representa a la razón de aprendizaje. 4. ```maxError```: Máximo error permitido. Se agregó la línea ```System.out.println(err)``` para luego utilizar esos valores. Ahora veremos a nuestra red en funcionamiento. ``` public class App { public static void main(String[] args) { ArrayList<double[]> entradas = new ArrayList<double[]>(); ArrayList<double[]> salidas = new ArrayList<double[]>(); for(int i = 0; i < 4; i++){ entradas.add(new double[2]); salidas.add(new double[1]); } entradas.get(0)[0] = 0; entradas.get(0)[1] = 0; salidas.get(0)[0] = 1; entradas.get(1)[0] = 0; entradas.get(1)[1] = 1; salidas.get(1)[0] = 0; entradas.get(2)[0] = 1; entradas.get(2)[1] = 0; salidas.get(2)[0] = 0; entradas.get(3)[0] = 1; entradas.get(3)[1] = 1; salidas.get(3)[0] = 0; Perceptron p = new Perceptron(new int[]{entradas.get(0).length, 3, salidas.get(0).length}); p.Entrenar(entradas, salidas, 0.5, 0.01); Scanner read = new Scanner(System.in); while(true){ double a = read.nextDouble(); double b = read.nextDouble(); System.out.println(p.Activacion(new double[]{a, b})[0]); } } } ``` En este ejemplo trataremos que nuestra red aprenda la compuerta ```NOR```: A | B | A NOR B ---------|----------|--------- 0 | 0 | 1 0 | 1 | 0 1 | 0 | 0 1 | 1 | 0 Para ello crearemos una red neuronal con: * 2 entradas. * 1 salida. * 2 neuronas en la capa de entrada. * 3 neuronas en la capa oculta. * 1 neurona de salida. * 0.5 de razón de aprendizaje * 0.01 de máximo error permisible. La salida de nuestra red es la siguiente: A | B | Salida ---------|----------|--------- 0 | 0 | 0.86 0 | 1 | 0.04 1 | 0 | 0.002 1 | 1 | 3.3E-4 Con un error de 0.0198. Como ven nuestra red logró aprender la función NOR. Si graficamos los valores del error en un sistema de coordenadas nos da el siguiente resultado: ![alt](https://api.binary-coffee.dev/uploads/curva_error_ced0a049b3.png) Esto lo podemos hacer fácilmente si estamos en linux ejecutando: ``` javac App.java java App > salida.csv ``` Y luego abrimos el archivo ```salida.csv``` en ```LibreOffice Math```. ### Recomendaciones Con un Perceptrón multicapa se pueden resolver dos tipos de problemas: 1. Clasificación 2. Regresión El ejemplo descrito se encuentra en el primer grupo donde nuestra red tiene que determinar cuando la salida es 0 o 1. Un ejemplo de Regresión es cuando queremos encortar una función que describa el comportamiento de un conjunto de valores determinados. Esto también es posible hacerlo con la implementación descrita anterior mente. Pero para hacer esto se deben de normalizar los valores de entrada al mismo rango de la función de activación. Esto lo podemos hacer empleando la fórmula siguiente: ``` valor_normalizado = (valor - minValor)/(maxValor - minValor); ``` Donde el ```minValor``` es el mínimo valor de todos los valores de entrada y el ```maxValor``` es el máximo de todos los valores de entrada. A la hora de utilizar las salidas se deberá proceder a la desnormalización despejando el ```valor```. Otra recomendación es que el código mostrado no resuelve el problema de los mínimos locales, problema que se suele resolver reiniciando el proceso de entrenamiento después de un número de épocas determinadas. Hasta aquí este post, cabe resaltar que en el caso que necesitemos una Red Neuronal no la tenemos que programar nosotros, existen librerías muy famosas que ya cuentan con todas las funcionalidades necesarias en este campo, incluso entrenar una Red Neuronal es un proceso que en la mayoría de los casos tardaría mucho tiempo por lo que estas librerías ya cuentan con modelos pre-entrenados que se pueden utilizar perfectamente en nuestras aplicaciones. Pero siempre es bueno comprender los fundamentos y las bases de estos sistemas.
Opiniones
noavatar
Podrias hacer un ejemplo en/para android studio. Tienes otros ejemplos, estoy tratando de entender redes neuronales, por que quiero hacer una que compare imágenes pero en android stuido, con java