Construir una red neuronal básica desde 0 en python

Construir una red neuronal básica desde 0 en python

Existen muchas librerías y formas de modelar, entrenar y utilizar una red neuronal en la actualidad. A continuación veremos una forma sencilla pero clara de cómo programar una RN básica utilizando python.

import numpy as np
import scipy as sc
import matplotlib.pyplot as plt

from sklearn.datasets import make_circles

Importamos las librerías: numpy para el procesamiento numérico, scipy que extiende funcionalidades de numpy, matplotlib para realizar gráficas y sklearn que contiene herramientas de deep learning.

Crearemos un dataset de ejemplo usando la función make_circles, que nos generará dos conjuntos de puntos de la siguiente forma:

# create dataset
# number of registers
n = 500
# number of characteristics of our dataset (for each register)
p = 2 
X, Y = make_circles(n_samples=n, factor=0.5, noise=0.05)
plt.scatter(X[Y==0, 0], X[Y==0, 1], c="skyblue")
plt.scatter(X[Y==1, 0], X[Y==1, 1], c="salmon")
plt.axis("equal")
plt.show()
dataset para entrenar nuestra RN

A continuación, diseñamos el esqueleto de nuestra red neuronal. Para esto, necesitamos ver a cada capa de esta como un módulo, sabiendo que cada una puede llevar una función de activación y características similares. En el constructor de la clase neural_layer definimos lo siguiente:

# class of network layer
class neural_layer():
  def __init__(self, n_connections, n_neurons, a_function):
    self.a_function = a_function
    normalization = lambda x : x * 2 - 1
    # for bias
    self.b = normalization(np.random.rand(1, n_neurons))
    # for weights
    self.W = normalization(np.random.rand(n_connections, n_neurons))

Los parámetros b y W corresponden al vector de bias y a la matriz de pesos, que inicializaremos con valores aleatorios normalizados en cero. Así mismo, al definir un objeto de la clase neural_layer le enviaremos los parámetros correspondientes al número de conexiones, de neuronas y la función de activación correspondiente a cada capa, en ese orden.

Funciones de activación

Definiremos las funciones de activación que va a usar nuestra RN, así como sus derivadas que más adelante usaremos para hacer Back Propagation.

# activation functions
sigm = (lambda x : 1 / (1 + np.e ** (-x)),
        lambda x: x * (1 - x))
relu = lambda x: np.maximum(0, x)
_x = np.linspace(-5, 5, 100) # 100 values from -5 to 5
plt.plot(_x, sigm[0](_x), color='green', linestyle='dashed')
Función sigmoide

Utilizaremos una función para crear la RN, de la siguiente forma:

def create_nn(topology, a_function):
  nn = [];
  for l, layer in enumerate(topology[:-1]):
    nn.append(neural_layer(topology[l], topology[l+1], a_function))
  return nn;

La función create_nn crea un arreglo vacío y toma un arreglo llamado topology, que es la sucesión de capas y la función de activación, que puede ser sigmode o ReLu en nuestro caso, o la que sea necesaria. La función devolverá la matriz nn que contiene la cantidad de neuronas por cada capa y su función de activación.

Lo siguiente será definir nuestra topología y función para calcular el error (o función de coste)

topology = [p, 4, 8, 1]
neural_network = create_nn(topology, sigm)

l2_cost = (lambda Yp, Yr: np.mean((Yp - Yr )**2),
          lambda Yp, Yr: (Yp - Yr))

Con esto listo, lo siguiente será entrenar a nuestra RN, calculando los tres ítems primordiales: El forward pass, backpropagation y el algoritmo del descenso del gradiente (veremos cada uno en detalle a continuación):

def train(neural_network, X, Y, l2_cost, lr = 0.5, train = True):
  out = [(None, X)]
  # forward pass: we pass our ponderate sum trough all our layers
  for l, layer in enumerate(neural_network):
    z = out[-1][1] @ neural_network[l].W + neural_network[l].b
    a = neural_network[l].a_function[0](z)
    out.append((z, a))
  # we can print our error now
  # print("error", l2_cost[0](out[-1][1], Y))
  if train:
    # backward pass
    deltas = []
    for l in reversed(range(0, len(neural_network))):
      z = out[l+1][0]
      a = out[l+1][1]

      if l == len(neural_network)-1:
        # for the last layer
        deltas.insert(0, l2_cost[1](a, Y) * neural_network[l].a_function[1](a))
      else:
        # for other layers
        deltas.insert(0, deltas[0] @  W_temporal.T * neural_network[l].a_function[1](a))
      W_temporal = neural_network[l].W
      # gradient descent
      neural_network[l].b = neural_network[l].b - np.mean(deltas[0], axis=0, keepdims=True) * lr 
      neural_network[l].W = neural_network[l].W  - out[l][1].T @ deltas[0] * lr
  return out[-1][1];
train(neural_network, X, Y, l2_cost, 0.5)

El primer paso, es recorrer cada una de nuestras capas hacia adelante y calcular el valor a la salida, recordando que nuestros valores iniciales de W y bias son aleatorios. A esto le llamamos forward pass o "paso hacía adelante", y es útil también para realizar predicciones sin necesidad de entrenar a nuestra red (por esto el parámetro Train puede ser falso).

Al final de este paso obtenemos el cálculo del error, que es totalmente aleatorio, como sus entradas. Para el siguiente paso, llamado backward pass o "retropropagación" necesitamos calcular las derivadas parciales desde la última capa hasta la inicial. Este cálculo difiere en la última capa y en el resto de ella, cómo se ve en el script, y en el caso de la última capa se calcula como la derivada de la función de coste, multiplicada por la derivada de la función de activación. Para las siguientes capas necesitamos la derivada de la capa anterior por los pesos W de la capa actual multiplicada nuevamente por la derivada de la función de activación.

Para el paso del descenso del gradiente iremos modificando nuestros parámetros W y bias en el mismo ciclo del cálculo del backward pass. Para esto guardamos una variable temporal con nuestro valor actual de W.

Por último, comprobaremos la capacidad para clasificar de nuestro proyecto, iterando 2500 veces y jugando con el learning rate arbitrariamente:

import time
from IPython.display import clear_output

neural_network = create_nn(topology, sigm)
loss = []

for i in range(2500):
  pY = train(neural_network, X, Y, l2_cost, lr=0.01)
  if i % 25 == 0:
    loss.append(l2_cost[0](pY, Y))
    res = 50

    _x0 = np.linspace(-1.5, 1.5, res)
    _x1 = np.linspace(-1.5, 1.5, res)
    _Y = np.zeros((res, res))

    for i0, x0 in enumerate (_x0):
      for i1, x1 in enumerate(_x1):
        _Y[i0, i1] = train(neural_network, np.array([[x0, x1]]), Y, l2_cost, train = False)[0][0]
    plt.pcolormesh(_x0, _x1, _Y, cmap="coolwarm")
    plt.axis("equal")

    plt.scatter(X[Y[:,0] == 0, 0], X[Y[:,0] == 0, 1], c="skyblue")
    plt.scatter(X[Y[:,0] == 1, 0], X[Y[:,0] == 1, 1], c="salmon")

    clear_output(wait=True)
    plt.show()
    plt.plot(range(len(loss)), loss)
    plt.show()
    time.sleep(0.5)
Clasificación de nuestra RN y evolución de la pérdida

Puede encontrar un enlace al cuaderno de collab con todo el código aquí