Lesson 84: Transformer Encoder for Classification

USAAIO Lesson 84, from Week 29 of Phase 3. It covers stacking N encoder blocks, sinusoidal positional encoding, pooling on the [CLS] token, and constructing a padding mask, then assembles the full encoder classifier with AdamW, linear warmup, cosine decay, and label smoothing. You build TransformerEncoderClassifier from scratch, verify that it is permutation-invariant without positional encoding, and confirm all the encoder-block parameter counts. The lesson runs to 32 slides.

Subject: Machine Learning · 61 slides · code lesson

Open the interactive version of this deck · Homework for this lesson

What this lesson covers

The lesson, slide by slide

1. Transformer Encoder for Classification

Title

USAAIO · Lesson 84 · Week 29 (Phase 3: Transformers)

Stack N encoder blocks, prepend a [CLS] token, mask padding, and train with AdamW + warmup + cosine decay + label smoothing — the recipe powering BERT-family classifiers.

2. By the end of this lesson you can

Objectives

  1. Build a stacked TransformerEncoder (nn.TransformerEncoderLayer × N) and count its parameters
  2. Construct sinusoidal positional encoding and add it to token embeddings before the encoder
  3. Prepend a [CLS] token, extract its final-layer output, and route it through a linear classification head
  4. Create a padding attention mask from token ids and pass it to nn.MultiheadAttention
  5. Train with AdamW, a linear-warmup / cosine-decay schedule, and label smoothing, and explain why each one matters

3. What survived from Positional Encoding?

Warm-up

Discussion prompt

Before we open Lesson 84: Transformer Encoder for Classification: without looking back, what was the main idea of Positional Encoding, and what could you do by the end of it that you could not do before?

Hint: One sentence for the idea, one for the skill. If the second one is blank, that is the part to revisit.

Answer:

why self-attention is permutation-equivariant and requires positional information, sinusoidal PE (formula, relative-position linearity proof), learned PE (nn.Embedding), RoPE (rotate Q and K by position angle, relative-position dot product property), and ALiBi (linear attention score bias, length extrapolation).

4. Stacking encoder blocks

Section

Part 1 of 4

5. One encoder block recap (Lesson 82)

Concept

A single encoder block = multi-head self-attention → Add&Norm → position-wise FFN → Add&Norm (Lessons 82–83). Today we stack N of them and add the classification machinery on top.

\( \text{Block}(x) = \text{LayerNorm}\!\left(\text{FFN}\!\left(\text{LayerNorm}(x + \text{MHA}(x))\right)\!+\!\text{LayerNorm}(x + \text{MHA}(x))\right) \)

In PyTorch: nn.TransformerEncoderLayer(d_model, nhead, dim_feedforward, batch_first=True). Wrap N of them with nn.TransformerEncoder(layer, num_layers=N).

6. Break it if you can: One encoder block recap (Lesson 82)

Counterexample

Discussion prompt

A single encoder block = multi-head self-attention → Add&Norm → position-wise FFN → Add&Norm (Lessons 82–83). Today we stack N of them and add the classification machinery on top.

That is stated as though it always holds. Do one of two things: produce a case where it fails, or say precisely what rules such a case out. "It just does" is not on the menu.

Hint: Hunt at the extremes first — zero, one, negative, empty, equal. If every extreme survives, the reason they survive is the proof.

Answer:

In PyTorch: nn.TransformerEncoderLayer(d_model, nhead, dim_feedforward, batch_first=True). Wrap N of them with nn.TransformerEncoder(layer, num_layers=N).

7. Parameter count per encoder block

Concept

For one block with d_model=64, d_ff=256, n_heads=8 — count each sub-module.

sub-moduleformulacount (d=64, d_ff=256)
Self-attention (Wq,Wk,Wv,Wo + biases)4d² + 4d16,640
FFN (W1, b1, W2, b2)d·d_ff + d_ff + d_ff·d + d33,088
LayerNorm ×2 (scale + bias)2·2d256
Total per block49,984

Verified: sum(p.numel() for p in nn.TransformerEncoderLayer(64,8,256,batch_first=True).parameters()) = 49,984. Scale linearly with N layers.

8. What each one costs: Parameter count per encoder block

Trade off

Comparison matrix

From Parameter count per encoder block: every row here is a choice with a cost. Fill the count (d=64, d_ff=256) column, then say which row you would actually pick and what you give up for it.

sub-moduleformulacount (d=64, d_ff=256)
Self-attention (Wq,Wk,Wv,Wo + biases)4d² + 4d16,640
FFN (W1, b1, W2, b2)d·d_ff + d_ff + d_ff·d + d33,088
LayerNorm ×2 (scale + bias)2·2d256
Total per block49,984

9. Guess the shape of the answer: Stacking N layers — shape trace

Estimation

Predict first

Build a 2-layer encoder, pass a batch of shape (B=2, T=4, d=16) through it, and confirm the output shape is unchanged.

Commit before you compute: what does Stacking N layers — shape trace come out to? A rough magnitude and the right form is enough — the point is to have something concrete to be wrong about.

Correct: output shape = (2, 4, 16), params = 3,264 (= 2 × 1,632 per block)

Why: A prediction you can defend turns the computation into a check rather than a leap of faith — and an answer that contradicts it is caught on the spot. The encoder is shape-preserving: every token embedding is transformed but the (B, T, d) dimensions are identical at input and output — the stack adds depth, not width.

10. Stacking N layers — shape trace

Worked example

Build a 2-layer encoder, pass a batch of shape (B=2, T=4, d=16) through it, and confirm the output shape is unchanged.

import torch, torch.nn as nn
torch.manual_seed(42)
d, n_heads, d_ff, N = 16, 2, 32, 2
enc_layer = nn.TransformerEncoderLayer(d_model=d, nhead=n_heads,
                                        dim_feedforward=d_ff, batch_first=True,
                                        dropout=0.0)
encoder   = nn.TransformerEncoder(enc_layer, num_layers=N)
x = torch.randn(2, 4, d)       # (B, T, d)
out = encoder(x)               # same shape
print('input :', x.shape)
print('output:', out.shape)
print('params:', sum(p.numel() for p in encoder.parameters()))

output shape = (2, 4, 16), params = 3,264 (= 2 × 1,632 per block)

Why: The encoder is shape-preserving: every token embedding is transformed but the (B, T, d) dimensions are identical at input and output — the stack adds depth, not width.

tensorshapenote
input x(2, 4, 16)B=2 seqs, T=4 tokens, d=16
after layer 1(2, 4, 16)shape preserved
after layer 2(2, 4, 16)shape preserved
params (N=2, d=16, d_ff=32)3,2642 × 1,632

11. Which is which, by shape

Discrimination

Sort into buckets

Sort these by shape, from memory, without looking back at Stacking N layers — shape trace. Telling them apart on the spot is the skill; the table is only where the answer happens to be written down.

(2, 4, 16)
input x; after layer 1; after layer 2
3,264
params (N=2, d=16, d_ff=32)
g1
shape is "(2, 4, 16)" for input x, after layer 1, after layer 2 — that is what the table on "Stacking N layers — shape trace" records, and it is the single property separating this group from the rest.
g2
shape is "3,264" for params (N=2, d=16, d_ff=32) — that is what the table on "Stacking N layers — shape trace" records, and it is the single property separating this group from the rest.

12. Positional encoding

Section

Part 2 of 4

13. Why attention needs position signals

Concept

Self-attention treats input as a set: the result for token i depends on all tokens but not on their order. Without a position signal, the encoder is permutation-invariant — it cannot tell 'dog bites man' from 'man bites dog'.

Sinusoidal positional encoding (Vaswani et al. 2017) adds a deterministic signal PE[pos, dim] to each token embedding. No extra parameters; any position can be expressed, and relative distances can be extracted by dot products (Lesson 82).

14. Sinusoidal PE formula

Concept

\[ PE[\text{pos},\, 2i] = \sin\!\left(\frac{\text{pos}}{10000^{2i/d}}\right), \quad PE[\text{pos},\, 2i{+}1] = \cos\!\left(\frac{\text{pos}}{10000^{2i/d}}\right) \]

Verified for d=8, positions 0–2, dims 0–3 (sin dims only):

posdim 0 (sin)dim 2 (sin)dim 1 (cos)dim 3 (cos)
00.00000.00001.00001.0000
10.84150.09980.54030.9950
20.90930.1987-0.41610.9801

15. Fill in: dim 1 (cos) for Sinusoidal PE formula

Comparison

Comparison matrix

From Sinusoidal PE formula: refill the dim 1 (cos) column from what you know. The rest of the table is as it appeared.

posdim 0 (sin)dim 2 (sin)dim 1 (cos)dim 3 (cos)
00.00000.00001.00001.0000
10.84150.09980.54030.9950
20.90930.1987-0.41610.9801

16. Guess the shape of the answer: Building sinusoidal PE in PyTorch

Estimation

Predict first

Compute the PE matrix for d=8, max_len=10 and print the first 3 rows and 4 columns.

Commit before you compute: what does Building sinusoidal PE in PyTorch come out to? A rough magnitude and the right form is enough — the point is to have something concrete to be wrong about.

Correct: PE[:3, :4] = [[0,1,0,1],[0.8415,0.5403,0.0998,0.995],[0.9093,-0.4161,0.1987,0.9801]]

Why: A prediction you can defend turns the computation into a check rather than a leap of faith — and an answer that contradicts it is caught on the spot. Even columns (0, 2) use sin; odd columns (1, 3) use cos.

17. Building sinusoidal PE in PyTorch

Worked example

Compute the PE matrix for d=8, max_len=10 and print the first 3 rows and 4 columns.

import torch, numpy as np
d_model, max_len = 8, 10
PE = torch.zeros(max_len, d_model)
pos = torch.arange(0, max_len).unsqueeze(1).float()
div_term = torch.exp(torch.arange(0, d_model, 2).float()
                     * (-np.log(10000.0) / d_model))
PE[:, 0::2] = torch.sin(pos * div_term)   # even dims
PE[:, 1::2] = torch.cos(pos * div_term)   # odd dims
print('PE shape:', PE.shape)
print(PE[:3, :4].numpy().round(4))

PE[:3, :4] = [[0,1,0,1],[0.8415,0.5403,0.0998,0.995],[0.9093,-0.4161,0.1987,0.9801]]

Why: Even columns (0, 2) use sin; odd columns (1, 3) use cos. The high-frequency dim 0 completes a half-cycle by position 2; dim 2 barely moves (low frequency, large period).

posPE[pos,0] sinPE[pos,1] cos
00.00001.0000
10.84150.5403
20.9093-0.4161

18. Watch it run: Building sinusoidal PE in PyTorch

Pattern

Step through it

Step through Building sinusoidal PE in PyTorch one row at a time. What is driving the change, and what would the row after the last one be?

  1. Step 1: pos is 0
  2. Step 2: pos is 1
  3. Step 3: pos is 2

19. Something is wrong here: skipping positional encoding

Anomaly

Predict first

A student writes this, and it looks reasonable:

The self-attention weights implicitly learn position from training data — no explicit PE needed.

It is wrong. Say what breaks — and say it before you turn the page.

Correct: Without PE, the encoder is a permutation-invariant function: shuffling the tokens produces the same output (just reordered).

Without PE: sorted output_a == sorted output_b — the model cannot distinguish 'A B C' from 'C A B'.

Why: Without PE, the encoder is a permutation-invariant function: shuffling the tokens produces the same output (just reordered). On a dataset with shuffled inputs, accuracy stays at chance level. Experimentally verified: same tokens in different order produce identical sorted output vectors (torch.allclose = True).

20. Trap: skipping positional encoding

Trap

The trap

The self-attention weights implicitly learn position from training data — no explicit PE needed.

Remove PE; train the encoder; expect normal accuracy

Why: Without PE, the encoder is a permutation-invariant function: shuffling the tokens produces the same output (just reordered). On a dataset with shuffled inputs, accuracy stays at chance level. Experimentally verified: same tokens in different order produce identical sorted output vectors (torch.allclose = True).

The fix

Without PE: sorted output_a == sorted output_b — the model cannot distinguish 'A B C' from 'C A B'.

Add sinusoidal PE: input = embed(ids) + PE[:T, :]

Why: PE injects order information so the same token at different positions produces different representations. Single line: x = self.embedding(ids) + self.pe[:x.size(1)].unsqueeze(0). With PE the model learns that position matters; without it the transformer homework experiment (USAAIO L84 experiment) confirms same accuracy on shuffled input.

21. Break it on purpose: skipping positional encoding

Break the constraint

Discussion prompt

The rule this trap just fixed:

Without PE: sorted output_a == sorted output_b — the model cannot distinguish 'A B C' from 'C A B'.

Now break it on purpose. Build a case that violates it and follow the consequences until something visibly fails. Where does the failure first show up — and would you have noticed it if you had not been looking?

Hint: The dangerous rules are the ones whose violation still produces an answer. If yours fails loudly, try to find one that fails quietly.

Answer:

Without PE, the encoder is a permutation-invariant function: shuffling the tokens produces the same output (just reordered). On a dataset with shuffled inputs, accuracy stays at chance level. Experimentally verified: same tokens in different order produce identical sorted output vectors (torch.allclose = True).

22. [CLS] token & padding mask

Section

Part 3 of 4

23. The [CLS] token pattern

Concept

For sequence-level classification, we need one vector summarizing the entire sequence. The [CLS] (classification) token is a learnable special token prepended at position 0.

After N encoder layers, the [CLS] embedding has attended to every token and aggregates global context. We feed only output[:, 0, :] — position 0 — into the linear classification head.

stepoperation
1. Prepend CLSids = cat([CLS_id], token_ids) → shape (T+1,)
2. Embed + PEx = embed(ids) + PE[:T+1] → (B, T+1, d)
3. Encoderx = encoder(x, mask) → (B, T+1, d)
4. Poolcls_out = x[:, 0, :] → (B, d)
5. Classifylogits = fc(cls_out) → (B, C)

24. By analogy: The [CLS] token pattern

Analogy

Discussion prompt

Explain The [CLS] token pattern by analogy to something with no Machine Learning in it at all — a queue, a recipe, a map, a bank balance, whatever fits. Then say where your analogy breaks.

Hint: An analogy that never breaks is not an analogy, it is the same idea wearing a hat. Find the seam — that is the part that is actually new.

Answer:

For sequence-level classification, we need one vector summarizing the entire sequence. The [CLS] (classification) token is a learnable special token prepended at position 0.

25. Padding attention mask

Concept

Batched sequences are padded to the same length. Padding positions must not contribute to attention: queries should not see padding tokens as valid context.

For input_ids = [[CLS,1,2,3],[CLS,4,5,PAD]], the valid mask and key padding mask are:

batch rowtoken idsvalid mask (True=ok)key_padding_mask (True=ignore)
0[10, 1, 2, 3][T, T, T, T][F, F, F, F]
1[10, 4, 5, 0][T, T, T, F][F, F, F, T]

Pass key_padding_mask = (input_ids == 0) to nn.MultiheadAttention. PyTorch convention: True = ignore that key position.

26. Teach it back: Padding attention mask

Explain it

Discussion prompt

Explain Padding attention mask to a student a year behind you. No notation, no jargon they have not met — and it still has to be true.

Hint: If your explanation needs a symbol they have never seen, you are describing the notation rather than the idea.

Answer:

Batched sequences are padded to the same length. Padding positions must not contribute to attention: queries should not see padding tokens as valid context.

27. Guess the shape of the answer: MHA with padding mask — attention weights

Estimation

Predict first

Run nn.MultiheadAttention on the two-sentence batch with a pad mask and observe that position 3 (PAD) in batch-1 gets zero attention weight.

Commit before you compute: what does MHA with padding mask — attention weights come out to? A rough magnitude and the right form is enough — the point is to have something concrete to be wrong about.

Correct: attn_w[1, :, 3] = [0, 0, 0, 0] — the PAD column is zeroed out entirely

Why: A prediction you can defend turns the computation into a check rather than a leap of faith — and an answer that contradicts it is caught on the spot. The key padding mask sets the raw attention score for PAD keys to -inf before softmax, so e^(-inf) = 0.

28. MHA with padding mask — attention weights

Worked example

Run nn.MultiheadAttention on the two-sentence batch with a pad mask and observe that position 3 (PAD) in batch-1 gets zero attention weight.

import torch, torch.nn as nn
torch.manual_seed(7)
D = 8
embed = nn.Embedding(12, D)
cls_id = 10
tokens = torch.tensor([[1, 2, 3], [4, 5, 0]])
cls_tok = torch.full((2, 1), cls_id)
input_ids = torch.cat([cls_tok, tokens], dim=1)   # (2, 4)
key_pad = (input_ids == 0)                        # (2, 4) bool
mha = nn.MultiheadAttention(embed_dim=D, num_heads=2, batch_first=True)
x_emb = embed(input_ids).detach()
_, attn_w = mha(x_emb, x_emb, x_emb, key_padding_mask=key_pad)
print('attn_w[1] (row=query, col=key):')          # batch-1 has PAD at col 3
print(attn_w[1].detach().numpy().round(4))

attn_w[1, :, 3] = [0, 0, 0, 0] — the PAD column is zeroed out entirely

Why: The key padding mask sets the raw attention score for PAD keys to -inf before softmax, so e^(-inf) = 0. The query cannot 'look at' padding.

query posattn to CLSattn to tok4attn to tok5attn to PAD
0 (CLS)0.24260.29510.28110.1812
1 (tok4)0.25890.28310.14490.3131
2 (tok5)0.21520.17080.32430.2897
3 (PAD)0.20260.11470.31620.3665

29. Watch it run: MHA with padding mask — attention weights

Pattern

Step through it

Step through MHA with padding mask — attention weights one row at a time. What is driving the change, and what would the row after the last one be?

  1. Step 1: query pos is 0 (CLS)
  2. Step 2: query pos is 1 (tok4)
  3. Step 3: query pos is 2 (tok5)
  4. Step 4: query pos is 3 (PAD)

30. AdamW, warmup, label smoothing

Section

Part 4 of 4

31. AdamW: decoupled weight decay

Concept

Standard Adam applies L2 regularization by adding λw to the gradient before the adaptive scaling — this entangles the decay with the adaptive scale and under-regularizes large-gradient parameters.

\[ \text{AdamW update: } \theta \leftarrow \theta - \eta \cdot \frac{\hat{m}}{\sqrt{\hat{v}} + \epsilon} - \eta \lambda \theta \]

The - ηλθ decay is applied after the adaptive gradient update, independently. In PyTorch: torch.optim.AdamW(params, lr=1e-4, weight_decay=0.01). Empirically outperforms Adam for transformers (Loshchilov & Hutter, 2019).

32. Teach it back: AdamW: decoupled weight decay

Explain it

Discussion prompt

Explain AdamW: decoupled weight decay to a student a year behind you. No notation, no jargon they have not met — and it still has to be true.

Hint: If your explanation needs a symbol they have never seen, you are describing the notation rather than the idea.

Answer:

Standard Adam applies L2 regularization by adding λw to the gradient before the adaptive scaling — this entangles the decay with the adaptive scale and under-regularizes large-gradient parameters.

33. Linear warmup + cosine decay schedule

Concept

Transformers are sensitive to the initial learning rate. Training with a large lr from step 0 destroys early attention patterns. The standard fix: linearly ramp from 0 to lr_max over warmup_steps, then cosine-decay to 0.

stepLR (lr_max=1e-3, warmup=10, total=100)
00.000000 (start of warmup)
50.000500 (mid-warmup)
100.001000 (peak)
500.000587 (cosine decay)
990.000000 (end)

Implemented with LambdaLR. The lambda returns step/warmup_steps before the peak, then 0.5*(1 + cos(π·progress)) after it. Verified with torch 2.7.1.

34. Fill in: LR (lr_max=1e-3, warmup=10, total=100) for Linear warmup + cosine decay schedule

Comparison

Comparison matrix

From Linear warmup + cosine decay schedule: refill the LR (lr_max=1e-3, warmup=10, total=100) column from what you know. The rest of the table is as it appeared.

stepLR (lr_max=1e-3, warmup=10, total=100)
00.000000 (start of warmup)
50.000500 (mid-warmup)
100.001000 (peak)
500.000587 (cosine decay)
990.000000 (end)

35. Label smoothing

Concept

Hard targets (one-hot) push the model to assign all probability to the correct class — logits diverge to +∞ and the model becomes overconfident. Label smoothing redistributes a fraction ε of probability mass uniformly.

\[ y_{\text{smooth}} = (1 - \varepsilon)\cdot y_{\text{hard}} + \frac{\varepsilon}{C} \]

With logits=[2.0,0.5,-1.0,-0.5], label=0, C=4: hard CE = 0.3038, smoothed CE (ε=0.1) = 0.4788 — the higher loss prevents logit saturation. nn.CrossEntropyLoss(label_smoothing=0.1).

36. By analogy: Label smoothing

Analogy

Discussion prompt

Explain Label smoothing by analogy to something with no Machine Learning in it at all — a queue, a recipe, a map, a bank balance, whatever fits. Then say where your analogy breaks.

Hint: An analogy that never breaks is not an analogy, it is the same idea wearing a hat. Find the seam — that is the part that is actually new.

Answer:

With logits=[2.0,0.5,-1.0,-0.5], label=0, C=4: hard CE = 0.3038, smoothed CE (ε=0.1) = 0.4788 — the higher loss prevents logit saturation. nn.CrossEntropyLoss(label_smoothing=0.1).

37. Something is wrong here: applying weight_decay to LayerNorm and biases

Anomaly

Predict first

A student writes this, and it looks reasonable:

AdamW(model.parameters(), weight_decay=0.01) — weight decay on all parameters.

It is wrong. Say what breaks — and say it before you turn the page.

Correct: Decaying LayerNorm parameters and biases hurts performance: LayerNorm parameters control the scale of activations, and biases are unconstrained offsets — shrinking them toward 0 is poorly motivated and disrupts training.

Split parameter groups: apply weight_decay only to weight matrices, not biases or norm parameters.

Why: Decaying LayerNorm parameters and biases hurts performance: LayerNorm parameters control the scale of activations, and biases are unconstrained offsets — shrinking them toward 0 is poorly motivated and disrupts training.

38. Trap: applying weight_decay to LayerNorm and biases

Trap

The trap

AdamW(model.parameters(), weight_decay=0.01) — weight decay on all parameters.

Decay LayerNorm scale/bias and all bias vectors the same as weight matrices

Why: Decaying LayerNorm parameters and biases hurts performance: LayerNorm parameters control the scale of activations, and biases are unconstrained offsets — shrinking them toward 0 is poorly motivated and disrupts training.

The fix

Split parameter groups: apply weight_decay only to weight matrices, not biases or norm parameters.

no_decay = [p for n,p in model.named_parameters() if 'bias' in n or 'norm' in n]

Why: Pass two param groups to AdamW: one with weight_decay=0.01 (weight matrices), one with weight_decay=0.0 (biases + LayerNorm). This is the standard BERT fine-tuning recipe and prevents the optimizer from shrinking parameters that control activation scale.

39. Which of these survive contact with Lesson 84: Transformer Encoder for…?

Two truths and a lie

Sort into buckets

Some of these hold up and some are the exact mistakes this lesson is built to prevent. Sort them.

Holds up
In PyTorch: nn.TransformerEncoderLayer(d_model, nhead, dim_feedforward, batch_first=True). Wrap N of them with nn.TransformerEncoder(layer, num_layers=N).; For one block with d_model=64, d_ff=256, n_heads=8 — count each sub-module.; Verified for d=8, positions 0–2, dims 0–3 (sin dims only):
Breaks
The self-attention weights implicitly learn position from training data — no explicit PE needed.; AdamW(model.parameters(), weight_decay=0.01) — weight decay on all parameters.
sound
These are stated as this lesson states them — each one survives the edge cases Lesson 84: Transformer Encoder for Classification puts it through.
flawed
Each of these is lifted from a trap in this deck: reasonable-sounding, and wrong in a way that only shows up once you rely on it.

40. Without one step: The transformer encoder classifier recipe

Constraint

Discussion prompt

Run The transformer encoder classifier recipe with this step confiscated:

Stack N blocks: nn.TransformerEncoder(layer, N) — shape-preserving

Is it still possible? If it is, say what takes its place and what it costs you. If it is not, say exactly what that step was providing that nothing else does.

Hint: A step you can drop for free was never load-bearing. If you cannot drop it, name the thing that goes wrong the moment it is gone.

Answer:

  1. Embed: nn.Embedding(vocab+1, d) — reserve one id for [CLS]
  2. Positional encoding: x += PE[:T] (sinusoidal, fixed buffer, no grad)
  3. Prepend [CLS]: ids = cat([CLS_id], token_ids) before embedding
  4. Padding mask: key_padding_mask = (ids == pad_id) — True = ignore
  5. Stack N blocks: nn.TransformerEncoder(layer, N) — shape-preserving
  6. Pool: cls_out = encoder_out[:, 0, :] — only the [CLS] position
  7. Classify: logits = fc(cls_out) → CrossEntropyLoss(label_smoothing=ε)
  8. Optimizer: AdamW(weight_decay=0.01) + linear warmup + cosine decay

41. The transformer encoder classifier recipe

Pattern

  1. Embed: nn.Embedding(vocab+1, d) — reserve one id for [CLS]
  2. Positional encoding: x += PE[:T] (sinusoidal, fixed buffer, no grad)
  3. Prepend [CLS]: ids = cat([CLS_id], token_ids) before embedding
  4. Padding mask: key_padding_mask = (ids == pad_id) — True = ignore
  5. Stack N blocks: nn.TransformerEncoder(layer, N) — shape-preserving
  6. Pool: cls_out = encoder_out[:, 0, :] — only the [CLS] position
  7. Classify: logits = fc(cls_out) → CrossEntropyLoss(label_smoothing=ε)
  8. Optimizer: AdamW(weight_decay=0.01) + linear warmup + cosine decay

42. Where does it stop working: The transformer encoder classifier recipe

Edge cases

Discussion prompt

The transformer encoder classifier recipe works on the cases you have just seen. Push it to the edge: what is the most degenerate input it still handles — empty, zero, one item, everything equal — and what is the first case where it stops being true? Name the case, not just "it breaks".

Hint: Try the smallest legal input, then the largest, then the one where two things collide. Methods are specified at their edges; the middle takes care of itself.

Answer:

  1. Embed: nn.Embedding(vocab+1, d) — reserve one id for [CLS]
  2. Positional encoding: x += PE[:T] (sinusoidal, fixed buffer, no grad)
  3. Prepend [CLS]: ids = cat([CLS_id], token_ids) before embedding
  4. Padding mask: key_padding_mask = (ids == pad_id) — True = ignore
  5. Stack N blocks: nn.TransformerEncoder(layer, N) — shape-preserving
  6. Pool: cls_out = encoder_out[:, 0, :] — only the [CLS] position
  7. Classify: logits = fc(cls_out) → CrossEntropyLoss(label_smoothing=ε)
  8. Optimizer: AdamW(weight_decay=0.01) + linear warmup + cosine decay

43. Rule out three: Check yourself — [CLS] pooling

Elimination

Eliminate the wrong options

After N encoder layers, how do we extract a single vector for sequence classification?

3 of these 4 are wrong. Strike them one at a time, and say what rules each one out before you strike the next. The survivor is the answer.

  • A. Take encoder_out[:, 0, :] — the [CLS] token at position 0
  • B. Average encoder_out over the time dimension (T)
  • C. Take encoder_out[:, -1, :] — the last token
  • D. Max-pool encoder_out over the time dimension

Survives elimination: A

Why: The [CLS] token is prepended at position 0. Because self-attention is global, after N layers the CLS representation has aggregated context from every other token. We index position 0 with encoder_out[:, 0, :] and pass it to the linear head.

44. Check yourself — [CLS] pooling

Check

Think before clicking.

Check your understanding

After N encoder layers, how do we extract a single vector for sequence classification?

  • A. Take encoder_out[:, 0, :] — the [CLS] token at position 0 (correct)
  • B. Average encoder_out over the time dimension (T)
  • C. Take encoder_out[:, -1, :] — the last token
  • D. Max-pool encoder_out over the time dimension

Answer: A

Why: The [CLS] token is prepended at position 0. Because self-attention is global, after N layers the CLS representation has aggregated context from every other token. We index position 0 with encoder_out[:, 0, :] and pass it to the linear head.

Why B tempts people
Mean pooling over T is a valid alternative (used in some models) but it mixes in padding-token vectors unless masked; [CLS] pooling is the standard BERT-family approach.
Why C tempts people
In decoder-only models (GPT) the last token aggregates context causally. In the encoder, the last token is arbitrary — there is no causal chain, and padding often sits at the end.
Why D tempts people
Max pooling can work for sentence embeddings (e.g. InferSent) but is not standard in BERT-style encoders and discards positional structure.

45. Answer it before you see the options: Check yourself — padding mask convention

Prediction

Predict first

In nn.MultiheadAttention, the key_padding_mask should be True for positions that are:

Answer it in your own words, now, with nothing to choose from. The options are on the next slide — and picking the right one off a list is an easier skill than producing it.

Correct: Padding tokens — should be ignored

Why: PyTorch's MHA uses True = 'mask out / ignore this position'. So key_padding_mask = (input_ids == pad_id). The masked positions get -inf before softmax, zeroing their attention weight.

46. Check yourself — padding mask convention

Check

PyTorch convention for key_padding_mask in nn.MultiheadAttention.

Check your understanding

In nn.MultiheadAttention, the key_padding_mask should be True for positions that are:

  • A. Padding tokens — should be ignored (correct)
  • B. Valid tokens — should attend normally
  • C. The [CLS] token — special handling required
  • D. The first token of each sequence

Answer: A

Why: PyTorch's MHA uses True = 'mask out / ignore this position'. So key_padding_mask = (input_ids == pad_id). The masked positions get -inf before softmax, zeroing their attention weight.

Why B tempts people
The opposite: True = ignore, False = attend. Passing a mask of valid positions (True=valid) would zero out your actual content and attend only to padding.
Why C tempts people
The [CLS] token is a regular valid token at position 0 — it should not be masked. False for [CLS].
Why D tempts people
Position-based masking is for causal (autoregressive) masks; key_padding_mask is for variable-length sequences, not specific positions.

47. Rule out three: Check yourself — AdamW vs Adam

Elimination

Eliminate the wrong options

AdamW differs from Adam with L2 regularization because:

3 of these 4 are wrong. Strike them one at a time, and say what rules each one out before you strike the next. The survivor is the answer.

  • A. AdamW applies weight decay directly to the parameters, independent of the gradient adaptive scaling
  • B. AdamW uses a different moment estimator (β1, β2) than Adam
  • C. AdamW decays the learning rate whereas Adam does not
  • D. AdamW is Adam with momentum set to 0

Survives elimination: A

Why: Adam+L2 adds λw to the gradient before adaptive scaling, so the effective decay is λ/(sqrt(v̂)+ε) — it varies per parameter and is too small for high-gradient params. AdamW decouples the decay: θ ← θ - η·(grad_update) - ηλθ. The decay is the same for every parameter regardless of its gradient history.

48. Check yourself — AdamW vs Adam

Check

Know the distinction for the exam.

Check your understanding

AdamW differs from Adam with L2 regularization because:

  • A. AdamW applies weight decay directly to the parameters, independent of the gradient adaptive scaling (correct)
  • B. AdamW uses a different moment estimator (β1, β2) than Adam
  • C. AdamW decays the learning rate whereas Adam does not
  • D. AdamW is Adam with momentum set to 0

Answer: A

Why: Adam+L2 adds λw to the gradient before adaptive scaling, so the effective decay is λ/(sqrt(v̂)+ε) — it varies per parameter and is too small for high-gradient params. AdamW decouples the decay: θ ← θ - η·(grad_update) - ηλθ. The decay is the same for every parameter regardless of its gradient history.

Why B tempts people
Both Adam and AdamW use the same β1=0.9, β2=0.999 defaults. The moment estimator is unchanged.
Why C tempts people
Neither Adam nor AdamW decays the learning rate by default — that is done by a separate LR scheduler (e.g. LambdaLR). They differ only in how weight decay is applied.
Why D tempts people
Setting β1=0 in Adam gives RMSProp, not AdamW. AdamW keeps the same momentum as Adam.

49. Your turn: build the classifier

Section

Project

50. Project: TransformerEncoderClassifier from scratch

Concept

Build a full TransformerEncoderClassifier that embeds tokens, adds sinusoidal PE, applies 2 stacked encoder layers, pools the [CLS] token, and classifies. Train with AdamW + label smoothing.

#milestonekey tool
1Build sinusoidal PE and verify PE[:3,:4]torch.sin/cos, register_buffer
2Prepend [CLS], build padding mask, run MHAnn.MultiheadAttention, key_padding_mask
3Assemble full model; train 50 epochs on synthetic datann.TransformerEncoder, AdamW, LambdaLR

Build rules: type every line, print shapes after each module, and confirm the PAD column in attn_w[1] sums to near-zero.

51. Break it if you can: Project: TransformerEncoderClassifier from scratch

Counterexample

Discussion prompt

Build a full TransformerEncoderClassifier that embeds tokens, adds sinusoidal PE, applies 2 stacked encoder layers, pools the [CLS] token, and classifies. Train with AdamW + label smoothing.

That is stated as though it always holds. Do one of two things: produce a case where it fails, or say precisely what rules such a case out. "It just does" is not on the menu.

Hint: Hunt at the extremes first — zero, one, negative, empty, equal. If every extreme survives, the reason they survive is the proof.

Answer:

Build rules: type every line, print shapes after each module, and confirm the PAD column in attn_w[1] sums to near-zero.

52. Milestone 1 — sinusoidal PE

Worked example

Your turn: compute the PE matrix for d=8, max_len=10. Predict PE[1, 0] and PE[1, 1].

Hint: div_term = exp(arange(0,d,2) * (-log(10000)/d)). Even columns: sin; odd columns: cos.

import torch, numpy as np
d_model, max_len = 8, 10
PE = torch.zeros(max_len, d_model)
pos = torch.arange(0, max_len).unsqueeze(1).float()
div = torch.exp(torch.arange(0, d_model, 2).float()
                * (-np.log(10000.0) / d_model))
PE[:, 0::2] = torch.sin(pos * div)
PE[:, 1::2] = torch.cos(pos * div)
print(PE[:3, :4].numpy().round(4))
posdim 0 (sin)dim 1 (cos)dim 2 (sin)dim 3 (cos)
00.00001.00000.00001.0000
10.84150.54030.09980.9950
20.9093-0.41610.19870.9801

53. Watch it run: Milestone 1 — sinusoidal PE

Pattern

Step through it

Step through Milestone 1 — sinusoidal PE one row at a time. What is driving the change, and what would the row after the last one be?

  1. Step 1: pos is 0
  2. Step 2: pos is 1
  3. Step 3: pos is 2

54. Milestone 2 — CLS + padding mask

Worked example

Your turn: prepend [CLS] to two sentences (second has a PAD at position 3), build key_padding_mask, run MHA, and confirm PAD attention weight is 0.

Hint: key_padding_mask = (input_ids == 0) — True=ignore. Check attn_w[1, :, 3] is all zeros.

import torch, torch.nn as nn
torch.manual_seed(7)
D = 8; embed = nn.Embedding(12, D)
cls_id = 10
tokens = torch.tensor([[1,2,3],[4,5,0]])
input_ids = torch.cat([torch.full((2,1),cls_id), tokens], dim=1)
key_pad = (input_ids == 0)
mha = nn.MultiheadAttention(D, num_heads=2, batch_first=True)
x = embed(input_ids).detach()
_, attn_w = mha(x, x, x, key_padding_mask=key_pad)
print('PAD col (batch1):', attn_w[1, :, 3].detach().numpy().round(4))
batch rowPAD col sumexpected
batch 0 (no PAD)0.1812 (not masked)non-zero, col 3 valid
batch 1 (PAD at pos 3)~0.0zeroed by mask

55. Milestone 3 — full model, 50 epochs

Worked example

Your turn: assemble TinyEncoderCls (embed→encoder×2→CLS pool→fc), train 50 epochs with AdamW + label smoothing. Predict val_acc at epoch 9.

Hint: pool with x[:, 0, :]; use AdamW(lr=3e-3, weight_decay=0.01) and CrossEntropyLoss(label_smoothing=0.1). Total params should be around 4,674.

import torch, torch.nn as nn
torch.manual_seed(42)
class TinyEncoderCls(nn.Module):
    def __init__(self):
        super().__init__()
        self.embed = nn.Embedding(12, 16, padding_idx=0)
        enc = nn.TransformerEncoderLayer(d_model=16, nhead=2,
                  dim_feedforward=32, batch_first=True, dropout=0.0)
        self.encoder = nn.TransformerEncoder(enc, num_layers=2)
        self.fc = nn.Linear(16, 2)
    def forward(self, x):
        x = self.embed(x)
        x = self.encoder(x)
        return self.fc(x[:, 0, :])   # CLS pool
model = TinyEncoderCls()
print('params:', sum(p.numel() for p in model.parameters()))
epochtrain lossval acc
00.846650.0%
90.441182.5%
240.340480.0%
490.321480.0%

56. What each one costs: Milestone 3 — full model, 50 epochs

Trade off

Comparison matrix

From Milestone 3 — full model, 50 epochs: every row here is a choice with a cost. Fill the train loss column, then say which row you would actually pick and what you give up for it.

epochtrain lossval acc
00.846650.0%
90.441182.5%
240.340480.0%
490.321480.0%

57. The full program

Concept

import torch, torch.nn as nn, numpy as np, math
from torch.optim.lr_scheduler import LambdaLR
torch.manual_seed(42); np.random.seed(42)

# --- Sinusoidal PE ---
def make_pe(max_len, d):
    PE = torch.zeros(max_len, d)
    pos = torch.arange(0, max_len).unsqueeze(1).float()
    div = torch.exp(torch.arange(0,d,2).float()*(-np.log(10000)/d))
    PE[:,0::2]=torch.sin(pos*div); PE[:,1::2]=torch.cos(pos*div)
    return PE

class TransformerEncoderClassifier(nn.Module):
    def __init__(self, vocab, d, n_heads, N, d_ff, C, max_len=50):
        super().__init__()
        self.embed = nn.Embedding(vocab+1, d, padding_idx=0)
        self.register_buffer('pe', make_pe(max_len, d))
        enc = nn.TransformerEncoderLayer(d, n_heads, d_ff,
                  batch_first=True, dropout=0.0)
        self.encoder = nn.TransformerEncoder(enc, num_layers=N)
        self.fc = nn.Linear(d, C)
    def forward(self, ids, kpm=None):
        x = self.embed(ids) + self.pe[:ids.size(1)].unsqueeze(0)
        x = self.encoder(x, src_key_padding_mask=kpm)
        return self.fc(x[:, 0, :])   # CLS pool

model = TransformerEncoderClassifier(20,16,2,2,32,2)
opt   = torch.optim.AdamW(model.parameters(), lr=3e-3, weight_decay=0.01)
warm, total = 5, 50
scheduler = LambdaLR(opt, lambda s: s/warm if s<warm
            else 0.5*(1+math.cos(math.pi*(s-warm)/(total-warm))))
loss_fn = nn.CrossEntropyLoss(label_smoothing=0.1)

# Synthetic data (make_data from milestone 3)
for epoch in range(total):
    opt.zero_grad()
    loss = loss_fn(model(X_train), y_train)
    loss.backward(); opt.step(); scheduler.step()
print('done')
componentchoicewhy
embeddingnn.Embedding(vocab+1, d)+1 reserves an id for [CLS]
PEregister_buffer (no grad)fixed sinusoidal, not learned
poolingencoder_out[:, 0, :]CLS token aggregates all context
optimizerAdamW(wd=0.01)decoupled decay; better for transformers
schedulewarmup→cosineavoids early divergence; decays to 0
lossCE(label_smoothing=0.1)prevents logit saturation

If your val_acc exceeds 80% after 50 epochs — you have a working transformer encoder classifier. Same architecture scales to IMDB (50k reviews) by swapping the embedding and adjusting d, N, d_ff.

58. Fill in: choice for The full program

Comparison

Comparison matrix

From The full program: refill the choice column from what you know. The rest of the table is as it appeared.

componentchoicewhy
embeddingnn.Embedding(vocab+1, d)+1 reserves an id for [CLS]
PEregister_buffer (no grad)fixed sinusoidal, not learned
poolingencoder_out[:, 0, :]CLS token aggregates all context
optimizerAdamW(wd=0.01)decoupled decay; better for transformers
schedulewarmup→cosineavoids early divergence; decays to 0
lossCE(label_smoothing=0.1)prevents logit saturation

59. Show it off

Concept

Out loud, slides closed: (1) explain why a transformer without positional encoding is permutation-invariant and why that is a problem, (2) trace how a [CLS] token produces a sequence-level vector, and (3) state the two differences between AdamW and Adam+L2.

Stretch (homework): implement the USAAIO experiment — train two models (with vs without PE) on shuffled input and confirm the no-PE model's accuracy is invariant to shuffle. Compare your transformer to an LSTM on the same task (L57 LSTM baseline). Next: Lesson 85 — Transformer Decoder & Full Transformer (causal attention, seq2seq).

60. Connect it up: Lesson 84: Transformer Encoder for Classification

Connect it up

Draw it

One page, no notation unless you need it: draw how these connect — Stacking encoder blocks · Positional encoding · [CLS] token & padding mask · AdamW, warmup, label smoothing · Your turn: build the classifier. Put an arrow wherever one of them is what makes another possible, and label the arrow with why.

61. What you can do now

Recap

conceptthe one thing to remember
stacked encodershape-preserving; depth = capacity; N layers = N × (49,984) params for d=64
positional encodingwithout it the encoder is permutation-invariant (same output on shuffled input)
[CLS] poolingprepend at pos 0, read off pos 0 after encoding
padding maskTrue = ignore; pass to every MHA layer
AdamWweight decay applied after adaptive update, not inside it
label smoothingtrades a bit of fit for better-calibrated logits

Sources

  1. USAAIO Year-Long Master Lesson Plan, Lesson 84 (Week 29 — Transformer Encoder for Classification) — Barron · USAAIO Round 2 Preparation, 2026
  2. All positional encoding values, attention weights, padding masks, label-smoothing losses, permutation-invariance check, scheduler LR values, and encoder block param counts verified with torch 2.7.1+cpu, numpy 2.2.6, real execution June 2026 — Real execution, verified

Want this taught 1-on-1? Alexander tutors Machine Learning — $55/session, free consultation.

Book on Wyzant · Text (657) 465-8108