Algoritmo de retro propagación
Uno de los algoritmos más populares para el entrenamiento de redes neuronales artificiales multicapa es el algoritmo de propagación de errores hacia atrás, o algoritmo de retro propagación. En este artículo voy a tratar de explicar sus fundamentos, mediante una implementación simplificada de una red neuronal que permite realizar pruebas con diferentes configuraciones de la red.
En este enlace podéis descargar el código fuente de la solución BackPropagationDemo, con los ejemplos de código y datos utilizados en este artículo. Está escrito en csharp con Visual Studio 2017.
El código está basado en el de este post de Andrew Kirillov en CodeProject, aunque yo lo he simplificado mucho para que resulte más sencillo centrarnos en el algoritmo de aprendizaje.
Para poder aplicar este algoritmo, la red debe ser de tipo feedforward; es decir, debe tener más de una capa de neuronas y estas no deben formar ciclos, las salidas de una capa alimentan únicamente las entradas de la siguiente, como en esta figura:
Los datos de entrada de la red están representados por los círculos más claros de la izquierda; la red tiene tres capas intermedias de 5, 4 y 3 neuronas y una capa de salida con una única neurona. Cada neurona tiene una sola salida, que se aplica como entrada a todas las neuronas de la siguiente capa, y tantas entradas como neuronas haya en la capa anterior.
Cada neurona aplica un peso a cada una de sus entradas, es decir, multiplica el valor de cada entrada por una cantidad fija en función de la importancia que le haya asignado durante el proceso de aprendizaje; todos estos valores, las entradas multiplicadas por sus pesos, se suman, y al resultado se le suma también un valor llamado umbral, que es independiente de la entrada y permite ajustar hacia arriba o hacia abajo el valor de la suma de las entradas por sus pesos. Cada neurona implementa una función interna que aplica a esta suma para obtener el resultado de salida. Aunque cada neurona puede implementar una función diferente, normalmente todas usan la misma; esta función debe ser derivable para poder implementar el algoritmo de aprendizaje pro retro propagación.
Existen muchas funciones apropiadas para implementar neuronas artificiales, yo he elegido la sigmoide bipolar, cuyo resultado está acotado entre -1 y 1 y tiene esta forma:
S(x) = (2 / (1 + e-αx)) – 1
Su derivada es la siguiente:
S’(x) = α (1 – x2) / 2
El parámetro α nos permite configurar la función proporcionando un factor de escala que permite hacer su pendiente más o menos suave.
La clase Neuron
En el espacio de nombres NeuralNetwork, se encuentran las clases utilizadas para implementar la red neuronal. La clase Neuron implementa las neuronas individuales, utilizando los siguientes datos en su implementación:
private double[] _weights = null;
private double[] _weightUpdates = null;
private double _threshold = 0;
private double _thresholdUpdate = 0;
private double _alpha = 0;
private double _output = 0;
_weights es un array que contiene los pesos asignados a las entradas, mientras que _threshold contiene el valor umbral de la neurona. _weightUpdates y _thresholdUpdate se utilizan durante el proceso de aprendizaje para almacenar temporalmente las actualizaciones de los pesos y el umbral. _alpha es el parámetro para configurar la función sigmoide, y _output es el valor de la salida, para evitar realizar cálculos redundantes.
Al crearse la neurona, los pesos iniciales se seleccionan aleatoriamente; el valor a la salida se calcula con los siguientes métodos:
public double CalculateOutput(double[] inputs)
{
double sum = 0;
for (int w = 0; w < _weights.Length; w++)
{
sum += inputs[w] * _weights[w];
}
_output = Sigmoid(sum + _threshold);
return _output;
}
private double Sigmoid(double x)
{
return (2 / (1 + Math.Exp(-_alpha * x))) - 1;
}
A la función CalculateOutput se le pasa un array con los valores de las entradas actuales, y devuelve el valor de salida correspondiente. La derivada de la función en la salida, que se utilizará en el algoritmo de aprendizaje, se obtiene de la propiedad Delta:
public double Delta
{
get
{
return _alpha * (1 - (_output * _output)) / 2;
}
}
También existen métodos que se utilizarán en el proceso de aprendizaje para obtener y modificar los pesos asignados a las entradas:
public double GetWeight(int input)
{
return _weights[input];
}
public double GetWeightUpdate(int input)
{
return _weightUpdates[input];
}
public void SetWeightUpdate(int input, double update)
{
_weightUpdates[input] = update;
}
public void ApplyUpdates()
{
for (int w = 0; w < _weights.Length; w++)
{
_weights[w] += _weightUpdates[w];
}
Threshold += _thresholdUpdate;
}
La clase Layer
Las neuronas se organizan en capas. Una capa es simplemente un array de objetos Neuron, y se implementa en la clase Layer. Los cálculos necesarios para el algoritmo de retro propagación también se implementan aquí. Estos son los datos utilizados en la implementación de esta clase:
private Neuron[] _neurons = null;
private Layer _prevLayer = null;
private Layer _nextLayer = null;
private double[] _outputs = null;
private double[] _errors = null;
private double _momentum = 0;
El array _neurons contiene las neuronas de la capa, y con _prevLayer y _nextLayer tenemos referencias a la capa anterior y a la siguiente, que utilizaremos en el proceso de aprendizaje y de propagación de los cálculos al computar las entradas de la red para obtener la salida. _outputs contiene las salidas de las neuronas de la capa, para evitar realizar cálculos redundantes. Las dos variables restantes se utilizan solamente durante el proceso de aprendizaje: _errors contendrá los errores calculados para la salida de cada una de las neuronas de la capa, y _momentum es un parámetro que nos permite configurar la actualización de los pesos, como veremos al comentar el algoritmo en sí.
Existe un constructor especial para la capa de entrada y otro para el resto. La diferencia radica en que la capa de entrada no tiene una capa anterior, y el número de entradas por neurona es igual a la cantidad de valores que se presentan para cada vector de entrada, mientras que el resto de capas tienen todas una capa anterior y el número de entradas por neurona es la cantidad de salidas de dicha capa anterior:
public Layer(int inputs, int neurons, double alpha)
{
SetNeurons(inputs, neurons, alpha);
}
public Layer(int neurons, double alpha, Layer prevlayer)
{
SetNeurons(prevlayer.OutputCount, neurons, alpha);
_prevLayer = prevlayer;
_prevLayer.NextLayer = this;
}
Para procesar un vector de datos, se utiliza el siguiente método, que debe ser llamado únicamente para la primera de las capas, con los datos de entrada como parámetro:
public void Process(double [] inputs)
{
for(int n = 0; n < _neurons.Length; n++)
{
_outputs[n] = _neurons[n].CalculateOutput(inputs);
}
if (_nextLayer != null)
{
_nextLayer.Process(Outputs);
}
}
Los resultados se obtendrán leyendo las salidas de la última capa.
La clase Network
La clase Network implementa la red neuronal completa, como un array de objetos Layer. Este es el constructor, donde se inicializa todo:
public Network(int datainputs, int inputs, int outputs,
int[] hidden, double alpha)
{
_layers = new Layer[2 + ((hidden != null) ? hidden.Length : 0)];
_layers[0] = new Layer(datainputs, inputs, alpha);
Layer prev = _layers[0];
if ((hidden != null) && (hidden.Length > 0))
{
for (int h = 0; h < hidden.Length; h++)
{
_layers[1 + h] = new Layer(hidden[h], alpha, prev);
prev = _layers[1 + h];
}
}
_layers[_layers.Length - 1] = new Layer(outputs, alpha, prev);
_alpha = alpha;
}
El parámetro datainputs contiene el número de valores que constituyen cada vector de entrada, inputs es el número de neuronas de la capa de entrada, outputs el número de neuronas de la capa de salida, y el array hidden contiene tantos elementos como capas intermedias, cada uno con el número de neuronas correspondiente a la capa; Este parámetro puede ser nulo, en cuyo caso la red solo tendrá la capa de entrada y la de salida. El parámetro alpha se utiliza para inicializar las neuronas.
Para procesar un vector de entrada, o un array de vectores, se utilizan los siguientes métodos:
public double[] Compute(double[] inputs)
{
_layers[0].Process(inputs);
return _layers[_layers.Length - 1].Outputs;
}
public double[][] Compute(double[][] inputs)
{
double[][] result = new double[inputs.Length][];
for (int ix = 0; ix < inputs.Length; ix++)
{
_layers[0].Process(inputs[ix]);
result[ix] = new double[_layers[_layers.Length - 1].Outputs.Length];
_layers[_layers.Length - 1].Outputs.CopyTo(result[ix], 0);
}
return result;
}
Que reciben como parámetro las entradas de la red y nos devuelven las salidas correspondientes.
Algoritmo de retro propagación
Pasemos por fin al algoritmo en cuestión. Voy a tratar de explicarlo sin recurrir a formulaciones matemáticas, de las que podéis encontrar infinidad buscando por internet, ya que trato de transmitir una comprensión práctica del procedimiento.
Para entrenar la red, tomamos un conjunto de datos con un número m de muestras. Cada muestra consta de unos valores correspondientes a las entradas y otros valores que corresponden con las salidas que esperamos obtener; el algoritmo de retro propagación se aplica al tipo de aprendizaje que llamamos supervisado, ya que necesitamos conocer de antemano la salida que queremos obtener. El algoritmo procede de la siguiente manera para cada una de las muestras:
- Calcula el valor de salida de la red a partir de los valores de entrada.
- A partir de la capa de salida, calcula el error que se comete entre la salida real y la que proporciona la red. Este error se propaga hacia atrás hacia la anterior capa, que también calcula un valor de error para cada neurona a partir de los errores de la capa siguiente. En este paso obtenemos el error actual de la red, del cual obtenemos el promedio para todas las muestras.
- Una vez calculados los errores para todas las capas, se calculan las actualizaciones necesarias para los pesos de las neuronas de cada capa. En esta fase existen dos parámetros configurables: el ritmo de aprendizaje, que permite hacer estas actualizaciones en pasos más o menos pequeños, y el momento, que permite tener en cuenta el valor de la actualización de pesos resultante de la anterior muestra procesada. Jugando con estos dos valores, podemos afinar el proceso de aprendizaje.
- Por último, se aplican estas actualizaciones a todas las neuronas de la red.
Una vez procesadas todas las muestras, si el error promedio es inferior a un valor que hemos prefijado, o si ya hemos realizado el proceso un número máximo de veces, el algoritmo termina y la red queda entrenada.
En el código, este es el método de la clase Network que procesa una muestra:
private Task<double> TrainSample(double[] inputs,
double[] finaloutputs, double learningrate)
{
return Task.Run(() =>
{
_layers[0].Process(inputs);
double error = _layers[_layers.Length - 1].ComputeError(finaloutputs);
_layers[0].ComputeUpdates(inputs, learningrate);
_layers[0].ApplyUpdates();
return error;
});
}
Como parámetros recibe los valores de entrada en inputs, los valores de salida esperados en finaloutputs y el ritmo de aprendizaje en learningrate, este ritmo suele ser menor que 1, del orden de 0,001 o 0,01. El error de la red para la muestra actual se calcula empezando por la capa de salida, con el método ComputeError de la clase Layer:
public double ComputeError(double[] finaloutput)
{
double error = 0;
for (int n = 0; n < _neurons.Length; n++)
{
double e = finaloutput[n] - _outputs[n];
_errors[n] = e * _neurons[n].Delta;
error += e * e;
}
_prevLayer.ComputeError();
return error / 2;
}
Para cada neurona de la capa, se calcula la diferencia entre su salida actual y la salida esperada; el error de la capa de salida para esta neurona es el producto de esta diferencia y la derivada de la función de la neurona en el punto de su salida actual. Este valor se almacena en el array _errors como el error correspondiente a la neurona actual. La variable error, por su parte contendrá el error cuadrático medio de todas las neuronas de la capa de salida, que es el valor que consideraremos como error de la red para la muestra actual.
De la última capa pasaremos a calcular el error de la anterior capa, con otra versión del método ComputeError, ya que en el resto de capas el cálculo del error se realiza de forma diferente:
private void ComputeError()
{
for (int n = 0; n <_neurons.Length; n++)
{
double esum = 0;
for (int e = 0; e < _nextLayer.Errors.Length; e++)
{
esum += _nextLayer.Errors[e] * _nextLayer.GetWeight(e, n);
}
_errors[n] = esum * _neurons[n].Delta;
}
if (_prevLayer != null)
{
_prevLayer.ComputeError();
}
}
En estas capas intermedias, no sabemos cuál debería ser el valor final correcto, así que lo que calculamos es la suma del producto del error de cada neurona de la siguiente capa por el peso que le ha asignado esta neurona a la salida de la neurona cuyo error estamos procesando. De nuevo, multiplicamos este error por la derivada de la función de la neurona en el punto correspondiente a la salida actual para calcular el error definitivo de la neurona de la capa actual. Desde aquí, el procedimiento es el mismo para calcular los errores del resto de capas, hasta llegar a la primera, donde se detiene el proceso.
Con todos los errores calculados, el siguiente paso consiste en calcular el valor de la actualización para cada uno de los pesos. Este proceso comienza en la primera capa, la de entrada, con los datos de la muestra actual en el parámetro inputs del método ComputeUpdates de la clase Layer:
public void ComputeUpdates(double[] inputs, double learningrate)
{
for(int n = 0; n < _neurons.Length; n++)
{
for (int w = 0; w < _neurons[n].InputCount; w++)
{
_neurons[n].SetWeightUpdate(w, learningrate *
(_momentum * _neurons[n].GetWeightUpdate(w) +
(1 - _momentum) * _errors[n] * inputs[w]));
}
_neurons[n].ThresholdUpdate = learningrate *
(_momentum * _neurons[n].ThresholdUpdate +
(1 - _momentum) * _errors[n]);
}
if (_nextLayer != null)
{
_nextLayer.ComputeUpdates(Outputs, learningrate);
}
}
Procesamos cada neurona de la capa, y dentro de cada neurona, el peso de cada una de las entradas. El parámetro _momentum puede tomar cualquier valor entre 0 y 1, de manera que repartimos la cantidad a actualizar entre dos factores: por un lado, el valor de la última actualización que hayamos calculado para este peso ((_momentum * _neurons[n].GetWeightUpdate(w)) y, por otro lado, el error de la neurona multiplicado por el valor de la entrada corresponiente ((1 - _momentum) * _errors[n] * inputs[w]). El valor de _momentum determina si tenemos en cuenta, y en qué medida, las actualizaciones pasadas.
Con el parámetro learningrate hacemos que esta actualización sea más o menos lenta, para ajustar finamente la precisión y la velocidad de aprendizaje. El cálculo continúa en la siguiente capa, esta vez con las salidas de la capa actual como valores de entrada.
Por último, se aplican los cambios a todas las neuronas, empezando también desde la primera capa, con la función ApplyUpdates:
public void ApplyUpdates()
{
for(int n = 0; n < _neurons.Length; n++)
{
_neurons[n].ApplyUpdates();
}
if (_nextLayer != null)
{
_nextLayer.ApplyUpdates();
}
}
La aplicación BackPropagationDemo
Como demostración del uso de este algoritmo, he preparado la aplicación BackPropagationDemo, con el siguiente interfaz de usuario:
En la barra de herramientas, de izquierda a derecha, existen los siguientes botones de comando:
- Abrir archivo: permite cargar datos en formato csv o redes neuronales ya entrenadas por el programa.
- Guardar: Permite guardar en un archivo binario la red actual.
- Construir red: Construye una nueva red apropiada para los datos actuales.
- Entrenar: Entrena la red con los parámetros y los datos actuales.
- Detener entrenamiento: permite detener el proceso de aprendizaje antes de que finalice.
- Procesar datos: permite procesar los datos cargados desde un archivo y muestra los resultados.
La primera pestaña, Network, permite introducir los parámetros de la red. Siempre se debe indicar una capa de entrada y una de salida, cada una con el número de neuronas que elijamos, pero teniendo en cuenta que se reservarán tantos valores del final de cada fila de datos cargados como neuronas hayamos seleccionado para la capa de salida. El resto de valores se utilizarán como entradas. Si queremos añadir capas ocultas, podemos hacerlo con una lista separada por comas.
En la pestaña Training se proporcionan los parámetros de entrenamiento. El número máximo de iteraciones, indica cuantas veces va a procesar el conjunto completo de datos usado para entrenamiento; los parámetros learningrate y momentum tienen la función indicada anteriormente; máximo error proporciona una cota de error mínima aceptable que permite detener el proceso de entrenamiento antes de terminar todas las iteraciones. Por último, indicamos, para la muestra de datos que vamos a cargar con un archivo csv, el porcentaje de los mismos que se utilizará para el entrenamiento. Este valor hay que indicarlo antes de cargar los datos.
Mientras se entrena la red, podemos seguir interactuando con el programa, mientras vemos una gráfica con la variación del error de la red y las iteraciones realizadas:
Por último, una vez entrenada la red, podemos comprobar el resultado aplicándole el resto de los datos que no se han utilizado para el entrenamiento:
Este gráfico no es muy ortodoxo que digamos. En la parte superior se encuentran las cantidades de filas cuyo valor esperado tiene una diferencia con el valor proporcionado de la red inferior a n / 10 partes del error máximo cometido por la red. En la captura, por ejemplo, hay 2474 muestras con un error inferior a 1 / 10 del error máximo.
La gráfica de la parte inferior representa las diferencias para cada muestra entre los valores reales y calculados, con una flecha que parte del valor real y termina, en forma de flecha, en el valor calculado por la red. Estas gráficas solo tienen sentido para redes con una única neurona de salida.
He añadido dos conjuntos de datos que pueden usarse para realizar pruebas, iris.csv, obtenido de la aplicación R, con 4 valores de datos y un valor de resultado para tres especies de lirios, y postures.csv, con 20 valores de datos y uno de resultado, que representan valores de dos posturas diferentes obtenidos con el sensor Kinect de Microsoft, según la normalización de la postura que proporcioné en estos artículos.
Todos los datos que se cargan en el programa son escalados dentro del intervalo (-1, 1), independientemente de cual sea su valor.