Electric Sheaves

Step 1 — A Bigram Model from Counts

Released with Lecture 1 · ~3 hours · Prerequisites: Setup complete.

In what follows, there are blocks of code provided for you. It’s important that you can read the code. If you are not proficient with Python, then I propose the following workflow. For each block of code, keywords are provided for the python syntax which is being used, so you can look up python syntax and functions you don’t recognise. I’ve put a “Python background” foldout tutorial on topics you might need, right below the code blocks. You can also ask the copilot in Colab. Then I suggest that you first experiment with changing the code or printing out intermediate variables, until you have figured out what you think it does. Then point AI toward the page and step number, provide your precise explanation of what you believe each line of the code is doing, and ask for corrections. In this way you will quickly become a code reader (if not a code writer). — Kate

Goal

Build the simplest possible language model, namely a bigram model p(xt+1xt)p(x_{t+1} \mid x_t) estimated by counting character pairs in the corpus. Sample text from it, and measure its quality with average log loss (cross-entropy) in nats per character, in bits per character, and with perplexity. These are three reports of the same underlying score. You will measure it twice: first on the text the model was fit on, then on a held-out 10% the counts never saw.

Concept Review

  1. Let VV be a finite alphabet and suppose we model a text x1,,xTx_1,\dots,x_T by a Markov chain: p(x1,,xT)=p(x1)t=1T1p(xt+1xt)p(x_1,\dots,x_T) = p(x_1)\prod_{t=1}^{T-1} p(x_{t+1}\mid x_t), with parameters θab=p(ba)\theta_{ab} = p(b \mid a). Show that the maximum likelihood estimator for a given text is the count ratio θ^ab=Nab/cNac\hat\theta_{ab} = N_{ab} / \sum_{c} N_{ac}, where NabN_{ab} is the number of times character bb follows character aa in the text.
  2. The model’s average log loss on the text is L=1T1t=1T1logp(xt+1xt)\mathcal L = -\frac{1}{T-1}\sum_{t=1}^{T-1} \log p(x_{t+1}\mid x_t). This is the empirical cross-entropy, measured in nats. Divide by ln2\ln 2 for bits per character; perplexity is eLe^{\mathcal L}. Show that perplexity is V|V| exactly when the model is uniform (Lecture 1’s Proposition 4.2).

Tasks

1.1 Load the data

If on Colab, download the data file first: !wget -q https://raw.githubusercontent.com/karpathy/char-rnn/master/data/tinyshakespeare/input.txt

with open('input.txt', 'r') as f:   # with statement (context manager)
    text = f.read()
print(len(text))        # 1,115,394 characters
print(text[:250])       # slicing — First Citizen: Before we proceed any further...
Python background: with statements (context managers)

open(filename, 'r') opens a file for reading; the with ... as f: form names the open file f for the duration of the indented block and guarantees the file is closed when the block ends, even if something goes wrong partway through. This is the standard Python idiom for working with files. The colon plus indentation is how Python marks a block — the same pattern you will see in for, if, and def.

The box below is editable and runnable. It creates a small file first, so it is fully self-contained (the in-page Python has its own tiny private filesystem):

# Create a small file to practice on ('w' means open for writing):
with open('demo.txt', 'w') as f:
    f.write("To be, or not to be,\n")
    f.write("that is the question.\n")
# Read it back as one string ('r' means open for reading):
with open('demo.txt', 'r') as f:
    text = f.read()
print(len(text))
print(text)

Try changing f.read() to f.readlines() — what do you get instead of one string?

Python background: strings, len, and slicing

A string is a sequence of characters, indexed from 0. len(s) is its length, s[i] is one character, and a slice s[start:stop] is the substring from position start up to but not including position stop. Leaving out an endpoint means “from the beginning” or “to the end”, and negative positions count from the end. Slicing works exactly the same on lists.

text = "First Citizen: Before we proceed any further, hear me speak."
print(len(text))
print(text[0])        # indexing starts at 0
print(text[0:5])      # positions 0,1,2,3,4 — the stop index is excluded
print(text[:5])       # same thing: missing start = from the beginning
print(text[15:])      # missing stop = to the end
print(text[-6:])      # negative = count from the end

The pair of slices text[:-1] (drop the last character) and text[1:] (drop the first) will matter in Task 1.3: lined up side by side, they form every adjacent pair of characters.

1.2 Tokenize

Our tokens, for the next five steps, are single characters.

vocab = sorted(set(text))                   # set; sorted
V = len(vocab)                              # 65
stoi = {ch: i for i, ch in enumerate(vocab)}  # dictionary (dict comprehension); enumerate
itos = {i: ch for i, ch in enumerate(vocab)}
encode = lambda s: [stoi[c] for c in s]     # lambda function; list comprehension
decode = lambda ids: ''.join(itos[i] for i in ids)  # str.join; generator expression
assert decode(encode("Fear no more")) == "Fear no more"  # assert statement
Python background: set and sorted

A set keeps one copy of each distinct element, so set(text) is the collection of characters that occur in text, each listed once. Sets promise no particular order; sorted(...) turns any collection into a list in ascending order — for characters, that means order by character code (space before punctuation, digits before capitals, capitals before lowercase). Sorting is what makes the token ids reproducible.

text = "to be or not to be"
distinct = set(text)
print(distinct)            # each character once, in no promised order
vocab = sorted(distinct)   # a list, in a fixed, reproducible order
print(vocab)
print(len(vocab))

Try a different text — which characters make the cut?

Python background: dictionaries, enumerate, and dict comprehensions

A dictionary (dict) maps keys to values: stoi['b'] looks up the value stored under the key 'b'. enumerate(vocab) walks a list while also counting, producing the pairs (0, first item), (1, second item), and so on. A dict comprehension {key: value for ... in ...} builds a dictionary from any such stream of pairs in a single line.

vocab = ['a', 'b', 'n']
for i, ch in enumerate(vocab):
    print(i, ch)
# Build character-to-integer and integer-to-character tables:
stoi = {ch: i for i, ch in enumerate(vocab)}
itos = {i: ch for i, ch in enumerate(vocab)}
print(stoi)
print(itos)
print(stoi['b'])           # look up a key
print(itos[2])

stoi and itos (“string to int”, “int to string”) are inverse tables: itos[stoi[ch]] is ch again. Try adding a character to vocab and rerunning.

Python background: lambda functions and list comprehensions

A list comprehension [expression for item in sequence] applies an expression to every element of a sequence and collects the results in a new list. A lambda is a small one-expression function defined in one line and stored in a variable: encode = lambda s: ... is a compact way of defining a function named encode.

squares = [n * n for n in [1, 2, 3, 4]]
print(squares)
double = lambda x: 2 * x       # a one-line function...
print(double(7))               # ...called like any other
# Combined, exactly as in the code above:
stoi = {'a': 0, 'b': 1, 'n': 2}
encode = lambda s: [stoi[c] for c in s]
print(encode("banana"))

A string is a sequence of its characters, so for c in s visits each character in turn.

Python background: str.join and generator expressions

sep.join(pieces) glues a sequence of strings together with sep between them — so ''.join(...), with an empty separator, is plain concatenation. A generator expression looks like a list comprehension without the square brackets: it produces its items one at a time, on demand, and join consumes them directly.

words = ['To', 'be', 'or', 'not']
print(' '.join(words))     # the separator goes in front
print(''.join(words))
itos = {0: 'a', 1: 'b', 2: 'n'}
ids = [1, 0, 2, 0, 2, 0]
print(''.join(itos[i] for i in ids))   # decode, as in the code above
Python background: assert statements

assert condition does nothing if the condition is true, and stops the program with an AssertionError if it is false. Sprinkling cheap asserts through numerical code — “rows sum to 1”, “decode undoes encode” — turns silent wrong answers into loud, immediate failures, and this course leans on that habit heavily.

assert 2 + 2 == 4                # true: nothing happens
print("still running")
assert 2 + 2 == 5, "arithmetic is broken"   # false: stops with an error
print("this line is never reached")

The string after the comma is an optional message shown when the assertion fails.

Look at vocab. What are tokens 0 and 1? Which characters made the cut?

1.3 Count

PyTorch (import torch) is the deep-learning library used throughout this course. At its core it is a Python library for computing with tensors — multi-dimensional numerical arrays, generalizing vectors and matrices — with two extras that matter later: the same code can run on a GPU, and it can differentiate through computations automatically (autograd, which you will build a miniature of yourself in Step 3 and use ever after). This week we use none of that: it is purely an array library here, playing the role NumPy plays elsewhere in scientific Python.

Build the 65×6565 \times 65 count matrix NN with NabN_{ab} = occurrences of character pair (a,b)(a, b), as a PyTorch integer tensor. Then visualize it:

import torch
import matplotlib.pyplot as plt

N = torch.zeros((V, V), dtype=torch.int64)  # keyword argument (dtype=)
ids = encode(text)
for a, b in zip(ids, ids[1:]):    # zip; tuple unpacking; list slicing
    N[a, b] += 1                  # augmented assignment (+=)

plt.figure(figsize=(10, 10))
plt.imshow(N.log1p(), cmap='Blues')   # log(1+N): raw counts span 4 orders of magnitude
PyTorch background: tensors

A tensor is PyTorch’s array: a grid of numbers with a shape (one entry per axis) and a dtype (the kind of number in each cell). Tensors support arithmetic, indexing, slicing, and sums, acting elementwise or along axes — the same semantics as the NumPy arrays used in the live boxes of other foldouts, with NumPy’s axis= / keepdims= spelled dim= / keepdim= in PyTorch. (PyTorch itself doesn’t run in the browser, so this foldout has no live box; the comments show what each line produces.)

import torch
N = torch.zeros((3, 2), dtype=torch.int64)  # a 3x2 grid of integer zeros
N[0, 1] += 1                     # index by row, column
print(N.shape)                   # torch.Size([3, 2])
t = torch.tensor([1., 2., 3.])   # build a tensor from a Python list
print(t * 10)                    # tensor([10., 20., 30.]) — elementwise
print(t.sum())                   # tensor(6.) — still a (0-dimensional) tensor...
print(t.sum().item())            # 6.0 — .item() unwraps it to a plain Python number

Everything the model does in this course — counting, normalizing, sampling, training — happens in tensors.

Python background: keyword arguments

Function arguments can be passed by position or by name. Passing one by name — a keyword argument, written name=value — makes calls readable and lets you skip arguments that have default values. The code above uses one: in torch.zeros((V, V), dtype=torch.int64), the keyword argument dtype names the kind of number the tensor holds (64-bit integers — the right choice for exact counts).

def repeat(word, times=2, sep=' '):     # times and sep have defaults
    return sep.join([word] * times)
print(repeat('fear'))                   # use both defaults
print(repeat('fear', times=4))          # override one default, by name
print(repeat('fear', sep='-', times=3)) # named arguments go in any order
Python background: zip and tuple unpacking

zip(xs, ys) walks two sequences in lockstep, producing the pairs (xs[0], ys[0]), (xs[1], ys[1]), … and stopping when the shorter one runs out. Tuple unpacking for a, b in ... splits each pair into two named variables on the spot. Zipping a list against its own tail ids[1:] therefore visits every adjacent pair — exactly the bigrams.

ids = [5, 3, 8, 3, 5]
print(ids[1:])               # the same list shifted left by one
for a, b in zip(ids, ids[1:]):
    print(a, b)              # each element with its successor

Five elements give four adjacent pairs — which is why the count matrix sums to len(text) - 1.

Python background: augmented assignment (+=)

x += 1 is shorthand for x = x + 1: read the current value, add, and store the result back. It works on anything indexable too, so N[a, b] += 1 means “add 1 to the entry of N in row a, column b” — the counting step at the heart of this whole model.

count = 0
count += 1
count += 10
print(count)
tallies = {'a': 0, 'b': 0}
tallies['a'] += 1
tallies['a'] += 1
print(tallies)

Stare at the heatmap and find three things (each is checkable from N, and the answers are in the checkpoints):

  1. the row for q — what single column dominates, and how completely?
  2. the column for space — is every character ever followed by a space?
  3. the capital-letter block — is it brighter into lowercase or into itself? Predict before you look, then explain what you actually see.

1.4 Normalize — carefully

Turn counts into the MLE transition matrix PP with rows summing to 1. Use add-one (Laplace) smoothing, PabNab+1P_{ab} \propto N_{ab} + 1: some pairs never occur, and a single zero probability makes the log-likelihood of any text containing that pair -\infty. (Question worth 30 seconds of thought: what is Laplace smoothing the Bayesian posterior mean for?)

P = (N + 1).float()                 # method call on an expression
P = P / P.sum(dim=1, keepdim=True)  # broadcasting (row-wise division)
assert torch.allclose(P.sum(dim=1), torch.ones(V))
Python background: methods and method chaining

A method is a function attached to a value, called with a dot: s.upper(). Any expression has methods, so you can parenthesize a computation and call a method on its result — (N + 1).float() builds the matrix N + 1, then converts its entries to floating point (counts are integers, but probabilities need fractions). Chains read left to right.

s = "fear no more"
print(s.upper())              # a method on a string
print(s.split())              # split on spaces, into a list
print(s.upper().split())      # chained: left to right
print(("no " * 3).strip())    # a method on a parenthesized expression
Python background: broadcasting

When you combine arrays of different shapes, NumPy and PyTorch broadcast: an axis of size 1 is stretched to match the other operand. Dividing a matrix of shape (2, 3) by its row-sums kept as a column of shape (2, 1) therefore divides each row by its own sum — which is exactly how P is normalized above. This box uses NumPy, since PyTorch does not run in the browser; the ideas are identical, with NumPy’s axis= / keepdims= spelled dim= / keepdim= in PyTorch. (The first run downloads NumPy, so give it a moment.)

import numpy as np
M = np.array([[1., 2., 3.],
              [4., 5., 6.]])
sums = M.sum(axis=1, keepdims=True)
print(sums)                 # shape (2, 1): one sum per row, kept as a column
print(M / sums)             # broadcasting: each row divided by its own sum
print(M.sum(axis=1))        # without keepdims: shape (2,) — a flat vector

Try dividing by M.sum(axis=1) (no keepdims) — here the shapes fail to line up and you get an error. Beware: for a square matrix like the 65×65 P, that same mistake broadcasts the other way and silently normalizes columns instead of rows. That is precisely the bug in the Troubleshooting section, and why keepdim=True appears in the course code.

1.5 Sample

Generate text by running the Markov chain: start from the newline character, repeatedly draw the next character from row PxtP_{x_t} (torch.multinomial), for 500 steps. Decode and read your model’s Shakespeare aloud.

1.6 Evaluate on the training text

Compute your model’s average log loss on the text (average logPxt,xt+1-\log P_{x_t, x_{t+1}}; vectorize it — P[ids[:-1], ids[1:]] indexes all the transitions at once) and report:

One caveat to keep in mind throughout: the counts came from this same text, so every number in this task is a training value — the model is being graded on the exam it studied from. Task 1.7 is the honest version.

Guidance. You are implementing Lecture 1 §3’s token-level log loss, specialized to a bigram model with pθ(ba)=Pabp_\theta(b\mid a)=P_{ab} and T1T-1 predicted tokens. Perplexity is defined in Lecture 1 §4; §6 gives the bits-per-character conversion.

You are computing one number per transition: for each of the T1T-1 adjacent pairs (xt,xt+1)(x_t, x_{t+1}) in the text, look up the probability your model assigned to the character that actually came next, take log-\log, and average. The vectorized lookup works because PyTorch accepts a pair of index tensors:

ids_t = torch.tensor(ids)              # list -> tensor
p_next = P[ids_t[:-1], ids_t[1:]]      # paired (fancy) indexing: entry t is P[ids[t], ids[t+1]]
Python background: fancy (paired) indexing

Indexing an array with a pair of index arraysP[rows, cols] — picks out one entry per position: (rows[0], cols[0]), then (rows[1], cols[1]), and so on. It is the vectorized replacement for a loop over pairs. As in the broadcasting foldout, this box uses NumPy; PyTorch tensors index identically.

import numpy as np
P = np.array([[0.1, 0.2, 0.7],
              [0.5, 0.5, 0.0],
              [0.3, 0.3, 0.4]])
print(P[1, 2])                # one entry: row 1, column 2
rows = np.array([0, 1, 2])
cols = np.array([2, 0, 1])
print(P[rows, cols])          # entries (0,2), (1,0), (2,1), all at once
ids = np.array([0, 2, 1, 1, 0])
print(P[ids[:-1], ids[1:]])   # one entry per adjacent pair of ids

The last line is Task 1.6 in miniature: entry tt is the probability the model assigned to the transition from ids[t] to ids[t+1].

p_next has length len(text) - 1, and entry tt is the model’s probability for character t+1t{+}1 given character tt — the likelihood of the corpus, unrolled into a vector. From it: the mean of log-\log is L\mathcal L in nats; divide by ln2\ln 2 for bits per character, and apply eLe^{\mathcal L} to obtain perplexity.

Checks and common stumbles, in the order people hit them:

1.7 Evaluate on held-out text

Task 1.6 graded the model on the text its counts came from (teaching to the test). It’s better to test it on fresh data.

  1. Split chronologically: the first 90% of the text is the training set, the last 10% the validation set.

    n_train = int(0.9 * len(ids))            # 1,003,854
    ids_tr = torch.tensor(ids[:n_train])
    ids_va = torch.tensor(ids[n_train:])     # 111,540 characters

    Keep the vocabulary from Task 1.2, built from the full text. (Conveniently, the last 10% contains no characters absent from the first 90%, so nothing else changes.)

  2. Refit from training counts only. Rebuild the count matrix N_tr and the add-one matrix P_tr exactly as in Tasks 1.3–1.4, but from ids_tr alone.

  3. Evaluate twice through one pipeline. Wrap the Task 1.6 computation in a function avg_log_loss(P, ids) and call it on ids_tr and on ids_va (score the transitions internal to each portion). Report log loss in nats, bits per character, and perplexity for both.

  4. Count the transitions training never saw.

    train_count_of_val_pairs = N_tr[ids_va[:-1], ids_va[1:]]
    print((train_count_of_val_pairs == 0).sum())
  5. Run the uniform model through the same validation pipeline. It must give exactly 4.1744 nats again: any change from Task 1.6 is a bug.

Python background: defining functions with def

def name(inputs): starts a function definition; the indented body runs each time the function is called, and return hands the answer back to the caller. Wrapping the Task 1.6 computation in a function is what lets you run one identical pipeline on two different texts — the point of step 3 above.

def avg(numbers):
    total = sum(numbers)
    return total / len(numbers)
print(avg([1.0, 2.0, 6.0]))
print(avg([10, 20]))          # the same code, reused on different input

Guidance. Validation loss is the number that estimates performance on new text (Lecture 1 §6.3); training loss only tells you how well the counts memorized their own corpus. The gap you should see is real but tiny, about 0.03 nats. The bigram matrix has only 4,225 cells sharing a million characters of evidence, so almost every cell is estimated from abundant data. Overfitting is the tendency of a model to do better on the training data than on novel validation data. In general, overfitting is a function of the ratio of parameters to data, and here that ratio is tiny.

Checkpoints

For calibration: Shannon (1951) estimated English at ~1 bit/character, and your Step 6 transformer will reach roughly 1.5 bits/char on this dataset. Watch your bits/char drop as the course proceeds.

Troubleshooting

Going further (optional)

Catch-up

None — this is the first step. If you’re joining after Lecture 2+, run solutions/step-01.ipynb and read its prose; it’s self-contained.