Week 9
Week 9 — Tokenizers: from raw text to training batches
Key questionHow can we reliably turn “我喜欢AI,AI也喜欢猫。” into usable integer IDs while keeping tokenizer training, encoding and model learning distinct?
Learning objectives
- Compare character, word, UTF-8 byte and subword units in terms of V, L, coverage and computational cost.
- Distinguish code points, UTF-8 bytes, BPE symbols, tokens and token IDs; explain why normalization belongs to the tokenizer protocol.
- Work through two byte-level BPE rounds: pair counts, tie-breaking, merging and length changes.
- Distinguish tokenizer training from encoding and bind tokenizer artifacts to model checkpoints.
- Follow an auditable path: text → tokens → stream [L] → shifted examples [T] → a batch [B,T], with padding only where needed.
- Construct causal, padding and loss masks separately and explain which errors each prevents.
- Before Week 10, stop using the independent w09-readable-v1 ID space and return to the incompatible five-token mini-gpt-v1 main path.
145 min estimated reading time
This week focuses on a reliable data interface, not implementing four production tokenizers. The main model path keeps the five-token vocabulary and course_data.py. Character and BPE examples are separate demonstrations of alternatives. Learn one reliable input path before comparing more complex schemes.
Scroll horizontally to view all columns.
| Study unit | The question we solve |
|---|---|
| 1: A stable text interface | Fix segmentation, ID order, and unknown-token behavior. Encoding again must not randomly renumber the vocabulary. |
| 2: Construct actual data | Build T+1-token windows from documents using shared data functions. Split training and validation documents first, then construct windows within each split. |
| 3: Decide what an experiment demonstrates | Check duplicate documents, long shared passages, and valid-target counts. Distinguish a pipeline demonstration from independent validation. |
| 4: Compare alternatives as needed | Start with two readable BPE merges, then optionally examine bytes, special tokens, and padding. These are not hidden prerequisites for the five-token model. |
Run python week09_data_protocol.py from the examples directory. It produces the five-token inputs/targets accepted by Week 10. The final project explicitly creates a separate character vocabulary instead of silently feeding a different ID mapping into an existing checkpoint.
Alternate reading, hand calculation, and code changes. Units can take several sessions. Existing section numbers remain for links and reference; follow the displayed order rather than jumping around by older numbers.
Week 9 learning goal: a stable shared protocol between text and model
A tokenizer is not the language model. It specifies normalization, segmentation, vocabulary entries, token IDs, and decoding. It converts text into integers before model computation and generated integers into text afterward. The contextual patterns learned by the language model reside in its embedding and Transformer parameters, not in the act of encoding.
Scroll horizontally to view all columns.
| Concept | Meaning in this chapter | Example from the fixed sentence |
|---|---|---|
| Token | A discrete unit emitted by a tokenizer | It might be 我, 喜欢, AI, or an individual byte |
| Vocabulary | A finite list of allowed tokens and their IDs | w09-readable-v1 contains V=11 entries |
| Token ID | An integer index for an entry | In this snapshot, AI has ID 6 |
| Tokenizer | The complete normalization, segmentation, encoding, decoding, and special-token policy | Map the fixed sentence into the stream below |
Scroll horizontally to view all columns.
| ID | token | role |
|---|---|---|
| 0 | <BOS> | Document start |
| 1 | <EOS> | Document end |
| 2 | <PAD> | Pad batch sequences to a common length |
| 3 | <UNK> | A lossy unknown-item policy |
| 4 | 我 (I) | Content token |
| 5 | 喜欢 (like) | Content token |
| 6 | AI | Content token |
| 7 | , | Content token |
| 8 | 也 (also) | Content token |
| 9 | 猫 (cat) | Content token |
| 10 | 。 | Content token |
- 我喜欢AI,AI也喜欢猫。
- [<BOS>, 我, 喜欢, AI, ,, AI, 也, 喜欢, 猫, 。, <EOS>]
- IDs s=[0,4,5,6,7,6,8,5,9,10,1], shape [L]=[11]
- decode(skip_special_tokens=True) → 我喜欢AI,AI也喜欢猫。
Scroll horizontally to view all columns.
| Teaching artifact | Where used | Base units / ID space | Learning purpose | Where this example ends |
|---|---|---|---|---|
| character-demo | Section 4 | Nine known Unicode code points, IDs 0..8 | Understand encode, decode, dtype, and [L] | End of Section 4 |
| toy-byte-bpe | Sections 5–7 | UTF-8 byte IDs 0..255, then merged IDs 256 and 257 | Calculate pair counts, merges, frozen encoding, and reconstruction | End of Section 7 |
| w09-readable-v1 | Sections 8–18 | Readable pieces, V=11, IDs 0..10 | Trace the special-token stream, windows, masks, and batches | End of Week 9 |
| mini-gpt-v1 | From Week 10 | Five tokens, V=5, IDs 0..4 | Connect to the canonical MiniGPT | According to the Weeks 10–12 protocol |
Knowledge check
Why version tokenizer artifacts together with model checkpoints?
1. A token is not necessarily a word: four possible boundaries
The same text, “我喜欢AI,AI也喜欢猫。”, can produce different units under different rules. The word-like row below depends on a specified segmenter. The illustrative subword row happens to look the same, but that is a result of its chosen vocabulary, not the definition of subword tokenization.
Scroll horizontally to view all columns.
| scheme | emitted units | count L | immediate benefit | immediate cost |
|---|---|---|---|---|
| character / code point | [我, 喜, 欢, A, I, ,, A, I, 也, 喜, 欢, 猫, 。] | 13 | Code-point boundaries do not require a word dictionary | 喜欢 and AI are split, producing a longer sequence |
| Word-like (specified segmenter) | [我, 喜欢, AI, ,, AI, 也, 喜欢, 猫, 。] | 9 | Short and easy to read | Chinese word boundaries, unseen words, and normalization require additional rules |
| UTF-8 byte | E6 88 91 … 41 49 … E3 80 82 | 31 | A fixed set of 256 byte symbols covers valid UTF-8 text | Longest in this example; a byte fragment may not be independently readable |
| Subword (illustrative result after additional training) | [我, 喜欢, AI, ,, AI, 也, 喜欢, 猫, 。] | 9 | Frequent pieces can use fewer tokens; less familiar text can be split into smaller units | Merges, normalization, and ID assignments belong to the tokenizer version |
Knowledge check
Does a sentence with thirteen code points necessarily produce thirteen token IDs?
2. Why not use whole words? Coverage versus length
For the fixed Chinese sentence, a word-like tokenizer can keep 喜欢 and AI intact. A byte vocabulary covers its valid UTF-8 encoding but needs thirty-one positions. Subword schemes can start from smaller units and learn frequent adjacent combinations from a corpus.
Scroll horizontally to view all columns.
| choice | Vocabulary / coverage | sequence length | Main trade-off |
|---|---|---|---|
| whole word | Many whole-word entries; missing words need <UNK> or another fallback | Common words can be represented compactly | A large V or information loss on unknown words |
| character / byte | A smaller base vocabulary; a complete byte base covers valid UTF-8 text | Thirteen / thirty-one positions for this sentence | More positions and, for bytes, reduced direct readability |
| subword | common pieces + smaller fallback pieces | Often a compromise; nine positions in this illustration | Learned merges and segmentation rules must be saved and fixed for use |
Knowledge check
What can subwords do that a finite whole-word vocabulary alone cannot?
3. Vocabulary-size trade-offs: V and L both affect the system
The sentence's lengths of thirteen, nine, and thirty-one illustrate different choices; they do not prove nine is optimal. Larger vocabularies can dedicate entries to common pieces, while smaller ones may use more positions. The model still produces V logits at every processed position.
Scroll horizontally to view all columns.
| design direction | vocabulary-facing tables | same text length | context / Attention implication |
|---|---|---|---|
| smaller V | Fewer embedding rows and output candidates | Often a larger L | A fixed T may cover less original text; a longer processed sequence has more position pairs |
| larger V | More embedding rows and output candidates | Frequent pieces may need fewer positions | More output-head computation and storage per position |
Scroll horizontally to view all columns.
| example | input embedding VC | bias-free untied LM head VC | total 2VC |
|---|---|---|---|
| Teaching dimensions V=11, C=4 | 11×4=44 | 11×4=44 | 88 parameters |
| Illustrative larger dimensions V=50,000, C=768 | 38,400,000 | 38,400,000 | 76,800,000 parameters |
V determines the size of vocabulary-facing parameter tables and the number of candidates scored per position. Through segmentation, the tokenizer also changes L, the number of positions needed to represent text.
Knowledge check
With C fixed, which interfaces grow when V doubles?
4. A character tokenizer: first complete the encode/decode loop
For “我喜欢AI,AI也喜欢猫。”, this tokenizer treats each Unicode code point as a token. It is a useful mapping demonstration, not a production recommendation. It does not automatically handle out-of-vocabulary code points, grapheme clusters, or normalization differences.
import torch
text = "我喜欢AI,AI也喜欢猫。"
character_tokens = ["我", "喜", "欢", "A", "I", ",", "也", "猫", "。"]
char_to_id = {token: index for index, token in enumerate(character_tokens)}
id_to_char = {index: token for token, index in char_to_id.items()}
def encode_characters(value: str) -> list[int]:
return [char_to_id[character] for character in value]
def decode_characters(ids: list[int]) -> str:
return "".join(id_to_char[index] for index in ids)
ids = encode_characters(text)
token_tensor = torch.tensor(ids, dtype=torch.long)
assert ids == [0, 1, 2, 3, 4, 5, 3, 4, 6, 1, 2, 7, 8]
assert tuple(token_tensor.shape) == (13,)
assert decode_characters(ids) == textKnowledge check
Why implement the character tokenizer first even though it uses thirteen tokens here?
8. Tokenizer Training and Text Encoding Are Different
Scroll horizontally to view all columns.
| phase | input | changes artifacts? | output |
|---|---|---|---|
| tokenizer training | training corpus documents | Yes: choose normalization, the base vocabulary, merges and special IDs | versioned artifact A |
| text encoding | one text + frozen A | No | IDs in 0..V_A−1 |
| model training | batches of frozen-A IDs | No: A stays fixed; model parameters change | updated neural weights |
- train documents
- normalization / pre-tokenization policy
- BPE counts + ordered merges
- frozen tokenizer artifact A
- encode train / validation / inference text
- token streams
- model training
Below, w09-readable-v1 is a small, concrete teaching artifact—not an unspecified library tokenizer. It uses identity normalization, an explicitly ordered vocabulary and fixed longest-first content routes. An unmatched Unicode code point becomes <UNK>. This routing policy is a teaching convention; it is not claimed to be the result of the preceding two toy BPE merges.
- ① TOKENS freezes the ordered vocabulary and special IDs
- ② CONTENT_ROUTES defines longest-first content segmentation
- ③ segment_content returns token strings only
- ④ encode_content looks up their IDs without adding boundaries
- ⑤ encode_document adds BOS and EOS exactly once
- ⑥ decode reconstructs text using the same ID table
On your first reading, follow the cursor through the fixed sentence from 0 to len(text). On the second reading, inspect boundary ownership and error handling. This separates the main data flow from the defensive details.
TOKENS = (
"<BOS>",
"<EOS>",
"<PAD>",
"<UNK>",
"我",
"喜欢",
"AI",
",",
"也",
"猫",
"。",
)
TOKEN_TO_ID = {token: token_id for token_id, token in enumerate(TOKENS)}
ID_TO_TOKEN = {token_id: token for token, token_id in TOKEN_TO_ID.items()}
BOS_ID = TOKEN_TO_ID["<BOS>"] # 0
EOS_ID = TOKEN_TO_ID["<EOS>"] # 1
PAD_ID = TOKEN_TO_ID["<PAD>"] # 2
UNK_ID = TOKEN_TO_ID["<UNK>"] # 3
# Deterministic longest-first routing for this small teaching artifact.
# Equal-length routes retain this declared order.
CONTENT_ROUTES = ("喜欢", "AI", "我", ",", "也", "猫", "。")
HIDDEN_ON_DECODE_IDS = {BOS_ID, EOS_ID, PAD_ID}
class ReadableTokenizerV1:
version = "w09-readable-v1"
normalization = "identity"
vocabulary = TOKENS
def segment_content(self, text: str) -> list[str]:
pieces: list[str] = []
cursor = 0
while cursor < len(text):
matched = next(
(
piece
for piece in CONTENT_ROUTES
if text.startswith(piece, cursor)
),
None,
)
if matched is None:
pieces.append("<UNK>")
cursor += 1
else:
pieces.append(matched)
cursor += len(matched)
return pieces
def encode_content(
self,
text: str,
*,
add_special_tokens: bool = False,
) -> list[int]:
if add_special_tokens:
raise ValueError("encode_content never adds BOS/EOS")
return [TOKEN_TO_ID[piece] for piece in self.segment_content(text)]
def encode_document(
self,
text: str,
*,
add_special_tokens: bool = True,
) -> list[int]:
if not add_special_tokens:
raise ValueError("use encode_content when boundaries are unwanted")
return [
BOS_ID,
*self.encode_content(text, add_special_tokens=False),
EOS_ID,
]
def decode(
self,
ids: list[int],
*,
skip_special_tokens: bool = True,
) -> str:
pieces: list[str] = []
for token_id in ids:
if token_id not in ID_TO_TOKEN:
raise ValueError(f"token ID out of range: {token_id}")
if skip_special_tokens and token_id in HIDDEN_ON_DECODE_IDS:
continue
pieces.append(ID_TO_TOKEN[token_id])
return "".join(pieces)
W09_READABLE_V1 = ReadableTokenizerV1()
def encode_content(text: str) -> list[int]:
return W09_READABLE_V1.encode_content(
text,
add_special_tokens=False,
)
def encode_document(text: str) -> list[int]:
return W09_READABLE_V1.encode_document(
text,
add_special_tokens=True,
)
RUNNING_TEXT = "我喜欢AI,AI也喜欢猫。"
content_ids = encode_content(RUNNING_TEXT)
document_ids = encode_document(RUNNING_TEXT)
assert content_ids == [4, 5, 6, 7, 6, 8, 5, 9, 10]
assert document_ids == [0, *content_ids, 1]
assert W09_READABLE_V1.decode(
document_ids,
skip_special_tokens=True,
) == RUNNING_TEXT
# These calls never mutate vocabulary, routes, or IDs.Scroll horizontally to view all columns.
| fixed input | method | output | artifact mutation |
|---|---|---|---|
| 我喜欢AI,AI也喜欢猫。 | segment_content | [我,喜欢,AI,,,AI,也,喜欢,猫,。] | none |
| 我喜欢AI,AI也喜欢猫。 | encode_content | [4,5,6,7,6,8,5,9,10] | none |
| 我喜欢AI,AI也喜欢猫。 | encode_document | [0,4,5,6,7,6,8,5,9,10,1] | none |
| [0,4,5,6,7,6,8,5,9,10,1] | decode(skip_special_tokens=True) | 我喜欢AI,AI也喜欢猫。 | none |
Knowledge check
After freezing the BPE rule (41,49)→256, does encoding the fixed sentence again change that rule?
9. Bind the Tokenizer to the Model: Matching Shapes Are Not Enough
In w09-readable-v1, 喜欢 has ID 5 and AI has ID 6. If another V=11 tokenizer swaps those IDs, input 6 still reads the embedding row trained for AI, but the new system calls it 喜欢. Output entry 6 will also decode to the wrong piece.
Scroll horizontally to view all columns.
| bundle item | why model loading needs it |
|---|---|
| ordered token list / Vocabulary | Names the token represented by every embedding row and output-logit entry |
| ordered BPE merges | Determines how new text is assembled into vocabulary entries |
| normalizer + pre-tokenizer | Determines the symbols and boundaries seen before merging |
| special-token IDs / insertion policy | Defines the control IDs for BOS, EOS, PAD and UNK, and when they appear |
| tokenizer version / content hash | Supports an exact compatibility check before loading |
| model config + weights | Declares shapes such as V, C and context length, and stores learned parameters |
w09-readable-v1:
ID 5 -> 喜欢
ID 6 -> AI
incompatible-tokenizer:
ID 5 -> AI
ID 6 -> 喜欢
Both report V=11.
Model input ID 6 is numerically in range, but its learned row and displayed token disagree.Knowledge check
Why might two V=11 tokenizers be incompatible with the same checkpoint?
12. Turn a Corpus into a Token Tensor: Keep Order and Document Boundaries
For the single document “我喜欢AI,AI也喜欢猫。”, w09-readable-v1 produces [0,4,5,6,7,6,8,5,9,10,1], shape [11]. This is still one ordered stream—not a batch, and not a tensor with a C feature axis.
import torch
from w09_readable_v1 import encode_document
documents = ["我喜欢AI,AI也喜欢猫。"]
# encode_document already inserts exactly one BOS and one EOS per document.
stream: list[int] = []
for document in documents:
stream.extend(encode_document(document))
stream_tensor = torch.tensor(stream, dtype=torch.long)
assert stream == [0, 4, 5, 6, 7, 6, 8, 5, 9, 10, 1]
assert tuple(stream_tensor.shape) == (11,)
assert stream_tensor.dtype == torch.longScroll horizontally to view all columns.
| multi-document policy | boundary example | Which transitions are supervised? | trade-off |
|---|---|---|---|
| Concatenate boundary-marked documents | …content,<EOS>,<BOS>,next document… | Last content token→EOS, and possibly EOS→BOS | Simple implementation; includes the selected cross-document control-token transitions |
| Build windows separately within each document | No window crosses a document boundary | Only within-document transitions, including final content→EOS | Clear boundaries; short tails may leave positions unused |
| packing + boundary-aware masks | Pack multiple documents into one rectangular batch | Use explicit attention/loss policies to block unwanted cross-document connections | Efficient use of positions, but more complex to implement and audit |
These alternatives do not differ for this chapter's single-document stream. With multiple documents, choose and document a policy. Inserting EOS alone does not block all cross-document information flow.
Knowledge check
What are the shape and dtype immediately after the fixed sentence becomes stream_tensor?
13. Train/Validation Split: Separate Documents Before Building Windows
The single sentence “我喜欢AI,AI也喜欢猫。” is a pipeline example, not a meaningful train/validation experiment. For a real corpus, split documents first, freeze the tokenizer, then build train_stream and val_stream separately. Validation forward passes do not call backward or optimizer.step.
- raw documents
- document/group/temporal split
- fit tokenizer A on train documents only (when training from scratch)
- freeze A
- encode train documents → train_stream [L_train]
- encode validation documents → val_stream [L_val]
- form windows separately inside each split
This is a provenance guarantee, not value-level deduplication. Independent documents can contain common phrases or boilerplate, so identical ID windows may appear in both splits. If deduplication is required, specify a separate document/group deduplication policy.
Scroll horizontally to view all columns.
| stage | train lane | validation lane |
|---|---|---|
| raw data | train documents | held-out validation documents |
| tokenizer | fit A here if from scratch, then freeze | only apply frozen A |
| model | forward + loss + backward + step | eval/no_grad forward + loss only |
| claim | optimization progress | estimate on held-out token sequences |
Validation does not participate in backward, but it influences model selection and hyperparameter choices. Repeatedly improving against one validation set makes it less suitable as an untouched final assessment. Formal comparisons can reserve a test split for the end. This course's tiny toy data cannot support strong quality claims.
Knowledge check
Why split by document before concatenating streams?
14. Take One Example from a Stream: Shift Targets One Position Right
The fixed stream s=[0,4,5,6,7,6,8,5,9,10,1] encodes “我喜欢AI,AI也喜欢猫。”. Choose start i=0 and context length T=4. x reads four consecutive IDs; y reads four IDs beginning one position later.
Scroll horizontally to view all columns.
| stream index | ID | token |
|---|---|---|
| 0 | 0 | <BOS> |
| 1 | 4 | 我 (I) |
| 2 | 5 | 喜欢 (like) |
| 3 | 6 | AI |
| 4 | 7 | , |
| 5 | 6 | AI |
| 6 | 8 | 也 (also) |
| 7 | 5 | 喜欢 (like) |
| 8 | 9 | 猫 (cat) |
| 9 | 10 | 。 |
| 10 | 1 | <EOS> |
x = s[0:4] = [0,4,5,6] = [<BOS>, 我, 喜欢, AI] shape [T]=[4]
y = s[1:5] = [4,5,6,7] = [我, 喜欢, AI, ,] shape [T]=[4]Scroll horizontally to view all columns.
| position t | input token | target next token | causal model may use |
|---|---|---|---|
| 0 | <BOS> | 我 (I) | [<BOS>] |
| 1 | 我 (I) | 喜欢 (like) | [<BOS>, 我] |
| 2 | 喜欢 (like) | AI | [<BOS>, 我, 喜欢] |
| 3 | AI | , | [<BOS>, 我, 喜欢, AI] |
Knowledge check
In the i=0 example, which target corresponds to input ID 5 (喜欢, “like”)?
15. Build a Batch: Randomize Row Starts, Keep Each Row Consecutive
Python s[a:b] includes position a but excludes b, giving b−a items. A T=4 training sequence needs five consecutive source IDs: the first four are inputs and the last four are targets, overlapping by three. randint also excludes its upper bound. Write down the last valid start before choosing high.
Continue with the w09-readable-v1 stream for the same Chinese sentence. Fix B=2, T=4 and valid starts [0,5]. Randomness chooses where each row begins; within a row, IDs retain their original order.
starts = [0,5]
inputs = [
[0,4,5,6], # <BOS> 我 喜欢 AI
[6,8,5,9], # AI 也 喜欢 猫
] # [B,T] = [2,4]
targets = [
[4,5,6,7], # 我 喜欢 AI ,
[8,5,9,10], # 也 喜欢 猫 。
] # [B,T] = [2,4]Scroll horizontally to view all columns.
| batch row b | start | input sequence | target sequence |
|---|---|---|---|
| 0 | 0 | <BOS> 我 喜欢 AI | 我 喜欢 AI , |
| 1 | 5 | AI 也 喜欢 猫 | 也 喜欢 猫 。 |
import torch
def sample_batch(source: torch.Tensor, batch_size: int, context_length: int):
if source.ndim != 1:
raise ValueError("source must have shape [L]")
if len(source) <= context_length:
raise ValueError("source needs at least context_length + 1 IDs")
# torch.randint excludes high, so high=L-T yields starts 0..L-T-1.
starts = torch.randint(
low=0,
high=len(source) - context_length,
size=(batch_size,),
)
inputs = torch.stack(
[source[i : i + context_length] for i in starts.tolist()]
)
targets = torch.stack(
[source[i + 1 : i + context_length + 1] for i in starts.tolist()]
)
return inputs, targets
source = torch.tensor([0, 4, 5, 6, 7, 6, 8, 5, 9, 10, 1])
inputs, targets = sample_batch(source, batch_size=2, context_length=4)
assert inputs.shape == targets.shape == (2, 4)Knowledge check
Why does each row require T+1 source IDs?
10. Special Tokens: Give Boundary Insertion One Owner
In Week 9's readable snapshot, IDs 0..3 are reserved specials and content entries begin at 4. They occupy vocabulary entries visible to the model; typing the literal characters <EOS> does not automatically invoke their control behavior. Display, masking, stopping generation and loss exclusion all require explicit API agreements.
Scroll horizontally to view all columns.
| ID | special token | role in w09-readable-v1 | typical handling |
|---|---|---|---|
| 0 | <BOS> | Document start / initial context | encode_document adds it once at the beginning |
| 1 | <EOS> | Document end | encode_document adds it once at the end; generation may use it as a stop candidate |
| 2 | <PAD> | Fill unequal-length rows to form a rectangle | Usually excluded through attention and loss policies |
| 3 | <UNK> | Unknown-token marker for this limited content vocabulary | Lossy; not inherently required by byte fallback |
Scroll horizontally to view all columns.
| API result for the running Chinese sentence | tokens | IDs |
|---|---|---|
| encode_content(add_special_tokens=False) | [我,喜欢,AI,,,AI,也,喜欢,猫,。] | [4,5,6,7,6,8,5,9,10] |
| encode_document(add_special_tokens=True) | [<BOS>,我,喜欢,AI,,,AI,也,喜欢,猫,。,<EOS>] | [0,4,5,6,7,6,8,5,9,10,1] |
from w09_readable_v1 import (
BOS_ID,
EOS_ID,
W09_READABLE_V1,
encode_content,
encode_document,
)
text = "我喜欢AI,AI也喜欢猫。"
content_ids = encode_content(text)
document_ids = encode_document(text)
assert document_ids == [BOS_ID, *content_ids, EOS_ID]
assert W09_READABLE_V1.decode(
document_ids,
skip_special_tokens=True,
) == text
# Wrong: encode_document already owns both boundaries.
# duplicated = [BOS_ID, *encode_document(text), EOS_ID]Knowledge check
Why does encode_document(text)+[EOS] violate this section's convention?
11. Padding and Attention Masks: Rectangular Does Not Mean Valid
Scroll horizontally to view all columns.
| Control | Question to ask first |
|---|---|
| causal mask | Could this query peek at a real answer to its right? |
| padding mask | Was this slot added only to make the tensor rectangular? |
| loss mask | Does this prediction have a valid target, and should it count toward loss? |
All three controls can coexist. Sharing the word “mask” does not make them interchangeable. Here, −100 means “do not score this target”; it is not in the vocabulary and must never be used for embedding lookup.
Use “我喜欢AI,AI也喜欢猫。” as row 0 and the shorter “我喜欢AI。” as row 1. Both come from w09-readable-v1's encode_document. Call this padded batch width T_pad=11 so it is not confused with the later training-window length T=4.
Scroll horizontally to view all columns.
| row | input_ids [T_pad=11] | attention_mask [T_pad=11] |
|---|---|---|
| 0: 我喜欢AI,AI也喜欢猫。 | [0,4,5,6,7,6,8,5,9,10,1] | [1,1,1,1,1,1,1,1,1,1,1] |
| 1: 我喜欢AI。 | [0,4,5,6,10,1,2,2,2,2,2] | [1,1,1,1,1,1,0,0,0,0,0] |
Scroll horizontally to view all columns.
| object | example value | job |
|---|---|---|
| PAD token ID | 2 | Occupies an unused slot in the rectangular tensor |
| attention_mask | real=1, pad=0 | Prevents queries from using PAD keys as context; the API also needs a policy for padded queries |
| causal mask | j≤t | Prevents queries from reading future keys; does not itself identify PAD |
| loss ignore index | -100 | Excludes padded targets from cross entropy |
Now apply these rules to a shorter next-token batch with T_mask=7. Row 0 uses eight consecutive source IDs, yielding seven input→target pairs. Row 1's entire short document has six IDs: append one PAD to its input, and set both the target after EOS and the target for the PAD slot to -100.
Scroll horizontally to view all columns.
| row | input_ids [7] | attention_mask [7] | next-token targets [7] |
|---|---|---|---|
| 0: Prefix of the main sentence | [0,4,5,6,7,6,8] | [1,1,1,1,1,1,1] | [4,5,6,7,6,8,5] |
| 1: Complete short document | [0,4,5,6,10,1,2] | [1,1,1,1,1,1,0] | [4,5,6,10,1,-100,-100] |
Scroll horizontally to view all columns.
| control | Question it answers | Specific effect on row 1 |
|---|---|---|
| causal mask | May query t read a future key at j>t? | t=4 (。) may read only key positions 0..4 |
| padding mask | May a query use an invalid PAD key as context? | No query may read the PAD key at position 6 |
| loss mask / ignore_index | Should this position's prediction contribute to loss and its gradients? | Targets at positions 5 and 6 are -100 and are excluded from mean loss |
import torch
import torch.nn.functional as F
input_ids = torch.tensor([
[0, 4, 5, 6, 7, 6, 8],
[0, 4, 5, 6, 10, 1, 2],
], dtype=torch.long)
attention_mask = torch.tensor([
[1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 0],
], dtype=torch.bool)
targets = torch.tensor([
[4, 5, 6, 7, 6, 8, 5],
[4, 5, 6, 10, 1, -100, -100],
], dtype=torch.long)
B, T = input_ids.shape
causal = torch.tril(torch.ones(T, T, dtype=torch.bool))
# allowed[b,t,j]: query t may read key j only when j is not
# in the future and row b says that key j is real rather than PAD.
allowed = causal.unsqueeze(0) & attention_mask[:, None, :]
assert tuple(allowed.shape) == (2, 7, 7)
assert allowed[1, 4].tolist() == [
True, True, True, True, True, False, False
]
valid_targets = targets.ne(-100)
assert int(valid_targets.sum()) == 12 # row 0: 7, row 1: 5
# Zero logits mean a uniform distribution over V=11 candidates.
# Cross Entropy averages only the 12 valid targets.
logits = torch.zeros(B, T, 11)
loss = F.cross_entropy(
logits.reshape(B * T, 11),
targets.reshape(B * T),
ignore_index=-100,
)
assert torch.isclose(loss, torch.log(torch.tensor(11.0)))Knowledge check
Why is input_ids.shape=[2,11] insufficient to establish that the padded batch is correct?
6. Unseen Tokens: Decide How to Preserve Information
Suppose a limited-vocabulary variant of w09-readable-v1 omits 猫 (“cat”). It could emit <UNK>, ID 3, but distinct unknown pieces would collapse to one address and 猫 could not be reconstructed exactly. Byte fallback can retain E7 8C AB and later reconstruct the original text.
Scroll horizontally to view all columns.
| policy | Output when 猫 cannot be looked up directly | round-trip | cost / behavior |
|---|---|---|---|
| <UNK> | [3] | Lossy: reconstructs only the unknown marker | Short, but different unknown pieces share one ID |
| subword / byte fallback | One or more known smaller-piece IDs | Can preserve the original text if normalization and byte handling are lossless | k may exceed 1, making the sequence longer |
| explicit error | Produces no IDs | The caller must handle the failure | Useful for strict input policies that forbid automatic replacement |
The actual snapshot includes 猫, so it normally encodes as ID 9. Malformed byte sequences are a separate issue requiring an explicit UTF-8 replacement/error policy; do not confuse them with valid but unseen words.
Knowledge check
Why does byte fallback usually lose less information than replacing 猫 with <UNK>?
7. BPE Intuition: Work Through Two Actual Merges
This toy byte-level BPE has no pre-token boundaries: every adjacent byte pair in the sentence can be counted. The initial table lists pairs occurring at least twice. (9C,E6) crosses from the final byte of 喜 to the first byte of 欢 and occurs in both instances of 喜欢.
Scroll horizontally to view all columns.
| initial adjacent byte pair | count | why repeated |
|---|---|---|
| (41,49) | 2 | Both occurrences of AI |
| (E5,96) | 2 | The first two bytes in each 喜 |
| (96,9C) | 2 | The last two bytes in each 喜 |
| (9C,E6) | 2 | The code-point boundary 喜→欢 in both occurrences |
| (E6,AC) | 2 | The first two bytes in each 欢 |
| (AC,A2) | 2 | The last two bytes in each 欢 |
Round 1
all six candidates tie at count 2
lexicographically smallest integer pair = (0x41, 0x49)
(41,49) -> new token 256, displayed [41 49]
... E6 AC A2 [41 49] EF BC 8C [41 49] E4 B9 9F ...
length: 31 - 2 = 29 symbolsRecount after round 1. The two [41 49] occurrences have different neighbors, so no new repeated pair appears. Each of the other five repeated pairs still occurs twice.
Scroll horizontally to view all columns.
| recount after round 1 | count |
|---|---|
| (96,9C) | 2 |
| (9C,E6) | 2 |
| (AC,A2) | 2 |
| (E5,96) | 2 |
| (E6,AC) | 2 |
Round 2
all five repeated pairs tie at count 2
lexicographically smallest integer pair = (0x96, 0x9C)
(96,9C) -> new token 257, displayed [96 9C]
喜 at each occurrence is now: E5 [96 9C]
length: 29 - 2 = 27 symbols
total after two rounds: 31 - 2 - 2 = 27The following is runnable code, not pseudocode. It starts from the sentence's 31 bytes, trains two rounds using the stated counts and tie-break, then applies the two frozen merges to the same text. Decoding expands each learned symbol into bytes, joins the entire byte stream and only then decodes UTF-8.
from collections import Counter
def count_pairs(symbols: list[int]) -> Counter:
return Counter(zip(symbols, symbols[1:]))
def choose_pair(pair_counts: Counter) -> tuple[int, int]:
highest_count = max(pair_counts.values())
tied_pairs = [
pair
for pair, count in pair_counts.items()
if count == highest_count
]
return min(tied_pairs) # lexicographic integer-pair tie break
def merge_non_overlapping(
symbols: list[int],
pair: tuple[int, int],
new_id: int,
) -> tuple[list[int], int]:
merged: list[int] = []
replacements = 0
cursor = 0
while cursor < len(symbols):
if (
cursor + 1 < len(symbols)
and (symbols[cursor], symbols[cursor + 1]) == pair
):
merged.append(new_id)
replacements += 1
cursor += 2
else:
merged.append(symbols[cursor])
cursor += 1
return merged, replacements
text = "我喜欢AI,AI也喜欢猫。"
training_symbols = list(text.encode("utf-8"))
history: list[tuple[tuple[int, int], int, int, int]] = []
assert len(training_symbols) == 31
for new_id in (256, 257):
pair_counts = count_pairs(training_symbols)
chosen_pair = choose_pair(pair_counts)
before_length = len(training_symbols)
training_symbols, replacements = merge_non_overlapping(
training_symbols,
chosen_pair,
new_id,
)
assert len(training_symbols) == before_length - replacements
history.append(
(chosen_pair, new_id, replacements, len(training_symbols))
)
assert history == [
((0x41, 0x49), 256, 2, 29),
((0x96, 0x9C), 257, 2, 27),
]
def encode_with_frozen_merges(
value: str,
merges: list[tuple[tuple[int, int], int, int, int]],
) -> list[int]:
symbols = list(value.encode("utf-8"))
for pair, new_id, _training_replacements, _training_length in merges:
symbols, _ = merge_non_overlapping(symbols, pair, new_id)
return symbols
encoded = encode_with_frozen_merges(text, history)
assert encoded == training_symbols
assert len(encoded) == 27
# A learned token stores a byte sequence, so decoding expands first.
token_bytes = {byte_id: bytes([byte_id]) for byte_id in range(256)}
for pair, new_id, _count, _length in history:
left, right = pair
token_bytes[new_id] = token_bytes[left] + token_bytes[right]
restored_bytes = b"".join(token_bytes[token_id] for token_id in encoded)
restored_text = restored_bytes.decode("utf-8", errors="strict")
assert restored_text == textScroll horizontally to view all columns.
| stage | selected pair | replacements | stream length | what changed |
|---|---|---|---|---|
| initial bytes | — | — | 31 | Only base byte IDs 0..255 |
| round 1 | (41,49)→256 | 2 | 29 | Each occurrence of AI saves one position |
| round 2 | (96,9C)→257 | 2 | 27 | The last two bytes of each 喜 save one position |
| encoding | Apply the merge creating 256, then the merge creating 257 | Depends on the input | Still 27 for this sentence | No retraining or rule changes |
Knowledge check
Why does round 2 choose (96,9C), and why is 喜 still not one token?
5. Unicode and UTF-8: Character Identity Is Not Serialized Bytes
Scroll horizontally to view all columns.
| Unit or notation | Plain-language meaning | Example |
|---|---|---|
| Unicode code point | An identifier for a code point | 我 has the code point U+6211 |
| UTF-8 byte | Stores/transmits text as bytes, each with a value from 0 to 255 | 我 uses the three bytes E6 88 91 |
| 0x / hexadecimal | A way to write integers; A–F represent 10–15 | 0x41=65; it does not mean the 41st token |
| token ID | An address assigned within a tokenizer's vocabulary | The number has meaning only under that vocabulary's mapping |
On a first pass, distinguish character counts, byte counts and token counts. Python's ord, encode and hex can verify U+6211 or 0xE6; mental base conversion is not required. Later set notation and mapping arrows simply abbreviate input and output ranges.
Unicode assigns 我 the code point U+6211. UTF-8 serializes it as E6 88 91. ASCII A and I each use one byte. This sentence has seven Han code points using three bytes each, four ASCII code points using one each, and two full-width punctuation marks using three each: 21+4+6=31 bytes.
Scroll horizontally to view all columns.
| visible code point | Unicode | UTF-8 hex bytes | byte count |
|---|---|---|---|
| 我 (I) | U+6211 | E6 88 91 | 3 |
| 喜 (first character of 喜欢) | U+559C | E5 96 9C | 3 |
| 欢 (second character of 喜欢) | U+6B22 | E6 AC A2 | 3 |
| A | U+0041 | 41 | 1 |
| I | U+0049 | 49 | 1 |
| , | U+FF0C | EF BC 8C | 3 |
| A | U+0041 | 41 | 1 |
| I | U+0049 | 49 | 1 |
| 也 (also) | U+4E5F | E4 B9 9F | 3 |
| 喜 (first character of 喜欢) | U+559C | E5 96 9C | 3 |
| 欢 (second character of 喜欢) | U+6B22 | E6 AC A2 | 3 |
| 猫 (cat) | U+732B | E7 8C AB | 3 |
| 。 | U+3002 | E3 80 82 | 3 |
text = "我喜欢AI,AI也喜欢猫。"
utf8_bytes = text.encode("utf-8")
assert len(text) == 13
assert len(utf8_bytes) == 31
assert utf8_bytes.hex(" ").upper() == (
"E6 88 91 E5 96 9C E6 AC A2 41 49 EF BC 8C "
"41 49 E4 B9 9F E5 96 9C E6 AC A2 E7 8C AB E3 80 82"
)Before encoding bytes, decide on normalization: which Unicode sequences should count as the same input? Text that looks like é can be one composed code point, U+00E9, or e (U+0065) followed by a combining acute accent (U+0301). Identity normalization preserves the distinction; NFC converts the latter to the composed form.
Scroll horizontally to view all columns.
| visible text | code points before normalization | UTF-8 bytes | policy result |
|---|---|---|---|
| é(composed) | [U+00E9] | C3 A9 | Identity and NFC both preserve the composed form |
| e + ◌́(decomposed) | [U+0065,U+0301] | 65 CC 81 | Identity preserves two code points; NFC produces U+00E9 |
| AI(full-width) | [U+FF21,U+FF29] | EF BC A1 EF BC A9 | NFKC can convert it to ASCII AI; NFC does not perform this compatibility folding |
import unicodedata
composed = "é"
decomposed = "e\u0301"
assert composed != decomposed
assert [f"U+{ord(ch):04X}" for ch in composed] == ["U+00E9"]
assert [f"U+{ord(ch):04X}" for ch in decomposed] == [
"U+0065",
"U+0301",
]
assert composed.encode("utf-8").hex(" ").upper() == "C3 A9"
assert decomposed.encode("utf-8").hex(" ").upper() == "65 CC 81"
# NFC makes these two canonically equivalent spellings identical.
assert unicodedata.normalize("NFC", decomposed) == composed
# NFKC also performs compatibility folding, which is a stronger choice.
assert unicodedata.normalize("NFKC", "AI") == "AI"- token IDs
- expand learned symbols back to byte symbols
- join one byte sequence
- UTF-8 decoder with a defined error policy
- 我喜欢AI,AI也喜欢猫。
Knowledge check
Why do A and 我 not both occupy one UTF-8 byte?
16. A Tokenizer Defines a Discrete Interface; It Does Not Understand Language
Mapping 猫 to 9 in w09-readable-v1 only identifies its vocabulary address. Predictive patterns such as 猫 frequently appearing with 喜欢 must be learned from token sequences through loss, backpropagation and neural-parameter updates. Neither the number 9 nor the BPE merge contains that knowledge.
- Raw text: 我喜欢AI,AI也喜欢猫。
- tokenizer → integer IDs [B,T]
- embedding lookup → learned floats [B,T,C]
- Transformer → contextual floats [B,T,C]
- LM head → logits [B,T,V]
- decode selected IDs → text
Scroll horizontally to view all columns.
| tokenizer responsibility | model / training responsibility |
|---|---|
| normalization and segmentation | learn useful continuous features from examples |
| ordered Vocabulary and ID lookup | combine left context with Attention / FFN |
| special-token and fallback policy | produce and update vocabulary logits via loss |
| decode IDs under fixed artifact | model probability, behavior, factuality and errors |
Knowledge check
Where can the system learn that 猫 and 喜欢 often occur together?
17. Nine Things to Understand from Week 9
- A token is a tokenizer-defined computational unit, not necessarily a word. Each token normally occupies one model position.
- Code points, UTF-8 bytes and tokens are different units. Normalization determines whether certain Unicode differences are folded before encoding.
- Subwords trade off vocabulary size against sequence length. A token count belongs to a particular tokenizer.
- Encoding maps text to IDs; decoding returns text under the frozen tokenizer's policy.
- Tokenizer training constructs artifacts; encoding applies them without changing them.
- The tokenizer's ID mapping, normalization and special-token policy must match the model checkpoint.
- Causal, padding and loss-ignore masks respectively control future keys, invalid padding keys and targets that should be scored.
- Shifting a token stream by one position provides inputs and next-token targets.
- A batch stacks consecutive, correctly shifted windows into inputs and targets of shape [B,T].
Use AI to check statements 5 and 8. After BPE training saves (41,49)→256, encoding both AI occurrences applies the rule without recounting. In the readable-stream example at i=0, AI is the input at t=3 and its shifted target is ,.
Knowledge check
Use AI in the fixed sentence to explain statements 5 and 8.
18. Week 9 → Week 10: Pass the Same Batch to the Model
The English main path uses FIVE_WORD_TOKENIZER in course_examples/course_data.py: you=0, like=1, AI=2, study=3, we=4. It splits on whitespace and has no BOS/EOS/PAD/UNK. The documents remain “you like AI”, “we like you” and “you study AI”; inputs are [[0,1],[4,1],[0,3]] and targets are [[1,2],[1,0],[3,2]]. Use the English example package so its displayed tokens match this edition.
Scroll horizontally to view all columns.
| Material | Purpose | Passed directly to the main MiniGPT? |
|---|---|---|
| Five-token tokenizer + T=2 windows | Step-by-step calculations and mechanism demonstrations in Weeks 6–12 | Yes. V=5 and the ID meanings remain unchanged. |
| w09-readable-v1 / character-demo / toy-byte-bpe | Compare segmentation, unknown characters, special tokens and byte merging | No. These are independent experiments; they do not reinterpret existing integers. |
| Character tokenizer for the final independent-document project | Week 12 explicitly starts a new experiment | Reuses the model class, but creates a new vocabulary, configuration and checkpoint explicitly. |
# Standalone: run from the English course_examples directory.
import torch
from course_data import FIVE_WORD_TOKENIZER, DEMO_DOCUMENTS, make_windows
x, y = make_windows(DEMO_DOCUMENTS, FIVE_WORD_TOKENIZER, block_size=2)
inputs = torch.tensor(x, dtype=torch.long)
targets = torch.tensor(y, dtype=torch.long)
print(inputs.tolist()) # [[0,1],[4,1],[0,3]]
print(targets.tolist()) # [[1,2],[1,0],[3,2]]
# Week 10 connects the model: logits, loss = model(inputs, targets)The independent tokenization examples still matter: they help you decide when a different scheme is needed. They are not hidden prerequisites for the main code. You do not need an industrial BPE implementation to understand GPT training. Next week keeps these inputs/targets and replaces Bigram's score-row lookup with embeddings, positions, blocks and an output head.
Knowledge check
Should these six target IDs change when replacing Bigram with MiniGPT?