¿Te gustó el sitio?

Módulo 5 - Estructurando la inteligencia

Introducción

Estamos en la recta final del entendimiento de las redes neuronales. Pero antes de continuar, recordemos dónde nos quedamos en el módulo anterior:

NeuroTIC.h

/*========BIBLIOTECAS========*/
#include <math.h>
/*========ACTIVACION=========*/
float activacion_booleana( float x ){
    return x >= 0;
}
float activacion_booleana_d( float x ){
    return 1;
}

float activacion_sigmoide( float x ){
    return 1 / ( 1 + exp( - x ) );
}
float activacion_sigmoide_d( float x ){
    return activacion_sigmoide( x ) * ( 1 - activacion_sigmoide( x ) );
}

float ( *activacion[][2] )( float )={
    { activacion_booleana , activacion_booleana_d },
    { activacion_sigmoide , activacion_sigmoide_d }
};
/*=======DEFINICIONES========*/
struct neurona{
    float *Entrada[2];
    float Salida;
    float Peso[2];
    float Sesgo;
    int activacion;
};
/*=========CALCULAR==========*/
float ponderacion( struct neurona N ){
    float p= N.Sesgo;
    for( int i= 0; i < 2 ; i++ )
        p+= *N.Entrada[i] * N.Peso[i];
    return p;
}

float evaluar_neurona( struct neurona N ){
   return activacion[N.activacion][0]( ponderacion( N ) );
}
/*=========ENTRENAR==========*/
int entrenar(  struct neurona *N , float entradas[4][2] , float resultados[4] , float tasa_aprendizaje , float error_permitido , int max_intentos  ){
    float error;
    float delta;
    float delta_oculta;
    float error_total;
    int epoca= 0;
    do{
        error_total= 0;
        for( int i= 0 ; i < 4 ; i++ ){
            for( int j= 0; j < 2 ; j++ )
                *N[0].Entrada[j]= entradas[i][j];
            for( int j= 0 ; j < 3 ; j++ )
                N[j].Salida= evaluar_neurona( N[j] );
            error= resultados[i] - N[2].Salida;
            error_total= fmax( error_total , fabs( error ) );
            delta= error * activacion[N[2].activacion][1]( ponderacion( N[2]) );
            N[2].Sesgo+= delta * tasa_aprendizaje;
            for( int j= 0 ; j < 2 ; j++ )
                N[2].Peso[j]+= delta * tasa_aprendizaje * entradas[i][j];
            for( int j= 0; j < 2 ; j++ ){
                delta_oculta= delta * N[2].Peso[j] * activacion[N[j].activacion][1]( ponderacion( N[j] ) );
                N[j].Sesgo+= tasa_aprendizaje * delta_oculta;
                for( int k= 0; k < 2; k++ )
                    N[j].Peso[k]+= tasa_aprendizaje * delta_oculta * *N[j].Entrada[k];
            }
        }
    } while( ++epoca < max_intentos && error_total > error_permitido );
    return epoca;
}

NeuroTIC.c

/*========BIBLIOTECAS========*/
#include <stdio.h>
#include "NeuroTIC.h"
/*=========VARIABLES=========*/
float Entrada[2];
float Salida;

struct neurona N[]= {
    {
    .Entrada= { &Entrada[0] , &Entrada[1] },
    .Peso= { 0.1 , -0.2 },
    .activacion= 1
    },
    {
    .Entrada= { &Entrada[0] , &Entrada[1] },
    Peso= { -0.4 , 0.5 },
    .activacion= 1
    },
    {
    .Entrada= { &N[0].Salida , &N[1].Salida },
    .Peso= { 0.7 , -0.8 },
    .activacion= 1
    }
};
/*=========PRINCIPAL=========*/
int main(){
    /* ENTRADAS */
    float tabla[4][2]={
        { 0 , 0 },
        { 0 , 1 },
        { 1 , 0 },
        { 1 , 1 }
    };
    /* MUESTRAS */
    float resultado[]= { 0 , 1 , 1 , 0 };
    /* ENTRENAMIENTO */
    printf( "\nEntrenamiento Intentos: %i\n" , entrenar( N , tabla , resultado , 0.1, 0.0, 100000 ) );
    /* CALCULAR E IMPRIMIR */
        printf( "\n\n| A | B | S |\n" );
        for( int i= 0 ; i < 4 ; i++ ){
            for( int j= 0 ;  j < 2 ; j++ )
                Entrada[j]= tabla[i][j];
            for( int j= 0; j < 3 ; j++ )
                N[j].Salida= evaluar_neurona( N[j] );
            Salida= N[2].Salida;
            printf( "| %.0f | %.0f | %.0f |\n" , Entrada[0] , Entrada[1] , Salida );
        }
        printf( "\n" );
    /* TERMINAR PROGRAMA */
    return 0;
}

Ahora ya cuentas con toda la teoría y las herramientas necesarias para construir una red y entrenarla con ejemplos concretos. Aprendiste a propagar el error desde la capa de salida hasta la capa de entrada, usando la derivada de la función sigmoide. Sin embargo, aún nos queda una última restricción por superar para romper el límite de las tres neuronas que hemos estado utilizando hasta ahora.

Esta vez, el obstáculo no es conceptual, sino técnico. Tiene que ver con cómo estamos declarando las entradas y los pesos dentro de la estructura de la neurona, y también con la forma en que estamos organizando las neuronas dentro de un arreglo. Pero antes de ver cómo solucionarlo, es importante entender por qué es un problema.


Topología de una red neuronal artificial

El concepto de topología en las redes neuronales se refiere a cómo están organizadas las neuronas y cómo se conectan entre sí.

Una topología básica implica que cada neurona de una capa se conecta a todas las neuronas de la capa anterior. Esta es, de hecho, la topología que has utilizado hasta ahora: cada neurona de tu capa de entrada se conecta a cada una de las entradas disponibles, y la neurona de salida se conecta a todas las neuronas de la capa anterior.

Como hasta ahora solo has trabajado con dos entradas y dos neuronas intermedias, cada neurona tiene exactamente dos entradas. Pero ¿qué pasaría si tuvieras más entradas? ¿O si las capas intermedias tuvieran más neuronas?

Aquí es donde aparece la limitación: tu estructura de neurona está definida como *Entrada[2] y Peso[2]. Esto significa que solo puede manejar exactamente dos entradas. Si quisieras usar más, tendrías que definir una estructura diferente para cada caso.

En una red neuronal real, esto no es viable, ya que cada neurona puede tener una cantidad distinta de entradas, dependiendo de cuántas neuronas haya en la capa anterior. Es necesario, entonces, crear una estructura más flexible que se adapte dinámicamente a cualquier cantidad de entradas.


Creadno entradas

Para poder lograr la flexibilidad que te propongo —y que te permitirá construir cualquier topología de red—, vas a conocer una nueva función de C llamada calloc. Esta función se encuentra definida en la biblioteca stdlib.h, así que lo primero será incluir dicha biblioteca en tu propia cabecera, para que esté disponible tanto en ella como en el código principal. Luego, haremos un cambio importante en la definición de la estructura de la neurona:

NeuroTIC.h

/*========BIBLIOTECAS========*/
#include <math.h>
#include <stdlib.h>
/*========ACTIVACION=========*/
/*=======DEFINICIONES========*/
struct neurona{
    int entradas;
    float **Entrada;
    float Salida;
    float *Peso;
    float Sesgo;
    int activacion;
};
/*=========ENTRENAR==========*/

Como puedes ver, ahora el atributo Entrada ya no es un arreglo estático, sino un puntero doble, lo cual nos permite apuntar a un arreglo de direcciones. Por otro lado, Peso ya no es un arreglo de tamaño fijo, sino un puntero simple, lo que nos permitirá crear su contenido dinámicamente.

Además, agregamos una nueva variable: entradas. Esta será muy útil porque nos permitirá definir cuántas entradas tendrá cada neurona de forma independiente.

Ya sabes que un puntero solo puede guardar una única dirección. Pero con la función calloc podemos pedirle al sistema operativo que nos reserve un bloque de memoria con una cierta cantidad de espacios, y luego nos devuelva la dirección de inicio de ese bloque. Esto es muy parecido a crear un arreglo durante la ejecución del programa.

Es muy importante que no utilices calloc sin tener dónde guardar la dirección que devuelve. Si la pierdes, no podrás acceder a ese bloque de memoria ni tampoco liberarlo después. Vamos a ver cómo se usa esta función en el código principal.

NeuroTIC.c

/*========BIBLIOTECAS========*/
#include <stdio.h>
#include "NeuroTIC.h"
/*=========PRINCIPAL=========*/
int main(){
/*=========VARIABLES=========*/
    float Entrada[2];
    float Salida;

    struct neurona N[]= {
        {
        .entradas= 2,
        .activacion= 1
        },
        {
        .entradas= 2,
        .activacion= 1
        },
        {
        .entradas= 2,
        .activacion= 1
        }
    };

    for( int i= 0 ; i < 3 ; i++ ){
        N[i].Entrada= calloc( N[i].entradas , sizeof( float * ) );
        N[i].Peso= calloc( N[i].entradas , sizeof( float ) );
    }

    for( int i= 0 ; i < 2 ; i++ )
        for( int j= 0 ; j < 2 ; j++ )
            N[i].Entrada[j]= &Entrada[j];

    for( int i= 0 ; i < 2 ; i++ )
        N[2].Entrada= &N[i].Salida;

    int max= 1;
    int min= -1;
    for( int i= 0 ; i < 3 ; i++ )
        for( int j= 0 ; j < N[i].entradas ; j++ )
            N[i].Peso[j]= ( rand() % ( max - min + 1 ) ) + min;
    /* ENTRADAS */
    /* MUESTRAS */
    /* ENTRENAMIENTO */
    /* CALCULAR E IMPRIMIR */
    /* TERMINAR PROGRAMA */
}

Explicación del código:

  • Como todas las funciones de tu biblioteca son independientes del código principal, lo primero que hacemos es mover la definición de main() justo después de incluir las bibliotecas.
  • Luego eliminamos la inicialización manual de las direcciones en el atributo Entrada y la reemplazamos por la asignación del atributo entradas, que ahora nos permite definir cuántas tendrá cada neurona.
  • Una vez creadas las estructuras, usamos un bucle para asignar memoria con calloc tanto a Entrada como a Peso.
  • Después, otro par de bucles nos permite asignar las direcciones específicas a cada neurona, ya que no podemos usar llaves para inicializar estos atributos después de declararlos.
  • Finalmente, se asignan los valores iniciales a los pesos. Para no hacerlo manualmente, se utiliza la función rand (de stdlib.h) que genera un número pseudoaleatorio. La operación que estás viendo permite mantener el resultado dentro de un rango entre un valor mínimo y un valor máximo.

Antes de entender bien cómo funciona calloc, necesitas conocer otra herramienta básica de C: la función sizeof.

Ya dijimos que las variables son como cajas donde guardas números. Pero lo que no te he explicado aún es que esas cajas no tienen todas el mismo tamaño: una variable de tipo int ocupa una cierta cantidad de espacio en memoria, y una de tipo float ocupa otra diferente.

La función sizeof te dice exactamente cuántos espacios (bytes) necesita un tipo de variable para guardarse en memoria. Por ejemplo:
sizeof(float)
Esto le pregunta al compilador: ¿cuánto espacio ocupa una variable tipo float?

Ahora, cuando usamos calloc(cantidad, tamaño), lo que le estamos pidiendo a la computadora es que nos reserve cantidad × tamaño espacios de memoria, donde:

  • cantidad es el número de elementos que queremos guardar, y
  • tamaño es cuánto espacio ocupa cada uno (lo que nos dice sizeof).

Por ejemplo:
N[i].Peso = calloc(N[i].entradas, sizeof(float));
Esto significa: reserva un bloque de memoria capaz de guardar N[i].entradas valores tipo float.

Y en esta otra:
N[i].Entrada = calloc(N[i].entradas, sizeof(float *));
Estamos reservando memoria para N[i].entradas direcciones, cada una de las cuales apunta a una variable tipo float.

NeuroTIC.c

/*========BIBLIOTECAS========*/
#include <stdio.h>
#include "NeuroTIC.h"
/*=========PRINCIPAL=========*/
int main(){
/*=========VARIABLES=========*/
    /* ENTRADAS */
    /* MUESTRAS */
    /* ENTRENAMIENTO */
    /* CALCULAR E IMPRIMIR */
    /* TERMINAR PROGRAMA */
    for( int i= 0 ; i < 3 ; i ++ ){
        free( N[i].Entrada );
        free( N[i].Peso );
    }
    return 0;
}

Cuando utilizas herramientas de creación de memoria dinámica como calloc, es muy importante liberar ese espacio una vez que ya no se necesita. De lo contrario, aunque termine tu programa, el sistema puede seguir marcando ese espacio de memoria como ocupado, lo que podría causar problemas si se repite muchas veces (esto se conoce como fuga de memoria).

Para liberar la memoria usamos la función free(), pasándole la dirección del bloque que queremos liberar. Por eso es tan importante siempre guardar esa dirección en un puntero: si la pierdes, no hay forma de liberar ese espacio.

Como puedes ver, en este bucle final se está liberando el espacio asignado a Entrada y Peso de cada una de las neuronas.


Creando topologías

Hasta ahora hemos utilizado un arreglo simple de tres neuronas porque la red era pequeña: dos neuronas en la primera capa y una en la segunda. En este caso, es fácil de manejar y no representa ninguna complicación.

Sin embargo, cuando tu red empieza a crecer, se vuelve muy difícil controlar a qué capa pertenece cada neurona y cómo se deben conectar entre sí. Además, si quieres que cada capa tenga un número distinto de neuronas, el manejo de los índices se vuelve cada vez más complejo y propenso a errores.

Por eso, a partir de este punto vas a rediseñar la estructura de tu red neuronal. Vas a crear una nueva estructura que no solo contenga las neuronas, sino que también describa la topología completa de la red: cuántas capas tiene, cuántas neuronas hay por capa y cómo se conectan entre sí. Esta estructura utilizará punteros dinámicos para que puedas construir redes de cualquier tamaño y forma.

NeuroTIC.h

/*========BIBLIOTECAS========*/
/*========ACTIVACION=========*/
/*=======DEFINICIONES========*/
struct neurona{
    int entradas;
    float **Entrada;
    float Salida;
    float *Peso;
    float Sesgo;
    int activacion;
};

struct red{
    int entradas;
    float **Entrada;
    int capas;
    int *neuronas;
    struct neurona **N;
    float ***buffer;
    float **Salida;
}
/*=========ENTRENAR==========*/

Veamos qué atributos tiene esta nueva estructura:

  • entradas: Aquí se define la cantidad de valores de entrada que tendrá la red.
  • *Entrada: Este será un arreglo de punteros que servirá para comunicar la red con el exterior. Es decir, no guarda los datos directamente, solo apunta a ellos.
  • capas: Será el valor que indica cuántas capas tiene la red en total, incluyendo la capa de entrada, las ocultas (si las hay) y la capa de salida.
  • *neuronas: Se convertirá en un arreglo donde cada posición indica cuántas neuronas hay en cada capa. Por ejemplo, neuronas[0] corresponde a la capa de entrada, neuronas[1] a la siguiente, y así sucesivamente.
  • **N: Este doble puntero se utiliza de forma distinta a como se hizo con Entrada. En lugar de ser solo un arreglo de punteros, será una matriz dinámica donde cada fila representa una capa y cada elemento de esa fila es una neurona. Así podrás posicionar las neuronas organizadas por capa y facilitar la construcción de la topología de la red.
  • ***buffer: ¿Un puntero triple? ¿Me he vuelto loco? No, ya lo estaba.
    Este puntero tiene la función de evitar que dupliques arreglos para conectar las neuronas intermedias con las salidas de las capas anteriores. El buffer será una matriz de punteros: cada fila representa una capa intermedia, y dentro de ella hay punteros que apuntan directamente a las salidas de la capa anterior. Las entradas de cada neurona se conectarán a este arreglo de punteros.
  • **Salida: Es un puntero doble similar a Entrada. Hasta ahora has trabajado con redes que solo tenían una salida, pero si más adelante agregas varias, este arreglo te permitirá devolver fácilmente todas las salidas sin complicaciones, ya que cada puntero apunta directamente al campo Salida de una neurona en la última capa.

Una vez definida la estructura, vamos a declarar una red con 2 entradas, 2 neuronas en la primera capa y 1 en la segunda (justo como hemos venido trabajando).

NeuroTIC.c

/*========BIBLIOTECAS========*/
/*=========PRINCIPAL=========*/
/*=========VARIABLES=========*/
    struct red R={
        .entradas= 2,
        .capas= 2,
    };

    R.neuronas= calloc( R.capas , sizeof( int ) );
    R.neuronas[0]= 2;
    R.neuronas[1]= 1;

    R.Entrada= calloc( R.entradas , sizeof( float * ) );

    R.N= calloc( R.capas , sizeof( struct neurona * ) );
    for( int i= 0 ; i < R.capas ; i++ )
        R.N[i]= calloc( R.neuronas[i] , sizeof( struct neurona ) );

    for( int i= 0 ; i < R.neuronas[0] ; i++ )
        R.N[0][i].Entrada= R.Entrada;
    
    R.Salida= calloc( R.neuronas[R.capas - 1] , sizeof( float * ) );
    for( int i= 0 ; i < R.neuronas[R.capas - 1] ; i++ )
        R.Salida[i]= &R.N[R.capas -1][i].Salida;
    
    R.buffer= calloc( R.capas - 1 ,  sizeof( float ** ) );
    for( int i= 0 ; i < R.capas - 1 ; i++ ){
        R.buffer[i]= calloc( R.neuronas[i] , sizeof( float * ) );
        for( int j= 0 ; j < R.neuronas[i] ; j++ )
            R.buffer[i][j]= &R.N[i][j].Salida;
    }

    for( int i= 0 ; i < R.capas - 1 ; i++ )
        for( int j= 0 ; j < R.neuronas[i + 1] ; j++ )
            R.N[i + 1][j].Entrada= R.buffer[i];

    for( int i= 0 ; i < R.neuronas[0] ; i++ )
        R.N[0][i].entradas= R.entradas;
    for( int i= 1 ; i < R.capas ; i++ )
        for( int j= 0 ; j < R.neuronas[i] ; j++ )
            R.N[i][j].entradas= R.neuronas[i - 1];

    for( int i= 0 ; i < R.capas ; i++ )
        for( int j= 0 ; j < R.neuronas[i] ; j++ )
            R.N[i][j].Peso= calloc( R.N[i][j].entradas , sizeof( float ) );

    int max= 1;
    int min= -1;
    for( int i= 0 ; i < R.capas ; i++ )
        for( int j= 0 ; j < R.neuronas[i] ; j++ )
            for( int k= 0 ; k < R.N[i][j].entradas ; k++ )
                R.N[i][j].Peso[k]= ( rand() % ( max - min + 1 ) ) + min;
    /* ENTRADAS */
    /* MUESTRAS */
    /* ENTRENAMIENTO */
    /* CALCULAR E IMPRIMIR */
    /* TERMINAR PROGRAMA */
    free( R.Salida );
    for( int i= 0 ; i < R.capas ; i++ ){
        for( int j= 0 ; j < R.neuronas[i] ; j++ )
            free( R.N[i][j].Peso );
        free( R.N[0][i].Entrada );
        free( R.N[i] );
    }
    free( R.N );
    free( buffer );
    return 0;

Sí, ¡el código ha crecido! Pero no te preocupes. Este bloque pronto se convertirá en una función reutilizable dentro de tu biblioteca. Así, cada vez que quieras crear una red, solo tendrás que decir cuántas entradas tendrá, cuántas capas usará, y cuántas neuronas habrá en cada capa.

Veamos paso a paso cómo se construye esta red neuronal:

  1. Inicia declarando una estructura red llamada R, donde defines dos valores fundamentales: la cantidad de entradas que recibirá del exterior, y la cantidad total de capas que tendrá la red, incluyendo tanto la capa de entrada como la de salida. Esta información será clave para crear toda la topología.
  2. Se reserva memoria para el arreglo neuronas, que indica cuántas neuronas tendrá cada capa. En este ejemplo, asignamos 2 neuronas para la primera capa (la de entrada) y 1 para la segunda (la de salida). Como verás, este arreglo es tan largo como el número de capas.
  3. Se reserva el espacio para el arreglo de punteros Entrada, que servirá para comunicar la red con el mundo exterior. Este arreglo no contiene valores, sino direcciones. Cada posición apuntará a una variable de entrada individual.
  4. Se construye la matriz N que contendrá las neuronas organizadas por capa. Primero se crea un arreglo de punteros a estructuras neurona, donde cada puntero representará una capa. Luego, para cada capa, se reserva un arreglo de estructuras neurona de acuerdo a cuántas se necesitan, según el arreglo neuronas.
  5. Se conectan las entradas externas a la primera capa de la red. Esto se logra cargando las direcciones almacenadas en R.Entrada dentro del atributo Entrada de cada neurona en la capa 0. Así, todas las neuronas de la primera capa estarán leyendo directamente las entradas definidas fuera de la red.
  6. Se prepara la salida de la red. En lugar de tener una única salida, se utiliza un arreglo de punteros dobles llamado Salida, donde se almacenan las direcciones de las salidas de todas las neuronas de la última capa. Para acceder a esta capa correctamente se usa el índice R.capas - 1, ya que los arreglos inician en cero.
  7. Se construye el buffer que conectará las capas intermedias. Primero se reserva un arreglo de punteros dobles, con un tamaño de R.capas - 1, para cubrir todos los espacios entre capas. Luego, para cada una de esas filas, se reserva un arreglo de punteros que apuntan a la salida de cada neurona de la capa anterior. Este buffer actúa como un puente: guarda punteros a las salidas de una capa para que las siguientes puedan usarlas como entrada.
  8. Se recorre la matriz N para conectar cada neurona con su correspondiente entrada. Las neuronas de la capa 0 ya están conectadas a las entradas externas, pero todas las demás se conectan al buffer de la capa anterior. Así se establece automáticamente la conexión entre capas.
  9. Se define cuántas entradas tendrá cada neurona. Las neuronas de la primera capa tomarán como número de entradas el valor R.entradas. Luego, capa por capa, cada neurona toma como número de entradas la cantidad de neuronas de la capa anterior. Esto asegura que cada neurona tenga exactamente un peso por cada entrada recibida.
  10. Se reserva memoria para el arreglo de pesos Peso de cada neurona. Finalmente, una vez conocido cuántas entradas tendrá cada neurona, se utiliza calloc para crear su arreglo de pesos correspondiente. Así, cada peso estará vinculado directamente a una entrada, lista para ser entrenada.
  11. Por último, se modifica el bucle de inicialización de los pesos. Ahora recorre toda la matriz de neuronas usando el número de capas y la cantidad de neuronas por capa. Para cada neurona, se recorre su arreglo de pesos y se les asigna un valor inicial. Esto garantiza que todos los pesos estén listos para el entrenamiento sin importar la topología de la red.

Como puedes observar, solo los pasos 1 y 2 debes indicarlos manualmente, lo demas sucede en funcion de estos.

Finalmente, se libera la memoria dinámica utilizada por la red. Se comienza con el arreglo de salidas, seguido por los pesos de cada neurona y el arreglo compartido de entradas (liberado una sola vez por capa). Luego se liberan las capas de neuronas, el arreglo principal de capas (R.N) y el buffer utilizado para conectar las salidas entre capas. Esto asegura que no queden bloques de memoria reservados tras finalizar el programa.


Ajustando el código

Antes de que empecemos a construir la función que generará redes completas automáticamente, vamos a hacer algunos ajustes en el código que ya tenemos. La idea es asegurarnos de que todo esté funcionando correctamente antes de agregar más capas (literal y metafóricamente).

Uno de los cambios más importantes es que vamos a modificar la función de entrenamiento. Hasta ahora, esta función trabajaba con un arreglo de neuronas, pero eso ya no es suficiente para las redes más complejas que queremos construir. Por eso, vamos a hacer que reciba una estructura red completa, con toda su topología, entradas y salidas integradas.

Esto nos permitirá entrenar redes de cualquier tamaño o forma sin tener que preocuparnos por la forma interna de los arreglos. Además, reducirá mucho la cantidad de código repetitivo en el futuro.

Veamos cómo queda la nueva versión de esta función dentro del archivo de cabecera:

NeuroTIC.h

/*========BIBLIOTECAS========*/
/*========ACTIVACION=========*/
/*=======DEFINICIONES========*/
/*=========CALCULAR==========*/
/*=========ENTRENAR==========*/
int entrenar(  struct red *R , float **entradas , float **resultados , int muestras , float tasa_aprendizaje , float error_permitido , int max_intentos  ){
    float *error;
    float *delta;
    float *delta_oculta;
    float error_total;
    int epoca= 0;
    error= calloc( R->neuronas[R->capas - 1 ] , sizeof( float ) );
    do{
        error_total= 0;
        for( int i= 0 ; i < muestras ; i++ ){
            for( int j= 0 ; j < R->entradas ; j++ )
                R->Entrada[j]= &entradas[i][j];
            for( int j= 0 ; j < R->capas ; j++ )
                for( int k= 0 ; k < R->neuronas[j] ; k++ )
                    R->N[j][k].Salida= evaluar_neurona( R->N[j][k] );
            delta= calloc( R->neuronas[R->capas - 1 ] , sizeof( float ) );
            for( int j= 0 ; j < R->neuronas[R->capas - 1 ] ){
                error[j]= resultados[i][j] - *R->Salida[j];
                error_total+= fabs( error[j] );
                delta[j]= error[j] * activacion[R->N[R->capas - 1][j].activacion][1]( ponderacion( R->N[R->capas - 1][j] ) );
            }
            for( int j= R->capas - 2 ; j >= 0 ; j-- ){
                delta_oculta= calloc( R->neuronas[j] , sizeof( float ) );
                for( int k= 0 ; k < R->neuronas[j + 1] ; k++ )
                    for( int l= 0 ; l < R->N[j + 1][k].entradas ; l++ )
                        delta_oculta[l]+= delta[k] * R->N[j + 1][k].Peso[l];
                for( int k= 0 ; k < R->neuronas[j] ; k++ )
                    delta_oculta[k]*= activacion[R->N[j][k].activacion][1]( ponderacion( R->N[j][k] ) );
                for( int k= 0 ; k < R->neuronas[j + 1] ; k++ ){
                    for( int l= 0 ; l < R->N[j + 1][k].entradas ; l++ )
                        R->N[j + 1][k].Peso[l]+= delta[k] * tasa_aprendizaje * *R->N[j + 1][k].Entrada[l];
                    R->N[j + 1][k].Sesgo+= delta[k] * tasa_aprendizaje;
                }
                free( delta );
                delta= calloc( R->neuronas[j] , sizeof( float ) );
                for( int k= 0 ; k < R->neuronas[j] ; k++ )
                    delta[k]= delta_oculta[k];
                free( delta_oculta );
            }
            for( int j= 0 ; j < R->neuronas[0] ; j++ ){
                for( int k= 0 ; k < R->N[0][j].entradas ; k++ )
                    R->N[0][j].Peso[k]+= delta[j] * tasa_aprendizaje * *R->N[0][j].Entrada[k];
                R->N[0][j].Sesgo+= delta[j] * tasa_aprendizaje;
            }
            free( delta );
        }
    } while( ++epoca < max_intentos && error_total > error_permitido );
    free( error );
    return epoca;
}
  • Se modificaron los argumentos de la función. Ahora, en lugar de recibir un puntero a una sola neurona, la función recibe un puntero a una estructura red, lo que permite trabajar con múltiples capas. También se cambiaron los parámetros de entrada y salida de arreglos fijos a matrices dinámicas:
    entradas pasó de ser una matriz fija [4][2] a un puntero doble float **entradas, permitiendo una cantidad variable de muestras y entradas.
    resultados también cambió de un arreglo plano [4] a un puntero doble float **resultados, con un valor esperado por cada neurona de salida en cada muestra.
    Se agregó un nuevo argumento muestras, que indica cuántos ejemplos de entrenamiento contiene el conjunto de datos.
  • Las variables error, delta y delta_oculta pasaron de ser variables simples a ser arreglos dinámicos, para permitir manejar cualquier cantidad de neuronas por capa.0
  • El bucle que recorre las muestras ahora utiliza la variable muestras para controlar su longitud, lo que reemplaza el valor fijo de 4 que se usaba anteriormente.
  • La asignación de entradas a la red sigue el mismo principio que antes, pero ahora se realiza en función de la cantidad de entradas que contiene la estructura red.
  • La evaluación hacia adelante ya no recorre un arreglo plano de neuronas, sino una matriz bidimensional que representa todas las capas y todas las neuronas por capa. Esto permite evaluar redes de cualquier tamaño.
  • El cálculo del error y del delta para las salidas se hace con arreglos. Además, el cálculo del error total ahora acumula la suma del valor absoluto de cada error individual, en lugar de reemplazarlo como antes.
  • La parte de retropropagación fue expandida para que funcione dinámicamente en función de la topología de la red. Esta es la parte más importante del cambio y la explicaremos paso a paso en la siguiente sección.
  • Para acceder a los miembros de la estructura a través de un puntero, se utiliza el operador -> en lugar del punto.

Una vez que se ha evaluado la red con una muestra de entrada, se comparan sus salidas con los resultados esperados. A partir de esa diferencia se calculan los errores y se ajustan los pesos de todas las neuronas, desde las de salida hasta las de la primera capa oculta.

  1. Cálculo del error y delta en la capa de salida
    El primer paso es calcular el error de cada neurona de salida. Para ello, se toma la diferencia entre el valor deseado (resultados[i][j]) y el valor real de la salida de la neurona.
    Luego, con ese error, se calcula el delta para cada neurona. El delta representa cuánto debe corregirse esa neurona, y se obtiene multiplicando el error por la derivada de la función de activación evaluada sobre su entrada ponderada.
    Además, se acumula el valor absoluto de todos los errores para calcular el error total de la red en esa iteración.
  2. Retropropagación hacia las capas ocultas
    Con los deltas de salida calculados, ahora se retropropaga el error hacia las capas anteriores. Este proceso comienza en la penúltima capa y avanza hacia la primera capa oculta, recorriendo la red en orden inverso.
    En cada capa, se crea un nuevo arreglo llamado delta_oculta, cuyo tamaño coincide con la cantidad de neuronas en esa capa. Luego, para cada neurona, se calcula su delta acumulando el aporte de todas las neuronas de la capa siguiente a las que está conectada.
    Cada aporte se obtiene multiplicando el delta de la neurona siguiente por el peso correspondiente. Al finalizar, este valor se multiplica por la derivada de la función de activación evaluada en la entrada ponderada de la neurona actual.
    Este procedimiento se repite capa por capa, retropropagando el error a lo largo de toda la red.
  3. Actualización de los pesos y sesgos
    Después de calcular los deltas, se actualizan los pesos y sesgos de cada neurona.
    Para cada peso, se multiplica el delta por la tasa de aprendizaje y por el valor de la entrada correspondiente, y luego se suma al peso actual.
    El sesgo también se actualiza sumando el delta por la tasa de aprendizaje.
  4. Última actualización en la capa de entrada
    Finalmente, se actualizan los pesos que conectan la capa de entrada con la primera capa oculta. Aunque no se retropropaga un delta desde la entrada (porque no tiene neuronas), sí se puede ajustar el valor de estos pesos usando el último arreglo de deltas calculado.
    Este paso asegura que toda la red se adapte para minimizar el error.

¡Excelente! Lo más difícil ya quedó atrás. Solo falta hacer unos pequeños ajustes al código principal para que todo funcione en armonía.

Comenzamos actualizando el banco de pruebas para que sea coherente con el tipo de datos que espera la función de entrenamiento. Como ahora usamos punteros dobles (float **) para representar las muestras de entrada y los resultados esperados, debemos usar calloc para reservar la memoria:

NeuroTIC.c

/*========BIBLIOTECAS========*/
/*=========PRINCIPAL=========*/
/*=========VARIABLES=========*/
    /* ENTRADAS */
    int muestras= 4;
    float **tabla= calloc( muestras , sizeof( float * ) );
    for( int i= 0 ; i < muestras ; i++ )
        tabla[i]= calloc( R.entradas , sizeof( float ) );
    for( int i= 0 ; i < muestras ; u++ )
        for( int j= 0 ; j < R.entradas ; j++ )
            tabla[i][j]= ( i >> j ) & 1;
    /* MUESTRAS */
    float **resultados= calloc( muestras , sizeof( float * ) );
    for( int i= 0 ; i < muestras ; i++ )
        resultados[i]= calloc( R.neuronas[R.capas - 1] , sizeof( float ) );
    for( int i= 0 ; i < muestras ; i++ )
        for( int j= 0 ; j < R.neuronas[R.capas - 1] ; j++ )
            resultados[i][j]= tabla[i][0] != tabla[i][1];
    /* ENTRENAMIENTO */
    /* CALCULAR E IMPRIMIR */
    /* TERMINAR PROGRAMA */
    free( R.Salida );
    for( int i= 0 ; i < R.capas ; i++ ){
        for( int j= 0 ; j < R.neuronas[i] ; j++ )
            free( R.N[i][j].Peso );
        free( R.N[0][i].Entrada );
        free( R.N[i] );
    }
    free( R.N );
    free( buffer );

    for( int i= 0 ; i < muastras ; i++ ){
        free( tabla[i] );
        free( resultados[i] );
    }
    free( tabla );
    free( resultados );

La matriz tabla representa las entradas de cada muestra, y resultados guarda el resultado esperado. En este ejemplo seguimos utilizando la tabla XOR, por lo que cada fila de tabla representa una combinación binaria de dos bits, y el resultado en resultados será 1 solo si los bits son distintos.

Luego, actualizamos la llamada a la función de entrenamiento para que reciba ahora la estructura completa de la red &R, junto con la nueva matriz de muestras y el número total de estas:

NeuroTIC.c

/*========BIBLIOTECAS========*/
/*=========PRINCIPAL=========*/
/*=========VARIABLES=========*/
    /* ENTRADAS */
    /* ENTRENAMIENTO */
    printf( "\nEntrenamiento Intentos: %i\n" , entrenar( &R , tabla , resultado , muestras , 0.1, 0.0, 100000 ) );
    /* CALCULAR E IMPRIMIR */
    /* TERMINAR PROGRAMA */

Finalmente, ajustamos el bloque que imprime los resultados para recorrer las muestras, asignar sus entradas, evaluar la red y mostrar el resultado:

NeuroTIC.c

/*========BIBLIOTECAS========*/
/*=========PRINCIPAL=========*/
/*=========VARIABLES=========*/
    /* ENTRADAS */
    /* ENTRENAMIENTO */
    /* CALCULAR E IMPRIMIR */
        printf( "\n\n| A | B | S |\n" );
        for( int i= 0 ; i < 4 ; i++ ){
            for( int j= 0 ;  j < R.entradas ; j++ )
                R.Entrada[j]= &tabla[i][j];
            for( int j= 0; j < R.capas ; j++ )
                for( int k= 0 ; k < R.neurnas[j] ; j++ )
                    R.N[j][k].Salida= evaluar_neurona( R.N[j][k] );
            printf( "| %.0f | %.0f | %.0f |\n" , Entrada[0] , Entrada[1] , *R.Salida );
        }
        printf( "\n" );
    /* TERMINAR PROGRAMA */

Recuerda que al final del programa es importante liberar la memoria de tabla y resultados, igual que hicimos con los elementos de la red.

Con esto, ¡tu red neuronal dinámica está completa y funcional! Solo resta compilar y ejecutar el programa para ver los resultados del entrenamiento.


Notas finales

Mira todo lo que has avanzado desde el comienzo del curso. Hagamos un repaso de tu recorrido por los distintos módulos:

  1. Comenzaste con un código simple que mostraba cómo una neurona artificial puede modificar sus resultados ajustando sus pesos y sesgo en función de datos de entrada y resultados esperados. El sistema de entrenamiento utilizaba estos datos para calcular y reducir el error.
  2. Aprendiste que al conectar varias neuronas en conjunto es posible lograr comportamientos mucho más complejos, superando las limitaciones de una neurona individual.
  3. Diste un paso importante al estructurar la red: agrupaste las neuronas como estructuras independientes, lo cual permitió trabajar con ellas mediante herramientas reutilizables desde una biblioteca, y diseñaste un sistema funcional de conexiones entre capas utilizando referencias de memoria.
  4. Descubriste cómo, usando derivadas, el error puede recorrer la red en sentido inverso durante el entrenamiento, ajustando pesos y sesgos de cada neurona. Implementaste un sistema flexible de funciones de activación para cada neurona, comprendiste por qué la función booleana original no podía propagar el error, y aprendiste la importancia de inicializar los pesos aleatoriamente.
  5. Finalmente, lograste construir un sistema completamente descriptivo: con unos pocos valores iniciales, es posible construir una red completa, entrenarla y adaptarla automáticamente según su topología, todo gracias a la nueva estructura de entrenamiento que ahora reconoce redes de cualquier tamaño y forma.

Hoy cuentas con un sistema completamente funcional y flexible. Puedes construir cualquier topología que imagines, o replicar modelos reales que encuentres en libros o artículos. Solo necesitas seguir practicando y profundizando en los conceptos del lenguaje C que ya dominas. Aquí están los principales parámetros que puedes ajustar para controlar tu red:

  • Cantidad de entradas que recibe la red.
  • Cantidad de capas en la topología.
  • Número de neuronas en cada capa, donde la última capa define cuántas salidas tiene la red.
  • Función de activación por neurona, pudiendo crear nuevas funciones y agregarlas a la matriz activacion de tu biblioteca.
  • Parámetros del entrenamiento, como la tasa de aprendizaje, el error mínimo permitido, y el límite de intentos para alcanzar el objetivo.
  • Función lógica a aprender, simplemente cambiando la operación lógica al asignar los valores esperados en la tabla de resultados:
    XOR [ A != B ]
    XNOR [ A == B ]
    AND [ A && B ]
    NAND [ !( A && B ) ]
    OR [ A || B ]
    NOR [ !( A || B ) ]
    conjunción excluyente [ A && !B ] o [ !A && B ]
    implicación [ !A || B ] o [ A || !B ].

Con esto, se cumple el objetivo principal de este curso: desmitificar el concepto de inteligencia artificial y guiarte paso a paso en la construcción de tu propio código, línea por línea.

En futuras entregas, nos enfocaremos en mejorar la biblioteca, optimizar el rendimiento de los cálculos, y añadir herramientas que faciliten la manipulación de redes. Todo ello, utilizando únicamente los conocimientos que ya has adquirido sobre lenguaje C. Una vez que esas bases estén sólidas, avanzaremos hacia entrenamientos más complejos y el análisis de topologías avanzadas.

¡Llegaste al final del primer gran capítulo de este curso!

Lo que acabas de lograr no es menor: has construido, desde cero y con tus propias manos, una red neuronal completamente funcional, capaz de aprender y adaptarse. No solo programaste neuronas artificiales, ¡les diste estructura, propósito y capacidad de aprendizaje! Todo esto utilizando exclusivamente lenguaje C y tus nuevas habilidades de programación.

Ahora tienes en tus manos una herramienta poderosa, flexible y totalmente tuya. Ya no estás simplemente ejecutando ejemplos que alguien más escribió: estás creando inteligencia artificial con tus propias reglas.

Y esto… es solo el comienzo.

A partir de aquí, el camino se abre a un mundo de posibilidades: más precisión, más funciones, redes más profundas, nuevos desafíos y muchas sorpresas. Has dominado las bases, y estás listo para explorar lo que viene.

Así que celebra este momento: ya no solo entiendes cómo funciona una red neuronal, ¡sabes construirla! Nos vemos muy pronto para continuar con la siguiente etapa. ¡Sigamos creando juntos!