Skip to content

Chapter 7 · Main chapter

Fine-tuning to Follow Instructions

A pretrained model completes text; an instruction-tuned one obeys. This is the last step — supervised fine-tuning on the 935-example training split of a 1,100-pair dataset — built from its two moving parts: the prompt template and the collate function.

Adapted from the original notebook by Sebastian Raschka · the Python shown is the book's; the demos run a faithful JavaScript port in your browser

Act 1 · One template for every task

one dataset entry

instruction: Identify the correct spelling of the following word.

input: Ocassion

output: The correct spelling is 'Occasion.'

01 / 06

A pretrained GPT is a champion at completing text — but it won't follow an instruction. Fine-tuning fixes that, using a dataset of 1,100 {instruction, input, output} triples like this one.

Once every example is a single formatted string, it's tokenized and batched. But batching needs equal lengths — and that's where the one genuinely new piece of code lives.

Act 2 · Pad, shift, and ignore

01234
56
789

three sequences · lengths 5, 2, 3

01 / 06

To train in batches, sequences must share a length — but instructions vary wildly. The custom collate function equalizes a batch on the fly. Here are three toy sequences of length 5, 2, and 3.

Those batches feed ordinary PyTorch DataLoaders — batch size 8, moved to the GPU when there is one — over an 85/5/10 split of the 1,100 pairs: 935 training, 55 validation, 110 test (§7.4).

With the data prepared, fine-tuning is the same training loop as pretraining, just on this new data and a larger model — GPT-2 medium (355M), since 124M is too small to follow instructions well.

Act 3 · From parrot to passive voice

instruction

Convert the active sentence to passive: 'The chef cooks the meal every day.'

01 / 05

One validation instruction is tracked throughout training: rewrite this sentence in the passive voice.

The model clearly learned to obey — but "how well?" has no string-match answer.

Act 4 · Let another model be the judge

reference

The type of cloud typically associated with thunderstorms is cumulonimbus.

model response

The type of cloud associated with thunderstorms is a cumulus cloud.

close, but not equal — string match can't score this

01 / 04

“Cumulus” instead of “cumulonimbus” is wrong, but not zero — and it shares almost no words with the reference. Accuracy, the metric from classification fine-tuning, can't grade open text.

The lab

Type any instruction and watch it become the exact string the model trains on, with a live GPT-2 token count.

Prompt formatter

Type an instruction and watch it wrap into the Alpaca template the model trains on.

formatted prompt

Below is an instruction that describes a task. Write a response that appropriately completes the request.

### Instruction:
Rewrite the sentence in a more formal tone.

### Input:
hey whats up

counting tokens… (fetching GPT-2 vocab)

Then build a batch yourself and drive the collate function: toggle the −100 padding mask and the optional instruction mask (exercise 7.2), on real token ids.

Collate lab

Build a batch, then toggle the masks and watch the (inputs, targets) tensors update live.

batch — pick examples

inputs · 2 × 57

21106318281120643268477257487613194302572882326204313254326225811319819821017464862519833234195826233762499328626217081573131981982101723412251984666118571981982101718261251984643376249933187052922342472637
211063182811206432684772574876131943025728823262043132543262258113198198210174648625198206131828128111224948286705238553474309601981982101718261251982025281112249482867052385534746318705364394458502565025650256

targets · shifted +1 · padding → −100

31828112064326847725748761319430257288232620431325432622581131981982101746486251983323419582623376249932862621708157313198198210172341225198466611857198198210171826125198464337624993318705292234247263750256
318281120643268477257487613194302572882326204313254326225811319819821017464862519820613182812811122494828670523855347430960198198210171826125198202528111224948286705238553474631870536439445850256−100−100−100

token eot 50256 pad −100 (ignored)

The real code

The whole prompt format is one function — a fixed preamble, then labelled sections, with the input block dropped when empty:

ch07.ipynb
def format_input(entry):
    instruction_text = (
        f"Below is an instruction that describes a task. "
        f"Write a response that appropriately completes the request."
        f"\n\n### Instruction:\n{entry['instruction']}"
    )
    input_text = f"\n\n### Input:\n{entry['input']}" if entry["input"] else ""
    return instruction_text + input_text

The collate function pads a batch, builds shifted targets, and masks all but the first padding token to −100:

ch07.ipynb
def custom_collate_fn(batch, pad_token_id=50256, ignore_index=-100,
                      allowed_max_length=None, device="cpu"):
    batch_max_length = max(len(item) + 1 for item in batch)
    inputs_lst, targets_lst = [], []
    for item in batch:
        new_item = item + [pad_token_id]                       # one end-of-text token
        padded = new_item + [pad_token_id] * (batch_max_length - len(new_item))
        inputs  = torch.tensor(padded[:-1])                    # drop the trailing pad
        targets = torch.tensor(padded[1:])                     # shift right by one
 
        mask = targets == pad_token_id                         # replace all but the
        indices = torch.nonzero(mask).squeeze()                # first pad token with
        if indices.numel() > 1:                                # ignore_index (-100)
            targets[indices[1:]] = ignore_index
        if allowed_max_length is not None:
            inputs, targets = inputs[:allowed_max_length], targets[:allowed_max_length]
        inputs_lst.append(inputs); targets_lst.append(targets)
    return torch.stack(inputs_lst).to(device), torch.stack(targets_lst).to(device)
Show the training call and evaluation loop
ch07.ipynb
# Same train_model_simple loop as pretraining — just a bigger model and this data.
optimizer = torch.optim.AdamW(model.parameters(), lr=0.00005, weight_decay=0.1)
train_losses, val_losses, tokens_seen = train_model_simple(
    model, train_loader, val_loader, optimizer, device,
    num_epochs=2, eval_freq=5, eval_iter=5,
    start_context=format_input(val_data[0]), tokenizer=tokenizer)
 
# Evaluate free-text responses with a larger model (Llama 3 via Ollama):
def generate_model_scores(json_data, json_key, model="llama3"):
    scores = []
    for entry in json_data:
        prompt = (f"Given the input `{format_input(entry)}` and correct output "
                  f"`{entry['output']}`, score the model response `{entry[json_key]}`"
                  f" on a scale from 0 to 100, where 100 is the best score. "
                  f"Respond with the integer number only.")
        scores.append(int(query_model(prompt, model)))
    return scores

Four things to remember

  1. One template, every task. Wrapping every example in the same Alpaca prompt teaches the model the shape of an instruction — and it must match at inference.
  2. Padding is bookkeeping, not signal. The collate function pads to a common length, shifts targets by one, and masks padding to −100 so the loss ignores it — keeping just the first end-of-text token as a learnable "stop".
  3. Fine-tuning reuses everything. The training loop is pretraining's, unchanged; only the data and the model size differ.
  4. Open-ended output needs an open-ended judge. The accuracy metric from classification fine-tuning can't grade free text, so a larger model scores it — our 355M model averages ~50/100, and preference tuning can refine it further.

Adapted from Build a Large Language Model (From Scratch) by Sebastian Raschka — original notebook (Apache 2.0). The prompt formatting and collate tensors run a TypeScript port verified against the Python; the model responses, loss curve, and Llama-3 judge scores are the its real GPT-2-medium outputs, shown as attributed data.