En el 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.
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:
umbral
: Variable de tipo double
donde se almacena el valor del umbral de la neurona.pesos
: Arreglo de double
donde se almacenan los pesos de la neurona.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:
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.Sigmoide
: Este método recibe un double
x
y retorna la función Sigmoidea evaluada en ese valor.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
.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:
neuronas
: Objeto de tipo ArrayList<Neurona>
para almacenar las neuronas de la capa.salidas
: Arreglo de doubles
que almacena en la posición i
la salida correspondiente a la neurona i
.Y con los siguientes métodos:
Constructor
: Recibe tres parámetros, número de entradas de la capa, número de neuronas de la capa y un objeto Random
.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.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:
capas
: Objeto de tipo ArrayList<Capa>
para almacenar las capas que forman al perceptrón.deltas
: Objeto de tipo ArrayList<double[][]>
para almacenar las derivadas $\frac{\delta error}{\delta w_{i,j}^{k}}$.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;
}
}
}
}
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:
entradasPruebas
: Lista de arreglos de double
donde se almacenan todos los datos de entrada.salidasPruebas
: Lista de arreglos de double
donde se almacenan las respectivas salidas para cada entrada.alfa
: Valor que representa a la razón de aprendizaje.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:
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:
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
.
Con un Perceptrón multicapa se pueden resolver dos tipos de problemas:
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.