Skip to content

Byte bigram: counts, logits, safetensors v0

ModuleL0.0 · build · Python · Pass 1 · 3 to 4 h
You buildpython/tinyllm/lm/bigram.py: BigramLM (fit_counts, logits, nll, sample); python/tinyllm/io/safetensors.py: save_safetensors, load_safetensors (F32 only)
Contractcourse/contracts/py/tinyllm/lm/bigram.pyi · course/contracts/py/tinyllm/io/safetensors.pyi · formats: safetensors.md, tokenizer.md · CLI verbs: spec/cli-roles.md
Testscourse/tests/L0.0/ (what they check: section 4)
Needsreading: M03.1 Python matmul and lang.01 Python and NumPy
Used byL10.0 your Rust engine serves this checkpoint · later: L0.5 retrains it with autograd, L0.6 adds every dtype
MilestoneMS-P1 (the tracer: every layer is yours and runs end to end)
Optional depthJurafsky and Martin, Speech and Language Processing (3rd ed. draft), ch. 3 “N-gram Language Models”
  • A bigram language model is one table: row ii is the distribution of the next byte after byte ii, and add-alpha smoothing makes every row a proper distribution with no zeros (test_rows_sum_to_one).
  • The negative log-likelihood of the count model is a closed form in the counts, and it is the number every later model in this course must beat (test_hand_example_nll, test_nll_matches_counts).
  • Indexing the table by token ids picks its rows exactly, so NumPy returns the model’s logits bit for bit (test_logits_are_weight_rows).
  • Sampling is temperature, softmax, one uniform draw, and an inverse CDF; a seed makes it repeatable (test_sample_is_seeded, test_sample_frequencies_match_model).
  • Your safetensors file is byte-identical to the reference library’s, so your Rust engine (and anyone else’s reader) loads it (test_matches_library_bytes).
Terminal window
ol start L0.0 # stubs bigram.py and safetensors.py into your repo
ol tests L0.0 # read the test catalog first: rung R0, you write no tests here
ol check L0.0 # exit code is the verdict
ol check L0.0 --ref-deps # only if a Python prerequisite is not passing yet
ol diff L0.0 # after passing: your code against the reference

You also write the first verbs of your own CLI, python/tinyllm/__main__.py (it is yours: the course ships no CLI). spec/cli-roles.md fixes what MS-P1 will run:

VerbWhat it does with this module
train bigram --data F --out DIR [--alpha A]reads the bytes of F as ids, calls fit_counts, writes DIR/model.safetensors (bigram.weight, metadata {"format": "tinyllm"}) and DIR/config.json, prints {"out", "tokens", "nll"} as the last line
generate --model DIR --prompt P [--max-tokens N] [--greedy | --temperature T] [--seed S]load_safetensors, BigramLM(weight), sample(list(P.encode()), N, T, S), prints {"ids", "text"}
logits --model DIR --prompt P [--prefix-ids ...]logits(ids)[-1] as a JSON list

config.json for the tracer is exactly {"tl_arch": "bigram", "tl_tokenizer": "bytes", "vocab_size": 256, "tl_format": 1}.

The Python project. python/ is a uv project that is not an installed package, and the CLI runs as a script from the repo root. Three files set it up (ol start writes none of them; they are yours):

python/pyproject.toml
[project]
name = "tinyllm"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["numpy>=2.0"]
[tool.uv]
package = false
# python/tinyllm/__init__.py is empty. python/tinyllm/__main__.py starts with:
import sys
from pathlib import Path
if __package__ in (None, ""): # run as a script: put python/ on the path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from tinyllm.lm.bigram import BigramLM # noqa: E402

Then declare the role in system.toml and try it:

[entry]
tinyllm = ["uv", "run", "--project", "python", "python", "python/tinyllm/__main__.py"]
Terminal window
uv run --project python python python/tinyllm/__main__.py train bigram --data some.txt --out artifacts/bigram
uv run --project python python python/tinyllm/__main__.py generate --model artifacts/bigram --prompt Once --greedy

ol check L0.0 grades the two library units only; the CLI is an entry point (D16), which MS-P1 runs.


The Rust engine you write next (L10.0) needs a model directory to serve, the Go gateway (gw.00) needs that engine behind it, and the cluster (dep.00) needs both. Today {tinyllm} train bigram does not exist and there is no checkpoint on disk, so every layer above Python has nothing to load. This module builds the smallest real language model, computes logits with a NumPy row gather, and writes the file format every later model uses. From here on, the tracer bullet has a payload.

This section is “just enough” math, defined from scratch. Each idea comes back properly later: functions, log, and exp in M00.1; probability and random variables in M07.1; maximum likelihood in M07.2; entropy, cross-entropy, and perplexity in M11.1; stable softmax in M09.2.

SymbolMeaningType / shape
VVvocabulary size: the number of distinct token ids (256 for bytes)int
x1,…,xTx_1, \dots, x_Tthe training text as token ids, xt∈{0,…,V−1}x_t \in \{0, \dots, V-1\}int64[T]
CijC_{ij}count of positions tt with xt=ix_t = i and xt+1=jx_{t+1} = jfloat64[V, V]
Ri=∑jCijR_i = \sum_j C_{ij}how often ii appears as a context (followed by something)float64[V]
α>0\alpha > 0smoothing constant (add-one: α=1\alpha = 1)float
P(j∣i)P(j \mid i)the model’s probability that jj follows iifloat64 in (0,1)(0, 1)
Wij=ln⁡P(j∣i)W_{ij} = \ln P(j \mid i)the weight table, stored as logitsfloat32[V, V]
softmax(z)j=ezj/∑kezk\mathrm{softmax}(z)_j = e^{z_j} / \sum_k e^{z_k}turns any real row zz into probabilitiesfloat64[V]
NLL\mathrm{NLL}mean negative log-likelihood, nats per predicted tokenfloat
τ≥0\tau \ge 0sampling temperaturefloat
Fj=∑k≤jpkF_j = \sum_{k \le j} p_kcumulative distribution (CDF) of a row ppfloat64[V]
uuone uniform random number in [0,1)[0, 1)float

Bytes are tokens. Text is stored as UTF-8 bytes, and each byte is a number from 0 to 255. The tracer uses the byte itself as the token id (decision D32, tokenizer.md), so V=256V = 256, there is no tokenizer file, and "hi" is the ids [104, 105]. A character such as é takes two bytes, so it is two tokens.

A language model predicts the next token. Given the text so far, it returns a probability for each of the VV possible next tokens. The bigram assumption is the bluntest one that works: only the previous token matters, P(xt+1∣x1,…,xt)≈P(xt+1∣xt)P(x_{t+1} \mid x_1, \dots, x_t) \approx P(x_{t+1} \mid x_t). So the whole model is a V×VV \times V table: row ii is the distribution of what follows ii.

Counts to probabilities. Slide over the text and count each adjacent pair (xt,xt+1)(x_t, x_{t+1}) into Cxt,xt+1C_{x_t, x_{t+1}}. Row index = where you are, column index = what comes next. The natural estimate is the observed frequency, P(j∣i)=Cij/RiP(j \mid i) = C_{ij} / R_i. It is the maximum-likelihood estimate (M07.2 proves it): no other table gives the training text a higher probability.

Zeros break it, so smooth. A pair that never occurs gets probability 0, and a byte that never occurs as a context has Ri=0R_i = 0, which makes its whole row 0/00/0. Add-alpha smoothing pretends every pair was seen α\alpha extra times:

P(j∣i)=Cij+αRi+αV.P(j \mid i) = \frac{C_{ij} + \alpha}{R_i + \alpha V}.

Each row still sums to one, because ∑j(Cij+α)=Ri+αV\sum_j (C_{ij} + \alpha) = R_i + \alpha V: the denominator must add α\alpha once for each of the VV columns, not once. An unseen context gets the uniform row α/(αV)=1/V\alpha / (\alpha V) = 1/V.

Store logs, not probabilities. The natural logarithm ln⁡\ln turns products into sums, ln⁡(ab)=ln⁡a+ln⁡b\ln(ab) = \ln a + \ln b, and exp⁡\exp undoes it, eln⁡p=pe^{\ln p} = p. The model stores Wij=ln⁡P(j∣i)W_{ij} = \ln P(j \mid i), called logits. Every later model in the course also outputs logits, and turns them into probabilities with softmax. For a row of log-probabilities, softmax gives the probabilities back exactly: eln⁡pj/∑keln⁡pk=pj/1e^{\ln p_j} / \sum_k e^{\ln p_k} = p_j / 1. Softmax also ignores a constant added to every entry, softmax(z+c)=softmax(z)\mathrm{softmax}(z + c) = \mathrm{softmax}(z), which is why you compute it as ezj−max⁡ze^{z_j - \max z}: the largest exponent becomes e0=1e^0 = 1 and nothing overflows.

Negative log-likelihood measures fit. Under the bigram assumption, the probability of the whole text (after its first token) is the product ∏t=1T−1P(xt+1∣xt)\prod_{t=1}^{T-1} P(x_{t+1} \mid x_t). That number underflows for any real text, so take its log and average:

NLL=−1T−1∑t=1T−1ln⁡P(xt+1∣xt).\mathrm{NLL} = -\frac{1}{T-1} \sum_{t=1}^{T-1} \ln P(x_{t+1} \mid x_t).

There are T−1T - 1 predictions, because the first token has no context. The unit is nats per token (natural log). Lower is better; a model that knows nothing (uniform over 256 bytes) scores ln⁡256≈5.545\ln 256 \approx 5.545. For the count model the sum regroups by pair: NLL=−1T−1∑i,jCijln⁡P(j∣i)\mathrm{NLL} = -\frac{1}{T-1} \sum_{i,j} C_{ij} \ln P(j \mid i), a closed form your autograd bigram in L0.5 must reach by gradient descent.

Logits by row gather. A one-hot vector eie_i has a 1 at position ii and 0 elsewhere. Multiplying it by the table selects row ii: (eiW)j=∑k[k=i]Wkj=Wij(e_i W)_j = \sum_k [k = i] W_{kj} = W_{ij}. NumPy expresses the same operation directly as weight[ids], producing the T×VT \times V logits without constructing a T×VT \times V one-hot matrix or doing T⋅V⋅VT \cdot V \cdot V multiply-adds. L10.0 computes the same row selection in Rust.

Temperature. To sample, divide the logits by τ\tau before softmax: p=softmax(z/τ)p = \mathrm{softmax}(z / \tau). With log-probabilities z=ln⁡qz = \ln q this gives pj∝qj1/τp_j \propto q_j^{1/\tau}. At τ=1\tau = 1 you sample the model as is; τ<1\tau < 1 sharpens it (at τ=0.5\tau = 0.5, p∝q2p \propto q^2); τ>1\tau > 1 flattens it. τ=0\tau = 0 is defined as greedy: take the largest logit, and on a tie the lowest id (decision D11, so every implementation agrees).

Sampling by inverse CDF. To draw jj with probability pjp_j, build the running sum Fj=p0+⋯+pjF_j = p_0 + \dots + p_j (so FV−1=1F_{V-1} = 1), draw one uniform u∈[0,1)u \in [0, 1), and return the smallest jj with Fj>uF_j > u. The interval of uu values that lands on jj is [Fj−1,Fj)[F_{j-1}, F_j), whose length is exactly pjp_j. One draw per token, from a generator you created once with a seed, makes the same seed give the same text. The tracer may use numpy.random.default_rng(seed); L0.5 switches to your own PCG32 (M06.3) so Python and Rust agree.

The file. A safetensors file is an 8-byte little-endian length NN, then NN bytes of JSON describing each tensor (dtype, shape, byte offsets), padded with spaces to a multiple of 8, then the raw tensor bytes back to back, little-endian and row-major. The full rules, including the order a canonical writer uses so its bytes match the reference library, are in safetensors.md. The tracer checkpoint holds one tensor, bigram.weight, F32 [256, 256].

Take a three-letter alphabet so the table fits on paper: a = 0, b = 1, c = 2, so V=3V = 3. The text is abbacab, ids [0, 1, 1, 0, 2, 0, 1], T=7T = 7.

Pairs. Six adjacent pairs: ab, bb, ba, ac, ca, ab.

Counts (row = current, column = next):

abcRiR_i
a0213
b1102
c1001

Add one (α=1\alpha = 1, so each row total grows by αV=3\alpha V = 3) and divide:

abc
a1/6 = 0.16673/6 = 0.52/6 = 0.3333
b2/5 = 0.42/5 = 0.41/5 = 0.2
c2/4 = 0.51/4 = 0.251/4 = 0.25

Every row sums to 1. The weight table is the natural log of each entry, for example Wab=ln⁡0.5=−0.6931W_{ab} = \ln 0.5 = -0.6931 and Waa=ln⁡(1/6)=−1.7918W_{aa} = \ln(1/6) = -1.7918.

NLL. The six predictions and their costs −ln⁡P-\ln P:

ttcontext →\to nextPP−ln⁡P-\ln P
1a →\to b3/6ln⁡2=0.6931\ln 2 = 0.6931
2b →\to b2/5ln⁡2.5=0.9163\ln 2.5 = 0.9163
3b →\to a2/5ln⁡2.5=0.9163\ln 2.5 = 0.9163
4a →\to c2/6ln⁡3=1.0986\ln 3 = 1.0986
5c →\to a2/4ln⁡2=0.6931\ln 2 = 0.6931
6a →\to b3/6ln⁡2=0.6931\ln 2 = 0.6931

Sum: 3ln⁡2+2ln⁡2.5+ln⁡3=2.0794+1.8326+1.0986=5.01063 \ln 2 + 2 \ln 2.5 + \ln 3 = 2.0794 + 1.8326 + 1.0986 = 5.0106. Divided by 6: NLL=0.8351\mathrm{NLL} = 0.8351 nats per token, better than the uniform model’s ln⁡3=1.0986\ln 3 = 1.0986.

Logits by row gather. For ids [0, 2], weight[ids] is rows a and c of WW, unchanged.

Greedy. From a the largest entry is b (0.5). From b, a and b tie at 0.4, and ties go to the lowest id, so a. Then b, then a: greedy from [a] for 4 tokens is [1, 0, 1, 0].

Temperature 0.5 on row a. q2=(1/36,9/36,4/36)q^2 = (1/36, 9/36, 4/36), which renormalizes to (1/14,9/14,4/14)(1/14, 9/14, 4/14): b goes from 50% to 64%.

Inverse CDF on row a. F=(1/6,4/6,1)=(0.1667,0.6667,1)F = (1/6, 4/6, 1) = (0.1667, 0.6667, 1). A draw u=0.10u = 0.10 gives a (the first Fj>0.10F_j > 0.10), u=0.50u = 0.50 gives b, u=0.90u = 0.90 gives c.

The file. The format page works out a 120-byte file by hand: one tensor w = [[1, 2], [3, 4]] with metadata {"format": "tinyllm"}. Its header is 93 bytes of JSON, padded with 3 spaces to N=96N = 96; the file is 8+96+16=1208 + 96 + 16 = 120 bytes, ending in 00 00 80 3f 00 00 00 40 00 00 40 40 00 00 80 40 (1.0, 2.0, 3.0, 4.0 as little-endian float32).

These numbers are the first cases in section 4: test_hand_example_probabilities, test_hand_example_nll, test_greedy_ties_go_to_lowest_id, and test_worked_example_bytes.

python/tinyllm/lm/bigram.py
class BigramLM:
weight: NDArray # float32 [V, V], log P(j | i)
def __init__(self, weight: ArrayLike | None = None) -> None
@property
def vocab_size(self) -> int
def fit_counts(self, ids: ArrayLike, vocab_size: int, alpha: float = 1.0) -> None
def logits(self, ids: ArrayLike) -> NDArray # [T, V] = W[ids]
def nll(self, ids: ArrayLike) -> float
def sample(self, prefix: list[int], n: int, temperature: float, seed: int) -> list[int]
# python/tinyllm/io/safetensors.py (v0: F32 only)
def save_safetensors(path: str, tensors: dict[str, NDArray], meta: dict[str, str]) -> None
def load_safetensors(path: str) -> tuple[dict[str, NDArray], dict[str, str]]

The contracts carry the exact rules: which errors are ValueError, that sample returns only the new ids, and that an empty meta omits __metadata__. For input ids i0,…,iT−1i_0, \dots, i_{T-1}, logits is the row gather weight[ids], with output shape TimesVT imes V.

TestKINDChecksWhy it matters downstream
test_hand_example_probabilitiesunitthe section 3 table, from weight and from logitsyou and the test agree on the definition
test_hand_example_nllunitNLL=0.8351\mathrm{NLL} = 0.8351 on abbacabthe number train bigram prints
test_counts_directionboundarytext ab: P(b∣a)=2/3P(b \mid a) = 2/3, row b uniforma transposed table predicts the past
test_rows_sum_to_onepropertyevery row of a 256-byte model sums to 1, unseen bytes includedL10.0 samples from any row
test_nll_matches_countsdifferentialNLL equals the closed form recomputed by loopsthe target L0.5 must reach
test_nll_normalizes_logitsunitrows [0, 0] and [5, 5] give ln⁡2\ln 2L0.5’s trained logits are not normalized
test_alpha_must_be_positiveboundaryα≤0\alpha \le 0 is a ValueErrorno -inf or nan in a checkpoint
test_rejects_out_of_range_idsboundaryids -1 and V are a ValueErrornumpy would wrap -1 silently
test_rejects_non_integer_or_2d_idsboundaryfloat ids and a 2-D batch are a ValueError2.7 is not a byte
test_weight_is_checked_not_convertedboundarya float64, non-square, 1-D, inf, or nan weight is a ValueError; vocab_size before a table is a RuntimeErrora bad checkpoint fails at load, not inside the matmul
test_logits_are_weight_rowsdifferentiallogits(ids) == weight[ids] bit for bit, float32 [T, V]the forward pass L10.0 reproduces
test_greedy_ties_go_to_lowest_idunitgreedy from a is [1, 0, 1, 0]the tie rule every engine shares (D11)
test_temperature_zero_is_greedyunitτ=0\tau = 0 ignores the seedgenerate --greedy
test_sample_returns_only_new_idsunitn new ids, no prefix; n = 0 is []generate prints generated ids only
test_sample_is_seededpropertysame seed, same ids; seeds differmilestones replay a run
test_sample_frequencies_match_modelstatistical6000 transitions fit the table (chi-square, p>10−3p > 10^{-3})the sampler draws the model, not something near it
test_temperature_sharpensstatisticalat τ=0.5\tau = 0.5 transitions fit q2q^2 renormalizedtemperature divides
test_sample_rejects_bad_argsboundaryempty prefix, τ<0\tau < 0, n<0n < 0 are ValueErrorbytes have no start token
test_worked_example_bytesunitthe 120-byte file of section 3header length, padding, byte order
test_matches_library_bytesgoldensix files byte-identical to the pinned safetensors libraryany reader, including your engine, loads your checkpoint
test_bytes_are_little_endian_row_majorboundarya transposed view and a >f4 array write row-major little-endian bytesarrays in memory are not always in file order
test_save_rejects_non_f32boundaryfloat64, int64, and bool arrays are not silently reinterpreted; L0.6-supported dtypes stay validunsupported arrays fail before writing
test_load_reads_library_filesgoldenevery library file loads to the exact inputsgenerate loads the checkpoint
test_roundtrippropertyload(save(x)) == x, writable float32checkpoints survive a round trip
test_load_rejects_bad_filesboundarygap, trailing bytes, wrong element size, short file, duplicate key, unknown F64, malformed supported F16, a JSON array header, a non-string __metadata__ valuea reader never trusts the header
test_load_caps_the_header_lengthboundaryan otherwise valid file with N=100,000,001N = 100{,}000{,}001a corrupt length cannot make the reader parse gigabytes
PitfallSymptomCaught by
1. no smoothing (α=0\alpha = 0)-inf logits for unseen pairs; a row of nan for a byte never seen as a context; 0 * -inf = nan inside the matmultest_alpha_must_be_positive (mutant m01)
2. counting C[next, cur]the model predicts the previous byte; the hand table comes out transposedtest_counts_direction (mutant s02)
3. trusting ids-1 silently counts into the last row (numpy wraps negative indexes)test_rejects_out_of_range_ids (mutant s03)
4. reading logits as log-probabilities in nllcorrect for the count model, wrong the day L0.5 stores trained logitstest_nll_normalizes_logits (mutant s10)
5. a one-hot matrix multiply in logitsallocates an unnecessary TimesVT imes V array instead of gathering rowstest_logits_are_weight_rows
6. seeding a new generator at every stepstill repeatable, but every step draws the same uu, so the text follows one quantile of each rowtest_sample_frequencies_match_model (mutant s09)
7. writing the array’s memory as isa transposed view writes columns; a big-endian array writes swapped bytestest_bytes_are_little_endian_row_major (mutants s15, s16)
8. a reader that trusts the headerreads past a gap or ignores trailing bytes, so a corrupt checkpoint loadstest_load_rejects_bad_files (mutants s20, s21)
9. smoothing denominator Ri+αR_i + \alpharows sum to more than 1test_rows_sum_to_one (mutant s01)
10. multiplying by the temperatureτ=0.5\tau = 0.5 flattens instead of sharpeningtest_temperature_sharpens (mutant s05)
11. np.argmax on a reversed row, or >= in a manual loopgreedy ties go to the highest id, and engines disagreetest_greedy_ties_go_to_lowest_id (mutant s07)
12. scoring the context token in nllan NLL that does not depend on what followstest_hand_example_nll (mutant s06)
13. insertion order, zero padding, padding when already aligned, {} for empty metadata, \u escapesbytes differ from the library’s, so a strict reader or a hash check rejects the filetest_matches_library_bytes (mutants s12, s13, s17, s18), test_worked_example_bytes (mutant s14)
14. converting a loaded tensor with np.ascontiguousarraya scalar tensor (shape []) comes back with shape (1,): ascontiguousarray returns at least 1-D; use np.asarray(x, dtype=np.float32, order="C") (lang.01 pitfall 6)test_roundtrip, test_load_reads_library_files
DirectionModuleHow it uses this
BackM03.1its Python reference defines matrix multiplication; L0.0 uses NumPy row indexing for logits
Backlang.01numpy arrays, dtypes, np.add.at, and broadcasting (the vectorized bigram count)
ForwardL10.0your Rust engine reads bigram.weight from this file (tl_arch = bigram, tl_tokenizer = bytes) and computes the same logits by indexing bigram.weight with token ids
ForwardL0.5takes over bigram.py: trains the same table with your autograd until it reaches the count model’s NLL within 10−310^{-3}, and samples with your PCG32
ForwardL0.6takes over safetensors.py: every dtype, atomic checkpoints, the token-stream reader

If you skip this module, ol check L10.0 stops with L10.0 needs L0.0: build it, or rerun with --ref-deps.

Your pieceProduction equivalentWhat it addsWhere to look
add-alpha bigramKenLM n-gram models5-gram contexts, modified Kneser-Ney smoothing, backoff, compact triesKenLM lm/builder/
one-hot matmul for logitsembedding lookupa row gather instead of a matmul: ggml_get_rows in llama.cpp, torch.nn.Embeddingggml/src/ggml.c
samplevLLM and SGLang samplerstop-k, top-p, min-p, penalties, batched on GPU with per-request seedsvLLM v1/sample/sampler.py
save_safetensorsHugging Face safetensorsevery dtype, memory-mapped zero-copy loading, the Rust core your engine mirrorssafetensors/src/tensor.rs