ml-models / Language models / Encoder-only models core

Encoder-only models

BERT and its descendants read a whole sentence at once, every token looking both left and right, and turn it into one vector per token. They can't write text, but for classifying, tagging, matching and searching they were the default for years and are still the cheapest strong option. This page shows what changes when the causal mask is removed, how masked-language pre-training replaces next-token prediction, counts BERT-base's 108,490,752 parameters, and builds one in PyTorch v2.15.0.

On this page
  1. Problem
  2. Intuition
  3. Mechanics
  4. Bidirectional attention: delete the mask
  5. Padding and the key padding mask
  6. Masked language modelling
  7. The block, post-norm
  8. Using the outputs
  9. Worked example
  10. Corrupting one sentence pair
  11. Why both sides of the blank help
  12. Counting BERT-base and BERT-large
  13. Implementation
  14. Tradeoffs
  15. Check yourself
  16. Connections
  17. Read first
  18. Read next
  19. References

Problem

Many language tasks are about understanding a piece of text that already exists: is this review positive, which words in this sentence are names, does this passage answer that question, are these two sentences paraphrases. For all of them the whole input is available up front, so there is no reason to hide the end of the sentence from its beginning. The word "bank" in "she sat on the bank of the river" is only resolved by words that come after it.

A decoder-only model can't use that right-hand context: its causal mask exists so it can generate. We want the opposite trade, a model whose every output vector is built from the entire input, plus a way to pre-train it on unlabelled text, because labelled data for each task is scarce. Next-token prediction doesn't work as that pre-training task once every token can see every other token: the answer would be sitting in the input.

Intuition

Think of a cloze test: a paragraph with some words blanked out, and you fill each blank using everything around it. An encoder-only model is trained on exactly that. Hide some tokens, let every position attend to every other position, and ask the model to recover the hidden ones. To do it well it has to build, at every position, a vector that summarises the word in its full context, and those vectors are what you keep and fine-tune for downstream tasks.

The architecture is the encoder half of the Transformer, unchanged: no causal mask, no cross-attention. Only two things are new, both about the input: a learned segment embedding so that one sequence can hold two sentences, and special tokens: [CLS] at the front, whose final vector stands for the whole input, and [SEP] after each sentence.

BERT's input: three embeddings summed per token The sentence pair "[CLS] the cat sat on the mat [SEP] it was warm [SEP]" laid out as 12 tokens. Under each token are three rows: its token embedding, its segment embedding (A for the first sentence including the first [SEP], B for the second) and its position embedding, numbered 0 to 11. The three vectors are added and then normalized with LayerNorm before the first encoder block. token segment position [CLS] + A + 0 the + A + 1 cat + A + 2 sat + A + 3 on + A + 4 the + A + 5 mat + A + 6 [SEP] + A + 7 it + B + 8 was + B + 9 warm + B + 10 [SEP] + B + 11 sum → LayerNorm → dropout [B, 12, 768] 12 encoder blocks, no causal mask

Segment A covers [CLS], the first sentence and its [SEP]; segment B covers the second sentence and the final [SEP]. A single-sentence input uses segment A throughout.

Mechanics

Bidirectional attention: delete the mask

Self-attention is the same computation as on the attention page; the only difference from a decoder is that no −∞ is added above the diagonal. Here are the attention weights for the four-token example (the, cat, sat, down) both ways:

Attention weights with and without the causal mask Two four by four grids of attention weights for the tokens the, cat, sat, down, queries as rows and keys as columns. On the left, the encoder: every cell has a nonzero weight, so the first token attends to all four. On the right, the decoder: cells above the diagonal are zero and the first token attends only to itself with weight 1. The last row is the same in both grids. encoder (bidirectional) thecatsatdown the 0.22 0.11 0.22 0.45 cat 0.14 0.29 0.29 0.29 sat 0.04 0.73 0.18 0.04 down 0.12 0.12 0.25 0.51 decoder (causal) thecatsatdown the 1.00 0.00 0.00 0.00 cat 0.33 0.67 0.00 0.00 sat 0.05 0.77 0.19 0.00 down 0.12 0.12 0.25 0.51

Rows sum to 1 in both grids. Only the last token's row is identical, because it is the one position that already sees everything under the causal mask.

The price is the property decoders rely on. Drop the last token and rerun: with the causal mask the first three outputs don't move at all (gap 0.0); without it they change by up to 0.36. Every encoder output depends on the whole input, so you can't append a token and reuse earlier work, and you can't generate left to right. That is the trade: richer per-token vectors, no generation.

Padding and the key padding mask

A batch holds sequences of different lengths, so shorter ones are filled with [PAD] to a common width. In our batch the pair has 12 tokens and the single sentence [CLS] the cat sat [SEP] has 5, followed by 7 pads. Pads must be invisible: no real token should attend to them. PyTorch takes a src_key_padding_mask of shape [B, T] in which True means "ignore this key" (documented on MultiheadAttention); internally a boolean mask becomes a float mask with −∞ at the ignored keys (_canonical_mask).

The check: take the first three tokens of the attention example, append a pad whose key and value are zero, and attend. With the pad masked, the three real outputs equal the outputs of the three-token sequence alone, gap 0.0. Without the mask the pad still takes attention weight 0.165, 0.089, 0.043 from the three real rows (a zero key scores 0, which is not −∞) and the outputs move by up to 0.13. Forgetting the padding mask doesn't crash; it quietly makes a sentence's vectors depend on how long its batch-mates were.

The pad's own row still gets computed (here it averages the three real tokens), so it produces some vector; nothing downstream should read it. When TransformerEncoder runs in inference mode with a padding mask it can take a fast path that packs the batch into a nested tensor, skipping pads entirely, and then unpacks with zeros at the padded positions (lines 531-550), so pad outputs differ between training and evaluation. Another reason to never use them.

Masked language modelling

BERT's pre-training picks 15% of the token positions (implementations skip [CLS], [SEP] and pads) and asks the model to predict the original token at each. Of the chosen positions, 80% are replaced by [MASK], 10% by a random token and 10% are left as they are. The mix exists because [MASK] never appears at fine-tuning time: if it were the only corruption, the model could learn that only [MASK] positions need a good representation. Random and unchanged tokens force it to keep every position's vector honest.

The loss is cross-entropy over the vocabulary, but only at chosen positions. Every other label is set to −100, the default ignore_index of CrossEntropyLoss (line 1389); ignored targets contribute nothing, and the mean is taken over the rest (lines 1278-1281).

BERT also had a second objective, next sentence prediction: the [CLS] vector classifies whether sentence B really followed sentence A (half the pairs) or was drawn at random. It is why the input has segments. Later work such as RoBERTa reported that dropping it didn't hurt, and most successors pre-train with masked tokens alone.

The block, post-norm

BERT keeps the original Transformer's order: x ← LayerNorm(x + Attention(x)), then x ← LayerNorm(x + FFN(x)). That is PyTorch's default, norm_first=False (lines 745-755 list the defaults; lines 945-957 are the two branches). Because every block ends in a LayerNorm, no final norm is needed, unlike the pre-norm decoder. The feed-forward width is 4 × d with GELU, which PyTorch's layer accepts as activation="gelu" (lines 1204-1210); the layer's default is ReLU.

Using the outputs

The encoder returns a [B, T, d] tensor. Fine-tuning adds one small layer on top, chosen by the task: a classifier on the [CLS] vector for sentence or sentence-pair labels; a classifier on every token's vector for tagging (named entities, parts of speech); two vectors that score each token as the start or end of an answer span for extractive question answering. The whole model is then trained end to end for a few epochs at a small learning rate. For embeddings and search, a pooled vector (the [CLS] vector, or the mean of the non-pad token vectors) represents the whole text.

ComponentShape/ParamsMeaningSource
nn.Embedding (tokens) [30000, 768]; 23,040,000 params Token id → vector. padding_idx keeps the pad row at zero and never updates it. sparse.py (23-27)
nn.Embedding (positions) [512, 768]; 393,216 params One learned vector per position; caps the input at 512 tokens. sparse.py
nn.Embedding (segments) [2, 768]; 1,536 params Sentence A or B. sparse.py
nn.LayerNorm (embeddings) [768] × 2; 1,536 params Normalizes the summed embedding before the first block. normalization.py
TransformerEncoderLayer (norm_first=False) d_model=768, nhead=12, dim_feedforward=3072; 7,087,872 params each A post-norm block: attention, add, norm; feed-forward, add, norm. transformer.py
TransformerEncoder(layer, 12) 12 copies; src_key_padding_mask [B, T] bool Runs the blocks; True in the mask marks pads. transformer.py
MultiheadAttention key_padding_mask [B, S] Binary: True ignores the key. Float: added to the scores. activation.py
F.cross_entropy(…, ignore_index=-100) logits [B·T, 30000], labels [B·T] Masked-LM loss averaged over the chosen positions only. functional.py

Worked example

Corrupting one sentence pair

The pair has 12 tokens, 9 of them ordinary words. 15% of 9 rounds to 1, so one position is chosen (position 4, "on"), and the draw sends it down the 80% branch:

Position01234567891011
Original[CLS]thecatsatonthemat[SEP]itwaswarm[SEP]
SegmentAAAAAAAABBBB
Model input[CLS]thecatsat[MASK]themat[SEP]itwaswarm[SEP]
Label−100−100−100−100on−100−100−100−100−100−100−100

Eleven of twelve positions carry no loss. That is the efficiency cost of masked language modelling: a decoder gets 11 training signals from this sequence, the encoder one.

Over 100,000 tokens with the same rule and a ten-word vocabulary, 15,000 were chosen; 11.93% of all tokens became [MASK], 1.51% a random word and 1.56% stayed but still carried a label, close to 12%, 1.5% and 1.5%. 125 random replacements happened to draw the original word (a one-in-ten chance here, rare with a real vocabulary), so 13.31% of the input actually differed from the text.

Why both sides of the blank help

A three-word toy language: a left word from {a, b, c}, a middle from {p, q, r}, a right word from {x, y, z}. Six training sentences are chosen so that each left word is followed by two possible middles and each right word is preceded by two. A model sees a test sentence with the middle blanked and scores the true middle three ways (counts with add-½ smoothing): from the left word only, from the right word only, or from both, using P(m | l, r) ∝ P(m | l) · P(r | m).

Test sentenceP(true | left)P(true | right)P(true | both)Best guess, both
a _ z (true p)0.4290.4290.600p
a _ y (true q)0.4290.4290.600q
b _ x (true q)0.4290.4290.600q
b _ z (true r)0.4290.4290.600r
c _ x (true p)0.4290.4290.600p
c _ y (true r)0.4290.4290.600r
Mean loss −ln P0.84730.84730.5108

Each side alone leaves two middles tied at 0.429, a coin flip; together they pick the right one every time with probability 0.6, and the loss drops from 0.847 to 0.511. A left-to-right model only ever has the first column. Four of the six test sentences never occur in training, and they are resolved just as well: the model combines two pieces of evidence rather than recalling whole sentences.

Counting BERT-base and BERT-large

Counts come from encoder_param_count, which the tests check against the decoder's per-block count: the block is the same; only the embeddings and the final norm differ.

PartFormulaBERT-baseBERT-large
Width d, layers L, heads768, 12, 121024, 24, 16
Token tableV × d23,040,00030,720,000
Position table512 × d393,216524,288
Segment table2 × d1,5362,048
Embedding LayerNorm2·d1,5362,048
Attention, per block4·d² + 4·d2,362,3684,198,400
Feed-forward, per block2·d·4d + 4d + d4,722,4328,393,728
Two LayerNorms, per block2 × (2·d)3,0724,096
All blocksL × block85,054,464302,309,376
Totalembeddings + blocks108,490,752333,557,760
Reported in the paper110M340M

The formula gives 108.5M and 333.6M against the rounded 110M and 340M in the paper; the vocabulary is "30,000" there too, and the released checkpoints add task heads on top of the encoder. Embeddings are 21.6% of BERT-base and 9.4% of BERT-large: as models widen, the blocks take over.

Implementation

import torch
from torch import nn
import torch.nn.functional as F

class Encoder(nn.Module):
    def __init__(self, vocab=30000, positions=512, d=768, layers=12, heads=12, pad_id=0):
        super().__init__()
        self.tok = nn.Embedding(vocab, d, padding_idx=pad_id)
        self.pos = nn.Embedding(positions, d)
        self.seg = nn.Embedding(2, d)
        self.norm = nn.LayerNorm(d)
        self.drop = nn.Dropout(0.1)
        block = nn.TransformerEncoderLayer(
            d, heads, dim_feedforward=4 * d, dropout=0.1,
            activation="gelu", batch_first=True,          # norm_first=False: post-norm
        )
        self.blocks = nn.TransformerEncoder(block, layers)
        self.mlm = nn.Linear(d, vocab)
        self.pad_id = pad_id

    def forward(self, ids, segments):                    # both [B, T]
        T = ids.size(1)
        pos = torch.arange(T, device=ids.device)
        x = self.drop(self.norm(self.tok(ids) + self.pos(pos) + self.seg(segments)))
        pad = ids == self.pad_id                         # True = ignore this key
        return self.blocks(x, src_key_padding_mask=pad)  # [B, T, d]

def mask_tokens(ids, special, vocab, mask_id, rate=0.15):
    labels = ids.clone()
    chosen = (torch.rand(ids.shape) < rate) & ~special
    labels[~chosen] = -100                               # ignored by cross_entropy
    roll = torch.rand(ids.shape)
    inputs = ids.clone()
    inputs[chosen & (roll < 0.8)] = mask_id
    swap = chosen & (roll >= 0.8) & (roll < 0.9)
    inputs[swap] = torch.randint(vocab, (int(swap.sum()),))
    return inputs, labels                                # the last 10% stay as they are

model = Encoder()
ids = torch.randint(5, 30000, (2, 12))
special = torch.zeros_like(ids, dtype=torch.bool)
special[:, 0] = True                                     # the [CLS] slot
inputs, labels = mask_tokens(ids, special, 30000, mask_id=4)
hidden = model(inputs, torch.zeros_like(ids))
loss = F.cross_entropy(model.mlm(hidden).reshape(-1, 30000), labels.reshape(-1))
cls_vector = hidden[:, 0]                                # [B, d], for a classifier head

Things to know. padding_idx zeroes the pad row at construction (lines 179-186) and stops it from updating. The encoder takes the padding mask as src_key_padding_mask, converted with _canonical_mask (lines 433-439); a sequence's real tokens should come first, which the fast path checks when mask_check=True. Selection here is a coin flip per token, so the count varies around 15%; the page's helper instead samples an exact count. A real pipeline also never draws [CLS], [SEP] or pads, which is what special is for. Fine-tuning replaces self.mlm with a head for the task, often reading hidden[:, 0].

Tradeoffs

  • Encoder-only versus decoder-only. For the same size, a bidirectional encoder usually gives better vectors for classification, tagging and retrieval, and a whole input is processed in one parallel pass. It can't generate, and it can't reuse work when the input grows by one token. Decoders trade some understanding per parameter for one model that does every task as text, which is why they took over once models became large.
  • Sample efficiency. Only about 15% of positions carry a loss, against nearly 100% for next-token prediction. Variants such as ELECTRA score every position (was this token replaced?) to recover the signal.
  • The [MASK] mismatch. Pre-training inputs contain a token fine-tuning inputs never do. The 80/10/10 split softens it; it doesn't remove it.
  • Length. A learned table of 512 positions is a hard cap, and full attention costs grow with T². Long documents get split into windows.
  • Pitfalls. A missing padding mask (outputs depend on batch-mates), reading pad outputs, labelling unmasked positions instead of −100 (the model learns to copy), and using the raw [CLS] vector of a model that was never fine-tuned as a sentence embedding, which often works poorly without training for that purpose.

Check yourself

Why can't an encoder be trained by plain next-token prediction?

Every position attends to every other, so the next token is in the input; the model would learn to copy it. Hiding the target tokens is what makes the task non-trivial.

A key has score 0 and isn't masked. Does it get attention?

Yes. Softmax gives every finite score a positive weight; in the padding check an unmasked zero-key pad took up to 0.165 of a row. Only −∞ gives weight 0.

Why does BERT leave 10% of the chosen tokens unchanged?

So the model can't assume a visible token is always correct and stop building a good vector for it; at fine-tuning time there are no [MASK] tokens at all.

Where does BERT-base's count differ from GPT-2 small's, block for block?

Nowhere: both have width 768 and the same block of 7,087,872 parameters. The differences are the embeddings (vocabulary, segments, embedding LayerNorm) and the missing final LayerNorm.

Connections

Read first

Read next

No page builds on this one yet.

References