¿Te gustó el sitio?

Módulo 1 – Perceptrón Booleano

Introducción

¿Listo para desarmar una neurona artificial y ver de qué está hecha?

Verás que no tiene tantos secretos. Solo unas cuantas piezas básicas que, colocadas en orden, pueden aprender a tomar decisiones.

Si vamos a entrenar una operación lógica, es obvio que necesitamos algo que se comporte de manera similar. Por eso, nuestra neurona tendrá dos entradas y una salida.

Además de esos elementos externos, vamos a definir su funcionamiento interno. Estos son:

  • Una lista de pesos según la cantidad de entradas. Estos, junto con el sesgo (un valor extra que aprenderás enseguida), serán los responsables de definir el comportamiento de la neurona. Todo depende de los valores que tengan.
  • Una función de ponderación. Se encarga de multiplicar cada entrada por su peso y luego sumar esos resultados con el sesgo.
  • La función de activación. Esta generará el número que irá en la única salida según el resultado de la ponderación.

Nada más. Así de simple.


Preparando el entorno de trabajo

Antes de ponernos a codificar en serio, asegúrate de que ya tienes listo tu espacio de trabajo.

Si todavía no descargaste Code::Blocks, o no sabes cómo crear un proyecto o un archivo nuevo, te recomiendo que revises primero la sección de requisitos. Ahí está todo explicado paso a paso.

Ahora bien, si ya habías dejado todo listo pero cerraste Code::Blocks y no estás seguro de cómo retomar el proyecto que creaste, no te preocupes. Puede que estés en alguna de estas situaciones:

  • Si ya creaste el proyecto y lo guardaste, abre Code::Blocks y usa el menú File > Open > Project (o simplemente haz doble clic sobre el archivo .cbp desde tu explorador de archivos).
  • Si no recuerdas si lo guardaste o no sabes dónde quedó, abre Code::Blocks y revisa File > Recent Projects. A veces con eso es suficiente.
  • Y si nada de eso funciona, puedes crear un nuevo proyecto siguiendo otra vez las instrucciones de la sección de requisitos. No pasa nada. Lo importante es tener un lugar donde escribir y ejecutar tu código.

Una vez que tengas tu editor abierto, sin miedo, escribe lo siguiente:

/*=========VARIABLES=========*/
/*=========PRINCIPAL=========*/

Lo que acabas de escribir se conoce como comentarios. Estas líneas no afectan en nada la ejecución del programa, pero sirven como referencia visual para quien está leyendo el código.

Todo comentario comienza con /* y termina con */. Todo lo que esté entre esos símbolos es ignorado por el compilador, así que puedes usarlos para dejar anotaciones, separar secciones o incluso desactivar líneas de código sin borrarlas.

En este caso, se usan como etiquetas para marcar dos secciones principales de nuestro programa: una donde van las variables, y otra donde va el bloque principal del código. Ya verás en breve para qué sirve cada una.


Construyendo el perceptron

Variables

Ahora sí, vamos a programar en serio

Una neurona puede tener cualquier cantidad de entradas. Para nuestro perceptrón booleano vamos a usar dos, así que vamos a crearlas en el editor escribiendo:

/*=========VARIABLES=========*/
int Entrada_1;
int Entrada_2;
/*=========PRINCIPAL=========*/

La instrucción int permite crear una variable que contendrá un número entero. Esta variable tendrá el nombre que le asignemos a la derecha de int, en nuestro caso: las variables Entrada_1 y Entrada_2.

Al final de cada línea se usa el carácter ;, que indica que la instrucción ha terminado. No tardarás mucho en darte cuenta de su importancia: si olvidas un punto y coma, el compilador te avisará con un error.

Siguiendo el proceso anterior, vamos a crear la variable de salida:

/*=========VARIABLES=========*/
int Entrada_1;
int Entrada_2;

int Salida;
/*=========PRINCIPAL=========*/

La salida será el resultado final de la neurona, es decir, la respuesta que obtendremos después de hacer los cálculos.

¿Ves qué sencillo es crear los elementos que componen la neurona?

Ahora, según la lista de ingredientes del inicio, necesitamos una lista de pesos, uno por cada entrada, además de un sesgo:

/*=========VARIABLES=========*/
int Entrada_1;
int Entrada_2;

int Salida;

float Peso_1;
float Peso_2;

float Sesgo;
/*=========PRINCIPAL=========*/

¿Qué ha pasado aquí?
Ya no usamos int, ahora hemos usado la instrucción float. No te asustes: funciona de manera parecida a int, pero float se usa cuando la variable puede tener valores con punto decimal. Por ejemplo, podrías usarlo para guardar números como 0.5 o 1.25.

Los pesos y el sesgo suelen necesitar valores decimales para poder ajustar con más precisión el comportamiento de la neurona.

Ya tienes los elementos. Ahora hay que definir cómo se relacionan entre sí. En otras palabras, el comportamiento de la neurona.

Funcion main

Para hacer esto, vamos a conocer un elemento más del lenguaje C: la función main. Este será el espacio donde irán las instrucciones que le pediremos a la computadora que ejecute.

Para crear la función main, solo es necesario declararla:

He omitido las variables que ya se han creado para faicilitar la legibilidad de los ejercicios, esto no implica que debas borrarlas.

/*=========VARIABLES=========*/
/*=========PRINCIPAL=========*/
int main(){}

¿Por qué la función main es tan especial?
En cualquier programa escrito en C, siempre debe existir una función llamada main. Puedes pensar en ella como el punto de inicio: cuando ejecutas tu programa, el sistema operativo entra por esa puerta y empieza a recorrer las instrucciones que encuentre dentro, de arriba hacia abajo. Cuando llega al final de las instrucciones dentro de main, el programa termina. No importa cuántas otras funciones o variables declares: sin main, nada sucede.

Ya tienes tu código con las variables y la función main. Es momento de compilar y ejecutar.

Cuando ejecutes tu programa se generará un programa que creará en memoria las variables que declaraste y entrará en la función main. Pero como todavía no escribiste ninguna instrucción dentro, el programa no hará absolutamente nada: simplemente creará esos elementos, los guardará un instante y terminará la ejecución destruyéndolos al salir de main.

Ahora que tienes un mejor entendimiento sobre qué hace main, vamos a describir el comportamiento de la neurona.

Ponderación y activación

Primero haremos el proceso de ponderación, que va a asociar cada entrada con su respectivo peso mediante una multiplicación, y combinará los productos de estas operaciones mediante una sencilla suma. A este resultado se le suma el valor del sesgo. La operación se vería algo así: Sesgo + (E1 × P1) + (E2 × P2) + ....

/*=========VARIABLES=========*/
/*=========PRINCIPAL=========*/
int main(){
/* CALCULAR */
    float ponderacion= Sesgo + (Entrada_1 * Peso_1) + (Entrada_2 * Peso_2);
}

Algunas cosas que debes notar aquí:

  • El operador * se usa para indicar una multiplicación. No se utiliza una “x” porque podría confundirse con una letra.
  • El resultado de la operación se guarda en una variable de tipo float llamada ponderacion, usando el operador =, que asigna ese valor a la variable.
  • Recuerda el uso del carácter ; al final de la instrucción. No lo olvides.

Ahora agreguemos la última pieza: la función de activación. Esta recibe el resultado de la ponderación, aplica una operación matemática y devuelve un nuevo valor.

Existen muchas funciones de activación, pero para nuestro perceptrón booleano usaremos la más sencilla: una función que regresa un 1 si el resultado de la ponderación es mayor o igual que 0, y devuelve un 0 si el resultado es menor que 0.

/*=========VARIABLES=========*/
/*=========PRINCIPAL=========*/
int main(){
/* CALCULAR */
    float ponderacion= Sesgo + (Entrada_1 * Peso_1) + (Entrada_2 * Peso_2);
    Salida= ponderacion >= 0;
}

Has terminado tu neurona. Puedes compilar y ejecutar el programa...

¿Qué ha sucedido? ¿Nada?
No exactamente. En realidad, se han ejecutado todas las instrucciones: primero se calcula la ponderación, luego se evalúa el resultado comparándolo contra 0 usando el operador >=, y finalmente se guarda esa respuesta en la variable Salida.

En este caso, ya no es necesario volver a escribir el tipo de dato de Salida porque lo declaraste arriba como int.

Todo esto ocurrió dentro del programa, pero aún no vimos nada porque todavía no le hemos dado ninguna instrucción para que nos muestre información en pantalla.


Obteniendo información

Hola mundo

Aquí es donde vamos a conocer algunas características más del lenguaje C. Vamos a agregar un par de líneas a nuestro código:

/*========BIBLIOTECAS========*/
#include <stdio.h>
/*=========VARIABLES=========*/
/*=========PRINCIPAL=========*/
int main(){
/* CALCULAR */
/* IMPRIMIR */
    printf( "Hola mundo" );
}

Vuelve a compilar y ejecutar. Ahora verás el mensaje Hola mundo en la pantalla.

Seguramente ya has deducido qué hace printf, pero permíteme dar una explicación más clara:

El mensaje “Hola mundo” es una tradición que se usa como primera prueba en casi cualquier lenguaje de programación.

printf es una función, igual que main. Su trabajo es mostrar en pantalla el texto que escribas entre comillas dentro de sus paréntesis. Pero esta función no forma parte del lenguaje C de manera nativa. Su comportamiento está definido dentro de una biblioteca llamada stdio.h.

Para poder usarla, primero debes indicarle al compilador que incluya esa biblioteca en tu programa. Eso se hace con esta instrucción: #include <stdio.h>

Una biblioteca no es más que una colección de funciones listas para resolver necesidades específicas. En este caso, stdio.h contiene varias funciones que tienen que ver con la entrada y salida de información, y una de ellas es printf, que permite mostrar mensajes en pantalla.

Ahora que tu programa tiene la posibilidad de mostrarte información, vamos a aprovecharlo para leer el valor de las variables. Te mostraré algunos trucos que puedes hacer con la función printf.

Mostrando variables

Empecemos con algo simple: leer el valor de la salida de la neurona. Vamos a modificar printf de esta forma:

/*========BIBLIOTECAS========*/
/*=========VARIABLES=========*/
/*=========PRINCIPAL=========*/
int main(){
/* CALCULAR */
/* IMPRIMIR */
    printf( "%i" , Salida );
}

Compila y ejecuta el programa otra vez...

Verás que ahora aparece un número: 0 o 1. Este número es el que está guardado en la variable Salida, y es el resultado de las operaciones que le anteceden.

Esto ocurre porque el símbolo de formato % le indica a printf que lo que sigue es una instrucción especial y no un texto literal. En este caso, %i significa: “imprime aquí un número entero”.

Después de las comillas, se coloca una coma y se indica qué variable debe imprimirse en ese espacio que definimos.

Ahora vamos a agregar un poco más de información al mensaje de salida:

/*========BIBLIOTECAS========*/
/*=========VARIABLES=========*/
/*=========PRINCIPAL=========*/
int main(){
/* CALCULAR */
/* IMPRIMIR */
    printf( "Entrada 1: %i , Entrada 2: %i = Salida: %i\n" , Entrada_1 ,  Entrada_2 , Salida );
}

Si comparas este último ejemplo con el anterior, verás que ahora no estamos imprimiendo una sola variable, sino varias. Para hacerlo, simplemente añadimos más caracteres de formato %i dentro del texto entre comillas, separados por texto normal si lo deseas (por ejemplo, para poner etiquetas como “Entrada 1” o “Salida”).

Después de las comillas, se colocan las variables correspondientes en el mismo orden en que aparecerán en el texto resultante, separadas por comas. Es decir, la primera variable ocupará la primera instrucción de formato %i, la segunda la segunda, y así sucesivamente.

Además, al final del texto agregamos \n. Este es un carácter especial que significa “salto de línea”. Cada vez que printf llega a \n, baja el cursor a la línea siguiente, sería como presionar la tecla Enter. Así, si el programa imprime varios resultados, cada uno aparecerá ordenado en su propia línea.

Existen otros caracteres especiales (como \t para tabulaciones, por ejemplo), pero de momento solo vamos a usar \n.


Controlando la neurona

Empecemos a definir el comportamiento de la neurona. Para esto, vamos a manipular los valores de los pesos y el sesgo para simular el funcionamiento de una operación OR, y también vamos a ingresar los valores en las entradas que la neurona usará para hacer el cálculo:

/*========BIBLIOTECAS========*/
/*=========VARIABLES=========*/
int Entrada_1= 0;
int Entrada_2= 0;

int Salida;

float Peso_1= 1;
float Peso_2= 1;

float Sesgo= -1;
/*=========PRINCIPAL=========*/

Compila y ejecuta…

Ahora ya tienes tu primera neurona que se comporta exactamente como una función OR. Si vas cambiando los valores de las entradas para cada combinación de la tabla de verdad ([0,0], [0,1], [1,0], [1,1]), verás cómo responde.

Para probar los distintos valores, tendrás que volver a compilar cada vez que modifiques las entradas en el código.

Cuando hayas probado las cuatro combinaciones, verás una impresión similar a la tabla de verdad del OR: las entradas muestran todas las combinaciones posibles, y la salida vale 1 siempre que alguna de las dos entradas sea 1.

Esto ocurre por la combinación de los valores de los pesos (que valen 1) y el sesgo (que vale -1). No es que tengan que ser necesariamente estos números, sino que cumplen una condición: el sesgo debe tener un valor negativo. De esta manera, cuando ambas entradas valen 0, las multiplicaciones por sus pesos también son 0, haciendo que la ponderación sea igual al sesgo. Al evaluarse en la función de activación, como es menor que 0, devuelve 0.

Pero si alguna de las entradas es 1 o mayor, a la ponderación se le suma el valor correspondiente, haciendo que el resultado sea igual o mayor que 0. En ese caso, la función de activación devuelve 1.

Para que quede más claro cómo funciona, ahora vamos a configurar los valores de los pesos y el sesgo para lograr el comportamiento de la operación NOR:

/*========BIBLIOTECAS========*/
/*=========VARIABLES=========*/
int Entrada_1= 0;
int Entrada_2= 0;

int Salida;

float Peso_1= -1;
float Peso_2= -1;

float Sesgo= 0;
/*=========PRINCIPAL=========*/

Ahora la ponderación puede tomar los valores 0, -1 y -2, según qué entradas estén activadas. Esto quiere decir que, al evaluar el resultado en la función de activación, la salida será 1 solamente cuando ninguna de las dos entradas esté activada.

Ya has aprendido cómo funciona una neurona artificial: para que se comporte como una operación lógica determinada, solo necesitas establecer los valores de los pesos y el sesgo según ciertas condiciones específicas. Por ejemplo, si quisieras simular la función AND, deberías asignar un número negativo al sesgo y asegurarte de que cada peso, por sí solo, no logre que la ponderación alcance un valor de 0 o mayor, pero que sí lo consiga con la suma de ambos pesos. Así, la neurona solo regresará un 1 cuando ambas entradas estén activas y los dos pesos se sumen para vencer al sesgo.


Control por teclado

Ya te habrás dado cuenta de que compilar el programa cada vez que cambias los valores de las entradas no es muy práctico. Ahora vamos a conocer otra función disponible en stdio.h: scanf. Esta función te permite ingresar valores desde el teclado mientras el programa está en ejecución.

Ajusta los valores de los pesos y el sesgo para una operación AND y veamos cómo funciona:

/*========BIBLIOTECAS========*/
/*=========VARIABLES=========*/
int Entrada_1= 0;
int Entrada_2= 0;

int Salida;

float Peso_1= 1;
float Peso_2= 1;

float Sesgo= -2;
/*=========PRINCIPAL=========*/
int main(){
/* ENTRADAS */
    printf( "Entrada 1: " );
    scanf( "%i" , &Entrada_1 );
    printf( "Entrada 2: " );
    scanf( "%i" , &Entrada_2 );
/* CALCULAR */
/* IMPRIMIR */
}

Compila y ejecuta...

Ahora la computadora te pedirá primero los valores de las entradas, una por una, guardará esos valores en sus respectivas variables, realizará el cálculo usando los pesos y el sesgo que definiste, y finalmente mostrará la salida correspondiente.

De esta manera, podrás ejecutar el programa varias veces sin necesidad de recompilar cada vez que quieras probar una combinación distinta de entradas. Solo será necesario compilar si deseas ajustar los pesos o el sesgo para que la neurona se comporte de manera diferente.

No te preocupes por ahora en qué hace el carácter & dentro de la instrucción scanf. Por el momento, solo recuerda que es necesario colocarlo. Más adelante en el curso verás con detalle qué significa y por qué se utiliza.


Normalizando datos

Ahora te invito a probar qué ocurre si en las entradas colocas valores distintos de 0 o 1. Notarás que la salida puede presentar comportamientos extraños, dependiendo de los valores que hayas definido en los pesos y el sesgo.

Para asegurarnos de que nuestra neurona siempre trabaje con entradas que sean 0 o 1, vamos a añadir un pequeño ajuste al código:

/*========BIBLIOTECAS========*/
/*=========VARIABLES=========*/
/*=========PRINCIPAL=========*/
int main(){
/* ENTRADAS */
/* NORMALIZAR */
    Entrada_1= !!Entrada_1;
    Entrada_2= !!Entrada_2;
/* CALCULAR */
/* IMPRIMIR */
}

Aquí estás conociendo el operador NOT en lenguaje C, que se representa con el signo de exclamación (!). Lo que hemos añadido son un par de líneas que aplican una doble negación a cada entrada.

La primera negación convierte cualquier valor distinto de cero en 0 (falso) y cualquier cero en 1 (verdadero). La segunda negación invierte ese resultado nuevamente: de esta forma, cualquier número distinto de cero termina transformándose en 1, y el cero permanece como 0.

En resumen, cuando aplicas !! sobre una variable garantizas que todas las entradas sean estrictamente binarias, logrando un comportamiento más predecible dentro de la neurona.


Agrupando variables

Es momento de tomar un poco más de control sobre el código. El perceptrón de este ejercicio solo maneja dos entradas, pero ¿te imaginas si tuviera que manejar 10, 20, 100 o hasta miles, con un peso para cada una? Sería demasiado trabajo crear y controlar todas esas variables por separado.

Para resolver esto, vamos a introducir una herramienta más del lenguaje C: los arreglos.

/*========BIBLIOTECAS========*/
/*=========VARIABLES=========*/
int Entrada[2];

int Salida;

float Peso[]={ 1 , 1 };

float Sesgo= -2;
/*=========PRINCIPAL=========*/
int main(){
/* ENTRADAS */
    printf( "Entrada 1: " );
    scanf( "%i" , &Entrada[0] );
    printf( "Entrada 2: " );
    scanf( "%i" , &Entrada[1] );
/* NORMALIZAR */
    Entrada[0]= !!Entrada[0];
    Entrada[1]= !!Entrada[1];
/* CALCULAR */
    float ponderacion= Sesgo + (Entrada[0] * Peso[0]) + (Entrada[1] * Peso[1]);
    Salida= ponderacion >= 0;
/* IMPRIMIR */
    printf( "Entrada 1: %i , Entrada 2: %i = Salida: %i\n" , Entrada[0] ,  Entrada[1] , Salida );
}

Como ves, ahora ha cambiado la forma en que declaramos y usamos las entradas y los pesos. Al incluir los corchetes [] después del nombre, indicamos que no se trata de una sola variable, sino de un arreglo. Un arreglo es una colección de variables del mismo tipo y nombre, a las que podemos acceder usando un índice. En C, el primer elemento siempre tiene el índice 0.

En este código se declararon los arreglos de dos maneras distintas:

  • Entradas: int Entrada[2];
    Aquí indicamos explícitamente que el arreglo tendrá dos posiciones.
  • Pesos: float Peso[]= { 1 , 1 };
    En este caso, como le estamos asignando los valores en el momento de la declaración, C calcula automáticamente cuántos elementos tiene el arreglo (en este ejemplo, dos).

Cada número entre llaves {} se va asignando en orden a las posiciones del arreglo: el primero ocupa el índice 0, el segundo el índice 1, y así sucesivamente.


Automatizar tareas repetitivas

En muchos programas hay tareas que deben repetirse varias veces: hacer cálculos, mostrar resultados, leer datos o recorrer una lista de elementos. Escribir esas instrucciones una por una no solo lleva tiempo, sino que además complica mucho el código.

Por suerte, no hace falta hacerlo manualmente. En lugar de repetir las instrucciones tú mismo, puedes usar un bucle para que el programa las repita por su cuenta, tantas veces como sea necesario.

Aquí te presento el bucle for, uno de los tres que existen en el lenguaje C.

/*========BIBLIOTECAS========*/
/*=========VARIABLES=========*/
/*=========PRINCIPAL=========*/
int main(){
/* ENTRADAS Y NORMALIZACION */
    for( int i= 0 ; i < 2 ; i= i + 1 ){
        printf( "Entrada %i: " , i + 1 );
        scanf( "%i" , &Entrada[i] );
        Entrada[i]= !!Entrada[i];
    }
/* CALCULAR */
    float ponderacion= Sesgo;
    for( int i= 0 ; i < 2 ; i+= 1 )
        ponderacion+= Entrada[i] * Peso[i];
    Salida= ponderacion >= 0;
/* IMPRIMIR */
    for( int i= 0 ; i < 2 ; i++ )
        printf( "Entrada %i: %i ", i + 1 , Entrada[i] );
    printf( "= Salida: %i\n" , Salida );
}

Lo que estás viendo en el código es el uso más común del bucle "for". Tiene tres componentes separados por punto y coma que definen su comportamiento: inicio, condición y control.

Su funcionamiento general es este:

  1. Se inicia una variable que servirá como contador. Puede tener cualquier nombre, aunque por convención se suele usar i.
  2. Se evalúa la condición. Mientras el resultado sea distinto de 0 (es decir, mientras sea verdadero), el bucle seguirá ejecutando su contenido. En este caso, la condición es i < 2.
  3. El bloque de instrucciones se encierra entre llaves {}. Si solo hay una instrucción dentro del bucle, las llaves pueden omitirse.
  4. Al finalizar el bloque, se ejecuta la operación de control: generalmente es un incremento o decremento de la variable i.
  5. Se vuelve a evaluar la condición, y si sigue siendo verdadera, se repite todo el ciclo con el nuevo valor de la variable de control.

Ahora observa con atención cómo se expresa esa operación de control en cada bucle del ejemplo:

  • En el primer bucle se usa la forma más explícita: i= i + 1. Es fácil de leer: simplemente indica que el nuevo valor de i será su valor actual más 1.
  • En el segundo bucle aparece una forma abreviada: i+= 1. Esto significa exactamente lo mismo que la anterior, solo que más compacto. También se usa ese mismo operador += para ir sumando al valor de ponderacion, que empieza con el valor del sesgo y se va incrementando con el producto de cada entrada y su peso correspondiente.
  • En el tercer bucle se utiliza un operador más corto aún: i++. El operador ++ se usa únicamente cuando quieres sumar 1 a una variable. Es equivalente a i+= 1 y a i= i + 1.

Además de controlar el ciclo, la variable i también se está utilizando dentro de las instrucciones. Por ejemplo, nos sirve para acceder a las posiciones de los arreglos (Entrada[i], Peso[i]) y también se usa dentro de printf para mostrar el número de entrada que se está leyendo o imprimiendo. Como i empieza en 0, le sumamos 1 al mostrarlo en pantalla. Esto no cambia el valor real de la variable, solo ajusta el número que se imprime para que sea más natural para el lector.

Lo que acabas de ver es la forma más convencional de usar un for: empezar en cero, incrementar de uno en uno y detenerse en un valor determinado. Funciona como un contador. Pero si te pones creativo, podrías hacer cuentas regresivas, usar saltos de dos en dos, o incluso modificar la condición para lograr comportamientos más complejos. Eso ya lo dejo en tus manos si decides profundizar en lógica de programación.


Entrenamiento de la neurona

Automatizar entradas

Ya automatizaste la ejecución de una neurona, pero todavía falta la parte más interesante: lograr que aprenda.

Hasta ahora venías configurando los pesos y el sesgo a mano, buscando distintos comportamientos. Pero lo que queremos realmente es que el programa sea capaz de ajustar esos valores por su cuenta, en función de una tabla de verdad que le demos como ejemplo. Es decir: que pueda aprender a imitar un patrón lógico sin que se lo digamos explícitamente.

Para eso, primero hay que preparar el terreno.

En lugar de ingresar las entradas manualmente cada vez que corremos el programa, vamos a construir un banco de pruebas que contenga todas las combinaciones posibles de entradas. Así, el perceptrón podrá ver los distintos casos y ajustar sus parámetros con base en ellos.

Para organizar ese banco de pruebas vamos a usar una herramienta nueva del lenguaje C: las matrices, que no son otra cosa que arreglos que contienen otros arreglos. En este caso, cada arreglo interno representa una combinación distinta de entradas, como si fueran las filas y columnas de una tabla.

También aprovecharemos lo que aprendiste sobre bucles para recorrer esa tabla automáticamente y cargar los valores en las variables correspondientes. Y ya que estamos, mejoramos un poco el formato de salida para que todo se vea más ordenado.

Veamos cómo se implementa:

/*========BIBLIOTECAS========*/
/*=========VARIABLES=========*/
/*=========PRINCIPAL=========*/
int main(){
/* ENTRADAS */
    int tabla[4][2]={
        { 0 , 0 },
        { 0 , 1 },
        { 1 , 0 },
        { 1 , 1 }
    };
/* ENTRENAMIENTO */
    for( int i= 0 ; i < 4 ; i++ )
        for( int j= 0 ; j < 2 ; j++ )
            Entrada[j]= tabla[i][j];
/* CALCULAR E IMPRIMIR */
    printf( "\n\n| A | B | S | " );
    for( int i= 0 ; i < 4 ; i++ ){
        float ponderacion= Sesgo;
        for( int j= 0 ; j < 2 ; j++ ){
            Entrada[j]= tabla[i][j];
            ponderacion+= Entrada[j] * Peso[j];
        }
        Salida= ponderacion >= 0;
        printf( "\n| %i | %i | %i |" , Entrada[0] , Entrada[1] , Salida );
    }
}

Se ha creado una matriz llamada tabla que contiene todas las combinaciones posibles de entradas para una operación lógica con dos variables. Es decir, los cuatro pares binarios que ya conoces: (0,0), (0,1), (1,0) y (1,1). Esta será nuestra base de pruebas.

Justo después aparece una nueva sección en el código marcada como ENTRENAMIENTO, aunque por ahora no se está entrenando nada. Lo único que se ha hecho es preparar el espacio y comenzar por automatizar la carga de las entradas.

Esa automatización se logra usando dos bucles anidados: el bucle externo recorre las filas de la matriz (es decir, cada combinación), y el interno recorre las columnas (los valores individuales de entrada). Para esto se ha usado una nueva variable llamada j, que funciona igual que i, pero sirve para recorrer los elementos dentro de cada fila. Usar distintos nombres para las variables de control ayuda a que los bucles no interfieran entre sí y mantiene el código ordenado.

Un patrón muy similar se repite más adelante para calcular e imprimir los resultados. Se añadió una línea que imprime el encabezado | A | B | S |, que le da formato de tabla a la salida. Luego, por cada combinación de entradas, el programa toma los valores de la matriz, los carga en las entradas, realiza el cálculo y muestra los tres valores como una fila más en la tabla.

Todo el proceso está listo para ejecutarse varias veces sin que tengas que intervenir. Ya tienes una estructura completa que prueba la neurona de forma automatizada y presenta los resultados de manera ordenada.

Compila y ejecuta para que puedas ver el resultado.

Agrupando instrucciones

Ya lograste automatizar la evaluación de múltiples combinaciones de entradas y mostrar los resultados en forma ordenada. Ahora es momento de integrar el cálculo de ponderación y activación también en la sección de entrenamiento, para que cada vez que se cargue una combinación de prueba, la neurona realice su evaluación correspondiente.

Pero aquí surge un detalle: ese mismo cálculo también se necesita en la parte donde se imprimen los resultados. Es decir, el mismo bloque de instrucciones se repite en dos lugares distintos. Y cuando eso ocurre, lo más conveniente no es copiarlo dos veces, sino agruparlo en una función personalizada que se pueda reutilizar.

Hasta ahora venías usando funciones sin entrar en detalles. main es una función especial que marca el inicio del programa, y printf o scanf son funciones que vienen incluidas en las bibliotecas estándar. Lo que tal vez no sabías es que también puedes crear tus propias funciones, con el nombre que elijas y el contenido que necesites agrupar.

En este caso vas a crear una función para realizar el cálculo de la neurona, y así no tendrás que escribir esa lógica cada vez que quieras usarla: bastará con llamar a la función tanto en el entrenamiento como en la impresión.

Además de evitar repeticiones, las funciones hacen que el código sea más limpio, más fácil de leer y mucho más sencillo de mantener si más adelante necesitas hacer cambios.

/*========BIBLIOTECAS========*/
/*=========VARIABLES=========*/
/*=========FUNCIONES=========*/
int evaluar_neurona(){
    float ponderacion= Sesgo;
    for( int i= 0 ; i < 2 ; i++ )
        ponderacion+= Entrada[i] * Peso[i];
    return ponderacion >= 0;
}
/*=========PRINCIPAL=========*/
int main(){
/* ENTRADAS */
/* ENTRENAMIENTO */
    for( int i= 0 ; i < 4 ; i++ ){
        for( int j= 0 ; j < 2 ; j++ )
            Entrada[j]= tabla[i][j];
        Salida= evaluar_neurona();
    }
/* CALCULAR E IMPRIMIR */
    printf( "\n\n| A | B | S | " );
    for( int i= 0 ; i < 4 ; i++ ){
        for( int j= 0 ; j < 2 ; j++ )
            Entrada[j]= tabla[i][j];
        Salida= evaluar_neurona();
        printf( "\n| %i | %i | %i |" , Entrada[0] , Entrada[1] , Salida );
    }
/* TERMINAR PROGRAMA */
    return 0;
}

Has creado tu primera función personalizada: evaluar_neurona(). Esta función se encarga de realizar la ponderación y la activación de la neurona en función de los valores de las entradas.

Veamos cuál es su estructura:

La función se ha declarado como tipo int. A diferencia de las variables (donde el tipo de dato indica qué puedes guardar en ellas), en una función indica qué tipo de dato devuelve una vez que ha terminado su ejecución. Ese valor será el que esté asignado en la instrucción return, la cual finaliza la ejecución de la función y entrega el resultado al bloque de código que la llamó.

Ahora que ya conoces la instrucción return, observa la última línea que se agregó en la función main(). main también es de tipo int, lo que implica que puede devolver un valor. Por convención, se considera una buena práctica agregar la instrucción return 0;, que termina la ejecución del programa y le devuelve un valor al sistema operativo. Un valor 0 indica que el programa terminó sin problemas. Otros valores distintos de cero se pueden usar para señalar errores específicos que pudieron surgir mientras el programa estaba activo.

Si recuerdas, al principio de este módulo te comenté que pueden existir distintas funciones de activación. Así que, si ya estamos separando nuestro código en funciones que realizan tareas específicas, no sería mala idea separar la función de activación de la función activar_neurona(). En los próximos módulos, cuando utilicemos otras funciones de activación, te mostraré cómo remplazarlas de manera automática dentro de evaluar_neurona().

Veamos cómo queda el código:

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

int evaluar_neurona(){
    float ponderacion= Sesgo;
    for( int i= 0 ; i < 2 ; i++ )
        ponderacion+= Entrada[i] * Peso[i];
   return activacion_booleana( ponderacion );
}
/*=========PRINCIPAL=========*/

Permíteme explicarte el uso de los paréntesis en una función:

Podrás ver que en activacion_booleana() se hace la declaración de una variable de tipo float llamada x. Esto indica que esta función debe recibir un valor de tipo float cuando se quiera utilizar. Después, emplea ese valor dentro de la función y devuelve un valor nuevo (en este caso, un int), que corresponde al resultado de la comparación x >= 0.

Con esta estructura que diseñamos en activacion_booleana(), podemos usarla dentro de evaluar_neurona(). Esta última calcula la ponderación de las entradas y el sesgo, guarda el resultado en su variable correspondiente, y ese valor se pasa como argumento al llamar a activacion_booleana(). Esta hace su cálculo y devuelve un resultado que se pasa directamente a la instrucción return de activar_neurona().

Como ves, el funcionamiento no cambió, solo la forma en que lo describes y organizas.

Un detalle importante a destacar es que en C no puedes utilizar una función que no haya sido declarada previamente al punto donde pretendes emplearla. En otras palabras, si activacion_booleana() se va a usar dentro de evaluar_neurona(), debe aparecer más arriba en el código.

Calculando el error

El siguiente paso del entrenamiento es revisar cuánto se ha equivocado el resultado de la neurona, comparándolo con el valor esperado en la tabla de ejemplo, para esto vamos a crear un arreglo que guarde la combinacion de salidas correspondiente a la tabla de verdad deseada.

Mira cómo se implementa:

/*========BIBLIOTECAS========*/
/*=========VARIABLES=========*/
/*=========FUNCIONES=========*/
/*=========PRINCIPAL=========*/
int main(){
/* ENTRADAS */
/* ENTRENAMIENTO */
    int muestra[]={ 0 , 0 , 0 , 0 };
    for( int i= 0 ; i < 4 ; i++ ){
        for( int j= 0 ; j < 2 ; j++ )
            Entrada[j]= tabla[i][j];
        Salida= activar_neurona();
        int error= muestra[i] - Salida;
    }
/* CALCULAR E IMPRIMIR */
/* TERMINAR PROGRAMA */
}

Como ves, el cálculo del error solo consiste en una resta: El valor esperado en la muesta según su índice, menos el resultado de la neurona. Aqui se ha usado una muestra para una neurona que siempre regresa 0 independientemente de sus entrada, para ajustar el comportamiento de una operacion especifica es necesaro cambiar los valores, por ejemplo:

  • OR: { 0 , 1 , 1 , 1 }
  • NOR: { 1 , 0 , 0 , 0 }
  • AND: { 0 , 0 , 0 , 1 }
  • ...

Si la neurona devuelve un resultado menor que el valor esperado, el error será positivo. Si es mayor, el error será negativo. Si son iguales, el error será cero.

El signo del error es importante, porque indica si los valores de los pesos y el sesgo deben incrementarse o disminuirse para acercar la salida de la neurona al resultado esperado en la tabla de muestra.

Ajustando valores: donde la magia sucede

Ahora que ya podemos saber qué tan equivocada está nuestra neurona, hace falta usar ese error para ajustar, de manera individual, cada peso y el sesgo.

Para esto, vas a necesitar una nueva variable: la tasa de aprendizaje. Si el signo del error indica si el ajuste se debe hacer hacia arriba o hacia abajo, la tasa de aprendizaje define qué tan grandes serán los pasos de cada ajuste.

Si usas un valor muy grande, es posible que la neurona nunca logre estabilizarse, porque se pasa del valor correcto y sigue oscilando. Si es muy pequeño, tardará más en llegar al ajuste adecuado, pero los cambios serán más finos y precisos.

Aquí tienes el ejemplo:

/*========BIBLIOTECAS========*/
/*=========VARIABLES=========*/
/*=========FUNCIONES=========*/
/*=========PRINCIPAL=========*/
int main(){
/* ENTRADAS */
/* ENTRENAMIENTO */
    int muestra[]={ 0 , 0 , 0 , 0 };
    float tasa_aprendizaje= 0.1;
    for( int i= 0 ; i < 4 ; i++ ){
        for( int j= 0 ; j < 2 ; j++ )
            Entrada[j]= tabla[i][j];
        Salida= activar_neurona();
        int error= muestra[i] - Salida;
        Sesgo+= error * tasa_aprendizaje;
        for( int j= 0 ; j < 2 ; j++ )
            Peso[j]+= error * tasa_aprendizaje * Entrada[j];
    }
/* CALCULAR E IMPRIMIR */
/* TERMINAR PROGRAMA */
}

Y así, has terminado el código que entrena a la neurona.

Para lograr este efecto “mágico”, se toma el cálculo del error y se multiplica por la tasa de aprendizaje (en este ejemplo, 0.1). El resultado se asigna al sesgo usando el operador +=.

Para los pesos se hace un proceso similar, pero también se tiene en cuenta el valor de la entrada que recibió en ese momento. Esto último es importante: si la entrada valía 0, ese peso no se modifica (porque todo número multiplicado por 0 es 0). En cambio, si la entrada valía 1, el peso sí se ajusta, aumentando o disminuyendo según el signo del error.

Con esto, ya tienes en tus manos el primer ejemplo funcional de aprendizaje automático que, aunque sencillo, ilustra claramente la lógica detrás de una neurona artificial.

Repitiendo intentos

Ahora solo nos falta agregar un bucle más. Con un solo paso por los ejemplos, la neurona se acercará al resultado esperado, pero no habrá ajustado completamente sus pesos. Para que esto ocurra, el proceso debe repetirse varias veces, de la misma forma que cuando estás aprendiendo una habilidad nueva, como la mecanografía.

Aquí aprenderás un bucle distinto al for: el do while, que se traduce como “haz mientras”. Aunque se comporta de manera similar al for, tiene ciertas sutilezas que los diferencian. No entraré en detalle aquí, para eso existen cursos especializados en programación.

Para controlar este bucle tendrás que crear un par de variables más:

  • Un acumulador de errores, que contará los errores en cada intento. Esto servirá para detectar cuándo la neurona ha “aprendido” la operación y debe terminar el entrenamiento.
  • Un contador de intentos, que permitirá establecer un número máximo de intentos. Así evitamos que el programa se quede atrapado infinitamente intentando entrenar una operación que no logra resolver, por más ajustes que haga.
/*========BIBLIOTECAS========*/
/*=========VARIABLES=========*/
/*=========FUNCIONES=========*/
/*=========PRINCIPAL=========*/
int main(){
/* ENTRADAS */
/* ENTRENAMIENTO */
    int muestra[]={ 0 , 0 , 0 , 0 };
    float tasa_aprendizaje= 0.1;
    int epoca= 0;
    int error_total;
    do{
        error_total= 0;
        for( int i= 0 ; i < 4 ; i++ ){
            for( int j= 0 ; j < 2 ; j++ )
                Entrada[j]= tabla[i][j];
            Salida= activar_neurona();
            int error= muestra[i] - Salida;
            Sesgo+= error * tasa_aprendizaje;
            for( int j= 0 ; j < 2 ; j++ )
                Peso[j]+= error * tasa_aprendizaje * Entrada[j];
            error_total+= !!error;
        }
    } while( ++epoca < 1000 && error_total );
    printf( "\nIntentos: %i\n" , epoca );
    for( int i= 0 ; i < 2 ; i++ )
        printf( "Peso %i: %.2f\t" , i + 1 , Peso[i] );
    printf( "Sesgo: %.2f" , Sesgo );
/* CALCULAR E IMPRIMIR */
/* TERMINAR PROGRAMA */
}

Algunos detalles sobre el control del bucle de intentos:

  • Como mencioné, se usa el bucle do while. Este bucle ejecuta sus instrucciones al menos una vez. Luego, evalúa la condición que aparece entre los paréntesis tras el while. Si el resultado es distinto de cero, vuelve a ejecutarse; si es cero, termina.
  • La condición del ejemplo integra el operador AND (&&) y dice: “ incrementa el valor de epoca, si epoca es menor que 1000 Y haya al menos un error en el conjunto de ejemplos, repite el entrenamiento”. Esto implica que si llegas al límite de intentos, o la neurona ya no ha cometido errores, el entrenamiento termina.
  • La variable epoca se inicia en 0 y se incrementa con ++epoca antes de hacer la evaluacion en el bucle do while.
  • El acumulador error_total se reinicia en 0 en cada ciclo. Luego, para cada prueba individual, sumamos !!error. La doble negación convierte el error en 1 si es distinto de cero, o en 0 si no hay error. Esto es importante, porque el error puede ser negativo o positivo, y sumar directamente el valor del error podría falsear el conteo.

Ya tienes un código funcional con el que puedes empezar a experimentar. Intenta usando distintos valores iniciales para los pesos y el sesgo, y modificando los valores en el arreglo muestra para que pruebes distintos resultados. Puedes apoyarte en la seccion de operadores lógicos con las tablas de verdad del Módulo 0

Ahora que el código ya hace lo que debería, podemos mostrar información más clara y organizada:

  • Mostrar la cantidad de intentos (épocas) que le tomó entrenar a la neurona.
  • Imprimir el valor final de cada peso y el sesgo después del entrenamiento, con dos decimales de precisión.

En el bloque donde se imprimen los pesos, se usa el formato %f, que a diferencia de %i, sirve para imprimir números con punto decimal. Además, se utiliza el carácter \t para crear una separación con tabulaciones entre cada columna, facilitando la lectura.

  • El formato %.2f muestra los números con dos decimales.
  • Recuerda que en C debes poner siempre ; al final de cada instrucción.
  • Este programa ahora imprime un resumen ordenado y es más fácil de interpretar.
  • Recuerda compilar cada vez que hagas modificaciones en el código para que estos tengan efecto.

Notas finales

Este módulo ha terminado. Si has tenido la paciencia y la dedicación de llegar hasta aquí, permíteme ovacionarte. Sé que no fue un camino sencillo, pero para que tomes conciencia de lo que has conseguido, te daré un recuento de todo lo que has aprendido.

Sobre programación en lenguaje C has aprendido

  • Crear variables que utilizas para almacenar valores.
  • Agrupar estas variables mediante arreglos, para evitar hacer listas interminables de ellas.
  • Hacer bucles para recorrer los arreglos y realizar operaciones repetitivas.
  • Crear funciones para agrupar instrucciones que se van a repetir durante el diseño del código.
  • Mostrar información en pantalla usando printf.
  • Leer datos desde el teclado usando scanf.
  • Incluir bibliotecas, como hicimos con stdio.h.

Sobre inteligencia artificial has aprendido

  • Qué elementos componen una neurona artificial.
  • Cómo esta modifica su comportamiento ajustando los valores de los pesos y el sesgo, y no cambiando el código.
  • Crear un banco de ejemplos que el proceso de entrenamiento utiliza para entrenar a la neurona
  • Cómo logra “aprender” de manera automática calculando su error en función de los datos que le damos como muestra, y cómo utiliza este error para ajustar sus valores.

Una inteligencia artificial no es más que muchas de estas neuronas conectadas entre sí, donde las salidas de unas pasan como entradas de las que siguen. Así que, con esto, ya tienes toda la comprensión básica de cómo funciona la IA.

Antes de continuar con el siguiente módulo, te invito a intentar por ti mismo entrenar tu neurona para que resuelva XOR (muestra[]={ 0 , 1 , 1 , 0 }). Te darás cuenta de que, por más intentos que hagas, nunca llega a entrenarse correctamente. Prueba deducir por ti mismo, con las herramientas que ya tienes, por qué sucede esto y trata de proponer una solución que te permita lograrlo.

Ahora puedes imprimir tu código y enmarcarlo, pegarlo en el refrigerador, o tomarle una captura de pantalla y usarlo de fondo. Siéntete orgulloso de lo que has logrado, porque no es poca cosa.

Te espero en el siguiente módulo, donde seguiremos extendiendo este mismo código para resolver finalmente XOR.