ML: Recurrent Networks in PyTorch


Introduction


Encoder-Decoder

Suppose we have two different recurrent networks. The first is called Encoder, and the second is Decoder. The encoder receives text in some language (source) as input, while the decoder should output text in another language (target) at its output. The last cell of the RNN encoder contains at its output the hidden state vector $\mathbf{h}_e^{\text{last}}$ of its last cell. This vector "stores" information about the entire source sentence (the context vector). It is fed as the initial hidden state to the first cell of the RNN decoder.

Then the service token <BOS> (begin of sentence) is fed to the input of the first decoder cell. The word-translation "кот" should appear at the cell output. This means that the output of each cell is passed through a linear layer with the number of neurons equal to the number of words in the vocabulary. Then the softmax function outputs word probabilities, from which the index of the maximum is selected (argmax). The resulting word "кот" is passed to the input of the second cell and so on until the service token <EOS> (end of sentence) is obtained.

A faster but less accurate training mode is called teacher forcing. In this case, the correct target translation is fed to all decoder inputs at once, and the decoder is required to output this sentence shifted one word to the left. Usually, random switching occurs between the "honest" and "forced" training modes.


Encoder-Decoder implementation

Here is an implementation of the Encoder-Decoder architecture in PyTorch. Let the source language vocabulary size be VOC_SIZE and the embedding vector dimension E = VEC_DIM. Then the encoder module looks like this:

VEC_DIM = 100

class EncoderRNN(nn.Module):
    def __init__(self, VOC_SIZE, E):                  # vocabulary size & embedding dimension
        super(EncoderRNN, self).__init__()
 
        self.emb = nn.Embedding(VOC_SIZE, E, scale_grad_by_freq=True)      
        self.rnn = nn.GRU(E, E, bidirectional=True)   # bidirectional GRU   
  
    def forward(self, X):                               
        """ X:(B,L)  B - number of sentences with L words each. Zero means absence of a word """
         
        lens = torch.tensor([ len(x)-len(x[x==0]) for x in X ])
         
        emb = self.emb( X.t() )                       # (B,L) -> (L,B) -> (L,B,E)     
         
        Xp   = pack_padded_sequence(emb, lens, enforce_sorted=False)        
                  
        _, Hn = self.rnn(Xp)                          # (2,B,E)
        Hn = torch.cat([Hn[0],Hn[1]], dim=1)          # (B,2*E)
         
        return Hn.view(1,-1, Hn.size(1))              # (1,B,2*E) only hidden state

We will send one sentence at a time to the encoder (batch_size=1) of variable length L in the form of a vector of L integers (long). At the output, the encoder returns a pair: Y - tensor (L,1,E) of all cell outputs and the output Hid of the last cell.

class DecoderRNN(nn.Module):
    def __init__(self, VOC_SIZE, E):
        super(DecoderRNN, self).__init__()

        self.emb = nn.Embedding(VOC_SIZE, E, scale_grad_by_freq=True)
        self.rnn = nn.GRU(E, 2*E)                    # hidden from bidirectional
        self.out = nn.Linear(2*E, VOC_SIZE)        

    def forward(self, Hid, X = None, forcing = False):     #  Hid:(1,B,2*E), X:(B,L)
        max_len = MAX_EN_LEN if X is None else len(X[0])   # maximum sentence length
                
        W    = torch.empty( Hid.size(1),           dtype=torch.long ).fill_(BOS_INDEX)
        Wrds = torch.zeros( Hid.size(1),  max_len, dtype=torch.long  ) # predicted words
        Prbs = torch.ones ( Hid.size(1),  max_len, dtype=torch.float )  # probabilities 
        
        for i in range(max_len):                           #                
            W = self.emb( W.view(1,-1) )                   # (1,B,E)
            Y, Hid = self.rnn(W, Hid)                      # (1,B,2*E)
            
            Y = self.out (Y[0])                            # (B,VOC_SIZE)
            Y = torch.softmax( Y, dim=1 )                  # (B,VOC_SIZE)
            _, W =  Y.detach().topk(1, dim=1)              #  (B,1)            
            
            Wrds[:,i].copy_(W.squeeze())                   # remove 1 and save
            if not X is None and i < X.size(1):
                for b in range(X.size(0)): Prbs[b,i] = Y[b, X[b,i]]
            if forcing:
                W.copy_( X[:,i].view(-1,1) )
                        
        return Wrds, Prbs                                  # (B,L), (B,L)
The combined model:
class EncoderDecoderRNN(nn.Module):
    def __init__(self, encoder, decoder):
        super(EncoderDecoderRNN, self).__init__()
        
        self.enc = encoder
        self.dec = decoder

    def forward(self, sourse, target, forcing=False):           # (L1,)  (L2,)
        Hd = self.enc(sourse)        
        return self.dec(Hd, target, forcing)
                    
gpu = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
cpu = torch.device("cpu")

encoder = EncoderRNN(len(voc_en), VEC_DIM)
decoder = DecoderRNN(len(voc_ru), VEC_DIM)

model = EncoderDecoderRNN(encoder, decoder)                            # model instance 
model.to(gpu)