RNN desde cero | Construyendo modelo RNN en Python

Contenidos

Introducción

Los humanos no reinician su comprensión del lenguaje cada vez que escuchamos una oración. Dado un post, captamos el contexto en función de nuestra comprensión previa de esas palabras. Una de las características definitorias que poseemos es nuestra memoria (o poder de retención).

¿Puede un algoritmo replicar esto? La primera técnica que me viene a la mente es una red neuronal (NN). Pero, tristemente, las NN tradicionales no pueden hacer esto. Tome un ejemplo de querer predecir lo que viene a continuación en un video. Una red neuronal tradicional tendrá dificultades para generar resultados precisos.

Ahí es donde entra en juego el concepto de redes neuronales recurrentes (RNN). Los RNN se han vuelto extremadamente populares en el espacio del aprendizaje profundo, lo que hace que aprenderlos sea aún más imperativo. Algunas aplicaciones del mundo real de RNN incluyen:

  • Acreditación de voz
  • Máquina traductora
  • Composición musical
  • Acreditación de escritura a mano
  • Aprendizaje gramatical

fundamentos de la red neuronalEn este post, primero revisaremos rápidamente los componentes centrales de un modelo RNN típico. Posteriormente configuraremos la declaración del problema que en conclusión resolveremos implementando un modelo RNN desde cero en Python.

Siempre podemos aprovechar las bibliotecas de Python de alto nivel para codificar un RNN. Entonces, ¿por qué codificarlo desde cero? Creo firmemente que la mejor manera de aprender y verdaderamente arraigar un concepto es aprenderlo desde cero. Y eso es lo que mostraré en este tutorial.

Este post asume una comprensión básica de las redes neuronales recurrentes. En caso de que necesite un repaso rápido o esté buscando aprender los conceptos básicos de RNN, le recomiendo leer primero los posts a continuación:

Tabla de contenido

  • Flashback: un resumen de los conceptos de redes neuronales recurrentes
  • Predicción de secuencia usando RNN
  • Construyendo un modelo RNN usando Python

Flashback: un resumen de los conceptos de redes neuronales recurrentes

Recapitulemos rápidamente los conceptos básicos detrás de las redes neuronales recurrentes.

Haremos esto usando un ejemplo de datos de secuencia, digamos las acciones de una compañía en particular. Un modelo de aprendizaje automático simple, o una red neuronal artificial, puede aprender a predecir el precio de las acciones en función de una serie de características, como el volumen de las acciones, el valor de apertura, etc. Aparte de estas, el precio además depende de cómo la acción se comportó en las fays y semanas anteriores. Para un comerciante, estos datos históricos son en realidad un factor decisivo importante para hacer predicciones.

En las redes neuronales de alimentación directa convencionales, todos los casos de prueba se consideran independientes. ¿Puede ver que eso no encaja bien al predecir los precios de las acciones? El modelo NN no consideraría los valores del precio de las acciones anteriores, ¡no es una gran idea!

Hay otro concepto en el que podemos apoyarnos cuando nos enfrentamos a datos sensibles al tiempo: las redes neuronales recurrentes (RNN).

Un RNN típico se ve así:

Esto puede parecer intimidante al principio. Pero una vez que lo desarrollamos, las cosas comienzan a verse mucho más simples:

Ahora nos resulta más fácil visualizar cómo estas redes están considerando la tendencia de los precios de las acciones. Esto nos ayuda a predecir los precios del día. Aquí, cada predicción en el tiempo t (h_t) depende de todas las predicciones anteriores y de la información obtenida de ellas. Bastante sencillo, ¿verdad?

Los RNN pueden solucionar nuestro propósito de manejo de secuencias en gran medida, pero no del todo.

El texto es otro buen ejemplo de datos de secuencia. Ser capaz de predecir qué palabra o frase viene después de un texto determinado podría ser una ventaja muy útil. Queremos que nuestros modelos escribir sonetos de Shakespeare!

Ahora, las enfermeras registradas son excelentes cuando se trata de un contexto que es de naturaleza corta o pequeña. Pero para poder construir una historia y recordarla, nuestros modelos deben poder comprender el contexto detrás de las secuencias, como un cerebro humano.

Predicción de secuencia usando RNN

En este post, trabajaremos en un obstáculo de predicción de secuencia usando RNN. Una de las tareas más simples para esto es el pronóstico de ondas sinusoidales. La secuencia contiene una tendencia visible y es fácil de solucionar usando heurística. Así es como se ve una onda sinusoidal:

Primero diseñaremos una red neuronal recurrente desde cero para solucionar este problema. Nuestro modelo RNN además debería poder generalizarse bien para que podamos aplicarlo en otros problemas de secuencia.

Formularemos nuestro problema de esta manera: dada una secuencia de 50 números que pertenecen a una onda sinusoidal, predice el número 51 de la serie. ¡Es hora de encender su computadora portátil Jupyter (o su IDE de elección)!

Codificación de RNN usando Python

Paso 0: preparación de datos

Ah, el primer paso inevitable en cualquier proyecto de ciencia de datos: preparar los datos antes de hacer cualquier otra cosa.

¿Cómo espera nuestro modelo de red que sean los datos? Aceptaría una sola secuencia de longitud 50 como entrada. Entonces, la forma de los datos de entrada será:

(number_of_records x length_of_sequence x types_of_sequences)

Aquí, types_of_sequences es 1, debido a que solo tenemos un tipo de secuencia: la onda sinusoidal.

Por otra parte, la salida tendría solo un valor para cada registro. Desde luego, este será el valor 51 en la secuencia de entrada. Entonces su forma sería:

(number_of_records x types_of_sequences) #where types_of_sequences is 1

Vamos a sumergirnos en el código. Primero, importe las bibliotecas indispensables:

%pylab inline

import math

Para crear una onda sinusoidal como datos, usaremos la función sinusoidal de Python Matemáticas Biblioteca:

sin_wave = np.array([math.sin(x) for x in np.arange(200)])

Visualizando la onda sinusoidal que acabamos de generar:

plt.plot(sin_wave[:50])

Crearemos los datos ahora en el siguiente bloque de código:

X = []
Y = []

seq_len = 50
num_records = len(sin_wave) - seq_len

for i in range(num_records - 50):
    X.append(sin_wave[i:i+seq_len])
    Y.append(sin_wave[i+seq_len])
    
X = np.array(X)
X = np.expand_dims(X, axis=2)

Y = np.array(Y)
Y = np.expand_dims(Y, axis=1)

Imprima la forma de los datos:

Tenga en cuenta que hicimos un bucle para (num_records – 50) debido a que queremos reservar 50 registros como nuestros datos de validación. Podemos crear estos datos de validación ahora:

X_val = []
Y_val = []

for i in range(num_records - 50, num_records):
    X_val.append(sin_wave[i:i+seq_len])
    Y_val.append(sin_wave[i+seq_len])
    
X_val = np.array(X_val)
X_val = np.expand_dims(X_val, axis=2)

Y_val = np.array(Y_val)
Y_val = np.expand_dims(Y_val, axis=1)

Paso 1: Cree la arquitectura para nuestro modelo RNN

Nuestra siguiente tarea es establecer todas las variables y funciones indispensables que usaremos en el modelo RNN. Nuestro modelo tomará la secuencia de entrada, la procesará por medio de una capa oculta de 100 unidades y producirá una salida de valor único:

learning_rate = 0.0001    
nepoch = 25               
T = 50                   # length of sequence
hidden_dim = 100         
output_dim = 1

bptt_truncate = 5
min_clip_value = -10
max_clip_value = 10

Posteriormente definiremos los pesos de la red:

U = np.random.uniform(0, 1, (hidden_dim, T))
W = np.random.uniform(0, 1, (hidden_dim, hidden_dim))
V = np.random.uniform(0, 1, (output_dim, hidden_dim))

Aquí,

  • U es la matriz de ponderaciones para las ponderaciones entre capas de entrada y ocultas
  • V es la matriz de pesos para pesos entre capas ocultas y de salida
  • W es la matriz de peso para pesos compartidos en la capa RNN (capa oculta)

En resumen, definiremos la función de activación, sigmoidea, que se utilizará en la capa oculta:

def sigmoid(x):
    return 1 / (1 + np.exp(-x))

Paso 2: entrenar el modelo

Ahora que hemos definido nuestro modelo, en conclusión podemos continuar con el entrenamiento en nuestros datos de secuencia. Podemos subdividir el procedimiento de capacitación en pasos más pequeños, a saber:

Paso 2.1: Verifique la pérdida de datos de entrenamiento
Paso 2.1.1: Pase hacia adelante
Paso 2.1.2: Calcular el error
Paso 2.2: Verifique la pérdida de datos de validación
Paso 2.2.1: Pase hacia adelante
Paso 2.2.2: Calcular el error
Paso 2.3: Comience el entrenamiento real
Paso 2.3.1: Pase hacia adelante
Paso 2.3.2: Error de retropropagación
Paso 2.3.3: Actualice los pesos

Necesitamos repetir estos pasos hasta la convergencia. Si el modelo comienza a sobreajustarse, ¡deténgase! O simplemente predefinir el número de épocas.

Paso 2.1: Verifique la pérdida de datos de entrenamiento

Haremos una pasada hacia adelante por medio de nuestro modelo RNN y calcularemos el error al cuadrado de las predicciones para todos los registros con el fin de obtener el valor de pérdida.

for epoch in range(nepoch):
    # check loss on train
    loss = 0.0
    
    # do a forward pass to get prediction
    for i in range(Y.shape[0]):
        x, y = X[i], Y[i]                    # get input, output values of each record
        prev_s = np.zeros((hidden_dim, 1))   # here, prev-s is the value of the previous activation of hidden layer; which is initialized as all zeroes
        for t in range(T):
            new_input = np.zeros(x.shape)    # we then do a forward pass for every timestep in the sequence
            new_input[t] = x[t]              # for this, we establece a single input for that timestep
            mulu = np.dot(U, new_input)
            mulw = np.dot(W, prev_s)
            add = mulw + mulu
            s = sigmoid(add)
            mulv = np.dot(V, s)
            prev_s = s

    # calculate error 
        loss_per_record = (y - mulv)**2 / 2
        loss += loss_per_record
    loss = loss / float(y.shape[0])

Paso 2.2: Verifique la pérdida de datos de validación

Haremos lo mismo para calcular la pérdida en los datos de validación (en el mismo ciclo):

    # check loss on val
    val_loss = 0.0
    for i in range(Y_val.shape[0]):
        x, y = X_val[i], Y_val[i]
        prev_s = np.zeros((hidden_dim, 1))
        for t in range(T):
            new_input = np.zeros(x.shape)
            new_input[t] = x[t]
            mulu = np.dot(U, new_input)
            mulw = np.dot(W, prev_s)
            add = mulw + mulu
            s = sigmoid(add)
            mulv = np.dot(V, s)
            prev_s = s

        loss_per_record = (y - mulv)**2 / 2
        val_loss += loss_per_record
    val_loss = val_loss / float(y.shape[0])

    print('Epoch: ', epoch + 1, ', Loss: ', loss, ', Val Loss: ', val_loss)

Debería obtener el siguiente resultado:

Epoch:  1 , Loss:  [[101185.61756671]] , Val Loss:  [[50591.0340148]]
...
...

Paso 2.3: Comience el entrenamiento real

Ahora comenzaremos con la capacitación real de la red. En este, primero haremos un pase hacia adelante para calcular los errores y un pase hacia atrás para calcular los gradientes y actualizarlos. Déjame mostrarte estos paso a paso para que puedas visualizar cómo funciona en tu mente.

Paso 2.3.1: Pase hacia adelante

En el pase adelantado:

  • Primero multiplicamos la entrada con los pesos entre la entrada y las capas ocultas.
  • Agregue esto con la multiplicación de pesos en la capa RNN. Esto se debe a que queremos capturar el conocimiento del paso de tiempo anterior.
  • Pasarlo por medio de una función de activación sigmoidea.
  • Multiplique esto con los pesos entre las capas ocultas y de salida.
  • En la capa de salida, tenemos una activación lineal de los valores, por lo que no pasamos explícitamente el valor por medio de una capa de activación.
  • Guarde el estado en la capa actual y además el estado en el paso de tiempo anterior en un diccionario

Aquí está el código para realizar un pase hacia adelante (tenga en cuenta que es una continuación del ciclo anterior):

    # train model
    for i in range(Y.shape[0]):
        x, y = X[i], Y[i]
    
        layers = []
        prev_s = np.zeros((hidden_dim, 1))
        dU = np.zeros(U.shape)
        dV = np.zeros(V.shape)
        dW = np.zeros(W.shape)
        
        dU_t = np.zeros(U.shape)
        dV_t = np.zeros(V.shape)
        dW_t = np.zeros(W.shape)
        
        dU_i = np.zeros(U.shape)
        dW_i = np.zeros(W.shape)
        
        # forward pass
        for t in range(T):
            new_input = np.zeros(x.shape)
            new_input[t] = x[t]
            mulu = np.dot(U, new_input)
            mulw = np.dot(W, prev_s)
            add = mulw + mulu
            s = sigmoid(add)
            mulv = np.dot(V, s)
            layers.append({'s':s, 'prev_s':prev_s})
            prev_s = s

Paso 2.3.2: Error de retropropagación

Después del paso de propagación hacia adelante, calculamos los gradientes en cada capa y propagamos hacia atrás los errores. Usaremos la propagación hacia atrás truncada a través del tiempo (TBPTT), en lugar de la retropropagación de vainilla. Puede parecer complejo, pero en realidad es bastante sencillo.

La diferencia central en BPTT versus backprop es que el paso de retropropagación se realiza para todos los pasos de tiempo en la capa RNN. Entonces, si la longitud de nuestra secuencia es 50, retropropagaremos todos los pasos de tiempo anteriores al paso de tiempo actual.

Si ha adivinado correctamente, BPTT parece muy costoso computacionalmente. Entonces, en lugar de propagar hacia atrás por medio de todos los pasos de tiempo anteriores, realizamos la propagación hacia atrás hasta x pasos de tiempo para ahorrar energía computacional. Considere esto ideológicamente semejante al descenso de gradiente estocástico, donde incluimos un lote de puntos de datos en lugar de todos los puntos de datos.

Aquí está el código para propagar los errores hacia atrás:

        # derivative of pred
        dmulv = (mulv - y)
        
        # backward pass
        for t in range(T):
            dV_t = np.dot(dmulv, np.transpose(layers[t]['s']))
            dsv = np.dot(np.transpose(V), dmulv)
            
            ds = dsv
            dadd = add * (1 - add) * ds
            
            dmulw = dadd * np.ones_like(mulw)

            dprev_s = np.dot(np.transpose(W), dmulw)


            for i in range(t-1, max(-1, t-bptt_truncate-1), -1):
                ds = dsv + dprev_s
                dadd = add * (1 - add) * ds

                dmulw = dadd * np.ones_like(mulw)
                dmulu = dadd * np.ones_like(mulu)

                dW_i = np.dot(W, layers[t]['prev_s'])
                dprev_s = np.dot(np.transpose(W), dmulw)

                new_input = np.zeros(x.shape)
                new_input[t] = x[t]
                dU_i = np.dot(U, new_input)
                dx = np.dot(np.transpose(U), dmulu)

                dU_t += dU_i
                dW_t += dW_i
                
            dV += dV_t
            dU += dU_t
            dW += dW_t

Paso 2.3.3: Actualice los pesos

Por último, actualizamos los pesos con los gradientes de pesos calculados. Una cosa que debemos prestar atención es que los gradientes tienden a explotar si no los mantiene bajo control. Este es un tema fundamental en el entrenamiento de redes neuronales, llamado problema del gradiente explosivo. Por lo tanto tenemos que sujetarlos en un rango para que no exploten. Podemos hacerlo asi

            if dU.max() > max_clip_value:
                dU[dU > max_clip_value] = max_clip_value
            if dV.max() > max_clip_value:
                dV[dV > max_clip_value] = max_clip_value
            if dW.max() > max_clip_value:
                dW[dW > max_clip_value] = max_clip_value
                
            
            if dU.min() < min_clip_value:
                dU[dU < min_clip_value] = min_clip_value
            if dV.min() < min_clip_value:
                dV[dV < min_clip_value] = min_clip_value
            if dW.min() < min_clip_value:
                dW[dW < min_clip_value] = min_clip_value
        
        # update
        U -= learning_rate * dU
        V -= learning_rate * dV
        W -= learning_rate * dW

Al entrenar el modelo anterior, obtenemos este resultado:

Epoch:  1 , Loss:  [[101185.61756671]] , Val Loss:  [[50591.0340148]]
Epoch:  2 , Loss:  [[61205.46869629]] , Val Loss:  [[30601.34535365]]
Epoch:  3 , Loss:  [[31225.3198258]] , Val Loss:  [[15611.65669247]]
Epoch:  4 , Loss:  [[11245.17049551]] , Val Loss:  [[5621.96780111]]
Epoch:  5 , Loss:  [[1264.5157739]] , Val Loss:  [[632.02563908]]
Epoch:  6 , Loss:  [[20.15654115]] , Val Loss:  [[10.05477285]]
Epoch:  7 , Loss:  [[17.13622839]] , Val Loss:  [[8.55190426]]
Epoch:  8 , Loss:  [[17.38870495]] , Val Loss:  [[8.68196484]]
Epoch:  9 , Loss:  [[17.181681]] , Val Loss:  [[8.57837827]]
Epoch:  10 , Loss:  [[17.31275313]] , Val Loss:  [[8.64199652]]
Epoch:  11 , Loss:  [[17.12960034]] , Val Loss:  [[8.54768294]]
Epoch:  12 , Loss:  [[17.09020065]] , Val Loss:  [[8.52993502]]
Epoch:  13 , Loss:  [[17.17370113]] , Val Loss:  [[8.57517454]]
Epoch:  14 , Loss:  [[17.04906914]] , Val Loss:  [[8.50658127]]
Epoch:  15 , Loss:  [[16.96420184]] , Val Loss:  [[8.46794248]]
Epoch:  16 , Loss:  [[17.017519]] , Val Loss:  [[8.49241316]]
Epoch:  17 , Loss:  [[16.94199493]] , Val Loss:  [[8.45748739]]
Epoch:  18 , Loss:  [[16.99796892]] , Val Loss:  [[8.48242177]]
Epoch:  19 , Loss:  [[17.24817035]] , Val Loss:  [[8.6126231]]
Epoch:  20 , Loss:  [[17.00844599]] , Val Loss:  [[8.48682234]]
Epoch:  21 , Loss:  [[17.03943262]] , Val Loss:  [[8.50437328]]
Epoch:  22 , Loss:  [[17.01417255]] , Val Loss:  [[8.49409597]]
Epoch:  23 , Loss:  [[17.20918888]] , Val Loss:  [[8.5854792]]
Epoch:  24 , Loss:  [[16.92068017]] , Val Loss:  [[8.44794633]]
Epoch:  25 , Loss:  [[16.76856238]] , Val Loss:  [[8.37295808]]

¡Luciendo bien! Es hora de obtener las predicciones y trazarlas para tener una idea visual de lo que hemos diseñado.

Paso 3: obtén predicciones

Haremos un pase hacia adelante por medio de los pesos entrenados para obtener nuestras predicciones:

preds = []
for i in range(Y.shape[0]):
    x, y = X[i], Y[i]
    prev_s = np.zeros((hidden_dim, 1))
    # Forward pass
    for t in range(T):
        mulu = np.dot(U, x)
        mulw = np.dot(W, prev_s)
        add = mulw + mulu
        s = sigmoid(add)
        mulv = np.dot(V, s)
        prev_s = s

    preds.append(mulv)
    
preds = np.array(preds)

Trazando estas predicciones junto con los valores reales:

plt.plot(preds[:, 0, 0], 'g')
plt.plot(Y[:, 0], 'r')
plt.show()

Esto estaba en los datos de entrenamiento. ¿Cómo sabemos si nuestro modelo no se ajustó demasiado? Aquí es donde entra en juego el conjunto de validación, que creamos previamente:

preds = []
for i in range(Y_val.shape[0]):
    x, y = X_val[i], Y_val[i]
    prev_s = np.zeros((hidden_dim, 1))
    # For each time step...
    for t in range(T):
        mulu = np.dot(U, x)
        mulw = np.dot(W, prev_s)
        add = mulw + mulu
        s = sigmoid(add)
        mulv = np.dot(V, s)
        prev_s = s

    preds.append(mulv)
    
preds = np.array(preds)

plt.plot(preds[:, 0, 0], 'g')
plt.plot(Y_val[:, 0], 'r')
plt.show()

Nada mal. Las predicciones parecen impresionantes. La puntuación RMSE en los datos de validación además es respetable:

from sklearn.metrics import mean_squared_error

math.sqrt(mean_squared_error(Y_val[:, 0] * max_val, preds[:, 0, 0] * max_val))
0.127191931509431

Notas finales

No puedo enfatizar lo suficiente lo útiles que son los RNN cuando se trabaja con datos de secuencia. Les imploro a todos que tomen este aprendizaje y lo apliquen en un conjunto de datos. Tome un obstáculo de PNL y vea si puede hallar una solución. Siempre puede comunicarse conmigo en la sección de comentarios a continuación si tiene alguna duda.

En este post, aprendimos cómo crear un modelo de red neuronal recurrente desde cero usando solo la biblioteca numpy. Desde luego, puede utilizar una biblioteca de alto nivel como Keras o Caffe, pero es esencial conocer el concepto que está implementando.

Comparta sus pensamientos, preguntas y comentarios sobre este post a continuación. ¡Feliz aprendizaje!

Suscribite a nuestro Newsletter

No te enviaremos correo SPAM. Lo odiamos tanto como tú.