Long-Short Term Memories (LSTMs)¶

Las Long-Short Term Memories o LSTMs incorporan celdas de "memoria" en las redes recurrentes. La idea esencial de las LSTMs es preservar la información relevante de los estados previos, mientras que son capaces de olvidar información innecesarias también de las capas preivas.

Para implementar las LTMs haremos uso de las nociones de gráficas computacionales, definiendo un nodo LSTM. Dentro de este nodo se incorporarán el paso forward (el cálculo de los valores de salida) y el backward (la derivación y actualización de los pesos).

In [1]:
from layers import *
from tqdm import tqdm
import numpy as np

Ya que estaremos usando constantemente la función sigmoide dentro de las LSTMs definimos, en primer luagar, esta función:

In [2]:
def sig(x):
    """Función sigmoide"""
    return 1./(1.+np.exp(-x))

Capa de LSTM¶

Para generar la capa de LSTM como un nodo en nuestra gráfica computacional, definiremos dos pasos: el forward y el backward. Asimismo, definiremos todos los pesos de las matrices de la LSTM. Recordemos que la LSTM cuenta con tres puertas $i, o$ y $f$, además genera un candidato $\hat{c}$, donde cada una de estas puertas requiere de una matriz de pesos para los estados anteriores (recurrencias), una matriz de pesos para la entradas, y un bias. Es decir, requerimos 8 matrices de pesos y 4 bias. Estos se inicializan aleatoriamente.

Forward¶

En el paso forward, se calculan los valores de salida de la red, para esto, se estiman primero las tres compuertas principales del LSTM:

$$i_t = \sigma(U^ix^{(t)} + V^ih^{(t-1)} + b^i)$$$$o_t = \sigma(U^ox^{(t)} + V^oh^{(t-1)} + b^o)$$$$f_t = \sigma(U^fx^{(t)} + V^fh^{(t-1)} + b^f)$$

Asimismo, se define el candidato a sombra de la forma:

$$\hat{c}_t = \tanh(U^cx^{(t)} + V^ch^{(t-1)} + b^c)$$

La sombra (la información que se mandará al siguiente estado) se define a partir de estas compuertas de la siguiente forma:

$$c_t = f_t \odot c_{t-1} + i_t \odot \hat{c}_t$$

Es justamente aquí donde radica el paso central de las LSTM, pues se procesan los estados anteriores multiplicando por la compuerta $f_t$ que se encargará de filtrar aquellas cosas que sean relevantes para el estado actual. Asimismo, la información actual, almacenada en $\hat{c}_t$ se multiplicará con la compuerta $i_t$ que se enfocará en "escribir" la información relevante para el estado.

Finalmente, en la celda de salida para este estado se aplicará la función de tangente hiperbólica para escalar los valores en $c_t$ y se "leerá" por medio de la compuerta $o_t$. Tenemos entonces:

$$h_t = o_t \odot \tanh(c_t)$$

Algoritmo de aprendizaje

Backward¶

El backward es quizá el paso más pesado en una LSTM. En este paso, se deben estimar las derivadas de la capa para actualizar los pesos por medio de un optimizador basado en descenso por gradiente. En las redes recurrentes, recordemos que las función de riesgo (u objetivo) considera la suma sobre los diferentes tiempos:

$$R(\theta) = \sum_t \sum_y L_t(f_y(x)^{(t)}, y^{(t)})$$

Derivar sobre los pesos de la capa LSTM implicará, primero, entrar por medio de la retro-propagación, a esta capa. Para derivar sobre cada uno de los pesos de LSTM, tenemos que considerar el orden en que se ejecutan; es decir, debemos fijarnos en cómo se ejecuta la gráfica computacional.

En primer lugar, estaremos almacenando una variable que acumule las derivadas en los estados superiores de las recurrencias. Esta variable será de la forma:

$$d_{st} = \sum_t \sum_y \frac{\partial L_t}{\partial h_t} o_t(1-tanh(c_t)^2)$$

Donde el término $\sum_y \frac{\partial L_t}{\partial h_t}$ son las derivadas en las capas superiores en el tiempo $t$.

Podemos ver que los pesos que están más arriba en esta gráfica son los que corresponden a la compuerta $o_t$, pues ésta es la que se encuentra en la salida de la capa; entonces, podemos empezar derivando sobre los pesos en esta capa:

$$\frac{\partial R(\theta)}{\partial V_i^o} = \sum_y \frac{\partial L_t}{\partial h_t} o_t (1-o_t) \odot \tanh(c_t) h_i^{t-1}$$

El término $\frac{\partial L_t}{\partial h_t}$ es justamente la derivada en la capa superior. Por lo que sólo tenemos que fijarnos en la última parte. Como $o_t$ es la función sigmoide, su derivada es $o_t(1-o_t)$ el otro término se toma como constante. En el caso de derivar sobre $U^{(o)}$, sólo cambia el último término por $x^{(t)}$.

La siguiente operación que ejecuta la capa LSTM es el cálculo de $c_t$ en donde entran en juego las compuertas restantes. Aquí podemos derivar las compuertas en cualquier orden, y cada una de ellas tendrá una apariencia similar. Tomemos los siguientes casos:

  • Candidato $\hat{c}_t$: $$\frac{\partial R(\theta}{\partial V^c_i} = d_{st}\odot i_t \odot(1-\hat{c}_t^2) h_i^{(t-1} $$
  • Compuerta escritura $i_t$: $$\frac{\partial R(\theta}{\partial V^i_i} = d_{st}\odot i_t(1-i_t) \hat{c} h_i^{(t-1} $$
  • Compuerta olvido $f_t$: $$\frac{\partial R(\theta}{\partial V^f_i} = d_{st}\odot f_t(1-f_t) c_{t-1} h_i^{(t-1} $$

Para los casos de las matrices $U^f, U^i, U^c$ las derivadas sólo cmabian en el último término por el vector de entrada $x^{(t)}$. En los bias, este mismo término cambia por un valor 1. Por tanto, estos términos se guardarán como variables para usarse en la derivación de los términos.

En nuestra implementación, en lugar de acumular la derivada a través de los tiempos, actualizamos los pesos en cada tiempo $t$.

In [3]:
class LSTM():
    """Clase para crear un nodo LSTM"""
    def __init__(self, input_size, output_size, h0=None, c0=None):
        super(LSTM,self).__init__
        self.input_size = input_size
        self.output_size = output_size
        #Celda de escritura
        self.Vi = np.random.randn(output_size,output_size) / np.sqrt(output_size)
        self.Ui = np.random.randn(output_size, input_size) / np.sqrt(input_size)
        self.bi = np.zeros(output_size)
        #Celda de olvido
        self.Vf = np.random.randn(output_size,output_size) / np.sqrt(output_size)
        self.Uf = np.random.randn(output_size, input_size) / np.sqrt(input_size)
        self.bf = np.zeros(output_size)
        #Celda de lectura
        self.Vo = np.random.randn(output_size,output_size) / np.sqrt(output_size)
        self.Uo = np.random.randn(output_size, input_size) / np.sqrt(input_size)
        self.bo = np.zeros(output_size)
        #Celda de candidato
        self.Vc = np.random.randn(output_size,output_size) / np.sqrt(output_size)
        self.Uc = np.random.randn(output_size, input_size) / np.sqrt(input_size)
        self.bc = np.zeros(output_size)
        #Entrada
        self.x = None
        #Vector de inicialización
        if h0 == None and c0 == None:
            self.h0,self.c0 = np.zeros(output_size),np.zeros(output_size)
        else:
            self.h0,self.c0 = h0,c0
    
    def __call__(self, x):
        self.x = x
        l = x.shape[0]
        #Inicializacion de celdas
        self.h = np.zeros((l+1,self.output_size))
        self.h[0] = self.h0
        #Inicializacion de sombras
        self.c = np.zeros((l+1,self.output_size))
        self.c[0] = self.c0
        #Inicialización puertas
        self.i = np.zeros((l,self.output_size))
        self.f = np.zeros((l,self.output_size))
        self.o = np.zeros((l,self.output_size))
        self.c_hat = np.zeros((l,self.output_size))
        
        for t, x_t in enumerate(x):
            #Aplicación  del forward
            self.i[t] = sig(self.Vi@self.h[t] + self.Ui@x_t + self.bi)
            self.f[t] = sig(self.Vf@self.h[t] + self.Uf@x_t + self.bf)
            self.o[t] = sig(self.Vo@self.h[t] + self.Uo@x_t + self.bo)
            self.c_hat[t] = np.tanh(self.Vc@self.h[t] + self.Uc@x_t + self.bc)
    
            self.c[t+1] = self.f[t]*self.c[t] + self.i[t]*self.c_hat[t]
            self.h[t+1] = self.o[t]*np.tanh(self.c[t+1])
            
        return self.h[1:], self.c[1:]
    
    def backward(self,layer,lr=0.1):
        #Variable de estado
        d_t = np.zeros(self.output_size)
        self.w = []
        self.d = []
        for t in range(self.x.shape[0])[::-1]:
            prev_d = layer.d[t]
            #Variable de lectura
            d_o = prev_d*np.tanh(self.c[t+1])*self.o[t]*(1-self.o[t])
            dVo = np.outer(d_o,self.h[t])
            dUo = np.outer(d_o,self.x[t])
            self.Vo -= lr*dVo
            self.bo -= lr*d_o
            self.Uo -= lr*dUo
            #Varible de estado (se usa en escritura, olvido y sombra)
            d_st = prev_d*self.o[t]*(1-np.tanh(self.c[t+1])**2) + d_t
            #Variable de sombra
            d_c = d_st*self.i[t]*(1-self.c_hat[t]**2)
            dVc = np.outer(d_c,self.h[t])
            dUc = np.outer(d_c,self.x[t])
            self.Vc -= lr*dVc
            self.bc -= lr*d_c
            self.Uc -= lr*dUc
            #Variable de escritura
            d_i = d_st*self.c_hat[t]*self.i[t]*(1-self.i[t])
            dVi = np.outer(d_i,self.h[t])
            dUi = np.outer(d_i,self.x[t])
            self.Vi -= lr*dVi
            self.bi -= lr*d_i
            self.Ui -= lr*dUi
            #Variable de olvido
            d_f = d_st*self.c[t]*self.f[t]*(1-self.f[t])
            dVf = np.outer(d_f,self.h[t])
            dUf = np.outer(d_f,self.x[t])
            self.Vf -= lr*dVf
            self.bf -= lr*d_f
            self.Uf -= lr*dUf

            self.d.append( dUo.T@d_o + dUi.T@d_i + dUc.T@d_c + dUf.T@d_f )
            
            #Nueva variable de cambio
            d_t = self.f[t]*d_st

Aplicación de la LSTM¶

Para mostrar el funcionamiento de nuestra LSTM definiremos un problema simple de lenguaje natural: este problema es conocido como el etiquetado de partes de la oración (POS por sus siglas en inglés). En este se busca identificar la correspondiente categoría gramátical (o tipo de palabra) de una palabra en una oración. Por ejemplo "el", "la", "los" son artículos (DA), mientras que "comer", "salto" son verbos (V).

Utilizamos un toy dataset para ejemplificar este problema. El problema requiere de un preprocesamiento en que las palabras y las etiquetas se indexan. De hecho, como en muchos modelos orientados al lenguaje natural requerimos usar una capa de Embedding, que ya hemos definido previamente).

In [4]:
#Toy dataset
inputs = ['el perro come un hueso', 'un muchacho jugaba', 'el muchacho saltaba la cuerda', 'el perro come mucho',
          'un perro come croquetas', 'el perro come', 'el gato come croquetas', 'un gato come', 'yo juego mucho', 
          'el juego', 'un juego', 'yo juego un juego', 'el gato come mucho']
outputs = ['DA NC V DD NC', 'DD NC V', 'DA NC V DA NC', 'DD NC V NC', 'DA NC V Adv', 'DA NC V', 'DA NC V NC', 
           'DD NC V', 'DP V Adv', 'DA NC', 'DD NC', 'DP V DD NC', 'DA NC V Adv']

#Indexación de los datos
in_voc = vocab()
x = list(text2numba(inputs,in_voc))
out_voc = vocab()
y = list(text2numba(outputs,out_voc))

Ahora generamos nuestra red a partir de las siguientes capas:

  1. Capa de Embedding: generará los vectores de palabras a partir d elos índices de éstas.
  2. Capa LSTM para procesar secuencialmente la entrada.
  3. Capa de salida, con activación Softmax para elegir la etiqueta con mayor probabilidad.

Finalmente, nuestra función de riesgo será la entropía cruzada.

In [5]:
#Capa de Embedding
emb = Embedding(len(in_voc),100)
#Capa LSTM
lstm = LSTM(100,200)
#Capa Lineal
lin = Linear(200,len(out_voc))
#Activación Softmax
soft = Softmax(normalize=True)
#Función de riesgo
risk = CrossEntropy()

Finalmente, procedemos a entrenar nustra red con base en el descenso por gradiente. Elegimos un número de épocas adecuado y una tasa de aprendizaje adecuada. Las actualizaciones se hacen dentro del mismo método backward en cada capa de la red-

In [6]:
lr = 1e-2
epochs = 300
for t in tqdm(range(epochs)):
    for x_i, y_i in zip(x,y):
        #Forward
        e = emb(x_i)
        h1,c = lstm(e)
        a = lin(h1)
        f = soft(a)
        loss = risk(y_i,f)

        #Backward
        risk.backward()
        soft.backward(risk)
        lin.backward(soft, lr=lr)
        lstm.backward(lin, lr=lr)
        emb.backward(lstm, lr=lr)
100%|█████████████████████████████████████████| 300/300 [00:26<00:00, 11.52it/s]

Resultados¶

Para probar los resultados de nuestra red, construimos una funciónn tagger que nos permitirá etiquetar una oración por medio de nuestra red neuronal LSTM. El proceso es simple, pues toma la cadena, separa las palabras, las indexa y ésta es la entrada de nuestra red. En la salida sólo se recuperan las etiquetas correspondientes:

In [7]:
tags = {i:tag for tag,i in out_voc.items()}
def tagger(sent):
    """Función de etiquetado con LSTM"""
    x = [in_voc[w] for w in sent.split()]
    p = soft(lin(lstm(emb(x))[0]))
    y_pred = p.argmax(1)
    
    return ' '.join([tags[j] for j in y_pred])
In [8]:
sent = 'el muchacho come un perro'
result = tagger(sent)
print(sent)
print(result)
el muchacho come un perro
DA NC V DD NC

En este caso, DA corresponde a artículo, NC a nombre común, V a verbo, DD a demostrativo ("un", "una") y Adv a adverbio. Se observar que el etiquetado, en este ejemplo de juguete es satisfactorio.