This folder contains a simple distillation script for training the Qwen3 0.6B model on teacher-generated reasoning traces, as covered in chapter 8.
- distill.py: Trains Qwen3 0.6B on JSON-formatted distillation data (more on the format in the next section).
- By default, it trains the base model with the base tokenizer
- If you pass
--use_think_tokens, it uses the reasoning tokenizer and wraps the reasoning trace as<think>...</think>before the final answer similar to how it's done in chapter 8 - After each epoch, it saves a checkpoint to
checkpoints/distill/and appends training metrics tologs/distill_metrics.csv - If you initialize from
--checkpoint_path(optional) instead of the base model, you can continue an already existing checkpoint
- distill_batched.py: Batched version of the script above.
- It uses the padding-aware batched Qwen3 implementation so examples of different lengths can be trained together
- It adds a
--batch_sizeargument to process multiple examples per optimization step - It saves checkpoints to
checkpoints/distill_batched/and appends metrics tologs/distill_batched_metrics.csv - Of course, note that the batched variant uses much more GPU memory (depending on the batch size)
The script imports shared functionality from the reasoning_from_scratch package to avoid duplicating the model-loading and prompt-formatting code. (See chapter 2 setup instructions for installation details.)
Note: If you are not a uv user, replace uv run ...py with python ...py in the examples below.
The input is the JSON output produced by ../02_generate_distillation_data. Each row should look like this:
{
"problem": "Compute 1/2 + 1/6.",
"gtruth_answer": "2/3",
"message_thinking": "I will rewrite the fractions with a common denominator.",
"message_content": "The final answer is \\boxed{\\tfrac{2}{3}}."
}For training, only the following fields are used:
problem: inserted into the same math prompt template used in chapter 3message_content: required; used as the supervised target answermessage_thinking: optional; if present, it is prepended beforemessage_content
Rows with missing or malformed fields are skipped automatically, and examples longer than --max_seq_len are filtered out before the train/validation split.
For a quick sanity check, you can train on a small sample generated in the previous folder:
uv run distill.py \
--data_path ../02_generate_distillation_data/sample_openrouter_outputs.json \
--dataset_size 5 \
--validation_size 1 \
--epochs 2 \
--log_every 1This will:
- load the base Qwen3 0.6B weights
- tokenize the prompt/answer pairs
- reserve 1 example for validation
- save a checkpoint after each epoch in
checkpoints/distill/ - write CSV metrics to
logs/distill_metrics.csv
If you want to train with explicit reasoning tags and the reasoning tokenizer instead, add --use_think_tokens:
uv run distill.py \
--data_path ../02_generate_distillation_data/sample_openrouter_outputs.json \
--dataset_size 5 \
--validation_size 1 \
--epochs 2 \
--log_every 1 \
--use_think_tokensIf you want to train in batches instead, run:
uv run distill_batched.py \
--data_path ../02_generate_distillation_data/sample_openrouter_outputs.json \
--dataset_size 5 \
--validation_size 1 \
--epochs 2 \
--batch_size 2 \
--log_every 1
uv run distill.py --helpImportant arguments:
--data_path: path to the distillation JSON file--dataset_size: truncate the dataset before splitting (0uses all rows)--validation_size: absolute number of validation examples--epochs: number of passes over the training split--batch_size: number of examples per optimization step indistill_batched.py--lr: AdamW learning rate--max_seq_len: drops examples whose prompt + answer sequence is longer than this limit--checkpoint_path: initialize from an earlier distillation checkpoint--grad_clip_norm: optional gradient clipping--use_think_tokens: switch to the reasoning tokenizer and<think>...</think>formatting
See the "Experiments" section below for hands-on examples.
After training, you can evaluate a checkpoint on MATH-500 using the chapter 3 evaluation script.
If you trained without --use_think_tokens, evaluate it as a base model:
uv run ../../ch03/02_math500-verifier-scripts/evaluate_math500.py \
--dataset_size 500 \
--which_model base \
--checkpoint_path checkpoints/distill/qwen3-0.6B-distill-step00004-epoch1.pthImportant: If you trained with --use_think_tokens, evaluate it as a reasoning model so the reasoning tokenizer is used:
uv run ../../ch03/02_math500-verifier-scripts/evaluate_math500.py \
--dataset_size 500 \
--which_model reasoning \
--checkpoint_path checkpoints/distill/qwen3-0.6B-distill-step00004-epoch1.pth
The distillation datasets used in chapter 8 are available from my Hugging Face repo at rasbt/math_distill. In chapter 8, they are loaded via a helper that downloads partitions, e.g.,
from reasoning_from_scratch.ch08 import load_distill_data
_ = load_distill_data(
partition="deepseek-r1-math-train.json",
local_path="deepseek-r1-math-train.json"
)
_ = load_distill_data(
partition="qwen3-235b-a22b-math-train.json",
local_path="qwen3-235b-a22b-math-train.json"
)For the experiments below, I used the deepseek-r1-math-train.json and qwen3-235b-a22b-math-train.json files from that dataset collection.
| Teacher data | Epoch | MATH-500 Acc | Final val loss | |
|---|---|---|---|---|
| 1 | Base (chapter 3) | - | 15.2% | - |
| 2 | Reasoning (chapter 3) | - | 48.2% | - |
| 3 | DeepSeek R1 distillation data | 1 | 30.6% | 0.5436 |
| 4 | DeepSeek R1 distillation data | 2 | 32.4% | 0.5349 |
| 5 | DeepSeek R1 distillation data | 3 | 33.6% | 0.5343 |
| 6 | Qwen3 235B A22B distillation data | 1 | 45.0% | 0.4043 |
| 7 | Qwen3 235B A22B distillation data | 2 | 43.8% | 0.3963 |
| 8 | Qwen3 235B A22B distillation data | 3 | 44.2% | 0.3948 |
The training takes about 30 min on an H100 and about 3 hours on a DGX Spark and uses up to 15 GB RAM.
Below are the code snippets to reproduce the results reported in the table.
Row 1
uv run ../../ch03/02_math500-verifier-scripts/evaluate_math500.py \
--dataset_size 500 \
--which_model baseRow 2
uv run ../../ch03/02_math500-verifier-scripts/evaluate_math500.py \
--dataset_size 500 \
--which_model reasoningRows 3, 4, & 5
uv run distill.py \
--data_path deepseek-r1-math-train.json \
--validation_size 25 \
--epochs 3 \
--lr 1e-5 \
--max_seq_len 2048 \
--use_think_tokens \
--grad_clip 1.0Then, to evaluate the epoch checkpoints, run:
uv run ../../ch03/02_math500-verifier-scripts/evaluate_math500.py \
--dataset_size 500 \
--which_model reasoning \
--max_new_tokens 4096 \
--checkpoint_path run-1/checkpoints/distill/qwen3-0.6B-distill-step06682-epoch1.pthFor row 4 and row 5, replace the checkpoint path with ...step13364-epoch2.pth and ...step20046-epoch3.pth, respectively.
Rows 6, 7, & 8
uv run distill.py \
--data_path qwen3-235b-a22b-math-train.json \
--validation_size 25 \
--epochs 3 \
--lr 1e-5 \
--max_seq_len 2048 \
--use_think_tokens \
--grad_clip 1.0Then, to evaluate the epoch checkpoints, run:
uv run ../../ch03/02_math500-verifier-scripts/evaluate_math500.py \
--dataset_size 500 \
--which_model reasoning \
--max_new_tokens 4096 \
--checkpoint_path run_11/checkpoints/distill/qwen3-0.6B-distill-step05746-epoch1.pthFor row 7 and row 8, replace the checkpoint path with ...step11492-epoch2.pth and ...step17238-epoch3.pth, respectively.