Cómo construir una red neuronal desde cero

Las redes neuronales son como los caballos de batalla del aprendizaje profundo. Con suficientes datos y poder computacional, se pueden usar para resolver la mayoría de los problemas del aprendizaje profundo. Es muy fácil usar una biblioteca Python o R para crear una red neuronal y entrenarla en cualquier conjunto de datos y obtener una gran precisión.

Podemos tratar las redes neuronales como una caja negra y usarlas sin ninguna dificultad. Pero aunque parece muy fácil ir por ese camino, es mucho más emocionante aprender qué hay detrás de estos algoritmos y cómo funcionan.

En este artículo entraremos en algunos de los detalles de la construcción de una red neuronal. Voy a usar Python para escribir código para la red. También usaré la biblioteca numpy de Python para realizar cálculos numéricos. Trataré de evitar algunos detalles matemáticos complicados, pero al final me referiré a algunos recursos brillantes si quieres saber más sobre eso.

Entonces empecemos.

Idea

Antes de comenzar a escribir código para nuestra red neuronal, esperemos y entendamos qué es exactamente una red neuronal.

En la imagen de arriba puede ver un diagrama muy casual de una red neuronal. Tiene algunos círculos de colores conectados entre sí con flechas que apuntan a una dirección particular. Estos círculos de colores a veces se denominan neuronas .

Estas neuronas no son más que funciones matemáticas que, cuando se les da alguna entrada, generan una salida . La salida de las neuronas depende de la entrada y los parámetros de las neuronas . Podemos actualizar estos parámetros para obtener un valor deseado de la red.

Cada una de estas neuronas se define mediante la función sigmoidea . Una función sigmoidea da una salida entre cero y uno por cada entrada que recibe. Estas unidades sigmoides están conectadas entre sí para formar una red neuronal.

Por conexión aquí queremos decir que la salida de una capa de unidades sigmoides se da como entrada a cada unidad sigmoidea de la siguiente capa. De esta manera, nuestra red neuronal produce una salida para cualquier entrada dada. El proceso continúa hasta llegar a la capa final. La capa final genera su salida.

Este proceso de una red neuronal que genera una salida para una entrada determinada es Propagación hacia adelante . La salida de la capa final también se denomina predicción de la red neuronal. Más adelante en este artículo analizaremos cómo evaluamos las predicciones . Estas evaluaciones se pueden utilizar para saber si nuestra red neuronal necesita mejoras o no.

Inmediatamente después de que la capa final genera su salida, calculamos la función de costo . La función de costo calcula qué tan lejos está nuestra red neuronal de realizar las predicciones deseadas. El valor de la función de costo muestra la diferencia entre el valor predicho y el valor de verdad .

Nuestro objetivo aquí es minimizar el valor de la función de costo . El proceso de minimización de la función de costo requiere un algoritmo que pueda actualizar los valores de los parámetros en la red de tal manera que la función de costo alcance su valor mínimo .

Se utilizan algoritmos como el descenso de gradiente y el descenso de gradiente estocástico para actualizar los parámetros de la red neuronal. Estos algoritmos actualizan los valores de ponderaciones y sesgos de cada capa en la red dependiendo de cómo afectará la función de minimización de costos. El efecto sobre la minimización de la función de costo con respecto a cada uno de los pesos y sesgos de cada una de las neuronas de entrada en la red se calcula por retropropagación .

Código

Entonces, ahora conocemos las ideas principales detrás de las redes neuronales. Comencemos a implementar estas ideas en el código. Comenzaremos importando todas las bibliotecas necesarias.

import numpy as np import matplotlib.pyplot as plt

Como mencioné, no vamos a utilizar ninguna de las bibliotecas de aprendizaje profundo. Entonces, usaremos principalmente numpy para realizar cálculos matemáticos de manera eficiente.

El primer paso para construir nuestra red neuronal será inicializar los parámetros. Necesitamos inicializar dos parámetros para cada una de las neuronas en cada capa: 1) Peso y 2) Sesgo .

Estos pesos y sesgos se declaran en forma vectorizada . Eso significa que en lugar de inicializar pesos y sesgos para cada neurona individual en cada capa, crearemos un vector (o una matriz) para pesos y otro para sesgos, para cada capa.

Estos pesos y vectores de sesgo se combinarán con la entrada a la capa. Luego aplicaremos la función sigmoidea sobre esa combinación y la enviaremos como entrada a la siguiente capa.

layer_dimscontiene las dimensiones de cada capa. Pasaremos estas dimensiones de capas a init_parmsfunción que los utilizará para inicializar parámetros. Estos parámetros se almacenarán en un diccionario llamado params . Entonces, en el diccionario de params, params ['W1']representará la matriz de peso para la capa 1.

def init_params(layer_dims): np.random.seed(3) params = {} L = len(layer_dims) for l in range(1, L): params['W'+str(l)] = np.random.randn(layer_dims[l], layer_dims[l-1])*0.01 params['b'+str(l)] = np.zeros((layer_dims[l], 1)) return params

¡Excelente! Hemos inicializado los pesos y sesgos y ahora definiremos la función sigmoidea . Calculará el valor de la función sigmoidea para cualquier valor dado de Z y también almacenará este valor como caché. Almacenaremos los valores de la caché porque los necesitamos para implementar la propagación hacia atrás. La Z aquí es la hipótesis lineal .

Tenga en cuenta que la función sigmoidea se incluye en la clase de funciones de activación en la terminología de la red neuronal. El trabajo de una función de activación es dar forma a la salida de una neurona.

Por ejemplo, la función sigmoidea toma entradas con valores discretos y da un valor que se encuentra entre cero y uno. Su propósito es convertir las salidas lineales en salidas no lineales. Hay diferentes tipos de funciones de activación que se pueden utilizar para un mejor rendimiento, pero nos ceñiremos a sigmoide por simplicidad.

# Z (linear hypothesis) - Z = W*X + b , # W - weight matrix, b- bias vector, X- Input def sigmoid(Z): A = 1/(1+np.exp(np.dot(-1, Z))) cache = (Z) return A, cache

Ahora, comencemos a escribir código para la propagación hacia adelante. Hemos discutido anteriormente que la propagación hacia adelante tomará los valores de la capa anterior y los dará como entrada a la siguiente capa. La siguiente función tomará los datos de entrenamiento y los parámetros como entradas y generará una salida para una capa y luego alimentará esa salida a la siguiente capa y así sucesivamente.

def forward_prop(X, params): A = X # input to first layer i.e. training data caches = [] L = len(params)//2 for l in range(1, L+1): A_prev = A # Linear Hypothesis Z = np.dot(params['W'+str(l)], A_prev) + params['b'+str(l)] # Storing the linear cache linear_cache = (A_prev, params['W'+str(l)], params['b'+str(l)]) # Applying sigmoid on linear hypothesis A, activation_cache = sigmoid(Z) # storing the both linear and activation cache cache = (linear_cache, activation_cache) caches.append(cache) return A, caches

A_prev i de entrada s a la primera capa. Recorreremos todas las capas de la red y calcularemos la hipótesis lineal. Posteriormente tomará el valor de Z (hipótesis lineal) y se lo dará a la función de activación sigmoidea. Los valores de caché se almacenan a lo largo del camino y se acumulan en cachés . Finalmente, la función devolverá el valor generado y el caché almacenado.

Definamos ahora nuestra función de costes.

def cost_function(A, Y): m = Y.shape[1] cost = (-1/m)*(np.dot(np.log(A), Y.T) + np.dot(log(1-A), 1-Y.T)) return cost

A medida que disminuye el valor de la función de costo, el rendimiento de nuestro modelo mejora. El valor de la función de costo se puede minimizar actualizando los valores de los parámetros de cada una de las capas de la red neuronal. Se utilizan algoritmos como Gradient Descent para actualizar estos valores de tal manera que se minimiza la función de costo.

Gradient Descent actualiza los valores con la ayuda de algunos términos de actualización. Estos términos de actualización llamados gradientes se calculan mediante la propagación hacia atrás. Los valores de gradiente se calculan para cada neurona en la red y representan el cambio en la salida final con respecto al cambio en los parámetros de esa neurona en particular.

def one_layer_backward(dA, cache): linear_cache, activation_cache = cache Z = activation_cache dZ = dA*sigmoid(Z)*(1-sigmoid(Z)) # The derivative of the sigmoid function A_prev, W, b = linear_cache m = A_prev.shape[1] dW = (1/m)*np.dot(dZ, A_prev.T) db = (1/m)*np.sum(dZ, axis=1, keepdims=True) dA_prev = np.dot(W.T, dZ) return dA_prev, dW, db

The code above runs the backpropagation step for one single layer. It calculates the gradient values for sigmoid units of one layer using the cache values we stored previously. In the activation cache we have stored the value of Z for that layer. Using this value we will calculate the dZ, which is the derivative of the cost function with respect to the linear output of the given neuron.

Once we have calculated all of that, we can calculate dW, db and dA_prev, which are the derivatives of cost function with respect the weights, biases and previous activation respectively. I have directly used the formulae in the code. If you are not familiar with calculus then it might seem too complicated at first. But for now think about it as any other math formula.

After that we will use this code to implement backpropagation for the entire neural network. The function backprop implements the code for that. Here, we have created a dictionary for mapping gradients to each layer. We will loop through the model in a backwards direction and compute the gradient.

def backprop(AL, Y, caches): grads = {} L = len(caches) m = AL.shape[1] Y = Y.reshape(AL.shape) dAL = -(np.divide(Y, AL) - np.divide(1-Y, 1-AL)) current_cache = caches[L-1] grads['dA'+str(L-1)], grads['dW'+str(L-1)], grads['db'+str(L-1)] = one_layer_backward(dAL, current_cache) for l in reversed(range(L-1)): current_cache = caches[l] dA_prev_temp, dW_temp, db_temp = one_layer_backward(grads["dA" + str(l+1)], current_cache) grads["dA" + str(l)] = dA_prev_temp grads["dW" + str(l + 1)] = dW_temp grads["db" + str(l + 1)] = db_temp return grads

Once, we have looped through all the layers and computed the gradients, we will store those values in the grads dictionary and return it.

Finally, using these gradient values we will update the parameters for each layer. The function update_parameters goes through all the layers and updates the parameters and returns them.

def update_parameters(parameters, grads, learning_rate): L = len(parameters) // 2 for l in range(L): parameters['W'+str(l+1)] = parameters['W'+str(l+1)] -learning_rate*grads['W'+str(l+1)] parameters['b'+str(l+1)] = parameters['b'+str(l+1)] - learning_rate*grads['b'+str(l+1)] return parameters

Finally, it's time to put it all together. We will create a function called train for training our neural network.

def train(X, Y, layer_dims, epochs, lr): params = init_params(layer_dims) cost_history = [] for i in range(epochs): Y_hat, caches = forward_prop(X, params) cost = cost_function(Y_hat, Y) cost_history.append(cost) grads = backprop(Y_hat, Y, caches) params = update_parameters(params, grads, lr) return params, cost_history

This function will go through all the functions step by step for a given number of epochs. After finishing that, it will return the final updated parameters and the cost history. Cost history can be used to evaluate the performance of your network architecture.

Conclusion

If you are still reading this, Thanks! This article was a little complicated, so what I suggest you to do is to try playing around with the code. You might get some more insights out of it and maybe you might find some errors in the code too. If that is the case or if you have some questions or both, feel free to hit me up on twitter. I will do my best to help you.

Resources

  • Neural Networks Playlist - by 3Blue1Brown
  • Neural Networks and Deep Learning  - by Michael A. Nielsen
  • Gradient Descent and Stochastic Gradient Descent