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).
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:
def sig(x):
"""Función sigmoide"""
return 1./(1.+np.exp(-x))
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.
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)$$
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:
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$.
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
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).
#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:
Finalmente, nuestra función de riesgo será la entropía cruzada.
#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-
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]
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:
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])
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.