TL;DR
- BERT pre-trains a 12- or 24-layer Transformer encoder on two self-supervised objectives — masked language modeling (predict selected original tokens) and next-sentence prediction — using only unlabeled text from Books and Wikipedia.
- The key insight is bidirectional context: unlike GPT, every token attends to every other token in both directions, producing richer contextualized representations.
- After pre-training, a small task-specific head is added and the whole model is fine-tuned on labeled data for minutes to hours — yielding state-of-the-art results on eleven NLP tasks, including GLUE and SQuAD at publication.
- BERT established the pretrain-then-finetune paradigm as the dominant approach in NLP, and the encoder-only architecture it pioneered remains foundational to this day.
Masked language modeling
Traditional language models predict the next token given all previous tokens — a left-to-right constraint. BERT breaks this constraint with masked language modeling (MLM): approximately 15% of WordPiece positions are selected for prediction, and the model reconstructs their original tokens from the surrounding context. Because the encoder sees the entire sequence simultaneously, both left and right context inform every prediction. This is the mechanism that makes BERT genuinely bidirectional.
In practice, the 15% of selected tokens are handled as follows: 80% are replaced with [MASK], 10% are replaced with a random token, and 10% are left unchanged. The mixed strategy prevents the model from only learning to handle [MASK] tokens at fine-tune time, when no masking occurs.
Bidirectional self-attention
The Transformer architecture introduced self-attention, but its decoder applies a causal mask: each position can only attend to itself and earlier positions. This is necessary for autoregressive generation but limits representation quality — the model never sees the right-side context when building a token’s representation.
BERT uses a Transformer encoder with no causal mask. Every token attends to every other token in every layer. The full N×N attention matrix is active. This is what “deep bidirectional” means: not just a shallow bidirectional RNN pass, but full cross-position attention at every layer of a deep stack.
The multi-head attention mechanism runs this in parallel across H heads, each learning a different relational pattern. BERT-Base uses 12 heads across 12 layers; BERT-Large uses 16 heads across 24 layers.
Implementation
Full-context attention is the encoder operation; the pretraining objectives explain how that encoder learns. Follow one reduced pass from a sentence pair through corruption to both losses and a shared update.
Authored offline CPU mechanics: one block, width 8, two heads and a 16-token synthetic vocabulary. Random reduced weights and a tiny ordered corpus connect corruption, bidirectional encoding, MLM and NSP in one backward pass. This is not a pretrained BERT call, language knowledge, native accelerator performance or benchmark reproduction. Equations below explain the cited operations; they are authored notation.
Primary conventions follow Google BERT revision eedf5716ce12 and §3–3.1. Token-level masking is the pinned default. Initialization uses truncated normal std 0.02 and zero biases. LayerNorm follows the TensorFlow contrib float32 epsilon. Seeds stay local and import performs no run. The explicit pair fixture and one SGD step are reductions, not identical upstream preprocessing or full training.
1. Pack two sentences and declare the NSP label
Original paper
An ordered document supplies the positive pair; another document supplies B for the negative.
Math and tensor shapes
The tiny corpus explicitly chooses consecutive or cross-document sentences. Segment 0 includes CLS, A and its separator; segment 1 includes B and its separator. Actual padding has validity false. This replaces the original document sampler, short-sequence policy and truncation; it is not next-token prediction.
IDs, segments, validity [B, N]; NSP labels [B]
Python implementation
def sentence_pair(
document=0,
sentence=0,
is_random_next=False,
random_document=1,
random_sentence=0,
max_length=10,
):
"""Pack an explicit corpus pair; label 0 means IsNext, 1 means NotNext."""
if not isinstance(is_random_next, bool):
raise ValueError("is_random_next must be boolean.")
if not (0 <= document < len(CORPUS)):
raise ValueError("Invalid document.")
if not (0 <= sentence < len(CORPUS[document]) - 1):
raise ValueError("Sentence A must have a successor.")
a = CORPUS[document][sentence]
if is_random_next:
if (
not (0 <= random_document < len(CORPUS))
or random_document == document
):
raise ValueError("Random B must come from another document.")
if not (0 <= random_sentence < len(CORPUS[random_document])):
raise ValueError("Invalid random sentence.")
b = CORPUS[random_document][random_sentence]
else:
b = CORPUS[document][sentence + 1]
tokens = [CLS, *a, SEP, *b, SEP]
if max_length < len(tokens):
raise ValueError("Pair does not fit; this fixture does not truncate.")
segments = [0] * (len(a) + 2) + [1] * (len(b) + 1)
padding = max_length - len(tokens)
return {
"ids": torch.tensor(tokens + [PAD] * padding),
"segments": torch.tensor(segments + [0] * padding),
"valid": torch.tensor([True] * len(tokens) + [False] * padding),
"nsp_label": int(is_random_next),
}2. Select targets first, then corrupt the input
Original paper
The default token-level recipe shuffles eligible positions, caps their number and retains the original labels.
Math and tensor shapes
N counts unpadded tokens including CLS and SEP. Shuffle precedes capped selection; there is no independent Bernoulli trial at each token. Original RNG branches draw <0.8, then <0.5 for unchanged versus random. Replacement samples the full vocabulary, including PAD and specials. A replaced PAD remains a valid input position. Original specials and actual padding cannot be selected; unchanged selections still have loss weight 1. Whole-word masking is outside this example.
original [N] → corrupted [N], positions/targets/weights [M]
Python implementation
def select_and_corrupt(ids, valid, rng, probability=0.15, max_predictions=3):
"""Use original capped selection and two RNG draws for 80/10/10."""
check_ids(ids, VOCAB_SIZE, "ids")
if ids.ndim != 1 or valid.shape != ids.shape or valid.dtype != torch.bool:
raise ValueError("Expected one sequence and a boolean validity mask.")
length = int(valid.sum())
if length == 0 or not valid[:length].all() or valid[length:].any():
raise ValueError("Valid tokens must form a nonempty prefix.")
if not 0 < probability <= 1 or max_predictions < 1:
raise ValueError(
"Probability and maximum predictions must be positive."
)
candidates = [i for i in range(length) if int(ids[i]) >= 4]
if not candidates:
raise ValueError("At least one eligible content token is required.")
rng.shuffle(candidates)
count = min(max_predictions, max(1, int(round(length * probability))))
selected = candidates[:count]
output = ids.clone()
branches = {}
for index in selected:
if rng.random() < 0.8:
output[index], branches[index] = MASK, "mask"
elif rng.random() < 0.5:
branches[index] = "unchanged"
else:
# Original replacement samples the ENTIRE vocabulary, specials too.
output[index] = rng.randint(0, VOCAB_SIZE - 1)
branches[index] = "random"
selected.sort()
slots = max_predictions - len(selected)
positions = torch.tensor(selected + [0] * slots)
targets = torch.tensor([int(ids[i]) for i in selected] + [PAD] * slots)
weights = torch.tensor([1.0] * len(selected) + [0.0] * slots)
return output, positions, targets, weights, [branches[i] for i in selected]3. Sum three learned embeddings
Original paper
Position is learned separately from the special CLS token.
Math and tensor shapes
CLS has a learned vocabulary row. Token, absolute position and segment vectors share width D. LayerNorm precedes embedding dropout. Float32 LayerNorm epsilon is 1e-12, matching the original TensorFlow contrib convention; no padding_idx freezes a tied vocabulary row.
IDs [B, N] → embedding sum and normalized states [B, N, D]
Python implementation
def input_embeddings(self, ids, segments):
"""Sum learned token, position and segment embeddings, then LN/dropout."""
if (
ids.ndim != 2
or min(ids.shape) < 1
or ids.shape[1] > self.max_length
):
raise ValueError(
"Expected nonempty [B, N] IDs within position capacity."
)
if segments.shape != ids.shape:
raise ValueError("Segment shape must match tokens.")
check_ids(ids, VOCAB_SIZE, "ids")
check_ids(segments, 2, "segments")
positions = torch.arange(ids.shape[1], device=ids.device)
x = self.token(ids) + self.position(positions) + self.segment(segments)
# CLS uses its learned token row, just like other vocabulary entries.
return self.drop(self.embedding_norm(x))4. Run padding-aware bidirectional post-norm attention
Original paper
Valid right context is visible; padded keys receive the original −10000 score bias.
Math and tensor shapes
Biased Q/K/V projections split across heads. Attention-probability dropout and residual dropout are included. Both residual sums precede normalization. GELU uses the original tanh approximation. Padded queries need not be zero; their MLM slots are excluded. Smoke disables dropout for reproducible comparisons.
states [B, N, D]; attention [B, H, N, N]; FFN width 4D
Python implementation
def encode(self, ids, segments, valid):
"""Attend to every valid key in both directions, then post-norm FFN."""
x = self.input_embeddings(ids, segments)
if valid.shape != ids.shape or valid.dtype != torch.bool:
raise ValueError("Expected boolean validity [B, N].")
if not valid.any(dim=1).all():
raise ValueError("Every sequence needs at least one valid key.")
batch, length, width = x.shape
head_width = width // self.heads
q, k, v = [
projection(x)
.reshape(batch, length, self.heads, head_width)
.transpose(1, 2)
for projection in (self.q, self.k, self.v)
]
scores = q @ k.transpose(-1, -2) / math.sqrt(head_width)
scores = scores + (~valid[:, None, None, :]).to(x.dtype) * -10000.0
attention = torch.softmax(scores, dim=-1) # No causal triangle.
context = (self.drop(attention) @ v).transpose(1, 2).reshape(x.shape)
z = self.attention_norm(x + self.drop(self.attention_output(context)))
# Original modeling.gelu uses this tanh approximation, not exact erf.
ffn = self.output(F.gelu(self.intermediate(z), approximate="tanh"))
return self.output_norm(z + self.drop(ffn)), attention5. Gather only selected prediction positions
Original paper
The MLM head consumes selected states, not an all-token reconstruction objective.
Math and tensor shapes
Positions are sorted after corruption. Unused slots have position 0, target 0 and weight 0. Gathering their CLS state is harmless because the loss ignores those slots. Changing an unselected logit cannot change the MLM objective.
encoded [B, N, D] + positions [B, M] → gathered [B, M, D]
Python implementation
def gather_selected_states(encoded, positions):
"""Gather [B, M, D]; padded slots remain present but have zero loss weight."""
if encoded.ndim != 3 or positions.ndim != 2 or min(positions.shape) < 1:
raise ValueError(
"Expected encoded [B, N, D] and nonempty positions [B, M]."
)
if positions.shape[0] != encoded.shape[0]:
raise ValueError("Position batch must match encoded batch.")
check_ids(positions, encoded.shape[1], "positions")
return encoded.gather(
1, positions[..., None].expand(-1, -1, encoded.shape[-1])
)6. Predict original tokens through the tied MLM head
Original paper
A pretraining-only transform feeds the same matrix used for input token embeddings.
Math and tensor shapes
Dense/GELU/LayerNorm precedes the tied vocabulary projection. Output-only bias has V entries. The single token parameter receives gradients through both input lookup and output projection, including rows absent from the input.
selected [B, M, D] → transformed [B, M, D] → logits [B, M, V]
Python implementation
def mlm_logits(self, states):
"""Dense/GELU/LN, then tied token matrix and output-only vocab bias."""
h = self.mlm_norm(F.gelu(self.mlm_dense(states), approximate="tanh"))
return F.linear(h, self.token.weight, self.vocab_bias)7. Pool CLS for the two-class NSP head
Original paper
The pooled sequence state supplies a separate binary head.
Math and tensor shapes
Labels are 0 for the actual successor and 1 for the declared cross-document negative. The random reference exposes tensor flow; its pooled CLS carries no pretrained sentence semantics.
CLS [B, D] → pooled [B, D] → NSP logits [B, 2]
Python implementation
def nsp_logits(self, encoded):
"""Pool final CLS through dense+tanh, then predict IsNext/NotNext."""
pooled_cls = torch.tanh(self.pooler(encoded[:, 0]))
return self.nsp(pooled_cls)8. Normalize both objectives and update shared weights
Original paper
The shared encoder receives gradients from both separately normalized objectives.
Math and tensor shapes
The denominator epsilon is the original 1e-5. Padding slots contribute zero; unchanged selected targets contribute normally. One illustrative SGD step updates all parameters. Original Adam with weight decay, its schedule, corpus scale and task fine-tuning are outside this reference.
MLM scalar + NSP scalar → backward → shared encoder, MLM and NSP gradients
Python implementation
def joint_loss(logits, targets, weights, nsp_logits, labels):
"""Normalize selected MLM slots and batch NSP separately, then add."""
validate_objectives(logits, targets, weights, nsp_logits, labels)
per_slot = (
-F.log_softmax(logits, dim=-1)
.gather(-1, targets[..., None])
.squeeze(-1)
)
mlm_loss = (per_slot * weights).sum() / (weights.sum() + 1e-5)
nsp_loss = F.cross_entropy(nsp_logits, labels, reduction="mean")
return mlm_loss + nsp_loss, mlm_loss, nsp_loss
def joint_update(model, batch, learning_rate=0.05):
"""Take one illustrative SGD step; both objectives reach shared parameters."""
if not math.isfinite(learning_rate) or learning_rate <= 0:
raise ValueError("Learning rate must be finite and positive.")
optimizer = torch.optim.SGD(model.parameters(), lr=learning_rate)
optimizer.zero_grad(set_to_none=True)
encoded, logits, nsp_logits, attention = model(
batch["ids"], batch["segments"], batch["valid"], batch["positions"]
)
losses = joint_loss(
logits, batch["targets"], batch["weights"], nsp_logits, batch["labels"]
)
losses[0].backward()
norms = {name: float(p.grad.norm()) for name, p in model.named_parameters()}
if not all(math.isfinite(value) for value in norms.values()):
raise ValueError("Nonfinite gradients.")
optimizer.step()
return encoded, logits, nsp_logits, attention, losses, normsActual smoke JSON reports source SHA-256, seed, reduced shapes, both losses, selection bookkeeping, shared/head gradients and the tied embedding update. It measures valid-state padding invariance and CLS sensitivity to right context. Learning useful representations needs pretraining data and many updates before downstream adaptation.
python -m pip install -r requirements-bert.txt python bert.py --smoke --seed 7
Pre-train once, fine-tune everywhere
BERT separates the expensive representation learning from the cheap task adaptation. Pre-training uses a massive unlabeled corpus (BooksCorpus + English Wikipedia, around 3.3 billion words) and runs for days on TPUs. The result is a set of encoder weights encoding deep knowledge of English syntax, semantics, and world facts.
Fine-tuning then adds a small head on top of the pre-trained encoder and trains for a few epochs on a labeled dataset that may be orders of magnitude smaller. Different tasks require different heads:
- Sentence classification (e.g., sentiment, entailment): the final-layer embedding of the special
[CLS]token feeds a linear classifier. - Span extraction (e.g., SQuAD question answering): two linear layers output per-token start and end logits; the span with the highest combined score is extracted.
- Sequence labeling (e.g., named entity recognition): each token’s final-layer embedding feeds a per-token linear classifier independently.
The [CLS] token is a special vocabulary token with a learned token embedding, prepended to every sequence. Its input also receives separate learned position and segment embeddings. Its final hidden state feeds the dense+tanh NSP pooler during pre-training and task-specific classification heads during fine-tuning; the paper cautions that it is not a general sentence representation without fine-tuning.
Why it mattered
BERT’s release in October 2018 was a watershed moment for NLP. It improved the state of the art on the GLUE benchmark by 7.7 points absolute, on SQuAD 1.1 by 1.5 F1, and on SQuAD 2.0 by 5.1 F1. Its SQuAD 2.0 test F1 was 83.1, below the reported human F1 of 89.5 (Table 3). Eleven NLP tasks saw new records in a single paper.
The deeper impact was architectural and methodological. Before BERT, NLP systems were largely task-specific pipelines with task-specific training. After BERT, the default approach became: take a pre-trained encoder, add a small head, fine-tune. This shift mirrors what ImageNet pre-training did for computer vision years earlier, and it directly enabled the scaling era that followed — RoBERTa, ALBERT, DeBERTa, and eventually the large language models that descended from the encoder–decoder and decoder-only branches of the same Transformer family.
BERT also popularized WordPiece tokenization, the [CLS]/[SEP] sentence boundary convention, and the use of the [CLS] vector as a sentence representation — conventions that persist across many modern models.
Related Reading
- Attention Is All You Need — the Transformer architecture that BERT’s encoder is built on; reading it first clarifies exactly what BERT inherits and what it changes
- CLIP — extends the pretrain-then-finetune idea to vision–language with contrastive learning, showing the same recipe generalizes across modalities
- Vision Transformer — applies BERT’s encoder-only Transformer directly to image patches, demonstrating that the architecture transfers to vision with minimal modification
