¿Te gustó el sitio?

Módulo 4 - Neuronas trabajando en equipo

Introducción

Como ya se está volviendo costumbre, vamos a retomar el código con el que terminaste el módulo anterior:

NeuroTIC.h

/*=======DEFINICIONES========*/
struct neurona{
    int *Entrada[2];
    int Salida;
    float Peso[2];
    float Sesgo;
};
/*=========CALCULAR==========*/
int activacion_booleana( float x ){
    return x >= 0;
}

int evaluar_neurona( int *Entrada[2] , float Peso[2] , float Sesgo ){
    float ponderacion= Sesgo;
    for( int i= 0 ; i < 2 ; i++ )
        ponderacion+= *Entrada[i] * Peso[i];
   return activacion_booleana( ponderacion );
}
/*=========ENTRENAR==========*/
int entrenar( int entradas[4][2] , int resultados[4] , float *Peso , float *Sesgo ){
    int error;
    int error_total;
    float tasa_aprendizaje= 0.1;
    int epoca= 0;
    do{
        error_total= 0;
        for( int i= 0 ; i < 4 ; i++ ){
            int *conv_entradas[]= { &entradas[i][0] , &entradas[i][1] };
            error= resultados[i] - evaluar_neurona( conv_entradas , Peso , *Sesgo );
            *Sesgo+= error * tasa_aprendizaje;
            for( int j= 0 ; j < 2 ; j++)
                Peso[j]+= error * tasa_aprendizaje * entradas[i][j];
            error_total+= !!error;
        }
    } while( ++epoca < 1000 && error_total );
    return epoca;
}

NeuroTIC.c

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

struct neurona N[]= {
    { .Entrada= { &Entrada[0] , &Entrada[1] } },
    { .Entrada= { &Entrada[0] , &Entrada[1] } },
    { .Entrada= { &N[0].Salida , &N[1].Salida } }
};
/*=========PRINCIPAL=========*/
int main(){
    /* ENTRADAS */
    int tabla[4][2]={
        { 0 , 0 },
        { 0 , 1 },
        { 1 , 0 },
        { 1 , 1 }
    };
    /* MUESTRAS */
    int resultado[3][4]= {
        { 1 , 1 , 1 , 0 },
        { 0 , 1 , 1 , 1 },
        { 0 , 0 , 0 , 1 },
    };
    /* ENTRENAMIENTO */
    for( int i= 0; i < 3 ; i++ ){
        printf( "\nEntrenamiento %c Intentos: %i\n" , 'A' + i , entrenar( tabla , resultado[i] , N[i].Peso , &N[i].Sesgo ) );
        for( int j= 0 ; j < 2 ; j++ )
            printf( "Peso %i: %.2f\t" , i + 1 , N[i].Peso[j] );
        printf( "Sesgo: %.2f" , N[i].Sesgo );
    }
    /* 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].Entrada , N[j].Peso , N[j].Sesgo );
            Salida= N[2].Salida;
            printf( "| %i | %i | %i |\n" , Entrada[0] , Entrada[1] , Salida );
        }
        printf( "\n" );
    /* TERMINAR PROGRAMA */
    return 0;
}

Lo primero que quiero hacerte notar es la extensión del código, tanto de su biblioteca como del principal. No es demasiado largo: son aproximadamente unas 80 líneas de instrucciones. No está nada mal si consideras lo que logra hacer tu programa:

  • Es capaz de crear neuronas como elementos individuales.
  • Puede conectarlas entre sí.
  • Tiene un banco de pruebas y un sistema automático para cargarlo y entrenar las neuronas.
  • Ejecuta el entrenamiento dentro de un bucle (que además podrías extender fácilmente para incluir más neuronas).
  • Usa esa red para procesar los datos de entrada y mostrar los resultados por pantalla.

Pero es momento de romper la siguiente limitación:

Actualmente no es posible crear un único banco de pruebas para XOR, ingresarlo a la red, y que el entrenamiento se encargue por sí mismo de descubrir qué funciones intermedias debe resolver cada neurona para alcanzar el resultado final.

Y al final, de eso se trata realmente la inteligencia artificial.


Entendiendo la limitación

Para comprender por qué el sistema es incapaz de lograr esto, tenemos que enfocarnos en el modelo de entrenamiento:

NeuroTIC.h

/*=======DEFINICIONES========*/
/*=========CALCULAR==========*/
/*=========ENTRENAR==========*/
int entrenar( int entradas[4][2] , int resultados[4] , float *Peso , float *Sesgo ){
    int error;
    int error_total;
    float tasa_aprendizaje= 0.1;
    int epoca= 0;
    do{
        error_total= 0;
        for( int i= 0 ; i < 4 ; i++ ){
            int *conv_entradas[]= { &entradas[i][0] , &entradas[i][1] };
            error= resultados[i] - evaluar_neurona( conv_entradas , Peso , *Sesgo );
            *Sesgo+= error * tasa_aprendizaje;
            for( int j= 0 ; j < 2 ; j++ )
                Peso[j]+= error * tasa_aprendizaje * entradas[i][j];
            error_total+= !!error;
        }
    } while( ++epoca < 1000 && error_total );
    return epoca;
}

Presta atención a los elementos que se requieren para ajustar los pesos: necesitas dos datos, la entrada y el error, el cual depende del resultado generado. Esto funciona bien si vas a entrenar una única neurona, que tiene acceso tanto a sus entradas como al resultado final que se espera de ella.

Pero si estás trabajando con una red como la que has venido desarrollando, las cosas cambian:

La información de entrada se encuentra en las neuronas N[0] y N[1], que forman la primera capa. Sin embargo, sus resultados no se pueden comparar directamente con la salida esperada de la red.

Por otro lado, la neurona N[2] es la que genera la salida final que puede usarse para calcular el error, pero no tiene acceso a las entradas principales, que son las que realmente determinan la respuesta esperada.

Entonces, te enfrentas a una especie de fractura: Una parte de la red tiene las entradas, otra parte tiene la salida, pero ninguna de ellas por sí sola tiene toda la información necesaria para entrenarse correctamente.

Así que el siguiente reto es encontrar una forma de enlazar estos datos sin tener que resolver a mano cada paso de su transformación.


La inteligencia artificial es una calculadora

Vamos a cambiar la perspectiva con la que hemos venido entendiendo el sistema. Hasta ahora, has visto los elementos de la red como una serie de pasos individuales que resuelven un problema. Esto no se aleja mucho de la programación convencional, donde podrías escribir todo en una sola línea de código:
Salida= !(Entrada[0] && Entrada[1]) && (Entrada[0] || Entrada[1]);
O incluso más sencillo:
Salida= Entrada[0] != Entrada[1];

Es decir, si tú decides qué parte del problema resuelve cada componente, tratarías de construir la solución de la forma más directa posible.

Pero ahora te propongo cambiar el enfoque: vamos a pensar la red neuronal como una función matemática.

Una red neuronal es una función

Una función matemática se escribe igual que en tu programa: nombre(argumentos). Dentro de esa función se definen los pasos necesarios para calcular un resultado. Por ejemplo:
doble(X) = X × 2

Esta función simplemente multiplica el valor de entrada por 2. Si le das un 3, te devuelve un 6. No hay magia: solo una transformación de entrada a salida.

Hasta aquí, una función no hace nada que no puedas hacer con una calculadora.

Entendiendo la pendiendte de una funcion

Imagina que vas a hacer un viaje largo y usas Google Maps para planearlo. El mapa te indica que la distancia a tu destino es de 3500 km, y el tiempo estimado es de 40 horas. Eso es más de un día de viaje: tendrás que planificar algunos descansos. Para entender mejor esta relación, puedes representarla gráficamente:

Esta gráfica muestra una línea recta. Es una función lineal que relaciona tiempo y distancia. A partir de esa recta, puedes obtener información útil, como la velocidad promedio sugerida por Google Maps.

Para hacerlo, primero recordemos la forma general de una función lineal:
f(x) = m·x + c

Donde:

  • f(x) es el resultado de la función.
  • x es la variable de entrada.
  • m es la pendiente (cuánto cambia la salida si cambia la entrada).
  • c es la ordenada al origen (el valor cuando x = 0).

¿Te suena familiar? Es muy similar al modelo que utiliza una neurona:
f(entrada) = peso × entrada + sesgo

Calculando la pendiente de tu viaje

Volvamos al ejemplo. Usando los datos que te da el mapa, puedes escribir:
Distancia(40h) = Velocidad × 40h + c = 3500km

Para obtener c, basta con ver el valor de la distancia cuando el tiempo es cero:
Distancia(0h) = 0km → Velocidad × 0h + c = 0 → c = 0

Ahora que sabemos que c = 0, podemos calcular la pendiente m (en este caso, la velocidad):
m = (3500 km - 0 km) / (40 h - 0 h) = 87.5 km/h

Así que la función completa que representa este trayecto sería:
Distancia(t) = 87.5 × t

Esta es una función lineal con pendiente constante. Pero ¿qué pasa si las condiciones cambian?

Agregando condiciones reales

Ahora agreguemos una regla al viaje: cada 10 horas de manejo, debes descansar 1 hora. Esto cambia la gráfica:

Ahora observas lo siguiente:

  • La distancia total sigue siendo 3500 km.
  • Pero el tiempo total ha aumentado a 43 horas (40 de manejo + 3 de descanso).
  • La gráfica ya no es una línea recta continua. Tiene tramos horizontales (pendiente cero) donde no avanzas en distancia: estás detenido.

Has salido del mundo de las funciones lineales. Aún puedes describir esta nueva gráfica con una función, pero ya no basta con una sola recta. Necesitas un modelo que se adapte a tramos de distinta pendiente.

¿Y si agregamos aún más condiciones?

Imagina que ahora decides no manejar de noche, que quieres visitar algunos pueblitos en el camino, o que hay zonas montañosas donde vas más lento y otras con autopistas que te permiten ir más rápido.

Tu gráfica estaría llena de secciones con diferentes pendientes: algunas planas, otras inclinadas, algunas más empinadas. Aun así, hay una función que describe esta gráfica, pero ya no es fácil encontrarla a simple vista.

Aquí es donde las derivadas se vuelven cruciales.

Derivadas: la clave para ajustar una red

Una derivada es una herramienta que nos dice cuál es la pendiente de una función en un punto específico. En funciones lineales, la pendiente es constante. Pero en funciones más complejas, la pendiente cambia en cada punto.

En redes neuronales, usamos derivadas para saber cómo cambiar los pesos y sesgos para acercarnos al resultado correcto. Pero como las redes pueden tener muchas entradas (y cada una con su propio peso), usamos una técnica llamada derivadas parciales: calculamos la pendiente respecto a cada variable de forma independiente.

Incluso el sesgo se puede tratar como un peso con una entrada constante igual a 1.

Así, calculando la derivada de la función de activación y repitiendo el proceso desde la salida hacia las entradas, la red puede ajustar todos los pesos y sesgos para acercarse al comportamiento deseado.

Eso es lo que hace el algoritmo de entrenamiento. No adivina los pesos: los calcula a partir de las pendientes, igual que tú lo hiciste al principio con tu viaje por carretera.


Una nueva función de activación

Hasta ahora, has utilizado una función de activación booleana definida como:
f(x) = x ≥ 0

Primero, observa su gráfica:

A simple vista, salta el problema de intentar usar derivadas con esta función: no hay derivada que calcular. La pendiente es cero a lo largo de toda la función, excepto en el punto exacto donde cambia de 0 a 1, y ni siquiera ahí está bien definida.

Y si no podemos calcular una derivada, tampoco podemos saber si debemos ajustar el peso hacia arriba o hacia abajo durante el entrenamiento. En resumen: esta función no nos sirve si queremos entrenar una red mediante derivadas.

Necesitamos una nueva función que sí nos diga —mediante su derivada— en qué punto de la gráfica estamos.

Existen muchas funciones de activación que puedes encontrar en internet, pero para nuestro caso vamos a utilizar una que es especialmente famosa (y muy útil): la función sigmoide.

Sí, tiene un nombre raro, pero tranquilo, no es nada del otro mundo. Se llama así porque tiene forma de “S”.

La función sigmoide se define como:
σ(x) = 1 / (1 + e⁻ˣ)

No te preocupes por memorizarla ni entenderla a detalle por ahora. Lo importante es que veas su gráfica:

Como puedes notar, esta función tiene varias ventajas:

  • Se comporta como un interruptor, pero de forma suave.
  • Su salida siempre está entre 0 y 1, lo cual es útil para representar probabilidades o decisiones binarias.
  • Y lo más importante: tiene una pendiente clara en cada punto, lo que significa que sí podemos derivarla y usarla para ajustar los pesos.

Gracias a esto, ahora sí podemos aplicar todo el análisis de derivadas que vimos en el ejemplo del viaje, y extenderlo a redes más complejas.


Integrando la funcion sigmoide: dandole personalidad a las neuronas

Ahora que ya comprendes cómo una red neuronal aprovecha las derivadas parciales para ajustar sus parámetros, es momento de volver al código y ver cómo cambiar la función de activación.

El lenguaje C cuenta con operadores aritméticos sencillos, como los que has usado a lo largo del curso. Pero si queremos usar expresiones matemáticas más complejas, como e⁻ˣ, necesitaremos apoyarnos en una biblioteca especial: math.h.

A menos que quieras diseñar todas estas funciones tú mismo, vas a incluir esta biblioteca dentro de tu propia biblioteca: NeuroTIC.h.

NeuroTIC.h

/*========BIBLIOTECAS========*/
#include <math.h>
/*=======DEFINICIONES========*/
/*=========CALCULAR==========*/
/*=========ENTRENAR==========*/

Ahora, dentro de tu archivo NeuroTIC.h, vas a crear una nueva sección llamada ACTIVACION. Ahí moverás tu función activacion_booleana, y además crearás dos nuevas funciones: la función sigmoide y su derivada.

NeuroTIC.h

/*========BIBLIOTECAS========*/
/*========ACTIVACION=========*/
int activacion_booleana( float x ){
    return x >= 0;
}

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

Hasta este punto ya tienes las funciones definidas. Pero vamos a organizarlas de una forma más flexible: queremos que cada neurona pueda tener su propia función de activación, es decir, que cada una pueda tener su propia "personalidad".

Para lograrlo, vas a usar algo muy útil: punteros a funciones, combinados con arreglos. Así podrás seleccionar automáticamente qué función usar, simplemente con un índice.

NeuroTIC.h

/*========BIBLIOTECAS========*/
/*========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========*/
/*=========CALCULAR==========*/
/*=========ENTRENAR==========*/

Con esto, ya tienes una matriz de funciones llamada activacion, con dos filas y dos columnas. Cada fila representa una función distinta (por ahora, la booleana y la sigmoide). La primera columna contiene la función como tal, y la segunda columna contiene su derivada.

Esto es muy útil: al momento de ejecutar o entrenar la red, podrás elegir qué función usar y su derivada correspondiente simplemente indicando el índice de la fila (tipo de función) y el de la columna (función o derivada).

Además, como convertiste la función booleana para que regrese un float, ahora todas las funciones son compatibles en esta matriz, incluso si la derivada booleana simplemente regresa un 1 (se utiliza este valor en lugar del 0 que representa su derivada real, para que no entre en conflicto con el resto del código cuando se quiera usar sobre una unica neurona).

Cuando más adelante agregues nuevas funciones de activación, bastará con añadir una nueva fila con su respectiva derivada. Así, cada neurona podrá tener su propio comportamiento, como si tuviera su propia personalidad matemática.

Ahora vamos a crear un nuevo atributo en la estructura neurona que guarde el índice que indica qué función de activación va a usar cada neurona.

NeuroTIC.h

/*========BIBLIOTECAS========*/
/*========ACTIVACION=========*/
/*=======DEFINICIONES========*/
struct neurona{
    float *Entrada[2];
    float Salida;
    float Peso[2];
    float Sesgo;
    int activacion;
};
/*=========CALCULAR==========*/
/*=========ENTRENAR==========*/

Con esto, ya tienes un modelo de neuronas que permite definir cuál función de activación va a usar cada una.

También cambiaste los tipos de todos los atributos a float, ya que al usar la función sigmoide el resultado contiene punto decimal, lo cual debe ser compatible con los atributos Entrada y Salida.

Veamos ahora su implementación dentro del código de la función de evaluación:

NeuroTIC.h

/*========BIBLIOTECAS========*/
/*========ACTIVACION=========*/
/*=======DEFINICIONES========*/
/*=========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==========*/

En esta función se hicieron varios cambios importantes:

  • Se separó la instrucción de ponderación en su propia función.
  • Se eliminaron los argumentos individuales y en su lugar se recibe una estructura neurona. De esta forma, puedes pasar todos los atributos de una neurona en conjunto.
  • Se usó la matriz activacion para seleccionar dinámicamente la función deseada.
  • Usamos el índice N.activacion para seleccionar la fila correspondiente (booleana o sigmoide), y el índice 0 para indicar que queremos aplicar la función (no su derivada).
  • Finalmente, a la función seleccionada se le pasa como argumento el resultado de la ponderación.

Con esto, tus neuronas ya tienen "libertad de personalidad": cada una puede elegir su función de activación.

¡Es momento de ver cómo se implementa la derivada en el proceso de entrenamiento!

NeuroTIC.h

/*========BIBLIOTECAS========*/
/*========ACTIVACION=========*/
/*=======DEFINICIONES========*/
/*=========CALCULAR==========*/
/*=========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;
}

Antes de comprender el nuevo comportamiento de la función de entrenamiento, te voy a señalar los cambios que se hicieron:

  • Cambiaron los argumentos: ahora la tasa de aprendizaje, el número máximo de intentos y el error permitido se controlan desde aquí. También se agregó un puntero a una estructura neurona, lo que permite modificar directamente sus valores internos.
  • Se agregaron nuevas variables internas: delta y delta_oculta, y se cambió el tipo de la variable error a float para que sea compatible con los nuevos cálculos decimales.
  • Ahora la carga de los valores de entrada se realiza directamente sobre las direcciones a las que apuntan las entradas de N[0]. Como N[1] comparte esas mismas direcciones, no es necesario volver a asignarlas.
  • El cálculo de la red completa se hace recorriendo el arreglo de neuronas, justo como en el código principal.
  • El cálculo del error se realiza directamente sobre el atributo Salida de N[2], que ahora está disponible al pasar la estructura completa como argumento.
  • El valor de error_total ya no se calcula con la doble negación !!. En su lugar, se usa fmax (para conservar el error más grande) y fabs (para obtener el valor absoluto del error).
  • Se calcula delta multiplicando el error por la derivada de la función de activación de N[2], evaluada con su propia ponderación.
  • El sesgo y los pesos de N[2] se ajustan usando delta y no directamente con el error, como se hacía antes.
  • Para las neuronas N[0] y N[1], se calcula delta_oculta multiplicando delta por el peso que conecta esa neurona con N[2] y por la derivada de su propia función de activación (con su ponderación como entrada).
  • Finalmente, se ajustan los pesos y el sesgo de N[0] y N[1] de forma similar, pero usando delta_oculta en lugar de delta.

Con estos cambios, estás aplicando por primera vez una técnica esencial en el entrenamiento de redes neuronales: la propagación hacia atrás del error. Este proceso es, en el fondo, una aplicación sistemática de derivadas parciales sobre toda la red. Es decir, usamos derivadas para calcular cómo afecta cada parámetro (cada peso, cada sesgo) al error final, y luego los ajustamos en la dirección correcta para reducir ese error.

En el código, este procedimiento se refleja paso a paso:

  1. Primero, calculas el error que cometió la neurona de salida (N[2]):
    error= resultados[i] - N[2].Salida;
  2. Después, aplicas la regla de la cadena: tomas la derivada de la función de activación (como ya lo estudiaste en las derivadas parciales), evaluada en la ponderación que recibió la neurona. Ese resultado se multiplica por el error para obtener la tasa de ajuste, que llamamos delta:
    delta= error * activacion[N[2].activacion][1]( ponderacion(N[2]) );
    Aquí estás diciendo: “¿Qué tanto afecta la salida de esta neurona al error final?” y “¿Qué tan sensible es la función de activación en ese punto?”. Eso es exactamente lo que representa la derivada parcial.
  3. Luego, usas ese delta para ajustar los parámetros de la neurona de salida:
    N[2].Sesgo+= delta * tasa_aprendizaje;
    N[2].Peso[j]+= delta * tasa_aprendizaje * entradas[i][j];
    Esto es directamente la aplicación de la regla de actualización que vimos antes: nuevo valor = valor anterior ± derivada parcial × tasa de aprendizaje.
  4. Pero aquí es donde entra la verdadera fuerza de la red: el mismo delta se propaga hacia atrás a las neuronas que estaban antes (N[0] y N[1]). Se calcula un delta_oculta para cada una, multiplicando el delta de salida por el peso que conecta ambas neuronas y por la derivada de activación de la neurona oculta:
    delta_oculta= delta * N[2].Peso[j] * activacion[N[j].activacion][1](ponderacion(N[j]));
    Esta expresión representa una cadena de derivadas parciales: estás preguntando cómo el cambio en una neurona oculta afecta el error final, pasando por su efecto en la neurona de salida.
  5. Finalmente, con ese delta_oculta también ajustas los pesos y sesgos de las neuronas ocultas:
    N[j].Sesgo+= tasa_aprendizaje * delta_oculta;
    N[j].Peso[k]+= tasa_aprendizaje * delta_oculta * *N[j].Entrada[k];

Este proceso entero es backpropagation: una manera de repartir el error hacia atrás en la red, usando las derivadas parciales como guía. Cada neurona recibe una señal de corrección proporcional a cuánto contribuyó al error, y ajusta sus parámetros para hacerlo mejor la próxima vez.

Y lo más interesante es que, gracias a la estructura que programaste (con funciones de activación organizadas en una matriz y accesibles por índice), este mecanismo funciona automáticamente sin importar qué función de activación estés usando. El código es el mismo, lo único que cambia es el “comportamiento matemático” de la neurona, es decir, su personalidad.

Llegó el momento de hacer los cambios al código principal:

NeuroTIC.c

/*========BIBLIOTECAS========*/
/*=========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;
}

Los cambios aqui son minimos:

  • Se cambió el tipo de dato de las variables Entrada, Salida, tabla y resultado a float, para que sean compatibles con la función sigmoide, que devuelve valores con decimales.
  • Se agregó el campo .activacion a cada una de las estructuras neurona dentro del arreglo N[], asignándole el valor 1 para indicar que deben usar la función sigmoide como función de activación.
  • Se inicializan los pesos de las neuronas con valores cualquiera. Aunque estos no son los valores finales que la red usará, es necesario comenzar con algún valor para que el proceso de entrenamiento tenga de dónde partir. Si los pesos estuvieran en cero, la red no tendría margen para ajustar sus salidas y aprender. Cualquier valor pequeño, positivo o negativo, es suficiente para comenzar.
  • Se eliminó el bloque que entrenaba de forma individual las compuertas NAND, OR y AND. Ahora el entrenamiento se hace directamente sobre el patrón XOR utilizando toda la red.
  • Se reemplazó la matriz resultados[][] por un arreglo resultado[] que contiene las salidas esperadas del XOR para las combinaciones de entrada.
  • Se actualizó la llamada a la función entrenar() para pasarle la tabla de entradas, los resultados esperados, la tasa de aprendizaje, el error máximo permitido, el número máximo de intentos, y un puntero al arreglo de neuronas.
  • En la impresión de prueba posterior al entrenamiento, se cambió la evaluación de las neuronas para que usen directamente la estructura completa como argumento en la función evaluar_neurona.
  • Se actualizó el formato de printf que muestra el número de intentos usados durante el entrenamiento.
  • En la impresión de prueba final, se cambió el formato de impresión del resultado: de %i a %.0f, ya que ahora los valores son tipo float.

Antes de compilar, necesitas hacer un pequeño ajuste en el compilador, ya que estás usando la biblioteca math.h.

  1. En la parte superior del entorno, abre el menú Project y selecciona Build options.
  2. Se abrirá una ventana: en la parte izquierda, asegúrate de que esté seleccionado el nombre de tu proyecto.
  3. Ve a la pestaña Linker settings.
  4. En la sección Other linker options, escribe lo siguiente:
    -lm
  5. Da clic en OK para guardar los cambios.

Una vez hecho esto, ya puedes compilar el programa. Si el compilador muestra errores o advertencias, úsalo como guía para encontrar posibles fallos. También puedes comparar con el código mostrado en esta sección para corregir cualquier detalle.

Al ejecutar tu programa, es probable que la red no logre entrenarse de inmediato. Esto no significa que haya un error en el código. En realidad, puede deberse a que los parámetros usados en la función entrenar() no son los adecuados. Las posibles causas son:

  • El número de intentos (max_intentos) es insuficiente.
    La red necesita más ciclos para ajustarse correctamente.
  • La tasa de aprendizaje (tasa_aprendizaje) es demasiado baja.
    Los ajustes en los pesos y sesgos son pequeños, lo que alarga el proceso.
  • El error permitido (error_permitido) es demasiado estricto.
    La red se queda cerca del resultado correcto, pero no lo alcanza con la precisión exigida.

Con esta información, puedes experimentar libremente: cambia los valores de estos parámetros y observa cómo se comporta la red. También puedes modificar el contenido del arreglo resultado[] para entrenar la red con otros patrones lógicos, además del XOR.


Notas finales

Puedes inflarte el pecho y decir que ya sabes cómo funciona una inteligencia artificial. Hagamos un recuento del camino que has seguido hasta ahora:

Módulo 1:

  • Aprendiste el modelo básico de una neurona y cómo esta realiza un cálculo basado en una función de ponderación y una de activación.
  • Pudiste observar que el resultado de la neurona no se define por su operación interna, sino por los valores de sus pesos y su sesgo.
  • Comprendiste cómo es el proceso de entrenamiento de esta neurona.
  • Viste cómo el sistema puede identificar y calcular el error entre el resultado esperado y el entregado por la neurona.
  • Observaste cómo el algoritmo de entrenamiento utiliza ese error para ajustar los pesos y el sesgo y así obtener el resultado deseado.
  • Conociste el parámetro de tasa de aprendizaje, el cual indica qué tanto se acerca el ajuste que permite lograr ese resultado.
  • Te enfrentaste a la limitación del problema XOR.

Módulo 2:

  • Lograste crear un sistema flexible que te permitió repetir el proceso de entrenamiento sobre varias neuronas en un mismo programa.
  • Viste cómo, conectando varias neuronas con comportamientos distintos entre sí, puedes lograr resultados más complejos.

Módulo 3:

  • Pudiste convertir tus definiciones de neuronas en unidades autónomas, casi como un objeto tangible.
  • Aprendiste a crear un sistema de conexión funcional y directo entre neuronas.

En este módulo has logrado algo asombroso:

  • Entendiste el verdadero principio matemático detrás del entrenamiento de una red neuronal: las derivadas parciales.
  • Profundizaste en el entendimiento de la función booleana que impedía el entrenamiento en conjunto.
  • Conociste la función sigmoide y su ventaja sobre la función booleana.
  • Aprendiste a cambiar la función de activación usando punteros a funciones.
  • Aprovechaste todos estos conocimientos para lograr un entrenamiento más funcional.
  • Ahora sabes cómo controlar los parámetros de entrenamiento y cómo estos impactan en el proceso de aprendizaje.

Ahora posees oficialmente una red neuronal.

Algo en lo que quiero que prestes atención antes de terminar este módulo es al parámetro de entrenamiento llamado error_permitido, este valor representa un margen de error aceptable. Esto no es un detalle menor. Toda inteligencia artificial con la que llegues a interactuar —ya sea un asistente virtual, un traductor automático o un sistema de recomendación— fue entrenada con un criterio similar. Eso significa que siempre hay un pequeño rango en el que la IA puede equivocarse… y ese margen fue considerado “suficientemente bueno”.

Así que cada vez que te encuentres con una IA, recuerda: su precisión no es absoluta, es una aproximación que fue considerada adecuada para su propósito.

Ya llevamos varios módulos trabajando con una red de tres neuronas con dos entradas cada una. En los próximos módulos te enseñaré cómo lograr que tu código pueda crear redes más extensas, y construiremos un sistema modular para toda la red. Esto te permitirá, más adelante, conectar varias redes entre sí para que trabajen en conjunto.

Por ahora, puedes disfrutar tus nuevas herramientas: tanto las que has incorporado a tu acervo cultural, como las funciones que ahora viven en tu biblioteca de código.

Te espero en la siguiente entrega para seguir explorando este universo de las redes neuronales.