QuiddityML

By · 4 November 2026 · 9 min read

Encoder-decoder (seq2seq) models explained

What the encoder and the decoder each compute, how one context vector connects them in PyTorch code, and why that single vector struggles with long inputs.

An encoder-decoder model is two networks joined together. The encoder reads an input sequence and turns it into vectors of numbers, and the decoder turns those vectors into an output sequence one token at a time. It is the classic way to build sequence-to-sequence (seq2seq) models for tasks such as translation, where the input and the output are both sequences and their lengths can differ. In text, a token is a whole word or a piece of a word.

What kind of task needs seq2seq?

Many tasks have a fixed relation between input and output length:

Translation fits none of these. The English "thank you very much" has four words and the German "danke schön" has two, and the word order can change between languages too. Sequence-to-sequence is the many-to-many pattern where the input and output lengths can differ. The model reads a source sequence and then produces a target sequence.

Four task shapes: many-to-one for sentiment, same-length many-to-many for tagging, one-to-many for captioning, and seq2seq for translation, with five source items S mapped to seven output items U.

Encoder-decoder is one way to build this shape with recurrent networks. A decoder-only model, which only generates text, can also translate when the source text is placed at the start of its input.

What an encoder is

An encoder is a model, or part of a model, that reads input data and turns it into internal representations. These representations are vectors of numbers that describe the input. The input can be a sentence, an image or audio. The encoder's output is usually not the final answer. It is something another component reads, such as a classifier head, a tagger or a decoder.

The word encoder names the job the network does. An encoder can be a recurrent network, an LSTM or a GRU, and it can also be a convolutional network or a transformer. For an image, the encoder often returns a grid of vectors called a feature map.

In the diagram below, B is the batch size (how many sequences go in together), T is the number of steps and H is the hidden size.

An encoder taking text, image or audio and producing hidden states, a summary vector or a feature map, with a token row x1 to x4 read by an RNN into h1 to h4, and outputs of shape B, T, H for all steps and hn[-1] of shape B, H for the last step.

What an RNN encoder returns

Here the encoder is a recurrent neural network (RNN). It reads the source tokens $\mathbf{x}_1, \ldots, \mathbf{x}_S$ one at a time, where $S$ is the source length and each $\mathbf{x}_t$ is the vector for one token. At each step it updates its hidden state $\mathbf{h}_t$ from the current input and the previous hidden state:

$$\mathbf{h}_t = f(\mathbf{x}_t, \mathbf{h}_{t-1})$$

Here $f$ is the ordinary RNN update, $\tanh(\mathbf{W}_h \mathbf{h}_{t-1} + \mathbf{W}_x \mathbf{x}_t + \mathbf{b})$, with learned matrices $\mathbf{W}_h$ and $\mathbf{W}_x$ and a learned bias $\mathbf{b}$. After the last source token, the encoder can return two things. The first is every hidden state, one per source token:

$$\texttt{output} = [\mathbf{h}_1, \mathbf{h}_2, \ldots, \mathbf{h}_S]$$

The second is the last hidden state alone, which has read the whole sequence:

$$\texttt{summary} = \mathbf{h}_S$$

The task decides which one to use. A classifier usually reads the summary. A tagger reads output, because it needs one vector per token. A basic decoder starts from the summary.

An RNN chain h_1, h_2, h_3 to h_T, with output = [h_1 ... h_T] of shape B, T, H collected from the whole chain and final = h_T of shape B, H taken from the last state.

In PyTorch the encoder wraps one recurrent layer and returns both:

1import torch
2import torch.nn as nn
3 
4class RNNEncoder(nn.Module):
5    def __init__(self, input_size, hidden_size):
6        super().__init__()
7        self.rnn = nn.RNN(
8            input_size, hidden_size, batch_first=True
9        )
10 
11    def forward(self, x):
12        output, hn = self.rnn(x)
13        summary = hn[-1]
14        return output, summary
15 
16torch.manual_seed(0)
17encoder = RNNEncoder(input_size=32, hidden_size=128)
18x = torch.rand(4, 10, 32)  # 4 sentences, 10 tokens each, 32 numbers per token
19output, summary = encoder(x)
20print(output.shape)
21print(summary.shape)
1torch.Size([4, 10, 128])
2torch.Size([4, 128])

With batch_first=True, the input is laid out as (batch, source length, input size). output keeps one 128-number hidden state for each of the 10 tokens. hn has an extra first dimension that counts layers, and hn[-1] takes the last layer's final state, one vector per sentence. The encoder does not produce any target tokens by itself.

The RNNEncoder code with its shapes: x is (B, S, D), output is (B, S, H) and summary is (B, H), where B is batch size, S source length, D input size and H hidden size.

What a decoder is

A decoder is a model, or part of a model, that turns an internal representation into outputs. What it outputs depends on the task. An image decoder can produce pixels and an audio decoder can produce chunks of a waveform. In text generation, the decoder produces logits one step at a time. Logits are one score for every token in the vocabulary.

The softmax function turns logits into probabilities that add up to 1. For example, logits of $[2.0, 1.0, 0.1]$ over a three-token vocabulary become probabilities of about $[0.659, 0.242, 0.099]$.

A text decoder needs a previous token at every step, including the first one. So the target side starts with a special start token, often written BOS for "beginning of sequence".

A decoder turning an internal state of shape (B, H) into token logits, pixels or audio chunks, and below it three decoder cells starting from the context and a BOS token, each producing a token y1, y2, y3 that is fed into the next cell as the previous token.

What the decoder computes at each step

The decoder counts its own steps with $u$, so $y_u$ is the target token at step $u$. It keeps its own state $\mathbf{s}_u$, a different letter from the encoder's $\mathbf{h}_t$ to keep the two apart. At step $u$ it reads $\mathbf{y}_{u-1}$, the embedding of the previous target token (the vector that represents that token), together with its previous state $\mathbf{s}_{u-1}$:

$$\mathbf{s}_u = f_{\text{dec}}(\mathbf{y}_{u-1}, \mathbf{s}_{u-1})$$

A linear layer with weights $\mathbf{W}_{\text{out}}$ and bias $\mathbf{b}_{\text{out}}$, called the vocabulary head, maps the state to logits $\mathbf{o}_u$:

$$\mathbf{o}_u = \mathbf{W}_{\text{out}}\mathbf{s}_u + \mathbf{b}_{\text{out}}$$

Softmax turns those logits into a probability for every possible next token. $y_{<u}$ means all target tokens before step $u$, and the context is whatever the decoder started from:

$$P(y_u \mid y_{<u}, \text{context}) = \mathrm{softmax}(\mathbf{o}_u)$$

During training, the previous-token inputs usually come from the correct target sentence. During generation, the decoder feeds the token it chose at step $u$ back in as the previous token for step $u + 1$.

One decoder step: y_(u-1) and s_(u-1) enter the decoder cell, which outputs s_u, then the linear head with W_out and b gives logits o_u, softmax picks the next token y_u, and y_u loops back as the next step's previous token.

In PyTorch the decoder wraps one recurrent layer and one vocabulary head:

1class RNNDecoder(nn.Module):
2    def __init__(self, input_size, hidden_size, vocab_size):
3        super().__init__()
4        self.rnn = nn.RNN(
5            input_size, hidden_size, batch_first=True
6        )
7        self.fc = nn.Linear(hidden_size, vocab_size)
8 
9    def forward(self, prev_embs, hidden):
10        output, hidden = self.rnn(prev_embs, hidden)
11        logits = self.fc(output)
12        return logits, hidden
13 
14decoder = RNNDecoder(input_size=32, hidden_size=128, vocab_size=1000)
15prev_embs = torch.rand(4, 8, 32)   # 4 targets, 8 previous-token embeddings each
16hidden0 = torch.zeros(1, 4, 128)   # starting state
17logits, hidden = decoder(prev_embs, hidden0)
18print(logits.shape)
19print(hidden.shape)
1torch.Size([4, 8, 1000])
2torch.Size([1, 4, 128])

Here random vectors stand in for real token embeddings. The decoder returns one logits vector per target step, so 8 previous-token inputs give 8 rows of 1,000 scores for each of the 4 targets. The starting state hidden0 can be zeros, a learned state or the output of an encoder, and that last case is the encoder-decoder model.

The RNNDecoder code with its shapes: prev_embs is (B, U, D), hidden is (1, B, H), output is (B, U, H) and logits is (B, U, V), where U is target length and V vocabulary size.

How the encoder and decoder connect

In the basic RNN version, the encoder compresses the whole source sequence into one context vector $\mathbf{c}$, its final hidden state:

$$\mathbf{c} = \mathbf{h}^{\text{enc}}_S$$

The decoder uses that context as its starting state:

$$\mathbf{s}_0 = \mathbf{c}$$

Each target step then runs the decoder update and the vocabulary head. The probability of each target token is now conditioned on the whole source sequence $\mathbf{x}_{1:S}$, because the source reached the decoder through $\mathbf{s}_0$:

$$P(y_u \mid y_{<u}, \mathbf{x}_{1:S}) = \mathrm{softmax}(\mathbf{W}_{\text{out}}\mathbf{s}_u + \mathbf{b}_{\text{out}})$$

The encoder loop runs over the $S$ source positions and the decoder loop runs over the $U$ target positions. $S$ and $U$ do not have to match, which is what lets the model handle four English words in and two German words out.

The encoder-decoder: x_1 to x_S feed encoder cells whose last state becomes the representation c, c becomes the decoder's starting state s_0, and the decoder cells start from BOS and feed each output token y_1, y_2 to y_U into the next cell as the previous token.

The combined model wires the two parts together. Here the encoder returns hidden with its layer dimension still in place, because that is the shape nn.RNN expects as a starting state:

1class Encoder(nn.Module):
2    def __init__(self, input_size, hidden_size):
3        super().__init__()
4        self.rnn = nn.RNN(input_size, hidden_size, batch_first=True)
5 
6    def forward(self, src):
7        output, hidden = self.rnn(src)
8        return hidden
9 
10class Decoder(nn.Module):
11    def __init__(self, input_size, hidden_size, vocab_size):
12        super().__init__()
13        self.rnn = nn.RNN(input_size, hidden_size, batch_first=True)
14        self.fc = nn.Linear(hidden_size, vocab_size)
15 
16    def forward(self, tgt, hidden):
17        output, hidden = self.rnn(tgt, hidden)
18        logits = self.fc(output)
19        return logits, hidden
20 
21class EncoderDecoder(nn.Module):
22    def __init__(self, encoder, decoder):
23        super().__init__()
24        self.encoder = encoder
25        self.decoder = decoder
26 
27    def forward(self, src, tgt):
28        context = self.encoder(src)
29        logits, _ = self.decoder(tgt, context)
30        return logits
31 
32model = EncoderDecoder(
33    Encoder(input_size=64, hidden_size=128),
34    Decoder(input_size=64, hidden_size=128, vocab_size=1000),
35)
36src = torch.rand(4, 10, 64)  # 4 source sentences, 10 tokens each
37tgt = torch.rand(4, 8, 64)   # 4 target inputs, 8 tokens each
38print(model.encoder(src).shape)
39print(model(src, tgt).shape)
1torch.Size([1, 4, 128])
2torch.Size([4, 8, 1000])

The source has 10 tokens and the target has 8, and the logits come out with 8 steps. The output length follows tgt, because the decoder produces one logits vector per target-side input.

Wiring the encoder to the decoder: src goes into the encoder, its hidden state becomes the context for the decoder, tgt goes into the decoder, and logits = self.fc(output), with the note that the output length follows tgt and not src.

The bottleneck of one context vector

A recurrent network's hidden state has a fixed size no matter how long the input is. LSTMs and GRUs have gates that help carry information through time, but their state is still a fixed number of values. In a basic encoder-decoder, the same limit shows up between the two networks. Everything the decoder needs about the source has to pass through the one vector $\mathbf{c}$, including what the tokens say, their order and how they relate to each other.

For short inputs this can work well. For long inputs, the encoder may lose details from the start of the sequence before the decoder ever begins, because the last tokens it read tend to be represented best in its final state.

A fix is to let the decoder look back at all the encoder states and pick the ones it needs for each output step, instead of receiving only the final one. That is what attention does, and it is a step from this basic model toward the transformer.

A 100-token input read by an encoder into one context vector c that feeds the decoder, a chart where information kept in c rises from low for early tokens to high for recent ones, and the note that the decoder could look at all encoder states instead, which is what attention answers.

Common mistakes

The RNN post covers the recurrent update that both the encoder and the decoder use here. An encoder-decoder can use LSTM or GRU cells in place of the plain RNN. With LSTM cells, the encoder's final state is the pair (hn, cn) and the decoder starts from both. Attention removes the single-vector handoff by letting each decoder step read every encoder state. Choosing which token to feed back during generation is its own topic, with methods such as greedy decoding, sampling and beam search.

QuiddityML teaches encoders, decoders and the encoder-decoder model as three concepts in the language models unit of its NLP track. The exercises include ordering the lines of RNNEncoder, following tensor shapes from src and tgt to the logits and writing the full encoder-decoder from scratch (QuiddityML).