Ejercicio obligatorio 1

Fecha de entrega: Lunes 23 de septiembre

Introducción

Las redes neuronales son el corazón de los sistemas de inteligencia artificial.

Una red neuronal está conformada por neuronas, que se interconectan entre sí. Las neuronas se excitan con los estímulos que reciben y se activan con determinados estímulos.

Las redes neuronales se estructuran por capas, hay una primera capa que es excitada por las señales de entrada, que propaga su información a la siguiente capa, esta a la siguiente y así hasta llegar a la última capa que es la capa de salida.

En el siguiente diagrama:

../_images/20242_ej1.png

Tenemos una capa de entrada con dos neuronas \(i_0\) e \(i_1\), una segunda capa con dos neuronas \(h\) y, por último, una capa de salida también con dos neuronas \(o_0\). En este modelo la entrada serían dos valores al igual que la salida. En una red neuronal podemos tener tantas entradas, tantas salidas, tantas capas intemedias y la cantidad de neuronas en cada capa intermedia que queramos.

Las flechas representan las conexiones, todas las neuronas de una capa están comunicadas con todas las neuronas de la capa siguiente. Estas conexiones se representan como pesos que denotan el grado de conexión. Los pesos son números reales.

Si queremos calcular la excitación de determinada neurona tenemos que computar el aporte de cada una de las conexiones entrantes.

Por ejemplo, si tuviéramos una determinada entrada \(i_0\) e \(i_1\) y quisiéramos calcular la excitación de la neurona \(h_0\) deberíamos sumar la contribución de la entrada según los pesos de las conexiones:

\[h_0 = i_0 w_{00} + i_1 w_{10}.\]

Análogamente podemos calcular el aporte de cada una de las demás neuronas siguiendo este esquema.

Notar que si:

\[\begin{split}W = \left( \begin{array}{cc} w_{00} & w_{01} \\ w_{10} & w_{11} \end{array} \right),\end{split}\]

y \(I = \left( i_0, i_1 \right)\) y \(H = \left( h_0, h_1, h_2 \right)\) entonces podemos ver que:

\[H = I W,\]

el producto matricial de dos matrices.

Es más, en principio \(O = I W V\), obtenemos la salida de nuestra red simplemente multiplicando las matrices que representan los pesos.

Si bien esta es la idea general ahora vamos a complejizar un poquito más este modelo.

En primer lugar vamos a agregar un factor que se llama bias, que simplemente es un número que se suma o resta en nuestra neurona:

\[h_0 = i_0 w_{00} + i_1 w_{10} + b_0,\]

no viene al caso explicar qué representa este valor, pero sirve para que la red funcione. Si bien estamos adosando este valor por fuera de la operación matricial es fácil incluir al mismo en ella.

Si nuestra entrada tiene una determinada cantidad de neuronas, agregaremos una entrada adicional que valdrá siempre 1. En nuestro ejemplo entonces \(I = \left(i_0, i_1, 1 \right)\). Y eso generará nuevas conexiones y agregará una nueva fila en la matriz \(W\), bueno, \(b_0\) es básicamente la primera columna de esta última fila:

\[h_0 = i_0 w_{00} + i_1 w_{10} + 1 w_{20}.\]

En segundo lugar la física de las neuronas es más compleja. Si consideráramos que el resultado de \(IW\) es el valor de las neuronas de \(H\) diríamos que las funciones se activan de forma lineal. En la vida real las neuronas se saturan, es decir, hay un umbral en el que reaccionan pero si la excitación es demasiada no se excitan proporcional a ella. Para tener una analogía pensemos en la sensación de dolor, dentro de determinado umbral el dolor que sentimos es proporcional a lo que produce el dolor, ahora bien, pasado determinado punto alcanzamos un máximo y no importa ya la intensidad del estímulo.

Lo que vamos a hacer es aplicar una función de activación al resultado de nuestra cuenta. Hay múltiples funciones pero elijamos la función sigmoidea logística:

\[\sigma(x) = \frac1{1 + exp(-x)}\]

Que tiene la siguiente gráfica:

../_images/20242_ej1_sigmoidea.png

Notar que la activación se mantiene estable entre 0 y 1, sin importar lo extremo de la excitación.

Entonces cuando operemos \(IW\) esa será la excitación de las neuronas de \(H\) pero tendremos que aplicarle \(\sigma(x)\) a cada uno de esos valores para obtener la activación de las neuronas.

Por ejemplo, para calcular el valor de \(h_0\) que habíamos computado antes sin la función de activación sería:

\[h_0 = \sigma(i_0 w_{00} + i_1 w_{10} + 1 w_{20}).\]

Si asumimos que agregamos un 1 a la entrada podemos decir que:

\[H = \sigma(IW).\]

(Cuando notamos \(\sigma(X)\) con \(X\) algo multidimensional queremos decir, aplicarle \(\sigma(x)\) a cada elemento \(x_i\).)

Entonces, la salida completa será \(O = \sigma(\sigma(IW)V)\), una composición de productos matriciales y funciones de activación.

Entrenamiento

Hasta ahí el modelo. Bien. Si tuviéramos las matrices de pesos podríamos computar la red neuronal que quisiéramos. ¿Pero cómo hacemos para generar esos pesos en primer lugar?

Bueno, los modelos neuronales siempre se construyen en base a un juego de datos de entrenamiento. En el juego de datos de entrenamiento sabemos tanto la entrada como la salida para determinados casos. Si entrenamos bien nuestra red, entonces la red debería poder reaccionar tanto a datos del entrenamiento como a datos nuevos (si los datos de entrenamiento fueran representativos).

El procedimiento para entrenar una red es el siguiente: Se empieza con pesos aleatorios, generalmente entre 0 y 1.

Luego se excita la red según alguno de los casos de entrenamiento. Esto nos dará una salida, que probablemente esté muy mal, dado que la red no está entrenada.

Lo que vamos a hacer es computar el error, o sea, qué tan mal está la salida que tuvimos con respecto a la deseada.

Generalmente se utiliza de métrica el error cuadrático. Si suponemos que \(\hat O\) es el valor esperado y \(O\) es el resultado de nuestra red:

\[L = \frac12 \sum_{i = 0}^2 \left(\hat o_i - o_i \right)^2\]

La idea es utilizar ese error para decidir qué coeficientes ajustar para distribuir ese error entre los diferentes pesos y de esa manera mejorar la predicción. El algoritmo más común para realizar esta operación se llama backpropagation.

Este proceso es un proceso iterativo, se realiza para cada uno de los valores de entrenamiento no una si no muchas veces. Si el entrenamiento fue adecuado (y la geometría de nuestra red es buena para el problema) al final tendremos una red con pesos razonables que será capaz de predecir salidas a entradas.

Recapitulación

El modelo que presentamos para cada una de las neuronas es lo que se llama un perceptrón simple.

Si bien en nuestro esquema inicial presentamos la red en su conjunto, después de todas las adiciones que hicimos el modelo de cada una de las neuronas nos quedó así:

../_images/20242_ej1_perceptron.png

Es decir, para computar la salida de nuestra neurona \(y\) pasamos por diferentes estadíos. Primero entran las señales de cada una de las neuronas de la capa anterior \(x_i\) más el bias 1. Esos valores se multiplican por los pesos relativos dados por \(w_i\) y se suman. Eso genera el estímulo \(s\). Ahora bien, este estímulo se pasa a través de la función de activación \(\sigma\) y es este el valor final de mi neurona.

Reiteramos:

\[y = \sigma(x_0 w_0 + x_1 w_1 + \ldots + 1 w_n) = \sigma\left(\sum x_i w_i\right).\]

No dijimos nada nuevo, sólo remarcamos que la salida viene de la función de activación, que a su vez viene de una sumatoria, que a su vez viene de aportes individuales de las neuronas previas por sus respectivos pesos. Lo vamos a utilizar en la próxima sección.

Backpropagation

Como ya se dijo, la forma más usual de entrenar redes es con el algoritmo de backpropagation.

En este algoritmo vamos a computar la actualización de cada peso \(w_i\) (y \(v_i\)) individual de nuestras matrices como \(\frac{\partial L}{\partial w_i}\).

Recordemos la regla de la cadena que nos decía que si \(y = f(u)\) y \(u = g(x)\) podemos escribir:

\[\frac{dy}{dx} = \frac{dy}{du} \frac{du}{dx}.\]

En nuestro método la derivada buscada la resolveremos según la cadena de sus diferentes componentes... porque no nos olvidemos de que nuestra salida no es otra cosa que una concatenación de funciones.

Antes de ir a las cuentas notar que:

\[\frac d{dx} \sigma(x) = \sigma(x) [ 1 - \sigma(x)].\]

Empecemos, por ejemplo, con el cómputo en la capa \(V\), supongamos el peso dado por \(v_{00}\), calcularemos la contribución del error \(L\) a \(v_{00}\) según:

\[\frac{\partial L}{\partial v_{00}}\]

Recordemos el concepto de derivada parcial: Nuestra variable de derivación es \(v_{00}\), por lo que todos los términos que no dependan de ella van a ser derivados como si se tratara de constantes y nada más.

\(L\) era el error y contenía el valor de la salida, que ya sabemos que es una combinación lineal de muchas cosas... ahora bien, sólo \(o_0\) depende de \(v_{00}\). De este modo podemos aplicar la regla de la cadena en el sentido que nos interesa:

\[\frac{\partial L}{\partial v_{00}} = \frac{\partial L}{\partial o_0} \frac{\partial o_0}{\partial \sum_{o_0}} \frac{\partial \sum_{o_0}}{\partial v_{00}}.\]

Y parece un choclazo pero vamos a ver que la operatoria es sencilla, para que se entienda por dónde viene la mano por ejemplo: si tenemos una sumatoria donde cada término está afectado por un único peso entonces sólo va a sobrevivir el término de \(v_{00}\).

Vayamos por partes, ataquemos la primera derivada:

\[\frac{\partial L}{\partial o_0} = \frac\partial{\partial o_0} \frac12 \sum \left(\hat o_i - o_i\right)^2 = o_0 - \hat o_0\]

¿Toda esa línea termina en eso? Sí, fijate que de la sumatoria el término de \(o_1\) no importa y entonces es sólo derivar \(\frac12\left(\hat o_0 - o_0\right)^2\) con respecto a \(o_0\).

El siguiente término de nuestra derivada es la derivada de la salida con respecto a la sumatorial. En el perceptrón este término es el que aplica la función de activación, y ya vimos la derivada de esta función. Por lo tanto:

\[\frac{\partial o_0}{\partial \sum_{o_0}} = o_0 (1 - o_0)\]

Y finalmente el último término, tenemos la derivada de \(h_0 v_{00} + h_1 v_{10} + 1 v_{20}\):

\[\frac{\partial \sum_{o_0}}{\partial v_{00}} = h_0\]

Entonces poniendo todo junto:

\[\frac{\partial L}{\partial v_{00}} = \left[ o_0 - \hat o_0 \right] \left[ o_0 (1 - o_0) \right] \left[ h_0 \right]\]

Todos los parámetros que aparecen son términos que tuvimos que calcular cuando computamos la salida. Esto es apenas una cuenta sencilla.

Habiendo computado este término calcularemos el nuevo valor \(v_{00}'\) como:

\[v_{00}' = v_{00} - \eta \frac{\partial L}{\partial v_{00}},\]

donde el parámetro \(\eta\) es el grado de aprendizaje. Cuando empecemos a entrenar este valor será alto, porque queremos moldear mucho la red e iremos descendiendo su valor a medida que queramos hacer ajustes más finos.

Notar que el peso \(v_{00}\) es el que relaciona a \(h_0\) con \(o_0\), genéricamente \(v_{ij}\) relaciona a \(h_i\) con \(v_j\). Luego, para todos los pesos de \(V\) podemos escribir:

\[\frac{\partial L}{\partial v_{ij}} = \left[ o_j - \hat o_j \right] \left[ o_j (1 - o_j) \right] \left[ h_i \right]\]

Si te perdiste, esta última fórmula y la anterior son las únicas dos que van a importarnos cuando actualicemos las matrices, el resto fue el desarrollo para llegar acá.

Si vamos a los pesos de la matriz \(W\) podemos seguir un enfoque similar. Ahora bien, observemos una diferencia importante entre las dos capas de neuronas con respecto a la salida. En la última capa cada peso incide en un solo valor de la salida, en cambio en la primera capa cada neurona incide en los pesos de la salida. Como \(L\) es una sumatoria de tantos términos como salida, computemos por separado cada una de ellas y después sumemos al final. Entonces:

\[\frac{\partial L_0}{\partial w_{00}} = \frac{\partial L_0}{\partial o_0} \frac{\partial o_0}{\partial \sum_{o_0}} \frac{\partial \sum_{o_0}}{\partial h_0} \frac{\partial h_0}{\partial \sum_{h_0}} \frac{\partial \sum_{h_0}}{\partial w_{00}}.\]

Dado que es una cadena, los primeros términos son los mismos que ya computamos para la capa de salida. Veamos los nuevos:

\[\frac{\partial \sum_{o_0}}{\partial v_{00}} = v_{00}.\]

(Es la misma derivada que antes pero ahora derivamos en función de \(h_0\)).

Análogamente a antes, cuando derivemos la función de activación:

\[\frac{\partial h_0}{\partial \sum_{h_0}} = h_0 (1 - h_0)\]

Y finalmente no debería ser sorpresa que el último término sea:

\[\frac{\partial \sum_{h_0}}{\partial w_{00}} = i_0,\]

Entonces:

\[\frac{\partial L_0}{\partial w_{00}} = \left[ o_0 - \hat o_0 \right] \left[ o_0 (1 - o_0) \right] \left[ v_{00} \right] \left[ h_0 (1 - h_0)\right] \left[ i_0 \right].\]

No nos olvidemos que:

\[\frac{\partial L}{\partial w_{00}} = \frac{\partial L_0}{\partial w_{00}} + \frac{\partial L_1}{\partial w_{00}},\]

es decir, habíamos partido la sumatoria en 2. Otra vez, si suponemos un \(w_{ij}\) entonces:

\[\frac{\partial L}{\partial w_{ij}} = \sum_{k=0}^2 \left[ o_k - \hat o_k \right] \left[ o_k (1 - o_k) \right] \left[ v_{jk} \right] \left[ h_j (1 - h_j)\right] \left[ i_i \right].\]

Esta última es la otra ecuación importante.

No nos olvidemos de que teniendo el aporte del error al peso entonces vamos a actualizar según:

\[w' = w - \eta \frac{\partial L}{\partial W}.\]

¿Hacemos un ejemplo?

Imaginemos que queremos entrenar una red de dos entradas y dos salidas con un único caso de entrenamiento:

\[I = (0.7, 0.8) \implies \hat O = (0.05, 0.95),\]

Así se vería la red (en verde los datos de entrada, en rojo los que calcularemos):

../_images/20242_ej1_ejemplo.png

Ahora bien, empezamos nuestro problema con los siguientes valores:

\[\begin{split}W = \left( \begin{array}{ccc} 0.1 & 0.2 \\ 0.3 & 0.4 \\ 0.5 & 0.6 \end{array} \right),\end{split}\]
\[\begin{split}V = \left( \begin{array}{ccc} 0.15 & 0.25 \\ 0.35 & 0.45 \\ 0.55 & 0.65 \end{array} \right),\end{split}\]

Computemos:

\[h_0 = \sigma(i_0 w_{00} + i_1 w_{10} + 1 w_{20}) = \sigma(0.7\times 0.1 + 0.8\times 0.3 + 1\times 0.5) = \sigma(0.81) = 0.6921.\]

Análogamente \(h_1 = \sigma(1.06) = 0.7427\).

Acabamos de computar \(H = \sigma((0.7, 0.8, 1) W) = (0.6921, 0.7427)\).

Computemos ahora:

\[o_0 = \sigma(h_0 v_{00} + h_1 v_{10} + 1 v_{20}) = \sigma(0.6921\times 0.15 + 0.7427\times 0.35 + 1 0.55) = \sigma(0.9138) = 0.7138.\]

Análogamente \(o_1 = \sigma(1.157) = 0.7608\).

Acabamos de computar \(O = \sigma(\sigma((0.7, 0.8, 1) W) V) = (0.7138, 0.7608)\).

Ahora bien, nosotros esperábamos que la salida fuera \((0.05, 0.95)\), ¿está bien computado esto? Sí, está bien computado... pero entendamos que estamos partiendo de una red no entrenada. Tenemos un montón de error, particularmente tenemos:

\[L = \frac12 \left[ \left(0.05 - 0.7138 \right)^2 + \left(0.95 - 0.7608 \right)^2 \right]= \frac12 \left[ (-0.6638)^2 + 0.1892^2 \right] = 0.2382\]

Lo siguiente sería entrenar a la red para que mejore en su predicción.

Empecemos por \(v_{00}\):

\[\frac{\partial L}{\partial v_{00}} = \left[ o_0 - \hat o_0 \right] \left[ o_0 (1 - o_0) \right] \left[ h_0 \right] = \left[ 0.7138 - 0.05 \right] \left[ 0.7138 (1 - 0.7138) \right] \left[ 0.6921 \right] = 0.6638\times 0.2043\times 0.6921 = 0.09386.\]

Análogamente para los otros valores:

\[\begin{split}\frac{\partial L}{\partial V} = \left( \begin{array}{ccc} 0.09386 & -0.0238 \\ 0.1007 & -0.02557 \\ 0.1356 & -0.03442 \end{array}\right).\end{split}\]

Ataquemos ahora \(w_{00}\):

\[\frac{\partial L_0}{\partial w_{00}} = \left[ o_0 - \hat o_0 \right] \left[ o_0 (1 - o_0) \right] \left[ v_{00} \right] \left[ h_0 (1 - h_0)\right] \left[ i_0 \right] = 0.6638 \times 0.2043 \times 0.15 \times \left[0.6921 (1 - 0.6921) \right] \times 0.7 = 0.003033.\]

(Omitimos recalcular los números que ya precalculamos.)

Luego:

\[\frac{\partial L_1}{\partial w_{00}} = \left[ o_1 - \hat o_1 \right] \left[ o_1 (1 - o_1) \right] \left[ v_{01} \right] \left[ h_0 (1 - h_0)\right] \left[ i_0 \right] = -0.1892 \times 0.1819 \times 0.25 \times 0.2131 \times 0.7 = -0.001283.\]

Entonces:

\[\frac{\partial L}{\partial w_{00}} = 0.003033 - 0.001283 = 0.00175.\]

Totalizando:

\[\begin{split}\frac{\partial L}{\partial W} = \left( \begin{array}{ccc} 0.001751 & 0.004277 \\ 0.002001 & 0.004888 \\ 0.002501 & 0.006110 \end{array}\right).\end{split}\]

Ahora ya tenemos todo lo necesario como para entrenar nuestra red.

Debemos computar la actualización, para eso vamos a elegir \(\eta = 0.5\):

Entonces tenemos

\[\begin{split}W' = W - \eta \frac{\partial L}{\partial W} = \left( \begin{array}{cc} 0.0991& 0.1978 \\ 0.2990 &0.3975 \\ 0.4988& 0.5969 \end{array}\right).\end{split}\]

Esa será la nueva \(W\).

Y análogamente:

\[\begin{split}V' = V - \eta \frac{\partial L}{\partial V} = \left(\begin{array}{cc} 0.1030& 0.2619 \\ 0.2996& 0.4628 \\ 0.4822& 0.6672 \end{array}\right).\end{split}\]

Si volviéramos a correr el problema con estos nuevos valores ahora la salida sería \(O = (0.6847, 0.7670)\) y el error bajaría un 10% a \(L = 0.218191\).

Manteniendo el mismo \(\eta\) en 100 iteraciones obtenemos \(O = (0.1012, 0.9083)\) y en 1000 iteraciones \(O = (0.05137, 0.9487)\).

En un entrenamiento real no entrenaríamos a la red siempre con el mismo caso si no que alternaríamos entre todos nuestros casos de entrenamiento. La idea de una red es que nos sirva para clasificar casos conocidos e interpolar casos nuevos, por eso es que las entrenaremos con casos diferentes.

Trabajo

Función sigmoidea logística

Implementar una función double sigmoidea(double x) que dado un x devuelva la evaluación de la función sigmoidea logística en x.

Imprimir matrices

Implementar una función void imprimir_matriz(size_t fs, size_t cs, float m[fs][cs]); que imprima los elementos de la matriz m por stdout.

Inicializar matrices

Implementar una función void inicializar_matriz(size_t fs, size_t cs, float m[fs][cs]); que inicialice la matriz m en valores aleatorios entre 0 y 1.

Extraer fila

Implementar una función void extraer_fila(size_t cs, float origen[][cs], float destino[cs], size_t fila); que extraiga la fila indicada del la matriz origen y la guarde en el vector destino.

Extender vector

Implementar una función void extender_vector(size_t n, const float origen[n], float destino[n + 1]); que copie los elementos de origen a destino e inicialice en 1 el valor que sobra en destino.

Aplicar función

Implementar una función void aplicar_funcion(size_t n, float v[n]); que aplique la función sigmoidea logística a cada uno de los elementos del vector v.

Actualizar matriz

Implementar una función void actualizar_matriz(size_t fs, size_t cs, float m[fs][cs], float l[fs][cs], float eta) que aplique actualice m en base a l y eta según \(M' = M - \eta L\).

Multiplicar

Implementar una función void multiplicar(size_t nx, size_t ny, const float x[nx], float w[nx][ny], float y[ny]); que calcule Y = X W, siendo x e y vectores fila y w una matriz.

Leer matriz

Implementar una función bool leer_matriz(size_t fs, size_t cs, float m[fs][cs]) que lea de stdin los valores que representan una matriz y los almacene en m. La función debe devolver true en caso de poder leer la matriz, false en caso contrario.

Esta función debe ser capaz de leer las matrices que imprime imprimir_matriz().

Aplicación 1

Se pide implementar un programa que lea de stdin dos matrices de pesos \(W\) y \(V\) de dimensiones \(3\times2\) y que luego espere que el usuario ingrese una sucesión indefinida de pares de valores \((i_0, i_1)\).

El programa debe computar la salida de la red neuronal definida por las matrices provistas para cada uno de los pares de valores que ingrese el usuario.

Por ejemplo, los siguientes valores definen una red entrenada de tal manera que \(o_0 = i_0 \lor i_1\) y \(o_1 = i_0 \land i_1\):

-4.92228079
5.38175964
-4.92212534
5.38191175
6.67276812
-3.39423680
-6.36431265
-14.04024220
13.64763451
8.00000095
-0.12261391
-1.46909201

Aplicación 2

Se pide implementar un programa que lea de stdin primero un entero n y luego una cantidad n de cuartetos \((i_0, i_1, o_0, o_1)\) y los use para entrenar una red neuronal de con la geometría ya desarrollada.

El entrenamiento debe iniciarse con las matrices \(W\) y \(V\) inicializadas en valores aleatorios y realizar 10000 iteraciones con \(\eta = 0.5\) donde en cada iteración entrenará con cada uno de los valores de entrenamiento provistos.

Al terminar imprimirá por stdout las matrices de pesos \(W\) y \(V\).

Por ejemplo, la siguiente entrada se utilizó para entrenar los pesos ya dados:

4
0 0 0 0
0 1 1 0
1 0 1 0
1 1 1 1

Se presentan los valores en una única línea pero es totalmente equivalente a:

4
0
0
0
0
0
1
1
0
1
0
1
0
1
1
1
1

Entrega

Deberán entregarse los códigos fuentes de los dos programas desarrollados.

Ambos programas deben compilar correctamente con los flags:

-Wall -Werror -std=c99 -pedantic

y validar los ejemplos dados.

La entrega se realiza a través del sistema de entregas.

El ejercicio es de entrega individual.