Week 11
Week 11 — Training and inference: learn, evaluate, save, and generate with the same Mini GPT
Key questionHow do we organize training, validation, checkpoints and generation around the same Week 10 MiniGPT, and identify exactly which state each line changes?
Learning objectives
- Trace the six fixed supervision signals through forward, loss, backward, clipping and AdamW in the correct state-change order.
- Use ln(5)≈1.609 as a uniform reference, and token-weighted held-out loss to distinguish training performance from generalization measurement.
- Recognize conflicting [you] contexts and derive the ideal fixed-batch mean-NLL infimum ln(2)/3≈0.231 rather than zero.
- Reuse Week 10's exact model, tokenizer and checkpoint identity; apply τ, top-k, Softmax and multinomial to final-position logits.
100 min estimated reading time
Week 10 computed one prediction. This week repeats that computation to learn and distinguishes training, validation and generation. Start with the minimal single-batch loop. Gradient accumulation and strict resume checks remain optional sections, not prerequisites for understanding one update.
Scroll horizontally to view all columns.
| Learning unit | Problem to solve |
|---|---|
| 1: Complete one update | Distinguish parameters, gradients and optimizer state; calculate two AdamW steps for one parameter. |
| 2: Repeat and measure | First learn fixed data, then validate on documents excluded from updates. State exactly when each log value was measured. |
| 3: Save and load | Save model configuration, vocabulary and weights. Restoring inference is not the same as reproducing interrupted training exactly. |
| 4: Generate | Take only the current final position's logits, adjust temperature, restrict candidates, sample, append and run forward again. |
Run python week11_adamw_numbers.py to inspect the two-step calculation. Next week's complete project provides independent-document training commands. Do not call falling loss on three fixed sentences generalization. Compare probability tables before judging temperature from a single random continuation.
Alternate reading, hand calculation and code changes. Each unit can take several sessions. Section numbers remain stable for old links and references; follow the page from top to bottom rather than jumping around to restore numerical order.
Week 11 Goal: Four Jobs Around the Same MiniGPT
The model computes; Week 11's outer training/generation module decides how to use the result. Training uses labels and intentionally updates θ. Validation measures current θ against genuinely held-out labels. Checkpointing saves or restores compatible long-lived state. Inference has no targets and selects/appends tokens. English mini-gpt-v1 splits on whitespace, uses ordered tokens [you, like, AI, study, we] at IDs 0..4 and has no special, padding or unknown token. Its canonical GPTConfig is vocab_size=5, block_size=2, n_embd=4, n_head=2, n_layer=2, with untied token-embedding and output-head weights.
# week11_training_and_generation.py
# This Week 11 caller imports the frozen Week 10 implementation.
import math
import torch
import torch.nn.functional as F
from mini_gpt_walkthrough import (
GPTConfig,
MiniGPT,
load_mini_gpt_for_inference,
save_mini_gpt_training_checkpoint,
validate_checkpoint_tokenizer_identity,
)
CANONICAL_ORDERED_TOKENS = ("you", "like", "AI", "study", "we")
CANONICAL_TOKENIZER_POLICY = (
"whitespace-delimited;no-specials;no-pad;no-unk"
)
CANONICAL_TOKENIZER_VERSION = "mini-gpt-v1"Scroll horizontally to view all columns.
| phase | Inputs and purpose | State that may change | Explicitly prohibited |
|---|---|---|---|
| training | inputs [3,2] + targets [3,2]; fit the training split | backward changes .grad; step changes θ and AdamW state | Do not score only the final position |
| validation | Held-out inputs/targets; read and measure current θ | Temporarily change module mode, then restore it; no θ/optimizer changes | No backward or step |
| checkpointing | Saving reads and persists parameters, optimizer, configuration, tokenizer and progress; loading validates before restoring | model/optimizer load_state_dict explicitly replaces long-lived values; this is restoration, not learning | Do not substitute matching shapes for identity checks |
| inference | A prompt without targets; append one ID per round | The caller's uncropped history grows | No loss, backward or step |
Scroll horizontally to view all columns.
| state | owner | When it changes | Who reads or persists it? |
|---|---|---|---|
| parameters θ | model | optimizer.step() learns; model.load_state_dict() explicitly restores | Training, validation and inference read it; checkpoint saving persists it |
| parameter.grad | Each parameter | backward accumulates; zero_grad clears | optimizer.step() reads it; the canonical checkpoint does not save transient .grad |
| AdamW moments / counters | optimizer | optimizer.step() learns; optimizer.load_state_dict() explicitly restores | Training reads/updates it; checkpoints persist it for optimizer-state resumption |
| completed_updates | training caller | Increment after each successful optimizer.step(); restore on checkpoint load | Logging/checkpoint saving; resumption also checks AdamW's step state |
| GPTConfig / tokenizer identity | model / data caller | Frozen within this run; loading validates canonical values before constructing matching objects | All phases depend on it; checkpoints explicitly persist it |
| activations / loss value / training graph | Current forward call | Forward creates values; with grad mode enabled, differentiable operations also build a graph | Current training/validation computation; not persisted in the checkpoint |
“Only optimizer.step() performs the learning update” still holds in this loop. load_state_dict() also replaces model/optimizer values, but that is explicit restoration, not learning from the current batch's error. Saving reads parameters, optimizer state, configuration, tokenizer and progress. Resumption validates identity before restoring the saved parameter, optimizer and progress values.
- training:forward [3,2] → logits [3,2,5] + loss [] → backward → step
- Validation: held-out forward → token-loss sum / valid-token count; no update
- Checkpointing: validate and save/restore the same identity and long-lived state
- inference:prompt → last logits [B,5] → next_id [B,1] → append history
The fixed Week 6/11 training batch has B=3,T=2, so one forward maps [3,2]→[3,2,5]: 30 raw scores, not an automatically generated sentence. Inference may use [1,2]→[1,2,5]; validation uses [B_i,T_i]→[B_i,T_i,5]. Both validation and inference should avoid gradient recording. Validation has held-out targets and aggregates loss; inference has no targets and uses only the current final-position distribution.
Knowledge check
Which of the four jobs may call optimizer.step() to learn?
1. One Batch Supplies Six Training Signals
A batch gives the model several independent sequences at once. Teacher forcing uses the real left-side tokens at each position, so one forward answers six next-token questions. Here B=3 batch rows, N=3 raw IDs per row before shifting, T=2 teacher-forced positions, C=4 representation channels per position and V=5 candidates per question.
Scroll horizontally to view all columns.
| ID | token |
|---|---|
| 0 | you |
| 1 | like |
| 2 | AI |
| 3 | study |
| 4 | we |
raw_ids = torch.tensor([
[0, 1, 2], # you like AI
[4, 1, 0], # we like you
[0, 3, 2], # you study AI
], dtype=torch.long) # [B,N] = [3,3]
inputs = raw_ids[:, :-1] # [[0,1],[4,1],[0,3]], shape [3,2]
targets = raw_ids[:, 1:] # [[1,2],[1,0],[3,2]], shape [3,2]
assert inputs.shape == targets.shape == (3, 2)
assert inputs.dtype == targets.dtype == torch.longScroll horizontally to view all columns.
| Position | Visible causal context | target | Corresponding five scores |
|---|---|---|---|
| b=0, t=0 | you | like | logits[0,0,:] |
| b=0, t=1 | you like | AI | logits[0,1,:] |
| b=1, t=0 | we | like | logits[1,0,:] |
| b=1, t=1 | we like | you | logits[1,1,:] |
| b=2, t=0 | you | study | logits[2,0,:] |
| b=2, t=1 | you study | AI | logits[2,1,:] |
This lets hardware compute in parallel while one gradient estimate aggregates six supervision signals. logits[b,t,:] always contains five raw scores ordered [you, like, AI, study, we]. targets[b,t] is one long integer naming the correct class, not a one-hot vector.
- raw IDs [B,N] = [3,3]
- Shift → inputs [3,2] and targets [3,2]
- MiniGPT token + position representations [3,2,4]
- two pre-norm Blocks + final_norm + lm_head → logits [3,2,5]
- Row-major reshape → logits [6,5] and targets [6]
- Mean of six per-position NLLs → scalar loss []
inputs [B,T]=[3,2] → token/position representations [B,T,C]=[3,2,4] → logits [B,T,V]=[3,2,5] → logits.reshape(B×T,V)=[6,5] and targets.reshape(B×T)=[6] → scalar mean cross-entropy loss [].
Knowledge check
Why does this batch provide six target labels rather than three?
2. Step, Microbatch and Epoch: Name the Units of Progress
A microbatch is the data processed in one memory-sized forward/backward contribution. An optimizer step performs an actual parameter update. An epoch traverses the training loader once. If the three fixed sentences form one batch and accumulation_steps=1, an epoch happens to contain one step. That coincidence is not the definition.
Scroll horizontally to view all columns.
| Unit | What happens? | How Week 11 counts it |
|---|---|---|
| microbatch | One forward + backward contribution | Not automatically an update |
| optimizer step | Reads accumulated gradients and updates state | Increment completed_updates by 1 |
| epoch | One full traversal of training batches | May contain many updates |
| generation iteration | Sample and append one ID | Not a training step |
Each fixed microbatch still follows [3,2]→[3,2,4]→[3,2,5]→[6,5]+[6]→[]. An epoch changes how often the computation repeats, not tensor rank. A checkpoint's completed_updates counts optimizer.step() calls already completed, not a zero-based loop index or the next step's number.
Knowledge check
With 120 microbatches and one update per four complete contributions, how many optimizer steps are in an epoch?
3. AdamW: Turn Gradient History into This Parameter Update
AdamW uses more than current gradient g_t. For each parameter coordinate, it keeps a moving average m_t of gradients and v_t of squared gradients, then uses them to scale the update. Weight decay separately shrinks weights. This is useful for noisy gradients with differing scales, but the learning rate still needs a deliberate choice.
device = torch.device(
"cuda" if torch.cuda.is_available() else "cpu"
)
model = MiniGPT(GPTConfig()).to(device)
optimizer = torch.optim.AdamW(
model.parameters(),
lr=1e-3,
weight_decay=1e-2,
)Move the model to its device before passing its parameter objects to AdamW. Construction creates parameter groups; the first optimizer.step() with a gradient creates and updates that parameter's moment tensors and counter. optimizer.zero_grad() affects parameter.grad, not m, v or step history.
Scroll horizontally to view all columns.
| Object | Example shape | Who changes it? |
|---|---|---|
| token_embedding.weight | [5,4] | optimizer.step() |
| token_embedding.weight.grad | [5,4] | backward accumulates; zero_grad clears |
| Corresponding AdamW m and v | Each [5,4] | optimizer.step() |
| Current logits / loss | [3,2,5] / [] | Recomputed by forward |
Knowledge check
Why restore AdamW state when continuing its history?
4. A Standard Training Step: Follow State Line by Line
# Run in the English course_examples directory: python week11_minimal_loop.py
import torch
from mini_gpt_walkthrough import GPTConfig, MiniGPT
from course_data import DEMO_DOCUMENTS, FIVE_WORD_TOKENIZER, make_windows, configure_console
configure_console()
torch.set_num_threads(1)
torch.manual_seed(7)
x, y = make_windows(DEMO_DOCUMENTS, FIVE_WORD_TOKENIZER, block_size=2)
inputs = torch.tensor(x, dtype=torch.long)
targets = torch.tensor(y, dtype=torch.long)
model = MiniGPT(GPTConfig())
optimizer = torch.optim.AdamW(model.parameters(), lr=0.001, weight_decay=0.01)
for step in range(1, 101):
model.train()
optimizer.zero_grad(set_to_none=True)
logits, loss = model(inputs, targets)
if loss is None or not torch.isfinite(loss):
raise ValueError("Non-finite loss; no parameter update performed")
loss.backward()
optimizer.step()
if step == 1 or step % 20 == 0:
print("completed_update=", step, "loss_before_this_update=", loss.item())
model.eval()
with torch.no_grad():
_, final_loss = model(inputs, targets)
print("same_batch_loss_after_100_updates=", final_loss.item())
print("This demonstrates the pipeline on three fixed sentences, not independent validation or generalization.")The logged loss comes from the forward before that update. Only final_loss is recomputed after all 100 updates. optimizer.step does not rewrite the old loss variable. Predict what happens if you remove step or recreate the model each round, then inspect the reusable diagnostic function below.
A training step turns a batch's supervision error into one parameter update. This function belongs to week11_training_and_generation.py and uses the imported canonical MiniGPT. It does not redefine configuration, attention, blocks, initialization or state-dict names.
def train_mini_gpt_step(
model: MiniGPT,
optimizer: torch.optim.AdamW,
inputs: torch.Tensor,
targets: torch.Tensor,
device: torch.device,
max_grad_norm: float = 1.0,
completed_updates: int = 0,
) -> tuple[torch.Tensor, torch.Tensor, int]:
if not math.isfinite(max_grad_norm) or max_grad_norm <= 0:
raise ValueError("max_grad_norm must be positive")
if type(completed_updates) is not int or completed_updates < 0:
raise ValueError("completed_updates must be a non-negative integer")
validate_mini_gpt_adamw_completed_updates(
model,
optimizer,
completed_updates,
)
model.train()
inputs = inputs.to(device)
targets = targets.to(device)
optimizer.zero_grad(set_to_none=True)
logits, loss = model(inputs, targets)
assert logits.shape == (*inputs.shape, model.config.vocab_size)
assert loss is not None
if not torch.isfinite(loss):
raise ValueError("Non-finite training loss; stop before this update")
loss.backward()
grad_norm = torch.nn.utils.clip_grad_norm_(
model.parameters(),
max_norm=max_grad_norm,
error_if_nonfinite=True,
)
optimizer.step()
completed_updates += 1 # only after optimizer.step succeeds
return loss.detach(), grad_norm.detach(), completed_updatesScroll horizontally to view all columns.
| line | What changes or is created immediately? | Why here? |
|---|---|---|
| model.train() | Recursively sets module training flags | Selects training behavior before forward; does not update weights |
| inputs/targets.to(device) | Creates or returns batch tensors on the requested device | A CPU batch cannot directly multiply CUDA parameters |
| optimizer.zero_grad(set_to_none=True) | Clears old .grad references | Prevents unintended addition of gradients from the previous update window |
| model(inputs, targets) | Creates activations/graph, logits [3,2,5] and loss [] | Parameters and AdamW state are still unchanged |
| loss.backward() | Accumulates ∂L/∂θ in parameter.grad | The optimizer step needs current gradients first |
| clip_grad_norm_ | If necessary, scales all gradient tensors in place | Limits the norm after all intended backward contributions and before step |
| optimizer.step() | Changes parameter values and AdamW moments/counters; increment the caller's count after success | This is the line that performs the learning update |
| loss.detach() | Returns a logging tensor detached from the graph | Avoids retaining the completed graph through logs |
- inputs/targets [3,2] on model device
- forward → representations [3,2,4] → raw logits [3,2,5]
- reshape [6,5] + [6] → scalar loss []
- backward → one .grad tensor per participating parameter
- clip complete gradient set → optimizer.step()
- same parameter shapes, new parameter values and AdamW state
Conceptually, the parameter update is θ←θ+Δθ. It does not retroactively change the previous forward's inputs, targets or logits. The next forward uses the new θ to calculate new logits.
The executable version checks that loss is finite before backward and requires finite gradients during clipping. NaN/Inf stops the step. Detect the numerical problem before updating parameters, not afterward in a log.
Knowledge check
After loss.backward() but before optimizer.step(), which state is new?
6. Initial Loss Reference: Derive ln(5) from Uniform Five-Way Prediction
Cross entropy measures the probability assigned to the true target. If each question has logits [0,0,0,0,0], Softmax gives [0.2,0.2,0.2,0.2,0.2] in candidate order [you, like, AI, study, we]. Whether the target is like or AI, its probability is only 0.2.
Scroll horizontally to view all columns.
| Quantity | Meaning in the fixed batch | shape / value |
|---|---|---|
| raw equal logits | Six questions, each with a row [0,0,0,0,0] | [6,5] |
| uniform probabilities | All five entries in every row are 0.2 | [6,5] |
| per-position NLL | Six values, each approximately 1.609 | [6] |
| mean CE | Mean of the six values | Scalar [], approximately 1.609 |
This provides a scale for reading the loss curve, not a pass/fail threshold. Randomly initialized logits need not be zero, so initial loss may be somewhat above or below 1.609. The path remains inputs [3,2] → representations [3,2,4] → logits [3,2,5] → flattened logits [6,5] and targets [6] → scalar mean CE [].
Knowledge check
With five tokens, what is per-position CE when the correct target has probability 0.2?
7. Why Validate? Measure Data That Did Not Participate in Updates
The training split supplies gradient signals; validation observes current parameters. Both use the same tokenizer, input/target alignment and CE definition, but different data sources. Validation may inform checkpoint selection, stopping, learning rate or capacity choices; do not backpropagate its batches.
Scroll horizontally to view all columns.
| train loss | held-out validation loss | Reasonable interpretation | Next direct check |
|---|---|---|---|
| Falls while staying close to validation | Also falls | Held-out performance on this split is improving | Continue inspecting samples and checkpoints |
| Keeps falling | Keeps rising | Possible overfitting to training data | Consider an earlier checkpoint, more data, regularization or a smaller model |
| Stays near ln(5) | Also near ln(5) | Underfitting or a broken signal/update path | one-batch diagnostic、targets、gradients、LR |
| Large fluctuations or non-finite values | Also unstable | data/numerical/update instability | Locate the first non-finite value; inspect learning rate and gradient norm |
Each validation batch maps inputs [B_i,T_i] to logits [B_i,T_i,5] and computes target losses. B_i may vary; T_i must be within 1..block_size=2. Across batches, sum token losses and divide by the total valid-target count. Do not equally average means from differently sized batches.
Knowledge check
What is the immediate concern if training loss keeps falling while held-out validation loss keeps rising?
8. Correct Validation: Mode, No-Grad and Token Weighting
eval() controls layer behavior; no_grad() controls backward-graph recording. Token-weighted aggregation gives a batch with six valid targets three times the weight of one with two targets—not half each.
def evaluate_mini_gpt_loss(
model: MiniGPT,
validation_batches,
device: torch.device,
ignore_index: int = -100,
) -> float:
if 0 <= ignore_index < model.config.vocab_size:
raise ValueError("ignore_index must not be a vocabulary ID")
was_training = model.training
batch_count = 0
valid_target_count = 0
loss_sum = 0.0
model.eval()
try:
with torch.no_grad():
for inputs, targets in validation_batches:
batch_count += 1
if targets.shape != inputs.shape:
raise ValueError("targets must match inputs shape")
if targets.dtype != torch.long:
raise TypeError("targets must have dtype torch.long")
inputs = inputs.to(device)
targets = targets.to(device)
valid_mask = targets.ne(ignore_index)
batch_valid_count = int(valid_mask.sum().item())
if batch_valid_count == 0:
continue
valid_targets = targets[valid_mask]
if (
int(valid_targets.min().item()) < 0
or int(valid_targets.max().item())
>= model.config.vocab_size
):
raise ValueError("valid target IDs are outside vocabulary")
# Do not pass -100 targets into canonical MiniGPT.forward.
logits, no_loss = model(inputs)
assert no_loss is None
batch_loss_sum = F.cross_entropy(
logits.reshape(-1, model.config.vocab_size),
targets.reshape(-1),
ignore_index=ignore_index,
reduction="sum",
)
loss_sum += batch_loss_sum.item()
valid_target_count += batch_valid_count
finally:
model.train(was_training)
if batch_count == 0:
raise ValueError("validation_batches must not be empty")
if valid_target_count == 0:
raise ValueError("validation has no valid target tokens")
return loss_sum / valid_target_countCanonical MiniGPT rejects target ID -100, so masked validation must not call model(inputs, targets). Call model(inputs) for raw logits, then compute summed CE externally with ignore_index. Unpadded batches use the same token-weighted path. try/finally restores the previous train/eval mode even if iteration or shape validation raises an error.
Scroll horizontally to view all columns.
| batch | Valid targets | loss sum | Incorrect weight for the batch mean | Correct token weight |
|---|---|---|---|---|
| A | 6 | 6.0 | 1/2 | 6/8 |
| B | 2 | 6.0 | 1/2 | 2/8 |
| Aggregate | 8 | 12.0 | (1.0+3.0)/2=2.0 | 12.0/8=1.5 |
- remember was_training
- model.eval() + torch.no_grad()
- inputs [B_i,T_i] → logits [B_i,T_i,5]; do not pass ignored targets into the model
- external CE reduction=sum over valid target IDs
- accumulate loss_sum and valid_target_count
- finally restore exact prior mode
- guard empty/all-masked → return token-weighted Python float
Knowledge check
What prevents validation forward from recording a backward graph? Can eval() replace it?
9. Gradient Clipping: Limit the Complete Gradient Before Updating
Read the L2 norm as the length of a vector: [3,4] has length √(3²+4²)=5. With a cap of 1, multiply both entries by 1/5 to obtain [0.6,0.8], length 1. Their directional ratio remains 3:4.
Clipping directly bounds gradient length. For plain SGD, update length scales with η times that length. AdamW also uses moment history and weight decay, so a gradient cap of 1 is not a guarantee that parameter-update length is at most 1.
Imagine concatenating all parameter gradients into one long vector g. If its L2 norm exceeds cap c, multiply every component by a common scale to preserve direction while limiting magnitude. This helps guard against unusually large gradients; the logged norm still matters for diagnosing their cause.
Scroll horizontally to view all columns.
| raw global norm | cap c | Shared scale | Result |
|---|---|---|---|
| 5.0 | 1.0 | Approximately 1/5 | Norm falls to approximately 1.0; direction is unchanged |
| 0.6 | 1.0 | 1 | Gradients unchanged |
In the fixed model, token_embedding.weight.grad remains [5,4] and each qkv.weight.grad remains [12,4]. Clipping is not an operation on [B,T] activations. clip_grad_norm_ returns the pre-clip norm for detached logging alongside loss. With accumulation, wait until all contributions in the window have reached .grad, then clip once.
def backward_and_clip_mini_gpt(
model: MiniGPT,
loss: torch.Tensor,
max_grad_norm: float = 1.0,
) -> torch.Tensor:
if loss.ndim != 0:
raise ValueError("loss must be a scalar tensor")
if not math.isfinite(max_grad_norm) or max_grad_norm <= 0:
raise ValueError("max_grad_norm must be finite and positive")
loss.backward()
grad_norm = torch.nn.utils.clip_grad_norm_(
model.parameters(),
max_norm=max_grad_norm,
)
return grad_norm.detach()
# Caller order inside one update window:
# optimizer.zero_grad(...) -> forward creates loss -> helper above
# -> optimizer.step() -> increment completed_updatesKnowledge check
When should clipping occur with gradient accumulation?
10. Learning Rate: AdamW Still Needs a Global Step Scale
lr controls the scale of optimizer.step(). It does not directly change current logits [3,2,5]; it changes θ, which affects a subsequent forward. Record lr, completed_updates, loss and gradient norm, and change one experimental factor at a time.
Scroll horizontally to view all columns.
| controlled-run symptom | Possible learning-rate interpretation | Check first |
|---|---|---|
| Loss stays near ln(5) over many updates | 1e-6 may be too small | Confirm aligned labels, non-None gradients and actual optimizer steps |
| Loss falls smoothly | 1e-3 is a possible starting point for this tiny run | Keep comparing held-out loss and samples |
| loss spike / oscillation | May be too large | Inspect the first bad update and pre-clip gradient norm |
| Non-finite loss or gradients | Updates may be unstable | Locate the first NaN/Inf before adjusting learning rate |
The fixed teaching run uses AdamW(..., lr=1e-3, weight_decay=1e-2), not a universal magic setting. When comparing 1e-6 with 1e-3, keep tokenizer, split, batch/accumulation policy, seed and update count fixed so the comparison isolates learning rate.
Knowledge check
What observation might suggest an excessively small learning rate in a controlled run?
11. Overfit One Batch: Identify Irreducible Label Conflicts First
Read inf as a lower bound that can be approached arbitrarily closely in the ideal distribution calculation. Identical prefixes must use one distribution but are asked for different next tokens. Splitting probability equally is optimal; both cannot receive probability 1. The 0.231 reference follows from context visibility and conflicting data, not a guarantee that this finite network and optimizer will attain it.
This diagnostic asks whether the supervision path can learn substantially, not whether the model generalizes. Repeating inputs [[0,1],[4,1],[0,3]] and targets [[1,2],[1,0],[3,2]] should produce a clear reduction from the approximate ln(5) random reference in a working, suitably configured run. Do not require deterministic causal argmax to classify all six labels correctly.
def overfit_mini_gpt_one_batch(
model: MiniGPT,
optimizer: torch.optim.AdamW,
inputs: torch.Tensor,
targets: torch.Tensor,
device: torch.device,
updates: int = 200,
completed_updates: int = 0,
) -> tuple[list[float], int]:
if type(updates) is not int or updates < 1:
raise ValueError("updates must be a positive integer")
if type(completed_updates) is not int or completed_updates < 0:
raise ValueError("completed_updates must be a non-negative integer")
validate_mini_gpt_adamw_completed_updates(
model,
optimizer,
completed_updates,
)
model.train()
inputs = inputs.to(device)
targets = targets.to(device)
history: list[float] = []
for _ in range(updates):
optimizer.zero_grad(set_to_none=True)
logits, loss = model(inputs, targets)
assert logits.shape == (3, 2, 5)
assert loss is not None and torch.isfinite(loss)
loss.backward()
optimizer.step()
completed_updates += 1 # only after optimizer.step succeeds
history.append(loss.detach().item())
return history, completed_updatesScroll horizontally to view all columns.
| position(s) | causal context | observed target(s) | empirical optimum |
|---|---|---|---|
| b=0,t=0 and b=2,t=0 | [you] | like, study | P(like|you)=0.5, P(study|you)=0.5 |
| b=0,t=1 | [you,like] | AI | This row can ideally approach probability 1 for AI |
| b=1,t=0 | [we] | like | Can ideally approach probability 1 for like |
| b=1,t=1 | [we,like] | you | This row can ideally approach probability 1 for you |
| b=2,t=1 | [you,study] | AI | This row can ideally approach probability 1 for AI |
- The same [you] prefix at t=0 → one shared five-token distribution
- Two conflicting labels → empirical mass 0.5 on like and 0.5 on study
- four distinguishable contexts → correct-label probability can approach 1
- mean loss falls materially below ln(5) toward, but not to, ln(2)/3
- Compare final logits for [you,like] and [we,like] to inspect whether context changes predictions.
Record first/last loss, finite values and gradients. When inspecting logits.argmax(dim=-1) [3,2], acknowledge that the conflicting contexts can select only one of their labels. The helper accepts an existing completed_updates count, increments only after a successful optimizer.step(), and returns cumulative progress with history. It does not pretend the requested loop length equals completed work. Compare final distributions for [you,like] and [we,like] as an additional context check: those prefixes really differ.
Knowledge check
Why can this diagnostic not require correct argmax at all six positions?
13. Inference, Training and Validation: All Use Forward, for Different Purposes
Training uses answers to compute loss and backpropagate error. Validation also has answers, but only scores them. Inference has no answer: it converts the current final-position distribution into an ID, appends it and repeats. MiniGPT.forward stays unchanged; the Week 11 caller defines the workflow.
Scroll horizontally to view all columns.
| phase | model call | graph / mode | What happens next? |
|---|---|---|---|
| training | model(inputs [3,2], targets [3,2]) | train mode + Autograd graph | all-position loss → backward → step |
| validation | model(held_out_inputs),external summed CE | eval mode + no_grad | Token-weighted report; no update |
| inference | model(cropped context), without targets | eval mode + no_grad | last logits → sample [B,1] → append |
prompt = torch.tensor([[0, 1]], dtype=torch.long, device=device)
was_training = model.training
model.eval()
try:
with torch.no_grad():
prompt_logits, no_loss = model(prompt)
finally:
model.train(was_training)
assert prompt_logits.shape == (1, 2, 5)
assert no_loss is None
next_logits = prompt_logits[:, -1, :] # [1,5] after you like- training:([3,2],[3,2]) → (logits [3,2,5], loss []) → gradients → updated θ
- validation:held-out ([B,T],[B,T]) → logits → external loss sum/count → Python float
- inference:[B,T_context] → logits [B,T_context,5] → last [B,5] → next_id [B,1]
eval() does not remove existing .grad values; it changes module mode. no_grad() prevents this forward from recording a gradient graph, but does not clear old gradients either. Inference avoids updates because its call path contains neither loss.backward() nor optimizer.step().
Knowledge check
Which output do training and inference both receive, and when does loss exist?
14. Why Generation Uses Only the Final Position
For prompt [you, like], position 0 asks “what follows you?” Position 1 asks “what follows you like?” This generation round appends the answer to the latter. Earlier logits are useful supervised predictions during teacher-forced training, but they do not predict beyond the current prompt's end.
prompt = torch.tensor([[0, 1]], dtype=torch.long, device=device)
was_training = model.training
model.eval()
try:
with torch.no_grad():
logits, no_loss = model(prompt) # targets intentionally absent
finally:
model.train(was_training)
assert logits.shape == (1, 2, 5)
assert no_loss is None
next_logits = logits[:, -1, :]
assert next_logits.shape == (1, 5)Scroll horizontally to view all columns.
| tensor slice | shape | labelled meaning |
|---|---|---|
| logits[0,0,:] | [5] | After you: five candidate scores |
| logits[0,1,:] | [5] | After you like: five candidate scores |
| logits[:,-1,:] | [1,5] | Source of the next-token distribution for each prompt's current final position |
For three prompts with logits [3,2,5], the same indexing gives [3,5], one independent next-ID decision per row. Here -1 indexes the final sequence position; it is not token ID -1.
- Training: reshape all logits [3,2,5] to [6,5] and score six positions
- Generation: first select [:,-1,:] from logits [B,T_context,5]
- Next logits [B,5] then pass through τ / top-k / Softmax / sampling
Knowledge check
If logits has shape [3,2,5], what shape does logits[:,-1,:] have?
15. Temperature τ: Change the Sampling Distribution's Concentration
τ<1 enlarges logit gaps and concentrates mass on higher scores; τ>1 reduces gaps and flattens the distribution; τ=1 gives ordinary Softmax. Subtract each row's maximum first, making its largest value zero, then divide by τ. Subtracting a common constant preserves ranking and Softmax probabilities while avoiding positive exponential overflow. This is an inference-time control, not a change to parameters, AdamW state or the training objective. Reserve T for the sequence/time axis.
Scroll horizontally to view all columns.
| τ setting | Relative gaps between scaled logits | sampling effect |
|---|---|---|
| τ=0.5 | Original gaps doubled | Sharper; probability concentrates on the highest logit |
| τ=1 | Unchanged | Ordinary Softmax |
| τ=2 | Original gaps halved | Flatter; lower-scoring candidates gain probability |
- finite floating last logits z [B,5]
- Promote float16/bfloat16 to at least float32 and validate τ>0
- Center z−max(z) [B,5], then divide by τ [B,5]
- reject non-finite centered/scaled values with a clear numeric-domain error
- Ranking unchanged; relative gaps change
- Then apply optional top-k and Softmax
Positive τ preserves ranking while changing relative probabilities. τ=0 divides by zero and τ<0 reverses ranking; neither is allowed by this API. Even with finite raw logits, an extremely small τ can overflow negative centered gaps to -∞ in the working dtype. The helper explicitly rejects this before Softmax instead of sending NaN probabilities to multinomial.
Knowledge check
Does lowering τ from 1 to 0.5 change model weights in the checkpoint?
16. Calculate Temperature: The Same Five Labeled Probabilities
Calculating the same scores makes “sharper” and “flatter” checkable probability changes. Keep [you, like, AI, study, we] and z=[0,2,1,-1,-0.5]. Apply Softmax after temperature scaling. Table entries are rounded, so displayed columns may not sum to exactly 1.
Scroll horizontally to view all columns.
| token | raw z | p(τ=1) | p(τ=0.5) | p(τ=2) |
|---|---|---|---|---|
| you | 0.0 | 0.083 | 0.016 | 0.148 |
| like | 2.0 | 0.612 | 0.860 | 0.403 |
| AI | 1.0 | 0.225 | 0.116 | 0.244 |
| study | -1.0 | 0.030 | 0.002 | 0.090 |
| we | -0.5 | 0.050 | 0.006 | 0.115 |
At τ=0.5, uncentered hand calculation uses z/τ=[0,4,2,-2,-1], with exponential sum approximately 63.490. Stable computation centers by m=2 first, then divides, giving [-4,0,-2,-6,-5]. This subtracts 4 from every uncentered scaled score, preserving probabilities while making the largest exponential exp(0)=1. The highest-logit token, like, still gets approximately 0.860. At τ=2 the gaps shrink; centering again preserves the result.
next_logits = torch.tensor(
[[0.0, 2.0, 1.0, -1.0, -0.5]],
device=device,
) # [1,5] ordered as you, like, AI, study, we
temperature = 0.5
if not math.isfinite(temperature) or temperature <= 0:
raise ValueError("temperature must be positive")
sampling_logits = (
next_logits.float()
if next_logits.dtype in (torch.float16, torch.bfloat16)
else next_logits
)
centered_logits = sampling_logits - sampling_logits.amax(
dim=-1,
keepdim=True,
)
scaled_logits = centered_logits / temperature # [-4,0,-2,-6,-5]
if not bool(torch.isfinite(scaled_logits).all()):
raise ValueError("temperature is too small for stable scaling")
probabilities = F.softmax(scaled_logits, dim=-1) # [1,5]
if not bool(torch.isfinite(probabilities).all()):
raise ValueError("sampling probabilities must be finite")
next_id = torch.multinomial(
probabilities,
num_samples=1,
) # torch.long [1,1]torch.argmax(probabilities, dim=-1, keepdim=True) also returns [1,1], but always selects a highest-probability candidate. torch.multinomial samples by probability and can select another token. Both are policies applied after forward, not a second model computation.
If a token has probability 0.6, repeated independent draws from that fixed distribution select it about 60% of the time in the long run; a single draw may not. During generation, every appended token changes the prefix, so the next distribution usually changes too. A whole continuation is not repeated drawing from one fixed table. Lowering temperature changes concentration, not knowledge, and does not guarantee factual correctness.
Knowledge check
Which token gains the most probability when τ drops from 1 to 0.5 here, and why?
17. Top-k: Restrict Candidates After Temperature Scaling
Top-k narrows the set of tokens eligible for this draw. Positive τ preserves ranking, so scale first and retain the highest k entries. Rejected entries become -∞, receive probability zero after Softmax, and surviving entries renormalize. This does not update the model and is not a training regularizer.
Scroll horizontally to view all columns.
| token | scaled logit at τ=1 | after k=3 filter | final probability |
|---|---|---|---|
| you | 0.0 | 0.0 | 0.090 |
| like | 2.0 | 2.0 | 0.665 |
| AI | 1.0 | 1.0 | 0.245 |
| study | -1.0 | -∞ | 0.000 |
| we | -0.5 | -∞ | 0.000 |
The table uses uncentered τ=1 scores so the denominator 11.107 is easy to verify. The helper subtracts maximum 2, using [-2,0,-1,-3,-2.5]. The top-three survivors, ranking and probabilities are identical; their equivalent centered denominator is exp(-2)+exp(0)+exp(-1).
def sample_mini_gpt_next_id(
last_logits: torch.Tensor,
temperature: float = 1.0,
top_k: int | None = None,
) -> torch.Tensor:
if not isinstance(last_logits, torch.Tensor):
raise TypeError("last_logits must be a tensor")
if last_logits.ndim != 2:
raise ValueError("last_logits must have shape [B,V]")
if last_logits.size(0) < 1:
raise ValueError("last_logits must contain at least one batch row")
if last_logits.size(-1) != 5:
raise ValueError("mini-gpt-v1 requires V=5")
if not torch.is_floating_point(last_logits):
raise TypeError("last_logits must use a floating dtype")
if not bool(torch.isfinite(last_logits).all()):
raise ValueError("last_logits must be finite")
if type(temperature) not in (int, float):
raise TypeError("temperature must be a real number")
temperature = float(temperature)
if not math.isfinite(temperature) or temperature <= 0:
raise ValueError("temperature must be positive")
vocabulary_size = last_logits.size(-1)
if top_k is not None:
if type(top_k) is not int or not 1 <= top_k <= vocabulary_size:
raise ValueError("top_k must be an integer in [1,V]")
# Softmax support and numeric headroom are safer than half precision.
working_dtype = (
torch.float64
if last_logits.dtype == torch.float64
else torch.float32
)
sampling_logits = last_logits.to(dtype=working_dtype)
dtype_limits = torch.finfo(working_dtype)
if temperature < dtype_limits.tiny:
raise ValueError("temperature is too small for the sampling dtype")
if temperature > dtype_limits.max:
raise ValueError("temperature is too large for the sampling dtype")
row_max = sampling_logits.amax(dim=-1, keepdim=True)
centered_logits = sampling_logits - row_max
if not bool(torch.isfinite(centered_logits).all()):
raise ValueError("last_logits range is too wide after centering")
scaled_logits = centered_logits / temperature
if not bool(torch.isfinite(scaled_logits).all()):
raise ValueError(
"temperature is too small for this logit range and sampling dtype"
)
# Top-k stays after temperature scaling and before Softmax.
filtered_logits = scaled_logits
if top_k is not None:
top_values, top_indices = torch.topk(
scaled_logits,
k=top_k,
dim=-1,
)
filtered_logits = torch.full_like(
scaled_logits,
float("-inf"),
)
filtered_logits.scatter_(
dim=-1,
index=top_indices,
src=top_values,
)
probabilities = F.softmax(filtered_logits, dim=-1)
probability_sums = probabilities.sum(dim=-1)
if (
not bool(torch.isfinite(probabilities).all())
or bool((probabilities < 0).any())
or not bool(torch.isfinite(probability_sums).all())
or bool((probability_sums <= 0).any())
):
raise ValueError("temperature/top_k produced unusable probabilities")
next_id = torch.multinomial(probabilities, num_samples=1)
assert next_id.dtype == torch.long
return next_id # [B,1]- last_logits z [B,V]=[B,5]
- Validate floating/finite z, τ>0 and optional integer 1≤k≤V
- promote low precision → row-center → finite scale (z−max(z))/τ [B,5]
- retain top k logits; others become -∞ [B,5]
- Softmax renormalizes survivors;verify finite nonnegative row mass [B,5]
- multinomial samples next_id torch.long [B,1]
k=1 leaves one selected maximum with probability 1; k=V=5 filters no candidate. If scores tie at the cutoff, the particular retained indices may depend on the implementation. Do not promise a specific tie outcome.
Knowledge check
What is a candidate's Softmax probability after top-k sets its logit to -∞?
18. Complete Generation Loop: Crop, Last, Sample, Append
Each round passes at most the last two IDs of full history into MiniGPT. The model returns logits for every visible position; the caller takes the final row, samples one integer ID per batch row [B,1], and appends it to uncropped history. No targets are supplied and model parameters are read-only.
@torch.no_grad()
def generate_mini_gpt_sampled(
model: MiniGPT,
history: torch.Tensor,
max_new_tokens: int,
temperature: float = 1.0,
top_k: int | None = None,
) -> torch.Tensor:
if type(max_new_tokens) is not int or max_new_tokens < 0:
raise ValueError("max_new_tokens must be a non-negative integer")
if history.ndim != 2:
raise ValueError("history must have shape [B,L_history]")
if history.dtype != torch.long:
raise TypeError("history must have dtype torch.long")
if history.numel() == 0 or history.size(1) < 1:
raise ValueError("history must contain at least one token per row")
if int(history.min().item()) < 0 or int(history.max().item()) >= 5:
raise ValueError("history IDs must be in [0,4]")
if history.device != next(model.parameters()).device:
raise ValueError("history and model must be on the same device")
if not math.isfinite(temperature) or temperature <= 0:
raise ValueError("temperature must be positive")
if top_k is not None:
if type(top_k) is not int or not 1 <= top_k <= 5:
raise ValueError("top_k must be an integer in [1,5]")
was_training = model.training
model.eval()
try:
for _ in range(max_new_tokens):
context = history[:, -model.config.block_size :]
logits, no_loss = model(context) # no targets in generation
assert no_loss is None
last_logits = logits[:, -1, :]
next_id = sample_mini_gpt_next_id(
last_logits,
temperature=temperature,
top_k=top_k,
)
assert next_id.shape == (history.size(0), 1)
history = torch.cat((history, next_id), dim=1)
finally:
model.train(was_training)
return historyScroll horizontally to view all columns.
| First iteration from you like | value | shape |
|---|---|---|
| full history | [[0,1]] = [you,like] | [1,2] |
| cropped context | [[0,1]], still within block_size=2 | [1,2] |
| model logits | Two visible positions, five scores each | [1,2,5] |
| last_logits | After you like | [1,5] |
| sampled example next_id | [[2]] = AI | [1,1] |
| new full history | [[0,1,2]] = [you,like,AI] | [1,3] |
- uncropped history [B,L_history]
- crop only forward context → [B,min(L_history,2)]
- MiniGPT(context), no targets → [B,T_context,5]
- logits[:,-1,:] → [B,5]
- promote + row-center + τ scale → optional top-k → checked Softmax → multinomial
- next_id torch.long [B,1]
- append to uncropped history → [B,L_history+1]
In round 2, full history is [0,1,2] but model context is its tail [1,2]. For a reproducible sampling demonstration, explicitly set the caller's PyTorch RNG seed. Sampling changes RNG/history, not model parameters.
Knowledge check
Which tensor must have shape [B,1] before appending?
19. Crop the Forward Context, Not the History
History is the complete result for the user. Context is the tail window the model sees this round. Retain history and calculate context just before forward. Returned text loses no tokens, but each decision genuinely depends on at most the latest two.
Scroll horizontally to view all columns.
| Object | IDs / tokens | shape | owner |
|---|---|---|---|
| full history | [0,1,2] = you like AI | [1,3] | generation caller |
| tail context | [1,2] = like AI | [1,2] | Current forward input |
| model logits | Two context positions × five candidates | [1,2,5] | MiniGPT forward |
| last logits | After like AI | [1,5] | caller sampling path |
history = torch.tensor(
[[0, 1, 2]],
dtype=torch.long,
device=device,
) # you like AI, shape [1,3]
context = history[:, -model.config.block_size :]
assert context.tolist() == [[1, 2]]
assert context.shape == (1, 2)
was_training = model.training
model.eval()
try:
with torch.no_grad():
logits, no_loss = model(context) # targets intentionally absent
finally:
model.train(was_training)
assert logits.shape == (1, 2, 5)
assert no_loss is NoneThis respects position_embedding.weight [2,4], causal_mask [1,1,2,2] and the forward guard 1≤T≤2. Cropping is not causal masking: the mask restricts query/key visibility inside the window; cropping excludes earlier tokens from computation altogether. The model cannot secretly retain you at history position 0.
Knowledge check
With full history [0,1,2] and block_size=2, which IDs enter the next forward?
20. Common Bug Checklist: Inspect Phase Boundaries by Symptom
First identify the phase and what it may read or change. Use this checklist before adding layers, changing optimizers or collecting more data. Each row points to a small observable check.
Scroll horizontally to view all columns.
| symptom | Likely boundary mistake | first direct check |
|---|---|---|
| Fixed-batch loss never falls | Misaligned targets, unregistered/unoptimized parameters, missing backward/step or unsuitable learning rate | Print the six pairs; inspect gradients and completed_updates |
| Loss/gradients become NaN or Inf | Excessive update scale, or a non-finite input/intermediate value | Find the first non-finite value; record pre-clip gradient norm |
| Expected zero loss, but it approaches about 0.231 | Ignored conflicting labels for two identical [you] contexts | Inspect whether P(like|you) and P(study|you) approach 0.5/0.5 |
| Accumulation behaves like one microbatch | Clearing gradients inside the window or stepping too early | Count backward contributions before each step |
| Earlier logits change with a future token | Answer leakage through mask/slice/axis mistakes | Fix the prefix, change only the right-side token and compare t=0 logits |
| Validation consumes excess memory or fluctuates randomly | Missing no_grad/eval, or too little validation data | Use both controls and restore mode; inspect held-out sample size |
| Validation changes when batch packing changes | Equal averaging of batch means | Accumulate reduction=sum losses and valid-target counts |
| Checkpoint loading gives incorrect text or errors | Tokenizer/configuration/tying/member-key mismatch, or different AdamW parameter groups/order | Validate identity first, then canonical optimizer IDs/state shapes before optimizer loading |
| device error | Model, inputs and targets are on incompatible devices | Print devices; use map_location and .to(device) appropriately |
| Generation fails after history exceeds two tokens | Forgot to crop forward context | assert context.size(1)≤block_size |
| Wrong shape/type for the next value | Used all positions or treated a probability vector as an ID | assert last [B,5];next_id long [B,1] |
| τ/top-k produces an invalid distribution | τ≤0 or too small, low-precision overflow, invalid k or incorrect filtering order | Promote and center; check scaled values before Softmax and probabilities before sampling |
assert inputs.shape == (3, 2)
assert targets.shape == (3, 2)
logits, loss = model(inputs.to(device), targets.to(device))
assert logits.shape == (3, 2, 5)
assert loss is not None and loss.ndim == 0
last_logits = logits[:, -1, :]
assert last_logits.shape == (3, 5)
# Shape assertions locate axes; they do not prove targets or causality.- training contract:inputs [3,2] + targets [3,2]
- MiniGPT representations [3,2,4] → logits [3,2,5]
- reshape [6,5] + [6] → mean loss [] → backward → step
- generation contract:context [B,T_context] → logits [B,T_context,5]
- select last [B,5] → safe center/τ/top-k/Softmax → next_id [B,1]
One successful sample does not prove model quality. One shape assertion does not prove target meaning, causality or held-out integrity. Inspect actual state along data→forward→loss→gradient→update or prompt→crop→last→sample→append.
Knowledge check
After generation receives logits [B,T,V], what indexing step comes first?
21. Seven Things to Understand from Week 11
Use this route when reading a small language-model run: identify the data signal, then the state change, measurement, persistence and target-free generation. Diagnose failures in that order too.
- Fixed inputs/targets both have shape [3,2]. One forward returns logits [3,2,5]; reshape to [6,5]+[6] and score all six next-token signals.
- Uniform five-way prediction has mean NLL ln(5)≈1.609. Random initial loss may be nearby; this is not a training-completion target.
- backward() accumulates parameter.grad and zero_grad() defines the accumulation window. In the training loop, optimizer.step() updates parameters and AdamW moments/counters.
- A step is one parameter update; an epoch traverses training batches once. Accumulation can combine several forward/backward contributions into one completed update.
- Held-out validation uses eval() and no_grad(), restores the prior mode, and aggregates summed loss divided by valid-target count. Same-corpus scoring is not held-out validation.
- Identical [you] contexts have targets like and study. Their empirical optimum is 0.5/0.5, giving an ideal batch-NLL infimum ln(2)/3≈0.231, not zero; finite weights need not attain it. Also inspect distinguishable contexts such as [you,like] and [we,like].
- Compatible checkpoints preserve Week 10's schema, exact tokenizer SHA-256, configuration and untied state keys, and validate AdamW's parameter group/object/order binding. Inference has no targets: crop context, take logits[:,-1,:], promote/center, apply τ and optional top-k, validate Softmax probabilities, sample [B,1] with multinomial and append to full history.
Scroll horizontally to view all columns.
| training trace | generation trace |
|---|---|
| inputs [3,2] | History shape [1,2], IDs [[0,1]] = [you,like] |
| representations [3,2,4] | cropped context [1,2] |
| logits [3,2,5] | logits [1,2,5] → last [1,5] |
| loss [] → gradients → updated θ | sample next_id [1,1] → history [1,3] |
Place symptoms at the right boundary: absent learning suggests the supervision path; strange validation suggests measurement; load failures suggest identity; generation failures suggest crop, final-position selection or sampling transforms.
Knowledge check
Distinguish zero_grad() and optimizer.step() in one sentence.
5. Engineering Extension: Combine Small Batches into One Update
On the first pass, use train_mini_gpt_step with accumulation_steps=1 and understand one forward/backward per update. The epoch helper below adds prevalidation of equally sized microbatches before combining them. This is a second-pass extension; infinite streams, variable-size batches and accumulation need not all be learned at once.
backward adds new contributions to existing .grad. This supports both a parameter used multiple times in one graph and several microbatches forming one effective batch. Accumulation is intentional only when loss scaling, clearing and step boundaries agree.
def train_mini_gpt_epoch(
model: MiniGPT,
optimizer: torch.optim.AdamW,
train_batches,
device: torch.device,
accumulation_steps: int = 1,
completed_updates: int = 0,
) -> tuple[int, float]:
if type(accumulation_steps) is not int or accumulation_steps < 1:
raise ValueError("accumulation_steps must be a positive integer")
if type(completed_updates) is not int or completed_updates < 0:
raise ValueError("completed_updates must be a non-negative integer")
validate_mini_gpt_adamw_completed_updates(
model,
optimizer,
completed_updates,
)
# Validate all batches before touching model mode, gradients, or optimizer.
batches = list(train_batches)
if not batches:
raise ValueError("train_batches must not be empty")
if len(batches) % accumulation_steps != 0:
raise ValueError(
"train_batches must form complete accumulation windows"
)
reference_shape = None
reference_target_count = None
for batch_number, batch in enumerate(batches, start=1):
if not isinstance(batch, (tuple, list)) or len(batch) != 2:
raise TypeError(f"batch {batch_number} must be (inputs, targets)")
inputs, targets = batch
if not isinstance(inputs, torch.Tensor) or not isinstance(
targets,
torch.Tensor,
):
raise TypeError(f"batch {batch_number} values must be tensors")
if inputs.ndim != 2 or targets.shape != inputs.shape:
raise ValueError(
f"batch {batch_number} inputs/targets must share [B,T]"
)
if inputs.dtype != torch.long or targets.dtype != torch.long:
raise TypeError(
f"batch {batch_number} inputs/targets must be torch.long"
)
target_count = targets.numel()
if target_count <= 0:
raise ValueError(f"batch {batch_number} has no target tokens")
if not 1 <= inputs.size(1) <= model.config.block_size:
raise ValueError(f"batch {batch_number} has invalid T")
for name, token_ids in (("inputs", inputs), ("targets", targets)):
if (
int(token_ids.min().item()) < 0
or int(token_ids.max().item()) >= model.config.vocab_size
):
raise ValueError(
f"batch {batch_number} {name} IDs are outside vocabulary"
)
if reference_shape is None:
reference_shape = inputs.shape
reference_target_count = target_count
elif (
inputs.shape != reference_shape
or target_count != reference_target_count
):
raise ValueError(
"this teaching helper requires equal-token microbatches"
)
model.train()
optimizer.zero_grad(set_to_none=True)
detached_loss_sum = 0.0
for window_start in range(0, len(batches), accumulation_steps):
window = batches[
window_start : window_start + accumulation_steps
]
for inputs, targets in window:
inputs = inputs.to(device)
targets = targets.to(device)
logits, loss = model(inputs, targets)
assert logits.shape[-1] == model.config.vocab_size
assert loss is not None
(loss / accumulation_steps).backward()
detached_loss_sum += loss.detach().item()
torch.nn.utils.clip_grad_norm_(
model.parameters(),
max_norm=1.0,
)
optimizer.step()
completed_updates += 1 # only after optimizer.step succeeds
optimizer.zero_grad(set_to_none=True)
mean_microbatch_loss = detached_loss_sum / len(batches)
return completed_updates, mean_microbatch_lossThis teaching helper supports equal-token microbatches only. It materializes and validates all inputs before model.train() or zero_grad(): nonempty data, complete windows, tensor input/target pairs, matching shapes, a shared [B,T] shape across batches, torch.long dtype, equal positive target counts, valid T and valid IDs. Then loss/accumulation_steps and mean_microbatch_loss are correctly token-weighted.
An empty iterator, seven microbatches with accumulation_steps=4, or a smaller/shorter batch is rejected before model, mode, gradient or optimizer mutation. No partial gradient window remains. This helper is unsuitable for large, infinite or variable-token streams. Those need window-level summed token loss divided by valid-token count, or a deliberately equal-size sampler/drop_last policy.
Scroll horizontally to view all columns.
| Moment in the window | .grad | parameters / AdamW state |
|---|---|---|
| After zero_grad | None | Unchanged |
| After backward contributions 1–3 | Accumulates a partial window | Unchanged |
| After backward 4 + clipping | Complete and possibly rescaled | Still unchanged |
| After optimizer.step | Still present until cleared | Both update once |
| After step returns successfully | The complete window has been consumed | Only now increment completed_updates |
| After the following zero_grad | None; the next window starts clean | Retain the newly updated long-lived state |
Knowledge check
Why does the accumulation loop clear gradients after optimizer.step()?
12. Engineering Extension: What State Does Resuming Training Need?
A checkpoint is a compatibility contract, not an arbitrary bag of tensors. English mini-gpt-v1 uses whitespace-delimited [you, like, AI, study, we] at IDs 0..4, with no special/padding/unknown tokens. Configuration remains vocab_size=5, block_size=2, n_embd=4, n_head=2, n_layer=2 and untied embeddings/head. Week 10's stable members—token_embedding, position_embedding, blocks[i].ln1/attention/ln2/feed_forward, final_norm, lm_head—and _init_weights stay unchanged. Week 11 imports and drives them.
Scroll horizontally to view all columns.
| checkpoint key | exact meaning / value |
|---|---|
| schema | {name: mini-gpt-training-checkpoint, version: 1} |
| tokenizer | version + ordered_tokens + policy + sha256 |
| config | vocab_size=5, block_size=2, n_embd=4, n_head=2, n_layer=2 |
| weight_policy | token_embedding_lm_head=untied |
| model_state | Week 10 exact state-dict names and tensors |
| optimizer | exact AdamW class + one canonical ordered model-parameter group + state_dict |
| completed_updates | Nonnegative integer count of completed optimizer.step() calls |
Scroll horizontally to view all columns.
| canonical tokenizer identity | exact value |
|---|---|
| compact sorted-key UTF-8 JSON | {"ordered_tokens":["you","like","AI","study","we"],"policy":"whitespace-delimited;no-specials;no-pad;no-unk","version":"mini-gpt-v1"} |
| SHA-256 | 38d630f4c589664c9bef567457d48764cbe2307734777e80f7d5d5c63ac88dd6 |
def validate_mini_gpt_adamw_model_binding(
model: MiniGPT,
optimizer: torch.optim.AdamW,
) -> list[torch.nn.Parameter]:
if type(model) is not MiniGPT:
raise TypeError("faithful training requires exactly MiniGPT")
if type(optimizer) is not torch.optim.AdamW:
raise TypeError("faithful training requires exactly torch.optim.AdamW")
model_parameters = list(model.parameters())
if not model_parameters:
raise ValueError("MiniGPT must have parameters")
if len(optimizer.param_groups) != 1:
raise ValueError("AdamW must have exactly one canonical param group")
live_group = optimizer.param_groups[0]
live_parameters = live_group.get("params")
if not isinstance(live_parameters, list):
raise ValueError("AdamW live params must be a list")
if len(live_parameters) != len(model_parameters):
raise ValueError("AdamW must own every MiniGPT parameter exactly once")
if any(
actual is not expected
for actual, expected in zip(live_parameters, model_parameters)
):
raise ValueError("AdamW parameters must match MiniGPT identity and order")
return model_parameters
def validate_mini_gpt_adamw_state_dict(
model: MiniGPT,
optimizer_state: object,
completed_updates: int,
) -> None:
if type(completed_updates) is not int or completed_updates < 0:
raise ValueError("completed_updates must be a non-negative integer")
if not isinstance(optimizer_state, dict):
raise ValueError("AdamW state_dict must be a dictionary")
if set(optimizer_state) != {"state", "param_groups"}:
raise ValueError("AdamW state_dict keys mismatch")
state = optimizer_state["state"]
param_groups = optimizer_state["param_groups"]
if not isinstance(state, dict) or not isinstance(param_groups, list):
raise ValueError("malformed AdamW state_dict")
if len(param_groups) != 1 or not isinstance(param_groups[0], dict):
raise ValueError("serialized AdamW must have one canonical param group")
model_parameters = list(model.parameters())
expected_ids = list(range(len(model_parameters)))
stored_ids = param_groups[0].get("params")
if not isinstance(stored_ids, list) or not all(
type(parameter_id) is int for parameter_id in stored_ids
):
raise ValueError("serialized AdamW parameter IDs must be integers")
if stored_ids != expected_ids:
raise ValueError("serialized AdamW parameter IDs/order are not canonical")
# AdamW creates per-parameter state lazily on its first successful step.
if completed_updates == 0:
if state:
raise ValueError("zero completed updates require empty AdamW state")
return
if not all(type(parameter_id) is int for parameter_id in state):
raise ValueError("AdamW state keys must be integer parameter IDs")
if set(state) != set(expected_ids):
raise ValueError("nonzero progress requires state for every parameter")
amsgrad = param_groups[0].get("amsgrad")
if type(amsgrad) is not bool:
raise ValueError("AdamW amsgrad metadata must be boolean")
expected_state_keys = {"step", "exp_avg", "exp_avg_sq"}
if amsgrad:
expected_state_keys.add("max_exp_avg_sq")
for parameter_id, parameter in enumerate(model_parameters):
parameter_state = state[parameter_id]
if not isinstance(parameter_state, dict):
raise ValueError("each AdamW parameter state must be a dictionary")
if set(parameter_state) != expected_state_keys:
raise ValueError("AdamW per-parameter state keys mismatch")
raw_step = parameter_state["step"]
if torch.is_tensor(raw_step):
if raw_step.numel() != 1:
raise ValueError("AdamW step must be scalar")
raw_step = raw_step.detach().cpu().item()
if isinstance(raw_step, bool) or not isinstance(raw_step, (int, float)):
raise ValueError("AdamW step must be a finite integer")
numeric_step = float(raw_step)
if not math.isfinite(numeric_step) or not numeric_step.is_integer():
raise ValueError("AdamW step must be a finite integer")
if int(numeric_step) != completed_updates:
raise ValueError("completed_updates disagrees with AdamW step state")
moment_names = ["exp_avg", "exp_avg_sq"]
if amsgrad:
moment_names.append("max_exp_avg_sq")
for moment_name in moment_names:
moment = parameter_state[moment_name]
if not torch.is_tensor(moment) or moment.shape != parameter.shape:
raise ValueError(
f"AdamW {moment_name} shape mismatches parameter order"
)
def validate_mini_gpt_adamw_completed_updates(
model: MiniGPT,
optimizer: torch.optim.AdamW,
completed_updates: int,
) -> None:
validate_mini_gpt_adamw_model_binding(model, optimizer)
validate_mini_gpt_adamw_state_dict(
model,
optimizer.state_dict(),
completed_updates,
)
def train_and_save_week11_one_batch(
path: str,
*,
model: MiniGPT,
optimizer: torch.optim.AdamW,
inputs: torch.Tensor,
targets: torch.Tensor,
device: torch.device,
requested_updates: int = 200,
completed_updates: int = 0,
) -> tuple[list[float], int]:
# Check resume progress before training, then derive new progress from
# successful optimizer.step calls rather than from requested_updates.
validate_mini_gpt_adamw_completed_updates(
model,
optimizer,
completed_updates,
)
history, completed_updates = overfit_mini_gpt_one_batch(
model,
optimizer,
inputs,
targets,
device,
updates=requested_updates,
completed_updates=completed_updates,
)
validate_mini_gpt_adamw_completed_updates(
model,
optimizer,
completed_updates,
)
# Save through the canonical Week 10 API; do not invent new keys.
save_mini_gpt_training_checkpoint(
path,
model=model,
optimizer=optimizer,
completed_updates=completed_updates,
ordered_tokens=CANONICAL_ORDERED_TOKENS,
tokenizer_policy=CANONICAL_TOKENIZER_POLICY,
tokenizer_version=CANONICAL_TOKENIZER_VERSION,
)
return history, completed_updatesThe live check does more than count parameters. AdamW must have exactly one group, whose params match list(model.parameters()) in length, order and object identity via is. Subsets, reordering, duplicates or multiple groups are rejected before training/saving. Serialized IDs are PyTorch's positional bookkeeping, not Python id(parameter). This canonical one-group layout requires exactly 0..n−1, and checks groups/order, state keys, moment shapes and every step before optimizer.load_state_dict.
def week11_config_fields(config: GPTConfig) -> dict[str, int]:
return {
"vocab_size": config.vocab_size,
"block_size": config.block_size,
"n_embd": config.n_embd,
"n_head": config.n_head,
"n_layer": config.n_layer,
}
def load_mini_gpt_training_resume(
path: str,
*,
device: torch.device,
) -> tuple[MiniGPT, torch.optim.AdamW, int]:
checkpoint = torch.load(
path,
map_location=device,
weights_only=False,
)
if not isinstance(checkpoint, dict):
raise ValueError("checkpoint must be a dictionary")
required_keys = {
"schema",
"tokenizer",
"config",
"weight_policy",
"model_state",
"optimizer",
"completed_updates",
}
if set(checkpoint) != required_keys:
raise ValueError("training checkpoint keys mismatch")
if checkpoint["schema"] != {
"name": "mini-gpt-training-checkpoint",
"version": 1,
}:
raise ValueError("checkpoint schema/version mismatch")
validate_checkpoint_tokenizer_identity(
checkpoint,
expected_ordered_tokens=CANONICAL_ORDERED_TOKENS,
expected_tokenizer_policy=CANONICAL_TOKENIZER_POLICY,
expected_tokenizer_version=CANONICAL_TOKENIZER_VERSION,
)
expected_config = GPTConfig()
if checkpoint["config"] != week11_config_fields(expected_config):
raise ValueError("checkpoint config mismatch")
if checkpoint["weight_policy"] != {
"token_embedding_lm_head": "untied",
}:
raise ValueError("checkpoint weight policy mismatch")
completed_updates = checkpoint["completed_updates"]
if type(completed_updates) is not int or completed_updates < 0:
raise ValueError("completed_updates must be a non-negative integer")
optimizer_payload = checkpoint["optimizer"]
if not isinstance(optimizer_payload, dict):
raise ValueError("optimizer checkpoint must be a dictionary")
if set(optimizer_payload) != {"class", "state"}:
raise ValueError("optimizer checkpoint keys mismatch")
expected_optimizer_class = (
f"{torch.optim.AdamW.__module__}."
f"{torch.optim.AdamW.__qualname__}"
)
if optimizer_payload["class"] != expected_optimizer_class:
raise ValueError("optimizer class mismatch")
serialized_optimizer_state = optimizer_payload["state"]
if not isinstance(serialized_optimizer_state, dict):
raise ValueError("optimizer state must be a dictionary")
# Construct and use state only after every identity check above passes.
model = MiniGPT(expected_config).to(device)
model.load_state_dict(checkpoint["model_state"], strict=True)
if model.lm_head.weight is model.token_embedding.weight:
raise ValueError("restored model must keep canonical untied weights")
optimizer = torch.optim.AdamW(
model.parameters(),
lr=1e-3,
weight_decay=1e-2,
)
validate_mini_gpt_adamw_model_binding(model, optimizer)
validate_mini_gpt_adamw_state_dict(
model,
serialized_optimizer_state,
completed_updates,
)
optimizer.load_state_dict(serialized_optimizer_state)
validate_mini_gpt_adamw_completed_updates(
model,
optimizer,
completed_updates,
)
return model, optimizer, completed_updatesdef load_week11_mini_gpt_for_inference(
path: str,
*,
device: torch.device,
) -> MiniGPT:
# Inference-only restore delegates to the frozen Week 10 loader.
inference_model = load_mini_gpt_for_inference(
path,
expected_ordered_tokens=CANONICAL_ORDERED_TOKENS,
expected_tokenizer_policy=CANONICAL_TOKENIZER_POLICY,
expected_tokenizer_version=CANONICAL_TOKENIZER_VERSION,
map_location=device,
)
inference_model.eval()
return inference_modelScroll horizontally to view all columns.
| restore goal | Required | May omit |
|---|---|---|
| inference only | schema/tokenizer/config/untied policy/model_state | Optimizer and completed_updates |
| faithful optimizer resume | inference fields + exact optimizer class/state + completed_updates | Cannot omit optimizer moments/history |
| bit-for-bit continuation | Also needs matching data order and relevant CPU/CUDA/Python RNG state | This teaching checkpoint does not claim that guarantee |
map_location maps serialized tensors to the requested CPU/CUDA/MPS device; model and later batches must still be compatible. A fresh optimizer has completed_updates=0, one complete canonical group and empty per-parameter state. Nonzero progress requires moments of the right shapes for every parameter, with all step counters equal to completed_updates. Resume checks serialized layout before loading and live object binding/state afterward. The schema does not separately save parameter names, so IDs alone cannot authenticate two maliciously swapped same-shape moment payloads. Load trusted files only. SHA-256 binds the tokenizer bytes to detect mismatch/corruption, not authenticate provenance.
Knowledge check
How do inference restoration and faithful optimizer-state resumption differ in checkpoint requirements?
22. Week 11 → Week 12: Connect the Lifecycle to the Full Pipeline
Week 9 determines how text becomes model-bound IDs. Week 10's fixed MiniGPT defines one forward from IDs to logits. Week 11 determines when labels, gradients, updates, measurement, persistence and sampling are used. Week 12 traces the same code and state contract end to end rather than inventing new classes or silently changing IDs.
- Raw corpus: you like AI / we like you / you study AI
- mini-gpt-v1 IDs [B,N]=[3,3]
- shift → inputs/targets [3,2]
- token + position representations [3,2,4]
- Week 10 two-block MiniGPT → logits [3,2,5]
- reshape [6,5]+[6] → loss [] → backward → optimizer.step()
- token-weighted held-out validation and/or canonical checkpoint
- target-free prompt history → crop → last logits [B,5] → sampled ID [B,1] → append
Scroll horizontally to view all columns.
| handoff point | Before | After |
|---|---|---|
| after optimizer step | Supervised training; may update θ | May validate, save or continue training |
| after checkpoint identity validation/load | serialized compatible state | Restored model/optimizer or inference model |
| target-free eval/no-grad forward | prompt context IDs | Select final-position logits and begin autoregressive appending |
The switch occurs when the caller runs an eval/no-grad forward on a prompt without targets and samples/appends from logits[:,-1,:]. A checkpoint may sit between training and inference, but is not itself a learning update.
Knowledge check
Where does the end-to-end trace switch from supervised training to autoregressive inference?