BERT

Master bidirectional encoders, masked language modeling, and pre-training

advanced50 min
On this page

The Big Picture

What Is BERT?

BERT (2018, Devlin et al.) stands for Bidirectional Encoder Representations from Transformers. It's a transformer-based model specifically designed for understanding text, not generating it.

Key distinctions:

  • Encoder-only: BERT uses only the encoder stack from the transformer architecture (no decoder)
  • Bidirectional: Unlike GPT (which reads left-to-right), BERT looks at both left AND right context simultaneously during pre-training
  • Pre-trained + Fine-tune: Pre-train once on massive text corpus → fine-tune for specific downstream tasks (classification, NER, Q&A, etc.)
Why "Bidirectional" Matters: In GPT, when predicting the next token, the model can't "cheat" by looking ahead. But for understanding existing text (like ad copy relevance), looking both directions is better. BERT's masked language modeling enables this.

Why BERT Changed NLP

Before BERT (2018):

  • Task-specific models: sentiment analysis → one model, NER → different model, Q&A → yet another model
  • Lots of feature engineering and hand-crafted heuristics
  • Pre-trained embeddings (Word2Vec, GloVe) were context-agnostic

After BERT:

  • One pre-trained model → add simple task-specific head → fine-tune → state-of-the-art on nearly every NLP task
  • Contextualized embeddings: the same word gets different representations depending on context
  • Transfer learning worked at scale (110M parameters for BERT-Base, 340M for BERT-Large)
The BERT Philosophy: Learn general language understanding from massive unlabeled text, then specialize. This became the blueprint for modern NLP (RoBERTa, ELECTRA, ERNIE) and beyond (CLIP for vision-language, etc.).

Transformer Foundation

BERT builds on the Transformer architecture. For transformer details (attention, Q/K/V matrices, multi-head attention), see the Transformer guide Section 4-6. Here we focus on what makes BERT unique.

AdTech Application: Ad Relevance Scoring

The Problem

You're running a search advertising platform. A user searches "best running shoes for marathons" and multiple ads are eligible to bid:

  • Ad A: "Nike Marathon Elite Shoes - 20% Off"
  • Ad B: "Running Gear for Beginners"
  • Ad C: "Cheap Shoes Outlet"

Which ad is most relevant? You need a score from 0-1 to inform your auction. Users prefer relevant ads (better CTR), so relevance → higher quality scores → better auction dynamics.

BERT Approach

Input format: [CLS] search query [SEP] ad text [SEP]

For our example:

[CLS] best running shoes for marathons [SEP] Nike Marathon Elite Shoes - 20% Off [SEP]

BERT encodes this as a sequence of embeddings. The [CLS] token (classification token) at the start gets a special role: its final hidden state representation summarizes the entire input pair and is used for the relevance classification.

AdTech Pipeline: Query + Ad → BERT → Relevance Score
Input [CLS] query [SEP] ad text [SEP] BERT Encoder 12 layers 768 hidden output vectors Extract [CLS] hidden_state[0] shape: (768,) sentence pair repr. Classification Linear(768 → 2) softmax relevant / not Output: Relevance Score P(relevant) = 0.87 → Use in ranking/bidding

Why BERT Wins Here

  • Semantic matching: BERT learns that "Marathon Elite Shoes" matches "running shoes for marathons" semantically, even without exact words
  • Context awareness: The word "shoes" is embedded differently in context of marathons vs. "Cheap Shoes Outlet"
  • Pre-trained knowledge: BERT already learned language patterns from Wikipedia + BookCorpus, so it generalizes to new ads/queries

BERT Architecture (Detailed)

BERT-Base vs BERT-Large

Parameter BERT-Base BERT-Large
Encoder Layers 12 24
Hidden Size (d_model) 768 1024
Attention Heads 12 16
d_head (per head) 64 64
FFN Hidden (d_ff) 3072 4096
Total Parameters 110M 340M
Training Time (GPU) ~4 days (16 TPUs) ~4 days (64 TPUs)

Architecture Overview

BERT is a stack of transformer encoder blocks. No decoder. Each block contains:

Multi-Head Self-Attention

12 attention heads (BERT-Base), d_k = 64 per head. Each head learns different representation subspaces. For attention details, see Transformer guide Section 5-6.

Add & Norm

Residual connection + LayerNorm. Let H be attention output, X be input: Output = LayerNorm(X + Attention(X))

Feed-Forward Network (FFN)

For each token independently: Linear(768→3072) → GELU activation → Linear(3072→768). Expands then contracts.

Add & Norm Again

Residual + LayerNorm after FFN. Output = LayerNorm(H + FFN(H))

Single Transformer Encoder Block (repeated 12 times)
Input: (seq_len, 768) MH-Attn MH-Attn Add & LayerNorm Linear 768→3K GELU activation Linear 3K→768 Add & LayerNorm Dropout (p=0.1) train only Output: (seq_len, 768)
Stack Organization: BERT-Base = Input Embeddings → 12 identical blocks → final hidden states. Each block is a "layer" in the typical deep learning sense. Total: 110M parameters.
Transformer Mechanics: For detailed coverage of Q, K, V, multi-head attention, and how self-attention works, see the Transformer guide Sections 4-6. BERT's attention is standard scaled dot-product multi-head attention.

Input Representation (Detailed)

Three Embeddings Summed Together

BERT's input representation is built by summing three types of embeddings for each token:

1. Token Embeddings (WordPiece)

BERT uses WordPiece tokenization, which breaks words into subword units. This allows BERT to handle:

  • Rare words: "uncharacteristically" → ["un", "##character", "##istically"] (## means "continuation")
  • OOV (out-of-vocab): Unknown words get broken into familiar subwords
  • Morphology: Model learns "##ing" across many verbs

Example: "I love playing" → ["I", "love", "play", "##ing"]

Each token has a learned embedding vector (shape: 768 for BERT-Base).

2. Segment Embeddings

When processing two sentences (like query + ad), BERT marks which sentence each token belongs to:

  • E_A: Embedding for tokens from sentence A (first input)
  • E_B: Embedding for tokens from sentence B (second input)

These are learned embeddings (shape: 768), not hand-crafted. There are only 2 segment embeddings (for single-sentence and sentence-pair tasks).

3. Position Embeddings

Unlike the original Transformer (which uses sinusoidal position encodings), BERT uses learned position embeddings:

  • Absolute positions: position 0, 1, 2, ..., up to max_seq_len (typically 512)
  • Each position has a learned 768-dim vector
  • Pre-trained on 512 tokens, so BERT handles sequences up to 512 tokens

Why learned instead of sinusoidal? Learned embeddings are more flexible and can capture task-specific positional patterns during fine-tuning.

Summing Three Embeddings
Token "playing" 768-dim vector Segment E_A (sent A) 768-dim vector Position pos=4 768-dim vector + + Input Embedding e_playing = e_token + e_segmentA + e_pos4 Shape: (768,) Repeat for every token in sequence Input sequence (seq_len, 768) fed to BERT encoder

Concrete Example

Input: [CLS] buy shoes online [SEP] Nike running shoes sale [SEP]

Tokenized (WordPiece): ["[CLS]", "buy", "shoes", "online", "[SEP]", "Nike", "running", "shoes", "sale", "[SEP]"]

For token "running" (position 6, segment B):

e_running = e_token("running") + e_segment(B) + e_position(6)

Special Tokens

Token Purpose Example
[CLS] Classification token. Always first. Final hidden state is used as sentence pair representation. [CLS] query [SEP] ad [SEP]
[SEP] Separator. Between sentences and at the end. Marks segment boundaries. sentence1 [SEP] sentence2 [SEP]
[MASK] Mask token. Used during MLM pre-training to hide tokens BERT must predict. The user [MASK] the ad
[PAD] Padding token. Used to pad sequences to max_length for batching. tokens... [PAD] [PAD]
Why Sum Instead of Concatenate? Summing keeps dimensionality fixed (768). Concatenating would grow embeddings, increasing parameters. Summing is simpler and proved equally effective.

Pre-Training: MLM & NSP (Detailed)

Two Pre-Training Objectives

BERT is pre-trained on two tasks simultaneously on a large corpus (Wikipedia + BookCorpus, 3.3B words):

Objective 1: Masked Language Model (MLM)

Idea: Hide some tokens, make BERT predict them. This forces BERT to learn bidirectional context.

Process:

  • Step 1: Select 15% of tokens at random
  • Step 2: For each selected token, replace it with:
    • 80% of the time: [MASK] token
    • 10% of the time: a random token from vocab
    • 10% of the time: keep original token unchanged
  • Step 3: BERT encoder processes the modified sequence and predicts the original token at each masked position

Why 80/10/10 split? During fine-tuning, there are no [MASK] tokens. If BERT always sees [MASK], it won't learn to handle regular tokens. The 10% random and 10% unchanged force robustness.

Example:

Original: "The user clicked the ad" Modified: "The [MASK] clicked the ad" (80% replace) Modified: "The user [RANDOM] the ad" (10% random) Modified: "The user clicked the ad" (10% unchanged) Loss: compute cross-entropy only at masked positions. For the [MASK] case: CE(logits at pos 1, label="clicked")

Objective 2: Next Sentence Prediction (NSP)

Idea: Given sentence A and B, predict: is B the actual next sentence? Binary classification.

Process:

  • 50% of the time: B is the actual next sentence (label=IsNext)
  • 50% of the time: B is a random sentence from the corpus (label=NotNext)
  • Use [CLS] token's hidden state → Linear(768→2) → softmax(IsNext, NotNext)

Why NSP? Helps BERT learn sentence-level relationships. Turned out later (RoBERTa) that NSP wasn't essential, but it didn't hurt.

MLM & NSP Pre-Training Objectives (Single Training Step)
"The user [MASK] the ad" MLM Head Linear(768 → vocab_size) at masked position only Logits over vocab softmax → P(clicked) high L_MLM = CE(logits, "clicked") NSP Head Linear(768 → 2) uses [CLS] token Binary classification P(IsNext), P(NotNext) L_NSP = CE(logits, label) Total Loss L_total = L_MLM + L_NSP Backprop through all 12 layers, update all parameters

Pre-Training Details

  • Corpus: Wikipedia (2.5B words) + BookCorpus (800M words)
  • Batch size: 256 sequences
  • Max sequence length: 512 tokens
  • Optimizer: Adam (lr=1e-4, warmup over 10k steps)
  • Total training: 1M steps = ~1 epoch over corpus
  • Hardware: 16 TPUs for 4 days (BERT-Base)
Key Insight: Pre-training is computationally expensive (16 TPUs × 4 days). This is why pre-trained BERT models are shared openly—it's wasteful for everyone to retrain from scratch. Fine-tuning on downstream tasks is cheap (1-3 epochs on a single GPU).

Training Walkthrough (Step-by-Step)

Here's exactly what happens during a single pre-training step:

Step 1: Tokenize

Convert text to WordPiece tokens:

Text: "I bought nice running shoes" Tokens: ["I", "bought", "nice", "running", "shoes"] (no subword split here) Token IDs: [146, 2572, 3231, 3607, 5680] (vocab index)

Step 2: Create Two-Sentence Pair

Sample or construct sentence A and B, concatenate with special tokens:

Sent A: "I bought nice running shoes" Sent B: "They arrived within two days" (real next, 50% chance) Input: "[CLS] I bought nice running shoes [SEP] They arrived within two days [SEP]" Token IDs: [101, 146, 2572, 3231, 3607, 5680, 102, 2064, 3600, 1365, 2484, 2100, 102] Segment IDs: [0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1] (0=sent A, 1=sent B)

Step 3: Apply MLM (Mask 15%)

Randomly select 15% of tokens, apply the 80/10/10 rule:

Original: "[CLS] I bought nice running shoes [SEP] They arrived within two days [SEP]" Masked: "[CLS] I [MASK] nice [RANDOM] shoes [SEP] They arrived [MASK] two days [SEP]" (positions 2, 4, 8 masked)

Step 4: Create Input Embeddings

For each token, sum three embeddings:

For token at position 2 ("[MASK]", was "bought"): e_token = embedding["[MASK]"] (vocabulary index 103) e_segment = embedding[0] (sent A) e_position = embedding[2] (position 2) input_embedding = e_token + e_segment + e_position Shape: (768,)

This creates: (seq_len=13, hidden_size=768) tensor

Step 5: Pass Through 12 Encoder Layers

The input tensor flows through the encoder stack. For each layer:

  • Multi-Head Self-Attention: (seq_len, 768) → 12 attention heads, each computing attention (see Transformer guide Section 5)
  • Add & LayerNorm: Residual connection + layer normalization
  • FFN: Linear(768→3072) + GELU activation + Linear(3072→768)
  • Add & LayerNorm: Residual + LayerNorm again
  • Dropout: During training, randomly drop 10% of values

Key detail on GELU activation: GELU(x) = x · Φ(x), where Φ is the cumulative distribution function of standard normal. It's smoother than ReLU. See DNN guide Section 8 for activation functions.

Output shape after all 12 layers: (seq_len=13, hidden_size=768)

Step 6: MLM Head - Predict Masked Tokens

Take hidden states only at masked positions (positions 2, 4, 8). For each:

For position 2 (was "bought"): h_masked = output_layer12[2] (768-dim vector) logits = Linear_MLM(h_masked) (768 → vocab_size=30522) probabilities = softmax(logits) loss = -log(probabilities["bought"]) Total L_MLM = average loss over all masked positions

Step 7: NSP Head - Next Sentence Prediction

Use [CLS] token (position 0) to predict if B follows A:

h_CLS = output_layer12[0] (768-dim) logits = Linear_NSP(h_CLS) (768 → 2) logits_IsNext, logits_NotNext = logits[0], logits[1] probabilities = softmax([logits_IsNext, logits_NotNext]) In this case, sentence B IS the real next sentence, so: label = 0 (IsNext) loss = -log(probabilities[0])

Step 8: Combine Losses

L_total = L_MLM + L_NSP

Step 9: Backpropagation

Compute gradients with respect to all parameters (entire 12-layer stack + embeddings). See DNN guide Section 7 for backpropagation mechanics.

  • Gradients flow back through all 12 layers (chain rule)
  • Compute ∂L / ∂W for every matrix W
  • Typical gradient shapes: attention W_Q: (768, 768), FFN W_1: (768, 3072), etc.

Step 10: Update Weights (Adam Optimizer)

θ_new = θ_old - lr · (adaptive learning rate) · gradients

BERT uses Adam with:

  • Learning rate: 1e-4
  • Warmup: Linear increase over first 10k steps, then decay
  • β1=0.9, β2=0.999: Momentum and RMSprop parameters
  • Weight decay: 0.01 (L2 regularization)
Full Training Loop: Repeat steps 1-10 for 1M total steps across Wikipedia + BookCorpus. On 16 TPUs, this takes ~4 days. Once done, the pre-trained BERT weights are released for everyone to fine-tune.

Fine-Tuning BERT

The Fine-Tuning Paradigm

After pre-training, BERT weights are frozen initially, and we add a small task-specific head on top:

Pre-trained BERT → [Task-Specific Head] → Output

For different tasks:

Classification (Sentence Pair)

E.g., ad relevance, entailment, paraphrase detection

[CLS] sent A [SEP] sent B [SEP] ↓ (BERT encoder) hidden[0] (CLS token) ↓ Linear(768 → num_classes) softmax → P(class)

Classification (Single Sentence)

E.g., sentiment, intent detection

[CLS] sentence [SEP] ↓ (BERT encoder) hidden[0] (CLS token) ↓ Linear(768 → num_classes) softmax → P(class)

Token-Level (NER, POS)

Named Entity Recognition, Part-of-Speech tagging

[CLS] word1 word2 ... [SEP] ↓ (BERT encoder) [hidden[1], hidden[2], ...] ↓ Linear(768 → num_tags) per token softmax → P(tag) per token

Regression

E.g., relevance scores (0-1), bid price prediction

[CLS] input [SEP] ↓ (BERT encoder) hidden[0] ↓ Linear(768 → 1) sigmoid/tanh → scalar output

Fine-Tuning Hyperparameters

Hyperparameter Typical Value Note
Learning Rate 2e-5 to 5e-5 Much lower than pre-training (1e-4). Pre-trained weights are good, small updates.
Epochs 2-4 Usually 2-3. More epochs → overfitting risk.
Batch Size 16-32 Depends on GPU memory. 32 typical.
Warmup Steps 10% of total Linear warmup helps stability.
Max Seq Length 128-512 Shorter for efficiency. 512 if needed.
Fine-tune all layers? Yes Update all BERT weights (not frozen).

Two Approaches

Approach 1: Full Fine-Tuning

Update all BERT parameters + task head during training. This is typically best for moderate-sized datasets (100K+ examples).

Approach 2: Feature Extraction

Freeze BERT, train only the task head. Faster, uses less memory. Better for small datasets (<10K examples) or when compute is limited.

approach1_loss = fine_tune_all_layers(data) # update BERT + head approach2_loss = train_head_only(frozen_BERT_embeddings) # freeze BERT
Rule of Thumb: Start with full fine-tuning on BERT-Base. If memory/time is limited, freeze BERT and train head only. BERT is already so powerful that even frozen embeddings often work well.

Extracting Embeddings from BERT (Detailed)

Which Hidden State to Use?

BERT produces 13 hidden state tensors (input embeddings + output from each of 12 layers). Which one should you use?

Method Best For Pros / Cons
Last [CLS] (layer 12, pos 0) Classification, sentence-level tasks ✓ Designed for sentence pair classification. ✗ Loses token-level info.
Last 4 layers concat (layers 9-12 concatenated) General purpose, token-level tasks ✓ Captures multiple abstraction levels. ✗ Dimensionality: 4×768=3072.
Average pool (last layer) Sentence-level without fine-tuning ✓ Simple, meaningful. ✗ Less powerful than [CLS] when fine-tuned.
Second-to-last layer (layer 11) Feature extraction without fine-tuning ✓ Often better than last layer for frozen embeddings. ✗ Requires experimentation.

Concrete Example: Extracting Ad Relevance Embeddings

Input: "[CLS] running shoes [SEP] Nike Marathon Elite Shoes [SEP]"

Method 1: [CLS] token (sentence pair representation)

output = bert(input_ids, segment_ids) cls_embedding = output.last_hidden_state[0] # shape: (768,) # Use for classification head relevance_score = linear_head(cls_embedding) # (768,) → (1,)

Method 2: Average pooling (sentence representation without special token)

output = bert(input_ids, segment_ids) all_tokens = output.last_hidden_state # shape: (seq_len, 768) # Average across tokens (ignore padding) sentence_embedding = all_tokens.mean(dim=0) # shape: (768,) relevance_score = linear_head(sentence_embedding)

Method 3: Concatenate last 4 layers (for token-level or richer representation)

output = bert(input_ids, segment_ids, output_hidden_states=True) last_4_layers = output.hidden_states[-4:] # 4 tensors, each (seq_len, 768) concatenated = torch.cat(last_4_layers, dim=-1) # shape: (seq_len, 3072) cls_embedding = concatenated[0] # shape: (3072,) relevance_score = linear_head(cls_embedding) # (3072,) → (1,)

Why Different Layers?

BERT layers learn progressively more abstract representations:

  • Early layers (1-4): Syntax, morphology, local context
  • Middle layers (5-8): Semantic phrases, relations
  • Late layers (9-12): Task-specific high-level concepts

Concatenating multiple layers captures this hierarchy. For frozen embeddings (feature extraction), middle/late layers often outperform the very last layer because they're less task-specialized during pre-training.

Extracting Embeddings from Different Layers
Input Embed Layers 1-4 Syntax Layers 5-8 Semantic Layers 9-12 Task-specific Last [CLS] Layer 12, pos 0 (768,) Concat 4 Layers Layers 9-12 (3072,) Avg Pool All tokens, Layer 12 (768,) Classification Rich repr. Frozen embed.
Practical Advice: For most downstream tasks, use the last [CLS] token after fine-tuning. If you can't fine-tune (compute constraints), try concatenating the last 4 layers or using layer 11 (second-to-last).

Components Deep Dive

LayerNorm (Not BatchNorm)

BERT uses LayerNorm, not BatchNorm. Why?

LayerNorm formula:

μ = mean(x) # mean across features (not batch) σ = std(x) # std across features LN(x) = γ · (x - μ) / σ + β # learned γ, β parameters

Pros: Works with variable-length sequences. No batch dependency (important for NLP with padding).

Cons: Slightly slower than BatchNorm on GPUs.

LayerNorm vs BatchNorm: BatchNorm normalizes across the batch dimension. For NLP with variable-length sequences and padding, this is problematic. LayerNorm normalizes across the feature dimension, so each sample is independent.

Dropout

Dropout is applied after:

  • Attention weights (before weighted sum)
  • After FFN output
  • On embeddings (less common)

BERT dropout: p=0.1 (10%)

During training: randomly set 10% of values to 0, scale rest by 1/(1-p) During inference: no dropout (disable with model.eval() in PyTorch)

Dropout prevents co-adaptation of neurons. See DNN guide Section 10 for details.

GELU Activation

BERT's FFN uses GELU instead of ReLU:

GELU(x) = x · Φ(x) where Φ(x) is the cumulative distribution function of the standard normal distribution. Approximation: GELU(x) ≈ 0.5·x·(1 + tanh(√(2/π)·(x + 0.044715·x³)))

GELU is smoother than ReLU and often performs better. See DNN guide Section 8 for activation function comparisons.

Attention Mask for Padding

When sequences are padded to max_length, BERT must ignore [PAD] tokens in attention:

Example sequence (max_len=10): Tokens: ["[CLS]", "buy", "shoes", "[SEP]", "[PAD]", "[PAD]", ...] Attention mask: [1, 1, 1, 1, 0, 0, ...] # 1=attend, 0=ignore In attention scores (before softmax): scores[i, j] = (Q_i · K_j) / √d_k If mask[j] == 0: scores[i, j] += -10000 # makes attention weight ≈ 0 after softmax
Attention Masking: Prevents the model from attending to padding tokens. Without this, [PAD] tokens would contribute meaningless gradient signal during backprop.

Loss Functions

Pre-Training Losses

Masked Language Model (MLM) Loss

Cross-entropy over predicted tokens at masked positions only:

L_MLM = -(1/M) · Σ_{i ∈ masked} log P(x_i | context) where M is the number of masked tokens. Example: 15% of 128 tokens = ~19 masked tokens. L_MLM averages loss only over those 19 positions.

Next Sentence Prediction (NSP) Loss

Binary cross-entropy on [CLS] classification:

L_NSP = -[y · log(p) + (1-y) · log(1-p)] where: y = 1 if B is real next sentence, 0 if random p = P(IsNext) from softmax

Fine-Tuning Losses

Classification (Sentiment, Ad Relevance)

L_classification = -(1/batch_size) · Σ log P(y_i | input_i) One-hot encoded labels, cross-entropy over num_classes outputs.

Token-Level (NER, POS)

L_token = -(1/(seq_len·batch_size)) · Σ Σ log P(tag_ij | input_i, j)

Regression (Bid Prediction)

L_regression = (1/batch_size) · Σ (y_i - ŷ_i)² Mean squared error for continuous targets.

Ranking Loss (Ad Relevance Scoring)

For ranking ads by relevance, use ranking losses:

Pairwise margin loss: L = max(0, margin + score_negative - score_positive) Listwise (e.g., LambdaRank): L = -(1/batch_size) · Σ log(softmax(scores)_relevant)
Loss Selection: For most classification tasks, cross-entropy is standard. For ranking, pairwise or listwise losses work better. For regression, MSE or MAE. See details in task-specific literature.
Cross-Entropy Derivation: For detailed derivation of cross-entropy loss and its connection to information theory, see the Logistic Regression guide.

Python Implementation

Using HuggingFace Transformers

The easiest way to use BERT:

pip install transformers torch datasets

Load Pre-Trained BERT & Extract Embeddings

from transformers import AutoTokenizer, AutoModel
import torch

# Load pre-trained BERT-Base
model_name = "bert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModel.from_pretrained(model_name, output_hidden_states=True)

# Example: Ad relevance input
query = "best running shoes for marathons"
ad_text = "Nike Marathon Elite Shoes - 20% Off"

# Tokenize
inputs = tokenizer(
    query, ad_text,
    return_tensors="pt",
    padding=True,
    truncation=True,
    max_length=128
)

# Forward pass
with torch.no_grad():
    outputs = model(**inputs)

# Extract embeddings
last_hidden = outputs.last_hidden_state  # (1, seq_len, 768)
cls_embedding = last_hidden[:, 0, :]     # (1, 768) - CLS token

print(f"CLS embedding shape: {cls_embedding.shape}")
print(f"CLS embedding (first 5 values): {cls_embedding[0, :5]}")

Fine-Tune BERT for Ad Relevance Classification

from transformers import AutoTokenizer, AutoModelForSequenceClassification
from torch.optim import Adam
from torch.utils.data import DataLoader, TensorDataset
import torch

# Load BERT with classification head (2 classes: relevant/not-relevant)
model_name = "bert-base-uncased"
model = AutoModelForSequenceClassification.from_pretrained(
    model_name, num_labels=2
)
tokenizer = AutoTokenizer.from_pretrained(model_name)

# Example fine-tuning data
queries = [
    "running shoes for marathons",
    "cheap office chairs",
    "best winter jackets"
]
ads = [
    "Nike Marathon Elite Shoes",
    "wooden desk chairs on sale",
    "Patagonia winter coats"
]
labels = torch.tensor([1, 1, 1])  # All relevant (1 = relevant)

# Tokenize
inputs = tokenizer(
    queries, ads,
    padding=True,
    truncation=True,
    return_tensors="pt"
)

# Create dataset
dataset = TensorDataset(
    inputs["input_ids"],
    inputs["attention_mask"],
    labels
)
loader = DataLoader(dataset, batch_size=2, shuffle=True)

# Fine-tune
optimizer = Adam(model.parameters(), lr=2e-5)
model.train()

for epoch in range(2):
    for batch_idx, (input_ids, attn_mask, batch_labels) in enumerate(loader):
        optimizer.zero_grad()

        outputs = model(
            input_ids,
            attention_mask=attn_mask,
            labels=batch_labels
        )
        loss = outputs.loss

        loss.backward()
        optimizer.step()

        if batch_idx % 10 == 0:
            print(f"Epoch {epoch}, Batch {batch_idx}, Loss: {loss.item():.4f}")

Inference on New Ads

model.eval()

test_query = "high performance running shoes"
test_ad = "Nike Vaporfly Elite Shoes - Record Breaking Speed"

inputs = tokenizer(
    test_query, test_ad,
    return_tensors="pt",
    padding=True,
    truncation=True
)

with torch.no_grad():
    outputs = model(**inputs)
    logits = outputs.logits
    probabilities = torch.softmax(logits, dim=-1)

relevant_score = probabilities[0, 1].item()  # P(relevant)
print(f"Relevance score: {relevant_score:.4f}")  # e.g., 0.92

Extract Different Embeddings

# Method 1: Last [CLS] (already shown above)
cls_emb = last_hidden[:, 0, :]  # shape: (batch, 768)

# Method 2: Average pooling
avg_emb = last_hidden.mean(dim=1)  # shape: (batch, 768)

# Method 3: Concatenate last 4 layers
all_hidden = outputs.hidden_states  # tuple of 13 tensors
# Take layers 9, 10, 11, 12 (last 4)
last_4 = torch.cat(all_hidden[-4:], dim=-1)  # (batch, seq_len, 3072)
cls_concat = last_4[:, 0, :]  # (batch, 3072)

print(f"CLS embedding: {cls_emb.shape}")
print(f"Avg pooling: {avg_emb.shape}")
print(f"Last 4 layers concat: {cls_concat.shape}")

Custom BERT Encoder Block (NumPy - Educational)

Here's a simplified transformer encoder block from scratch to understand the internals:

import numpy as np

class SimpleAttentionHead:
    """Single attention head (simplified)"""
    def __init__(self, d_model=768, d_k=64):
        self.d_model = d_model
        self.d_k = d_k
        # Initialize weights (Xavier init)
        self.W_q = np.random.randn(d_model, d_k) * np.sqrt(1 / d_model)
        self.W_k = np.random.randn(d_model, d_k) * np.sqrt(1 / d_model)
        self.W_v = np.random.randn(d_model, d_k) * np.sqrt(1 / d_model)

    def forward(self, X):
        """X shape: (seq_len, d_model)"""
        Q = X @ self.W_q  # (seq_len, d_k)
        K = X @ self.W_k
        V = X @ self.W_v

        # Scaled dot-product attention
        scores = (Q @ K.T) / np.sqrt(self.d_k)  # (seq_len, seq_len)
        attn_weights = softmax(scores, axis=1)  # (seq_len, seq_len)

        output = attn_weights @ V  # (seq_len, d_k)
        return output, attn_weights

class TransformerEncoderBlock:
    """One encoder block with attention + FFN"""
    def __init__(self, d_model=768, num_heads=12, d_ff=3072):
        self.d_model = d_model
        self.num_heads = num_heads
        self.d_k = d_model // num_heads

        # Multi-head attention (simplified: one head only)
        self.attn_head = SimpleAttentionHead(d_model, self.d_k)
        self.W_o = np.random.randn(self.d_k, d_model) * np.sqrt(1 / self.d_k)

        # FFN
        self.W_1 = np.random.randn(d_model, d_ff) * np.sqrt(1 / d_model)
        self.b_1 = np.zeros(d_ff)
        self.W_2 = np.random.randn(d_ff, d_model) * np.sqrt(1 / d_ff)
        self.b_2 = np.zeros(d_model)

        # LayerNorm parameters
        self.gamma_1 = np.ones(d_model)
        self.beta_1 = np.zeros(d_model)
        self.gamma_2 = np.ones(d_model)
        self.beta_2 = np.zeros(d_model)

    def layer_norm(self, X, gamma, beta, eps=1e-6):
        mean = X.mean(axis=1, keepdims=True)
        std = X.std(axis=1, keepdims=True)
        return gamma * (X - mean) / (std + eps) + beta

    def gelu(self, x):
        """GELU activation"""
        from scipy.stats import norm
        return x * norm.cdf(x)

    def forward(self, X):
        """X shape: (seq_len, d_model)"""
        # Multi-head attention (simplified: 1 head)
        attn_out, _ = self.attn_head.forward(X)
        attn_out = attn_out @ self.W_o  # project back to d_model

        # Residual + LayerNorm
        X = self.layer_norm(X + attn_out, self.gamma_1, self.beta_1)

        # FFN
        ffn_hidden = self.gelu(X @ self.W_1 + self.b_1)
        ffn_out = ffn_hidden @ self.W_2 + self.b_2

        # Residual + LayerNorm
        X = self.layer_norm(X + ffn_out, self.gamma_2, self.beta_2)

        return X

def softmax(x, axis=None):
    e_x = np.exp(x - np.max(x, axis=axis, keepdims=True))
    return e_x / np.sum(e_x, axis=axis, keepdims=True)

# Test the block
block = TransformerEncoderBlock(d_model=768, num_heads=12, d_ff=3072)
X = np.random.randn(10, 768)  # (seq_len=10, d_model=768)
output = block.forward(X)
print(f"Input shape: {X.shape}, Output shape: {output.shape}")
Key Insight: These NumPy implementations show the math clearly. For real use:
  • Use PyTorch, TensorFlow, or JAX for automatic differentiation
  • Use optimized CUDA kernels (FlashAttention, etc.)
  • Implement distributed training for large models
  • Use mixed precision (FP16) for memory efficiency
Takeaway: Understanding BERT's mechanics (input embeddings, MLM pre-training, attention, layer normalization) equips you to fine-tune, interpret, and debug models. BERT's architecture appears in modern variants (RoBERTa, DistilBERT, ELECTRA), vision transformers, and multimodal models. Master BERT, and you'll recognize these components everywhere.
End of BERT