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.
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:
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.
| Component | Shape/Params | Meaning | Source |
|---|---|---|---|
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:
| Position | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Original | [CLS] | the | cat | sat | on | the | mat | [SEP] | it | was | warm | [SEP] |
| Segment | A | A | A | A | A | A | A | A | B | B | B | B |
| Model input | [CLS] | the | cat | sat | [MASK] | the | mat | [SEP] | it | was | warm | [SEP] |
| Label | −100 | −100 | −100 | −100 | on | −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 sentence | P(true | left) | P(true | right) | P(true | both) | Best guess, both |
|---|---|---|---|---|
| a _ z (true p) | 0.429 | 0.429 | 0.600 | p |
| a _ y (true q) | 0.429 | 0.429 | 0.600 | q |
| b _ x (true q) | 0.429 | 0.429 | 0.600 | q |
| b _ z (true r) | 0.429 | 0.429 | 0.600 | r |
| c _ x (true p) | 0.429 | 0.429 | 0.600 | p |
| c _ y (true r) | 0.429 | 0.429 | 0.600 | r |
| Mean loss −ln P | 0.8473 | 0.8473 | 0.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.
| Part | Formula | BERT-base | BERT-large |
|---|---|---|---|
| Width d, layers L, heads | 768, 12, 12 | 1024, 24, 16 | |
| Token table | V × d | 23,040,000 | 30,720,000 |
| Position table | 512 × d | 393,216 | 524,288 |
| Segment table | 2 × d | 1,536 | 2,048 |
| Embedding LayerNorm | 2·d | 1,536 | 2,048 |
| Attention, per block | 4·d² + 4·d | 2,362,368 | 4,198,400 |
| Feed-forward, per block | 2·d·4d + 4d + d | 4,722,432 | 8,393,728 |
| Two LayerNorms, per block | 2 × (2·d) | 3,072 | 4,096 |
| All blocks | L × block | 85,054,464 | 302,309,376 |
| Total | embeddings + blocks | 108,490,752 | 333,557,760 |
| Reported in the paper | 110M | 340M |
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
- The Transformer core
Read next
No page builds on this one yet.
References
- Devlin, Chang, Lee and Toutanova. BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding. NAACL, 2019 (arXiv 1810.04805).
- Liu et al. RoBERTa: A Robustly Optimized BERT Pretraining Approach. 2019 (arXiv 1907.11692).
- Clark, Luong, Le and Manning. ELECTRA: Pre-training Text Encoders as Discriminators Rather Than Generators. ICLR, 2020 (arXiv 2003.10555).
- Vaswani et al. Attention Is All You Need. NeurIPS, 2017 (arXiv 1706.03762).
- Hendrycks and Gimpel. Gaussian Error Linear Units (GELUs). 2016 (arXiv 1606.08415).
- PyTorch v2.15.0:
transformer.py,activation.py,sparse.py,loss.py,functional.py.