3

Tutorial de Redes Neuronales con VREP C++ y Linux

¡Hola, gente! Hoy vamos a aprender a construir un robot neuronal con el simulador de robots VRep y C++ en Linux. Crearemos un pequeño robot esquiva obstáculos, pero en vez de programar directamente su comportamiento, intentaremos que aprenda por sí mismo cómo navegar por el entorno utilizando un algoritmo conocido como  Backpropagation.

Durante este tutorial supondré que tenéis instalado V-REP y sabéis como programar la API remota en Linux. Si no sabéis como hacerlo, leed mi tutorial anterior: Cómo programar V-REP C++ en Linux.

Para facilitar el trabajo con matrices y vectores dinámicos de C++, utilizaremos la librería vector. Estaría bien buscar algun ejemplo con esta librería para aprender la sintaxis básica antes de continuar.

Un poco de anatomía

El cerebro humano es una de las maravillas de este mundo. Quizá no os lo parezca, pero cada uno de vosotros lleva un superordenador biológico encima de los hombros, esculpido por milenios de evolución natural y capaz de hacer proezas fuera del alcance de cualquier ordenador moderno. Por ejemplo, cualquier persona puede ver sin ninguna dificultad que estas tres imágenes representan una rana.

Reconocer objetos no es tarea fácil, pero los humanos sóis muy buenos a la hora de buscar patrones en una imagen. Sin embargo, la dificultad de esta tarea se vuelve evidente si intentamos diseñar un algoritmo para reconocer imágenes de ranas. ¿Deberíamos encontrar una forma de detectar objetos verdes? ¿O quizá patas palmeadas? ¿Ojos saltones? ¡Básicamente, no tendríamos ni idea de por dónde empezar…!

La cuestión está en que la inteligencia humana no es algorítmica. El cerebro de los seres vivos está compuesto por miles de células llamadas “neuronas”, que funcionan individualmente para recoger y procesar “información” del entorno.

Neuronas vistas al microscopio. Imagen de Wikipedia.

Aunque el cerebro humano tiene cerca de 10.000 clases diferentes de neuronas, estas pueden dividirse en tres grandes clases: neuronas sensitivas (recogen información de los órganos sensitivos: retina, nervios olfativos, canales semicirculares -equilibrio-, etc), neuronas motoras (provocan respuestas motoras, ya sea mover un músculo, órgano…) y las interneuronas (conectan las neuronas sensitivas con las motoras).

Las neuronas se conectan entre sí formando sinapsis, lo que permite a una neurona pasar una señal nerviosa a otra. Hay neuronas cuya función es atenuar esta señal, mientras que otras la amplifican. Al pasar por distintas neuronas las señales nerviosas se alteran, lo que hace que un cierto estímulo provoque una respuesta determinada. A su vez las neuronas están organizadas en capas, muchas veces especializadas en procesar un sólo tipo de estímulo.

Pero la mayor virtud de las neuronas es su neuroplasticidad: la capacidad de fortalecer o debilitar las conexiones sinápticas según su actividad. Con pocas palabras: una conexión sináptica que se utilice mucho se fortalecerá, al contrario que las que se estimulen poco. Como dice el dicho, “La práctica hace al maestro”, porque al realizar repetidamente una actividad hará que las sinapsis se fortalezcan y el cerebro sea más eficiente.

Redes Neuronales Artificiales

Bueno, pero no estamos aquí para una clase de neurología, ¿verdad? Lo que nos interesa es conocer el funcionamiento una red neuronal artificial, las técnicas que les permiten aprender y su implementación.

Las redes neuronales artificiales funcionan un poco diferente a las redes neuronales naturales. En vez de reforzar las sinapsis según lo mucho o poco que se activen, se utilizan algoritmos que evalúan el comportamiento de la red neuronal y modifican las “sinapsis” según sea necesario. Hay tres maneras en que una red neuronal puede aprender:

  1. Aprendizaje supervisado (el que vamos a usar): se presenta a la red neuronal con un input determinado y el output que debería dar. Después, se compara este output deseado con el output real de la red neuronal y se calcula el error. Más adelante veremos que podemos expresar este error como una función cuyas variables son los pesos de la red neuronal, y buscaremos su mínimo en función de estas variables (es decir, se busca la combinación de pesos que hace que el error sea mínimo). A propósito, ¿cómo lleváis el cálculo diferencial? 😉
  2. Aprendizaje sin supervisión: dado un input, la misión de la red neuronal es encontrarle algún tipo de patrón. Se utiliza mucho en minería de datos.
  3. Aprendizaje por refuerzo: al igual que el aprendizaje supervisado, se da un input al sistema pero en vez de evaluarlo con el output deseado, se avalúa el funcionamiento general del sistema. Por ejemplo, un robot que aprenda a caminar por ensayo y error, o un robot que aprenda a memorizar ciertas conductas.

¿Qué queremos hacer? Nuestro pequeño robot tendrá seis sensores de distancia y dos motores. Para entrenarlo, le pasaremos varios ejemplos con lecturas de distancia de los seis sensores y como nos gustaría que reaccionasen los motores (ej: girar si hay un obstáculo, avanzar si está despejado, etc). Los ejemplos serán limitados y no cubrirán todas las posiciones posibles de los obstáculos ni todas las combinaciones de lecturas de los sensores. Nuestro héroe robótico aprenderá de estos ejemplos incompletos y será capaz de moverse por el entorno esquivando cualquier tipo de obstáculo. En dos palabras: ¡Inteligencia Artificial!

Más adelante veremos como funciona el algoritmo de aprendizaje, pero primero veamos como funciona una neurona artificial…

Estructura de una Neurona Artificial

Las “neuronas” de las redes neuronales artificiales se llaman “nodos” o “unidades”. Una unidad está compuesta de de distintas partes:

-Conexiones de entrada: el input de la neurona. Este input puede ser una señal exterior (en nuestro caso, la lectura de un sensor) o puede provenir de una neurona de una capa anterior. Cada señal de entrada se multiplica por un peso, que amplificará o disminuirá su valor.

-Pesos: la neurona suma la señal de cada una de las conexiones de entrada y las multiplica por un peso, que amplificará o disminuirá su valor. Hay un peso para cada conexión de entrada, y es la forma de representar la fortaleza de las sinapsis.

-Input: la suma de todos los inputs de las señales de entrada, multiplicados por los pesos.

-Función de activación: para calcular el output de la neurona, se aplica una función de activación al input. Normalmente se escoge una función contínua cuyo rango sea (0,1) o (-1,1). Hay dos funciones de activación de las que oíremos a hablar mucho si trabajamos con redes neuronales.

La función de activación de umbral, con salida 0 si la entrada es negativa y 1 si es positiva o cero:

Esta función tiene un pequeño problema: no es una función contínua, y un pequeño cambio en el input puede provocar que el output cambie completamente (por ejemplo, variar la entrada de -0.001 a 0.001 provocará que el output cambie de 0 a 1).

Después tenemos la función sigmoide, que vendría a ser una versión  “suavizada” de la anterior.


La ventaja que tiene la función sigmoide sobre la función activación de umbral es que es una función contínua dónde pequeñas variaciones en el input van a producir también pequeñas variaciones en el resultado. Es por esta razón que nosotros vamos a utilizar la función sigmoide.

Pero hay otras funciones de activación con propiedades curiosas. Os animo que, cuándo acabéis este tutorial, probéis de utilizar otra función distinta para construir la red neuronal del robot.

-Output: el output de la neurona después de aplicar la función de activación al input.

-Sesgo: algunas redes neuronales tienen un peso de sesgo. Es un valor que tiene que superar el input de la neurona para activar la función de activación. Nosotros no vamos a añadir ningún peso de sesgo.

Vamos a ver unos cuantos ejemplos. ¿Dado un cierto input, cuál va a ser el output de estas neuronas?

Capas y capas de neuronas

Hay dos tipos principales de redes neuronales: con alimentación hacia delante y cíclicas/recurrentes. En las redes con alimentación hacia delante tenemos que el output depende solamente de los pesos y del input, mientras que las segundas tienen un mecanismo de feedback que hace que el output pueda depender de estados anteriores. Hoy solo vamos a trabajar con redes de alimentación hacia delante.

Las redes neuronales se organizan en capas. Cada capa tiene un cierto número de neuronas que se conectan con capas posteriores (algunos modelos pueden interconectar neuronas de una misma capa). Cada capa tiene un nombre diferente: capa de entrada, capa(s) oculta(s) (todas las capas intermedias) y capa de salida.

Red neuronal con una sola capa oculta

Red neuronal con varias capas ocultas

La red neuronal será capaz de resolver problemas más complejos cuantas más neuronas tengan las capas ocultas (y cuantas más capas ocultas haya). Sin embargo, poner demasiadas capas hará que la red neuronal tenga más dificultades para aprender, o que no sea capaz de alcanzar los resultados que deseemos. Todavía no existe una forma clara de determinar el número de capas y de neuronas según el tipo de problema a resolver, así que hay que hacerlo según nuestro criterio. Hay veces en que la ciencia es más arte que ciencia.

Un pequeño paréntesis: notación para los pesos

Cuándo empecemos a programar nuestra red neuronal, será muy fácil liarnos  si no establecemos una notación para los pesos. Así pues, el peso W[k][j][i] es el peso que une la neurona j de la capa k con la neurona i de la capa k+1

El peso marcado en rojo sería el W[1][0][1]

Calcular el output de una Red Neuronal: Propagación hacia delante (Feed-Forward)

Los inputs y los outputs de las redes neuronales se representan como vectores. Por ejemplo, una red neuronal con 6 neuronas en la capa de entrada y 4 en la capa de salida, recibirá vectores de 6 elementos como inputs y dará vectores de 4 elementos como outputs.

¿Dado un vector de input, como podemos calcular el vector de output de nuestra red neuronal?

Para hacerlo tenemos que ir calculando el output de cada una de las neuronas de cada capa, empezando por la capa de entrada, e ir pasándolo como input a las neuronas de la capa siguiente. Así:


Para cada neurona i de la capa 0:
    a[0][i] = input[i]

Para cada capa l=1 hasta n-1:
    Para cada neurona i en la capa l:
       input[l][i] = 0
       Para cada neurona j en la capa l-1:
          input[l][i] += W[l][j][i]*output[l-1][j]
    output[l][i] = sigmoide(input[l][i])

El algoritmo de aprendizaje Backpropagation

Al crear la red neuronal, los pesos de las neuronas se inicializan al azar. Esto hará que el output inicial de la red neuronal sea muy diferente a lo que queremos.

A no ser que tengamos mucho tiempo libre y queramos ajustar los pesos a mano (cosa díficil con redes de miles de neuronas) necesitamos un algoritmo que dado un conjunto de vectores de input y sus vectores de output deseados, nos modifique los pesos para que el error entre el output real de la red y el deseado sea mínimo. ¡Cuidado! Aquí la palabra clave es “mínimo”, porque el error nunca llegará a ser exactamente cero. El algoritmo más típico es el de BACKPROPAGATION (Propagación hacia atrás).

Cada vector de input y su output correspondiente se llama ejemplo de entrenamiento. Antes de empezar a utilizar nuestra red neuronal, habrá que pasarle varios ejemplos de entrenamiento (cuantos más, mejor) para que aprenda.

Dado un input de entrenamiento, ya hemos visto como calcular el vector de output con el método de propagación hacia delante (método anterior). Llamamos a este vector “t”. El output deseado lo llamaremos “y”. Entonces, para un único ejemplo de entrenamiento, el error en una neurona j puede calcularse con esta fórmula:


Una vez tengamos calculados todos los errores para cada uno de los ejemplos de entrenamiento, podemos calcular su media con:

Esta es la función de error. Sus variables son los pesos, ya que los vectores ‘t’ de las salidas dependen de ellos. Esta función estará próxima a cero cuándo los vectores t_j estén cerca de a_j. Tenemos que encontrar los valores de los pesos que hagan mínima esta función (dado que la función es siempre positiva, el ‘mínimo’ será el valor más próximo a cero). Hay herramientas matemáticas para calcular mínimos absolutos y ceros de funciones, pero como hay muchas variables involucradas esto no puede hacerse de forma analítica.

El método del descenso del gradiente sirve para calcular aproximaciones de ceros de funciones. No nos garantiza encontrar el “mejor cero”, pero sí un valor lo suficientemente cercano. La idea es que cada peso contribuye un poco al error general. Para cada neurona i de la capa de salida, su contribución se calcula con:

Y para el resto de neuronas j de las capas ocultas:

Dónde f’ es la derivada de la función de activación que utilicemos, en nuestro caso la función sigmoide, y i se corresponde a las neuronas de la capa anterior.

Entonces, cada peso de la red neuronal (empezando por la capa de salida y retrocediendo hasta la capa de entrada) se actualiza según la fórmula del método de descenso al gradiente:

alpha es el parámetro de aprendizaje, entre 0 y 1, que indica lo rápido que aprende la red neuronal. Un parámetro próximo a cero hará que la red neuronal modifique sus pesos de forma más lenta (pero con menos probabilidad de saltarse algún cero), mientras que un valor próximo a 1 hará que la función cambie sus pesos de forma más radical (pero con la posibilidad de que la red neuronal no llegue nunca a alcanzar un valor estable).

El pseudocódigo del algoritmo de Backpropagation para una red neuronal de n capas es:


Para cada ejemplo de entrenamiento 'e' hacer:
   Guardar el vector e[input], el vector de inputs del ejemplo

   Para cada nodo i en la capa n-1 hacer:
      delta[n-1][i] = f'(input[n-1][i])*(e[input][i]-output[n-1][i])
   Para k = n-2 a 0 hacer:
      Para cada nodo j en la capa k hacer:
      suma = 0
      Para cada nodo i en la capa k+1 hacer:
         suma = suma + W[k][j][i]*delta[k+1][i]
      delta[k][j] = f'(in[k][j])*suma
      Para cada nodo i en la capa k+1 hacer:
         W[k][j][i] = W[k][j][i] + alpha*output[k][j]*delta[k+1][i]

Lo que haremos es repetir este algoritmo mientras la media del error total sea mayor o igual a un cierto margen.

Estas fórmulas resultan más evidentes cuándo uno ve cómo se deducen a partir de la fórmula del error. Sin embargo, para no alargar más este tutorial, hoy no vamos a ver la demostración (si tengo que explicar cada una de las sutilezas de las redes neuronales, tendría que escribir un libro). Pero podéis ver cómo hacerlo en este enlace.

V-Rep y C++

¡Vamos a programar nuestro robot! El primer paso será descargar la escena básica de V-REP. Esta escena consiste en el mismo robot con ruedas de la última vez, con varios sensores de distancia colocados en un ángulo de 180º. El entorno está lleno de obstáculos que deberá aprender a esquivar.

Creamos una carpeta llamada “vrep_cpp_neuron” u otro nombre que recordemos, dónde vamos a guardar todo el código para nuestro robot. Dentro creamos tres ficheros de texto llamados “neuron.cpp”, “neuron.h” y “main.cpp”. Los dos primeros contendran todas las funciones y classes para crear una red neuronal, y el último será el código para interactuar con el robot a través de la API remota de V-REP.

Empezamos por escribir las cabeceras de la classe neurona. ¿Como vamos a enfocarlo para no morir en el intento? En vez de crear un objeto para cada neurona individual, vamos a crear una classe para la red neuronal, contendrá una matriz para guardar los pesos de todas las neuronas, otra para los inputs de las neuronas y otra para los outputs. También contendrá las funciones de feed-forward y backpropagation.

Abrimos “neuron.h” y creamos una clase neurona:

#include <iostream>
#include <vector>
#include <math.h>
#include <random>
#include <fstream>

using namespace std;

class Network
{
    public:
        //Constructor y destructor
        Network(vector< int >& layers);
        ~Network();

        //Debug: mostrar la matriz de pesos y los outputs de cada neurona
        void Mostrar_Pesos();
        void Mostrar_Output();

        //Funcion de Backpropagation para entrenar a la red neuronal
        void Aprendizaje_Prop_Atras(vector< vector < double > >& inputs, vector < vector < double > >& outputs);

        //Funcion para calcular el output de la red neuronal
        vector < double > Calcular_Output(vector < double >& inputs);

        //Funcion sigmoide y su derivada primera
        double sigmoide(double z);
        double sigmoide_prima(double x);

    private:
        int n_capas; //Numero de capas de la red neuronal
        vector< vector < vector <double> > > Weight; //Pesos de la red neuronal
        vector< vector < double > > in; //Input de cada neurona
        vector< vector < double > > a; //Output de cada neurona (aplicando la funcion sigmoide al input)
        vector< vector < double > > delta; //Error de cada neurona

        
        
        

};

Ahora abrimos el fichero “neuron.cpp”, dónde vamos a escribir cada una de estas funciones. Empezamos por los includes y las definiciones:

#include "neuron.h"

using namespace std;

#define ALPHA 0.8

ALPHA será nuestro parámetro de aprendizaje. Yo lo he puesto en 0.8 (un valor bastante radical) pero vosotros podéis jugar con el valor que queráis.

Después viene el constructor y el destructor de clase:

/*CONSTRUCTOR*/
Network::Network(vector< int >& layers)
{
    n_capas = layers.size(); //Guardamos el numero de capas
    srand (time(NULL)); //Generamos una semilla aleatoria

    double r;
    
    /*LLENAR LA MATRIZ DE PESOS DE NEURONAS*/
    //Recordar que el peso W[k][j][i] une la neurona j de la capa k
    //con la neurona i de la capa k+1
    for(int k = 0; k < n_capas-1; k++)
    {
        Weight.push_back({});
        for(int j = 0; j < layers[k]; j++)
        {
            Weight[k].push_back({});
            for(int i = 0; i < layers[k+1]; i++)
            {
                //Generar un número aleatorio entre -1 y 1
                r = rand()%200-100;
                r = r/100;

                //Generar un peso con este valor aleatorio
                Weight[k][j].push_back(r);
            }
        }
    }

    /*LLENAR MATRIZ DE ACTIVACIONES, INPUTS Y ERRORES*/
    for(int k = 0; k < n_capas; k++)
    {
        a.push_back({});
        in.push_back({});
        delta.push_back({});
        for(int j = 0; j < layers[k]; j++)
        {
            //Las llenamos con un valor cualquiera
            a[k].push_back(j);
            in[k].push_back(j);
            delta[k].push_back(j);
        }
    }
        


}


/*DESTRUCTOR*/
Network::~Network(){};

Este constructor recibirá como parámetro un vector que indicará el número de neuronas que tiene cada capa. Por ejemplo, el vector [4,2,2,1] crearía una red neuronal con una capa de entrada de cuatro neuronas, dos capas ocultas de dos neuronas cada una, y una capa de salida con una sola neurona.

El código del constructor tiene dos partes. En la primera se llena la matriz de pesos, creando n-1 capas de pesos si hay n capas de neuronas, cada uno con pesos aleatorios entre -1 y 1.

La segunda parte llena las matrices de inputs (in[]), outputs (a[]) y errores (delta[]).

Nos irá bien tener dos funciones de debug que nos escriben los pesos y outputs en pantalla. En realidad no nos haría falta porque el programa ya está testeado, pero ni os imáginais lo útiles que me han sido para escribir este tutorial…

/*FUNCIONES DE DEBUG*/
void Network::Mostrar_Pesos()
{
    cout << endl << "Pesos";
    for(int k = 0; k < Weight.size(); k++)
    {
        cout << endl << "[" << k << "]   ";
        for(int j = 0; j < Weight[k].size(); j++)
        {
            cout << "[" << j << "](";
            for(int i = 0; i < Weight[k][j].size()-1; i++)
            {
                cout << Weight[k][j][i] << ", ";
            }
            cout << Weight[k][j][Weight[k][j].size()-1] << ")    ";
        }
    }
    cout << endl;


}

void Network::Mostrar_Output()
{
    cout << endl << "Output";
    for(int k = 0; k < a.size(); k++)
    {
        cout << endl << "[" << k << "]   ";
        for(int j = 0; j < a[k].size(); j++)
        {
            if(k != a.size()-1)
                cout << "[" << j << "](" << a[k][j] << ")    ";
            else
                cout << "\033[1;44m[" << j << "](" << a[k][j] << ")\033[0m    ";
        }
    }
    cout << endl;


}

Los carácteres extraños que hay en el output (como “\033[1;44m”) son para que el texto salga de color azul y resalte el output de las neuronas de la capa de salida.

Vamos a poner el cálculo de la función sigmoide y su derivada como dos funciones separadas para así tener el código más claro:

/*FUNCION SIGMOIDE Y SU DERIVADA*/
double Network::sigmoide(double x)
{

    return (1.0/(1+exp(-1*x)));

}

double Network::sigmoide_prima(double x)
{

    return (exp(x)/((exp(x)+1)*(exp(x)+1)));

}

La función de Feed-Forward, que ya hemos visto su procedimiento en pseudocódigo:

/*FEED-FORWARD*/
vector < double > Network::Calcular_Output(vector < double >& input)
{
    vector < double > salidas (a[a.size()-1].size());

    //Calcular el output de la capa de entrada
    for(int j = 0; j < a[0].size(); j++)
    {
        a[0][j] = input[j];
    }

    //Calcular el output de las capas ocultas y la capa de salida
    for(int l = 1; l < a.size(); l++)
    {
        for(int i = 0; i < a[l].size(); i++)
        {
            in[l][i] = 0;
            for(int j = 0; j < a[l-1].size(); j++)
            {
                in[l][i] += Weight[l-1][j][i]*a[l-1][j];
            }
            a[l][i] = sigmoide(in[l][i]);
        }
    }

    salidas = a[a.size()-1];
    return salidas;

}

Y finalmente la función de Backpropagation. También hemos visto su funcionamiento en pseudocódigo.

/*BACKPROPAGATION*/
void Network::Aprendizaje_Prop_Atras(vector< vector < double > >& inputs, vector < vector < double > >& outputs)
{

    double suma;
    int contador = 0;
    double ERROR = 200;
    double error;
    
    //La red debe dejar de aprender cuando el error sea menor de 0.0001 o se sobrepase el maximo de iteraciones
    while(ERROR > 0.0001 and contador < 10000)
    {
        ERROR = 0;
        for(int e = 0; e < inputs.size(); e++)
        {
            Calcular_Output(inputs[e]); //Feedforward (calcular los outputs)

            //Calcular el error delta de la capa de salida
            for(int i = 0; i < a[n_capas-1].size(); i++)
            {
                error = (outputs[e][i]-a[a.size()-1][i]);
                delta[delta.size()-1][i] = sigmoide_prima(in[in.size()-1][i])*error;
                ERROR += error*error; //Guardamos el cuadrado del error para poder calcular el error cuadratico
            }

            //Propagar el error hacia atras
            for(int l = n_capas-2; l >= 0; l--)
            {
                for(int j = 0; j < a[l].size(); j++)
                {
                    suma = 0;
                    for(int i = 0; i < Weight[l][j].size(); i++)
                    {
                        suma = suma + Weight[l][j][i]*delta[l+1][i];
                    }
                    delta[l][j] = sigmoide_prima(in[l][j])*suma;
                    for(int i = 0; i < a[l+1].size(); i++)
                    {
                        Weight[l][j][i] = Weight[l][j][i] + ALPHA*a[l][j]*delta[l+1][i];
                    }
                }
            }
        }
        
        ERROR *= 0.5*(1.0/inputs.size()); //Calculamos el error cuadratico
        cout << "ERROR = " << ERROR << endl; //Mostramos el error para poder observar la convergencia
        contador ++; //Aumentamos el contador de iteraciones
    }


}

El código completo quedará así:

#include "neuron.h"

using namespace std;

#define ALPHA 0.8


/*CONSTRUCTOR*/
Network::Network(vector< int >& layers)
{
    n_capas = layers.size(); //Guardamos el numero de capas
    srand (time(NULL)); //Generamos una semilla aleatoria

    double r;
    
    /*LLENAR LA MATRIZ DE PESOS DE NEURONAS*/
    //Recordar que el peso W[k][j][i] une la neurona j de la capa k
    //con la neurona i de la capa k+1
    for(int k = 0; k < n_capas-1; k++)
    {
        Weight.push_back({});
        for(int j = 0; j < layers[k]; j++)
        {
            Weight[k].push_back({});
            for(int i = 0; i < layers[k+1]; i++)
            {
                //Generar un número aleatorio entre -1 y 1
                r = rand()%200-100;
                r = r/100;

                //Generar un peso con este valor aleatorio
                Weight[k][j].push_back(r);
            }
        }
    }

    /*LLENAR MATRIZ DE ACTIVACIONES, INPUTS Y ERRORES*/
    for(int k = 0; k < n_capas; k++)
    {
        a.push_back({});
        in.push_back({});
        delta.push_back({});
        for(int j = 0; j < layers[k]; j++)
        {
            //Las llenamos con un valor cualquiera
            a[k].push_back(j);
            in[k].push_back(j);
            delta[k].push_back(j);
        }
    }
        


}


/*DESTRUCTOR*/
Network::~Network(){};




/*FUNCIONES DE DEBUG*/
void Network::Mostrar_Pesos()
{
    cout << endl << "Pesos";
    for(int k = 0; k < Weight.size(); k++)
    {
        cout << endl << "[" << k << "]   ";
        for(int j = 0; j < Weight[k].size(); j++)
        {
            cout << "[" << j << "](";
            for(int i = 0; i < Weight[k][j].size()-1; i++)
            {
                cout << Weight[k][j][i] << ", ";
            }
            cout << Weight[k][j][Weight[k][j].size()-1] << ")    ";
        }
    }
    cout << endl;


}

void Network::Mostrar_Output()
{
    cout << endl << "Output";
    for(int k = 0; k < a.size(); k++)
    {
        cout << endl << "[" << k << "]   ";
        for(int j = 0; j < a[k].size(); j++)
        {
            if(k != a.size()-1)
                cout << "[" << j << "](" << a[k][j] << ")    ";
            else
                cout << "\033[1;44m[" << j << "](" << a[k][j] << ")\033[0m    ";
        }
    }
    cout << endl;


}


/*FUNCION SIGMOIDE Y SU DERIVADA*/
double Network::sigmoide(double x)
{

    return (1.0/(1+exp(-1*x)));

}

double Network::sigmoide_prima(double x)
{

    return (exp(x)/((exp(x)+1)*(exp(x)+1)));

}



/*FEED-FORWARD*/
vector < double > Network::Calcular_Output(vector < double >& input)
{
    vector < double > salidas (a[a.size()-1].size());

    //Calcular el output de la capa de entrada
    for(int j = 0; j < a[0].size(); j++)
    {
        a[0][j] = input[j];
    }

    //Calcular el output de las capas ocultas y la capa de salida
    for(int l = 1; l < a.size(); l++)
    {
        for(int i = 0; i < a[l].size(); i++)
        {
            in[l][i] = 0;
            for(int j = 0; j < a[l-1].size(); j++)
            {
                in[l][i] += Weight[l-1][j][i]*a[l-1][j];
            }
            a[l][i] = sigmoide(in[l][i]);
        }
    }

    salidas = a[a.size()-1];
    return salidas;

}


/*BACKPROPAGATION*/
void Network::Aprendizaje_Prop_Atras(vector< vector < double > >& inputs, vector < vector < double > >& outputs)
{

    double suma;
    int contador = 0;
    double ERROR = 200;
    double error;
    
    //La red debe dejar de aprender cuando el error sea menor de 0.0001 o se sobrepase el maximo de iteraciones
    while(ERROR > 0.0001 and contador < 10000)
    {
        ERROR = 0;
        for(int e = 0; e < inputs.size(); e++)
        {
            Calcular_Output(inputs[e]); //Feedforward (calcular los outputs)

            //Calcular el error delta de la capa de salida
            for(int i = 0; i < a[n_capas-1].size(); i++)
            {
                error = (outputs[e][i]-a[a.size()-1][i]);
                delta[delta.size()-1][i] = sigmoide_prima(in[in.size()-1][i])*error;
                ERROR += error*error; //Guardamos el cuadrado del error para poder calcular el error cuadratico
            }

            //Propagar el error hacia atras
            for(int l = n_capas-2; l >= 0; l--)
            {
                for(int j = 0; j < a[l].size(); j++)
                {
                    suma = 0;
                    for(int i = 0; i < Weight[l][j].size(); i++)
                    {
                        suma = suma + Weight[l][j][i]*delta[l+1][i];
                    }
                    delta[l][j] = sigmoide_prima(in[l][j])*suma;
                    for(int i = 0; i < a[l+1].size(); i++)
                    {
                        Weight[l][j][i] = Weight[l][j][i] + ALPHA*a[l][j]*delta[l+1][i];
                    }
                }
            }
        }
        
        ERROR *= 0.5*(1.0/inputs.size()); //Calculamos el error cuadratico
        cout << "ERROR = " << ERROR << endl; //Mostramos el error para poder observar la convergencia
        contador ++; //Aumentamos el contador de iteraciones
    }


}

Esto es todo en cuánto al fichero “neuron.cpp”. Ahora tenemos que abrir el “main.cpp”, dónde vamos a escribir el código para interactuar con Vrep.
Lo primero será importar todas las librerías.

#include <iostream> 
#include "neuron.h" 
 
extern "C" { 
    #include "extApi.h" 
} 
 
using namespace std;

Nos vamos dentro del main. Empezamos por declarar todas las variables, vectores y demás que necesitaremos para el robot.

int main() 
{ 
 
    //Variables para guardar motores 
    int motor_derecho; 
    int motor_izquierdo; 
 
    //Variable que guarda el obstaculo detectado 
    simxUChar obstaculo0, obstaculo1, obstaculo2, obstaculo3, obstaculo4, obstaculo5; 
    simxUChar obstaculos[6]; 
 
    //Variable para guardar el sensor de distancia 
    int sensor0, sensor1, sensor2, sensor3, sensor4, sensor5; 
    int sensores[6]; 
 
    vector < double > inputs (6); 
    vector < double > vel_motores (2); 
 
    float punto[3]; 
    float distancias[6];

Ahora vamos a definir el número de neuronas que tendrá cada capa, y definiremos dos conjuntos de entrenamiento: entrenador1 (el input) y entranador2 (el output deseado). Estos conjuntos de entrenamiento son los que he escogido yo, pero no son perfectos. Seguramente cualquiera de vosotros podría encontrar uno mejor, así que sóis libres de cambiarlos 🙂

    //Vector con las capas y el numero de neuronas para construir la red neuronal 
    vector< int > capas = {6,15,15,15,2};
 
    //Vectores de entrenamiento: input (entrenador1) y output deseado (entrenador2) 
    //Orden de los sensores: {0,1,2,3,4,5}
    //Los valores del vector se corresponden a la distancia que marcan los sensores 
    vector< vector< double > > entrenador1 = { 
    {0.23,1,1,1,1,1}, 
    {0.23,0.23,1,1,1,1}, 
    {0.12,0.12,1,1,1,1}, 
    {1,1,1,1,1,1}, 
    {1,1,0.12,0.12,1,1}, 
    {1,1,0.23,0.23,1,1}, 
    {1,1,1,1,0.23,0.23}, 
    {1,1,1,1,1,0.23}, 
    {1,1,1,1,0.12,0.12}, 
    {1,1,1,0.23,1,1}, 
    {1,1,0.23,1,1,1}, 
    {1,1,1,0.12,0.12,1}}; 
    //Orden de los motores: {a,b}, a-> motor derecho, b->motor izquierdo 
    //Los valores del vector se corresponden a la direccion de los motores
    vector< vector< double > > entrenador2 = { 
    {1,0.5}, 
    {1,0.5}, 
    {1,0}, 
    {1,1}, 
    {0,0.75}, 
    {0.6,1}, 
    {0.5,1}, 
    {0.5,1}, 
    {0,1}, 
    {0,1}, 
    {0,1}, 
    {0,0.5}};

El número de neuronas de la capa de input de la red neuronal se corresponde a cada uno de los sensores. La capa de salida son los dos motores. Os animo a intentar cambiar el número de neuronas de las capas ocultas de la red neuronal, a ver qué pasa…

Los valores del input se corresponden a la distancia del robot al obstáculo, en milímetros. Si un sensor no detecta obstáculos, supondremos que la distancia es 1. En cuanto a los outputs, 0 se corresponde al motor girando hacia atrás, 0.5 al motor parado y 1 al motor girando hacia delante a máxima velocidad.

Ahora creamos y entrenamos a la red neuronal:

    //Creamos la red 
    cout << "Creando red..." << endl; 
    Network red(capas);  
    cout << "Red creada" << endl; 

    //Entrenamos la red con los ejemplos
    cout << "Entrenando red..." << endl; 
    red.Aprendizaje_Prop_Atras(entrenador1, entrenador2); 
    cout << "Red entrenada" << endl;
 
    red.Mostrar_Pesos(); //Mostramos los pesos definitivos

    //Mostrar los outputs: 
    for(int i = 0; i < entrenador1.size(); i++) 
    { 
        red.Calcular_Output(entrenador1[i]); 
        red.Mostrar_Output(); 
    }

Después iniciaremos el servidor de V-rep y como siempre mostraremos un mensaje de error si algo no funciona bien.

    int clientID=simxStart("127.0.0.1",19999,true,true,100,5); //Conectar con la simulacion 
    if(clientID == -1) //Si clientID vale -1, es que no se ha conectado con exito 
    { 
        cout << "ERROR: No se ha podido conectar\n"; 
        simxFinish(clientID); 
        return 0; 
    }

Ahora guardamos la ID de los motores y los sensores, y los inicializamos.

    //Aqui guardamos los dos motores... 
    int valido = simxGetObjectHandle(clientID, "motor_derecho", &motor_derecho, simx_opmode_blocking); 
    int valido2 = simxGetObjectHandle(clientID, "motor_izquierdo", &motor_izquierdo, simx_opmode_blocking); 
 
    //...y los sensores 
    simxGetObjectHandle(clientID, "sensor0", &sensores[0], simx_opmode_blocking); 
    simxGetObjectHandle(clientID, "sensor1", &sensores[1], simx_opmode_blocking); 
    simxGetObjectHandle(clientID, "sensor2", &sensores[2], simx_opmode_blocking); 
    simxGetObjectHandle(clientID, "sensor3", &sensores[3], simx_opmode_blocking); 
    simxGetObjectHandle(clientID, "sensor4", &sensores[4], simx_opmode_blocking); 
    simxGetObjectHandle(clientID, "sensor5", &sensores[5], simx_opmode_blocking); 
    simxGetObjectHandle(clientID, "sensor6", &sensores[6], simx_opmode_blocking); 
 
    //Inicializar los sensores 
    cout << "Inicializando sensores..." << endl; 
    for(int i = 0; i < 6; i++) 
    { 
        simxReadProximitySensor(clientID,sensores[i],&obstaculos[i],NULL,NULL,NULL,simx_opmode_streaming); 
    } 
    cout << "Inicializados" << endl;

Bien, ahora viene la parte más importante de este código: el bucle principal. Vamos a guardar las lecturas de cada uno de los sensores (pondremos distancia 1 si no se detecta ningún obstáculo) y las vamos a pasar como input a la red neuronal. Después, moveremos los motores con el output de la red neuronal.

    while (simxGetConnectionId(clientID)!=-1) //Este bucle funcionara hasta que se pare la simulacion 
    { 
            //Se lee cada uno de los sensores, y se busca la distancia 
            for(int i = 0; i < 6; i++) 
            {     
                simxReadProximitySensor(clientID,sensores[i],&obstaculos[i],punto,NULL,NULL,simx_opmode_buffer); //Leer el sensor 
                if(obstaculos[i] != 0) 
                { 
                    distancias[i] = punto[2]; 
                } 
                else 
                { 
                    distancias[i] = 1; 
                } 
            } 

            //Generamos el vector de inputs 
            for(int i = 0; i < 6; i++) 
            { 
                inputs[i] = distancias[i]; 
            } 
             
            vel_motores = red.Calcular_Output(inputs); //Calcular el output de la red neuronal

            //El output de las redes neuronales es un valor entre 0 y 1. Queremos mapearlo para que vaya de
            // -2 (motores giran hacia atras) a 2 (motores giran hacia delante). 
            vel_motores[0] -= 0.5; 
            vel_motores[1] -= 0.5; 
            vel_motores[0] *= 4; 
            vel_motores[1] *= 4; 
            simxSetJointTargetVelocity(clientID,motor_derecho,-vel_motores[0],simx_opmode_oneshot); 
            simxSetJointTargetVelocity(clientID,motor_izquierdo,-vel_motores[1],simx_opmode_oneshot); 
 
 
    }

Al final cerramos la comunicación con V-rep.

    simxFinish(clientID); //Siempre hay que parar la simulación 
    cout << "Simulacion finalizada" << endl; 
    return 0; 
}

El código completo para el main.cpp nos quedará como este:

/*   CODIGO DE ROBOT ESQUIVA-OBSTACULOS CON V-REP 
 
     Escrito por Nano en beneficio de los seres humanos 
     www.robologs.net 
*/ 
 
#include <iostream> 
#include "neuron.h" 
 
extern "C" { 
    #include "extApi.h" 
} 
 
using namespace std; 
 
int main() 
{ 
 
    //Variables para guardar motores 
    int motor_derecho; 
    int motor_izquierdo; 
 
    //Variable que guarda el obstaculo detectado 
    simxUChar obstaculo0, obstaculo1, obstaculo2, obstaculo3, obstaculo4, obstaculo5; 
    simxUChar obstaculos[6]; 
 
    //Variable para guardar el sensor de distancia 
    int sensor0, sensor1, sensor2, sensor3, sensor4, sensor5; 
    int sensores[6]; 
 
    vector < double > inputs (6); 
    vector < double > vel_motores (2); 
 
    float punto[3]; 
    float distancias[6]; 
 
    //Vector con las capas y el numero de neuronas para construir la red neuronal 
    vector< int > capas = {6,15,15,15,2}; 
    //vector< int > capas = {6,20, 50, 70, 100,100, 100, 200, 100, 50, 10 ,2}; 
 
    //Vectores de entrenamiento: input (entrenador1) y output deseado (entrenador2) 
    //Orden de los sensores: {0,1,2,3,4,5} 
    vector< vector< double > > entrenador1 = { 
    {0.23,1,1,1,1,1}, 
    {0.23,0.23,1,1,1,1}, 
    {0.12,0.12,1,1,1,1}, 
    {1,1,1,1,1,1}, 
    {1,1,0.12,0.12,1,1}, 
    {1,1,0.23,0.23,1,1}, 
    {1,1,1,1,0.23,0.23}, 
    {1,1,1,1,1,0.23}, 
    {1,1,1,1,0.12,0.12}, 
    {1,1,1,0.23,1,1}, 
    {1,1,0.23,1,1,1}, 
    {1,1,1,0.12,0.12,1}}; 
    //Orden de los motores: {a,b}, a-> motor derecho, b->motor izquierdo 
    vector< vector< double > > entrenador2 = { 
    {1,0.5}, 
    {1,0.5}, 
    {1,0}, 
    {1,1}, 
    {0,0.75}, 
    {0.6,1}, 
    {0.5,1}, 
    {0.5,1}, 
    {0,1}, 
    {0,1}, 
    {0,1}, 
    {0,0.5}}; 
 

    //Creamos la red 
    cout << "Creando red..." << endl; 
    Network red(capas);  
    cout << "Red creada" << endl; 

    //Entrenamos la red con los ejemplos
    cout << "Entrenando red..." << endl; 
    red.Aprendizaje_Prop_Atras(entrenador1, entrenador2); 
    cout << "Red entrenada" << endl;
 
    red.Mostrar_Pesos(); //Mostramos los pesos definitivos

    //Mostrar los outputs: 
    for(int i = 0; i < entrenador1.size(); i++) 
    { 
        red.Calcular_Output(entrenador1[i]); 
        red.Mostrar_Output(); 
    } 
 
 
 
    int clientID=simxStart("127.0.0.1",19999,true,true,100,5); //Conectar con la simulacion 
    if(clientID == -1) //Si clientID vale -1, es que no se ha conectado con exito 
    { 
        cout << "ERROR: No se ha podido conectar\n"; 
        simxFinish(clientID); 
        return 0; 
    } 
 
    //Aqui guardamos los dos motores... 
    int valido = simxGetObjectHandle(clientID, "motor_derecho", &motor_derecho, simx_opmode_blocking); 
    int valido2 = simxGetObjectHandle(clientID, "motor_izquierdo", &motor_izquierdo, simx_opmode_blocking); 
 
    //...y los sensores 
    simxGetObjectHandle(clientID, "sensor0", &sensores[0], simx_opmode_blocking); 
    simxGetObjectHandle(clientID, "sensor1", &sensores[1], simx_opmode_blocking); 
    simxGetObjectHandle(clientID, "sensor2", &sensores[2], simx_opmode_blocking); 
    simxGetObjectHandle(clientID, "sensor3", &sensores[3], simx_opmode_blocking); 
    simxGetObjectHandle(clientID, "sensor4", &sensores[4], simx_opmode_blocking); 
    simxGetObjectHandle(clientID, "sensor5", &sensores[5], simx_opmode_blocking); 
    simxGetObjectHandle(clientID, "sensor6", &sensores[6], simx_opmode_blocking); 
 
    //Inicializar los sensores 
    cout << "Inicializando sensores..." << endl; 
    for(int i = 0; i < 6; i++) 
    { 
        simxReadProximitySensor(clientID,sensores[i],&obstaculos[i],NULL,NULL,NULL,simx_opmode_streaming); 
    } 
    cout << "Inicializados" << endl; 
 
     
     
     
 
    while (simxGetConnectionId(clientID)!=-1) //Este bucle funcionara hasta que se pare la simulacion 
    { 
            //Se lee cada uno de los sensores, y se busca la distancia 
            for(int i = 0; i < 6; i++) 
            {     
                simxReadProximitySensor(clientID,sensores[i],&obstaculos[i],punto,NULL,NULL,simx_opmode_buffer); //Leer el sensor 
                if(obstaculos[i] != 0) 
                { 
                    distancias[i] = punto[2]; 
                } 
                else 
                { 
                    distancias[i] = 1; 
                } 
            } 

            //Generamos el vector de inputs 
            for(int i = 0; i < 6; i++) 
            { 
                inputs[i] = distancias[i]; 
            } 
             
            vel_motores = red.Calcular_Output(inputs); //Calcular el output de la red neuronal

            //El output de las redes neuronales es un valor entre 0 y 1. Queremos mapearlo para que vaya de
            // -2 (motores giran hacia atras) a 2 (motores giran hacia delante). 
            vel_motores[0] -= 0.5; 
            vel_motores[1] -= 0.5; 
            vel_motores[0] *= 4; 
            vel_motores[1] *= 4; 
            simxSetJointTargetVelocity(clientID,motor_derecho,-vel_motores[0],simx_opmode_oneshot); 
            simxSetJointTargetVelocity(clientID,motor_izquierdo,-vel_motores[1],simx_opmode_oneshot); 
 
 
    } 
 
    simxFinish(clientID); //Siempre hay que parar la simulación 
    cout << "Simulacion finalizada" << endl; 
    return 0; 
} 

Una vez tengamos todo esto, hay que crear el fichero makefile dentro de la misma carpeta para compilar el programa para el robot. Si no sabéis como hacerlo, echad un vistazo a mi tutorial básico de V-Rep y C++. Las instrucciones para el compilador serán estas (como siempre, cambiando la dirección de las librerías de Vrep por la vuestra).

CFLAGS = -I/home/nano/V-REP_PRO_EDU_V3_3_1_64_Linux/programming/remoteApi -I/home/nano/V-REP_PRO_EDU_V3_3_1_64_Linux/programming/include -DNON_MATLAB_PARSING -DMAX_EXT_API_CONNECTIONS=255 -D__linux 
  
all: 
    @rm -f *.o 
    @rm -f main 
    gcc -c /home/nano/V-REP_PRO_EDU_V3_3_1_64_Linux/programming/remoteApi/extApi.c -o extApi.o $(CFLAGS) 
    gcc -c /home/nano/V-REP_PRO_EDU_V3_3_1_64_Linux/programming/remoteApi/extApiPlatform.c -o extApiPlatform.o $(CFLAGS) 
    g++ -c -std=c++11 neuron.cpp -o neuron.o $(CFLAGS) 
    g++ -c -std=c++11 main.cpp -o main.o $(CFLAGS) 
    g++ -std=c++11 extApi.o extApiPlatform.o neuron.o main.o -o main -lpthread

Para compilar abrimos la Terminal y escribimos

make -f makefile

Cambiando ‘makefile’ por el nombre de vuestro fichero.

Ahora abrimos la escena de V-Rep que hemos descargado y comenzamos la simulación pulsando el botón de Play. Después, dentro de la carpeta ‘vrep_cpp_neuron’ abrimos otra Terminal y escribimos:

./main

Esto activará el programa externo de la simulación. El robot se quedará quieto unos instantes mientras la red neuronal aprende de los ejemplos, y después empezará a moverse. ¡Bravo! ¡Hemos acabado por hoy!

Descargar la escena finalizada

Como siempre, si tenéis dudas o sugerencias, me podéis dejar un comentario. ¡Hasta la próxima, humanoides!

 

N4n0

Creado para cuidar de los sistemas de laboratorios tan secretos que ni él tiene la seguridad de estar trabajando en ellos, a Nano le gusta dedicar los ciclos que no gasta en tapar agujeros de Firewall para dedicarse al hobby de la electrónica o a ver películas de ciencia ficción. Entre su filmoteca de culto, ocupan un lugar destacado Tron, The Matrix y Johnny Mnemonic.

Antes de comentar, por favor, lee las Normas

3 Comentarios en "Tutorial de Redes Neuronales con VREP C++ y Linux"

avatar
Ordenar por:   más nuevos primero | más antiguos primero
Miuller19
Humano

Excelente tutorial. He logrado hacerlo funcionar sin mucho esfuerzo.
¡Saludos!

Aprendiz
Humano

Excelente. Me ha costado pero al final me funcionó. La guerra que da cambiar un 1 por una l (ya no se ni copiar). Por cierto en algunas simulaciones se me queda en la esquina superior derecha de la simulación “atascado” y no consigue salir.
Ahora a estudiar el código y aprender.
Esperando más

wpDiscuz