Train a small causal language model end to end, inspect loss curves, and verify checkpoint resumption.
A model becomes a pretraining artifact only when the data path, objective, update count, validation boundary, and checkpoint state are all reproducible. This chapter keeps a NumPy loop as a transparent reference, then runs the same contracts through the PyTorch ProofLM decoder and the reusable trainer. The smoke path records training and validation loss, stores a checkpoint, and reloads the state needed for a reproducible continuation.
The CPU smoke profile is deliberately small enough to run locally. Its purpose is to validate the artifact lineage and resume behavior before a larger CUDA run is authorized.
Causal language modeling
For input tokens x_{t-C:t-1} and target x_t, a fixed-context causal model produces logits z_t. Across a batch of windows, the training objective is
The target is one position to the right of the final input position. Validation must construct its windows from a separate text boundary and must not update parameters. A loss curve is meaningful only when these two roles remain separate.
The NumPy loop below makes the update and checkpoint ideas inspectable. The end-to-end smoke cell later uses the shared byte-level tokenizer, packed boundary masks, PyTorch decoder, trainer, and checkpoint artifact.
The training and validation strings share a vocabulary but are separate sequences. That distinction is enough for this small demonstration: a validation window is never sampled from the training array. The MLP flattens six embeddings, applies one nonlinear hidden layer, and returns one logit vector for the next character. Its limited receptive field makes the token shift easy to inspect.
One training update
The backward pass follows the cached path in reverse. For h=\tanh(a), the local derivative is 1-h^2. After the loss gradient reaches all parameter arrays, global-norm clipping applies one common scale
\tilde g = g\min\left(1,\frac{\tau}{\lVert g\rVert_2+10^{-12}}\right),
so it changes the step size without changing the direction of the concatenated gradient. The training loop below samples a batch with a supplied generator, evaluates a loss, clips, and updates every parameter exactly once.
def loss_and_gradients(parameters, inputs, targets): logits, cache = model_forward(parameters, inputs) loss =-np.mean(log_softmax(logits)[np.arange(len(targets)), targets]) probabilities = softmax(logits) dlogits = probabilities.copy() dlogits[np.arange(len(targets)), targets] -=1.0 dlogits /=len(targets) _, embeddings, flattened, hidden = cache gradients = {"w2": hidden.T @ dlogits,"b2": dlogits.sum(axis=0), } dhidden = dlogits @ parameters["w2"].T dhidden_pre = dhidden * (1.0- hidden **2) gradients["w1"] = flattened.T @ dhidden_pre gradients["b1"] = dhidden_pre.sum(axis=0) dembeddings = (dhidden_pre @ parameters["w1"].T).reshape(embeddings.shape) gradients["embedding"] = np.zeros_like(parameters["embedding"])for position inrange(inputs.shape[1]): np.add.at(gradients["embedding"], inputs[:, position], dembeddings[:, position])returnfloat(loss), gradientsdef gradient_norm(gradients):returnfloat(np.sqrt(sum(np.sum(value **2) for value in gradients.values())))def clip_gradients(gradients, maximum_norm): norm = gradient_norm(gradients) scale =min(1.0, maximum_norm / (norm +1e-12))return {name: value * scale for name, value in gradients.items()}, normdef update(parameters, gradients, learning_rate):for name in parameters: parameters[name] -= learning_rate * gradients[name]initial_loss, initial_gradients = loss_and_gradients(parameters, train_inputs[:12], train_targets[:12])clipped, unclipped_norm = clip_gradients(initial_gradients, maximum_norm=0.5)print("initial batch loss:", round(initial_loss, 5))print("gradient norm before clipping:", round(unclipped_norm, 5))print("gradient norm after clipping:", round(gradient_norm(clipped), 5))assert gradient_norm(clipped) <=0.5+1e-10assertall(value.shape == parameters[name].shape for name, value in clipped.items())
initial batch loss: 2.63168
gradient norm before clipping: 0.77634
gradient norm after clipping: 0.5
Clipping is inactive when the norm is already below the threshold and scales every array together when it is active. Per-parameter clipping would change the direction of the combined update and would represent a different optimizer. The explicit shape check protects the embedding scatter-add and matrix derivatives before the loop runs repeatedly.
Checkpoint state
A resumable run needs more than parameter arrays. The update count identifies the schedule position, and the random-generator state identifies the next batch draw. Serialize both with the parameters. An in-memory byte stream is used here so the notebook obeys its no-file fixture constraint; a production trainer would write the same payload to a versioned checkpoint path.
Generation receives a separate generator. Evaluating a sample must not advance the generator used to choose future training batches.
def copy_parameters(parameters):return {name: value.copy() for name, value in parameters.items()}def save_checkpoint(parameters, step, random_generator): payload = {"parameters": copy_parameters(parameters),"step": int(step),"rng_state": random_generator.bit_generator.state, }buffer= io.BytesIO() pickle.dump(payload, buffer)returnbuffer.getvalue()def load_checkpoint(serialized): payload = pickle.loads(serialized) restored_parameters = copy_parameters(payload["parameters"]) restored_rng = np.random.default_rng() restored_rng.bit_generator.state = payload["rng_state"]return restored_parameters, payload["step"], restored_rngdef sample_next(logits, random_generator, temperature=0.8):if temperature <=0.0:raiseValueError("temperature must be positive") probabilities = softmax(np.asarray(logits) / temperature)returnint(random_generator.choice(len(probabilities), p=probabilities))def generate(parameters, prompt, steps, random_generator, temperature=0.8): generated =list(prompt)for _ inrange(steps):iflen(generated) < CONTEXT_LENGTH:raiseValueError("prompt must contain at least CONTEXT_LENGTH symbols") context = np.asarray(generated[-CONTEXT_LENGTH:], dtype=np.int64)[None, :] logits = model_forward(parameters, context)[0][0] generated.append(sample_next(logits, random_generator, temperature))return"".join(vocabulary_symbols[index] for index in generated)checkpoint_rng = np.random.default_rng(SEED +1)checkpoint_bytes = save_checkpoint(parameters, step=7, random_generator=checkpoint_rng)restored_parameters, restored_step, restored_rng = load_checkpoint(checkpoint_bytes)print("serialized checkpoint bytes:", len(checkpoint_bytes), "step:", restored_step)for name in parameters: np.testing.assert_array_equal(parameters[name], restored_parameters[name])assert restored_step ==7assert restored_rng.integers(0, 1000) == checkpoint_rng.integers(0, 1000)
serialized checkpoint bytes: 13948 step: 7
The equality check consumes one draw from each restored generator only after loading, so both generators produce the same next integer. If the checkpoint stored only weights, a resumed run with random mini-batches would use a different data order even when every hyperparameter matched. The sample generator is intentionally independent, which keeps evaluation side-effect free with respect to training randomness.
Tiny pretraining run
Record metrics by update count and save a checkpoint at fixed intervals. Each recorded validation loss is computed without changing parameters. Example generations use the checkpoint’s parameter state, so the output is an observable artifact of the training trajectory rather than a sample from only the final model.
step= 0 train=2.6281 valid=2.5284 sample=the caheoneg rgg tgdatns
step=20 train=1.8201 valid=2.3127 sample=the ca.ne.ogeasgashd.tht
step=40 train=1.0110 valid=1.9845 sample=the cat ate tat sctd aa
step=60 train=0.5701 valid=1.8225 sample=the cat eaetsnaro the eo
The checkpoint table ties each sample to the same update axis as the losses. A decreasing training curve confirms that the objective is being optimized; the validation curve shows whether those updates transfer to a separate string. A changing sample is weaker evidence than either loss, but it verifies that the saved parameter state affects generation and that the sampler is not returning a constant fixture.
Tiny-batch overfitting
Overfitting a few consistent windows is a unit test for the entire forward and backward path. It should be run before interpreting validation behavior. Use four early windows whose contexts are distinct in this corpus, train them with full-batch updates, and require a large loss reduction.
The tiny-batch loss is expected to approach zero because the selected windows have consistent targets and the model has more parameters than examples. This test does not estimate generalization. It establishes that a failure in the full run should be investigated as a data, gradient, or update problem before changing the validation split.
Checkpoint resumption
Run the same initial state for a fixed number of updates in two ways: uninterrupted, and split at a checkpoint. Restore both the parameter arrays and the generator state before continuing. Exact equality is stronger than matching rounded losses and catches a missing piece of serialized state.
resume equivalence: exact parameter match after 30 updates
CPU smoke lineage
The NumPy loop proves the mechanics locally, but the reusable lineage must include the same dataset boundary, tokenizer identity, model configuration, optimizer state, and masked objective that later chapters consume. The following smoke run trains ProofLM for a bounded number of updates, evaluates a separate packed validation stream, saves a checkpoint under the project artifact root, and reloads it before reporting the result.
The uninterrupted and resumed arrays match exactly because the checkpoint captured the state before update 13 and restored the next batch draw. Saving a rounded loss, a seed value, or weights without the generator state would not guarantee this result. The same principle applies to shuffled data order, dropout masks, and any learning-rate schedule state in a larger trainer.
Summary
Causal pretraining minimizes next-token cross-entropy on shifted windows from a declared training split.
The NumPy loop computes gradients, applies global clipping, logs update-indexed metrics, and evaluates validation data without updates.
Checkpoints contain parameters, update position, and random-generator state; in-memory serialization makes the state testable without creating repository files.
A tiny-batch overfit test validates the training path before validation curves are interpreted.
Exact resume equivalence requires restoring every source of trajectory state, not only the model weights.
Chapter 06 isolates optimizer updates, schedules, clipping thresholds, and token accounting so the training budget can be compared across runs.
Exercises
Use the exercises to test the chapter’s invariants and connect the derivations to the reusable implementation. Solutions are hidden in the notebook source and are available through the course tooling when needed.
[P5.1] Exact checkpoint resume
Checkpoint state. List the state required for an exact resume when batches are sampled randomly. Explain why restoring only parameter arrays is insufficient, then identify the corresponding fields in this chapter.
[P5.2] Pretraining sanity checks
Pretraining sanity test. Design two checks that distinguish a decreasing training loss from a valid validation measurement. Include one check for target alignment and one check showing that validation examples are not drawn from the training array.
[P5.3]
Masked pretraining batches. A packed batch contains inputs, shifted targets, a validity mask, and a loss mask. Explain why the target immediately after an EOS token is present in the packed stream but should have a false loss-mask entry. Give one invariant relating the number of loss tokens to valid tokens.
def boundary_loss_invariant(valid_mask, loss_mask):# Return whether the loss mask is a subset of valid target positions.pass