Emmanuel Akinfulubi

Foundations all 4 written

The Python, the vectors and the calculus you actually use. Nothing decorative.

4 The Python an AI engineer writes

You do not need all of Python. You need the shapes data arrives in, the two or three constructs that keep the code short, and one honest understanding of how floats behave. That last one is not decoration: every similarity score, probability and gradient in the rest of this course is a float, and comparing floats carelessly is the most common way a correct implementation looks broken.

The shapes that actually turn up

Almost every AI pipeline moves the same object around: a list of dicts. Documents, chunks, messages, tool calls, evaluation cases. Learn to map, filter and index that shape and you can read most Python in this field.

Map, filter, reduce and index over a list of dicts

ran at build time
# A list of dicts, and the four things you do to it constantly.
DOCS = [
    {"id": "d1", "title": "Refund policy", "words": 210, "tags": ["policy", "support"]},
    {"id": "d2", "title": "Shipping zones", "words": 940, "tags": ["logistics"]},
    {"id": "d3", "title": "Returns FAQ", "words": 130, "tags": ["policy", "faq"]},
]

titles = [d["title"] for d in DOCS]                    # map
short = [d["id"] for d in DOCS if d["words"] < 300]    # filter
total = sum(d["words"] for d in DOCS)                  # reduce, and lazily: no list is built
by_id = {d["id"]: d for d in DOCS}                     # index once, look up many times

# Counting without a library. dict.get with a default is the whole technique.
tag_counts = {}
for d in DOCS:
    for tag in d["tags"]:
        tag_counts[tag] = tag_counts.get(tag, 0) + 1

# Sorting: key= takes a function. Negating the number is how you sort descending.
longest = sorted(DOCS, key=lambda d: -d["words"])[0]

print("titles :", titles)
print("short  :", short)
print("words  :", total)
print("tags   :", tag_counts)
print("longest:", longest["title"])

test("three documents go in and three titles come out", len(titles), 3)
test("two of the three documents are under three hundred words", short, ["d1", "d3"])
test("the word counts add up to 1280", total, 1280)
test("policy is the only tag that appears twice", [t for t, n in tag_counts.items() if n == 2], ["policy"])
test("shipping zones is the longest document", longest["title"], "Shipping zones")
test("the index finds a document by its id", by_id["d3"]["title"], "Returns FAQ")

What it printed, when the build ran it

titles : ['Refund policy', 'Shipping zones', 'Returns FAQ']
short  : ['d1', 'd3']
words  : 1280
tags   : {'policy': 2, 'support': 1, 'logistics': 1, 'faq': 1}
longest: Shipping zones
ok   three documents go in and three titles come out
ok   two of the three documents are under three hundred words
ok   the word counts add up to 1280
ok   policy is the only tag that appears twice
ok   shipping zones is the longest document
ok   the index finds a document by its id

If you write JavaScript, you already know all four operations under different names. The panel below is the same work in the language you came from, so you can read the Python by translation rather than by memorisation. It is the only JavaScript panel in this lesson, and unlike the Python it runs in your browser.

The same four operations in JavaScript, for translation

runs here
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const test = (name, got, want) => console.log(
  (same(got, want) ? 'ok   ' : 'FAIL ') + name +
  (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
);
// Never use === on a computed float. See the Foundations lesson on floating point.
const close = (a, b, tol = 1e-9) => Math.abs(a - b) < tol;

// The same four operations, in the language you already know.
const DOCS = [
  { id: 'd1', title: 'Refund policy', words: 210 },
  { id: 'd2', title: 'Shipping zones', words: 940 },
  { id: 'd3', title: 'Returns FAQ', words: 130 },
];

const titles = DOCS.map((d) => d.title);                      // [d["title"] for d in DOCS]
const short = DOCS.filter((d) => d.words < 300).map((d) => d.id);
const total = DOCS.reduce((n, d) => n + d.words, 0);          // sum(d["words"] for d in DOCS)
const byId = Object.fromEntries(DOCS.map((d) => [d.id, d]));  // {d["id"]: d for d in DOCS}

console.log('titles :', titles);
console.log('short  :', short);
console.log('words  :', total);

test('map is a comprehension with no condition', titles.length, 3);
test('filter then map is a comprehension with an if', short, ['d1', 'd3']);
test('reduce is sum(), spelled out at length', total, 1280);
test('an object built from entries is the dict comprehension', byId.d3.title, 'Returns FAQ');

Output


      

One real difference is worth holding on to. A Python comprehension inside square brackets builds the whole list in memory; the same expression inside round brackets is a generator and builds nothing until something iterates it. sum(d["words"] for d in DOCS) never allocates a list. On four documents that is trivia. On four million rows it is the difference between working and not.

Floating point: the reason every float check here uses close()

A float is a binary approximation of a decimal number. Most decimals have no exact binary form, so arithmetic accumulates a tiny error. This is not a Python quirk, it is how the hardware works, and it is the same in JavaScript.

Why == on a computed float is a bug waiting to happen

ran at build time
# The one panel in this course that compares floats with ==, so you can watch it fail.
a = 0.1 + 0.2
print("0.1 + 0.2      =", a)
print("is it 0.3?     =", a == 0.3)
print("close to 0.3?  =", close(a, 0.3))

# The error compounds. Add a tenth ten times and you do not arrive at one.
running = 0.0
for _ in range(10):
    running += 0.1
print()
print("0.1 added ten times =", repr(running))
print("short of 1.0 by     =", abs(running - 1.0))

# Worse: the order of a sum changes the answer, so addition is not associative.
up = sum([1e16, 1.0, 1.0, -1e16])
down = sum([1e16, -1e16, 1.0, 1.0])
print()
print("1e16 + 1 + 1 - 1e16 =", up, " <- both ones were swallowed")
print("1e16 - 1e16 + 1 + 1 =", down)

test("a tenth plus two tenths is not three tenths", a == 0.3, False)
test("but it is close enough for any real purpose", close(a, 0.3), True)
test("adding a tenth ten times does not reach one either", running == 1.0, False)
test("close() forgives that too", close(running, 1.0), True)
test("the error is vanishingly small, which is exactly why it hides", abs(a - 0.3) < 1e-15, True)
test("changing the order of a sum changes its result", up == down, False)

What it printed, when the build ran it

0.1 + 0.2      = 0.30000000000000004
is it 0.3?     = False
close to 0.3?  = True

0.1 added ten times = 0.9999999999999999
short of 1.0 by     = 1.1102230246251565e-16

1e16 + 1 + 1 - 1e16 = 0.0  <- both ones were swallowed
1e16 - 1e16 + 1 + 1 = 2.0
ok   a tenth plus two tenths is not three tenths
ok   but it is close enough for any real purpose
ok   adding a tenth ten times does not reach one either
ok   close() forgives that too
ok   the error is vanishingly small, which is exactly why it hides
ok   changing the order of a sum changes its result

The rule for the rest of this course. Never assert equality on a computed float. Use close(a, b), which is the math.isclose idea with an absolute tolerance. Two follow-on habits matter as much: do not accumulate a long sum in a loop when the numbers vary wildly in size, and never test a computed value for being exactly zero — test that it is smaller than a threshold you chose on purpose. The vectors lesson has a division that blows up precisely there.

Generators, because model output arrives one token at a time

Streaming is the normal case when you call a model, so the generator is not an advanced topic here, it is the default shape. A function with yield in it returns something that produces values on demand and remembers where it stopped.

Lazy sequences, and the one-shot rule that catches everyone

ran at build time
# Generators: the shape you meet the moment you stream tokens out of a model.
import itertools


def token_stream(text):
    """Yields one token at a time. Nothing happens until somebody asks for one."""
    for word in text.split():
        yield word


def take(iterable, n):
    """The first n items, and no more work than that."""
    out = []
    for item in iterable:
        out.append(item)
        if len(out) == n:
            break
    return out


stream = token_stream("the quick brown fox jumps over the lazy dog")
first_three = take(stream, 3)
rest = list(stream)          # the SAME generator, resumed where it left off

print("first three  :", first_three)
print("what remained:", rest)
print("second pass  :", list(stream), "<- a generator is consumed, not rewound")

# A generator expression is a lazy comprehension. This allocates nothing.
squares = (n * n for n in range(1000000))
print("first five squares:", take(squares, 5))

# itertools writes the awkward plumbing for you, correctly.
counted = list(itertools.islice(itertools.count(10), 4))
print("four items from an endless counter:", counted)

test("asking for three tokens gives three tokens", first_three, ["the", "quick", "brown"])
test("the generator resumes at the fourth token, not the first", rest[0], "fox")
test("nothing was lost between the two reads", len(first_three) + len(rest), 9)
test("reading a used generator again yields nothing at all", list(stream), [])
test("a million squares cost nothing until you take five", take((n * n for n in range(1000000)), 5), [0, 1, 4, 9, 16])
test("an endless counter is safe as long as you slice it", counted, [10, 11, 12, 13])

What it printed, when the build ran it

first three  : ['the', 'quick', 'brown']
what remained: ['fox', 'jumps', 'over', 'the', 'lazy', 'dog']
second pass  : [] <- a generator is consumed, not rewound
first five squares: [0, 1, 4, 9, 16]
four items from an endless counter: [10, 11, 12, 13]
ok   asking for three tokens gives three tokens
ok   the generator resumes at the fourth token, not the first
ok   nothing was lost between the two reads
ok   reading a used generator again yields nothing at all
ok   a million squares cost nothing until you take five
ok   an endless counter is safe as long as you slice it

The trap is in the third print. A generator can be read once. Code that iterates a stream to count it and then iterates again to use it gets an empty second pass, and in a pipeline that failure looks like "the model returned nothing" rather than "I consumed it twice". If you need it twice, materialise it once with list(...) and pay the memory on purpose.

Shapes with names, and two traps in the way you declare them

Dicts are fine inside a function. At a boundary — something you index, cache, log or hand to another module — give the shape a name. A dataclass is the cheapest way, and freezing it stops anything downstream editing a record that has already been indexed.

A named shape, a frozen record, and the two traps in between

ran at build time
# Dataclasses for the shapes that cross a boundary, and the two traps nearby.
from dataclasses import dataclass, field, replace
from typing import Dict, List, Optional, Union


@dataclass(frozen=True)
class Chunk:
    """A slice of a document on its way to being embedded.

    Python here is 3.9, which has no  int | str  unions and no  match  statement.
    A maybe-vector is spelled Optional[List[float]], and a value that is either a
    string or a number is Union[str, int].
    """

    doc_id: str
    text: str
    vector: Optional[List[float]] = None
    meta: Dict[str, Union[str, int]] = field(default_factory=dict)

    def is_embedded(self) -> bool:
        return self.vector is not None


raw = Chunk(doc_id="d1", text="refunds are accepted within fourteen days")
embedded = replace(raw, vector=[0.2, -0.1, 0.4], meta={"source": "policy.md", "page": 3})

print("raw      :", raw.doc_id, "embedded?", raw.is_embedded())
print("embedded :", embedded.doc_id, "embedded?", embedded.is_embedded(),
      "dims", len(embedded.vector))

try:
    raw.text = "something else"
    frozen = False
except Exception as error:
    frozen = True
    print("editing a frozen chunk raises", type(error).__name__)


# Trap one. A mutable default is built once, when the function is defined, and then
# shared by every call for the lifetime of the process.
def collect(item, into=[]):
    into.append(item)
    return into


first = collect("a")
second = collect("b")
print()
print("collect('a') ->", first)
print("collect('b') ->", second, "<- not ['b']")


def collect_safely(item, into: Optional[List[str]] = None) -> List[str]:
    """The fix, and the only correct spelling of it."""
    if into is None:
        into = []
    into.append(item)
    return into


# Trap two. bool is a subclass of int, so a boolean passes a numeric check.
def looks_numeric(value: object) -> bool:
    return isinstance(value, (int, float))


def is_numeric(value: object) -> bool:
    return isinstance(value, (int, float)) and not isinstance(value, bool)


test("a fresh chunk has no vector yet", raw.is_embedded(), False)
test("replace() returns a new chunk and leaves the original alone",
     (embedded.is_embedded(), raw.vector), (True, None))
test("a frozen dataclass refuses to be edited in place", frozen, True)
test("a mutable default argument is shared between calls", second, ["a", "b"])
test("both calls handed back the very same list object", first is second, True)
test("defaulting to None instead keeps every call independent",
     [collect_safely("a"), collect_safely("b")], [["a"], ["b"]])
test("True passes a naive number check and fails a careful one",
     [looks_numeric(True), is_numeric(True)], [True, False])

What it printed, when the build ran it

raw      : d1 embedded? False
embedded : d1 embedded? True dims 3
editing a frozen chunk raises FrozenInstanceError

collect('a') -> ['a', 'b']
collect('b') -> ['a', 'b'] <- not ['b']
ok   a fresh chunk has no vector yet
ok   replace() returns a new chunk and leaves the original alone
ok   a frozen dataclass refuses to be edited in place
ok   a mutable default argument is shared between calls
ok   both calls handed back the very same list object
ok   defaulting to None instead keeps every call independent
ok   True passes a naive number check and fails a careful one

What a real project would use instead

Nothing above needed a library, and that is deliberate: this course has to verify itself with the standard library alone. In a real pipeline the numeric work goes to numpy, and the tabular work to pandas, for one reason worth stating plainly. A Python-level loop over a million floats runs in the interpreter; numpy pushes the same loop into compiled code over a contiguous block of memory, which is typically tens to hundreds of times faster and, just as importantly, shorter to read.

What numpy and pandas would do instead

shown, not run
# The same operations with the libraries a real project would use.
# NOT RUN HERE - numpy and pandas are not installed.

import numpy as np
import pandas as pd

# One embedding: a fixed-width array of float32, not a Python list of float64.
vector = np.array([0.2, -0.1, 0.4], dtype=np.float32)

# A whole corpus: 10,000 embeddings of 768 dimensions, as one 2-D block.
matrix = np.zeros((10000, 768), dtype=np.float32)

# Every similarity in the next lesson, for all 10,000 documents, in one call.
scores = matrix @ vector[:768]        # the loop happens in compiled code

# The list-of-dicts from the first panel, as a dataframe.
docs = pd.DataFrame([
    {"id": "d1", "title": "Refund policy", "words": 210},
    {"id": "d2", "title": "Shipping zones", "words": 940},
])
short = docs[docs["words"] < 300]                 # the filter comprehension
total = docs["words"].sum()                       # the sum
by_id = docs.set_index("id")                      # the dict comprehension

numpy and pandas are not installed in this environment, so this panel was never executed. Every operation in it has a plain-Python equivalent above that did run. Read it as the shape to reach for in a real project, not as verified output.

The useful reading order, if you want to be efficient about this: comprehensions, dicts, generators, dataclasses, typing. That is roughly ninety per cent of the Python you will write as an AI engineer. Decorators, metaclasses, async and the descriptor protocol can wait until something you are reading actually uses them.

The two traps in this lesson are not equally visible. A frozen dataclass or a shared mutable default fails loudly and once. Floating point fails quietly and forever: a cosine similarity of 0.9999999999999998 compared with 1.0 is a test that fails for no reason a reader can see, and it is the single most common bug in hand-written vector code. Every float assertion from here on uses close, and now you know why.

5 Vectors, distance and similarity

A vector is a list of numbers. An embedding is a list of numbers produced by a model, where nearby lists mean similar things. That is the whole idea, and the arithmetic that exploits it fits in three short functions: a dot product, a length, and a division. This lesson writes all three from scratch and then shows the two places they mislead you.

The dot product, which everything else is made of

Multiply the two vectors position by position and add up the results. One number comes out. It is large and positive when the vectors point the same way, near zero when they are unrelated, and negative when they oppose.

A dot product, and the silent truncation it has to guard against

ran at build time
# The dot product. Every similarity score in this course is built on this.
def dot(a, b):
    if len(a) != len(b):
        raise ValueError("vectors must be the same length, got {} and {}".format(len(a), len(b)))
    return sum(x * y for x, y in zip(a, b))


a = [1.0, 2.0, 3.0]
b = [4.0, -5.0, 6.0]

print("a . b =", dot(a, b), "  (4 - 10 + 18)")
print("a . a =", dot(a, a), "  <- a vector dotted with itself is its squared length")
print("[1,0] . [0,1] =", dot([1.0, 0.0], [0.0, 1.0]), "  <- perpendicular")
print("[1,1] . [-1,-1] =", dot([1.0, 1.0], [-1.0, -1.0]), "  <- opposed")

# The length check is not defensive clutter. zip() stops at the shorter input and
# says nothing, so without it a 768-dimension query silently scores against a
# 1536-dimension document and returns a plausible, wrong number.
print()
print("zip would have quietly truncated:", list(zip([1, 2, 3], [1, 2])))
try:
    dot([1.0, 2.0, 3.0], [1.0, 2.0])
    refused = False
except ValueError as error:
    refused = True
    print("so we refuse it instead:", error)

test("the dot product of a and b is twelve", close(dot(a, b), 12.0), True)
test("a vector dotted with itself gives its squared length", close(dot(a, a), 14.0), True)
test("perpendicular vectors dot to zero", close(dot([1.0, 0.0], [0.0, 1.0]), 0.0), True)
test("opposed vectors give a negative number", dot([1.0, 1.0], [-1.0, -1.0]) < 0.0, True)
test("the order of the two vectors makes no difference", close(dot(a, b), dot(b, a)), True)
test("mismatched lengths are refused rather than truncated", refused, True)

What it printed, when the build ran it

a . b = 12.0   (4 - 10 + 18)
a . a = 14.0   <- a vector dotted with itself is its squared length
[1,0] . [0,1] = 0.0   <- perpendicular
[1,1] . [-1,-1] = -2.0   <- opposed

zip would have quietly truncated: [(1, 1), (2, 2)]
so we refuse it instead: vectors must be the same length, got 3 and 2
ok   the dot product of a and b is twelve
ok   a vector dotted with itself gives its squared length
ok   perpendicular vectors dot to zero
ok   opposed vectors give a negative number
ok   the order of the two vectors makes no difference
ok   mismatched lengths are refused rather than truncated

Length, and what normalising actually does

The length of a vector — its norm — is Pythagoras in as many dimensions as you have. Divide a vector by its own length and you get a unit vector: same direction, length one. That division is where hand-written vector code breaks.

The norm, the unit vector, and the guard on the division

ran at build time
# Length, unit vectors, and the division that has to be guarded.
import math


def dot(a, b):
    return sum(x * y for x, y in zip(a, b))


def norm(v):
    """The length of v. Pythagoras, in as many dimensions as you like."""
    return math.sqrt(sum(x * x for x in v))


def unit(v):
    """Same direction, length one. Refuses anything too short to have a direction."""
    length = norm(v)
    if length < 1e-12:
        raise ValueError("a vector this short has no reliable direction")
    return [x / length for x in v]


print("norm([3, 4])        =", norm([3.0, 4.0]), " <- the 3-4-5 triangle")
print("norm([1, 2, 3])     =", norm([1.0, 2.0, 3.0]))
print("unit([3, 4])        =", unit([3.0, 4.0]))
print("norm of that unit   =", norm(unit([3.0, 4.0])))

# The guard matters because the failure is not a clean error. A vector of tiny
# numbers - an empty chunk, a padded row, a de-meaned constant feature - divides
# into nonsense long before it divides by zero.
tiny = [1e-200, 1e-200]
print()
print("norm of a tiny vector:", norm(tiny), "<- underflowed to exactly zero")
try:
    unit(tiny)
    refused = False
except ValueError as error:
    refused = True
    print("so we refuse it:", error)

test("the three four five triangle has length five", close(norm([3.0, 4.0]), 5.0), True)
test("a unit vector has length one", close(norm(unit([3.0, 4.0])), 1.0), True)
test("the length is the square root of the vector dotted with itself",
     close(norm([1.0, 2.0, 3.0]), math.sqrt(dot([1.0, 2.0, 3.0], [1.0, 2.0, 3.0]))), True)
test("doubling a vector doubles its length", close(norm([6.0, 8.0]), 2.0 * norm([3.0, 4.0])), True)
test("normalising keeps the direction, so the unit vectors agree",
     close(dot(unit([3.0, 4.0]), unit([30.0, 40.0])), 1.0), True)
test("a vector too short to have a direction is refused", refused, True)

What it printed, when the build ran it

norm([3, 4])        = 5.0  <- the 3-4-5 triangle
norm([1, 2, 3])     = 3.7416573867739413
unit([3, 4])        = [0.6, 0.8]
norm of that unit   = 1.0

norm of a tiny vector: 0.0 <- underflowed to exactly zero
so we refuse it: a vector this short has no reliable direction
ok   the three four five triangle has length five
ok   a unit vector has length one
ok   the length is the square root of the vector dotted with itself
ok   doubling a vector doubles its length
ok   normalising keeps the direction, so the unit vectors agree
ok   a vector too short to have a direction is refused

Notice the guard is length < 1e-12 and not length == 0.0. An exact zero is the easy case. The one that ruins an afternoon is a vector of very small numbers whose norm underflows or nearly does: the division produces enormous values or inf, the similarity score comes out as nan, and nan compares false against everything, so the document quietly ranks last instead of raising.

Cosine similarity: the dot product with the length divided out

Cosine similarity is the dot product of the two unit vectors. It answers one question — do these point the same way — and deliberately ignores how long either one is. The answer runs from 1 for identical directions, through 0 for unrelated, to -1 for opposite.

Cosine similarity, which is direction only

ran at build time
# Cosine similarity, built from the two previous functions.
import math


def dot(a, b):
    return sum(x * y for x, y in zip(a, b))


def norm(v):
    return math.sqrt(sum(x * x for x in v))


def cosine(a, b):
    denominator = norm(a) * norm(b)
    if denominator < 1e-12:
        raise ValueError("cannot compare direction when one vector has no length")
    return dot(a, b) / denominator


a = [1.0, 2.0, 3.0]
b = [4.0, -5.0, 6.0]
a_scaled = [10.0 * x for x in a]

print("cosine(a, a)          =", cosine(a, a))
print("cosine(a, 10a)        =", cosine(a, a_scaled), " <- magnitude thrown away")
print("cosine(a, b)          = {:.6f}".format(cosine(a, b)))
print("cosine([1,0], [0,1])  =", cosine([1.0, 0.0], [0.0, 1.0]))
print("cosine([1,2], [-1,-2])=", cosine([1.0, 2.0], [-1.0, -2.0]))

# Now the sharp edge. Some vectors come out more similar to themselves than is legal.
awkward = [0.3, 0.5, 0.7, 0.11]
self_score = cosine(awkward, awkward)
print()
print("cosine(awkward, awkward) =", repr(self_score), "<- larger than 1.0")
try:
    math.acos(self_score)
    blew_up = False
except ValueError as error:
    blew_up = True
    print("math.acos on it raises   : ValueError:", error)
print("clamping to [-1, 1] first:", math.acos(max(-1.0, min(1.0, self_score))), "radians")

try:
    cosine([0.0, 0.0], [1.0, 2.0])
    refused = False
except ValueError as error:
    refused = True
    print("a zero vector is refused :", error)

test("a vector is perfectly similar to itself", close(cosine(a, a), 1.0), True)
test("making a vector ten times longer does not change its cosine", close(cosine(a, a_scaled), 1.0), True)
test("the opposite direction scores minus one", close(cosine([1.0, 2.0], [-1.0, -2.0]), -1.0), True)
test("perpendicular vectors score zero", close(cosine([1.0, 0.0], [0.0, 1.0]), 0.0), True)
test("rounding can push a self-similarity just above one", self_score > 1.0, True)
test("so turning that score into an angle raises, unless you clamp it first", blew_up, True)
test("a zero-length vector is refused instead of returning nan", refused, True)

What it printed, when the build ran it

cosine(a, a)          = 1.0
cosine(a, 10a)        = 1.0  <- magnitude thrown away
cosine(a, b)          = 0.365487
cosine([1,0], [0,1])  = 0.0
cosine([1,2], [-1,-2])= -0.9999999999999998

cosine(awkward, awkward) = 1.0000000000000002 <- larger than 1.0
math.acos on it raises   : ValueError: math domain error
clamping to [-1, 1] first: 0.0 radians
a zero vector is refused : cannot compare direction when one vector has no length
ok   a vector is perfectly similar to itself
ok   making a vector ten times longer does not change its cosine
ok   the opposite direction scores minus one
ok   perpendicular vectors score zero
ok   rounding can push a self-similarity just above one
ok   so turning that score into an angle raises, unless you clamp it first
ok   a zero-length vector is refused instead of returning nan

The awkward vector is the practical warning. Cosine similarity is mathematically bounded by 1, but the computed value is not: rounding in the norms can leave it at 1.0000000000000002. Anything that treats the score as a genuine cosine — math.acos to get an angle, an angular distance metric, a sqrt(1 - s*s) anywhere — raises or returns nan. Clamp to [-1, 1] before you do arithmetic on a similarity score.

Euclidean distance, and how it relates to cosine

Distance is the other obvious question: how far apart are these two points. It is the length of the difference. Distance and cosine are not rivals — for unit vectors they are the same information, related by one identity that is worth verifying rather than believing.

Distance, and the identity linking it to cosine

ran at build time
# Euclidean distance, and the identity that ties it to cosine.
import math


def dot(a, b):
    return sum(x * y for x, y in zip(a, b))


def norm(v):
    return math.sqrt(sum(x * x for x in v))


def unit(v):
    length = norm(v)
    return [x / length for x in v]


def cosine(a, b):
    return dot(a, b) / (norm(a) * norm(b))


def euclidean(a, b):
    """The length of the difference between the two vectors."""
    return math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b)))


a = [1.0, 2.0, 3.0]
b = [4.0, -5.0, 6.0]
a_scaled = [10.0 * x for x in a]

print("euclidean(a, b)      = {:.6f}".format(euclidean(a, b)))
print("euclidean(a, a)      =", euclidean(a, a))
print("euclidean([0,0],[3,4])=", euclidean([0.0, 0.0], [3.0, 4.0]))

# On unit vectors the two measures carry the same information:
#     squared distance = 2 - 2 * cosine
d = euclidean(unit(a), unit(b))
c = cosine(a, b)
print()
print("unit distance squared = {:.15f}".format(d * d))
print("2 - 2 * cosine        = {:.15f}".format(2.0 - 2.0 * c))
print("equal? ==  says", d * d == 2.0 - 2.0 * c, " close() says", close(d * d, 2.0 - 2.0 * c))

# And here is where they part company. Ten times longer, same direction.
print()
print("cosine(a, 10a)    =", cosine(a, a_scaled), "  <- identical")
print("euclidean(a, 10a) = {:.4f}".format(euclidean(a, a_scaled)), " <- very far apart")

test("the distance from a point to itself is zero", close(euclidean(a, a), 0.0), True)
test("the three four five triangle again, as a distance",
     close(euclidean([0.0, 0.0], [3.0, 4.0]), 5.0), True)
test("distance does not care which way round you ask", close(euclidean(a, b), euclidean(b, a)), True)
test("for unit vectors, squared distance is two minus twice the cosine",
     close(d * d, 2.0 - 2.0 * c), True)
test("the identity is true but not bit for bit, which is the whole point of close()",
     d * d == 2.0 - 2.0 * c, False)
test("scaling a vector leaves its cosine alone but moves it a long way",
     [close(cosine(a, a_scaled), 1.0), euclidean(a, a_scaled) > 1.0], [True, True])

What it printed, when the build ran it

euclidean(a, b)      = 8.185353
euclidean(a, a)      = 0.0
euclidean([0,0],[3,4])= 5.0

unit distance squared = 1.269026115352193
2 - 2 * cosine        = 1.269026115352193
equal? ==  says False  close() says True

cosine(a, 10a)    = 1.0   <- identical
euclidean(a, 10a) = 33.6749  <- very far apart
ok   the distance from a point to itself is zero
ok   the three four five triangle again, as a distance
ok   distance does not care which way round you ask
ok   for unit vectors, squared distance is two minus twice the cosine
ok   the identity is true but not bit for bit, which is the whole point of close()
ok   scaling a vector leaves its cosine alone but moves it a long way

That identity is why production vector databases normalise every embedding once, at write time, and then rank by plain dot product. Once every vector has length one, the dot product is the cosine, and nearest by distance is the same ordering as most similar by cosine. One multiplication per dimension, no square roots, same answer. If you ever see a store described as using "inner product" similarity, this is the assumption it is making, and it is your job to have normalised.

A search, and the bug you get for forgetting to normalise

Here is retrieval in miniature. Five documents, each hand-placed on four made-up axes so you can read the numbers, and one query. Ranking by cosine gives the sensible answer. Ranking by raw dot product on un-normalised vectors gives a different one, and the reason is not subtle.

The same five documents ranked two ways, with different winners

ran at build time
# Retrieval in miniature: rank five documents against one query, two ways.
import math


def dot(a, b):
    return sum(x * y for x, y in zip(a, b))


def norm(v):
    return math.sqrt(sum(x * x for x in v))


def cosine(a, b):
    return dot(a, b) / (norm(a) * norm(b))


AXES = ("animal", "tech", "food", "money")
LIBRARY = {
    "cat photos":               [0.9, 0.10, 0.0, 0.00],
    "python tutorial":          [0.0, 0.95, 0.0, 0.10],
    "dog food prices":          [0.7, 0.00, 0.6, 0.30],
    "stock market news":        [0.0, 0.20, 0.0, 0.95],
    # Same direction as "cat photos", ten times longer. Think of a page that
    # repeats itself, or a chunk that was never length-normalised.
    "cat photos, long edition": [9.0, 1.00, 0.0, 0.00],
}
QUERY = [0.85, 0.05, 0.0, 0.0]      # roughly "kittens"


def rank(query, library, score):
    return sorted(((name, score(query, v)) for name, v in library.items()),
                  key=lambda pair: -pair[1])


by_cosine = rank(QUERY, LIBRARY, cosine)
by_dot = rank(QUERY, LIBRARY, dot)

print("axes:", AXES)
print()
print("ranked by cosine")
for name, score in by_cosine:
    print("   {:26} {:.4f}".format(name, score))
print()
print("ranked by raw dot product")
for name, score in by_dot:
    print("   {:26} {:.4f}".format(name, score))
print()
print("Length alone changed the winner. Nothing about the meaning changed.")

short_cos = cosine(QUERY, LIBRARY["cat photos"])
long_cos = cosine(QUERY, LIBRARY["cat photos, long edition"])

test("cosine puts a cat document at the top", by_cosine[0][0].startswith("cat photos"), True)
test("the two cat documents point the same way, so cosine cannot separate them",
     close(short_cos, long_cos), True)
test("the raw dot product crowns the longer document instead",
     by_dot[0][0], "cat photos, long edition")
test("so the two rankings disagree, purely because of length",
     by_dot[0][0] == by_cosine[0][0], False)
test("the least relevant document is last on cosine", by_cosine[-1][0], "stock market news")
test("every cosine score sits inside minus one to one",
     all(-1.0 <= s <= 1.0 for _, s in by_cosine), True)

What it printed, when the build ran it

axes: ('animal', 'tech', 'food', 'money')

ranked by cosine
   cat photos                 0.9987
   cat photos, long edition   0.9987
   dog food prices            0.7207
   python tutorial            0.0584
   stock market news          0.0121

ranked by raw dot product
   cat photos, long edition   7.7000
   cat photos                 0.7700
   dog food prices            0.5950
   python tutorial            0.0475
   stock market news          0.0100

Length alone changed the winner. Nothing about the meaning changed.
ok   cosine puts a cat document at the top
ok   the two cat documents point the same way, so cosine cannot separate them
ok   the raw dot product crowns the longer document instead
ok   so the two rankings disagree, purely because of length
ok   the least relevant document is last on cosine
ok   every cosine score sits inside minus one to one

This is a real bug, not a toy one. Most embedding APIs return vectors that are already unit length, so the mistake stays hidden until you mix sources, cache a vector from an older model version, average several embeddings into one, or build your own from word counts. Then the longest document wins every search and the ranking looks vaguely plausible, which is worse than looking broken. Normalise at write time and assert the norm is 1 in a test.

What a real project would use instead

Everything above is one document at a time, and that is the wrong shape at scale. Real retrieval scores a whole corpus at once: put every embedding in one matrix and the query becomes a single matrix-vector product. numpy does that in compiled code over contiguous memory, and the line count goes down as well as the runtime.

The same three measures, vectorised over a whole corpus

shown, not run
# The same three measures, vectorised.
# NOT RUN HERE - numpy is not installed in this environment.

import numpy as np

a = np.array([1.0, 2.0, 3.0])
b = np.array([4.0, -5.0, 6.0])

a @ b                                     # dot(a, b)
np.linalg.norm(a)                         # norm(a)
a / np.linalg.norm(a)                     # unit(a)
np.linalg.norm(a - b)                     # euclidean(a, b)
(a @ b) / (np.linalg.norm(a) * np.linalg.norm(b))          # cosine(a, b)

# The version that matters: 100,000 documents scored against one query at once.
corpus = np.random.randn(100000, 768).astype(np.float32)
corpus /= np.linalg.norm(corpus, axis=1, keepdims=True)    # normalise ONCE, at write time
query = corpus[0]

scores = corpus @ query                   # every cosine similarity, one call
top10 = np.argpartition(-scores, 10)[:10] # the ten best, without sorting all 100,000

# Because the rows are unit length, the dot product IS the cosine, which is the
# identity the lesson verified by hand.

numpy is not installed here, so this panel was never executed. Every line has a plain-Python counterpart above that did run and did assert. Treat it as the shape a real project uses, not as verified output.

Three functions, and you can now read any retrieval code you meet: dot for the raw agreement, norm for the length, cosine for direction alone. The choice between cosine and distance almost never matters once vectors are normalised, because the identity above makes them the same ordering. What does matter is remembering that a similarity score is a number about geometry, not about truth. Two texts can sit next to each other in embedding space and mean opposite things, which is the subject of the retrieval lessons later on.

6 Probability, softmax and sampling

A model's raw output is a list of unbounded real numbers called logits. They are not probabilities: they can be negative, they can be enormous, and they do not sum to anything in particular. Softmax converts them into a distribution, temperature decides how sharp that distribution is, and sampling picks one. Every generation setting you will ever tune is one of those three steps.

What makes a list of numbers a probability distribution

Two rules, and no others: nothing is negative, and the whole thing sums to one. The second rule is where floating point walks in, so the check has to be written with a tolerance from the very start.

The two rules, and why the sum needs a tolerance

ran at build time
# A distribution is two rules: nothing negative, and the whole lot sums to one.
def normalise(counts):
    """Turn counts into probabilities. The only honest way to make a distribution."""
    total = sum(counts.values())
    if total <= 0:
        raise ValueError("cannot normalise counts that sum to zero")
    return {name: n / total for name, n in counts.items()}


def is_distribution(p, tol=1e-9):
    return all(x >= 0.0 for x in p) and close(sum(p), 1.0, tol)


observed = {"cat": 12, "dog": 7, "bird": 1}
probs = normalise(observed)

print("counts        :", observed)
print("probabilities :", probs)
print("summing to    :", repr(sum(probs.values())))

# Seven equally likely outcomes. Same two rules, and the sum now misses.
seven = normalise({name: 1 for name in "abcdefg"})
print()
print("one seventh           :", repr(seven["a"]))
print("seven sevenths sum to :", repr(sum(seven.values())))
print("exactly 1.0?          :", sum(seven.values()) == 1.0)

print()
print("[0.6, 0.3, 0.1]  a distribution?", is_distribution([0.6, 0.3, 0.1]))
print("[0.6, 0.3, 0.2]  a distribution?", is_distribution([0.6, 0.3, 0.2]), " <- sums to 1.1")
print("[0.9, -0.2, 0.3] a distribution?", is_distribution([0.9, -0.2, 0.3]), " <- negative")

test("twelve cats out of twenty animals is a probability of 0.6", close(probs["cat"], 0.6), True)
test("the three animal probabilities form a distribution", is_distribution(list(probs.values())), True)
test("seven sevenths do not add up to one in floating point", sum(seven.values()) == 1.0, False)
test("yet they are a distribution by any sane tolerance", is_distribution(list(seven.values())), True)
test("numbers that sum to more than one are not probabilities", is_distribution([0.6, 0.3, 0.2]), False)
test("and nothing containing a negative is either", is_distribution([0.9, -0.2, 0.3]), False)

What it printed, when the build ran it

counts        : {'cat': 12, 'dog': 7, 'bird': 1}
probabilities : {'cat': 0.6, 'dog': 0.35, 'bird': 0.05}
summing to    : 1.0

one seventh           : 0.14285714285714285
seven sevenths sum to : 0.9999999999999998
exactly 1.0?          : False

[0.6, 0.3, 0.1]  a distribution? True
[0.6, 0.3, 0.2]  a distribution? False  <- sums to 1.1
[0.9, -0.2, 0.3] a distribution? False  <- negative
ok   twelve cats out of twenty animals is a probability of 0.6
ok   the three animal probabilities form a distribution
ok   seven sevenths do not add up to one in floating point
ok   yet they are a distribution by any sane tolerance
ok   numbers that sum to more than one are not probabilities
ok   and nothing containing a negative is either

If you ever write assert sum(probs) == 1.0 you have written a test that fails on real data for no reason a reader can see. Seven equal probabilities are enough to break it. Assert close(sum(probs), 1.0) instead, and when a library complains that your probabilities do not sum to 1, this is usually why — not a bug in your arithmetic.

Softmax, and the one line that stops it exploding

Softmax exponentiates every logit and divides by the total. Exponentiating makes everything positive and exaggerates gaps; dividing makes it sum to one. Written literally from the formula it is three lines, and it is unusable, because exp of a realistic logit does not fit in a float.

Softmax, the overflow, and the subtract-the-max fix

ran at build time
# Softmax: the formula as written, then the version you can actually ship.
import math


def softmax_naive(logits):
    """The textbook formula, taken literally."""
    exps = [math.exp(z) for z in logits]
    total = sum(exps)
    return [e / total for e in exps]


def softmax(logits):
    """The same function, shifted. Subtracting the max changes nothing and fixes everything."""
    shift = max(logits)
    exps = [math.exp(z - shift) for z in logits]
    total = sum(exps)
    return [e / total for e in exps]


small = [2.0, 1.0, 0.0]
big = [1000.0, 999.0, 998.0]          # the same gaps, moved up by 998

p_small = softmax(small)
print("logits        :", small)
print("softmax_naive :", softmax_naive(small))
print("softmax       :", p_small, "  <- the same answer")

print()
print("where a float64 gives up:")
print("  math.exp(709) =", math.exp(709))
try:
    math.exp(710)
    print("  math.exp(710) =", math.exp(710))
except OverflowError as error:
    print("  math.exp(710) -> OverflowError:", error)

print()
print("the same gaps at a scale a real model reaches:", big)
try:
    softmax_naive(big)
    overflowed = False
except OverflowError as error:
    overflowed = True
    print("  softmax_naive -> OverflowError:", error)

p_big = softmax(big)
print("  softmax      ->", p_big, " <- identical, and it never built exp(1000)")

test("softmax output is a probability distribution", close(sum(p_small), 1.0), True)
test("every probability lands strictly between zero and one", all(0.0 < x < 1.0 for x in p_small), True)
test("the largest logit receives the largest probability", p_small.index(max(p_small)), 0)
test("the naive formula overflows on logits a real model produces", overflowed, True)
test("the shifted version copes with exactly the same input", close(sum(p_big), 1.0), True)
test("and returns the same answer, because only the gaps between logits matter",
     all(close(x, y) for x, y in zip(p_small, p_big)), True)
test("adding any constant to every logit leaves softmax unchanged",
     all(close(x, y) for x, y in zip(p_small, softmax([z + 5.0 for z in small]))), True)

What it printed, when the build ran it

logits        : [2.0, 1.0, 0.0]
softmax_naive : [0.6652409557748219, 0.24472847105479764, 0.09003057317038046]
softmax       : [0.6652409557748218, 0.24472847105479764, 0.09003057317038046]   <- the same answer

where a float64 gives up:
  math.exp(709) = 8.218407461554972e+307
  math.exp(710) -> OverflowError: math range error

the same gaps at a scale a real model reaches: [1000.0, 999.0, 998.0]
  softmax_naive -> OverflowError: math range error
  softmax      -> [0.6652409557748218, 0.24472847105479764, 0.09003057317038046]  <- identical, and it never built exp(1000)
ok   softmax output is a probability distribution
ok   every probability lands strictly between zero and one
ok   the largest logit receives the largest probability
ok   the naive formula overflows on logits a real model produces
ok   the shifted version copes with exactly the same input
ok   and returns the same answer, because only the gaps between logits matter
ok   adding any constant to every logit leaves softmax unchanged

The last two assertions are the whole justification, so they are worth stating in words. Softmax depends only on the differences between logits: multiply top and bottom of the fraction by exp(-c) and the c cancels. So you may subtract any constant you like from every logit before exponentiating. Subtracting the maximum is the useful choice, because it makes the largest exponent exactly exp(0) = 1 and every other one smaller. Nothing can overflow, and the denominator is at least 1, so nothing can divide by zero either. That is why every real implementation does it, and why the naive three-line version appears in tutorials but never in libraries.

The other half of the story is underflow, and shifting handles that too. A very negative logit becomes exp(-800), which is not an error — it quietly rounds to 0.0. A zero probability is fine until something takes its logarithm, which is exactly what a cross-entropy loss does. That is why training code computes log-softmax directly, as z - shift - log(sum(exp(z - shift))), rather than taking the log of a softmax that has already rounded to zero.

Temperature: one number that decides how bold the model is

Temperature divides the logits before the softmax. Dividing by a small number spreads the logits apart, which sharpens the distribution; dividing by a large number squashes them together, which flattens it. That is the entire mechanism.

Temperature, measured by entropy rather than described

ran at build time
# Temperature: divide the logits, then softmax. That is all it is.
import math


def softmax(logits, temperature=1.0):
    if temperature <= 0.0:
        raise ValueError("temperature must be positive; zero means argmax, not sampling")
    scaled = [z / temperature for z in logits]
    shift = max(scaled)
    exps = [math.exp(z - shift) for z in scaled]
    total = sum(exps)
    return [e / total for e in exps]


def entropy(p):
    """How undecided the distribution is. Zero means certain."""
    return -sum(x * math.log(x) for x in p if x > 0.0)


TOKENS = ("cat", "dog", "bird", "quark")
LOGITS = [3.0, 1.0, 0.2, -1.0]
TEMPERATURES = (0.25, 0.5, 1.0, 2.0, 20.0)

rows = [(t, softmax(LOGITS, t)) for t in TEMPERATURES]

print("logits:", dict(zip(TOKENS, LOGITS)))
print()
print("  temp " + "".join("{:>9}".format(name) for name in TOKENS) + "    entropy   spread")
for t, p in rows:
    print("  {:5} ".format(t)
          + "".join("{:9.4f}".format(x) for x in p)
          + "    {:7.4f}   {:.4f}".format(entropy(p), max(p) - min(p)))

print()
print("At 0.25 it says 'cat' essentially every time. At 20 it is close to a coin")
print("toss between four options, including the one it thinks is least likely.")

entropies = [entropy(p) for _, p in rows]
coldest = rows[0][1]
hottest = rows[-1][1]

try:
    softmax(LOGITS, 0.0)
    refused = False
except ValueError as error:
    refused = True
    print()
    print("softmax(logits, 0.0) ->", error)

test("every temperature still produces a distribution", all(close(sum(p), 1.0) for _, p in rows), True)
test("a cold temperature puts almost all the mass on one token", max(coldest) > 0.99, True)
test("a hot temperature flattens the distribution towards uniform", max(hottest) - min(hottest) < 0.1, True)
test("entropy rises with every step up in temperature", entropies, sorted(entropies))
test("temperature never changes which token is most likely",
     all(p.index(max(p)) == 0 for _, p in rows), True)
test("a temperature of zero is refused, because that is argmax and not sampling", refused, True)

What it printed, when the build ran it

logits: {'cat': 3.0, 'dog': 1.0, 'bird': 0.2, 'quark': -1.0}

  temp       cat      dog     bird    quark    entropy   spread
   0.25    0.9997   0.0003   0.0000   0.0000     0.0032   0.9997
    0.5    0.9781   0.0179   0.0036   0.0003     0.1166   0.9778
    1.0    0.8234   0.1114   0.0501   0.0151     0.6177   0.8083
    2.0    0.5715   0.2102   0.1409   0.0773     1.1217   0.4941
   20.0    0.2783   0.2518   0.2420   0.2279     1.3836   0.0505

At 0.25 it says 'cat' essentially every time. At 20 it is close to a coin
toss between four options, including the one it thinks is least likely.

softmax(logits, 0.0) -> temperature must be positive; zero means argmax, not sampling
ok   every temperature still produces a distribution
ok   a cold temperature puts almost all the mass on one token
ok   a hot temperature flattens the distribution towards uniform
ok   entropy rises with every step up in temperature
ok   temperature never changes which token is most likely
ok   a temperature of zero is refused, because that is argmax and not sampling

Two things in that table are worth carrying into an interview. First, temperature never reorders the tokens — the most likely token is the most likely at every temperature, so "raise the temperature so it picks a different word" is only true in the sense that it makes the other words reachable. Second, the API parameter called temperature=0 is not this function with zero passed in; that would divide by zero. It is a separate code path that skips sampling and takes the argmax.

Sampling, and making it reproducible

Sampling from a distribution needs one random number. Walk along the probabilities, keeping a running total, and stop when the total passes your random draw. This is called inverse transform sampling and it is four lines.

Seeded sampling, and the frequencies it converges to

ran at build time
# Sampling: turn a distribution into a choice, reproducibly.
import random


def sample(probs, rng):
    """Inverse transform sampling: walk the running total until it passes r."""
    r = rng.random()
    running = 0.0
    for index, p in enumerate(probs):
        running += p
        if r < running:
            return index
    # Reachable only through floating point. The running total can finish a hair
    # under 1.0 while r sits just above it, so the last index is the fallback.
    # Code that returns None here fails once in millions of tokens, at 3am.
    return len(probs) - 1


def greedy(probs):
    """No dice at all. This is what temperature zero actually does."""
    return probs.index(max(probs))


TOKENS = ("cat", "dog", "bird", "quark")
PROBS = [0.6, 0.3, 0.08, 0.02]


def draw(seed, n):
    rng = random.Random(seed)      # our own generator, our own seed, nothing global
    return [sample(PROBS, rng) for _ in range(n)]


run_a = draw(42, 12)
run_b = draw(42, 12)
run_c = draw(7, 12)

print("seed 42 :", [TOKENS[i] for i in run_a])
print("seed 42 :", [TOKENS[i] for i in run_b], "<- identical, because the seed is")
print("seed  7 :", [TOKENS[i] for i in run_c], "<- a different stream, a different sentence")

N = 20000
draws = draw(42, N)
counts = [draws.count(i) for i in range(len(PROBS))]
freqs = [c / N for c in counts]

print()
print("  token     asked for    got in {} draws    off by".format(N))
for name, p, f in zip(TOKENS, PROBS, freqs):
    print("  {:8}   {:6.4f}       {:6.4f}             {:.4f}".format(name, p, f, abs(f - p)))

worst = max(abs(f - p) for f, p in zip(freqs, PROBS))
print()
print("greedy always returns '{}': no seed, no variety, no surprises.".format(TOKENS[greedy(PROBS)]))

test("the same seed replays exactly the same draws", run_a, run_b)
test("a different seed produces a different sequence", run_a == run_c, False)
test("over twenty thousand draws every frequency lands within a point of its probability",
     worst < 0.01, True)
test("even the two per cent token turns up, which is the point of sampling", counts[3] > 0, True)
test("the observed frequencies are themselves a distribution", close(sum(freqs), 1.0), True)
test("greedy decoding ignores the dice and picks the same token every time",
     sorted(set(greedy(PROBS) for _ in range(5))), [0])

What it printed, when the build ran it

seed 42 : ['dog', 'cat', 'cat', 'cat', 'dog', 'dog', 'dog', 'cat', 'cat', 'cat', 'cat', 'cat']
seed 42 : ['dog', 'cat', 'cat', 'cat', 'dog', 'dog', 'dog', 'cat', 'cat', 'cat', 'cat', 'cat'] <- identical, because the seed is
seed  7 : ['cat', 'cat', 'dog', 'cat', 'cat', 'cat', 'cat', 'cat', 'cat', 'cat', 'cat', 'cat'] <- a different stream, a different sentence

  token     asked for    got in 20000 draws    off by
  cat        0.6000       0.5962             0.0038
  dog        0.3000       0.3036             0.0036
  bird       0.0800       0.0802             0.0002
  quark      0.0200       0.0199             0.0001

greedy always returns 'cat': no seed, no variety, no surprises.
ok   the same seed replays exactly the same draws
ok   a different seed produces a different sequence
ok   over twenty thousand draws every frequency lands within a point of its probability
ok   even the two per cent token turns up, which is the point of sampling
ok   the observed frequencies are themselves a distribution
ok   greedy decoding ignores the dice and picks the same token every time

The reproducibility trap. Note that this code creates random.Random(42) rather than calling random.seed(42). The module-level functions share one hidden generator with every library in the process, so any dependency that draws a number — a retry jitter, a shuffled batch, a cache eviction — moves your stream and your "reproducible" run stops reproducing. Own your generator, pass it in as an argument, and a failing generation is something you can replay tomorrow. This is also the honest answer to "why did the model give a different answer to the same prompt": at temperature above zero it is sampling, and unless the seed is pinned it will not repeat.

What a real project would use instead

The arithmetic above is the arithmetic a library performs; the difference is that it happens on a whole vocabulary at once, in compiled code, in float32. A vocabulary of 50,000 tokens per generated token makes a Python loop the wrong tool, but nothing conceptual changes.

Softmax, temperature and sampling as a library does them

shown, not run
# The same three steps with the libraries a real project uses.
# NOT RUN HERE - numpy and torch are not installed.

import numpy as np
import torch

logits = np.random.randn(50257)               # one score per token in the vocabulary

# Softmax with the same shift, over the whole vocabulary at once.
shifted = logits - logits.max()
probs = np.exp(shifted) / np.exp(shifted).sum()

# Temperature is still just a division before the softmax.
def softmax(logits, temperature=1.0):
    shifted = logits / temperature
    shifted = shifted - shifted.max()
    e = np.exp(shifted)
    return e / e.sum()

# Sampling, seeded so a bad generation can be replayed.
rng = np.random.default_rng(42)
token = rng.choice(len(probs), p=probs)

# In torch, and note log_softmax: the log is computed directly rather than by
# taking the log of a probability that has already underflowed to zero.
t = torch.from_numpy(logits)
probs = torch.softmax(t / 0.7, dim=-1)
token = torch.multinomial(probs, num_samples=1)
logp = torch.log_softmax(t, dim=-1)           # what a cross-entropy loss uses

numpy and torch are not installed in this environment, so this panel was never executed. Every operation in it was written out and asserted in plain Python above. Read it for the shape of the API, not as verified output.

Worth knowing before an interview: top-k keeps the k highest probabilities, zeroes the rest and re-normalises. top-p, or nucleus sampling, keeps the smallest set of tokens whose probabilities add up to p, and so adapts its own size — wide where the model is unsure, narrow where it is confident. Both are filters applied to the distribution this lesson built, before the sampling step this lesson wrote. Lesson 19 implements both and shows where each one goes wrong.

7 Gradients, and how a model learns

A gradient is a list of slopes: for each parameter, how much the loss changes if you nudge that parameter up. Once you have it, learning is a two-line loop — step against the gradient, repeat. Everything difficult about training is elsewhere: getting the gradient right, and choosing how big a step to take.

A derivative with no calculus in it

The slope of a function at a point is the rise over the run for a very small run. So nudge the input, see what the output does, divide. Two versions are worth knowing: the forward difference, which is the obvious one, and the central difference, which is barely more work and much more accurate.

Forward and central differences, and the step size that goes wrong both ways

ran at build time
# A derivative from nothing but arithmetic: nudge the input, watch the output.
def f(x):
    return x * x * x - 2.0 * x + 1.0


def f_prime(x):
    """The analytic derivative, by the power rule. 3x^2 - 2."""
    return 3.0 * x * x - 2.0


def forward(fn, x, h):
    """The obvious version. One step forward, divided by the step."""
    return (fn(x + h) - fn(x)) / h


def central(fn, x, h):
    """A step each way. Same idea, and the errors mostly cancel."""
    return (fn(x + h) - fn(x - h)) / (2.0 * h)


x = 1.5
exact = f_prime(x)
print("f(x)  = x^3 - 2x + 1")
print("f'(x) = 3x^2 - 2,  so at x = 1.5 the true slope is", exact)
print()
print("        h        forward        error         central        error")

rows = []
for h in (1e-1, 1e-3, 1e-5, 1e-8, 1e-12):
    fw = forward(f, x, h)
    ce = central(f, x, h)
    rows.append((h, abs(fw - exact), abs(ce - exact)))
    print("  {:7.0e}  {:13.9f}  {:.3e}   {:13.9f}  {:.3e}".format(
        h, fw, abs(fw - exact), ce, abs(ce - exact)))

best = min(rows, key=lambda row: row[2])
print()
print("The central error falls, bottoms out near h = {:.0e}, then gets worse again.".format(best[0]))
print("Too large a step and the curve is not straight across it. Too small and the")
print("subtraction throws away almost every significant digit it had.")

# What it costs. Two evaluations, per parameter, per gradient.
calls = {"n": 0}


def counted(value):
    calls["n"] += 1
    return f(value)


central(counted, x, 1e-5)

test("the true slope at 1.5 is 4.75", close(exact, 4.75), True)
test("a central difference with a sensible step agrees to eight decimal places",
     close(central(f, x, 1e-5), exact, 1e-8), True)
test("the central difference beats the forward difference at the same step size",
     rows[1][2] < rows[1][1], True)
test("shrinking the step past the sweet spot makes the answer worse, not better",
     rows[-1][2] > best[2], True)
test("so the best step is neither the largest nor the smallest in the table",
     best[0] not in (rows[0][0], rows[-1][0]), True)
test("one numerical derivative costs two evaluations of the function", calls["n"], 2)

What it printed, when the build ran it

f(x)  = x^3 - 2x + 1
f'(x) = 3x^2 - 2,  so at x = 1.5 the true slope is 4.75

        h        forward        error         central        error
    1e-01    5.210000000  4.600e-01     4.760000000  1.000e-02
    1e-03    4.754501000  4.501e-03     4.750001000  1.000e-06
    1e-05    4.750045000  4.500e-05     4.750000000  1.310e-10
    1e-08    4.749999993  6.664e-09     4.749999971  2.887e-08
    1e-12    4.750422278  4.223e-04     4.750422278  4.223e-04

The central error falls, bottoms out near h = 1e-05, then gets worse again.
Too large a step and the curve is not straight across it. Too small and the
subtraction throws away almost every significant digit it had.
ok   the true slope at 1.5 is 4.75
ok   a central difference with a sensible step agrees to eight decimal places
ok   the central difference beats the forward difference at the same step size
ok   shrinking the step past the sweet spot makes the answer worse, not better
ok   so the best step is neither the largest nor the smallest in the table
ok   one numerical derivative costs two evaluations of the function

The instinct that a smaller h is a more accurate h is wrong, and the table shows it. Two errors pull in opposite directions: shrinking the step reduces the error from pretending the curve is straight, and increases the error from subtracting two nearly identical floats, which is called catastrophic cancellation. At h = 1e-12 the answer is worse than at h = 0.1. If you ever write a gradient check, h around 1e-5 with a central difference is the sensible default.

The analytic gradient, and checking it against the numerical one

Two evaluations per parameter is fine for one parameter and hopeless for a billion, so real training uses derivatives worked out by hand — or by autodiff, which is the same thing mechanised. Here is a model with two parameters, its loss, and the gradient derived with the chain rule, checked against the numerical version.

The analytic gradient, verified against the numerical one

ran at build time
# A two-parameter model, its loss, and the gradient - derived, then verified.
XS = [0.0, 1.0, 2.0, 3.0]
YS = [1.0, 3.0, 5.0, 7.0]          # exactly y = 2x + 1, so we know the right answer


def predict(w, b, x):
    return w * x + b


def loss(w, b):
    """Mean squared error: one number for how wrong the whole model is."""
    n = len(XS)
    return sum((predict(w, b, x) - y) ** 2 for x, y in zip(XS, YS)) / n


def gradient(w, b):
    """The analytic gradient, by the chain rule.

    L      = mean((w*x + b - y) ** 2)
    dL/dw  = mean(2 * (w*x + b - y) * x)
    dL/db  = mean(2 * (w*x + b - y))
    """
    n = len(XS)
    errors = [predict(w, b, x) - y for x, y in zip(XS, YS)]
    dw = sum(2.0 * e * x for e, x in zip(errors, XS)) / n
    db = sum(2.0 * e for e in errors) / n
    return dw, db


def numerical_gradient(w, b, h=1e-5):
    """The same gradient with no calculus. A check, not a method."""
    dw = (loss(w + h, b) - loss(w - h, b)) / (2.0 * h)
    db = (loss(w, b + h) - loss(w, b - h)) / (2.0 * h)
    return dw, db


analytic = gradient(0.0, 0.0)
numeric = numerical_gradient(0.0, 0.0)

print("at w = 0, b = 0 the loss is", loss(0.0, 0.0))
print()
print("analytic   : dL/dw = {:.10f}    dL/db = {:.10f}".format(*analytic))
print("numerical  : dL/dw = {:.10f}    dL/db = {:.10f}".format(*numeric))
print("difference :         {:.2e}              {:.2e}".format(
    abs(analytic[0] - numeric[0]), abs(analytic[1] - numeric[1])))
print()
print("Both slopes are negative, meaning the loss falls as w and b grow.")
print("So we subtract the gradient. That is the whole of 'step against it'.")

step = 0.05
after = loss(0.0 - step * analytic[0], 0.0 - step * analytic[1])
print()
print("loss before one step : {:.6f}".format(loss(0.0, 0.0)))
print("loss after  one step : {:.6f}".format(after))

at_answer = gradient(2.0, 1.0)
print()
print("gradient at the exact answer w = 2, b = 1:", at_answer, "<- flat, nowhere left to go")

test("the starting loss is twenty one", close(loss(0.0, 0.0), 21.0), True)
test("the analytic slope for the weight matches the numerical one",
     close(analytic[0], numeric[0], 1e-6), True)
test("the analytic slope for the bias matches the numerical one too",
     close(analytic[1], numeric[1], 1e-6), True)
test("both slopes are negative, so both parameters need to grow",
     [analytic[0] < 0.0, analytic[1] < 0.0], [True, True])
test("one step against the gradient does reduce the loss", after < loss(0.0, 0.0), True)
test("at the exact answer the gradient is zero and so is the loss",
     [close(at_answer[0], 0.0), close(at_answer[1], 0.0), close(loss(2.0, 1.0), 0.0)],
     [True, True, True])

What it printed, when the build ran it

at w = 0, b = 0 the loss is 21.0

analytic   : dL/dw = -17.0000000000    dL/db = -8.0000000000
numerical  : dL/dw = -16.9999999997    dL/db = -7.9999999997
difference :         2.88e-10              3.03e-10

Both slopes are negative, meaning the loss falls as w and b grow.
So we subtract the gradient. That is the whole of 'step against it'.

loss before one step : 21.000000
loss after  one step : 7.058750

gradient at the exact answer w = 2, b = 1: (0.0, 0.0) <- flat, nowhere left to go
ok   the starting loss is twenty one
ok   the analytic slope for the weight matches the numerical one
ok   the analytic slope for the bias matches the numerical one too
ok   both slopes are negative, so both parameters need to grow
ok   one step against the gradient does reduce the loss
ok   at the exact answer the gradient is zero and so is the loss

That comparison is the single most useful debugging technique in this part of the field, and it has a name: gradient checking. A wrong analytic gradient is vicious because the model still trains. The loss still falls, just more slowly or to a worse place, and nothing raises. Recomputing one gradient numerically and comparing takes four lines and catches a class of bug that otherwise costs days.

Gradient descent, printed step by step

Now the loop. Start with the parameters wrong, compute the gradient, subtract a small multiple of it, repeat. There is nothing else in it, and the loss below is printed at every step so you can watch it fall.

One gradient-descent loop, with the loss printed as it falls

ran at build time
# Gradient descent in full. Three lines of loop, nothing hidden.
XS = [0.0, 1.0, 2.0, 3.0]
YS = [1.0, 3.0, 5.0, 7.0]


def loss(w, b):
    n = len(XS)
    return sum((w * x + b - y) ** 2 for x, y in zip(XS, YS)) / n


def gradient(w, b):
    n = len(XS)
    errors = [w * x + b - y for x, y in zip(XS, YS)]
    return (sum(2.0 * e * x for e, x in zip(errors, XS)) / n,
            sum(2.0 * e for e in errors) / n)


def descend(lr, steps, w=0.0, b=0.0):
    """The trajectory: (w, b, loss) at the start and after every step."""
    trail = [(w, b, loss(w, b))]
    for _ in range(steps):
        dw, db = gradient(w, b)
        w -= lr * dw               # against the gradient, so downhill
        b -= lr * db
        trail.append((w, b, loss(w, b)))
    return trail


trail = descend(lr=0.1, steps=200)
losses = [row[2] for row in trail]

print("fitting y = w*x + b to y = 2x + 1, from w = 0, b = 0, learning rate 0.1")
print()
print("  step          w          b               loss")
for step in [0, 1, 2, 3, 4, 5, 6, 7, 20, 50, 100, 200]:
    w, b, value = trail[step]
    print("  {:4}   {:10.6f}   {:10.6f}   {:16.12f}".format(step, w, b, value))

final_w, final_b, final_loss = trail[-1]
print()
print("finished at w = {:.9f}, b = {:.9f}".format(final_w, final_b))
print("final loss {:.3e}, down from {:.1f}".format(final_loss, losses[0]))
print()
print("The first step does most of the work. The rest is the bias creeping up to 1,")
print("which is slower because x = 0 is the only row that isolates it.")

fell_every_step = all(losses[i + 1] < losses[i] for i in range(len(losses) - 1))

test("the loss fell on every one of the two hundred steps", fell_every_step, True)
test("the first step alone removed most of the loss", losses[1] < losses[0] / 30.0, True)
test("the weight converged on two", close(final_w, 2.0, 1e-6), True)
test("the bias converged on one", close(final_b, 1.0, 1e-6), True)
test("the final loss is effectively zero", final_loss < 1e-12, True)
test("nothing was hard-coded: it began at zero and zero", (trail[0][0], trail[0][1]), (0.0, 0.0))

What it printed, when the build ran it

fitting y = w*x + b to y = 2x + 1, from w = 0, b = 0, learning rate 0.1

  step          w          b               loss
     0     0.000000     0.000000    21.000000000000
     1     1.700000     0.800000     0.535000000000
     2     1.970000     0.930000     0.014350000000
     3     2.012000     0.953000     0.001021000000
     4     2.017700     0.958800     0.000606235000
     5     2.017670     0.961730     0.000528701350
     6     2.016782     0.964083     0.000467477941
     7     2.015810     0.966232     0.000413509146
    20     2.007125     0.984787     0.000083938972
    50     2.001132     0.997584     0.000002117569
   100     2.000053     0.999887     0.000000004595
   200     2.000000     1.000000     0.000000000000

finished at w = 2.000000114, b = 0.999999756
final loss 2.164e-14, down from 21.0

The first step does most of the work. The rest is the bias creeping up to 1,
which is slower because x = 0 is the only row that isolates it.
ok   the loss fell on every one of the two hundred steps
ok   the first step alone removed most of the loss
ok   the weight converged on two
ok   the bias converged on one
ok   the final loss is effectively zero
ok   nothing was hard-coded: it began at zero and zero

The learning rate, and the cliff it falls off

The step size is the one number you cannot reason your way past by staring at the code. Too small and training crawls. Too large and the parameters overshoot, land further away than they started, and the loss grows instead of shrinking. For a squared loss the exact threshold is computable, which makes this the rare case where you can watch theory and arithmetic agree.

Nine learning rates, and the exact point where descent stops working

ran at build time
# The learning rate: too small crawls, too large explodes, and the edge is exact.
import math

XS = [0.0, 1.0, 2.0, 3.0]
YS = [1.0, 3.0, 5.0, 7.0]


def loss(w, b):
    n = len(XS)
    return sum((w * x + b - y) ** 2 for x, y in zip(XS, YS)) / n


def gradient(w, b):
    n = len(XS)
    errors = [w * x + b - y for x, y in zip(XS, YS)]
    return (sum(2.0 * e * x for e, x in zip(errors, XS)) / n,
            sum(2.0 * e for e in errors) / n)


def descend(lr, steps, w=0.0, b=0.0):
    history = [loss(w, b)]
    for _ in range(steps):
        dw, db = gradient(w, b)
        w -= lr * dw
        b -= lr * db
        history.append(loss(w, b))
    return w, history


RATES = (0.02, 0.05, 0.1, 0.2, 0.23, 0.237, 0.239, 0.24, 0.3)
STEPS = 80
results = {}

print("every run starts from w = 0, b = 0, where the loss is {:.1f}".format(loss(0.0, 0.0)))
print()
print("  rate      loss after {} steps      w ended at        verdict".format(STEPS))
for lr in RATES:
    w, history = descend(lr, STEPS)
    results[lr] = (w, history)
    if history[-1] >= history[0]:
        verdict = "DIVERGED"
    elif history[-1] > 1e-3:
        verdict = "converging, slowly"
    else:
        verdict = "converged"
    print("  {:<6}  {:20.6g}   {:15.6g}   {}".format(lr, history[-1], w, verdict))

# The edge is not a mystery. For a squared loss the curvature is a constant matrix,
#     H = (2/n) * [[sum(x*x), sum(x)], [sum(x), n]]
# and plain gradient descent is stable exactly while lr < 2 / largest eigenvalue.
n = len(XS)
sum_x2 = sum(x * x for x in XS)
sum_x = sum(XS)
trace = (2.0 / n) * (sum_x2 + n)
determinant = (2.0 / n) ** 2 * (sum_x2 * n - sum_x * sum_x)
lambda_max = (trace + math.sqrt(trace * trace - 4.0 * determinant)) / 2.0
threshold = 2.0 / lambda_max

converging = [lr for lr in RATES if results[lr][1][-1] < results[lr][1][0]]
diverging = [lr for lr in RATES if results[lr][1][-1] >= results[lr][1][0]]

print()
print("largest curvature    : {:.6f}".format(lambda_max))
print("predicted safe limit : {:.6f}".format(threshold))
print("largest rate that worked : {}".format(max(converging)))
print("smallest rate that failed: {}".format(min(diverging)))

test("a very small rate helps but does not finish the job",
     [results[0.02][1][-1] < 1.0, results[0.02][1][-1] > results[0.2][1][-1]], [True, True])
test("a well-chosen rate lands on the answer", close(results[0.2][0], 2.0, 1e-5), True)
test("the largest rate that converged is below the predicted limit", max(converging) < threshold, True)
test("the smallest rate that diverged is above it", min(diverging) > threshold, True)
test("so the prediction brackets the observed cliff, to three decimal places",
     close(threshold, 0.238, 0.001), True)
test("well past the limit the loss does not merely fail, it explodes",
     results[0.3][1][-1] > 1e20, True)

What it printed, when the build ran it

every run starts from w = 0, b = 0, where the loss is 21.0

  rate      loss after 80 steps      w ended at        verdict
  0.02             0.000143781           2.00932   converged
  0.05             7.78417e-06           2.00217   converged
  0.1              5.34209e-08           2.00018   converged
  0.2              1.53991e-12                 2   converged
  0.23             0.000328527           1.99199   converged
  0.237                5.82222          0.934095   converging, slowly
  0.239                85.6489          -2.08823   DIVERGED
  0.24                 323.044          -5.93972   DIVERGED
  0.3              3.07201e+30      -7.74258e+14   DIVERGED

largest curvature    : 8.405125
predicted safe limit : 0.237950
largest rate that worked : 0.237
smallest rate that failed: 0.239
ok   a very small rate helps but does not finish the job
ok   a well-chosen rate lands on the answer
ok   the largest rate that converged is below the predicted limit
ok   the smallest rate that diverged is above it
ok   so the prediction brackets the observed cliff, to three decimal places
ok   well past the limit the loss does not merely fail, it explodes

Notice how the failure looks. At 0.239 the loss ends four times higher than it started; at 0.3 it reaches 1e30 and the weights are in the hundreds of trillions. In a real training run this is the nan that appears in your loss after two hundred steps, and the first thing to try is always dividing the learning rate by ten. The threshold depends on the data — it is set by the curvature, which is set by the scale of your inputs — which is the real reason features get normalised before training. Rescaling the inputs changes what a safe learning rate even is.

Why nobody computes gradients numerically

The numerical gradient in the second panel cost two evaluations of the loss for each of two parameters. That ratio does not improve. A model with a million parameters needs two million forward passes for one gradient, and a large language model has billions. Reverse-mode automatic differentiation — backpropagation — computes every partial derivative in a single backward pass costing roughly the same as one forward pass, regardless of how many parameters there are. That single fact is why deep learning is possible at all, and lesson 14 works through it by hand.

The same descent, with autodiff doing the calculus

shown, not run
# The same descent, with the library that does the derivative for you.
# NOT RUN HERE - torch is not installed.

import torch

xs = torch.tensor([0.0, 1.0, 2.0, 3.0])
ys = torch.tensor([1.0, 3.0, 5.0, 7.0])

w = torch.zeros(1, requires_grad=True)        # requires_grad: track operations on this
b = torch.zeros(1, requires_grad=True)

optimiser = torch.optim.SGD([w, b], lr=0.1)   # the two lines of loop, packaged

for step in range(200):
    loss = ((w * xs + b - ys) ** 2).mean()    # the same mean squared error
    optimiser.zero_grad()                     # forget last step's gradients, or they add up
    loss.backward()                           # every partial derivative, one backward pass
    optimiser.step()                          # w -= lr * w.grad, and the same for b

print(w.item(), b.item())                     # 2.0, 1.0

# torch.autograd.gradcheck compares the analytic gradient with a numerical one,
# which is the technique from the second panel, packaged and made rigorous.

torch is not installed in this environment, so this panel was never executed. The plain-Python loop above computes the identical gradient and did run, with its loss printed at every step. Read this for the shape of the API, not as verified output.

What to take from this lesson, because it is the last of the foundations. A loss is one number saying how wrong you are. A gradient is one slope per parameter. Training subtracts a small multiple of the gradient, over and over. The two things that go wrong are a gradient that is subtly incorrect, which you catch by checking it numerically, and a step size on the wrong side of a cliff, which you catch by watching the loss. Everything from here on — linear regression, logistic regression, a neural network, a transformer — is that same loop with a more complicated function in the middle.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.