Lab: Look Inside a Model
Validated on: written from the documentation cited above; not yet validated on hardware on any track. Per-track versions and dates go here once the validation pass has run these scripts.
Objective
Section titled “Objective”Before executing, read the lab execution and evidence guide. Use this lesson's explicit working directories and track setup; keep each server in its own terminal. Record hardware validation as pass, fail or not run, with the evidence requested below.
By the end of this lab you will have a real model on your own disk and will have checked, with
four short scripts, every claim the five lessons in this part made about it: every tensor name and
shape read from the checkpoint’s headers without loading a weight; the parameter count reconciled
with config.json to the digit; the token counts the tokens lesson asked you to predict; the
probability the model assigns to each of its 151,936 output entries for one position, under three
temperatures; one head’s attention matrix with the causal mask checked as a triangle of exact
zeroes; and one question sent to a base checkpoint and to its instruction-tuned sibling.
The model is Qwen3-1.7B, published by Alibaba under the Apache-2.0 licence according to its model card, with Qwen3-1.7B-Base, the same architecture at the end of pretraining, on the same licence; neither is gated. Both are in the model reference, which lists the BF16 weights at about 3.4 GB.
What is on the disk when you are done
Section titled “What is on the disk when you are done”A model repository is a directory of files, and this lab reads every one of them. The table is the two file lists as the Hub showed them on 2026-09-12, sizes in bytes, with the task that reads each file.
| File | Qwen/Qwen3-1.7B | Qwen/Qwen3-1.7B-Base | What it is, and who reads it |
|---|---|---|---|
config.json |
726 | 727 | The architecture as numbers: layers, widths, heads, vocabulary. Task 3, and every script. |
model-00001-of-00002.safetensors |
3,441,185,608 | — | 310 tensors: embedding, 28 blocks, final norm. Task 4. |
model-00002-of-00002.safetensors |
622,329,984 | — | One tensor, lm_head.weight. Task 4 explains why it is here. |
model.safetensors |
— | 3,441,185,608 | The base checkpoint, all 310 tensors in one file. Task 4. |
model.safetensors.index.json |
25,605 | — | Which tensor lives in which shard, plus total_size. Task 4. |
tokenizer.json |
11,422,654 | 7,031,645 | The tokeniser: vocabulary, merges, pipeline. Task 5. |
vocab.json, merges.txt |
2,776,833 and 1,671,853 | same | The same vocabulary and merges in the older two-file form. |
tokenizer_config.json |
9,732 | 9,678 | Special-token names and the chat template. Tasks 5 and 7. |
generation_config.json |
239 | 138 | Default sampling settings and the end-of-sequence ids. Task 7. |
README.md, LICENSE, .gitattributes |
13,963, 11,343, 1,570 | 2,941, 11,343, 1,519 | The model card, the Apache-2.0 text, Git LFS settings. |
Notice two things before downloading a byte: the instruct repository’s two safetensors files
total 4,063,515,592 bytes against the base repository’s 3,441,185,608, 622 MB more for the same
architecture; and the base tokenizer.json is 4 MB smaller, although both produce identical ids
for every string on this page. Task 4 makes you find the first explanation; task 5 gives the
second.
Requirements
Section titled “Requirements”Every track needs the course directory and virtual environment from Part 1’s lab, an internet connection, and the disk and time in the table.
| Track | Where the scripts run | Memory floor | Download | Free disk needed | Attended | Unattended |
|---|---|---|---|---|---|---|
| S (DGX Spark) | Inside the NGC PyTorch container from Part 1; CUDA | 8 GB of the 128 GB | 7.53 GB (two repositories) | 8 GB | 60 to 75 min | the download |
| X (Ryzen AI Max+) | The Part 1 .venv; ROCm if it worked, otherwise the 16 Zen 5 cores |
8 GB | 7.53 GB | 8 GB | 60 to 75 min; add a few minutes per generation on the CPU | the download |
| M (Apple silicon) | The Part 1 .venv; PyTorch on MPS, and MLX for task 8 |
8 GB | 7.53 GB | 8 GB | 70 to 85 min with task 8 | the download |
| N (NVIDIA desktop or laptop) | The Part 1 .venv, inside WSL2 on Windows; CUDA, or the CPU on a small card |
8 GB | 7.53 GB | 8 GB | 60 to 75 min | the download |
The attended figures are the author’s estimate, not measurements, and the download’s wall-clock is arithmetic on your link:
| Your link, as the provider states it | 7.53 GB takes about |
|---|---|
| 50 Mb/s | 20 minutes |
| 100 Mb/s | 10 minutes |
| 500 Mb/s | 2 minutes |
| 1 Gb/s | 1 minute |
The scripts load the weights in BF16, their native precision, so about 3.4 GB is resident while one runs; task 7 loads the two models one after the other, never together.
Below 8 GB, or short of disk. The same four scripts run unchanged on Qwen3-0.6B
(Qwen/Qwen3-0.6B and Qwen/Qwen3-0.6B-Base, Apache-2.0, not gated, about 1.5 GB and 1.2 GB on
disk):
RunnableAll tracks
hf download Qwen/Qwen3-0.6B --local-dir ~/llm-course/models/qwen3-0.6bhf download Qwen/Qwen3-0.6B-Base --local-dir ~/llm-course/models/qwen3-0.6b-baseEvery --model, --base and --instruct path on this page then ends in qwen3-0.6b or
qwen3-0.6b-base; the counts change, the shapes shrink from 2048 to 1024 wide, and every
mechanism is the same. The dry runs behind this page’s sample outputs used those two, because the
author’s writing machine had no accelerator and a 2 GB download limit, and each such block says
so.
The versions this page was written against are transformers 5.16.1 · verified 2026-09-08, Hugging Face CLI 1.30.0 · verified 2026-09-08, uv 0.12.11 · verified 2026-09-08 and, for task 8, mlx-lm 0.31.3 · verified 2026-09-08.
Track S — NVIDIA DGX Spark
Work inside the NGC PyTorch container that Part 1’s setup-env-spark.sh starts, with your
course directory mounted at /workspace/course. The container has no uv environment, so
install the libraries with its own pip, once per container start unless you commit the image:
RunnableTrack S · DGX Spark
pip install transformers tokenizers safetensors accelerate huggingface_hubTwo substitutions apply to every command on this page: where the page writes ~/llm-course,
type /workspace/course, and skip every source .venv/bin/activate line; after the
pip install above, python and hf are the container’s own. The downloads land on the
host and survive the container.
Track X — AMD Ryzen AI Max+ 395Partial
The ROCm path for this chip uses AMD's nightly wheel index, as Part 1 recorded. The lab is small enough that the CPU fallback finishes comfortably, and every count, shape and zero is identical either way.
Activate the Part 1 environment and install the libraries into it:
RunnableTrack X · Ryzen AI Max+
cd ~/llm-coursesource .venv/bin/activateuv pip install transformers tokenizers safetensors accelerate huggingface_hubWith a working ROCm build from Part 1 the scripts print device: cuda, PyTorch’s name for an
AMD GPU through the ROCm build. With device: cpu, finish the lab on the CPU: only task 7’s
eighty-token generation makes you wait, a minute or two per model.
Track M — Apple silicon
Activate the Part 1 environment and install the libraries into it, plus MLX LM for task 8:
RunnableTrack M · Apple silicon
cd ~/llm-coursesource .venv/bin/activateuv pip install transformers tokenizers safetensors accelerate huggingface_hub mlx-lmThe scripts select PyTorch’s MPS backend and print device: mps; task 8 runs the same
generation through MLX against the same directory, so there is no second download. Stay with
Transformers for task 6: in mlx-lm 0.31.3 the Qwen3 attention layer calls
mx.fast.scaled_dot_product_attention and returns only its output (mlx_lm/models/qwen3.py
and models/base.py at the v0.31.3 tag, read 2026-09-12), so there is no attention matrix
for it to hand back.
Track N — NVIDIA desktop or laptop
Activate the Part 1 environment, inside WSL2 on Windows, and install the libraries into it:
RunnableTrack N · NVIDIA GPU
cd ~/llm-coursesource .venv/bin/activateuv pip install transformers tokenizers safetensors accelerate huggingface_hubThe scripts print device: cuda when PyTorch sees the GPU. The weights are 3.44 GB in BF16
with the CUDA context on top, so a card with 6 GB or more is the comfortable case, an estimate
rather than a measurement; on a smaller card, or after CUDA out of memory, prefix the
command with CUDA_VISIBLE_DEVICES="" to hide the GPU and finish on the CPU. On Windows the
only driver is the Windows one, as Part 1’s lab set out.
Before you start
Section titled “Before you start”Where the files live, first. Download the four lab files embedded in tasks 4 to 7 into
~/llm-course (Track S: /workspace/course) and save the two scripts printed in tasks 3 and 4
there under the names in their titles. Every python command on this page runs from that
directory, which is why --labbook labbook.md is a relative path to the notebook Part 1 created
there. Then five checks, each with the line that means you may go on.
RunnableAll tracks
cd ~/llm-coursesource .venv/bin/activatepython -c "import torch, transformers, safetensors, tokenizers, huggingface_hub; print('torch', torch.__version__); print('transformers', transformers.__version__); print('safetensors', safetensors.__version__); print('tokenizers', tokenizers.__version__); print('huggingface_hub', huggingface_hub.__version__)"Output — what you should see
torch 2.x.x+cu13x (or +rocmx.x on Track X, or 2.x.x with no suffix on a Mac)transformers 5.16.1 (5.16 or later; the model card requires at least 4.51.0)safetensors 0.x.xtokenizers 0.x.xhuggingface_hub 1.30.0 (1.30 or later)Record the five version lines in the notebook’s Environment section. If the import fails, the
install step in your track’s tab did not run in this environment; see Troubleshooting.
RunnableAll tracks
command -v hfhf versionhf auth whoamiOutput — what you should see
/home/you/llm-course/.venv/bin/hf✓ hf version version: 1.30.0Error: Not logged incommand -v hf must print a path inside the environment you installed into: the .venv on
Tracks X, M and N, or on Track S a path inside the container. The documentation says
hf auth whoami “simply prints your username and the organizations you are a part of” and that
“if you are not logged in, an error message will be printed”; for a public model that changes
nothing, and task 1 says when it is worth fixing. The first hf command of a day may print a grey
Hint: line or two before its output (a newer release exists; an agent skill is not installed);
neither is an error. If you see version=1.30.0 instead, and later a bare path=… line or
tab-separated tables, that is the CLI’s agent format, printed when it detects an AI coding agent’s
environment variables; a terminal shows the ✓ hf version / version: 1.30.0 and
✓ Downloaded / path: … pairs, as the blocks on this page do.
RunnableAll tracks
python -c "import torch; print('cuda', torch.cuda.is_available()); mps = getattr(torch.backends, 'mps', None); print('mps', mps is not None and mps.is_available())"Output — what you should see
cuda Truemps FalseOne of the two is True on Tracks S, M and N and on Track X when ROCm works; both False means
the CPU, which finishes the lab. Record which.
RunnableAll tracks
df -h ~/llm-courseThe Avail column must show at least 8G; if not, read the reduced path in Requirements before
downloading anything.
RunnableAll tracks
mkdir -p ~/llm-course/modelshf download Qwen/Qwen3-1.7B --dry-run --local-dir ~/llm-course/models/qwen3-1.7bhf download Qwen/Qwen3-1.7B-Base --dry-run --local-dir ~/llm-course/models/qwen3-1.7b-baseThe documentation describes --dry-run as listing “all files to download on the repo” and
checking “whether they are already downloaded or not”; nothing is written except the empty
directories. The two Will download lines are the ones to read:
Output — what you should see
[dry-run] Fetching 12 files: 100%|██████████| 12/12 [00:00<00:00, xx.xxit/s][dry-run] Will download 12 files (out of 12) totalling 4.1G.FILE SIZE-------------------------------- ------.gitattributes 1.6KLICENSE 11.3KREADME.md 14.0Kconfig.json 726.0generation_config.json 239.0merges.txt 1.7Mmodel-00001-of-00002.safetensors 3.4Gmodel-00002-of-00002.safetensors 622.3Mmodel.safetensors.index.json 25.6Ktokenizer.json 11.4Mtokenizer_config.json 9.7Kvocab.json 2.8M[dry-run] Fetching 10 files: 100%|██████████| 10/10 [00:00<00:00, xx.xxit/s][dry-run] Will download 10 files (out of 10) totalling 3.5G.FILE SIZE---------------------- -----...model.safetensors 3.4Gtokenizer.json 7.0M...Task 2’s unauthenticated-requests warning may appear between the lines. If the counts are not twelve and ten, the repositories have changed since 2026-09-12: note the new file list in the notebook and read the tasks with the new names.
1. Sign in to the Hub, if you need to
Section titled “1. Sign in to the Hub, if you need to”Qwen3-1.7B is public and downloads without an account, but signing in once is worth it: several
later models are gated, and the CLI warns about rate limits on anonymous downloads. The
documentation describes hf auth login as logging you in with your browser: “it prints a URL and
a short code. Open the URL, enter the code, approve the request, and the CLI retrieves and saves
an access token on your machine.”
RunnableAll tracks
hf auth loginOutput — what you should see
? How would you like to log in? [Use arrows, Enter to confirm]> Log in with your browser Paste an access token
Open this URL in your browser: https://huggingface.co/oauth/device
And enter the code: XXXX-XXXX
Waiting for authorization...Token is valid.Your token has been saved to /home/you/.cache/huggingface/tokenLogin successful.Afterwards hf auth whoami prints your username. Record only whether you signed in; the token is
a credential and stays out of the notebook.
2. Download the two checkpoints
Section titled “2. Download the two checkpoints”hf download fetches a repository and, as the documentation puts it, “prints the returned path
to the terminal”. By default files go into the shared cache under HF_HOME; --local-dir puts
them in a directory you name, so that every model in the notebook has an obvious path.
RunnableAll tracks
hf download Qwen/Qwen3-1.7B --local-dir ~/llm-course/models/qwen3-1.7bOutput — what you should see
Warning: You are sending unauthenticated requests to the HF Hub. Please set a HF_TOKEN to enable higher rate limits and faster downloads.Fetching 12 files: 100%|██████████████████████████| 12/12 [xx:xx<00:00, x.xxs/it]✓ Downloaded path: /home/you/llm-course/models/qwen3-1.7bThe warning appears only if you skipped task 1 and is not an error; the progress bars, of which
1.30.0 may draw more than one, are the CLI’s own and may differ by version. The last two lines are
the path the documentation says is always printed. If the download stops partway, run the same
command again: the CLI keeps metadata under
.cache/huggingface/ inside the directory, which the documentation says “prevents re-downloading
files if they’re already up-to-date”.
Now the base checkpoint, which task 7 needs:
RunnableAll tracks
hf download Qwen/Qwen3-1.7B-Base --local-dir ~/llm-course/models/qwen3-1.7b-baseLook at what arrived, hidden entries included:
RunnableAll tracks
ls -lA ~/llm-course/models/qwen3-1.7b ~/llm-course/models/qwen3-1.7b-baseOutput — what you should see
/home/you/llm-course/models/qwen3-1.7b:total xxxxxxxdrwxr-xr-x 3 you you xxxx ... .cache-rw-r--r-- 1 you you 1570 ... .gitattributes...-rw-r--r-- 1 you you 3441185608 ... model-00001-of-00002.safetensors-rw-r--r-- 1 you you 622329984 ... model-00002-of-00002.safetensors...
/home/you/llm-course/models/qwen3-1.7b-base:total xxxxxxx...-rw-r--r-- 1 you you 3441185608 ... model.safetensors...Twelve and ten entries counting .gitattributes, plus the .cache directory the CLI adds, and
the byte counts of the table at the top, because they are the same bytes. That directory is the
whole model: weights, architecture, tokeniser and chat template, as files.
Record in the notebook: the two directory paths, the date, the twelve and ten entry names, and the two safetensors byte counts.
3. Read the configuration, and predict the count
Section titled “3. Read the configuration, and predict the count”Open the file the last lesson described:
RunnableAll tracks
cat ~/llm-course/models/qwen3-1.7b/config.jsonThe file as retrieved on 2026-09-12, cut to the fields this task uses; the real one adds
rope_theta, rms_norm_eps, dropout and window settings:
{ "architectures": ["Qwen3ForCausalLM"], "attention_bias": false, "bos_token_id": 151643, "eos_token_id": 151645, "head_dim": 128, "hidden_size": 2048, "intermediate_size": 6144, "max_position_embeddings": 40960, "num_attention_heads": 16, "num_hidden_layers": 28, "num_key_value_heads": 8, "tie_word_embeddings": true, "torch_dtype": "bfloat16", "vocab_size": 151936}Do the arithmetic before the next task checks it. The
parameters lesson
did two sums from config.json; here are all of them for one block. A linear layer’s weight is
stored as (output width, input width), and these are the shapes task 4 will print:
| Tensor in one block | Shape, from the config fields | Parameters |
|---|---|---|
self_attn.q_proj.weight |
(16 × 128, 2048) = (2048, 2048) | 4,194,304 |
self_attn.k_proj.weight |
(8 × 128, 2048) = (1024, 2048) | 2,097,152 |
self_attn.v_proj.weight |
(1024, 2048) | 2,097,152 |
self_attn.o_proj.weight |
(2048, 16 × 128) = (2048, 2048) | 4,194,304 |
self_attn.q_norm.weight, self_attn.k_norm.weight |
(128,) each | 256 |
mlp.gate_proj.weight, mlp.up_proj.weight |
(6144, 2048) each | 25,165,824 |
mlp.down_proj.weight |
(2048, 6144) | 12,582,912 |
input_layernorm.weight, post_attention_layernorm.weight |
(2048,) each | 4,096 |
| One block | 11 tensors | 50,336,000 |
Attention is 12,583,168 of the 50,336,000 and the feed-forward network 37,748,736, three quarters of the block, as the parameters lesson promised. Then the stack:
non-embedding = 28 blocks × 50,336,000 + model.norm (2,048) = 1,409,410,048embedding = vocab_size × hidden_size = 151,936 × 2,048 = 311,164,928total = non-embedding + embedding = 1,720,574,976The card’s 1.4B and 1.7B are these numbers before rounding. Save the same arithmetic as
~/llm-course/count-from-config.py, to point at any Qwen3 config.json in the course before
downloading its weights:
RunnableAll tracks
import json, sysc = json.load(open(sys.argv[1])) # path to config.jsonh, inter = c["hidden_size"], c["intermediate_size"]q_width = c["num_attention_heads"] * c["head_dim"] # 16 x 128 = 2048kv_width = c["num_key_value_heads"] * c["head_dim"] # 8 x 128 = 1024attention = 2 * h * q_width + 2 * h * kv_width + 2 * c["head_dim"] # q,o + k,v + q_norm,k_normmlp = 3 * h * inter # gate, up, downnorms = 2 * h # input_layernorm, post_attention_layernormper_layer = attention + mlp + normsnon_embedding = per_layer * c["num_hidden_layers"] + h # + model.normembedding = c["vocab_size"] * hprint(f"attention per layer {attention:>15,}")print(f"feed-forward per layer{mlp:>15,}")print(f"per layer {per_layer:>15,}")print(f"non-embedding {non_embedding:>15,} (model card: 1.4B)")print(f"embedding matrix {embedding:>15,}")print(f"total, head tied {non_embedding + embedding:>15,} (model card: 1.7B)")print(f"BF16 bytes once loaded{2 * (non_embedding + embedding):>15,}")RunnableAll tracks
python count-from-config.py ~/llm-course/models/qwen3-1.7b/config.jsonOutput — what you should see
attention per layer 12,583,168feed-forward per layer 37,748,736per layer 50,336,000non-embedding 1,409,410,048 (model card: 1.4B)embedding matrix 311,164,928total, head tied 1,720,574,976 (model card: 1.7B)BF16 bytes once loaded 3,441,149,952The last line is Part 1’s bytes-per-parameter arithmetic: two bytes per BF16 parameter. Now write down a prediction and keep it: how many bytes of tensor data do you expect the instruct repository’s two safetensors files to hold? You know their sizes from the table at the top; if the two disagree, you have found what task 4 is about.
Record in the notebook: the seven config fields, the per-block count, the three totals, and your byte prediction.
4. List the tensors and reconcile the count
Section titled “4. List the tensors and reconcile the count”The script reads only the safetensors headers: as the format specification describes, a file
begins with eight bytes giving the header length as an unsigned little-endian 64-bit integer,
then that many bytes of JSON naming every tensor with its dtype, shape and data_offsets,
then the byte buffer. Listing a multi-gigabyte checkpoint therefore reads a few tens of
kilobytes, with nothing but the standard library.
RunnableAll tracks
"""List every tensor in a safetensors checkpoint and count the parameters.
Purpose: open the safetensors file(s) of a downloaded model, print each tensor's name, shape and dtype, total the parameters and bytes, and compare the file with the arithmetic that config.json predicts, so that the numbers on a model card can be checked against the checkpoint on your own disk.Platform: all (this reads file headers only; no accelerator and no model loading)Minimum memory: 8 GB (the script itself needs a few megabytes)Assumes: a model directory downloaded with `hf download ... --local-dir <dir>` containing one or more *.safetensors files and a config.json; the course environment from Part 1. Only the standard library is used.
Usage: python list-tensors.py --model ~/llm-course/models/qwen3-1.7b [--labbook labbook.md] [--limit 12] [--layer 0]"""import argparseimport jsonimport structfrom pathlib import Path
# Bytes per element for the dtype strings the safetensors header uses.DTYPE_BYTES = { "BOOL": 1, "U8": 1, "I8": 1, "F8_E4M3": 1, "F8_E5M2": 1, "I16": 2, "U16": 2, "F16": 2, "BF16": 2, "I32": 4, "U32": 4, "F32": 4, "I64": 8, "U64": 8, "F64": 8,}
def read_header(path: Path) -> tuple[dict, int]: """Read a safetensors header without touching the weights.
The format specification says the file starts with 8 bytes holding an unsigned little-endian 64-bit integer N, followed by N bytes of JSON, followed by the byte buffer. Reading the first 8 + N bytes is therefore enough to list everything. Returns the parsed header and N. """ with path.open("rb") as fh: (header_len,) = struct.unpack("<Q", fh.read(8)) header = json.loads(fh.read(header_len).decode("utf-8")) return header, header_len
def numel(shape: list[int]) -> int: n = 1 for dim in shape: n *= dim return n
def expected_from_config(config: dict) -> dict | None: """Parameter arithmetic for a dense Qwen3-style block, from config.json alone.
Per block: q_proj (heads*head_dim x hidden), k_proj and v_proj (kv_heads*head_dim x hidden), o_proj (hidden x heads*head_dim), q_norm and k_norm (head_dim each), gate_proj and up_proj (intermediate x hidden), down_proj (hidden x intermediate), two RMSNorm scales (hidden each). Then the final norm and the embedding matrix. Returns None when the config lacks a field or declares attention biases. """ fields = ("hidden_size", "intermediate_size", "num_hidden_layers", "num_attention_heads", "num_key_value_heads", "head_dim", "vocab_size") if any(f not in config for f in fields) or config.get("attention_bias"): return None h, inter, layers = config["hidden_size"], config["intermediate_size"], config["num_hidden_layers"] q_width = config["num_attention_heads"] * config["head_dim"] kv_width = config["num_key_value_heads"] * config["head_dim"] attention = 2 * h * q_width + 2 * h * kv_width + 2 * config["head_dim"] mlp = 3 * h * inter norms = 2 * h per_layer = attention + mlp + norms embedding = config["vocab_size"] * h non_embedding = per_layer * layers + h return { "attention_per_layer": attention, "mlp_per_layer": mlp, "norms_per_layer": norms, "per_layer": per_layer, "non_embedding": non_embedding, "embedding": embedding, "total_tied": non_embedding + embedding, "total_untied": non_embedding + 2 * embedding, }
def main() -> None: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--model", required=True, help="directory holding the checkpoint") parser.add_argument("--limit", type=int, default=12, help="tensors to print in full before summarising") parser.add_argument("--layer", type=int, default=None, help="also print every tensor of this layer index") parser.add_argument("--labbook", default=None, help="append one JSON line per run to this file") args = parser.parse_args()
model_dir = Path(args.model).expanduser() if not model_dir.is_dir(): raise SystemExit(f"{model_dir} is not a directory; check the --model path") files = sorted(model_dir.glob("*.safetensors")) if not files: raise SystemExit(f"no *.safetensors files in {model_dir}; the download did not finish, or the path is wrong")
config_path = model_dir / "config.json" config = json.loads(config_path.read_text(encoding="utf-8")) if config_path.exists() else {} if not config: print(f"warning: no config.json in {model_dir}; the config comparison is skipped")
tensors: list[tuple[str, list[int], str, int, str]] = [] metadata: dict[str, str] = {} per_file: list[dict] = [] for path in files: header, header_len = read_header(path) meta = header.pop("__metadata__", None) if isinstance(meta, dict): metadata.update({str(k): str(v) for k, v in meta.items()}) count = 0 for name, info in header.items(): shape = [int(d) for d in info["shape"]] tensors.append((name, shape, info["dtype"], numel(shape), path.name)) count += 1 per_file.append({"file": path.name, "bytes": path.stat().st_size, "header_bytes": header_len, "tensors": count})
tensors.sort(key=lambda t: t[0]) total_params = sum(t[3] for t in tensors) total_bytes = sum(t[3] * DTYPE_BYTES.get(t[2], 0) for t in tensors) dtypes = sorted({t[2] for t in tensors}) names = {t[0] for t in tensors}
# The embedding matrix is the tensor whose first dimension is the vocabulary; an # output head, if stored, has the same shape under a different name. vocab_size = config.get("vocab_size") vocab_sized = [t for t in tensors if vocab_size and t[1] and t[1][0] == vocab_size] embedding_params = sum(t[3] for t in vocab_sized) tied = bool(config.get("tie_word_embeddings")) lm_head_stored = "lm_head.weight" in names lm_head_params = next((t[3] for t in tensors if t[0] == "lm_head.weight"), 0) # What the model occupies once loaded: a tied head is one matrix in memory even # when the file stores it twice. unique_params = total_params - (lm_head_params if tied and lm_head_stored else 0) bytes_per_param = DTYPE_BYTES.get(dtypes[0], 0) if len(dtypes) == 1 else None
print(f"directory: {model_dir}") for f in per_file: print(f"file: {f['file']:36s} {f['bytes']:>15,} bytes on disk " f"header {f['header_bytes']:>7,} bytes {f['tensors']} tensors") print(f"tensors: {len(tensors)}") print(f"dtypes: {', '.join(dtypes)}") if metadata: print(f"metadata: {metadata}") print() for name, shape, dtype, n, _ in tensors[: args.limit]: print(f" {name:52s} {str(tuple(shape)):>22s} {dtype:5s} {n:>13,}") if len(tensors) > args.limit: print(f" ... and {len(tensors) - args.limit} more tensors")
layer_params = None if args.layer is not None: prefix = f"model.layers.{args.layer}." block = [t for t in tensors if t[0].startswith(prefix)] if not block: raise SystemExit(f"no tensors named {prefix}*; the model has {config.get('num_hidden_layers', '?')} layers") layer_params = sum(t[3] for t in block) print(f"\nevery tensor of layer {args.layer} ({len(block)} tensors, {layer_params:,} parameters):") for name, shape, dtype, n, _ in block: print(f" {name[len(prefix):]:40s} {str(tuple(shape)):>16s} {dtype:5s} {n:>13,}")
print() print(f"parameters in the file: {total_params:>15,}") print(f" in vocabulary-sized tensors: {embedding_params:>15,} " f"({len(vocab_sized)} tensor(s): {', '.join(t[0] for t in vocab_sized) or 'none'})") print(f" everything else: {total_params - embedding_params:>15,}") print(f"tie_word_embeddings: {tied} lm_head.weight stored in the file: {lm_head_stored}") if tied and lm_head_stored: print(f" the output head is a second copy of the embedding matrix; it is loaded once, so") print(f" parameters once loaded: {unique_params:>15,}") print(f"weight bytes on disk: {total_bytes:>15,} ({total_bytes / 1e9:.2f} GB)") if bytes_per_param: print(f"weight bytes once loaded: {unique_params * bytes_per_param:>15,} " f"({unique_params * bytes_per_param / 1e9:.2f} GB at {bytes_per_param} bytes per parameter)")
expected = expected_from_config(config) if config else None if config: print() for field in ("model_type", "num_hidden_layers", "hidden_size", "intermediate_size", "num_attention_heads", "num_key_value_heads", "head_dim", "vocab_size", "max_position_embeddings", "tie_word_embeddings", "torch_dtype"): if field in config: print(f" config {field:26s} {config[field]}") if expected: print() print("from config.json alone (dense Qwen3-style block):") print(f" attention per layer: {expected['attention_per_layer']:>15,}") print(f" feed-forward per layer:{expected['mlp_per_layer']:>14,}") print(f" norms per layer: {expected['norms_per_layer']:>15,}") print(f" per layer: {expected['per_layer']:>15,}" + (f" file says {layer_params:,} match: {layer_params == expected['per_layer']}" if layer_params else "")) print(f" non-embedding: {expected['non_embedding']:>15,} file says {total_params - embedding_params:,}" f" match: {expected['non_embedding'] == total_params - embedding_params}") print(f" embedding matrix: {expected['embedding']:>15,}") print(f" total, tied head: {expected['total_tied']:>15,}") print(f" total, untied head: {expected['total_untied']:>15,}")
if args.labbook: record = { "lab": "part-02/list-tensors", "model_dir": str(model_dir), "files": per_file, "tensors": len(tensors), "dtypes": dtypes, "parameters_in_file": total_params, "parameters_loaded": unique_params, "embedding_parameters": embedding_params, "lm_head_stored": lm_head_stored, "weight_bytes_on_disk": total_bytes, "config": {k: config.get(k) for k in ("num_hidden_layers", "hidden_size", "intermediate_size", "num_attention_heads", "num_key_value_heads", "head_dim", "vocab_size", "tie_word_embeddings")}, "expected_from_config": expected, } with Path(args.labbook).expanduser().open("a", encoding="utf-8") as fh: fh.write(json.dumps(record) + "\n") print(f"\nrecorded in {args.labbook}")
if __name__ == "__main__": main()RunnableAll tracks
python list-tensors.py --model ~/llm-course/models/qwen3-1.7b --layer 0 --labbook labbook.mdEvery figure below is derived from the repository’s index file, the Hub’s file sizes and
config.json as retrieved on 2026-09-12, so it should match to the digit; the file name column
and the date are yours.
Output — what you should see
directory: /home/you/llm-course/models/qwen3-1.7bfile: model-00001-of-00002.safetensors 3,441,185,608 bytes on disk header 35,648 bytes 310 tensorsfile: model-00002-of-00002.safetensors 622,329,984 bytes on disk header 120 bytes 1 tensorstensors: 311dtypes: BF16metadata: {'format': 'pt'}
lm_head.weight (151936, 2048) BF16 311,164,928 model.embed_tokens.weight (151936, 2048) BF16 311,164,928 model.layers.0.input_layernorm.weight (2048,) BF16 2,048 model.layers.0.mlp.down_proj.weight (2048, 6144) BF16 12,582,912 model.layers.0.mlp.gate_proj.weight (6144, 2048) BF16 12,582,912 model.layers.0.mlp.up_proj.weight (6144, 2048) BF16 12,582,912 model.layers.0.post_attention_layernorm.weight (2048,) BF16 2,048 model.layers.0.self_attn.k_norm.weight (128,) BF16 128 model.layers.0.self_attn.k_proj.weight (1024, 2048) BF16 2,097,152 model.layers.0.self_attn.o_proj.weight (2048, 2048) BF16 4,194,304 model.layers.0.self_attn.q_norm.weight (128,) BF16 128 model.layers.0.self_attn.q_proj.weight (2048, 2048) BF16 4,194,304 ... and 299 more tensors
every tensor of layer 0 (11 tensors, 50,336,000 parameters): input_layernorm.weight (2048,) BF16 2,048 ... self_attn.v_proj.weight (1024, 2048) BF16 2,097,152
parameters in the file: 2,031,739,904 in vocabulary-sized tensors: 622,329,856 (2 tensor(s): lm_head.weight, model.embed_tokens.weight) everything else: 1,409,410,048tie_word_embeddings: True lm_head.weight stored in the file: True the output head is a second copy of the embedding matrix; it is loaded once, so parameters once loaded: 1,720,574,976weight bytes on disk: 4,063,479,808 (4.06 GB)weight bytes once loaded: 3,441,149,952 (3.44 GB at 2 bytes per parameter)
config model_type qwen3 config num_hidden_layers 28 ... config tie_word_embeddings True config torch_dtype bfloat16
from config.json alone (dense Qwen3-style block): attention per layer: 12,583,168 feed-forward per layer: 37,748,736 norms per layer: 4,096 per layer: 50,336,000 file says 50,336,000 match: True non-embedding: 1,409,410,048 file says 1,409,410,048 match: True embedding matrix: 311,164,928 total, tied head: 1,720,574,976 total, untied head: 2,031,739,904
recorded in labbook.mdRead it against task 3. Block 0 is your table: eleven tensors, the shapes you predicted,
50,336,000 parameters, match: True on the per-layer and non-embedding lines. The file agrees
with the configuration to the digit.
The file holds 2,031,739,904 parameters, not 1,720,574,976. The difference is exactly
311,164,928, one embedding matrix, and the listing shows where: lm_head.weight, (151936, 2048),
the entire second shard. config.json says tie_word_embeddings: true, so the loader uses one
matrix for both the input embedding and the output projection and “parameters once loaded” is
the card’s 1.7B; the publisher’s export wrote the tied copy to disk anyway. That is why the
instruct repository is 622 MB larger than the base one, whose single file you will find has no
lm_head.weight, and why the model reference figure of 3.4 GB describes
memory rather than the download. Reading a repository’s size as a parameter count, or the
reverse, gets this model wrong by 18 percent: memory follows “parameters once loaded” times bytes
per parameter, disk follows the file list, and Part 4’s lab keeps the two apart.
The header is 35,648 bytes, which you can derive from the file size, 3,441,185,608 − 8 − 3,441,149,952 bytes of tensor data. Everything the script printed came from those bytes and the second shard’s 120.
Now the base checkpoint:
RunnableAll tracks
python list-tensors.py --model ~/llm-course/models/qwen3-1.7b-base --limit 3 --labbook labbook.mdOutput — what you should see
directory: /home/you/llm-course/models/qwen3-1.7b-basefile: model.safetensors 3,441,185,608 bytes on disk header 35,648 bytes 310 tensorstensors: 310dtypes: BF16...parameters in the file: 1,720,574,976 in vocabulary-sized tensors: 311,164,928 (1 tensor(s): model.embed_tokens.weight) everything else: 1,409,410,048tie_word_embeddings: True lm_head.weight stored in the file: Falseweight bytes on disk: 3,441,149,952 (3.44 GB)weight bytes once loaded: 3,441,149,952 (3.44 GB at 2 bytes per parameter)...Same architecture, same 1,409,410,048 non-embedding parameters, 310 tensors instead of 311, a file byte-for-byte the size of the instruct repository’s first shard. The two checkpoints differ in the values of their weights, which is what post-training changed, and in one exported copy of a matrix, which is packaging.
Go one level further, optionally. The safetensors documentation shows safe_open with
get_slice, which reads a rectangle of one tensor and nothing else. The embeddings lesson said a
token id is a row number; save this as ~/llm-course/embedding-row.py to read four such rows
straight from the shard and compare them by cosine similarity:
RunnableAll tracks
import sysfrom pathlib import Pathimport torchfrom safetensors import safe_openfrom transformers import AutoTokenizer
model_dir = Path(sys.argv[1]).expanduser()tok = AutoTokenizer.from_pretrained(model_dir)words = ["ĠParis", "ĠLondon", "ĠFrance", "Ġbanana"]ids = {w: tok.convert_tokens_to_ids(w) for w in words}for path in sorted(model_dir.glob("*.safetensors")): # find the shard holding the embedding with safe_open(path, framework="pt", device="cpu") as f: if "model.embed_tokens.weight" not in f.keys(): continue rows = f.get_slice("model.embed_tokens.weight") # nothing is read yet print("embedding matrix", rows.get_shape(), "in", path.name) vec = {w: rows[i:i + 1].float()[0] for w, i in ids.items()} # one row each, 4 KB of I/Ofor w in words[1:]: cos = torch.nn.functional.cosine_similarity(vec["ĠParis"], vec[w], dim=0).item() print(f"cos(row {ids['ĠParis']} 'ĠParis', row {ids[w]} {w!r}) = {cos:.3f}")RunnableAll tracks
python embedding-row.py ~/llm-course/models/qwen3-1.7bOutput — what you should see
embedding matrix [151936, 2048] in model-00001-of-00002.safetensorscos(row 12095 'ĠParis', row 7148 'ĠLondon') = 0.xxxcos(row 12095 'ĠParis', row 9625 'ĠFrance') = 0.xxxcos(row 12095 'ĠParis', row 43096 'Ġbanana') = 0.xxxThe row numbers are exact, because they are the tokeniser’s ids; the cosines are yours to read. On the author’s 0.6B dry run the city and country rows sat well above the fruit row, the static-embedding geometry the embeddings lesson described, and no more than that.
Record in the notebook: the script appended a JSON line per run; add by hand the sentence that explains the 311,164,928 difference, in your own words, because the capstone will ask for it.
5. Tokenise, and be wrong about something
Section titled “5. Tokenise, and be wrong about something”RunnableAll tracks
"""Tokenise several strings with a model's own tokeniser and show what it did.
Purpose: turn the tokens lesson into measurements: how many tokens the same meaning costs in English and Portuguese, what happens to source code and emoji, how the byte-level vocabulary displays non-ASCII text, and what the special and chat control tokens of this model are.Platform: all (CPU only; the tokeniser is data, not a model)Minimum memory: 8 GB (the tokeniser itself needs a few hundred megabytes)Assumes: a model directory downloaded with `hf download ... --local-dir <dir>` containing tokenizer.json and tokenizer_config.json; transformers and tokenizers installed in the course environment from Part 1.
Usage: python tokenise-samples.py --model ~/llm-course/models/qwen3-1.7b [--guess 4,6,8,2] [--text "your own string"] [--labbook labbook.md]"""import argparseimport jsonfrom pathlib import Path
from transformers import AutoTokenizer
# The four strings the tokens lesson asked you to predict, in that order.PREDICTIONS = [ ("hello", "Hello, world!"), ("hello-pt", "Olá, mundo!"), ("signature", "def get_user_by_id(user_id: int):"), ("flag-rocket", "🇧🇷🚀"),]
SAMPLES = [ ("english", "The quick brown fox jumps over the lazy dog near the river bank."), ("portuguese", "A rápida raposa castanha salta sobre o cão preguiçoso perto da margem do rio."), ("code", "def get_user_by_id(user_id: int) -> User | None:\n return session.get(User, user_id)\n"), ("emoji", "🇧🇷 🚀 ✨ 🧮"),]
# Chat control tokens named in the Qwen3 chat template; printed if the vocabulary has them.CONTROL_TOKENS = ["<|endoftext|>", "<|im_start|>", "<|im_end|>", "<think>", "</think>"]
def describe(tokeniser, label: str, text: str, guess: int | None = None) -> dict: """Tokenise one string and print ids, token strings and the three counts.""" ids = tokeniser(text, add_special_tokens=False)["input_ids"] pieces = tokeniser.convert_ids_to_tokens(ids) n_bytes = len(text.encode("utf-8")) n_words = len([w for w in text.split() if w]) print(f"\n[{label}] {text!r}") guessed = f" your guess {guess:4d}" if guess is not None else "" print(f" tokens {len(ids):4d} words {n_words:4d} characters {len(text):4d} utf-8 bytes {n_bytes:4d}{guessed}") print(f" ids {ids}") print(f" pieces {pieces}") if not text.isascii(): # Byte-level BPE shows each UTF-8 byte as one printable character, so 'á' (bytes # C3 A1) appears as 'á'. Decoding each piece on its own shows the text it holds. decoded = [tokeniser.decode([i]) for i in ids] print(f" pieces decoded one by one {decoded}") if n_words: print(f" tokens per word {len(ids) / n_words:.2f} bytes per token {n_bytes / max(len(ids), 1):.2f}") # Round-tripping proves the split is lossless for this tokeniser. restored = tokeniser.decode(ids) print(f" decode round-trip identical: {restored == text}") return { "label": label, "tokens": len(ids), "words": n_words, "characters": len(text), "utf8_bytes": n_bytes, "round_trip": restored == text, "guess": guess, }
def main() -> None: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--model", required=True, help="model directory or Hub id") parser.add_argument("--guess", default=None, help="your four predicted token counts for the lesson's strings, e.g. 4,6,8,2") parser.add_argument("--text", action="append", default=[], help="an extra string to tokenise; repeatable") parser.add_argument("--labbook", default=None, help="append one JSON line per run to this file") args = parser.parse_args()
model_dir = Path(args.model).expanduser() if model_dir.is_dir() and not (model_dir / "tokenizer.json").exists(): raise SystemExit(f"{model_dir} has no tokenizer.json; the download did not finish, or the path is wrong")
guesses: list[int | None] = [None] * len(PREDICTIONS) if args.guess: parts = [p.strip() for p in args.guess.split(",")] if len(parts) != len(PREDICTIONS) or not all(p.isdigit() for p in parts): raise SystemExit(f"--guess needs {len(PREDICTIONS)} integers separated by commas, e.g. 4,6,8,2") guesses = [int(p) for p in parts]
tokeniser = AutoTokenizer.from_pretrained(args.model) print(f"tokeniser class: {type(tokeniser).__name__}") print(f"base vocabulary (BPE): {tokeniser.vocab_size:,} (tokeniser.vocab_size)") print(f"entries incl. added tokens: {len(tokeniser):,} (len(tokeniser))") config_path = model_dir / "config.json" if config_path.exists(): config_vocab = json.loads(config_path.read_text(encoding="utf-8")).get("vocab_size") if config_vocab: print(f"embedding rows (config): {config_vocab:,} (vocab_size in config.json; " f"{config_vocab - len(tokeniser):,} rows no token can select)") print(f"model max length: {tokeniser.model_max_length:,}")
specials = [] for name in ("bos_token", "eos_token", "pad_token", "unk_token"): token = getattr(tokeniser, name, None) token_id = getattr(tokeniser, f"{name}_id", None) print(f" {name:10s} {token!r:18s} id {token_id}") specials.append({"name": name, "token": token, "id": token_id}) print(" chat control tokens in this vocabulary:") controls = [] for token in CONTROL_TOKENS: token_id = tokeniser.convert_tokens_to_ids(token) if token_id is not None and token_id != tokeniser.unk_token_id: print(f" {token!r:16s} id {token_id}") controls.append({"token": token, "id": token_id})
print("\n=== the four strings you predicted in the tokens lesson ===") results = [describe(tokeniser, label, text, guess) for (label, text), guess in zip(PREDICTIONS, guesses)] print("\n=== four kinds of text ===") results += [describe(tokeniser, label, text) for label, text in SAMPLES] results += [describe(tokeniser, f"custom-{i + 1}", text) for i, text in enumerate(args.text)]
print("\nsummary") print(f" {'sample':12s} {'tokens':>7s} {'guess':>6s} {'words':>7s} {'bytes':>7s} {'bytes/token':>12s} {'round-trip':>11s}") for r in results: per_token = r["utf8_bytes"] / max(r["tokens"], 1) guess = str(r["guess"]) if r["guess"] is not None else "-" print(f" {r['label']:12s} {r['tokens']:7d} {guess:>6s} {r['words']:7d} {r['utf8_bytes']:7d} " f"{per_token:12.2f} {str(r['round_trip']):>11s}")
if args.labbook: record = { "lab": "part-02/tokenise-samples", "model": args.model, "tokeniser": type(tokeniser).__name__, "base_vocab_size": tokeniser.vocab_size, "entries_with_added_tokens": len(tokeniser), "special_tokens": specials, "control_tokens": controls, "samples": results, } with Path(args.labbook).expanduser().open("a", encoding="utf-8") as fh: fh.write(json.dumps(record, ensure_ascii=False) + "\n") print(f"\nrecorded in {args.labbook}")
if __name__ == "__main__": main()The tokens lesson asked you to predict four token counts; the script prints those four strings
first, with your guesses beside the answers if you pass them as --guess in the lesson’s order:
Hello, world!, its Portuguese equivalent, the function signature, the two emoji. Write your
four numbers down, then run:
RunnableAll tracks
python tokenise-samples.py --model ~/llm-course/models/qwen3-1.7b --guess 4,6,8,2 --labbook labbook.mdReplace 4,6,8,2 with your own guesses. Every id and piece below is exact for your download: the
Qwen3-1.7B tokenizer.json is byte-identical to the one the author’s dry run used (same
SHA-256), and the base repository’s smaller file produced the same ids for every string on this
page. The 4 MB is serialisation: both files hold the same 151,643-entry vocabulary and the same
151,387 merges, but the instruct file writes each merge as a two-string list where the base file
writes one "a b" string, pretty-printed over more lines (757,479 newlines against 303,281). The
base file registers 22 added tokens against the instruct file’s 26, and tokenizer_config.json
supplies the missing four (<tool_response>, </tool_response>, <think>, </think>) when
Transformers loads it, so len(tokeniser) is 151,669 and <think> is id 151667 in both. Checked
on the 0.6B pair, whose two files are byte-identical to the 1.7B pair’s.
Output — what you should see
tokeniser class: Qwen2Tokenizerbase vocabulary (BPE): 151,643 (tokeniser.vocab_size)entries incl. added tokens: 151,669 (len(tokeniser))embedding rows (config): 151,936 (vocab_size in config.json; 267 rows no token can select)model max length: 131,072 bos_token None id None eos_token '<|im_end|>' id 151645 pad_token '<|endoftext|>' id 151643 unk_token None id None chat control tokens in this vocabulary: '<|endoftext|>' id 151643 '<|im_start|>' id 151644 '<|im_end|>' id 151645 '<think>' id 151667 '</think>' id 151668
=== the four strings you predicted in the tokens lesson ===
[hello] 'Hello, world!' tokens 4 words 2 characters 13 utf-8 bytes 13 your guess 4 ids [9707, 11, 1879, 0] pieces ['Hello', ',', 'Ġworld', '!'] tokens per word 2.00 bytes per token 3.25 decode round-trip identical: True
[hello-pt] 'Olá, mundo!' tokens 5 words 2 characters 11 utf-8 bytes 12 your guess 6 ids [42719, 1953, 11, 28352, 0] pieces ['Ol', 'á', ',', 'Ġmundo', '!'] pieces decoded one by one ['Ol', 'á', ',', ' mundo', '!'] ...
[signature] 'def get_user_by_id(user_id: int):' tokens 10 words 3 characters 33 utf-8 bytes 33 your guess 8 ids [750, 633, 3317, 3710, 842, 4277, 842, 25, 526, 1648] pieces ['def', 'Ġget', '_user', '_by', '_id', '(user', '_id', ':', 'Ġint', '):'] ...
[flag-rocket] '🇧🇷🚀' tokens 3 words 1 characters 3 utf-8 bytes 12 your guess 2 ids [145340, 145070, 145836] pieces ['ðŁĩ§', 'ðŁĩ·', 'ðŁļĢ'] pieces decoded one by one ['🇧', '🇷', '🚀'] ...
=== four kinds of text ===
[english] 'The quick brown fox jumps over the lazy dog near the river bank.' tokens 14 words 13 characters 64 utf-8 bytes 64 pieces ['The', 'Ġquick', 'Ġbrown', 'Ġfox', 'Ġjumps', 'Ġover', 'Ġthe', 'Ġlazy', 'Ġdog', 'Ġnear', 'Ġthe', 'Ġriver', 'Ġbank', '.'] ...[portuguese] 'A rápida raposa castanha salta sobre o cão preguiçoso perto da margem do rio.' tokens 25 words 14 characters 77 utf-8 bytes 80 pieces ['A', 'Ġrápida', 'Ġrap', 'osa', 'Ġcast', 'an', 'ha', 'Ġsal', 'ta', 'Ġsobre', 'Ġo', 'Ġc', 'ão', 'Ġpre', 'gui', 'ç', 'oso', 'Ġp', 'erto', 'Ġda', 'Ġmarg', 'em', 'Ġdo', 'Ġrio', '.'] ...[code] 'def get_user_by_id(user_id: int) -> User | None:\n return session.get(User, user_id)\n' tokens 24 words 10 characters 87 utf-8 bytes 87 pieces ['def', 'Ġget', '_user', '_by', '_id', '(user', '_id', ':', 'Ġint', ')', 'Ġ->', 'ĠUser', 'Ġ|', 'ĠNone', ':Ċ', 'ĠĠĠ', 'Ġreturn', 'Ġsession', '.get', '(User', ',', 'Ġuser', '_id', ')Ċ'] ...[emoji] '🇧🇷 🚀 ✨ 🧮' tokens 10 words 4 characters 8 utf-8 bytes 22 pieces ['ðŁĩ§', 'ðŁĩ·', 'ĠðŁ', 'ļ', 'Ģ', 'Ġâľ', '¨', 'ĠðŁ', '§', '®'] ...
summary sample tokens guess words bytes bytes/token round-trip hello 4 4 2 13 3.25 True hello-pt 5 6 2 12 2.40 True signature 10 8 3 33 3.30 True flag-rocket 3 2 1 12 4.00 True english 14 - 13 64 4.57 True portuguese 25 - 14 80 3.20 True code 24 - 10 87 3.62 True emoji 10 - 4 22 2.20 True
recorded in labbook.mdSix things to read out of this, five of which the tokens lesson predicted.
Three vocabulary sizes, all right. The merge process produced 151,643 entries; 26 added
special tokens make 151,669; and config.json declares 151,936 embedding rows, 1,187 × 128, a
width the matrix kernels like, so 267 rows are parameters no input can select. The parameters
lesson counted the third figure, correctly, because that is the tensor’s shape.
Ġ is a space and Ċ a newline. The tokeniser is byte-level: it maps each of the 256 byte
values to a printable character and runs BPE over those, so Ġworld and world are different
tokens with different rows in the matrix you read in task 4. SentencePiece’s ▁ is the same idea
in another family.
Non-ASCII text shows as its bytes. á is C3 A1 in UTF-8 and shows as á, hence
'Ġrápida'; a flag emoji, four bytes per regional-indicator symbol, shows as 'ðŁĩ§'. The
“decoded one by one” line proves each piece holds the text it seems to; a piece holding part of a
multi-byte character decodes alone to a replacement character, and the sequence still round-trips.
Portuguese costs 25 tokens where English costs 14 for the same sentence: rápida,
castanha, preguiçoso and perto did not survive the merge process as whole words, so the
same meaning is 79 percent more expensive in context window and, later, in KV cache. Compare
languages by bytes per token, 3.20 against 4.57, rather than by raw counts.
Code is spiky. get_user_by_id is four tokens and the signature ten; return and session
are one each; four spaces of indentation are one token, ĠĠĠ plus the space carried by
Ġreturn. Task 6’s second prompt shows 1969 split into four single-digit tokens, the
mechanical reason the tokens lesson gave for fragile arithmetic.
The round trip is lossless, True on every line, which is why an engine can stream tokens
and reassemble the text without a table of exceptions. The control tokens at the top are ordinary
entries in the same table with rows in the same matrix; task 7 turns them into a conversation,
and the two eos_token_id values in the two config.json files, 151643 <|endoftext|> for the
base and 151645 <|im_end|> for the instruct, are two of them.
Record in the notebook: the script’s JSON line holds every count; add the gap between your four guesses and the four answers, because that gap is the measurement.
6. One forward pass: probabilities and attention
Section titled “6. One forward pass: probabilities and attention”RunnableAll tracks
"""Run one forward pass: print the next-token distribution and one head's attention.
Purpose: show the two things the lessons describe but cannot show on a page - the probability the model assigns to every token in its vocabulary for the position after the prompt (with the effect of temperature on that distribution), and the attention weights of one head in one layer, checked for the causal mask.Platform: all (cuda on Tracks S and N, ROCm on Track X, mps on Track M, or the CPU; the device is chosen automatically and printed)Minimum memory: 8 GB (about 3.4 GB of weights for Qwen3-1.7B in BF16)Assumes: a model directory downloaded with `hf download ... --local-dir <dir>`, and torch, transformers, safetensors and accelerate installed in the course environment from Part 1.
Usage: python next-token-and-attention.py --model ~/llm-course/models/qwen3-1.7b [--prompt "The capital of France is"] [--layer -1] [--head 0] [--top 10] [--dtype auto|float32|bfloat16|float16] [--labbook labbook.md]"""import argparseimport jsonfrom pathlib import Path
import torchfrom transformers import AutoModelForCausalLM, AutoTokenizer
TEMPERATURES = (0.5, 1.0, 1.5)
def pick_device() -> torch.device: if torch.cuda.is_available(): return torch.device("cuda") mps = getattr(torch.backends, "mps", None) if mps is not None and mps.is_available(): return torch.device("mps") return torch.device("cpu")
def main() -> None: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--model", required=True, help="model directory or Hub id") parser.add_argument("--prompt", default="The capital of France is") parser.add_argument("--layer", type=int, default=-1, help="layer index; -1 is the last") parser.add_argument("--head", type=int, default=0, help="attention head index within that layer") parser.add_argument("--top", type=int, default=10, help="how many next tokens to print") parser.add_argument("--dtype", default="auto", choices=["auto", "float32", "bfloat16", "float16"]) parser.add_argument("--max-print", type=int, default=24, help="print the full matrix only up to this many tokens") parser.add_argument("--labbook", default=None, help="append one JSON line per run to this file") args = parser.parse_args()
model_dir = Path(args.model).expanduser() if model_dir.is_dir() and not (model_dir / "config.json").exists(): raise SystemExit(f"{model_dir} has no config.json; the download did not finish, or the path is wrong")
device = pick_device() if args.dtype == "auto": # bfloat16 is the precision the checkpoint was saved in and halves the weight # memory, which is what keeps this lab inside the 8 GB tier. If an operator is # missing for bfloat16 on your CPU, rerun with --dtype float32 and twice the memory. dtype = torch.bfloat16 else: dtype = getattr(torch, args.dtype) print(f"device: {device} dtype: {dtype} torch {torch.__version__}")
tokeniser = AutoTokenizer.from_pretrained(args.model) # The eager implementation computes the attention matrix explicitly, which is what # output_attentions=True returns; the optimised backends never build it. model = AutoModelForCausalLM.from_pretrained( args.model, dtype=dtype, attn_implementation="eager", ).to(device) model.eval()
config = model.config groups = config.num_attention_heads // config.num_key_value_heads print(f"layers: {config.num_hidden_layers} query heads: {config.num_attention_heads} " f"key-value heads: {config.num_key_value_heads} head_dim: {config.head_dim} " f"vocab: {config.vocab_size:,}") print(f"weights once loaded: {sum(p.numel() for p in model.parameters()):,} parameters")
inputs = tokeniser(args.prompt, return_tensors="pt").to(device) ids = inputs["input_ids"][0].tolist() pieces = tokeniser.convert_ids_to_tokens(ids) print(f"\nprompt: {args.prompt!r}") print(f"tokens ({len(ids)}): {pieces}") print(f"ids: {ids}")
with torch.no_grad(): out = model(**inputs, output_attentions=True)
# ---- the next-token distribution ------------------------------------------------- print(f"\nlogits: tensor shape {tuple(out.logits.shape)} = (batch, prompt tokens, vocabulary)") logits = out.logits[0, -1].float() probs = torch.softmax(logits, dim=-1) top = torch.topk(probs, args.top) print(f"\ntop {args.top} next tokens after {pieces[-1]!r} (softmax of the last row, temperature 1):") print(f" {'rank':>4s} {'id':>8s} {'logit':>9s} {'probability':>12s} token") top_records = [] for rank, (p, token_id) in enumerate(zip(top.values.tolist(), top.indices.tolist()), start=1): token = tokeniser.convert_ids_to_tokens(token_id) print(f" {rank:4d} {token_id:8d} {float(logits[token_id]):9.3f} {p:12.6f} {token!r}") top_records.append({"rank": rank, "id": token_id, "token": token, "logit": round(float(logits[token_id]), 3), "probability": round(p, 6)}) covered = float(top.values.sum()) print(f" the other {config.vocab_size - args.top:,} tokens share {1 - covered:.6f} of the probability")
# The same logits under three temperatures: softmax(logits / T). print(f"\ntemperature on the same logits (probability of the top token, and of the top {args.top} together):") temperature_records = [] for temp in TEMPERATURES: p_t = torch.softmax(logits / temp, dim=-1) top_t = torch.topk(p_t, args.top) entropy = float(-(p_t * torch.log(p_t.clamp_min(1e-12))).sum()) print(f" T = {temp:3.1f} top-1 {top_t.values[0].item():.4f} top-{args.top} {top_t.values.sum().item():.4f}" f" entropy {entropy:5.2f} nats") temperature_records.append({"temperature": temp, "top1": round(top_t.values[0].item(), 4), "topk": round(top_t.values.sum().item(), 4), "entropy_nats": round(entropy, 2)})
# ---- one head's attention -------------------------------------------------------- layer_index = args.layer if args.layer >= 0 else len(out.attentions) + args.layer if not 0 <= layer_index < len(out.attentions): raise SystemExit(f"--layer {args.layer} is outside 0..{len(out.attentions) - 1}") heads = out.attentions[layer_index].shape[1] if not 0 <= args.head < heads: raise SystemExit(f"--head {args.head} is outside 0..{heads - 1}")
print(f"\nattentions: {len(out.attentions)} tensors (one per layer), each of shape " f"{tuple(out.attentions[layer_index].shape)} = (batch, heads, query position, key position)") attention = out.attentions[layer_index][0, args.head].float() print(f"layer {layer_index}, query head {args.head} (it reads key-value head {args.head // groups}, " f"shared by {groups} query heads): tensor shape {tuple(attention.shape)}")
row_sums = attention.sum(dim=-1) above = attention.triu(diagonal=1) print(f" row sums: min {row_sums.min().item():.4f} max {row_sums.max().item():.4f} " f"(one, within the rounding of {dtype})") print(f" largest entry above the diagonal: {above.max().item():.6f} (the causal mask; must be exactly 0)")
last_row = attention[-1] order = torch.argsort(last_row, descending=True) print(f"\n what the last token {pieces[-1]!r} attended to:") attended = [] for pos in order[: min(5, len(ids))].tolist(): weight = float(last_row[pos]) print(f" position {pos:3d} weight {weight:7.4f} {pieces[pos]!r}") attended.append({"position": pos, "weight": round(weight, 4), "token": pieces[pos]})
if len(ids) <= args.max_print: print("\n full matrix, rounded to two decimals (rows: query position; columns: key position):") header = " " + " ".join(f"{i:5d}" for i in range(len(ids))) print(header) for i in range(len(ids)): row = " ".join(f"{float(attention[i, j]):5.2f}" for j in range(len(ids))) print(f" {i:3d} {row}") else: print(f"\n (matrix not printed: {len(ids)} tokens is more than --max-print {args.max_print})")
if args.labbook: record = { "lab": "part-02/next-token-and-attention", "model": args.model, "device": str(device), "dtype": str(dtype), "torch": torch.__version__, "prompt": args.prompt, "prompt_tokens": len(ids), "logits_shape": list(out.logits.shape), "top_tokens": top_records, "temperature": temperature_records, "layer": layer_index, "head": args.head, "kv_head": args.head // groups, "attention_shape": list(attention.shape), "row_sum_min": round(row_sums.min().item(), 4), "row_sum_max": round(row_sums.max().item(), 4), "max_above_diagonal": above.max().item(), "last_token_attended": attended, } with Path(args.labbook).expanduser().open("a", encoding="utf-8") as fh: fh.write(json.dumps(record, ensure_ascii=False) + "\n") print(f"\nrecorded in {args.labbook}")
if __name__ == "__main__": main()RunnableAll tracks
python next-token-and-attention.py \ --model ~/llm-course/models/qwen3-1.7b \ --prompt "The capital of France is" \ --layer -1 --head 0 --top 10 \ --labbook labbook.mdThe shapes, ids and zeroes below are exact for Qwen3-1.7B; the logits, probabilities and attention weights are the author’s dry run on Qwen3-0.6B on a CPU in BF16 (2026-09-12), so that you know what a plausible run looks like; yours will differ in every one of those digits.
Output — what you should see
device: cuda dtype: torch.bfloat16 torch 2.x.xlayers: 28 query heads: 16 key-value heads: 8 head_dim: 128 vocab: 151,936weights once loaded: 1,720,574,976 parameters
prompt: 'The capital of France is'tokens (5): ['The', 'Ġcapital', 'Ġof', 'ĠFrance', 'Ġis']ids: [785, 6722, 315, 9625, 374]
logits: tensor shape (1, 5, 151936) = (batch, prompt tokens, vocabulary)
top 10 next tokens after 'Ġis' (softmax of the last row, temperature 1): rank id logit probability token 1 12095 17.500 0.659651 'ĠParis' 2 7407 14.312 0.027227 'Ġlocated' 3 279 14.062 0.021204 'Ġthe' 4 30743 13.750 0.015514 'Ġ____' ... the other 151,926 tokens share 0.205525 of the probability
temperature on the same logits (probability of the top token, and of the top 10 together): T = 0.5 top-1 0.9933 top-10 0.9986 entropy 0.06 nats T = 1.0 top-1 0.6597 top-10 0.7945 entropy 2.29 nats T = 1.5 top-1 0.1563 top-10 0.2675 entropy 7.10 nats
attentions: 28 tensors (one per layer), each of shape (1, 16, 5, 5) = (batch, heads, query position, key position)layer 27, query head 0 (it reads key-value head 0, shared by 2 query heads): tensor shape (5, 5) row sums: min 0.9986 max 1.0013 (one, within the rounding of torch.bfloat16) largest entry above the diagonal: 0.000000 (the causal mask; must be exactly 0)
what the last token 'Ġis' attended to: position 0 weight 0.8672 'The' position 4 weight 0.0918 'Ġis' position 3 weight 0.0254 'ĠFrance' ...
full matrix, rounded to two decimals (rows: query position; columns: key position): 0 1 2 3 4 0 1.00 0.00 0.00 0.00 0.00 1 0.98 0.02 0.00 0.00 0.00 2 0.96 0.02 0.02 0.00 0.00 3 0.97 0.00 0.01 0.02 0.00 4 0.87 0.00 0.01 0.03 0.09
recorded in labbook.mdA logit for every vocabulary entry at every position. The Transformers documentation gives
logits the shape (batch_size, sequence_length, config.vocab_size), “scores for each vocabulary
token before SoftMax”: (1, 5, 151936) here, of which the script uses the last row. Those scores
are the last hidden state, width 2048, multiplied by the embedding matrix you listed in task 4,
because the head is tied; the other four rows are the predictions for positions 2 to 5, computed
in the same pass, which is what makes next-token prediction a training signal at every position
at once.
Softmax turns scores into a distribution, and temperature divides the scores. With z the
logits and T the temperature:
p(token i) = exp(z_i / T) / sum over all j of exp(z_j / T)At T = 1 the top token took 0.66 of the probability in the dry run and the other 151,926 entries shared 0.21: even with one obvious answer, the output is a distribution, not an answer. Dividing every logit by 0.5 doubles the gaps and the top share went to 0.99; dividing by 1.5 shrinks them and it fell to 0.16 while the entropy rose from 2.29 to 7.10 nats. That is all temperature is; the next-token lesson’s sampling rules decide which entries to keep before drawing, and the model card’s recommended 0.6 in thinking mode and 0.7 otherwise is this divisor.
The model was loaded with eager attention. The script passes attn_implementation="eager"
because, as the attention-backends documentation states, “basic attention scales poorly because
it materializes the full attention matrix in memory” while “optimized implementations rearrange
the math to reduce memory traffic”: there is no matrix for them to hand back. Eager builds it,
which is fine for five tokens.
The attention tensor is square and triangular, and the script checks it. The documentation
gives attentions as one tensor per layer of shape
(batch_size, num_heads, sequence_length, sequence_length), “attentions weights after the
attention softmax”: 28 tensors of (1, 16, 5, 5). Query head 0 reads key-value head 0, because 8
key-value heads serve 16 query heads two each, the grouped-query arrangement the attention lesson
described and the KV-cache arithmetic later in the course depends on. Every row is one softmax
and sums to one within BF16 rounding, and the largest entry above the diagonal is exactly zero:
the mask sets those scores to minus infinity before the softmax, and exp(−∞) is zero, not small.
Now a longer prompt, a different head and a different layer:
RunnableAll tracks
python next-token-and-attention.py \ --model ~/llm-course/models/qwen3-1.7b \ --prompt "In 1969 the first crewed mission landed on the Moon, and the commander was" \ --layer 0 --head 3 --top 5 \ --labbook labbook.mdOutput — what you should see
prompt: 'In 1969 the first crewed mission landed on the Moon, and the commander was'tokens (20): ['In', 'Ġ', '1', '9', '6', '9', 'Ġthe', 'Ġfirst', 'Ġcrew', 'ed', 'Ġmission', 'Ġlanded', 'Ġon', 'Ġthe', 'ĠMoon', ',', 'Ġand', 'Ġthe', 'Ġcommander', 'Ġwas']ids: [641, 220, 16, 24, 21, 24, 279, 1156, 13627, 291, 8954, 26120, 389, 279, 17330, 11, 323, 279, 27994, 572]
logits: tensor shape (1, 20, 151936) = (batch, prompt tokens, vocabulary)...attentions: 28 tensors (one per layer), each of shape (1, 16, 20, 20) = (batch, heads, query position, key position)layer 0, query head 3 (it reads key-value head 1, shared by 2 query heads): tensor shape (20, 20) row sums: min 0.99xx max 1.00xx (one, within the rounding of torch.bfloat16) largest entry above the diagonal: 0.000000 (the causal mask; must be exactly 0)...1969 is five tokens, a space and four digits. The matrix is (20, 20): 400 entries per head per
layer instead of 25, sixteen times the work for four times the tokens, the attention lesson’s
cost argument in numbers. In the dry run this first-layer head put 0.99 of the last token’s
weight on itself where the last-layer head spread its weight over the prompt; early layers often
attend locally, but heads are not interpretable units, and one that seems to “track the subject”
on one prompt does not on the next. What is reliably true is the shape, the zeroes above the
diagonal and the row sums. If the top token is nonsense, check that --model points at a
directory whose listing matched task 2; if either check line is off, see Troubleshooting.
Record in the notebook: the script’s JSON line holds the top tokens, the temperature table and the two checks; add the tensor shapes in words and which key-value head your query head used.
7. Base and instruct, same question
Section titled “7. Base and instruct, same question”RunnableAll tracks
"""Give the same prompt to a base checkpoint and to its instruction-tuned sibling.
Purpose: show that a chat assistant is the same next-token loop with a template around it, by running one question through a base model as raw text and through the instruction-tuned model through its own chat template, printing the template's control tokens, the tokens it adds, and how many generated tokens went into the thinking block and the answer.Platform: all (cuda on Tracks S and N, ROCm on Track X, mps on Track M, or the CPU; the device is chosen automatically and printed)Minimum memory: 8 GB (one checkpoint of about 3.4 GB in BF16 is resident at a time)Assumes: both checkpoints downloaded with `hf download ... --local-dir <dir>`, and torch, transformers and accelerate installed in the course environment from Part 1. The two models are loaded one at a time and released, so only one is resident.
Usage: python base-vs-instruct.py --base ~/llm-course/models/qwen3-1.7b-base --instruct ~/llm-course/models/qwen3-1.7b [--prompt "..."] [--max-new-tokens 80] [--no-thinking] [--labbook labbook.md]"""import argparseimport gcimport jsonimport timefrom pathlib import Path
import torchfrom transformers import AutoModelForCausalLM, AutoTokenizer
THINK_START = "<think>"THINK_END = "</think>"
def pick_device() -> torch.device: if torch.cuda.is_available(): return torch.device("cuda") mps = getattr(torch.backends, "mps", None) if mps is not None and mps.is_available(): return torch.device("mps") return torch.device("cpu")
def release(model) -> None: """Drop a model and give the memory back before loading the next one.""" del model gc.collect() if torch.cuda.is_available(): torch.cuda.empty_cache()
def check_dir(path: str, label: str) -> None: p = Path(path).expanduser() if p.is_dir() and not (p / "config.json").exists(): raise SystemExit(f"--{label} {p} has no config.json; the download did not finish, or the path is wrong")
def run(path: str, prompt_ids, device: torch.device, dtype, max_new_tokens: int, tokeniser) -> dict: """Load a checkpoint, generate greedily from prepared ids, print and release.""" model = AutoModelForCausalLM.from_pretrained(path, dtype=dtype).to(device) model.eval() started = time.perf_counter() with torch.no_grad(): out = model.generate( **prompt_ids.to(device), do_sample=False, max_new_tokens=max_new_tokens, pad_token_id=tokeniser.pad_token_id or tokeniser.eos_token_id, ) seconds = time.perf_counter() - started prompt_len = prompt_ids["input_ids"].shape[1] generated = out[0, prompt_len:].tolist() stopped_on_eos = len(generated) < max_new_tokens or generated[-1] in {tokeniser.eos_token_id, tokeniser.pad_token_id} release(model) return {"ids": generated, "seconds": seconds, "stopped_on_eos": stopped_on_eos, "text": tokeniser.decode(generated, skip_special_tokens=True)}
def split_thinking(generated: list[int], tokeniser) -> tuple[str, str, int, int, bool]: """Split a Qwen3 reply at the </think> token, as the model card describes.
Returns (thinking, answer, thinking_tokens, answer_tokens, closed). When the reply opened a <think> block that never closed, the whole reply is thinking and closed is False: the budget ran out inside the block. """ think_start_id = tokeniser.convert_tokens_to_ids(THINK_START) think_end_id = tokeniser.convert_tokens_to_ids(THINK_END) if think_end_id is not None and think_end_id in generated: cut = len(generated) - generated[::-1].index(think_end_id) thinking = tokeniser.decode(generated[:cut], skip_special_tokens=True).strip("\n") answer = tokeniser.decode(generated[cut:], skip_special_tokens=True).strip("\n") return thinking, answer, cut, len(generated) - cut, True if think_start_id is not None and think_start_id in generated: thinking = tokeniser.decode(generated, skip_special_tokens=True).strip("\n") return thinking, "", len(generated), 0, False return "", tokeniser.decode(generated, skip_special_tokens=True).strip("\n"), 0, len(generated), True
def main() -> None: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--base", required=True, help="base checkpoint directory or Hub id") parser.add_argument("--instruct", required=True, help="instruction-tuned checkpoint directory or Hub id") parser.add_argument("--prompt", default="What is the capital of Brazil?") parser.add_argument("--max-new-tokens", type=int, default=80) parser.add_argument("--no-thinking", action="store_true", help="pass enable_thinking=False to the chat template (a Qwen3 template option)") parser.add_argument("--dtype", default="auto", choices=["auto", "float32", "bfloat16", "float16"]) parser.add_argument("--labbook", default=None, help="append one JSON line per run to this file") args = parser.parse_args() check_dir(args.base, "base") check_dir(args.instruct, "instruct")
device = pick_device() # bfloat16 is how these checkpoints were saved; --dtype float32 needs twice the memory. dtype = torch.bfloat16 if args.dtype == "auto" else getattr(torch, args.dtype) print(f"device: {device} dtype: {dtype} torch {torch.__version__} greedy decoding (do_sample=False)")
# ---- the base model sees the prompt as ordinary text ----------------------------- base_tokeniser = AutoTokenizer.from_pretrained(args.base) base_inputs = base_tokeniser(args.prompt, return_tensors="pt") base_len = int(base_inputs["input_ids"].shape[1]) print(f"\n=== base: {args.base}") print(f"input as sent : {args.prompt!r}") print(f"input tokens : {base_len} {base_tokeniser.convert_ids_to_tokens(base_inputs['input_ids'][0].tolist())}") base = run(args.base, base_inputs, device, dtype, args.max_new_tokens, base_tokeniser) print(f"generated : {len(base['ids'])} tokens in {base['seconds']:.1f} s; stopped on end-of-text: {base['stopped_on_eos']}") print(f"continuation : {base['text']!r}")
# ---- the instruction-tuned model gets the same question through its template ------ chat_tokeniser = AutoTokenizer.from_pretrained(args.instruct) messages = [{"role": "user", "content": args.prompt}] template_kwargs = {"add_generation_prompt": True} if args.no_thinking: template_kwargs["enable_thinking"] = False rendered = chat_tokeniser.apply_chat_template(messages, tokenize=False, **template_kwargs) chat_inputs = chat_tokeniser.apply_chat_template( messages, tokenize=True, return_dict=True, return_tensors="pt", **template_kwargs, ) chat_ids = chat_inputs["input_ids"][0].tolist() print(f"\n=== instruct: {args.instruct}") print(f"input as sent : the template's own string, control tokens included" f"{' (enable_thinking=False)' if args.no_thinking else ''}") print("---") print(rendered, end="") print("---") print(f"input tokens : {len(chat_ids)} {chat_tokeniser.convert_ids_to_tokens(chat_ids)}") print(f"template adds : {len(chat_ids) - base_len} tokens around the {base_len} of the question") chat = run(args.instruct, chat_inputs, device, dtype, args.max_new_tokens, chat_tokeniser) thinking, answer, n_think, n_answer, closed = split_thinking(chat["ids"], chat_tokeniser) print(f"generated : {len(chat['ids'])} tokens in {chat['seconds']:.1f} s; stopped on end-of-turn: {chat['stopped_on_eos']}") print(f"thinking block : {n_think} tokens{'' if closed else ' (never closed: the budget ran out inside it)'} {thinking!r}") print(f"answer : {n_answer} tokens {answer!r}")
if args.labbook: record = { "lab": "part-02/base-vs-instruct", "base": args.base, "instruct": args.instruct, "device": str(device), "dtype": str(dtype), "torch": torch.__version__, "prompt": args.prompt, "max_new_tokens": args.max_new_tokens, "enable_thinking": not args.no_thinking, "base_input_tokens": base_len, "chat_input_tokens": len(chat_ids), "template_overhead_tokens": len(chat_ids) - base_len, "base_generated_tokens": len(base["ids"]), "base_seconds": round(base["seconds"], 1), "base_completion": base["text"], "chat_generated_tokens": len(chat["ids"]), "chat_seconds": round(chat["seconds"], 1), "chat_thinking_tokens": n_think, "chat_thinking_closed": closed, "chat_answer_tokens": n_answer, "chat_thinking": thinking, "chat_answer": answer, } with Path(args.labbook).expanduser().open("a", encoding="utf-8") as fh: fh.write(json.dumps(record, ensure_ascii=False) + "\n") print(f"\nrecorded in {args.labbook}")
if __name__ == "__main__": main()The script sends one question to the base checkpoint as plain text and to the instruct checkpoint through its chat template.
RunnableAll tracks
python base-vs-instruct.py \ --base ~/llm-course/models/qwen3-1.7b-base \ --instruct ~/llm-course/models/qwen3-1.7b \ --prompt "What is the capital of Brazil?" \ --max-new-tokens 80 \ --labbook labbook.mdThe token counts and the rendered template below are exact for Qwen3-1.7B; the generated texts and the seconds are the author’s 0.6B dry run on a CPU (2026-09-12), and yours will differ in wording, though almost certainly not in kind.
Output — what you should see
device: cuda dtype: torch.bfloat16 torch 2.x.x greedy decoding (do_sample=False)
=== base: /home/you/llm-course/models/qwen3-1.7b-baseinput as sent : 'What is the capital of Brazil?'input tokens : 7 ['What', 'Ġis', 'Ġthe', 'Ġcapital', 'Ġof', 'ĠBrazil', '?']generated : 80 tokens in xx.x s; stopped on end-of-text: Falsecontinuation : ' What is the capital of the United States? What is the capital of France? What is the capital of Germany? ...'
=== instruct: /home/you/llm-course/models/qwen3-1.7binput as sent : the template's own string, control tokens included---<|im_start|>userWhat is the capital of Brazil?<|im_end|><|im_start|>assistant---input tokens : 15 ['<|im_start|>', 'user', 'Ċ', 'What', 'Ġis', 'Ġthe', 'Ġcapital', 'Ġof', 'ĠBrazil', '?', '<|im_end|>', 'Ċ', '<|im_start|>', 'assistant', 'Ċ']template adds : 8 tokens around the 7 of the questiongenerated : 80 tokens in xx.x s; stopped on end-of-turn: Falsethinking block : 80 tokens (never closed: the budget ran out inside it) "<think>\nOkay, the user is asking for the capital of Brazil. Let me think. ..."answer : 0 tokens ''
recorded in labbook.mdThe base model continued the text. Given seven tokens that look like a quiz question, the likeliest continuation under greedy decoding was another quiz question, then another; in the dry run it produced eleven and never emitted its end-of-text token. That is the pretraining objective doing what it was trained to do, and the chat-template documentation says it in one sentence: “All causal LMs, whether chat-trained or not, continue a sequence of tokens.”
The instruct model received a different input. Between the --- lines is the string the
model saw: the question wrapped in <|im_start|>user and <|im_end|>, then
<|im_start|>assistant and a newline, which the documentation’s add_generation_prompt argument
appends so that the model “will correctly write a response” instead of continuing the user’s
message. Those eight tokens are the template’s overhead on every request; with
You are a helpful assistant. as a system message the same question is 26 tokens, and Part 9
measures what each costs at prefill.
Post-training made a reply the likely continuation of that pattern. Same architecture, same
tokeniser, same 1,409,410,048 non-embedding parameters; different values in them, because the
model was trained further on text in which <|im_start|>assistant is followed by an answer, and
in Qwen3’s case by a <think> block first: the card describes thinking mode as on by default,
and in the dry run all eighty tokens went into the block. The script splits the reply at the
</think> token, id 151668, as the card’s quickstart does, and reports whether the block closed;
eighty tokens is a budget, not a verdict.
Now with thinking off. The card documents enable_thinking=False as an argument to
apply_chat_template, and the script passes it for you:
RunnableAll tracks
python base-vs-instruct.py \ --base ~/llm-course/models/qwen3-1.7b-base \ --instruct ~/llm-course/models/qwen3-1.7b \ --prompt "What is the capital of Brazil?" \ --no-thinking --max-new-tokens 80 \ --labbook labbook.mdOutput — what you should see
=== instruct: /home/you/llm-course/models/qwen3-1.7binput as sent : the template's own string, control tokens included (enable_thinking=False)---<|im_start|>userWhat is the capital of Brazil?<|im_end|><|im_start|>assistant<think>
</think>
---input tokens : 19 ['<|im_start|>', 'user', 'Ċ', 'What', 'Ġis', 'Ġthe', 'Ġcapital', 'Ġof', 'ĠBrazil', '?', '<|im_end|>', 'Ċ', '<|im_start|>', 'assistant', 'Ċ', '<think>', 'ĊĊ', '</think>', 'ĊĊ']template adds : 12 tokens around the 7 of the questiongenerated : x tokens in x.x s; stopped on end-of-turn: Truethinking block : 0 tokens ''answer : x tokens 'The capital of Brazil is Brasília.'The switch is in the prompt: the template writes an empty thinking block, <think>, two
newlines, </think>, two newlines, four more tokens, and the model continues past a closed block
with the answer. Nothing in the weights changed. The soft switches the card describes, /think
and /no_think typed into a user message, are the same idea;
Part 3’s post-training lesson
explains what the block is for, and
Part 10’s lesson on prompting, chat templates and thinking modes
owns the switches.
Then thinking on again, with a budget the block can finish in:
RunnableAll tracks
python base-vs-instruct.py \ --base ~/llm-course/models/qwen3-1.7b-base \ --instruct ~/llm-course/models/qwen3-1.7b \ --prompt "What is the capital of Brazil?" \ --max-new-tokens 300 \ --labbook labbook.mdOutput — what you should see
...generated : xxx tokens in xx.x s; stopped on end-of-turn: Truethinking block : xxx tokens "<think>\nOkay, the user is asking for the capital of Brazil. ... \n</think>"answer : xx tokens 'The capital of Brazil is Brasília.'Record how many tokens the block took to close: that number, the reasoning budget, is what every later part that touches reasoning models has to plan for. If it still reports “never closed”, raise the budget again rather than concluding anything about the answer.
Record in the notebook: the script’s JSON lines hold both inputs’ token counts, the overhead, the generated counts and the texts; add the number of tokens the thinking block took to close.
8. Track M only: the same generation in MLX
Section titled “8. Track M only: the same generation in MLX”MLX is Apple’s array framework and the native path on Apple silicon; Parts 8 and 13 lean on it.
Its README gives the Python API as load and generate from mlx_lm and a command-line entry
point, mlx_lm.generate, whose --model option its source describes as “the path to the local
model directory or Hugging Face repo”. Point it at the directory from task 2:
RunnableTrack M · Apple silicon
mlx_lm.generate --model ~/llm-course/models/qwen3-1.7b --prompt "What is the capital of Brazil?" --max-tokens 300Output — what you should see
==========<think>Okay, the user is asking for the capital of Brazil. ...</think>
The capital of Brazil is Brasília.==========Prompt: 15 tokens, x.xxx tokens-per-secGeneration: xxx tokens, x.xxx tokens-per-secPeak memory: x.xxx GBFifteen prompt tokens: the same template, because mlx_lm.generate applies the tokeniser’s chat
template by default whenever one exists and passes any JSON given to --chat-template-config
through to it, which is how thinking is switched off here:
RunnableTrack M · Apple silicon
mlx_lm.generate --model ~/llm-course/models/qwen3-1.7b --prompt "What is the capital of Brazil?" --max-tokens 300 --chat-template-config '{"enable_thinking": false}'Output — what you should see
==========The capital of Brazil is Brasília.==========Prompt: 19 tokens, x.xxx tokens-per-secGeneration: x tokens, x.xxx tokens-per-secPeak memory: x.xxx GBNineteen tokens, the four of the empty block added, as in task 7. The base model needs one more flag, because its repository also ships a chat template that MLX would otherwise apply:
RunnableTrack M · Apple silicon
mlx_lm.generate --model ~/llm-course/models/qwen3-1.7b-base --prompt "What is the capital of Brazil?" --max-tokens 60 --ignore-chat-templateOutput — what you should see
==========What is the capital of the United States? What is the capital of France? ...==========Prompt: 7 tokens, x.xxx tokens-per-secGeneration: 60 tokens, x.xxx tokens-per-secPeak memory: x.xxx GBSeven tokens, and the same kind of continuation. The three command shapes were exercised with
mlx-lm 0.31.3 on its CPU-only build against the 0.6B directories; the Metal path is what the
validation pass will record. Record the three Generation: lines next to the Transformers ones;
Part 8 makes the comparison properly.
Reconcile the artefacts before interpreting model behaviour
Section titled “Reconcile the artefacts before interpreting model behaviour”Complete the configuration and tensor inspection before the generation comparison. This lets you separate a mistaken model identity from a behavioural difference between base and instruct models.
- Record the downloaded repository and revision for each checkpoint. Confirm every shard named by the index exists before counting tensors.
- Compare the tensor shapes with the configuration. Account for tied embeddings rather than adding the same shared parameter twice to your conceptual parameter count.
- Save token IDs and decoded text for the same input under both tokenisers. If they differ, explain the consequence for a supposedly identical token budget.
- Inspect the actual prompt format for generation. A raw continuation and a templated chat request ask different questions; label both if you run both.
- Retain the next-token probabilities and attention output from one small forward pass. Check that masked future positions do not contribute before interpreting an attention plot.
If the configuration inspection works but weights cannot load, preserve that partial result and the first loader error. Reduce the workload using the lesson’s stated path instead of silently switching checkpoints. Your completed notebook should connect a named artefact, a tensor shape, a tokenisation observation and a generated response, with enough detail to reproduce each one.
Validation
Section titled “Validation”You are done when every row passes.
| Check | Command | Pass means |
|---|---|---|
| Both repositories are complete | ls -A ~/llm-course/models/qwen3-1.7b ~/llm-course/models/qwen3-1.7b-base |
Twelve and ten entries including .gitattributes, plus the .cache directory the CLI adds, with config.json, tokenizer.json and the safetensors sizes from the table at the top |
| The count reconciles | python list-tensors.py --model ~/llm-course/models/qwen3-1.7b --layer 0 |
match: True twice, parameters once loaded: 1,720,574,976, lm_head.weight stored in the file: True |
| The base has no stored head | python list-tensors.py --model ~/llm-course/models/qwen3-1.7b-base |
310 tensors and lm_head.weight stored in the file: False |
| Tokenisation is lossless and matches | python tokenise-samples.py --model ~/llm-course/models/qwen3-1.7b |
True in every round-trip column, 14 tokens for English, 25 for Portuguese |
| The forward pass has the right shapes | task 6’s first command | logits: tensor shape (1, 5, 151936), 28 attention tensors of (1, 16, 5, 5) |
| The causal mask is exact | the same run | largest entry above the diagonal: 0.000000, row sums within 0.01 of one |
| Temperature behaves | the same run | top-1 at T = 0.5 above top-1 at T = 1.0 above top-1 at T = 1.5 |
| The template is what you think | task 7’s first command | template adds : 8 tokens, the rendered string between the --- lines |
| The two checkpoints differ in kind | the same run | The base continues; the instruct replies or thinks; --no-thinking adds 4 tokens and an answer arrives |
| The notebook was written | grep -c '"lab": "part-02' ~/llm-course/labbook.md |
At least 6 (two listings, one tokenisation, two forward passes, one or more comparisons) |
RunnableAll tracks
grep -c '"lab": "part-02' ~/llm-course/labbook.mdExpected outcome
Section titled “Expected outcome”Two model directories you can point any tool at, and at least six recorded runs in the lab notebook whose numbers you derived rather than read, the 311,164,928 that the instruct repository stores twice among them. From here on the course assumes you know what is in a checkpoint, because you have listed one and added it up.
Troubleshooting
Section titled “Troubleshooting”Symptom, cause, fix. Error texts are quoted from the documentation or from the scripts, and say so where a message may differ by version.
| Symptom | Cause | Fix |
|---|---|---|
ModuleNotFoundError: No module named 'transformers' in preflight 1 |
The install ran in another environment, or the environment is not activated | source ~/llm-course/.venv/bin/activate, then your track’s install line; on Track S, the pip install inside the container |
bash: .venv/bin/activate: No such file or directory on Track S |
You are inside the NGC container, which has no uv environment | Skip the line, as the Spark tab says; python and hf are the container’s own |
hf: command not found |
The hf executable comes with huggingface_hub, and the environment is not active |
Activate, then uv pip install huggingface_hub if it is still missing |
Error: Not logged in from hf auth whoami |
You have not run task 1 | Nothing, for this lab; the repositories are public |
Warning: You are sending unauthenticated requests to the HF Hub... |
Same | Same; sign in once if you want the higher rate limits the warning mentions |
The download stops, or httpx.TimeoutException: ... Read timed out. (read timeout=10) |
A slow or interrupted link; the documentation gives the default timeout as 10 | Run the same hf download again; it resumes from the .cache/huggingface/ metadata. On a slow link, export HF_HUB_DOWNLOAD_TIMEOUT=30 first, as the documentation describes |
no *.safetensors files in ...; the download did not finish, or the path is wrong |
The script found the directory but no weights | Compare ls -A with task 2’s listing; rerun the download |
KeyError: 'qwen3' when a script loads the model |
Transformers older than 4.51.0, which the model card names as the minimum | uv pip install --upgrade transformers in the environment, then preflight 1 again |
RuntimeError naming BFloat16 and an unimplemented operator |
The CPU build lacks a BF16 kernel for one operation | Rerun with --dtype float32, which needs about 6.9 GB for the weights, or --dtype float16 on an accelerator that prefers it |
| Out of memory on an 8 GB machine | Two models resident, or another application holding memory | Run one script at a time and keep the default BF16; if base-vs-instruct.py still fails, give the same path to --base and --instruct and compare across two runs, or switch to the 0.6B pair from Requirements |
CUDA out of memory on a small GPU (Track N) |
The 3.44 GB of weights plus the CUDA context do not fit the card | Prefix the command with CUDA_VISIBLE_DEVICES="" to run it on the CPU; every count and shape is the same |
out.attentions is None, or an error names the attention implementation |
The model was loaded without attn_implementation="eager" |
The script sets it; in a notebook of your own, put it back: the optimised backends never build the matrix output_attentions=True returns |
| Row sums far from one, or a non-zero above the diagonal | You are reading a tensor other than the attention weights, or edited the script | Rerun the unmodified script; both check lines come from the tensor the matrix is printed from |
mps is selected but the run is slower than the CPU |
For a model this small, transfer and launch overheads can dominate | Not a fault; note it, and Part 5 measures the same work on both properly |
| The instruct model produces only a thinking block and no answer | The reply ran out of --max-new-tokens inside the block, which the script reports as “never closed” |
Raise --max-new-tokens to 300, or pass --no-thinking |
--guess needs 4 integers separated by commas, e.g. 4,6,8,2 |
The lesson asked for four predictions; the option wants exactly four | Pass four integers, or omit --guess |
Track M: mlx_lm.generate on the base model produces a chat-style reply |
MLX applied the base repository’s chat template | Add --ignore-chat-template, as task 8 shows |
A .cache/huggingface directory appears inside a model directory |
Expected: the CLI’s metadata about the downloaded files | Leave it; it is what makes a rerun of the download cheap |
Cleanup
Section titled “Cleanup”Keep the environment, the notebook and the instruct directory:
Part 4’s lab reads its
config.json from ~/llm-course/models/qwen3-1.7b/, and Qwen3-1.7B returns as the student in
Part 15’s distillation labs. If you need 3.4 GB back now, delete the base checkpoint; task 2’s
command re-creates it.
Task 8 made no second copy: MLX read the same directory. count-from-config.py and
embedding-row.py can stay in ~/llm-course or go; nothing later depends on them.
What you learned
Section titled “What you learned”- A checkpoint is a file format, and you read one without loading it: eight bytes of length, a 35,648-byte JSON header naming 310 tensors with their dtypes and shapes, then 3,441,149,952 bytes of BF16 data; a second shard of 120 bytes of header and one tensor.
- Published parameter counts are arithmetic you can redo: eleven shapes give 50,336,000 per block; 28 blocks, a final norm and a 151,936 × 2,048 embedding give 1,720,574,976, the card’s 1.7B; the repository holds 311,164,928 more because it stores the tied output head, which is why the download is 4.06 GB and the memory 3.44 GB.
- The tokeniser decides what text costs, and one tokeniser serves both checkpoints: four
tokens for
Hello, world!, five in Portuguese, ten for a signature, three for two emoji; 14 against 25 for one sentence in two languages;1969as four digits; a lossless round trip; three vocabulary sizes, each right for its purpose; a 4 MB file-size difference that is serialisation. - The output is 151,936 scores per position, and temperature is a divisor on them: the top token’s share rose at T = 0.5 and collapsed at T = 1.5 on the same scores.
- Attention is an n by n matrix per head per layer, with exact zeroes above the diagonal: 28 tensors of (1, 16, 5, 5), then (1, 16, 20, 20); rows summing to one; query head 0 reading key-value head 0. This is the object whose growth makes long context expensive.
- Chat is a template around next-token prediction: eight control tokens around seven of question, four more to close an empty thinking block, a base model that continued with eleven more questions and an instruct model that replied, on the same architecture and tokeniser, because of what the later training made likely.
Check your understanding
Sources for this lesson
17 verified · checked 2026-09-12
- 01Hugging Face Hub — Command Line Interface (hf), v1.30.0§ hf auth login; hf auth whoami; hf download; Dry-run mode; Download to a local folder; Quiet mode; Download timeouthuggingface.co/docs/huggingface_hub/v1.30.0/guides/cli2026-09-12
- 02huggingface_hub v1.30.0 — cli/_output.py and utils/_detect_agent.py (human and agent output formats)§ OutputFormat; result(); is_agent()github.com/huggingface/huggingface_hub/blob/v1.30.0/src/huggingface_hub/cli/_output.py2026-09-12
- 03safetensors — format specification§ Formatgithub.com/huggingface/safetensors2026-09-08
- 04safetensors — documentation index§ Load tensorshuggingface.co/docs/safetensors/index2026-09-12
- 05Hugging Face Tokenizers — documentation indexhuggingface.co/docs/tokenizers2026-09-08
- 06Hugging Face Transformers — Attention backends§ Set an attention backendhuggingface.co/docs/transformers/main/en/attention_interface2026-09-12
- 07Hugging Face Transformers — Model outputs§ CausalLMOutput; attentions; logitshuggingface.co/docs/transformers/main/en/main_classes/output2026-09-12
- 08Hugging Face Transformers — Chat templates§ Using apply_chat_template; add_generation_prompthuggingface.co/docs/transformers/main/en/chat_templating2026-09-12
- 09Hugging Face Transformers — Generation strategies§ Greedy search; Samplinghuggingface.co/docs/transformers/main/en/generation_strategies2026-09-12
- 10Qwen3-1.7B model card§ Model Overview; Quickstart; Switching Between Thinking and Non-Thinking Mode; Best Practiceshuggingface.co/Qwen/Qwen3-1.7B2026-09-12
- 11Qwen3-1.7B — files and versionshuggingface.co/Qwen/Qwen3-1.7B/tree/main2026-09-12
- 12Qwen3-1.7B — config.jsonhuggingface.co/Qwen/Qwen3-1.7B/blob/main/config.json2026-09-12
- 13Qwen3-1.7B-Base model card§ Model Overviewhuggingface.co/Qwen/Qwen3-1.7B-Base2026-09-12
- 14Qwen3-1.7B-Base — files and versionshuggingface.co/Qwen/Qwen3-1.7B-Base/tree/main2026-09-12
- 15MLX LM — README§ Quick Start; Python API; Command Linegithub.com/ml-explore/mlx-lm2026-09-12
- 16MLX LM — generate.py at v0.31.3§ setup_arg_parser; chat template applicationgithub.com/ml-explore/mlx-lm/blob/v0.31.3/mlx_lm/generate.py2026-09-12
- 17MLX LM — models/qwen3.py and models/base.py at v0.31.3§ Attention.__call__; scaled_dot_product_attentiongithub.com/ml-explore/mlx-lm/blob/v0.31.3/mlx_lm/models/qwen3.py2026-09-12
Every technical claim on this page was checked against the official documentation of the tool, vendor or model publisher on the date shown, at the version pinned for the course. Where the course disagrees with folklore, the source is how you can tell which one to trust.