Las Long-Short Term Memories o LSTM son una herramienta de gran utilidad cuando se trabaja con RNNs, pues evitan el desvanecimiento del gradiente y conservan adecuadamente las dependencias a largo plazo. Realizaremos un ejemplo muy simple de una LSTM, aplicadno el algoritmo de backpropagation y de gradien descent.
#-*- encoding:utf-8 -*-
import numpy as np
from collections import defaultdict, Counter
from itertools import chain
import seaborn as sns
#Funcion que crea un vocabulario de palabras con un indice numerico
def vocab():
vocab = defaultdict()
vocab.default_factory = lambda: len(vocab)
return vocab
#Funcion que pasa la cadena de simbolos a una secuencia con indices numericos
def text2numba(corpus, vocab):
for doc in corpus:
yield [vocab[w] for w in doc.split()]
Al igual que con modelos del lenguaje más tradicionales, tomamos un corpus e indexamos las palabras con valores numéricos, con el objetivo de que sea más fácil manejarlas.
corpus = ['el perro come un hueso', 'un muchacho jugaba', 'el muchacho saltaba la cuerda',
'un perro come croquetas', 'el perro come', 'el gato come croquetas', 'un gato come']
#Llamamos la funcion para crear el vocabulario
voc = vocab()
#Creamos el vocabulario y le asignamos un indice a cada simbolo segun su aparicion
sentences = list(text2numba(corpus,voc))
print(sentences)
[[0, 1, 2, 3, 4], [3, 5, 6], [0, 5, 7, 8, 9], [3, 1, 2, 10], [0, 1, 2], [0, 11, 2, 10], [3, 11, 2]]
Además, tenemos que añadir los símbolos de BOS (Beginning Of Sentence) y EOS (End Of Sentence). Estas palabras también se añaden al vocabulario con índices numéricos.
#Indicamos las etiquetas a usar
EOS = '<EOS>'
BOS = '<BOS>'
#Cada etiqeuta se le asigna un indice numerico
BOS_IDX = max(voc.values())+2
EOS_IDX = max(voc.values())+1
#Se agregan estas etiqeutas al vocabulario
voc[EOS] = EOS_IDX
voc[BOS] = BOS_IDX
#A cada cadena se le agrega la etiqueta BOS al inicio y EOS al final
cadenas = [[BOS_IDX] + cad + [EOS_IDX] for cad in sentences]
#Se obtiene la longitud del alfabeto
N = len(voc)
print(voc)
defaultdict(<function vocab.<locals>.<lambda> at 0x7fbccff0b430>, {'el': 0, 'perro': 1, 'come': 2, 'un': 3, 'hueso': 4, 'muchacho': 5, 'jugaba': 6, 'saltaba': 7, 'la': 8, 'cuerda': 9, 'croquetas': 10, 'gato': 11, '<EOS>': 12, '<BOS>': 13})
Entonces generamos los ejemplos, estos son pares de tuplas dadas como:
$$(BOS w_1 ...w_T; w_1 ... w_T EOS)$$Lo que buscamos, es predecir la palabra subsiguiente, como en un modelo del lenguaje. Así dada la palabra $w_1$, querermos que se prediga la palabra subsiguiente $w_2$. Por tanto, los ejemplos de entrenamiento toman como entrada la cadena (sin EOS) y buscan emitir la cadena adelantada un estado.
examples = [(s[:-1],s[1:]) for s in cadenas]
print(examples)
[([13, 0, 1, 2, 3, 4], [0, 1, 2, 3, 4, 12]), ([13, 3, 5, 6], [3, 5, 6, 12]), ([13, 0, 5, 7, 8, 9], [0, 5, 7, 8, 9, 12]), ([13, 3, 1, 2, 10], [3, 1, 2, 10, 12]), ([13, 0, 1, 2], [0, 1, 2, 12]), ([13, 0, 11, 2, 10], [0, 11, 2, 10, 12]), ([13, 3, 11, 2], [3, 11, 2, 12])]
Recordemos que una LSTM se define a partir de 3 puertas $i$, $o$ y $f$ (escritura, lectura y olvido), dadas por las siguientes funciones:
$$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, entonces como:
$$c_t = f_t \odot c_{t-1} + i_t \odot \hat{c}_t$$Y finalmente, la celda de salida para ese estado está dado por:
$$h_t = o_t \odot \tanh(c_t)$$Ya que las puertas en el LSTM utilizan la función sigmoide, definimos ésta a continuación:
def sig(x):
return 1./(1.+np.exp(-x))
Ahora inicializamos los parámetros $\theta$. Estos parámetros son los siguientes:
1) Parámetros de la puerta de escritura: $V^i\in\mathbb{R}^{m\times m}$, $U^i\in\mathbb{R}^{m\times N}$ y $b^i\in\mathbb{R}^m$.
2) Parámetros de la puerta de olvido: $V^f\in\mathbb{R}^{m\times m}$, $U^f\in\mathbb{R}^{m\times N}$ y $b^f\in\mathbb{R}^m$.
3) Parámetros de la puerta de lectura: $V^o\in\mathbb{R}^{m\times m}$, $U^o\in\mathbb{R}^{m\times N}$ y $b^o\in\mathbb{R}^m$.
4) Parámetros de la sombra: $V^c\in\mathbb{R}^{m\times m}$, $U^c\in\mathbb{R}^{m\times N}$ y $b^c\in\mathbb{R}^m$.
5) Parámetros de la capa de salida: $W\in\mathbb{R}^{N\times m}$y $b\in\mathbb{R}^N$.
np.random.seed(0)
#El número de rasgos que representan cada vector
nn_input_dim = N
#El total de clases que arrojará
output_dim = N
#Embedding
dim = 2
#Dimensiones de los vectores-palabra
cell_dim = 3
#Embedding matrix
C = np.random.randn(dim,N) / np.sqrt(N)
#Celda de escritura
Vi = np.random.randn(cell_dim,cell_dim) / np.sqrt(cell_dim)
Ui = np.random.randn(cell_dim,dim) / np.sqrt(dim)
bi = np.zeros(cell_dim)
#Celda de olvido
Vf = np.random.randn(cell_dim,cell_dim) / np.sqrt(cell_dim)
Uf = np.random.randn(cell_dim,dim) / np.sqrt(dim)
bf = np.zeros(cell_dim)
#Celda de lectura
Vo = np.random.randn(cell_dim,cell_dim) / np.sqrt(cell_dim)
Uo = np.random.randn(cell_dim,dim) / np.sqrt(dim)
bo = np.zeros(cell_dim)
#Celda de candidato
Vc = np.random.randn(cell_dim,cell_dim) / np.sqrt(cell_dim)
Uc = np.random.randn(cell_dim,dim) / np.sqrt(dim)
bc = np.zeros(cell_dim)
#Capa de salida
W = np.random.randn(N,cell_dim) / np.sqrt(cell_dim)
b = np.zeros(N)
Finalmente aplicamos el entrenamiento. En este caso, aplicamos el LSTM, y la capa de salida. Posteriormente aplicamos backpropagation sobre todos los parámetros para poder actualizarlos por medio del gradiente descendiente.
%%time
it = 100
lr = 0.1
for t in range(it):
for (seq,target_seq) in examples:
#Inicializacion de celdas
h = np.zeros((len(seq)+1,cell_dim))
#Inicializacion de sombras
c = np.zeros((len(seq)+1,cell_dim))
#Variable de cambio
d_t = np.zeros(cell_dim)
for t,w in enumerate(seq):
#Embedding
x = C.T[w]
#LSTM
#Puerta de escritura
i = sig(np.dot(Vi,h[t]) + np.dot(Ui,x) + bi)
#Puerta de olvido
f = sig(np.dot(Vf,h[t]) + np.dot(Uf,x) + bf)
#Puerta de escritura
o = sig(np.dot(Vo,h[t]) + np.dot(Uo,x) + bo)
#Candidato a sombra
c_hat = np.tanh(np.dot(Vc,h[t]) + np.dot(Uc,x) + bc)
#Sombra en el estado actual
c[t+1] = f*c[t] + i*c_hat
#Celda en el estado actual
h[t+1] = o*np.tanh(c[t+1])
#FORWARD SALIDA
#Softmax
p = np.exp(np.dot(W,h[t+1])+b)
phi = p/p.sum(0)
#BACK-PROP
#Variable de salida
d_out = phi
d_out[target_seq[t]] -= 1
#GD salida
dW = np.outer(d_out,h[t+1])
W -=lr*dW
#Variable de lectura
d_o = np.dot(W.T,d_out)*np.tanh(c[t+1])*o*(1-o)
#GD lectura Vo
dVo = np.outer(d_o,h[t])
Vo -= lr*dVo
#GD lectura Uo
dUo = np.outer(d_o,x)
Uo -= lr*dUo
#GD emb
do_emb = np.dot(Uo.T,d_o)
C.T[w] -= lr*do_emb
#Varible de estado (se usa en escritura, olvido y sombra)
d_st = np.dot(W.T,d_out)*o*(1-np.tanh(c[t+1])**2) + d_t
#Variable de sombra
d_c = d_st*i*(1-c_hat**2)
#GD sombra Vc
dVc = np.outer(d_c,h[t])
Vc -= lr*dVc
#GD sombra Uc
dUc = np.outer(d_o,x)
Uc -= lr*dUc
#GD emb
dc_emb = np.dot(Uc.T,d_c)
C.T[w] -= lr*dc_emb
#Variable de escritura
d_i = d_st*c_hat*i*(1-i)
#GD escritura Vi
dVi = np.outer(d_i,h[t])
Vi -= lr*dVi
#GD escritura Ui
dUi = np.outer(d_i,x)
Ui -= lr*dUi
#GD emb
di_emb = np.dot(Ui.T, d_i)
C.T[w] -= lr*di_emb
#Variable de olvido
d_f = d_st*c[t+1]*f*(1-f)
#GD olvido Vf
dVf = np.outer(d_f,h[t])
Vf -= lr*dVf
#GD olvido Uf
dUf = np.outer(d_f,x)
Uf -= lr*dUf
#GD emb
df_emb = np.dot(Uf.T,d_f)
C.T[w] -= lr*df_emb
#Nueva variable de cambio
d_t = f*d_st
CPU times: user 712 ms, sys: 1.11 ms, total: 713 ms Wall time: 711 ms
Una vez aprendidos los pesos, podemos pasar a utilizar la RNN con LSTM para diferentes tares. Así, definimos una función foward. Para el LSTM, tenemos:
$$h^{(t)},c^{(t)} = LSTM(x^{(t)},h^{(t-1)},c^{(t-1)})$$En la capa de salida, tenemos:
$$\phi(x^{(t)}) = Softmax(Wh^{(t)} + b)$$Así, la aplicación de backpropagation sobre las LSTMs implica derivar sobre las celdas: en este sentido debemos derivar sobre las puertas, la sombra y el estado.
def forward(sent, h = np.zeros(cell_dim),c=np.zeros(cell_dim)):
sent = sent.split()
prob_tot = np.zeros((len(sent),N))
for t,w in enumerate(sent):
#Embedding
x = C.T[voc[w]]
#LSTM
#Puerta de escritura
i = sig(np.dot(Vi,h) + np.dot(Ui,x) + bi)
#Puerta de olvido
f = sig(np.dot(Vf,h) + np.dot(Uf,x) + bf)
#Puerta de escritura
o = sig(np.dot(Vo,h) + np.dot(Uo,x) + bo)
#Candidato a sombra
c_hat = np.tanh(np.dot(Vc,h) + np.dot(Uc,x) + bc)
#Sombra en el estado actual
c = f*c + i*c_hat
#Celda en el estado actual
h = o*np.tanh(c)
#FORWARD SALIDA
#Softmax
p = np.exp(np.dot(W,h)+b)
phi = p/p.sum(0)
#Se almacenan las probabilidades
prob_tot[t] = phi
return h,c, prob_tot
Una aplicación sencilla es el modelo del lenguaje. Así, podemos obtener, por ejemplo las probabilidades iniciales de la manera siguiente:
h,c,probs = forward('<BOS>')
for word in voc.keys():
print(word, probs[-1][voc[word]])
el 0.2206602901957737 perro 0.001948265685908069 come 0.050528258392494196 un 0.2116536108434402 hueso 0.0020811816408656016 muchacho 0.0019890983977141883 jugaba 0.0013010332577813428 saltaba 0.0020422913373089185 la 0.001642761485243439 cuerda 0.0015703340057358036 croquetas 0.11089905627086936 gato 0.002445964314569349 <EOS> 0.3879008281821268 <BOS> 0.003337025990168772
También podemos predecir la palabra subsiguiente. Por ejemplo, podemos ver que palabra sigue a una construcción singular:
h,c,probs = forward('<BOS> el gato')
for word in voc.keys():
print(word, probs[-1][voc[word]])
el 0.007703312456386203 perro 0.05248207376479027 come 0.7299324839626479 un 0.024936663383801842 hueso 0.017217518781751814 muchacho 0.031133749526469502 jugaba 0.0034367102411035615 saltaba 0.005365334022710057 la 0.0005350037122039445 cuerda 0.0004980067633711965 croquetas 0.05607654431703367 gato 0.03929239243849569 <EOS> 0.029721448530493244 <BOS> 0.0016687580987410774
Asimismo, podemos obtener la probabilidad de una palabra dada las 3 anteriores:
h,c,probs = forward('<BOS> el muchacho saltaba la')
for word in voc.keys():
print(word, probs[-1][voc[word]])
el 0.026097569165812073 perro 0.056400205691679646 come 0.019259356490384523 un 0.0205806267501239 hueso 0.08126295346495523 muchacho 0.05039580535998145 jugaba 0.0926051297173081 saltaba 0.0967709617029092 la 0.2152432417386364 cuerda 0.17498121713820292 croquetas 0.01951465182353131 gato 0.05178114629330498 <EOS> 0.02085686626113491 <BOS> 0.0742502684020353
Al igual que con otros modelos del lenguaje, podemos predecir sentencias. Para esto, definiremos una función de predicción de sentencias:
def forward_prediction(sent):
arg_idx = BOS_IDX
words = []
h,c, probs = forward(sent)
arg_max = list(voc.keys())[list(voc.values()).index(np.argmax(probs[-1]))]
words.append(arg_max)
while arg_max != '<EOS>':
h,c, probs = forward(arg_max,h,c)
arg_max = list(voc.keys())[list(voc.values()).index(np.argmax(probs[-1]))]
words.append(arg_max)
return sent + ' ' + ' '.join(words)
print( forward_prediction('<BOS> el') )
print( forward_prediction('<BOS> el perro saltaba') )
print( forward_prediction('<BOS> la cuerda') )
print( forward_prediction('<BOS> el gato') )
<BOS> el perro come <EOS> <BOS> el perro saltaba la perro come <EOS> <BOS> la cuerda <EOS> <BOS> el gato come <EOS>
import matplotlib.pyplot as plt
from sklearn.decomposition import PCA
from sklearn.manifold import TSNE
from operator import itemgetter
#Funcion para plotear los datos con labels
def plot_words(Z,ids):
#Reduce la dimensionalidad a 2
Z = PCA(2).fit_transform(Z)
#Plotea con la marcas (marker) y el color indicado (c)
r=0
plt.scatter(Z[:,0],Z[:,1], marker='o', c='blue')
for label,x,y in zip(ids, Z[:,0], Z[:,1]):
plt.annotate(label, xy=(x,y), xytext=(-1,1), textcoords='offset points', ha='center', va='bottom')
r+=1
#plt.show()
#Ordena las etiquetas para que coincidan con los vectores-renglón de la matriz de embedding
label = [w[0] for w in sorted(voc.items(), key=itemgetter(1))]
plot_words(C.T[:-2],label)
ax = sns.heatmap(C.T[:-2], linewidth=1.0, yticklabels=label[:-2])
ax.set_title('Embeddings')
plt.show()
ax = sns.heatmap(Uc, linewidth=1.0) #, yticklabels=label)
ax.set_title('Pesos de candidato')
plt.show()
def get_cells(sent, h0 = np.zeros(cell_dim),c0=np.zeros(cell_dim)):
sent = sent.split()
prob_tot = np.zeros((len(sent),N))
#Longitud sente
long = len(sent)
#Matrices de puertas por estado
i = np.zeros((long+1, cell_dim))
o = np.zeros((long+1, cell_dim))
f = np.zeros((long+1, cell_dim))
c_hat = np.zeros((long+1, cell_dim))
c = np.zeros((long+1, cell_dim))
h = np.zeros((long+1, cell_dim))
#Inicializar h y c
c[0] = c0
h[0] = h0
for t,w in enumerate(sent):
#Embedding
x = C.T[voc[w]]
#LSTM
#Puerta de escritura
i[t+1] = sig(np.dot(Vi,h[t]) + np.dot(Ui,x) + bi)
#Puerta de olvido
f[t+1] = sig(np.dot(Vf,h[t]) + np.dot(Uf,x) + bf)
#Puerta de escritura
o[t+1] = sig(np.dot(Vo,h[t]) + np.dot(Uo,x) + bo)
#Candidato a sombra
c_hat[t+1] = np.tanh(np.dot(Vc,h[t]) + np.dot(Uc,x) + bc)
#Sombra en el estado actual
c[t+1] = f[t+1]*c[t] + i[t+1]*c_hat[t+1]
#Celda en el estado actual
h[t+1] = o[t+1]*np.tanh(c[t+1])
#FORWARD SALIDA
#Softmax
p = np.exp(np.dot(W,h[t+1])+b)
phi = p/p.sum(0)
#Se almacenan las probabilidades
prob_tot[t] = phi
return (i,o,f),h,c_hat,c, prob_tot
ex = 'el perro saltaba el gato'
gates, h,c_hat,c, p = get_cells(ex)
states = [0] + ex.split()
#Puerta de olvido
ax = sns.heatmap(gates[2], linewidth=1.0, yticklabels=states)
ax.set_title('f gate')
plt.show()
#Puerta de lectura i
ax = sns.heatmap(gates[0], linewidth=1.0, yticklabels=states)
ax.set_title('i gate')
plt.show()
#Puerta de escritura
ax = sns.heatmap(gates[1], linewidth=1.0, yticklabels=states)
ax.set_title('o gate')
plt.show()
ax = sns.heatmap(c, linewidth=1.0, yticklabels=states)
ax.set_title('Sombra')
plt.show()
ax = sns.heatmap(np.tanh(c), linewidth=1.0, yticklabels=states)
ax.set_title('tanh(c)')
plt.show()
ax = sns.heatmap(gates[1]*np.tanh(c), linewidth=1.0, yticklabels=states)
ax.set_title('o * tanh(c)')
plt.show()
plot_words(h,states)
ax = sns.heatmap(c_hat, linewidth=1.0, yticklabels=states)
ax.set_title('Candidato a sombra')
plt.show()