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
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.
Objectives
nn.TransformerEncoderLayer × N) and count its parametersnn.MultiheadAttentionWarm-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).
Section
Part 1 of 4
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).
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).
Concept
For one block with d_model=64, d_ff=256, n_heads=8 — count each sub-module.
| sub-module | formula | count (d=64, d_ff=256) |
|---|---|---|
| Self-attention (Wq,Wk,Wv,Wo + biases) | 4d² + 4d | 16,640 |
| FFN (W1, b1, W2, b2) | d·d_ff + d_ff + d_ff·d + d | 33,088 |
| LayerNorm ×2 (scale + bias) | 2·2d | 256 |
| Total per block | 49,984 |
Verified: sum(p.numel() for p in nn.TransformerEncoderLayer(64,8,256,batch_first=True).parameters()) = 49,984. Scale linearly with N layers.
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-module | formula | count (d=64, d_ff=256) |
|---|---|---|
| Self-attention (Wq,Wk,Wv,Wo + biases) | 4d² + 4d | 16,640 |
| FFN (W1, b1, W2, b2) | d·d_ff + d_ff + d_ff·d + d | 33,088 |
| LayerNorm ×2 (scale + bias) | 2·2d | 256 |
| Total per block | 49,984 |
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.
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.
| tensor | shape | note |
|---|---|---|
| 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,264 | 2 × 1,632 |
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.
Section
Part 2 of 4
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).
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):
| pos | dim 0 (sin) | dim 2 (sin) | dim 1 (cos) | dim 3 (cos) |
|---|---|---|---|---|
| 0 | 0.0000 | 0.0000 | 1.0000 | 1.0000 |
| 1 | 0.8415 | 0.0998 | 0.5403 | 0.9950 |
| 2 | 0.9093 | 0.1987 | -0.4161 | 0.9801 |
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.
| pos | dim 0 (sin) | dim 2 (sin) | dim 1 (cos) | dim 3 (cos) |
|---|---|---|---|---|
| 0 | 0.0000 | 0.0000 | 1.0000 | 1.0000 |
| 1 | 0.8415 | 0.0998 | 0.5403 | 0.9950 |
| 2 | 0.9093 | 0.1987 | -0.4161 | 0.9801 |
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.
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).
| pos | PE[pos,0] sin | PE[pos,1] cos |
|---|---|---|
| 0 | 0.0000 | 1.0000 |
| 1 | 0.8415 | 0.5403 |
| 2 | 0.9093 | -0.4161 |
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?
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).
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).
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.
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).
Section
Part 3 of 4
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.
| step | operation |
|---|---|
| 1. Prepend CLS | ids = cat([CLS_id], token_ids) → shape (T+1,) |
| 2. Embed + PE | x = embed(ids) + PE[:T+1] → (B, T+1, d) |
| 3. Encoder | x = encoder(x, mask) → (B, T+1, d) |
| 4. Pool | cls_out = x[:, 0, :] → (B, d) |
| 5. Classify | logits = fc(cls_out) → (B, C) |
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.
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 row | token ids | valid 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.
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.
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.
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 pos | attn to CLS | attn to tok4 | attn to tok5 | attn to PAD |
|---|---|---|---|---|
| 0 (CLS) | 0.2426 | 0.2951 | 0.2811 | 0.1812 |
| 1 (tok4) | 0.2589 | 0.2831 | 0.1449 | 0.3131 |
| 2 (tok5) | 0.2152 | 0.1708 | 0.3243 | 0.2897 |
| 3 (PAD) | 0.2026 | 0.1147 | 0.3162 | 0.3665 |
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?
Section
Part 4 of 4
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).
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.
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.
| step | LR (lr_max=1e-3, warmup=10, total=100) |
|---|---|
| 0 | 0.000000 (start of warmup) |
| 5 | 0.000500 (mid-warmup) |
| 10 | 0.001000 (peak) |
| 50 | 0.000587 (cosine decay) |
| 99 | 0.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.
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.
| step | LR (lr_max=1e-3, warmup=10, total=100) |
|---|---|
| 0 | 0.000000 (start of warmup) |
| 5 | 0.000500 (mid-warmup) |
| 10 | 0.001000 (peak) |
| 50 | 0.000587 (cosine decay) |
| 99 | 0.000000 (end) |
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).
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).
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.
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.
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.
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.
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):AdamW(model.parameters(), weight_decay=0.01) — weight decay on all parameters.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:
nn.Embedding(vocab+1, d) — reserve one id for [CLS]x += PE[:T] (sinusoidal, fixed buffer, no grad)ids = cat([CLS_id], token_ids) before embeddingkey_padding_mask = (ids == pad_id) — True = ignorenn.TransformerEncoder(layer, N) — shape-preservingcls_out = encoder_out[:, 0, :] — only the [CLS] positionlogits = fc(cls_out) → CrossEntropyLoss(label_smoothing=ε)AdamW(weight_decay=0.01) + linear warmup + cosine decayPattern
nn.Embedding(vocab+1, d) — reserve one id for [CLS]x += PE[:T] (sinusoidal, fixed buffer, no grad)ids = cat([CLS_id], token_ids) before embeddingkey_padding_mask = (ids == pad_id) — True = ignorenn.TransformerEncoder(layer, N) — shape-preservingcls_out = encoder_out[:, 0, :] — only the [CLS] positionlogits = fc(cls_out) → CrossEntropyLoss(label_smoothing=ε)AdamW(weight_decay=0.01) + linear warmup + cosine decayEdge 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:
nn.Embedding(vocab+1, d) — reserve one id for [CLS]x += PE[:T] (sinusoidal, fixed buffer, no grad)ids = cat([CLS_id], token_ids) before embeddingkey_padding_mask = (ids == pad_id) — True = ignorenn.TransformerEncoder(layer, N) — shape-preservingcls_out = encoder_out[:, 0, :] — only the [CLS] positionlogits = fc(cls_out) → CrossEntropyLoss(label_smoothing=ε)AdamW(weight_decay=0.01) + linear warmup + cosine decayElimination
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.
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.
Check
Think before clicking.
Check your understanding
After N encoder layers, how do we extract a single vector for sequence classification?
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.
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.
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:
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.
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.
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.
Check
Know the distinction for the exam.
Check your understanding
AdamW differs from Adam with L2 regularization because:
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.
Section
Project
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.
| # | milestone | key tool |
|---|---|---|
| 1 | Build sinusoidal PE and verify PE[:3,:4] | torch.sin/cos, register_buffer |
| 2 | Prepend [CLS], build padding mask, run MHA | nn.MultiheadAttention, key_padding_mask |
| 3 | Assemble full model; train 50 epochs on synthetic data | nn.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.
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.
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))| pos | dim 0 (sin) | dim 1 (cos) | dim 2 (sin) | dim 3 (cos) |
|---|---|---|---|---|
| 0 | 0.0000 | 1.0000 | 0.0000 | 1.0000 |
| 1 | 0.8415 | 0.5403 | 0.0998 | 0.9950 |
| 2 | 0.9093 | -0.4161 | 0.1987 | 0.9801 |
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?
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 row | PAD col sum | expected |
|---|---|---|
| batch 0 (no PAD) | 0.1812 (not masked) | non-zero, col 3 valid |
| batch 1 (PAD at pos 3) | ~0.0 | zeroed by mask |
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()))| epoch | train loss | val acc |
|---|---|---|
| 0 | 0.8466 | 50.0% |
| 9 | 0.4411 | 82.5% |
| 24 | 0.3404 | 80.0% |
| 49 | 0.3214 | 80.0% |
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.
| epoch | train loss | val acc |
|---|---|---|
| 0 | 0.8466 | 50.0% |
| 9 | 0.4411 | 82.5% |
| 24 | 0.3404 | 80.0% |
| 49 | 0.3214 | 80.0% |
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')| component | choice | why |
|---|---|---|
| embedding | nn.Embedding(vocab+1, d) | +1 reserves an id for [CLS] |
| PE | register_buffer (no grad) | fixed sinusoidal, not learned |
| pooling | encoder_out[:, 0, :] | CLS token aggregates all context |
| optimizer | AdamW(wd=0.01) | decoupled decay; better for transformers |
| schedule | warmup→cosine | avoids early divergence; decays to 0 |
| loss | CE(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.
Comparison
Comparison matrix
From The full program: refill the choice column from what you know. The rest of the table is as it appeared.
| component | choice | why |
|---|---|---|
| embedding | nn.Embedding(vocab+1, d) | +1 reserves an id for [CLS] |
| PE | register_buffer (no grad) | fixed sinusoidal, not learned |
| pooling | encoder_out[:, 0, :] | CLS token aggregates all context |
| optimizer | AdamW(wd=0.01) | decoupled decay; better for transformers |
| schedule | warmup→cosine | avoids early divergence; decays to 0 |
| loss | CE(label_smoothing=0.1) | prevents logit saturation |
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).
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.
Recap
nn.TransformerEncoder and count params (4d² + 4d + 2d·d_ff + d_ff + 4d per block)key_padding_mask=(ids==0), pool out[:, 0, :]| concept | the one thing to remember |
|---|---|
| stacked encoder | shape-preserving; depth = capacity; N layers = N × (49,984) params for d=64 |
| positional encoding | without it the encoder is permutation-invariant (same output on shuffled input) |
| [CLS] pooling | prepend at pos 0, read off pos 0 after encoding |
| padding mask | True = ignore; pass to every MHA layer |
| AdamW | weight decay applied after adaptive update, not inside it |
| label smoothing | trades a bit of fit for better-calibrated logits |
Want this taught 1-on-1? Alexander tutors Machine Learning — $55/session, free consultation.