Cómo programar una red neuronal desde 0 en Python

Share on linkedin
Share on twitter
Share on email

Las redes neuronales son unos algoritmos muy potentes en el mundo del Machine Learning. En este blog ya los hemos visto varias veces, desde cómo crear una red neuronal en R hasta la clasificación de imágenes con Keras. Pero, ¿cómo podría yo programar una red neuronal desde 0 en Python? En este post te lo explico. ¿Te suena interesante? ¡Pues vamos a ello!

Las redes neuronales están compuestas de neuronas, que a su vez se agrupan en capas: cada neurona de cada capa está conectada con todas las neuronas de la capa anterior. En cada neurona, se realizarán una serie de operaciones (que explicaremos más adelante) las cuales, al optimizar, conseguiremos que nuestra red aprenda. Vamos, que si vamos a programar una red neuronal desde 0 en Python, lo primero es programar las capas de neuronas. ¡Pues manos a la obra!

Creando las capas de neuronas

Para poder programar una capa de neuronas, primero debemos entender bien cómo funcionan. Básicamente una red neuronal funciona de la siguiente manera:

  1. Una capa recibe valores, llamados inputs. En la primera capa, esos valores vendrán definidos por los datos de entrada, mientras que el resto de capas recibirán el resultado de la capa anterior.
  2. Se realiza una suma ponderada todos los valores de entrada. Para hacer esa ponderación necesitamos una matriz de pesos, conocida como W. La matriz W tiene tantas filas como neuronas la capa anterior y tantas columnas como neuronas tiene esa capa.
  3. Al resultado de la suma ponderada anterior se le sumará otro parámetro, conocido como bias o, simplemente, b. En este caso, cada neurona tiene su propio bias, por lo que las dimensiones del vector bias será una columna y tantas filas como neuronas tiene esa capa.
  4. Por cuarto lugar tenemos una de las claves de las redes neuronales: la función de activación. Y es que, si te das cuenta, lo que tenemos hasta ahora no es más que una regresión lineal. Para evitar que toda la red neuronal se pueda reducir a una simple regresión lineal, al resultado de la suma del bias a la suma ponderada se le aplica una función, conocido como función de activación. El resultado de esta función será el resultado de la neurona.

Por tanto, para poder montar una capa de una red neuronal solo necesitamos saber el número de neuronas en la capa y el número de neuronas de la capa anterior. Con eso, podremos crear tanto W como b.

Para crear esta estructura vamos a crear una clase, que llamaremos capa. Además, vamos a inicializar los parámetros (b y W) con datos aleatorios. Para esto último usaremos la función trunconorm de la librería stats, ya que nos permite crear datos aleatorios dado un rango, media y desviación estándar, lo cual hará que a nuestra red le cueste menos arrancar. In [1]:

from scipy import stats

class capa():
  def __init__(self, n_neuronas_capa_anterior, n_neuronas, funcion_act):
    self.funcion_act = funcion_act
    self.b  = np.round(stats.truncnorm.rvs(-1, 1, loc=0, scale=1, size= n_neuronas).reshape(1,n_neuronas),3)
    self.W  = np.round(stats.truncnorm.rvs(-1, 1, loc=0, scale=1, size= n_neuronas * n_neuronas_capa_anterior).reshape(n_neuronas_capa_anterior,n_neuronas),3)

Con esto ya tendríamos definida la estructura de una capa. Sin duda alguna, la definición y uso de clases en Python es más sencilla que en R. Punto a para Python.

Dicho esto, vayamos a una cosa que hemos dejado en el tintero: las funciones de activación.

Funciones de Activación

Como he dicho antes, al resultado de la suma ponderada del input y el parámetro bias se aplica una función de activación, es decir, una transformación a los datos. El motivo es que, si no lo hiciéramos, cada neurona lo único que haría sería una transformación lineal de los datos, dando como resultado una función lineal normal.

¿Qué función usamos? Podríamos usar cualquier función de activación que haga que el resultado no sea lineal, pero generalmente se suelen usar dos: función sigmoide y función ReLu.

Función de activación: Función Sigmoide

La función sigmoide básicamente recibe un valor x y devuelve un valor entre 0 y 1. Esto hace que sea una función muy interesante, ya que indica la probabilidad de un estado. Por ejemplo, si usamos la función sigmoide en la última capa para un problema de clasificación entre dos clases, la función devolverá la probabilidad de pertenencia a un grupo. In [2]:

import numpy as np
import math
import matplotlib.pyplot as plt


sigmoid = (
  lambda x:1 / (1 + np.exp(-x)),
  lambda x:x * (1 - x)
  )

rango = np.linspace(-10,10).reshape([50,1])
datos_sigmoide = sigmoid[0](rango)
datos_sigmoide_derivada = sigmoid[1](rango)

#Cremos los graficos
fig, axes = plt.subplots(nrows=1, ncols=2, figsize =(15,5))
axes[0].plot(rango, datos_sigmoide)
axes[1].plot(rango, datos_sigmoide_derivada)
fig.tight_layout()

Función de activación: Función ReLu

La función ReLu es muy simple: para valores negativos, la función devuelve cero. Para valores positivos, la función devuelve el mismo valor. Pero, a pesar de ser tan simple, esta función es la función de activación más usada en el campo de las redes neuronales y deep learning. ¿El motivo? Pues precisamente porque es sencilla y porque evita el gradient vanish (más info aquí). In [3]:

def derivada_relu(x):
  x[x<=0] = 0
  x[x>0] = 1
  return x

relu = (
  lambda x: x * (x > 0),
  lambda x:derivada_relu(x)
  )

datos_relu = relu[0](rango)
datos_relu_derivada = relu[1](rango)


# Volvemos a definir rango que ha sido cambiado
rango = np.linspace(-10,10).reshape([50,1])

# Cremos los graficos
plt.cla()
fig, axes = plt.subplots(nrows=1, ncols=2, figsize =(15,5))
axes[0].plot(rango, datos_relu[:,0])
axes[1].plot(rango, datos_relu_derivada[:,0])
plt.show()

Bien, ya tenemos las capas y la función de activación. ¿Qué es lo próximo? Pues programar nuestra red neuronal usando Python. ¡Vamos a ello!

Programando una red neuronal en Python

Para crear una red neuronal, simplemente tendremos que indicar tres cosas: el número de capas que tiene la red, el número de neuronas en cada capa y la función de activación que se usará en cada una de las capas. Con eso y con lo que hemos programado hasta ahora ya podemos crear la estructura de nuestra red neuronal.

En nuestro caso, usaremos la red neuronal para solucionar un problema de clasificación de dos clases, para lo cual usaremos una red pequeña, de 4 capas que se compondrá de:

  • Una capa de entrada con dos neuronas, ya que usaremos dos variables.
  • Dos capas ocultas, una de 4 neuronas y otra de 8.
  • Una capa de salida, con una única neurona que predecirá la clase.

Asimismo, tenemos que definir qué función de activación se usará en cada capa. En nuestro caso, usaremos la función ReLu en todas las capas menos en la última, en la cual usaremos la función sigmoide. Es importante recordar que en la primera capa solo se reciben los datos, no se aplica una función ni nada.

Por otro lado, Python no permite crear una lista de funciones. Por eso, hemos definido las funciones relu y sigmoid como funciones ocultas usando lambda. In [4]:

# Numero de neuronas en cada capa. 
# El primer valor es el numero de columnas de la capa de entrada.
neuronas = [2,4,8,1] 

# Funciones de activacion usadas en cada capa. 
funciones_activacion = [relu,relu, sigmoid]

Con todo esto, ya podemos crear la estructura de nuestra red neuronal programada en Python. Lo haremos de forma iterativa e iremos guardando esta estructura en un nuevo objeto, llamado red_neuronal. In [5]:

red_neuronal = []

for paso in range(len(neuronas)-1):
  x = capa(neuronas[paso],neuronas[paso+1],funciones_activacion[paso])
  red_neuronal.append(x)

print(red_neuronal)
[<__main__.capa object at 0x000001B7A673D550>, <__main__.capa object at 0x000001B7A673DFD0>, <__main__.capa object at 0x000001B7A673D860>]

Con esto ya tenemos la estructura de nuestra red neuronal. Ahora solo quedarían dos pasos más: por un lado, conectar la red para que nos de una predicción y un error y, por el otro lado, ir propagando ese error hacia atrás para ir entrenando a nuestra red neuronal.

Haciendo que nuestra red neuronal prediga

Para que nuestra red neuronal prediga lo único que tenemos que hacer es definir los cáculos que tiene que seguir. Como he comentado anterirormente, son 3 los cálculos a seguir: multiplicar los valores de entrada por la matriz de pesos W y sumar el parámetro bias (b) y aplicar la función de activación.

Para multiplicar los valores de entrada por la matriz de pesos tenemos que hacer una multiplicación matricial. Veamos el ejemplo de la primera capa: In [6]:

X =  np.round(np.random.randn(20,2),3) # Ejemplo de vector de entrada

z = X @ red_neuronal[0].W

print(z[:10,:], X.shape, z.shape)
[[ 1.191768 -1.039061  1.0627   -0.57164 ]
 [ 0.341264  0.412747  1.27404  -0.336425]
 [ 0.218592 -0.431019 -0.133344 -0.046377]
 [ 0.24092  -0.24311   0.169692 -0.107519]
 [-1.104672  1.410984 -0.373584  0.420948]
 [-0.143616 -0.170053 -0.531184  0.140693]
 [ 0.49172  -0.532545  0.296708 -0.210606]
 [-0.779768  0.724851 -0.633884  0.363078]
 [-0.006408 -0.161554 -0.233908  0.043721]
 [ 0.469408 -0.394901  0.438176 -0.228647]] (20, 2) (20, 4)

Ahora, hay que sumar el parámetro bias (b) al resultado anterior de z. In [7]:

z = z + red_neuronal[0].b

print(z[:5,:])
[[ 1.878768e+00 -7.130610e-01  1.195700e+00 -7.946400e-01]
 [ 1.028264e+00  7.387470e-01  1.407040e+00 -5.594250e-01]
 [ 9.055920e-01 -1.050190e-01 -3.440000e-04 -2.693770e-01]
 [ 9.279200e-01  8.289000e-02  3.026920e-01 -3.305190e-01]
 [-4.176720e-01  1.736984e+00 -2.405840e-01  1.979480e-01]]

Ahora, habría que aplicar la función de activación de esa capa. In [8]:

a = red_neuronal[0].funcion_act[0](z)
a[:5,:]

Out[8]:

array([[ 1.878768, -0.      ,  1.1957  , -0.      ],
       [ 1.028264,  0.738747,  1.40704 , -0.      ],
       [ 0.905592, -0.      , -0.      , -0.      ],
       [ 0.92792 ,  0.08289 ,  0.302692, -0.      ],
       [-0.      ,  1.736984, -0.      ,  0.197948]])

Con esto, tendríamos el resultado de la primera capa, que a su vez es la entrada para la segunda capa y así hasta la última. Por tanto, queda bastante claro que todo esto lo podemos definir de forma iterativa dentro de un bucle. In [9]:

output = [X]

for num_capa in range(len(red_neuronal)):
  z = output[-1] @ red_neuronal[num_capa].W + red_neuronal[num_capa].b
  a = red_neuronal[num_capa].funcion_act[0](z)
  output.append(a)

print(output[-1])
[[0.61015892]
 [0.45732425]
 [0.55470963]
 [0.5508974 ]
 [0.38760538]
 [0.49644835]
 [0.57029934]
 [0.41200621]
 [0.51346008]
 [0.56647143]
 [0.42297515]
 [0.42578556]
 [0.41394167]
 [0.45673122]
 [0.50093812]
 [0.41234362]
 [0.5878003 ]
 [0.43970666]
 [0.59577249]
 [0.58614669]]

Así, tendríamos la estimación para cada una de las clases de este ejercicio de prueba. Como es la primera ronda, la red no ha entrenado nada, por lo que el resultado es aleatorio. Por tanto, solo quedaría una cosa: entrenar a nuestra red neuronal programada en Python. ¡vamos a ello!

Entrenar tu red neuronal

Creando la función de coste

Para poder entrenar la red neuronal lo primero que debemos hacer es calcular cuánto ha fallado. Para ello usaremos uno de los estimadores más típicos en el mundo del machine learning: el error cuadrático medio (MSE).

Calcular el error cuadrático medio es algo bastante simple: a cada valor predicho le restas el valor real, lo elevas al cuadrado, haces la suma ponderada y calculas su raíz. Además, como hemos hecho anteriormente aprovecharemos para que esta misma función nos devuelva la derivada dela función de coste, la cual nos será útil en el paso de backpropagation. In [10]:

def mse(Ypredich, Yreal):

  # Calculamos el error
  x = (np.array(Ypredich) - np.array(Yreal)) ** 2
  x = np.mean(x)

  # Calculamos la derivada de la funcion
  y = np.array(Ypredich) - np.array(Yreal)
  return (x,y)

Con esto, vamos a «inventarnos» unas clases (0 o 1) para los valores que nuestra red neuronal ha predicho antes. Así, calcularemos el error cuadrático medio. In [11]:

from random import shuffle

Y = [0] * 10 + [1] * 10
shuffle(Y)
Y = np.array(Y).reshape(len(Y),1)

mse(output[-1], Y)[0]

Out[11]:

0.27785863420024487

Ahora que ya tenemos el error calculado, tenemos que irlo propagando hacia atrás para ir ajustando los parámetros. Haciendo esto de forma iterativa, nuestra red neuronal irá mejorando sus predicciones, es decir, disminuirá su error. Vamos, que así es como se entrena a una red neuronal.

Backpropagation y gradient descent: entrenando a nuestra red neuronal

Gradient descent: optimizando los parámetros

Con el algoritmo de gradient descent optimizaremos los parámetros para así ir mejorando los resultados de nuestra red. Si volvemos atrás, los parámetros los hemos inicializado de forma aleatoria. Por eso, eso poco probable que sus valores sean los mejores para nuestra red neuronal. Supongamos, por ejemplo, que nuestros parámetros se han inicializado en esta posición.

Comienzo aleatorio de los parámetros
Imagen de Andrew Ng

Como véis, los valores están lejos del valor óptimo (el azul oscuro más abajo), por lo que deberíamos hacer que nuestro parámetro llegue a allí. Pero, ¿cómo lo hacemos?

Para ello, usaremos gradient descent. Este algoritmo utiliza el error en el punto en el que nos encontramos y calcula las derivadas parciales en dicho punto. Esto nos devuelve el vector gradiente, es decir, un vector de direcciones hacia donde el error se incrementa. Por tanto, si usamos el inverso de ese valor, iremos hacia abajo. En definitiva, gradient descent calcula la inversa del gradiente para saber qué valores deben tomar los hiperparámetros.

Cuánto nos movamos hacia abajo dependerá de otro hiperparámetro: el learning rate. Este hiperparámetro no se suele optimizar, aunque si que hay que tener en cuenta dos cuestiones:

  • Si el valor del learning rate es muy bajo, el algoritmo tardará en aprender, porque cada paso será muy corto.
  • Si el learning rate es muy grande, puede que te pases del valor óptimo, por lo que no llegues a encontrar el valor óptimo de los parámetros.

Para evitar esto se pueden aplicar varias técnicas, como la de disminuir el learning rate a cada paso que demos, por ejemplo. En nuestro caso, no nos vamos a complicar y dejaremos un learning rate fijo.

Con gradient descent a cada iteración nuestros parámetros se irán acercando a un valor óptimo, hasta que lleguen a un punto óptimo, a partir del cual nuestra red dejará de aprender.

Proceso de optimización mediante gradient descent
Imagen de Andrew Ng

Esto suena muy bien, pero como he dicho, gradient descent utiliza el error en el punto. Este error ya lo tenemos para nuestro vector de salida, pero, ¿que pasa en el resto de capas? Para eso, usamos backpropagation.

Backpropagation: calculando el error en cada capa

En nuestra red neuronal todos los pasos previos a la neurona de salida tendrán un impacto en el mismo: el error de la primera capa influirá en el error de la segunda capa, los de la primera y segunda influirán en los de la tercera y así sucesivamente.

Por tanto, la única manera de calcular el error de cada neurona en cada capa es haciendo el proceso inverso: primero calculamos el error de la última capa, con lo que podremos calcular el error de la capa anterior y así hasta completar todo el proceso.

Además, este es un proceso eficiente, ya que podemos aprovechar la propagación hacia atrás para ir ajustando los parámetros W y b mediante gradient descent. En cualquier caso, para calcular el descenso del gradiente necesitamos aplicar derivadas, entre las que se encuentra las derivadas de la función de coste. Por eso mismo, al definir las funciones de activación hemos definido también sus derivadas, ya que eso nos ahorrará mucho el proceso.

Dicho esto, veamos cómo funcionan gradient descent y backpropagation. Para ello,vamos a ver qué valores tienen inicialmente nuestros parámetros W y b en una capa cualquiera, como por ejemplo la última. In [12]:

red_neuronal[-1].b
red_neuronal[-1].W

Out[12]:

array([[ 0.583],
       [-0.692],
       [-0.15 ],
       [-0.69 ],
       [-0.547],
       [-0.316],
       [-0.581],
       [ 0.369]])

Como desconocemos el valor óptimo de estos parámetros, los hemos inicializado de forma aleatoria. Por tanto, en cada ronda estos valores se irán cambiando pooco a poco. Para ello, lo primero que debemos hacer es transmitir el error hacia atrás. Como estamos trabajando de atrás hacia adelante (o de derecha a izquierda si visualizamos la red), partiremos de la última capa e iremos hacia adelante.

El error lo calculamos como la derivada de la función de coste sobre el resultado de la capa siguiente por la derivada de la función de activación. En nuestro caso, el resultado del último valor está en la capa -1, mientras que la capa que vamos a optimizar es la anteúltima (posición -2). Además, como hemos definido las funciones como un par de funciones, simplemente tendremos que indicar el resultado de la función en la posición [1] en ambos casos. In [13]:

# Backprop en la ultima capa
a = output[-1]
x = mse(a,Y)[1] * red_neuronal[-2].funcion_act[1](a)

x

Out[13]:

array([[-0.38984108],
       [-0.54267575],
       [ 0.55470963],
       [ 0.5508974 ],
       [ 0.38760538],
       [ 0.49644835],
       [ 0.57029934],
       [-0.58799379],
       [-0.48653992],
       [ 0.56647143],
       [-0.57702485],
       [-0.57421444],
       [-0.58605833],
       [ 0.45673122],
       [-0.49906188],
       [-0.58765638],
       [ 0.5878003 ],
       [ 0.43970666],
       [ 0.59577249],
       [-0.41385331]])

Si hiciéramos esto en cada capa, iríamos propagando el error generado por la estimación de la red neuronal. Sin embargo, propagar el error por si mismo no hace nada, sino que ahora tenemos que usar ese error para optimizar los valores de los parámetros mediante gradient descent. Para ello, tenemos calcular las derivadas en el punto de los parámetros b y W y restar esos valores a los valores anteriores de b y W. In [14]:

red_neuronal[-1].b = red_neuronal[-1].b - x.mean() * 0.01
red_neuronal[-1].W = red_neuronal[-1].W - (output[-1].T @ x) * 0.01

red_neuronal[-1].b
red_neuronal[-1].W

Out[14]:

array([[ 0.58338478],
       [-0.69161522],
       [-0.14961522],
       [-0.68961522],
       [-0.54661522],
       [-0.31561522],
       [-0.58061522],
       [ 0.36938478]])

Con esto ya habríamos actualizado los parámetros de W y b en la última capa. Ahora bien, para calcular el error de la siguiente capa tendríamos que multiplicar matricialmente el error de esta capa (x) por los pesos de la misma, para así saber cuánto de ese error corresponde a cada neurona de la capa. Pero claro, ya hemos actualizado los pesos, por lo que eso fastidiaría el aprendizaje, ¿no?

Efectivamente, eso nos generaría un problema y tendríamos que esperar una iteración más para aplicar cambios. Sin embargo, tiene solución y muy fácil. Para evitar ese problema lo que hacemos es guardar los valores de W antes de actualizar en una variable «temporal», que en mi caso he llamado W_temp. De esta manera, somos capaces de calcular el error correspondiente a cada neurona y actualizar los valores de los parámetros todo en una misma iteración.

Si ponemos todo esto junto, la fórmula de backpropagation y gradient descent queda de la siguiente manera: In [15]:

# Definimos el learning rate
lr = 0.05

# Creamos el indice inverso para ir de derecha a izquierda
back = list(range(len(output)-1))
back.reverse()

# Creamos el vector delta donde meteremos los errores en cada capa
delta = []

for capa in back:
  # Backprop #

  # Guardamos los resultados de la ultima capa antes de usar backprop para poder usarlas en gradient descent
  a = output[capa+1][1]

  # Backprop en la ultima capa 
  if capa == back[0]:
    x = mse(a,Y)[1] * red_neuronal[capa].funcion_act[1](a)
    delta.append(x)

  # Backprop en el resto de capas 
  else:
    x = delta[-1] @ W_temp * red_neuronal[capa].funcion_act[1](a)
    delta.append(x)

  # Guardamos los valores de W para poder usarlos en la iteracion siguiente
  W_temp = red_neuronal[capa].W.transpose()

  # Gradient Descent #

  # Ajustamos los valores de los parametros de la capa
  red_neuronal[capa].b = red_neuronal[capa].b - delta[-1].mean() * lr
  red_neuronal[capa].W = red_neuronal[capa].W - (output[capa].T @ delta[-1]) * lr


print('MSE: ' + str(mse(output[-1],Y)[0]) )
print('Estimacion: ' + str(output[-1]) )
MSE: 0.5
Estimacion: [[1.]
 [1.]
 [1.]
 [1.]
 [1.]
 [1.]
 [1.]
 [1.]
 [1.]
 [1.]
 [1.]
 [1.]
 [1.]
 [1.]
 [1.]
 [1.]
 [1.]
 [1.]
 [1.]
 [1.]]

Con esto ya tendríamos aplicado backpropagation y gradient descent. Así que, ¿qué te parece ver cómo funciona nuestra red neuronal programada en Python con un caso práctico? ¡Vamos a ello!

Caso práctico: poniendo a prueba a nuestra red neuronal

Definición del problema: clasificación de puntos

Vamos a poner a prueba a nuestra red con un problema bastante sencillo: clasificar puntos de dos nubes de puntos. Para ello, lo primero que vamos a hacer es crear una función que nos devuelva puntos aleatorios al rededor de un círculo imaginario de radio R. In [16]:

import random

def circulo(num_datos = 100,R = 1, minimo = 0,maximo= 1):
  pi = math.pi
  r = R * np.sqrt(stats.truncnorm.rvs(minimo, maximo, size= num_datos)) * 10
  theta = stats.truncnorm.rvs(minimo, maximo, size= num_datos) * 2 * pi *10

  x = np.cos(theta) * r
  y = np.sin(theta) * r

  y = y.reshape((num_datos,1))
  x = x.reshape((num_datos,1))

  #Vamos a reducir el numero de elementos para que no cause un Overflow
  x = np.round(x,3)
  y = np.round(y,3)

  df = np.column_stack([x,y])
  return(df)

Ahora, crearemos dos sets de datos aleatorios, cada uno de 150 puntos y con radios diferentes. La idea de hacer que los datos se creen de forma aleatoria es que puedan solaparse, de tal manera que a la red neuronal le cueste un poco y el resultado no sea perfecto. In [17]:

datos_1 = circulo(num_datos = 150, R = 2)
datos_2 = circulo(num_datos = 150, R = 0.5)
X = np.concatenate([datos_1,datos_2])
X = np.round(X,3)

Y = [0] * 150 + [1] * 150
Y = np.array(Y).reshape(len(Y),1)

Con esto ya tendríamos nuestros datos de entrada (X) y sus correspondientes etiquetas (Y). Teniendo esto en cuenta, visualicemos cómo es el problema que debe resolver nuestra red neuronal: In [18]:

plt.cla()
plt.scatter(X[0:150,0],X[0:150,1], c = "b")
plt.scatter(X[150:300,0],X[150:300,1], c = "r")
plt.show()
Problema de clasificación a resolver con nuestra red neuronal programada en Python

Entrenamiento de nuestra red neuronal

Lo primero de todo, vamos a crear funciones a partir del código que hemos generado anteriormente. Esto nos facilitirará las cosas. In [19]:

def entrenamiento(X,Y, red_neuronal, lr = 0.01):

  # Output guardara el resultado de cada capa
  # En la capa 1, el resultado es el valor de entrada
  output = [X]

  for num_capa in range(len(red_neuronal)):
    z = output[-1] @ red_neuronal[num_capa].W + red_neuronal[num_capa].b

    a = red_neuronal[num_capa].funcion_act[0](z)

    # Incluimos el resultado de la capa a output
    output.append(a)

  # Backpropagation

  back = list(range(len(output)-1))
  back.reverse()

  # Guardaremos el error de la capa en delta  
  delta = []

  for capa in back:
    # Backprop #delta

    a = output[capa+1]

    if capa == back[0]:
      x = mse(a,Y)[1] * red_neuronal[capa].funcion_act[1](a)
      delta.append(x)

    else:
      x = delta[-1] @ W_temp * red_neuronal[capa].funcion_act[1](a)
      delta.append(x)

    W_temp = red_neuronal[capa].W.transpose()

    # Gradient Descent #
    red_neuronal[capa].b = red_neuronal[capa].b - np.mean(delta[-1], axis = 0, keepdims = True) * lr
    red_neuronal[capa].W = red_neuronal[capa].W - output[capa].transpose() @ delta[-1] * lr

  return output[-1]

¡Ya tenemos nuestra función de red neuronal funcionando! Ahora, simplemente tenemos que indicar los los parámetros y el número de rondas y esperar para ver cómo va aprendiendo nuestra red neuronal y cómo de bien se le da con el problema que hemos planteado. ¡Vamos a ello!

Para ello, volvemos a crear la clase neurona y definir nuestra red neuronal, para así reinicializar todo y no depender del código escrito anteriormente. In [26]:

class capa():
  def __init__(self, n_neuronas_capa_anterior, n_neuronas, funcion_act):
    self.funcion_act = funcion_act
    self.b  = np.round(stats.truncnorm.rvs(-1, 1, loc=0, scale=1, size= n_neuronas).reshape(1,n_neuronas),3)
    self.W  = np.round(stats.truncnorm.rvs(-1, 1, loc=0, scale=1, size= n_neuronas * n_neuronas_capa_anterior).reshape(n_neuronas_capa_anterior,n_neuronas),3)

neuronas = [2,4,8,1] 
funciones_activacion = [relu,relu, sigmoid]
red_neuronal = []

for paso in list(range(len(neuronas)-1)):
  x = capa(neuronas[paso],neuronas[paso+1],funciones_activacion[paso])
  red_neuronal.append(x)    

Ahora que ya tenemos la red entrenada, vamos a usar la función de entrenamiento. Además, vamos a ir guardando tanto las predicciones que hace como el error que está cometiendo. De esta manera podremos visualizar cómo ha entrenado nuestra red. In [27]:

error = []
predicciones = []

for epoch in range(0,1000):
  ronda = entrenamiento(X = X ,Y = Y ,red_neuronal = red_neuronal, lr = 0.001)
  predicciones.append(ronda)
  temp = mse(np.round(predicciones[-1]),Y)[0]
  error.append(temp)

Ningún error, así que parece que todo ha ido bien. Vamos a ver cómo ha mejorado el error de la red en cada iteración: In [28]:

epoch = list(range(0,1000))
plt.plot(epoch, error)

Out[28]:

[<matplotlib.lines.Line2D at 0x1b7a6a9a9e8>]
Evolución del  error de la red neuronal programada en Python

¡Nuestra red neuronal ha entrenado! De hecho, nuestra red neuronal programada en Python a partido de un error del 0.5, es decir, un respuesta completamente aleatoria, a un error de tan solo 0.12 en el último epoch.

De hecho vemos que desde el epoch 900 la red neuronal no ha mejorado su resultado. Esto se debe a que sus parámetros ya están optimizados, por lo que no puede aprender más.

Conclusión

Hay que decir que, independientemente si eres de R o Python, en ningún caso cuando trabajes con redes neuronales tendrás que programar una red neuronal desde 0 como hemos hecho aquí. Seguramente, usarás librerías como Tensorflow y Keras, como hicimos en los posts de .

Sin duda alguna programar una red neuronal en Python desde 0 te ayuda a afianzar los conceptos de las redes neuronales y a tener que pelearte con Python y descubrir formas eficientes de hacer las cosas.

En mi caso, para hacer este post he partido del post que hice de cómo programar una red neuronal en R desde 0. Ahora que he trabajado tanto con R como con Python, sin duda alguna veo que ambos tienen cosas buenas y otras… no tanto.

Por ejemplo, programar una red neuronal desde 0 en Python es más sencillo debido a la propia sintaxis del lenguaje, ya que en R, al trabajar con listas, había mucho corchete y era un poco complicado. Además, los temas más puros de programación, como la creación de una clase, está mucho más desarrollado en Python que en R.

Sin embargo, por otro lado, trabajar con matrices en R me ha sido bastante más sencillo que hacerlo en Python. Esto es normal, ya que al fin y al cabo R se basa en matrices y vectores y Python… pues Numpy sí, pero lo demás no.

En cualquier caso, tanto si eres de R o Python como si eres de los dos, yo te recomiendo que intentes programar tu propia red neuronal desde 0. Quizás sea un poco frustrante a veces, pero merece mucho la pena 🙂