septiembre 4, 2020

~ 14 MIN

Traducción de texto

< Blog RSS

Open In Colab

La arquitectura Encoder-Decoder

En posts anteriores hemos visto como podemos utilizar redes neuronales recurrentes para generación de texto así como clasificación de texto. En ambas aplicaciones hemos entrenado una red neuronal que alimentamos con una secuencia de texto, ya sean letras o palabras en una frase, a la cual le pedimos a la salida una distribución de probabilidad sobre la diferentes categorías (para el caso de la clasificación) o directamente el vocabulario (para la generación de texto). La principal limitación de estos modelos es que no podemos obtener más que una salida, y es por esto que en el caso de la generación de texto concatenamos la salida en cada instante a las entradas para utilizarlo de nuevo como entradas y obtener así una nueva predicción. En este post vamos a ver cómo podemos implementar modelos que no sólo sean capaces de recibir secuencias a la entrada, sino que también puedan dar secuencias de longitud arbitraria a la salida. Este tipo de modelos se conocen como modelos sequence to sequence (o simplemente seq2seq) y pueden ser utilizados para tareas tales como la generación de texto, traducción entre idiomas, resumir textos, etc.

💡 Este post está basado en el siguiente tutorial, en el que podrás encontrar más información.

El dataset

En este post vamos a ver cómo entrenar este tipo de arquitectura para traducir texto del inglés al castellano. Puedes encontrar un ejemplo de dataset para traducción aquí. Una vez descargados los datos vamos a leer el archivo, separando los pares de frases de cada ejemplo.

import unicodedata
import re

def unicodeToAscii(s):
    return ''.join(
        c for c in unicodedata.normalize('NFD', s)
        if unicodedata.category(c) != 'Mn'
    )

def normalizeString(s):
    s = unicodeToAscii(s.lower().strip())
    s = re.sub(r"([.!?])", r" \1", s)
    s = re.sub(r"[^a-zA-Z.!?]+", r" ", s)
    return s

def read_file(file, reverse=False):
    # Read the file and split into lines
    lines = open(file, encoding='utf-8').read().strip().split('\n')

    # Split every line into pairs and normalize
    pairs = [[normalizeString(s) for s in l.split('\t')[:2]] for l in lines]

    return pairs
pairs = read_file('spa.txt')
import random

random.choice(pairs)
['come at ten o clock sharp .', 'ven a las diez en punto .']

Como ya hemos visto en los posts anteriores, necesitamos un tokenizer. En este caso, la clase Lang se encargará de asignar un índice único a cada palabra calculando también su frecuencia para, más tarde, poder quedarnos sólo con las palabras más frecuentes. Necesitaremos, además, dos nuevos tokens especiales: el token <eos> y el token <sos> para indicar, respectivamente, el inicio y final de una frase. Más adelante veremos cómo utilizarlos.

SOS_token = 0
EOS_token = 1
PAD_token = 2

class Lang:
    def __init__(self, name):
        self.name = name
        self.word2index = {"SOS": 0, "EOS": 1, "PAD": 2}
        self.word2count = {}
        self.index2word = {0: "SOS", 1: "EOS", 2: "PAD"}
        self.n_words = 3  # Count SOS, EOS and PAD

    def addSentence(self, sentence):
        for word in sentence.split(' '):
            self.addWord(word)

    def addWord(self, word):
        if word not in self.word2index:
            self.word2index[word] = self.n_words
            self.word2count[word] = 1
            self.index2word[self.n_words] = word
            self.n_words += 1
        else:
            self.word2count[word] += 1
            
    def indexesFromSentence(self, sentence):
        return [self.word2index[word] for word in sentence.split(' ')]
    
    def sentenceFromIndex(self, index):
        return [self.index2word[ix] for ix in index]

Opcionalmente, también podemos indicar la longitud máxima de las frases a utilizar así como un conjunto de comienzos de frases que queramos filtrar. Esto lo hacemos únicamente para acelerar el proceso de entrenamiento, trabajando con un conjunto pequeño de datos.

MAX_LENGTH = 10

eng_prefixes = (
    "i am ", "i m ",
    "he is", "he s ",
    "she is", "she s ",
    "you are", "you re ",
    "we are", "we re ",
    "they are", "they re "
)


def filterPair(p, lang, filters, max_length):
    return len(p[0].split(' ')) < max_length and \
        len(p[1].split(' ')) < max_length and \
        p[lang].startswith(filters)

def filterPairs(pairs, filters, max_length, lang=0):
    return [pair for pair in pairs if filterPair(pair, lang, filters, max_length)]
def prepareData(file, filters=None, max_length=None, reverse=False):
    
    pairs = read_file(file, reverse)
    print(f"Tenemos {len(pairs)} pares de frases")
    
    if filters is not None:
        assert max_length is not None
        pairs = filterPairs(pairs, filters, max_length, int(reverse))
        print(f"Filtramos a {len(pairs)} pares de frases")
    
    # Reverse pairs, make Lang instances
    if reverse:
        pairs = [list(reversed(p)) for p in pairs]
        input_lang = Lang('eng')
        output_lang = Lang('spa')
    else:
        input_lang = Lang('spa')
        output_lang = Lang('eng')
    
    for pair in pairs:
        input_lang.addSentence(pair[0])
        output_lang.addSentence(pair[1])
        
        # add <eos> token
        pair[0] += " EOS"
        pair[1] += " EOS"
                           
    print("Longitud vocabularios:")
    print(input_lang.name, input_lang.n_words)
    print(output_lang.name, output_lang.n_words)
                           
    return input_lang, output_lang, pairs

input_lang, output_lang, pairs = prepareData('spa.txt')

# descomentar para usar el dataset filtrado
#input_lang, output_lang, pairs = prepareData('spa.txt', filters=eng_prefixes, max_length=MAX_LENGTH)
                           
random.choice(pairs)
Tenemos 124547 pares de frases
Longitud vocabularios:
spa 13074
eng 25274





['tom has been going to that beach every summer since he was very young . EOS',
 'tom estuvo yendo a esa playa todos los veranos desde que era muy joven . EOS']

Una vez construidos los dos vocabularios, podemos obtener los índices a partir de una frase, y viceversa, de la siguiente manera.

output_lang.indexesFromSentence('tengo mucha sed .')
[68, 5028, 135, 4]
output_lang.sentenceFromIndex([68, 5028, 135, 4])
['tengo', 'mucha', 'sed', '.']

Para terminar, las siguientes clases se encargarán de alimentar nuestro modelo seq2seq utilizando las clases Dataset y DataLoader de Pytorch. Debido a que nuestras frases pueden tener diferentes longitudes, tenemos que asegurarnos que al construir nuestros batches todas tengan la misma longitud, ya que para alimentar la red necesitamos tensores rectangulares. Esto lo conseguimos con la función collate.

import torch

device = "cuda" if torch.cuda.is_available() else "cpu"

class Dataset(torch.utils.data.Dataset):
    def __init__(self, input_lang, output_lang, pairs):
        self.input_lang = input_lang
        self.output_lang = output_lang
        self.pairs = pairs
    
    def __len__(self):
        return len(self.pairs)
        
    def __getitem__(self, ix):
        return torch.tensor(self.input_lang.indexesFromSentence(self.pairs[ix][0]), device=device, dtype=torch.long), \
               torch.tensor(self.output_lang.indexesFromSentence(self.pairs[ix][1]), device=device, dtype=torch.long)
    
    def collate(self, batch):
        # calcular longitud máxima en el batch 
        max_input_len, max_output_len = 0, 0
        for input_sentence, output_sentence in batch:
            max_input_len = len(input_sentence) if len(input_sentence) > max_input_len else max_input_len        
            max_output_len = len(output_sentence) if len(output_sentence) > max_output_len else max_output_len
        # añadimos padding a las frases cortas para que todas tengan la misma longitud
        input_sentences, output_sentences = [], []
        for input_sentence, output_sentence in batch:
            input_sentences.append(torch.nn.functional.pad(input_sentence, (0, max_input_len - len(input_sentence)), 'constant', self.input_lang.word2index['PAD']))
            output_sentences.append(torch.nn.functional.pad(output_sentence, (0, max_output_len - len(output_sentence)), 'constant', self.output_lang.word2index['PAD']))
        # opcionalmente, podríamos re-ordenar las frases en el batch (algunos modelos lo requieren)
        return torch.stack(input_sentences), torch.stack(output_sentences)

# separamos datos en train-test
train_size = len(pairs) * 80 // 100 
train = pairs[:train_size]
test = pairs[train_size:]

dataset = {
    'train': Dataset(input_lang, output_lang, train),
    'test': Dataset(input_lang, output_lang, test)
}

len(dataset['train']), len(dataset['test'])
(99637, 24910)
input_sentence, output_sentence = dataset['train'][1]

input_sentence, output_sentence
(tensor([3, 4, 1], device='cuda:0'), tensor([5, 4, 1], device='cuda:0'))
input_lang.sentenceFromIndex(input_sentence.tolist()), output_lang.sentenceFromIndex(output_sentence.tolist())
(['go', '.', 'EOS'], ['vete', '.', 'EOS'])
dataloader = {
    'train': torch.utils.data.DataLoader(dataset['train'], batch_size=64, shuffle=True, collate_fn=dataset['train'].collate),
    'test': torch.utils.data.DataLoader(dataset['test'], batch_size=256, shuffle=False, collate_fn=dataset['test'].collate),
}

inputs, outputs = next(iter(dataloader['train']))
inputs.shape, outputs.shape
(torch.Size([64, 10]), torch.Size([64, 12]))

El modelo

Una vez tenemos nuestros dataloaders listos, vamos a ver cómo construir nuestro modelo siguiendo la arquitectura encoder-decoder.

El encoder

Como encoder utilizaremos una red neuronal recurrente como las que ya hemos utilizado en los posts anteriores. Tendremos una primera capa embedding que se encargará de proveer a la RNN de la representación vectorial de cada palabra y luego la capa RNN (que puede ser también una LSTM o GRU como ya vimos en este post). El encoder codificará la frase original y nos quedaremos con las salidas de las capas ocultas en el último paso (cuando ya ha visto toda la frase). Este tensor será el responsable de codificar el significado de la frase para que luego el decoder pueda traducirla.

class Encoder(torch.nn.Module):
    def __init__(self, input_size, embedding_size=100, hidden_size=100, n_layers=2):
        super().__init__()
        self.hidden_size = hidden_size
        self.embedding = torch.nn.Embedding(input_size, embedding_size)
        self.gru = torch.nn.GRU(embedding_size, hidden_size, num_layers=n_layers, batch_first=True)

    def forward(self, input_sentences):
        embedded = self.embedding(input_sentences)
        output, hidden = self.gru(embedded)
        # del encoder nos interesa el último *hidden state* 
        return hidden
encoder = Encoder(input_size=input_lang.n_words)
hidden = encoder(torch.randint(0, input_lang.n_words, (64, 10)))

# [num layers, batch size, hidden size]
hidden.shape
torch.Size([2, 64, 100])

El decoder

El decoder será de nuevo una red neuronal recurrente. A diferencia del encoder, el estado oculto del decoder lo inicializaremos con el tensor obtenido a la salida del encoder. Tanto durante el entrenamiento como en fase de inferencia, le daremos al decoder como primera palabra el token <sos>. Con esta información, y el estado oculto del encoder, deberá predecir la primera palabra de la frase traducida. Seguidamente, usaremos esta primera palabra como nueva entrada junto al estado oculto obtenido en el paso anterior para generar la segunda palabra. Repetiremos este proceso hasta que el decoder nos de el token <eos>, indicando que la frase ha terminado.

class Decoder(torch.nn.Module):
    def __init__(self, input_size, embedding_size=100, hidden_size=100, n_layers=2):
        super().__init__()
        self.embedding = torch.nn.Embedding(input_size, embedding_size)
        self.gru = torch.nn.GRU(embedding_size, hidden_size, num_layers=n_layers, batch_first=True)
        self.out = torch.nn.Linear(hidden_size, input_size)

    def forward(self, input_words, hidden):
        embedded = self.embedding(input_words)
        output, hidden = self.gru(embedded, hidden)
        output = self.out(output.squeeze(1))
        return output, hidden
decoder = Decoder(input_size=output_lang.n_words)
output, decoder_hidden = decoder(torch.randint(0, output_lang.n_words, (64, 1)), hidden)

# [batch size, vocab size]
output.shape
torch.Size([64, 25274])
# [num layers, batch size, hidden size]
decoder_hidden.shape
torch.Size([2, 64, 100])

Entrenamiento

Vamos a implementar el bucle de entrenamiento. En primer lugar, al tener ahora dos redes neuronales, necesitaremos dos optimizadores (uno para el encoder y otro para el decoder). Al encoder le pasaremos la frase en el idioma original, y obtendremos el estado oculto final. Este estado oculto lo usaremos para inicializar el decoder que, junto al token <sos>, generará la primera palabra de la frase traducida. Repetiremos el proceso, utilizando como entrada la anterior salida del decoder, hasta obtener el token <eos>.

from tqdm import tqdm
import numpy as np

def fit(encoder, decoder, dataloader, epochs=10):
    encoder.to(device)
    decoder.to(device)
    encoder_optimizer = torch.optim.Adam(encoder.parameters(), lr=1e-3)
    decoder_optimizer = torch.optim.Adam(decoder.parameters(), lr=1e-3)
    criterion = torch.nn.CrossEntropyLoss()
    for epoch in range(1, epochs+1):
        encoder.train()
        decoder.train()
        train_loss = []
        bar = tqdm(dataloader['train'])
        for batch in bar:
            input_sentences, output_sentences = batch
            bs = input_sentences.shape[0]                    
            loss = 0
            encoder_optimizer.zero_grad()
            decoder_optimizer.zero_grad()
            # obtenemos el último estado oculto del encoder
            hidden = encoder(input_sentences)
            # calculamos las salidas del decoder de manera recurrente
            decoder_input = torch.tensor([[output_lang.word2index['SOS']] for b in range(bs)], device=device)
            for i in range(output_sentences.shape[1]):
                output, hidden = decoder(decoder_input, hidden)
                loss += criterion(output, output_sentences[:, i].view(bs))     
                # el siguiente input será la palbra predicha
                decoder_input = torch.argmax(output, axis=1).view(bs, 1)
            # optimización
            loss.backward()
            encoder_optimizer.step()
            decoder_optimizer.step()
            train_loss.append(loss.item())
            bar.set_description(f"Epoch {epoch}/{epochs} loss {np.mean(train_loss):.5f}")
                    
        val_loss = []
        encoder.eval()
        decoder.eval()
        with torch.no_grad():
            bar = tqdm(dataloader['test'])
            for batch in bar:
                input_sentences, output_sentences = batch
                bs = input_sentences.shape[0]  
                loss = 0
                # obtenemos el último estado oculto del encoder
                hidden = encoder(input_sentences)
                # calculamos las salidas del decoder de manera recurrente
                decoder_input = torch.tensor([[output_lang.word2index['SOS']] for b in range(bs)], device=device)
                for i in range(output_sentences.shape[1]):
                    output, hidden = decoder(decoder_input, hidden)
                    loss += criterion(output, output_sentences[:, i].view(bs))     
                    # el siguiente input será la palbra predicha
                    decoder_input = torch.argmax(output, axis=1).view(bs, 1)
                val_loss.append(loss.item())
                bar.set_description(f"Epoch {epoch}/{epochs} val_loss {np.mean(val_loss):.5f}")
fit(encoder, decoder, dataloader, epochs=30)
Epoch 1/30 loss 38.78804: 100%|██████████████| 1557/1557 [01:04<00:00, 24.02it/s]
Epoch 1/30 val_loss 76.18832: 100%|██████████████| 98/98 [00:06<00:00, 15.49it/s]
Epoch 2/30 loss 30.97937: 100%|██████████████| 1557/1557 [01:04<00:00, 24.09it/s]
Epoch 2/30 val_loss 69.06664: 100%|██████████████| 98/98 [00:06<00:00, 15.48it/s]
Epoch 3/30 loss 27.39243: 100%|██████████████| 1557/1557 [01:04<00:00, 24.14it/s]
Epoch 3/30 val_loss 67.37951: 100%|██████████████| 98/98 [00:06<00:00, 15.49it/s]
Epoch 4/30 loss 24.87624: 100%|██████████████| 1557/1557 [01:05<00:00, 23.82it/s]
Epoch 4/30 val_loss 65.11647: 100%|██████████████| 98/98 [00:06<00:00, 14.93it/s]
Epoch 5/30 loss 22.92705: 100%|██████████████| 1557/1557 [01:05<00:00, 23.84it/s]
Epoch 5/30 val_loss 64.65363: 100%|██████████████| 98/98 [00:06<00:00, 15.45it/s]
Epoch 6/30 loss 21.34507: 100%|██████████████| 1557/1557 [01:04<00:00, 24.04it/s]
Epoch 6/30 val_loss 63.20858: 100%|██████████████| 98/98 [00:06<00:00, 15.48it/s]
Epoch 7/30 loss 20.02982: 100%|██████████████| 1557/1557 [01:04<00:00, 24.05it/s]
Epoch 7/30 val_loss 63.83483: 100%|██████████████| 98/98 [00:06<00:00, 15.46it/s]
Epoch 8/30 loss 18.92103: 100%|██████████████| 1557/1557 [01:04<00:00, 24.04it/s]
Epoch 8/30 val_loss 63.22257: 100%|██████████████| 98/98 [00:06<00:00, 15.34it/s]
Epoch 9/30 loss 17.95518: 100%|██████████████| 1557/1557 [01:04<00:00, 24.33it/s]
Epoch 9/30 val_loss 63.11448: 100%|██████████████| 98/98 [00:06<00:00, 15.37it/s]
Epoch 10/30 loss 17.12930: 100%|█████████████| 1557/1557 [01:03<00:00, 24.49it/s]
Epoch 10/30 val_loss 63.32658: 100%|█████████████| 98/98 [00:06<00:00, 15.66it/s]
Epoch 11/30 loss 16.40971: 100%|█████████████| 1557/1557 [01:03<00:00, 24.48it/s]
Epoch 11/30 val_loss 64.17402: 100%|█████████████| 98/98 [00:06<00:00, 15.87it/s]
Epoch 12/30 loss 15.77446: 100%|█████████████| 1557/1557 [01:03<00:00, 24.53it/s]
Epoch 12/30 val_loss 64.04707: 100%|█████████████| 98/98 [00:06<00:00, 15.68it/s]
Epoch 13/30 loss 15.20607: 100%|█████████████| 1557/1557 [01:03<00:00, 24.47it/s]
Epoch 13/30 val_loss 65.23469: 100%|█████████████| 98/98 [00:06<00:00, 15.60it/s]
Epoch 14/30 loss 14.70213: 100%|█████████████| 1557/1557 [01:03<00:00, 24.48it/s]
Epoch 14/30 val_loss 65.15614: 100%|█████████████| 98/98 [00:06<00:00, 15.67it/s]
Epoch 15/30 loss 14.23768: 100%|█████████████| 1557/1557 [01:03<00:00, 24.48it/s]
Epoch 15/30 val_loss 65.88125: 100%|█████████████| 98/98 [00:06<00:00, 15.68it/s]
Epoch 16/30 loss 13.82993: 100%|█████████████| 1557/1557 [01:03<00:00, 24.41it/s]
Epoch 16/30 val_loss 65.79273: 100%|█████████████| 98/98 [00:06<00:00, 15.83it/s]
Epoch 17/30 loss 13.44992: 100%|█████████████| 1557/1557 [01:03<00:00, 24.33it/s]
Epoch 17/30 val_loss 66.12863: 100%|█████████████| 98/98 [00:06<00:00, 15.83it/s]
Epoch 18/30 loss 13.10958: 100%|█████████████| 1557/1557 [01:03<00:00, 24.50it/s]
Epoch 18/30 val_loss 67.42295: 100%|█████████████| 98/98 [00:06<00:00, 15.89it/s]
Epoch 19/30 loss 12.78299: 100%|█████████████| 1557/1557 [01:03<00:00, 24.46it/s]
Epoch 19/30 val_loss 66.85257: 100%|█████████████| 98/98 [00:06<00:00, 15.71it/s]
Epoch 20/30 loss 12.48871: 100%|█████████████| 1557/1557 [01:03<00:00, 24.46it/s]
Epoch 20/30 val_loss 67.61935: 100%|█████████████| 98/98 [00:06<00:00, 15.85it/s]
Epoch 21/30 loss 12.22273: 100%|█████████████| 1557/1557 [01:03<00:00, 24.53it/s]
Epoch 21/30 val_loss 68.18817: 100%|█████████████| 98/98 [00:06<00:00, 15.83it/s]
Epoch 22/30 loss 11.97009: 100%|█████████████| 1557/1557 [01:03<00:00, 24.45it/s]
Epoch 22/30 val_loss 68.91714: 100%|█████████████| 98/98 [00:06<00:00, 15.80it/s]
Epoch 23/30 loss 11.74301: 100%|█████████████| 1557/1557 [01:03<00:00, 24.47it/s]
Epoch 23/30 val_loss 69.85561: 100%|█████████████| 98/98 [00:06<00:00, 15.78it/s]
Epoch 24/30 loss 11.51658: 100%|█████████████| 1557/1557 [01:03<00:00, 24.60it/s]
Epoch 24/30 val_loss 69.67949: 100%|█████████████| 98/98 [00:06<00:00, 15.88it/s]
Epoch 25/30 loss 11.32669: 100%|█████████████| 1557/1557 [01:03<00:00, 24.51it/s]
Epoch 25/30 val_loss 69.73891: 100%|█████████████| 98/98 [00:06<00:00, 15.72it/s]
Epoch 26/30 loss 11.13689: 100%|█████████████| 1557/1557 [01:03<00:00, 24.43it/s]
Epoch 26/30 val_loss 71.07707: 100%|█████████████| 98/98 [00:06<00:00, 15.67it/s]
Epoch 27/30 loss 10.96223: 100%|█████████████| 1557/1557 [01:03<00:00, 24.55it/s]
Epoch 27/30 val_loss 71.77095: 100%|█████████████| 98/98 [00:06<00:00, 15.10it/s]
Epoch 28/30 loss 10.78285: 100%|█████████████| 1557/1557 [01:04<00:00, 24.10it/s]
Epoch 28/30 val_loss 71.04236: 100%|█████████████| 98/98 [00:06<00:00, 15.64it/s]
Epoch 29/30 loss 10.61848: 100%|█████████████| 1557/1557 [01:03<00:00, 24.49it/s]
Epoch 29/30 val_loss 71.52895: 100%|█████████████| 98/98 [00:06<00:00, 15.58it/s]
Epoch 30/30 loss 10.47587: 100%|█████████████| 1557/1557 [01:10<00:00, 21.97it/s]
Epoch 30/30 val_loss 72.44889: 100%|█████████████| 98/98 [00:06<00:00, 15.22it/s]

Como puedes ver, la loss de enterenamiento baja indicando que nuestra red está aprendiendo a traducir. Sin embargo, la loss de validación sube indicando que estamos haciendo overfitting. Esto es normal ya que estamos utilizando muy pocos datos para el entrenamiento, para reducir este problema tendrías que utilizar un dataset con más ejemplos.

Generando traducciones

Una vez tenemos nuestro modelo entrenado, podemos utilizarlo para traducir frases del inglés al castellano de la siguiente manera.

input_sentence, output_sentence = dataset['train'][129]
input_lang.sentenceFromIndex(input_sentence.tolist()), output_lang.sentenceFromIndex(output_sentence.tolist())
(['come', 'in', '.', 'EOS'], ['pase', '.', 'EOS'])
def predict(input_sentence):
    # obtenemos el último estado oculto del encoder
    hidden = encoder(input_sentence.unsqueeze(0))
    # calculamos las salidas del decoder de manera recurrente
    decoder_input = torch.tensor([[output_lang.word2index['SOS']]], device=device)
    # iteramos hasta que el decoder nos de el token <eos>
    outputs = []
    while True:
        output, hidden = decoder(decoder_input, hidden)
        decoder_input = torch.argmax(output, axis=1).view(1, 1)
        outputs.append(decoder_input.cpu().item())
        if decoder_input.item() == output_lang.word2index['EOS']:
            break
    return output_lang.sentenceFromIndex(outputs)
predict(input_sentence)
['entra', '.', 'EOS']

Resumen

En este post hemos aprendido a implementar una arquitectura encoder-decoder que nos permite trabajar con secuencia de longitud arbitraria tanto en las entradas como en las salida. El ejemplo de aplicación que hemos llevado a cabo es la traducción de texto. Esta arquitectura es muy versátil y puede utilizarse, con pequeños cambios, para otras aplicaciones como generación de descripciones a partir de imágenes (cambiando el encoder por una red convolucional, por ejemplo). Si bien esta arquitectura nos permite obtener buenos resultados, se ve limitada en el caso en el que trabajemos con secuencias muy largas, ya que el último estado del encoder es responsable de codificar todo el significado de la frase original, lo cual puede ser difícil. Podemos mejorar esta arquitectura añadiendo una capa de atenttion en el decoder, el cual no solo recibirá este estado oculto del encoder si no que además será capaz de mirar a todas las salidas del mismo para decidir, en cada caso, la mejor palabra a traducir. En el próximo post veremos como implementar este nuevo mecanismo.

< Blog RSS